diff --git a/.github/actions/setup-node-pnpm/action.yml b/.github/actions/setup-node-pnpm/action.yml index b22a603a..d2ef2cbd 100644 --- a/.github/actions/setup-node-pnpm/action.yml +++ b/.github/actions/setup-node-pnpm/action.yml @@ -5,11 +5,11 @@ inputs: node-version: description: Node.js version required: false - default: '25' + default: "25" install: description: Whether to run pnpm install required: false - default: 'true' + default: "true" runs: using: composite @@ -18,22 +18,11 @@ runs: uses: pnpm/action-setup@v4 - name: Setup Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: ${{ inputs.node-version }} - - - name: Get pnpm store directory - id: pnpm-cache - shell: bash - run: echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT - - - name: Cache pnpm store - uses: actions/cache@v4 - with: - path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} - key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-pnpm-store- + cache: pnpm + cache-dependency-path: pnpm-lock.yaml - name: Install workspace dependencies if: inputs.install == 'true' diff --git a/.github/actions/setup-rust/action.yml b/.github/actions/setup-rust/action.yml index 5c08148f..048bf750 100644 --- a/.github/actions/setup-rust/action.yml +++ b/.github/actions/setup-rust/action.yml @@ -24,7 +24,7 @@ runs: targets: ${{ inputs.targets }} - name: Cache cargo - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: | ~/.cargo/registry diff --git a/.github/actions/setup-tauri/action.yml b/.github/actions/setup-tauri/action.yml index 8255d2ec..40c1f27b 100644 --- a/.github/actions/setup-tauri/action.yml +++ b/.github/actions/setup-tauri/action.yml @@ -23,7 +23,7 @@ runs: steps: - name: Cache Linux package archives if: runner.os == 'Linux' - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: /var/cache/apt/archives key: ${{ runner.os }}-tauri-apt-${{ hashFiles('.github/actions/setup-tauri/action.yml') }} @@ -80,7 +80,7 @@ runs: echo "hash=$HASH" >> $GITHUB_OUTPUT - name: Cache cargo + tauri target - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: | ~/.cargo/registry diff --git a/Cargo.lock b/Cargo.lock index 71f19719..5d843bf3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2094,7 +2094,7 @@ dependencies = [ [[package]] name = "memory-sync-gui" -version = "2026.10329.110" +version = "2026.10330.108" dependencies = [ "dirs", "proptest", @@ -4372,7 +4372,7 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tnmsc" -version = "2026.10329.110" +version = "2026.10330.108" dependencies = [ "clap", "dirs", @@ -4394,7 +4394,7 @@ dependencies = [ [[package]] name = "tnmsc-logger" -version = "2026.10329.110" +version = "2026.10330.108" dependencies = [ "chrono", "napi", @@ -4406,7 +4406,7 @@ dependencies = [ [[package]] name = "tnmsc-md-compiler" -version = "2026.10329.110" +version = "2026.10330.108" dependencies = [ "markdown", "napi", @@ -4421,7 +4421,7 @@ dependencies = [ [[package]] name = "tnmsc-script-runtime" -version = "2026.10329.110" +version = "2026.10330.108" dependencies = [ "napi", "napi-build", diff --git a/cli/env.d.ts b/cli/env.d.ts index 54f2842b..df4bb8c8 100644 --- a/cli/env.d.ts +++ b/cli/env.d.ts @@ -16,3 +16,7 @@ declare const __CLI_PACKAGE_NAME__: string * Kiro global powers registry JSON string injected at build time */ declare const __KIRO_GLOBAL_POWERS_REGISTRY__: string + +interface GlobalThis { + __TNMSC_TEST_NATIVE_BINDING__?: object +} diff --git a/cli/eslint.config.ts b/cli/eslint.config.ts index bfe7f6c3..9c891393 100644 --- a/cli/eslint.config.ts +++ b/cli/eslint.config.ts @@ -11,7 +11,7 @@ const config = await eslint10({ strictTypescriptEslint: true, tsconfigPath: resolve(configDir, 'tsconfig.eslint.json'), parserOptions: { - allowDefaultProject: ['*.config.ts'] + allowDefaultProject: ['*.config.ts', 'test/**/*.ts'] } }, ignores: [ diff --git a/cli/package.json b/cli/package.json index 8cd72ad5..5f5c09ed 100644 --- a/cli/package.json +++ b/cli/package.json @@ -49,7 +49,9 @@ }, "scripts": { "build": "run-s build:deps build:napi bundle finalize:bundle generate:schema", - "build:napi": "tsx ../scripts/copy-napi.ts", + "build:napi": "run-s build:native build:napi:copy", + "build:napi:copy": "tsx ../scripts/copy-napi.ts", + "build:native": "napi build --platform --release --output-dir dist -- --features napi", "build:deps": "pnpm -F @truenine/logger -F @truenine/md-compiler -F @truenine/script-runtime run build", "build:deps:ts": "pnpm -F @truenine/logger -F @truenine/md-compiler -F @truenine/script-runtime run build:ts", "bundle": "tsx ../scripts/build-quiet.ts", diff --git a/cli/src/core/native-binding.ts b/cli/src/core/native-binding.ts index 0ab5605d..d761cef8 100644 --- a/cli/src/core/native-binding.ts +++ b/cli/src/core/native-binding.ts @@ -1,23 +1,16 @@ import {createRequire} from 'node:module' import process from 'node:process' -declare global { - interface GlobalThis { - __TNMSC_TEST_NATIVE_BINDING__?: object - } -} - function shouldSkipNativeBinding(): boolean { if (process.env['TNMSC_FORCE_NATIVE_BINDING'] === '1') return false if (process.env['TNMSC_DISABLE_NATIVE_BINDING'] === '1') return true - return process.env['NODE_ENV'] === 'test' - || process.env['VITEST'] != null - || process.env['VITEST_WORKER_ID'] != null + return process.env['NODE_ENV'] === 'test' || process.env['VITEST'] != null || process.env['VITEST_WORKER_ID'] != null } export function tryLoadNativeBinding(): T | undefined { - const testBinding: unknown = globalThis.__TNMSC_TEST_NATIVE_BINDING__ + const testGlobals = globalThis as typeof globalThis & {__TNMSC_TEST_NATIVE_BINDING__?: object} + const testBinding: unknown = testGlobals.__TNMSC_TEST_NATIVE_BINDING__ if (testBinding != null && typeof testBinding === 'object') return testBinding as T if (shouldSkipNativeBinding()) return void 0 @@ -58,12 +51,9 @@ export function tryLoadNativeBinding(): T | undefined { for (const candidate of possibleBindings) { if (candidate != null && typeof candidate === 'object') return candidate as T } - } - catch {} + } catch {} } - } - catch { - } + } catch {} return void 0 } diff --git a/cli/src/plugins/plugin-core/filters.ts b/cli/src/plugins/plugin-core/filters.ts index dbbf0f7e..0cec8122 100644 --- a/cli/src/plugins/plugin-core/filters.ts +++ b/cli/src/plugins/plugin-core/filters.ts @@ -1,45 +1,51 @@ -import type { - ProjectConfig, - RulePrompt, - SeriName -} from './types' +import type {ProjectConfig, RulePrompt, SeriName} from './types' import * as fs from 'node:fs' import * as path from 'node:path' import {getNativeBinding} from '@/core/native-binding' interface SeriesFilterFns { - readonly resolveEffectiveIncludeSeries: ( - topLevel?: readonly string[], - typeSpecific?: readonly string[] - ) => string[] - readonly matchesSeries: ( - seriName: string | readonly string[] | null | undefined, - effectiveIncludeSeries: readonly string[] - ) => boolean + readonly resolveEffectiveIncludeSeries: (topLevel?: readonly string[], typeSpecific?: readonly string[]) => string[] + readonly matchesSeries: (seriName: string | readonly string[] | null | undefined, effectiveIncludeSeries: readonly string[]) => boolean readonly resolveSubSeries: ( topLevel?: Readonly>, typeSpecific?: Readonly> ) => Record } -function requireSeriesFilterFns(): SeriesFilterFns { +let seriesFilterFnsCache: SeriesFilterFns | undefined + +function getSeriesFilterFns(): SeriesFilterFns { + if (seriesFilterFnsCache != null) return seriesFilterFnsCache + const candidate = getNativeBinding() if (candidate == null) { throw new TypeError('Native series-filter binding is required. Build or install the Rust NAPI package before running tnmsc.') } - if (typeof candidate.matchesSeries !== 'function' + if ( + typeof candidate.matchesSeries !== 'function' || typeof candidate.resolveEffectiveIncludeSeries !== 'function' - || typeof candidate.resolveSubSeries !== 'function') { + || typeof candidate.resolveSubSeries !== 'function' + ) { throw new TypeError('Native series-filter binding is incomplete. Rebuild the Rust NAPI package before running tnmsc.') } + seriesFilterFnsCache = candidate return candidate } -const { - resolveEffectiveIncludeSeries, - matchesSeries, - resolveSubSeries -}: SeriesFilterFns = requireSeriesFilterFns() +function resolveEffectiveIncludeSeries(topLevel?: readonly string[], typeSpecific?: readonly string[]): string[] { + return getSeriesFilterFns().resolveEffectiveIncludeSeries(topLevel, typeSpecific) +} + +function matchesSeries(seriName: string | readonly string[] | null | undefined, effectiveIncludeSeries: readonly string[]): boolean { + return getSeriesFilterFns().matchesSeries(seriName, effectiveIncludeSeries) +} + +function resolveSubSeries( + topLevel?: Readonly>, + typeSpecific?: Readonly> +): Record { + return getSeriesFilterFns().resolveSubSeries(topLevel, typeSpecific) +} /** * Interface for items that can be filtered by series name @@ -58,10 +64,7 @@ export function filterByProjectConfig( projectConfig: ProjectConfig | undefined, configPath: FilterConfigPath ): readonly T[] { - const effectiveSeries = resolveEffectiveIncludeSeries( - projectConfig?.includeSeries, - projectConfig?.[configPath]?.includeSeries - ) + const effectiveSeries = resolveEffectiveIncludeSeries(projectConfig?.includeSeries, projectConfig?.[configPath]?.includeSeries) return items.filter(item => matchesSeries(item.seriName, effectiveSeries)) } @@ -77,10 +80,7 @@ function smartConcatGlob(prefix: string, glob: string): string { return `${prefix}/${glob}` } -function extractPrefixAndBaseGlob( - glob: string, - prefixes: readonly string[] -): {prefix: string | null, baseGlob: string} { +function extractPrefixAndBaseGlob(glob: string, prefixes: readonly string[]): {prefix: string | null, baseGlob: string} { for (const prefix of prefixes) { const normalizedPrefix = prefix.replaceAll(/\/+$/g, '') const patterns = [ @@ -95,10 +95,7 @@ function extractPrefixAndBaseGlob( return {prefix: null, baseGlob: glob} } -export function applySubSeriesGlobPrefix( - rules: readonly RulePrompt[], - projectConfig: ProjectConfig | undefined -): readonly RulePrompt[] { +export function applySubSeriesGlobPrefix(rules: readonly RulePrompt[], projectConfig: ProjectConfig | undefined): readonly RulePrompt[] { const subSeries = resolveSubSeries(projectConfig?.subSeries, projectConfig?.rules?.subSeries) if (Object.keys(subSeries).length === 0) return rules @@ -115,9 +112,7 @@ export function applySubSeriesGlobPrefix( const matchedPrefixes: string[] = [] for (const [subdir, seriNames] of Object.entries(normalizedSubSeries)) { - const matched = Array.isArray(rule.seriName) - ? rule.seriName.some(name => seriNames.includes(name)) - : seriNames.includes(rule.seriName) + const matched = Array.isArray(rule.seriName) ? rule.seriName.some(name => seriNames.includes(name)) : seriNames.includes(rule.seriName) if (matched) matchedPrefixes.push(subdir) } @@ -168,9 +163,7 @@ export function resolveGitInfoDir(projectDir: string): string | null { const gitdir = path.resolve(projectDir, match[1]) return path.join(gitdir, 'info') } - } - catch { - } // ignore read errors + } catch {} // ignore read errors } return null @@ -193,8 +186,7 @@ export function findAllGitRepos(rootDir: string, maxDepth = 5): string[] { const raw = fs.readdirSync(dir, {withFileTypes: true}) if (!Array.isArray(raw)) return entries = raw - } - catch { + } catch { return } @@ -229,8 +221,7 @@ export function findGitModuleInfoDirs(dotGitDir: string): string[] { const raw = fs.readdirSync(dir, {withFileTypes: true}) if (!Array.isArray(raw)) return entries = raw - } - catch { + } catch { return } @@ -245,8 +236,7 @@ export function findGitModuleInfoDirs(dotGitDir: string): string[] { const raw = fs.readdirSync(path.join(dir, 'modules'), {withFileTypes: true}) if (!Array.isArray(raw)) return subEntries = raw - } - catch { + } catch { return } for (const sub of subEntries) { @@ -259,8 +249,7 @@ export function findGitModuleInfoDirs(dotGitDir: string): string[] { const raw = fs.readdirSync(modulesDir, {withFileTypes: true}) if (!Array.isArray(raw)) return results topEntries = raw - } - catch { + } catch { return results } diff --git a/cli/test/native-binding/cleanup.ts b/cli/test/native-binding/cleanup.ts index c7aa3c7a..d1320e98 100644 --- a/cli/test/native-binding/cleanup.ts +++ b/cli/test/native-binding/cleanup.ts @@ -1,4 +1,3 @@ -import type { DeletionError } from "./desk-paths"; import type { ILogger, OutputCleanContext, @@ -6,17 +5,17 @@ import type { OutputCleanupPathDeclaration, OutputFileDeclaration, OutputPlugin, - PluginOptions, -} from "../../src/plugins/plugin-core"; -import type { ProtectedPathRule, ProtectionMode, ProtectionRuleMatcher } from "../../src/ProtectedDeletionGuard"; -import * as fs from "node:fs"; -import * as path from "node:path"; -import glob from "fast-glob"; -import { buildDiagnostic, buildFileOperationDiagnostic, diagnosticLines } from "@/diagnostics"; -import { compactDeletionTargets } from "../../src/cleanup/delete-targets"; -import { planWorkspaceEmptyDirectoryCleanup } from "../../src/cleanup/empty-directories"; -import { deleteEmptyDirectories, deleteTargets as deskDeleteTargets } from "./desk-paths"; -import { collectAllPluginOutputs } from "../../src/plugins/plugin-core"; + PluginOptions +} from '../../src/plugins/plugin-core' +import type {ProtectedPathRule, ProtectionMode, ProtectionRuleMatcher} from '../../src/ProtectedDeletionGuard' +import type {DeletionError} from './desk-paths' +import * as fs from 'node:fs' +import * as path from 'node:path' +import glob from 'fast-glob' +import {compactDeletionTargets} from '../../src/cleanup/delete-targets' +import {planWorkspaceEmptyDirectoryCleanup} from '../../src/cleanup/empty-directories' +import {buildDiagnostic, buildFileOperationDiagnostic, diagnosticLines} from '../../src/diagnostics' +import {collectAllPluginOutputs} from '../../src/plugins/plugin-core' import { buildComparisonKeys, collectConfiguredAindexInputRules, @@ -25,121 +24,122 @@ import { createProtectedDeletionGuard, logProtectedDeletionGuardError, partitionDeletionTargets, - resolveAbsolutePath, -} from "../../src/ProtectedDeletionGuard"; + resolveAbsolutePath +} from '../../src/ProtectedDeletionGuard' +import {deleteEmptyDirectories, deleteTargets as deskDeleteTargets} from './desk-paths' /** * Result of cleanup operation */ export interface CleanupResult { - readonly deletedFiles: number; - readonly deletedDirs: number; - readonly errors: readonly CleanupError[]; - readonly violations: readonly import("../../src/ProtectedDeletionGuard").ProtectedPathViolation[]; - readonly conflicts: readonly CleanupProtectionConflict[]; - readonly message?: string; + readonly deletedFiles: number + readonly deletedDirs: number + readonly errors: readonly CleanupError[] + readonly violations: readonly import('../../src/ProtectedDeletionGuard').ProtectedPathViolation[] + readonly conflicts: readonly CleanupProtectionConflict[] + readonly message?: string } /** * Error during cleanup operation */ export interface CleanupError { - readonly path: string; - readonly type: "file" | "directory"; - readonly error: unknown; + readonly path: string + readonly type: 'file' | 'directory' + readonly error: unknown } export interface CleanupProtectionConflict { - readonly outputPath: string; - readonly outputPlugin: string; - readonly protectedPath: string; - readonly protectionMode: ProtectionMode; - readonly protectedBy: string; - readonly reason: string; + readonly outputPath: string + readonly outputPlugin: string + readonly protectedPath: string + readonly protectionMode: ProtectionMode + readonly protectedBy: string + readonly reason: string } export class CleanupProtectionConflictError extends Error { - readonly conflicts: readonly CleanupProtectionConflict[]; + readonly conflicts: readonly CleanupProtectionConflict[] constructor(conflicts: readonly CleanupProtectionConflict[]) { - super(buildCleanupProtectionConflictMessage(conflicts)); - this.name = "CleanupProtectionConflictError"; - this.conflicts = conflicts; + super(buildCleanupProtectionConflictMessage(conflicts)) + this.name = 'CleanupProtectionConflictError' + this.conflicts = conflicts } } interface CleanupTargetCollections { - readonly filesToDelete: string[]; - readonly dirsToDelete: string[]; - readonly emptyDirsToDelete: string[]; - readonly violations: readonly import("../../src/ProtectedDeletionGuard").ProtectedPathViolation[]; - readonly conflicts: readonly CleanupProtectionConflict[]; - readonly excludedScanGlobs: string[]; + readonly filesToDelete: string[] + readonly dirsToDelete: string[] + readonly emptyDirsToDelete: string[] + readonly violations: readonly import('../../src/ProtectedDeletionGuard').ProtectedPathViolation[] + readonly conflicts: readonly CleanupProtectionConflict[] + readonly excludedScanGlobs: string[] } -const DEFAULT_CLEANUP_SCAN_EXCLUDE_GLOBS = ["**/node_modules/**", "**/.git/**", "**/.turbo/**", "**/.pnpm-store/**", "**/.yarn/**", "**/.next/**"] as const; +const DEFAULT_CLEANUP_SCAN_EXCLUDE_GLOBS = ['**/node_modules/**', '**/.git/**', '**/.turbo/**', '**/.pnpm-store/**', '**/.yarn/**', '**/.next/**'] as const function normalizeGlobPattern(pattern: string): string { - return resolveAbsolutePath(pattern).replaceAll("\\", "/"); + return resolveAbsolutePath(pattern).replaceAll('\\', '/') } function expandCleanupGlob(pattern: string, ignoreGlobs: readonly string[]): readonly string[] { - const normalizedPattern = normalizeGlobPattern(pattern); + const normalizedPattern = normalizeGlobPattern(pattern) return glob.sync(normalizedPattern, { onlyFiles: false, dot: true, absolute: true, followSymbolicLinks: false, - ignore: [...ignoreGlobs], - }); + ignore: [...ignoreGlobs] + }) } function shouldExcludeCleanupMatch(matchedPath: string, target: OutputCleanupPathDeclaration): boolean { - if (target.excludeBasenames == null || target.excludeBasenames.length === 0) return false; - const basename = path.basename(matchedPath); - return target.excludeBasenames.includes(basename); + if (target.excludeBasenames == null || target.excludeBasenames.length === 0) return false + const basename = path.basename(matchedPath) + return target.excludeBasenames.includes(basename) } async function collectPluginCleanupDeclarations(plugin: OutputPlugin, cleanCtx: OutputCleanContext): Promise { - if (plugin.declareCleanupPaths == null) return {}; - return plugin.declareCleanupPaths({ ...cleanCtx, dryRun: true }); + if (plugin.declareCleanupPaths == null) return {} + return plugin.declareCleanupPaths({...cleanCtx, dryRun: true}) } async function collectPluginCleanupSnapshot( plugin: OutputPlugin, cleanCtx: OutputCleanContext, - predeclaredOutputs?: ReadonlyMap, + predeclaredOutputs?: ReadonlyMap ): Promise<{ - readonly plugin: OutputPlugin; - readonly outputs: Awaited>; - readonly cleanup: OutputCleanupDeclarations; + readonly plugin: OutputPlugin + readonly outputs: Awaited> + readonly cleanup: OutputCleanupDeclarations }> { - const existingOutputDeclarations = predeclaredOutputs?.get(plugin); + const existingOutputDeclarations = predeclaredOutputs?.get(plugin) const [outputs, cleanup] = await Promise.all([ - existingOutputDeclarations != null ? Promise.resolve(existingOutputDeclarations) : plugin.declareOutputFiles({ ...cleanCtx, dryRun: true }), - collectPluginCleanupDeclarations(plugin, cleanCtx), - ]); + existingOutputDeclarations != null ? Promise.resolve(existingOutputDeclarations) : plugin.declareOutputFiles({...cleanCtx, dryRun: true}), + collectPluginCleanupDeclarations(plugin, cleanCtx) + ]) - return { plugin, outputs, cleanup }; + return {plugin, outputs, cleanup} } function buildCleanupProtectionConflictMessage(conflicts: readonly CleanupProtectionConflict[]): string { - const pathList = conflicts.map((conflict) => conflict.outputPath).join(", "); - return `Cleanup protection conflict: ${conflicts.length} output path(s) are also protected: ${pathList}`; + const pathList = conflicts.map(conflict => conflict.outputPath).join(', ') + return `Cleanup protection conflict: ${conflicts.length} output path(s) are also protected: ${pathList}` } function detectCleanupProtectionConflicts( outputPathOwners: ReadonlyMap, - guard: ReturnType, + guard: ReturnType ): CleanupProtectionConflict[] { - const conflicts: CleanupProtectionConflict[] = []; + const conflicts: CleanupProtectionConflict[] = [] for (const [outputPath, outputPlugins] of outputPathOwners.entries()) { - const outputKeys = new Set(buildComparisonKeys(outputPath)); + const outputKeys = new Set(buildComparisonKeys(outputPath)) for (const rule of guard.compiledRules) { - const isExactMatch = rule.comparisonKeys.some((ruleKey) => outputKeys.has(ruleKey)); - if (!isExactMatch) continue; + const isExactMatch = rule.comparisonKeys.some(ruleKey => outputKeys.has(ruleKey)) + if (!isExactMatch) continue for (const outputPlugin of outputPlugins) { conflicts.push({ @@ -148,50 +148,50 @@ function detectCleanupProtectionConflicts( protectedPath: rule.path, protectionMode: rule.protectionMode, protectedBy: rule.source, - reason: rule.reason, - }); + reason: rule.reason + }) } } } return conflicts.sort((a, b) => { - const pathDiff = a.outputPath.localeCompare(b.outputPath); - if (pathDiff !== 0) return pathDiff; - return a.protectedPath.localeCompare(b.protectedPath); - }); + const pathDiff = a.outputPath.localeCompare(b.outputPath) + if (pathDiff !== 0) return pathDiff + return a.protectedPath.localeCompare(b.protectedPath) + }) } function logCleanupProtectionConflicts(logger: ILogger, conflicts: readonly CleanupProtectionConflict[]): void { - const firstConflict = conflicts[0]; + const firstConflict = conflicts[0] logger.error( buildDiagnostic({ - code: "CLEANUP_PROTECTION_CONFLICT_DETECTED", - title: "Cleanup output paths conflict with protected inputs", + code: 'CLEANUP_PROTECTION_CONFLICT_DETECTED', + title: 'Cleanup output paths conflict with protected inputs', rootCause: diagnosticLines( `tnmsc found ${conflicts.length} output path(s) that also match protected cleanup rules.`, firstConflict == null - ? "No conflict details were captured." - : `Example conflict: "${firstConflict.outputPath}" is protected by "${firstConflict.protectedPath}".`, + ? 'No conflict details were captured.' + : `Example conflict: "${firstConflict.outputPath}" is protected by "${firstConflict.protectedPath}".` ), - exactFix: diagnosticLines("Separate generated output paths from protected source or reserved workspace paths before running cleanup again."), + exactFix: diagnosticLines('Separate generated output paths from protected source or reserved workspace paths before running cleanup again.'), possibleFixes: [ - diagnosticLines("Update cleanup protect declarations so they do not overlap generated outputs."), - diagnosticLines("Move the conflicting output target to a generated-only directory."), + diagnosticLines('Update cleanup protect declarations so they do not overlap generated outputs.'), + diagnosticLines('Move the conflicting output target to a generated-only directory.') ], details: { count: conflicts.length, - conflicts: conflicts.map((conflict) => ({ + conflicts: conflicts.map(conflict => ({ outputPath: conflict.outputPath, outputPlugin: conflict.outputPlugin, protectedPath: conflict.protectedPath, protectionMode: conflict.protectionMode, protectedBy: conflict.protectedBy, - reason: conflict.reason, - })), - }, - }), - ); + reason: conflict.reason + })) + } + }) + ) } /** @@ -200,44 +200,45 @@ function logCleanupProtectionConflicts(logger: ILogger, conflicts: readonly Clea async function collectCleanupTargets( outputPlugins: readonly OutputPlugin[], cleanCtx: OutputCleanContext, - predeclaredOutputs?: ReadonlyMap, + predeclaredOutputs?: ReadonlyMap ): Promise { - const deleteFiles = new Set(); - const deleteDirs = new Set(); - const protectedRules = new Map(); - const excludeScanGlobSet = new Set(DEFAULT_CLEANUP_SCAN_EXCLUDE_GLOBS); - const outputPathOwners = new Map(); + const deleteFiles = new Set() + const deleteDirs = new Set() + const protectedRules = new Map() + const excludeScanGlobSet = new Set(DEFAULT_CLEANUP_SCAN_EXCLUDE_GLOBS) + const outputPathOwners = new Map() - const pluginSnapshots = await Promise.all(outputPlugins.map(async (plugin) => collectPluginCleanupSnapshot(plugin, cleanCtx, predeclaredOutputs))); + const pluginSnapshots = await Promise.all(outputPlugins.map(async plugin => collectPluginCleanupSnapshot(plugin, cleanCtx, predeclaredOutputs))) - const addDeletePath = (rawPath: string, kind: "file" | "directory"): void => { - if (kind === "directory") deleteDirs.add(resolveAbsolutePath(rawPath)); - else deleteFiles.add(resolveAbsolutePath(rawPath)); - }; + const addDeletePath = (rawPath: string, kind: 'file' | 'directory'): void => { + if (kind === 'directory') deleteDirs.add(resolveAbsolutePath(rawPath)) + else deleteFiles.add(resolveAbsolutePath(rawPath)) + } - const addProtectRule = (rawPath: string, protectionMode: ProtectionMode, reason: string, source: string, matcher: ProtectionRuleMatcher = "path"): void => { - const resolvedPath = resolveAbsolutePath(rawPath); + const addProtectRule = (rawPath: string, protectionMode: ProtectionMode, reason: string, source: string, matcher: ProtectionRuleMatcher = 'path'): void => { + const resolvedPath = resolveAbsolutePath(rawPath) protectedRules.set(`${matcher}:${protectionMode}:${resolvedPath}`, { path: resolvedPath, protectionMode, reason, source, - matcher, - }); - }; + matcher + }) + } const defaultProtectionModeForTarget = (target: OutputCleanupPathDeclaration): ProtectionMode => { - if (target.protectionMode != null) return target.protectionMode; - return target.kind === "file" ? "direct" : "recursive"; - }; + if (target.protectionMode != null) return target.protectionMode + return target.kind === 'file' ? 'direct' : 'recursive' + } - for (const rule of collectProtectedInputSourceRules(cleanCtx.collectedOutputContext)) - addProtectRule(rule.path, rule.protectionMode, rule.reason, rule.source); + for (const rule of collectProtectedInputSourceRules(cleanCtx.collectedOutputContext)) { + addProtectRule(rule.path, rule.protectionMode, rule.reason, rule.source) + } if (cleanCtx.collectedOutputContext.aindexDir != null && cleanCtx.pluginOptions != null) { for (const rule of collectConfiguredAindexInputRules(cleanCtx.pluginOptions as Required, cleanCtx.collectedOutputContext.aindexDir, { - workspaceDir: cleanCtx.collectedOutputContext.workspace.directory.path, + workspaceDir: cleanCtx.collectedOutputContext.workspace.directory.path })) { - addProtectRule(rule.path, rule.protectionMode, rule.reason, rule.source, rule.matcher); + addProtectRule(rule.path, rule.protectionMode, rule.reason, rule.source, rule.matcher) } } @@ -245,67 +246,67 @@ async function collectCleanupTargets( addProtectRule( rule.path, rule.protectionMode, - rule.reason ?? "configured cleanup protection rule", - "configured-cleanup-protection", - rule.matcher ?? "path", - ); + rule.reason ?? 'configured cleanup protection rule', + 'configured-cleanup-protection', + rule.matcher ?? 'path' + ) } for (const snapshot of pluginSnapshots) { for (const declaration of snapshot.outputs) { - const resolvedOutputPath = resolveAbsolutePath(declaration.path); - addDeletePath(resolvedOutputPath, "file"); - const existingOwners = outputPathOwners.get(resolvedOutputPath); - if (existingOwners == null) outputPathOwners.set(resolvedOutputPath, [snapshot.plugin.name]); - else if (!existingOwners.includes(snapshot.plugin.name)) existingOwners.push(snapshot.plugin.name); + const resolvedOutputPath = resolveAbsolutePath(declaration.path) + addDeletePath(resolvedOutputPath, 'file') + const existingOwners = outputPathOwners.get(resolvedOutputPath) + if (existingOwners == null) outputPathOwners.set(resolvedOutputPath, [snapshot.plugin.name]) + else if (!existingOwners.includes(snapshot.plugin.name)) existingOwners.push(snapshot.plugin.name) } - for (const ignoreGlob of snapshot.cleanup.excludeScanGlobs ?? []) excludeScanGlobSet.add(normalizeGlobPattern(ignoreGlob)); + for (const ignoreGlob of snapshot.cleanup.excludeScanGlobs ?? []) excludeScanGlobSet.add(normalizeGlobPattern(ignoreGlob)) } - const excludeScanGlobs = [...excludeScanGlobSet]; + const excludeScanGlobs = [...excludeScanGlobSet] const resolveDeleteGlob = (target: OutputCleanupPathDeclaration): void => { for (const matchedPath of expandCleanupGlob(target.path, excludeScanGlobs)) { - if (shouldExcludeCleanupMatch(matchedPath, target)) continue; + if (shouldExcludeCleanupMatch(matchedPath, target)) continue try { - const stat = fs.lstatSync(matchedPath); - if (stat.isDirectory()) addDeletePath(matchedPath, "directory"); - else addDeletePath(matchedPath, "file"); + const stat = fs.lstatSync(matchedPath) + if (stat.isDirectory()) addDeletePath(matchedPath, 'directory') + else addDeletePath(matchedPath, 'file') } catch {} } - }; + } const resolveProtectGlob = (target: OutputCleanupPathDeclaration, pluginName: string): void => { - const protectionMode = defaultProtectionModeForTarget(target); - const reason = target.label != null ? `plugin cleanup protect declaration (${target.label})` : "plugin cleanup protect declaration"; + const protectionMode = defaultProtectionModeForTarget(target) + const reason = target.label != null ? `plugin cleanup protect declaration (${target.label})` : 'plugin cleanup protect declaration' for (const matchedPath of expandCleanupGlob(target.path, excludeScanGlobs)) { - addProtectRule(matchedPath, protectionMode, reason, `plugin-cleanup-protect:${pluginName}`); + addProtectRule(matchedPath, protectionMode, reason, `plugin-cleanup-protect:${pluginName}`) } - }; + } - for (const { plugin, cleanup } of pluginSnapshots) { + for (const {plugin, cleanup} of pluginSnapshots) { for (const target of cleanup.protect ?? []) { - if (target.kind === "glob") { - resolveProtectGlob(target, plugin.name); - continue; + if (target.kind === 'glob') { + resolveProtectGlob(target, plugin.name) + continue } addProtectRule( target.path, defaultProtectionModeForTarget(target), - target.label != null ? `plugin cleanup protect declaration (${target.label})` : "plugin cleanup protect declaration", - `plugin-cleanup-protect:${plugin.name}`, - ); + target.label != null ? `plugin cleanup protect declaration (${target.label})` : 'plugin cleanup protect declaration', + `plugin-cleanup-protect:${plugin.name}` + ) } for (const target of cleanup.delete ?? []) { - if (target.kind === "glob") { - resolveDeleteGlob(target); - continue; + if (target.kind === 'glob') { + resolveDeleteGlob(target) + continue } - if (target.kind === "directory") addDeletePath(target.path, "directory"); - else addDeletePath(target.path, "file"); + if (target.kind === 'directory') addDeletePath(target.path, 'directory') + else addDeletePath(target.path, 'file') } } @@ -313,21 +314,21 @@ async function collectCleanupTargets( workspaceDir: cleanCtx.collectedOutputContext.workspace.directory.path, projectRoots: collectProjectRoots(cleanCtx.collectedOutputContext), rules: [...protectedRules.values()], - ...(cleanCtx.collectedOutputContext.aindexDir != null ? { aindexDir: cleanCtx.collectedOutputContext.aindexDir } : {}), - }); - const conflicts = detectCleanupProtectionConflicts(outputPathOwners, guard); - if (conflicts.length > 0) throw new CleanupProtectionConflictError(conflicts); - const filePartition = partitionDeletionTargets([...deleteFiles], guard); - const dirPartition = partitionDeletionTargets([...deleteDirs], guard); - - const compactedTargets = compactDeletionTargets(filePartition.safePaths, dirPartition.safePaths); + ...cleanCtx.collectedOutputContext.aindexDir != null ? {aindexDir: cleanCtx.collectedOutputContext.aindexDir} : {} + }) + const conflicts = detectCleanupProtectionConflicts(outputPathOwners, guard) + if (conflicts.length > 0) throw new CleanupProtectionConflictError(conflicts) + const filePartition = partitionDeletionTargets([...deleteFiles], guard) + const dirPartition = partitionDeletionTargets([...deleteDirs], guard) + + const compactedTargets = compactDeletionTargets(filePartition.safePaths, dirPartition.safePaths) const emptyDirectoryPlan = planWorkspaceEmptyDirectoryCleanup({ fs, path, workspaceDir: cleanCtx.collectedOutputContext.workspace.directory.path, filesToDelete: compactedTargets.files, - dirsToDelete: compactedTargets.dirs, - }); + dirsToDelete: compactedTargets.dirs + }) return { filesToDelete: compactedTargets.files, @@ -335,96 +336,96 @@ async function collectCleanupTargets( emptyDirsToDelete: emptyDirectoryPlan.emptyDirsToDelete, violations: [...filePartition.violations, ...dirPartition.violations].sort((a, b) => a.targetPath.localeCompare(b.targetPath)), conflicts: [], - excludedScanGlobs: [...excludeScanGlobSet].sort((a, b) => a.localeCompare(b)), - }; + excludedScanGlobs: [...excludeScanGlobSet].sort((a, b) => a.localeCompare(b)) + } } export async function collectDeletionTargets( outputPlugins: readonly OutputPlugin[], cleanCtx: OutputCleanContext, - predeclaredOutputs?: ReadonlyMap, + predeclaredOutputs?: ReadonlyMap ): Promise<{ - filesToDelete: string[]; - dirsToDelete: string[]; - emptyDirsToDelete: string[]; - violations: import("../../src/ProtectedDeletionGuard").ProtectedPathViolation[]; - conflicts: CleanupProtectionConflict[]; - excludedScanGlobs: string[]; + filesToDelete: string[] + dirsToDelete: string[] + emptyDirsToDelete: string[] + violations: import('../../src/ProtectedDeletionGuard').ProtectedPathViolation[] + conflicts: CleanupProtectionConflict[] + excludedScanGlobs: string[] }> { - const targets = await collectCleanupTargets(outputPlugins, cleanCtx, predeclaredOutputs); + const targets = await collectCleanupTargets(outputPlugins, cleanCtx, predeclaredOutputs) return { filesToDelete: targets.filesToDelete, dirsToDelete: targets.dirsToDelete.sort((a, b) => a.localeCompare(b)), emptyDirsToDelete: targets.emptyDirsToDelete.sort((a, b) => a.localeCompare(b)), violations: [...targets.violations], conflicts: [...targets.conflicts], - excludedScanGlobs: [...targets.excludedScanGlobs], - }; + excludedScanGlobs: [...targets.excludedScanGlobs] + } } -function buildCleanupErrors(logger: ILogger, errors: readonly DeletionError[], type: "file" | "directory"): CleanupError[] { - return errors.map((currentError) => { - const errorMessage = currentError.error instanceof Error ? currentError.error.message : String(currentError.error); +function buildCleanupErrors(logger: ILogger, errors: readonly DeletionError[], type: 'file' | 'directory'): CleanupError[] { + return errors.map(currentError => { + const errorMessage = currentError.error instanceof Error ? currentError.error.message : String(currentError.error) logger.warn( buildFileOperationDiagnostic({ - code: type === "file" ? "CLEANUP_FILE_DELETE_FAILED" : "CLEANUP_DIRECTORY_DELETE_FAILED", - title: type === "file" ? "Cleanup could not delete a file" : "Cleanup could not delete a directory", - operation: "delete", + code: type === 'file' ? 'CLEANUP_FILE_DELETE_FAILED' : 'CLEANUP_DIRECTORY_DELETE_FAILED', + title: type === 'file' ? 'Cleanup could not delete a file' : 'Cleanup could not delete a directory', + operation: 'delete', targetKind: type, path: currentError.path, error: errorMessage, details: { - phase: "cleanup", - }, - }), - ); + phase: 'cleanup' + } + }) + ) - return { path: currentError.path, type, error: currentError.error }; - }); + return {path: currentError.path, type, error: currentError.error} + }) } async function executeCleanupTargets( targets: CleanupTargetCollections, - logger: ILogger, -): Promise<{ deletedFiles: number; deletedDirs: number; errors: CleanupError[] }> { - logger.debug("cleanup delete execution started", { + logger: ILogger +): Promise<{deletedFiles: number, deletedDirs: number, errors: CleanupError[]}> { + logger.debug('cleanup delete execution started', { filesToDelete: targets.filesToDelete.length, dirsToDelete: targets.dirsToDelete.length + targets.emptyDirsToDelete.length, - emptyDirsToDelete: targets.emptyDirsToDelete.length, - }); + emptyDirsToDelete: targets.emptyDirsToDelete.length + }) const result = await deskDeleteTargets({ files: targets.filesToDelete, - dirs: targets.dirsToDelete, - }); - const emptyDirResult = await deleteEmptyDirectories(targets.emptyDirsToDelete); + dirs: targets.dirsToDelete + }) + const emptyDirResult = await deleteEmptyDirectories(targets.emptyDirsToDelete) - const fileErrors = buildCleanupErrors(logger, result.fileErrors, "file"); - const dirErrors = buildCleanupErrors(logger, [...result.dirErrors, ...emptyDirResult.errors], "directory"); - const allErrors = [...fileErrors, ...dirErrors]; + const fileErrors = buildCleanupErrors(logger, result.fileErrors, 'file') + const dirErrors = buildCleanupErrors(logger, [...result.dirErrors, ...emptyDirResult.errors], 'directory') + const allErrors = [...fileErrors, ...dirErrors] - logger.debug("cleanup delete execution complete", { + logger.debug('cleanup delete execution complete', { deletedFiles: result.deletedFiles.length, deletedDirs: result.deletedDirs.length + emptyDirResult.deletedPaths.length, - errors: allErrors.length, - }); + errors: allErrors.length + }) return { deletedFiles: result.deletedFiles.length, deletedDirs: result.deletedDirs.length + emptyDirResult.deletedPaths.length, - errors: allErrors, - }; + errors: allErrors + } } function logCleanupPlanDiagnostics(logger: ILogger, targets: CleanupTargetCollections): void { - logger.debug("cleanup plan built", { + logger.debug('cleanup plan built', { filesToDelete: targets.filesToDelete.length, dirsToDelete: targets.dirsToDelete.length + targets.emptyDirsToDelete.length, emptyDirsToDelete: targets.emptyDirsToDelete.length, violations: targets.violations.length, conflicts: targets.conflicts.length, - excludedScanGlobs: targets.excludedScanGlobs, - }); + excludedScanGlobs: targets.excludedScanGlobs + }) } /** @@ -436,34 +437,34 @@ export async function performCleanup( outputPlugins: readonly OutputPlugin[], cleanCtx: OutputCleanContext, logger: ILogger, - predeclaredOutputs?: ReadonlyMap, + predeclaredOutputs?: ReadonlyMap ): Promise { if (predeclaredOutputs != null) { - const outputs = await collectAllPluginOutputs(outputPlugins, cleanCtx, predeclaredOutputs); - logger.debug("Collected outputs for cleanup", { + const outputs = await collectAllPluginOutputs(outputPlugins, cleanCtx, predeclaredOutputs) + logger.debug('Collected outputs for cleanup', { projectDirs: outputs.projectDirs.length, projectFiles: outputs.projectFiles.length, globalDirs: outputs.globalDirs.length, - globalFiles: outputs.globalFiles.length, - }); + globalFiles: outputs.globalFiles.length + }) } - let targets: CleanupTargetCollections; + let targets: CleanupTargetCollections try { - targets = await collectCleanupTargets(outputPlugins, cleanCtx, predeclaredOutputs); + targets = await collectCleanupTargets(outputPlugins, cleanCtx, predeclaredOutputs) } catch (error) { if (error instanceof CleanupProtectionConflictError) { - logCleanupProtectionConflicts(logger, error.conflicts); + logCleanupProtectionConflicts(logger, error.conflicts) return { deletedFiles: 0, deletedDirs: 0, errors: [], violations: [], conflicts: error.conflicts, - message: error.message, - }; + message: error.message + } } - throw error; + throw error } const cleanupTargets: CleanupTargetCollections = { filesToDelete: targets.filesToDelete, @@ -471,29 +472,29 @@ export async function performCleanup( emptyDirsToDelete: targets.emptyDirsToDelete, violations: targets.violations, conflicts: targets.conflicts, - excludedScanGlobs: targets.excludedScanGlobs, - }; - logCleanupPlanDiagnostics(logger, cleanupTargets); + excludedScanGlobs: targets.excludedScanGlobs + } + logCleanupPlanDiagnostics(logger, cleanupTargets) if (cleanupTargets.violations.length > 0) { - logProtectedDeletionGuardError(logger, "cleanup", cleanupTargets.violations); + logProtectedDeletionGuardError(logger, 'cleanup', cleanupTargets.violations) return { deletedFiles: 0, deletedDirs: 0, errors: [], violations: cleanupTargets.violations, conflicts: [], - message: `Protected deletion guard blocked cleanup for ${cleanupTargets.violations.length} path(s)`, - }; + message: `Protected deletion guard blocked cleanup for ${cleanupTargets.violations.length} path(s)` + } } - const executionResult = await executeCleanupTargets(cleanupTargets, logger); + const executionResult = await executeCleanupTargets(cleanupTargets, logger) return { deletedFiles: executionResult.deletedFiles, deletedDirs: executionResult.deletedDirs, errors: executionResult.errors, violations: [], - conflicts: [], - }; + conflicts: [] + } } diff --git a/cli/test/native-binding/desk-paths.ts b/cli/test/native-binding/desk-paths.ts index 289cf5d6..243ac6bf 100644 --- a/cli/test/native-binding/desk-paths.ts +++ b/cli/test/native-binding/desk-paths.ts @@ -3,8 +3,8 @@ import type {LoggerDiagnosticInput} from '../../src/plugins/plugin-core' import * as fs from 'node:fs' import path from 'node:path' import process from 'node:process' -import {buildFileOperationDiagnostic} from '@/diagnostics' -import {resolveRuntimeEnvironment, resolveUserPath} from '@/runtime-environment' +import {buildFileOperationDiagnostic} from '../../src/diagnostics' +import {resolveRuntimeEnvironment, resolveUserPath} from '../../src/runtime-environment' type PlatformFixedDir = 'win32' | 'darwin' | 'linux' @@ -41,8 +41,7 @@ export function deletePathSync(p: string): void { if (stat.isSymbolicLink()) { if (process.platform === 'win32') fs.rmSync(p, {recursive: true, force: true}) else fs.unlinkSync(p) - } - else if (stat.isDirectory()) fs.rmSync(p, {recursive: true, force: true}) + } else if (stat.isDirectory()) fs.rmSync(p, {recursive: true, force: true}) else fs.unlinkSync(p) } @@ -56,8 +55,7 @@ export function writeFileSync(filePath: string, data: string | Buffer, encoding: export function readFileSync(filePath: string, encoding: BufferEncoding = 'utf8'): string { try { return fs.readFileSync(filePath, encoding) - } - catch (error) { + } catch (error) { const msg = error instanceof Error ? error.message : String(error) throw new Error(`Failed to read file "${filePath}": ${msg}`) } @@ -98,8 +96,7 @@ async function deletePath(p: string): Promise { await fs.promises.unlink(p) return true - } - catch (error) { + } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false throw error } @@ -109,19 +106,14 @@ async function deleteEmptyDirectory(p: string): Promise { try { await fs.promises.rmdir(p) return true - } - catch (error) { + } catch (error) { const {code} = error as NodeJS.ErrnoException if (code === 'ENOENT' || code === 'ENOTEMPTY') return false throw error } } -async function mapWithConcurrencyLimit( - items: readonly T[], - concurrency: number, - worker: (item: T) => Promise -): Promise { +async function mapWithConcurrencyLimit(items: readonly T[], concurrency: number, worker: (item: T) => Promise): Promise { if (items.length === 0) return [] const results: TResult[] = [] @@ -147,20 +139,14 @@ async function mapWithConcurrencyLimit( return results } -async function deletePaths( - paths: readonly string[], - options?: {readonly sortByDepthDescending?: boolean} -): Promise { - const sortedPaths = options?.sortByDepthDescending === true - ? [...paths].sort((a, b) => b.length - a.length || b.localeCompare(a)) - : [...paths] +async function deletePaths(paths: readonly string[], options?: {readonly sortByDepthDescending?: boolean}): Promise { + const sortedPaths = options?.sortByDepthDescending === true ? [...paths].sort((a, b) => b.length - a.length || b.localeCompare(a)) : [...paths] const results = await mapWithConcurrencyLimit(sortedPaths, DELETE_CONCURRENCY, async currentPath => { try { const deleted = await deletePath(currentPath) return {path: currentPath, deleted} - } - catch (error) { + } catch (error) { return {path: currentPath, error} } }) @@ -201,8 +187,7 @@ export async function deleteEmptyDirectories(dirs: readonly string[]): Promise { - const [fileResult, dirResult] = await Promise.all([ - deleteFiles(targets.files ?? []), - deleteDirectories(targets.dirs ?? []) - ]) +export async function deleteTargets(targets: {readonly files?: readonly string[], readonly dirs?: readonly string[]}): Promise { + const [fileResult, dirResult] = await Promise.all([deleteFiles(targets.files ?? []), deleteDirectories(targets.dirs ?? [])]) return { deletedFiles: fileResult.deletedPaths, @@ -264,21 +243,22 @@ export function writeFileSafe(options: SafeWriteOptions): SafeWriteResult { writeFileSync(fullPath, content) logger.trace({action: 'write', type, path: fullPath}) return {path: relativePath, success: true} - } - catch (error) { + } catch (error) { const errMsg = error instanceof Error ? error.message : String(error) - logger.error(buildFileOperationDiagnostic({ - code: 'OUTPUT_FILE_WRITE_FAILED', - title: `Failed to write ${type} output`, - operation: 'write', - targetKind: `${type} output file`, - path: fullPath, - error: errMsg, - details: { - relativePath, - type - } - })) + logger.error( + buildFileOperationDiagnostic({ + code: 'OUTPUT_FILE_WRITE_FAILED', + title: `Failed to write ${type} output`, + operation: 'write', + targetKind: `${type} output file`, + path: fullPath, + error: errMsg, + details: { + relativePath, + type + } + }) + ) return {path: relativePath, success: false, error: error as Error} } } diff --git a/cli/test/setup-native-binding.ts b/cli/test/setup-native-binding.ts index da543ed2..48fa5e04 100644 --- a/cli/test/setup-native-binding.ts +++ b/cli/test/setup-native-binding.ts @@ -1,45 +1,45 @@ -import type { ILogger, OutputCleanContext, OutputCleanupDeclarations, OutputPlugin } from "../src/plugins/plugin-core"; -import * as fs from "node:fs"; -import * as path from "node:path"; -import glob from "fast-glob"; -import * as deskPaths from "./native-binding/desk-paths"; -import { FilePathKind, PluginKind } from "../src/plugins/plugin-core/enums"; +import type {ILogger, OutputCleanContext, OutputCleanupDeclarations, OutputPlugin} from '../src/plugins/plugin-core' +import * as fs from 'node:fs' +import * as path from 'node:path' +import glob from 'fast-glob' +import {FilePathKind, PluginKind} from '../src/plugins/plugin-core/enums' +import * as deskPaths from './native-binding/desk-paths' interface NativeCleanupTarget { - readonly path: string; - readonly kind: "file" | "directory" | "glob"; - readonly excludeBasenames?: readonly string[]; - readonly protectionMode?: "direct" | "recursive"; - readonly scope?: string; - readonly label?: string; + readonly path: string + readonly kind: 'file' | 'directory' | 'glob' + readonly excludeBasenames?: readonly string[] + readonly protectionMode?: 'direct' | 'recursive' + readonly scope?: string + readonly label?: string } interface NativeCleanupDeclarations { - readonly delete?: readonly NativeCleanupTarget[]; - readonly protect?: readonly NativeCleanupTarget[]; - readonly excludeScanGlobs?: readonly string[]; + readonly delete?: readonly NativeCleanupTarget[] + readonly protect?: readonly NativeCleanupTarget[] + readonly excludeScanGlobs?: readonly string[] } interface NativePluginCleanupSnapshot { - readonly pluginName: string; - readonly outputs: readonly string[]; - readonly cleanup: NativeCleanupDeclarations; + readonly pluginName: string + readonly outputs: readonly string[] + readonly cleanup: NativeCleanupDeclarations } interface NativeProtectedRule { - readonly path: string; - readonly protectionMode: "direct" | "recursive"; - readonly reason: string; - readonly source: string; - readonly matcher?: "path" | "glob"; + readonly path: string + readonly protectionMode: 'direct' | 'recursive' + readonly reason: string + readonly source: string + readonly matcher?: 'path' | 'glob' } interface NativeCleanupSnapshot { - readonly workspaceDir: string; - readonly aindexDir?: string; - readonly projectRoots: readonly string[]; - readonly protectedRules: readonly NativeProtectedRule[]; - readonly pluginSnapshots: readonly NativePluginCleanupSnapshot[]; + readonly workspaceDir: string + readonly aindexDir?: string + readonly projectRoots: readonly string[] + readonly protectedRules: readonly NativeProtectedRule[] + readonly pluginSnapshots: readonly NativePluginCleanupSnapshot[] } function createMockLogger(): ILogger { @@ -49,8 +49,8 @@ function createMockLogger(): ILogger { info: () => {}, warn: () => {}, error: () => {}, - fatal: () => {}, - } as ILogger; + fatal: () => {} + } as ILogger } function createSyntheticOutputPlugin(snapshot: NativePluginCleanupSnapshot): OutputPlugin { @@ -61,38 +61,38 @@ function createSyntheticOutputPlugin(snapshot: NativePluginCleanupSnapshot): Out declarativeOutput: true, outputCapabilities: {}, async declareOutputFiles() { - return snapshot.outputs.map((output) => ({ path: output, source: {} })); + return snapshot.outputs.map(output => ({path: output, source: {}})) }, async declareCleanupPaths(): Promise { return { - ...(snapshot.cleanup.delete != null ? { delete: [...snapshot.cleanup.delete] as OutputCleanupDeclarations["delete"] } : {}), - ...(snapshot.cleanup.protect != null ? { protect: [...snapshot.cleanup.protect] as OutputCleanupDeclarations["protect"] } : {}), - ...(snapshot.cleanup.excludeScanGlobs != null ? { excludeScanGlobs: [...snapshot.cleanup.excludeScanGlobs] } : {}), - }; + ...snapshot.cleanup.delete != null ? {delete: [...snapshot.cleanup.delete] as OutputCleanupDeclarations['delete']} : {}, + ...snapshot.cleanup.protect != null ? {protect: [...snapshot.cleanup.protect] as OutputCleanupDeclarations['protect']} : {}, + ...snapshot.cleanup.excludeScanGlobs != null ? {excludeScanGlobs: [...snapshot.cleanup.excludeScanGlobs]} : {} + } }, async convertContent() { - return ""; - }, - }; + return '' + } + } } async function createSyntheticCleanContext(snapshot: NativeCleanupSnapshot): Promise { - const { mergeConfig } = await import("../src/config"); - const workspaceDir = path.resolve(snapshot.workspaceDir); - const cleanupProtectionRules = snapshot.protectedRules.map((rule) => ({ + const {mergeConfig} = await import('../src/config') + const workspaceDir = path.resolve(snapshot.workspaceDir) + const cleanupProtectionRules = snapshot.protectedRules.map(rule => ({ path: rule.path, protectionMode: rule.protectionMode, reason: rule.reason, - matcher: rule.matcher ?? "path", - })); + matcher: rule.matcher ?? 'path' + })) if (snapshot.aindexDir != null) { cleanupProtectionRules.push({ path: snapshot.aindexDir, - protectionMode: "direct", - reason: "resolved aindex root", - matcher: "path", - }); + protectionMode: 'direct', + reason: 'resolved aindex root', + matcher: 'path' + }) } return { @@ -104,8 +104,8 @@ async function createSyntheticCleanContext(snapshot: NativeCleanupSnapshot): Pro pluginOptions: mergeConfig({ workspaceDir, cleanupProtection: { - rules: cleanupProtectionRules, - }, + rules: cleanupProtectionRules + } }), collectedOutputContext: { workspace: { @@ -113,28 +113,28 @@ async function createSyntheticCleanContext(snapshot: NativeCleanupSnapshot): Pro pathKind: FilePathKind.Absolute, path: workspaceDir, getDirectoryName: () => path.basename(workspaceDir), - getAbsolutePath: () => workspaceDir, + getAbsolutePath: () => workspaceDir }, - projects: snapshot.projectRoots.map((projectRoot) => ({ + projects: snapshot.projectRoots.map(projectRoot => ({ dirFromWorkspacePath: { pathKind: FilePathKind.Relative, - path: path.relative(workspaceDir, projectRoot) || ".", + path: path.relative(workspaceDir, projectRoot) || '.', basePath: workspaceDir, getDirectoryName: () => path.basename(projectRoot), - getAbsolutePath: () => projectRoot, - }, - })), - }, - }, - } as unknown as OutputCleanContext; + getAbsolutePath: () => projectRoot + } + })) + } + } + } as unknown as OutputCleanContext } async function planCleanup(snapshotJson: string): Promise { - const { collectDeletionTargets } = await import("./native-binding/cleanup"); - const snapshot = JSON.parse(snapshotJson) as NativeCleanupSnapshot; - const outputPlugins = snapshot.pluginSnapshots.map(createSyntheticOutputPlugin); - const cleanCtx = await createSyntheticCleanContext(snapshot); - const result = await collectDeletionTargets(outputPlugins, cleanCtx); + const {collectDeletionTargets} = await import('./native-binding/cleanup') + const snapshot = JSON.parse(snapshotJson) as NativeCleanupSnapshot + const outputPlugins = snapshot.pluginSnapshots.map(createSyntheticOutputPlugin) + const cleanCtx = await createSyntheticCleanContext(snapshot) + const result = await collectDeletionTargets(outputPlugins, cleanCtx) return JSON.stringify({ filesToDelete: result.filesToDelete, @@ -142,58 +142,58 @@ async function planCleanup(snapshotJson: string): Promise { emptyDirsToDelete: result.emptyDirsToDelete, violations: result.violations, conflicts: result.conflicts, - excludedScanGlobs: result.excludedScanGlobs, - }); + excludedScanGlobs: result.excludedScanGlobs + }) } async function runCleanup(snapshotJson: string): Promise { - const { performCleanup } = await import("./native-binding/cleanup"); - const snapshot = JSON.parse(snapshotJson) as NativeCleanupSnapshot; - const outputPlugins = snapshot.pluginSnapshots.map(createSyntheticOutputPlugin); - const cleanCtx = await createSyntheticCleanContext(snapshot); - const result = await performCleanup(outputPlugins, cleanCtx, createMockLogger()); + const {performCleanup} = await import('./native-binding/cleanup') + const snapshot = JSON.parse(snapshotJson) as NativeCleanupSnapshot + const outputPlugins = snapshot.pluginSnapshots.map(createSyntheticOutputPlugin) + const cleanCtx = await createSyntheticCleanContext(snapshot) + const result = await performCleanup(outputPlugins, cleanCtx, createMockLogger()) return JSON.stringify({ deletedFiles: result.deletedFiles, deletedDirs: result.deletedDirs, - errors: result.errors.map((error) => ({ + errors: result.errors.map(error => ({ path: error.path, kind: error.type, - error: error.error instanceof Error ? error.error.message : String(error.error), + error: error.error instanceof Error ? error.error.message : String(error.error) })), violations: result.violations, conflicts: result.conflicts, filesToDelete: [], dirsToDelete: [], emptyDirsToDelete: [], - excludedScanGlobs: [], - }); + excludedScanGlobs: [] + }) } function resolveEffectiveIncludeSeries(topLevel?: readonly string[], typeSpecific?: readonly string[]): string[] { - if (topLevel == null && typeSpecific == null) return []; - return [...new Set([...(topLevel ?? []), ...(typeSpecific ?? [])])]; + if (topLevel == null && typeSpecific == null) return [] + return [...new Set([...topLevel ?? [], ...typeSpecific ?? []])] } function matchesSeries(seriName: string | readonly string[] | null | undefined, effectiveIncludeSeries: readonly string[]): boolean { - if (seriName == null) return true; - if (effectiveIncludeSeries.length === 0) return true; - if (typeof seriName === "string") return effectiveIncludeSeries.includes(seriName); - return seriName.some((name) => effectiveIncludeSeries.includes(name)); + if (seriName == null) return true + if (effectiveIncludeSeries.length === 0) return true + if (typeof seriName === 'string') return effectiveIncludeSeries.includes(seriName) + return seriName.some(name => effectiveIncludeSeries.includes(name)) } function resolveSubSeries( topLevel?: Readonly>, - typeSpecific?: Readonly>, + typeSpecific?: Readonly> ): Record { - if (topLevel == null && typeSpecific == null) return {}; - const merged: Record = {}; - for (const [key, values] of Object.entries(topLevel ?? {})) merged[key] = [...values]; + if (topLevel == null && typeSpecific == null) return {} + const merged: Record = {} + for (const [key, values] of Object.entries(topLevel ?? {})) merged[key] = [...values] for (const [key, values] of Object.entries(typeSpecific ?? {})) { - const existingValues = merged[key] ?? []; - merged[key] = Object.hasOwn(merged, key) ? [...new Set([...existingValues, ...values])] : [...values]; + const existingValues = merged[key] ?? [] + merged[key] = Object.hasOwn(merged, key) ? [...new Set([...existingValues, ...values])] : [...values] } - return merged; + return merged } globalThis.__TNMSC_TEST_NATIVE_BINDING__ = { @@ -211,5 +211,5 @@ globalThis.__TNMSC_TEST_NATIVE_BINDING__ = { performCleanup: runCleanup, resolveEffectiveIncludeSeries, matchesSeries, - resolveSubSeries, -}; + resolveSubSeries +} diff --git a/cli/tsconfig.eslint.json b/cli/tsconfig.eslint.json index 585b38ee..62f8268e 100644 --- a/cli/tsconfig.eslint.json +++ b/cli/tsconfig.eslint.json @@ -9,15 +9,12 @@ "src/**/*.ts", "src/**/*.test.ts", "src/**/*.spec.ts", + "test/**/*.ts", "env.d.ts", "eslint.config.ts", "tsdown.config.ts", "vite.config.ts", "vitest.config.ts" ], - "exclude": [ - "../node_modules", - "dist", - "coverage" - ] + "exclude": ["../node_modules", "dist", "coverage"] } diff --git a/cli/tsconfig.json b/cli/tsconfig.json index e27cc557..9006c87e 100644 --- a/cli/tsconfig.json +++ b/cli/tsconfig.json @@ -5,17 +5,13 @@ "incremental": true, "composite": false, "target": "ESNext", - "lib": [ - "ESNext" - ], + "lib": ["ESNext"], "moduleDetection": "force", "useDefineForClassFields": true, "module": "ESNext", "moduleResolution": "Bundler", "paths": { - "@/*": [ - "./src/*" - ], + "@/*": ["./src/*"], "@truenine/desk-paths": ["./src/core/desk-paths.ts"], "@truenine/desk-paths/*": ["./src/core/desk-paths/*"], "@truenine/plugin-output-shared": ["./src/plugins/plugin-output-shared/index.ts"], @@ -44,9 +40,7 @@ "@truenine/plugin-zed": ["./src/plugins/plugin-zed.ts"] }, "resolveJsonModule": true, - "types": [ - "node" - ], + "types": ["node"], "allowImportingTsExtensions": true, "strict": true, "strictBindCallApply": true, @@ -83,16 +77,6 @@ "verbatimModuleSyntax": true, "skipLibCheck": true }, - "include": [ - "src/**/*", - "env.d.ts", - "eslint.config.ts", - "tsdown.config.ts", - "vite.config.ts", - "vitest.config.ts" - ], - "exclude": [ - "../node_modules", - "dist" - ] + "include": ["src/**/*", "test/**/*.ts", "env.d.ts", "eslint.config.ts", "tsdown.config.ts", "vite.config.ts", "vitest.config.ts"], + "exclude": ["../node_modules", "dist"] } diff --git a/mcp/src/server.test.ts b/mcp/src/server.test.ts index 451568ef..e66e8d4d 100644 --- a/mcp/src/server.test.ts +++ b/mcp/src/server.test.ts @@ -12,6 +12,7 @@ const tempDirs: string[] = [] const serverMainPath = fileURLToPath(new URL('./main.ts', import.meta.url)) const tsxPackageJsonPath = fileURLToPath(new URL('../node_modules/tsx/package.json', import.meta.url)) const tsxCliPath = path.join(path.dirname(tsxPackageJsonPath), 'dist', 'cli.mjs') +const cliNativeBindingSetupPath = fileURLToPath(new URL('../test/setup-native-binding.ts', import.meta.url)) function createTempDir(prefix: string): string { const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)) @@ -20,8 +21,7 @@ function createTempDir(prefix: string): string { } function createTransportEnv(homeDir: string): Record { - const envEntries = Object.entries(process.env) - .filter((entry): entry is [string, string] => typeof entry[1] === 'string') + const envEntries = Object.entries(process.env).filter((entry): entry is [string, string] => typeof entry[1] === 'string') return { ...Object.fromEntries(envEntries), @@ -30,7 +30,9 @@ function createTransportEnv(homeDir: string): Record { XDG_CACHE_HOME: path.join(homeDir, '.cache'), XDG_CONFIG_HOME: path.join(homeDir, '.config'), XDG_DATA_HOME: path.join(homeDir, '.local', 'share'), - XDG_STATE_HOME: path.join(homeDir, '.local', 'state') + XDG_STATE_HOME: path.join(homeDir, '.local', 'state'), + VITEST: 'true', + NODE_ENV: 'test' } } @@ -40,17 +42,13 @@ interface TextContentBlock { } function getTextBlock(result: unknown): string { - if ( - typeof result !== 'object' - || result == null - || !('content' in result) - || !Array.isArray(result.content) - ) { + if (typeof result !== 'object' || result == null || !('content' in result) || !Array.isArray(result.content)) { throw new Error('Expected content blocks in MCP result') } - const textBlock = result.content - .find((block): block is TextContentBlock => typeof block === 'object' && block != null && 'type' in block && (block as {type?: unknown}).type === 'text') + const textBlock = result.content.find( + (block): block is TextContentBlock => typeof block === 'object' && block != null && 'type' in block && (block as {type?: unknown}).type === 'text' + ) if (textBlock?.text == null) throw new Error('Expected a text content block in MCP result') return textBlock.text } @@ -73,28 +71,21 @@ describe('memory-sync MCP stdio server', () => { }) const transport = new StdioClientTransport({ command: process.execPath, - args: [tsxCliPath, serverMainPath], + args: [tsxCliPath, '--import', cliNativeBindingSetupPath, serverMainPath], cwd: workspaceDir, env: createTransportEnv(homeDir), stderr: 'pipe' }) let stderrOutput = '' - transport.stderr?.on('data', (chunk: Buffer | string) => stderrOutput += String(chunk)) + transport.stderr?.on('data', (chunk: Buffer | string) => (stderrOutput += String(chunk))) try { await client.connect(transport) const tools = await client.listTools() - expect(tools.tools.map(tool => tool.name).sort()).toEqual([ - 'apply_prompt_translation', - 'get_prompt', - 'list_prompts', - 'upsert_prompt_src' - ]) - expect( - tools.tools.find(tool => tool.name === 'apply_prompt_translation')?.inputSchema.properties - ).toMatchObject({ + expect(tools.tools.map(tool => tool.name).sort()).toEqual(['apply_prompt_translation', 'get_prompt', 'list_prompts', 'upsert_prompt_src']) + expect(tools.tools.find(tool => tool.name === 'apply_prompt_translation')?.inputSchema.properties).toMatchObject({ promptId: expect.any(Object), enContent: expect.any(Object), distContent: expect.any(Object) @@ -241,14 +232,12 @@ describe('memory-sync MCP stdio server', () => { }) const filteredPrompts = parseToolResult<{prompts: {promptId: string}[]}>(filteredListResult) expect(filteredPrompts.prompts.map(prompt => prompt.promptId)).toEqual(['command:demo/build']) - } - catch (error) { + } catch (error) { if (stderrOutput.trim().length === 0) throw error const errorMessage = error instanceof Error ? error.message : String(error) throw new Error(`${errorMessage}\nMCP stderr:\n${stderrOutput.trim()}`) - } - finally { + } finally { await client.close() } }) diff --git a/mcp/test/setup-native-binding.ts b/mcp/test/setup-native-binding.ts new file mode 100644 index 00000000..458ba3c2 --- /dev/null +++ b/mcp/test/setup-native-binding.ts @@ -0,0 +1,42 @@ +function resolveEffectiveIncludeSeries(topLevel?: readonly string[], typeSpecific?: readonly string[]): string[] { + if (topLevel == null && typeSpecific == null) return [] + return [...new Set([...topLevel ?? [], ...typeSpecific ?? []])] +} + +function matchesSeries(seriName: string | readonly string[] | null | undefined, effectiveIncludeSeries: readonly string[]): boolean { + if (seriName == null) return true + if (effectiveIncludeSeries.length === 0) return true + if (typeof seriName === 'string') return effectiveIncludeSeries.includes(seriName) + return seriName.some(name => effectiveIncludeSeries.includes(name)) +} + +function resolveSubSeries( + topLevel?: Readonly>, + typeSpecific?: Readonly> +): Record { + if (topLevel == null && typeSpecific == null) return {} + + const merged: Record = {} + for (const [key, values] of Object.entries(topLevel ?? {})) merged[key] = [...values] + + for (const [key, values] of Object.entries(typeSpecific ?? {})) { + const existingValues = merged[key] ?? [] + merged[key] = Object.hasOwn(merged, key) ? [...new Set([...existingValues, ...values])] : [...values] + } + + return merged +} + +const testGlobals = globalThis as typeof globalThis & { + __TNMSC_TEST_NATIVE_BINDING__?: { + resolveEffectiveIncludeSeries: typeof resolveEffectiveIncludeSeries + matchesSeries: typeof matchesSeries + resolveSubSeries: typeof resolveSubSeries + } +} + +testGlobals.__TNMSC_TEST_NATIVE_BINDING__ = { + resolveEffectiveIncludeSeries, + matchesSeries, + resolveSubSeries +} diff --git a/mcp/tsconfig.json b/mcp/tsconfig.json index 9bab1cc0..defaae3d 100644 --- a/mcp/tsconfig.json +++ b/mcp/tsconfig.json @@ -5,22 +5,16 @@ "incremental": true, "composite": false, "target": "ESNext", - "lib": [ - "ESNext" - ], + "lib": ["ESNext"], "moduleDetection": "force", "useDefineForClassFields": true, "module": "ESNext", "moduleResolution": "Bundler", "paths": { - "@/*": [ - "./src/*" - ] + "@/*": ["./src/*"] }, "resolveJsonModule": true, - "types": [ - "node" - ], + "types": ["node"], "allowImportingTsExtensions": true, "strict": true, "strictBindCallApply": true, @@ -57,14 +51,6 @@ "verbatimModuleSyntax": true, "skipLibCheck": true }, - "include": [ - "src/**/*", - "eslint.config.ts", - "tsdown.config.ts", - "vitest.config.ts" - ], - "exclude": [ - "../node_modules", - "dist" - ] + "include": ["src/**/*", "test/**/*.ts", "eslint.config.ts", "tsdown.config.ts", "vitest.config.ts"], + "exclude": ["../node_modules", "dist"] } diff --git a/mcp/tsconfig.lib.json b/mcp/tsconfig.lib.json index 12ffb0d0..4da48c3b 100644 --- a/mcp/tsconfig.lib.json +++ b/mcp/tsconfig.lib.json @@ -3,10 +3,5 @@ "compilerOptions": { "noEmit": true }, - "include": [ - "src/**/*", - "eslint.config.ts", - "tsdown.config.ts", - "vitest.config.ts" - ] + "include": ["src/**/*", "test/**/*.ts", "eslint.config.ts", "tsdown.config.ts", "vitest.config.ts"] }