Skip to content

Commit 92002ee

Browse files
authored
Merge pull request #103 from TrueNine/dev
Add native cleanup runtime with JS fallback
2 parents c17db98 + 3ef6866 commit 92002ee

18 files changed

+2943
-420
lines changed

Cargo.lock

Lines changed: 25 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

cli/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ sha2 = { workspace = true }
3333
napi = { workspace = true, optional = true }
3434
napi-derive = { workspace = true, optional = true }
3535
reqwest = { version = "0.13.2", default-features = false, features = ["blocking", "json", "rustls"] }
36+
globset = "0.4.16"
37+
walkdir = "2.5.0"
3638

3739
[dev-dependencies]
3840
proptest = "1.10.0"

cli/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,9 @@
5858
"lint": "eslint --cache .",
5959
"prepublishOnly": "run-s build",
6060
"test": "run-s build:deps test:run",
61+
"test:native-cleanup-smoke": "tsx scripts/cleanup-native-smoke.ts",
6162
"test:run": "vitest run",
63+
"benchmark:cleanup": "tsx scripts/benchmark-cleanup.ts",
6264
"lintfix": "eslint --fix --cache .",
6365
"typecheck": "tsc --noEmit -p tsconfig.lib.json"
6466
},

cli/scripts/benchmark-cleanup.ts

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
import type {ILogger, OutputCleanContext, OutputCleanupDeclarations, OutputPlugin} from '../src/plugins/plugin-core'
2+
import * as fs from 'node:fs'
3+
import * as os from 'node:os'
4+
import * as path from 'node:path'
5+
import {performance} from 'node:perf_hooks'
6+
import glob from 'fast-glob'
7+
8+
process.env['TNMSC_FORCE_NATIVE_BINDING'] = '1'
9+
delete process.env['VITEST']
10+
delete process.env['VITEST_WORKER_ID']
11+
12+
const cleanupModule = await import('../src/commands/CleanupUtils')
13+
const fallbackModule = await import('../src/commands/CleanupUtils.fallback')
14+
const pluginCore = await import('../src/plugins/plugin-core')
15+
16+
function createMockLogger(): ILogger {
17+
return {
18+
trace: () => {},
19+
debug: () => {},
20+
info: () => {},
21+
warn: () => {},
22+
error: () => {},
23+
fatal: () => {}
24+
} as ILogger
25+
}
26+
27+
function createCleanContext(workspaceDir: string): OutputCleanContext {
28+
return {
29+
logger: createMockLogger(),
30+
fs,
31+
path,
32+
glob,
33+
collectedOutputContext: {
34+
workspace: {
35+
directory: {
36+
pathKind: pluginCore.FilePathKind.Absolute,
37+
path: workspaceDir,
38+
getDirectoryName: () => path.basename(workspaceDir),
39+
getAbsolutePath: () => workspaceDir
40+
},
41+
projects: Array.from({length: 40}, (_, index) => ({
42+
dirFromWorkspacePath: {
43+
pathKind: pluginCore.FilePathKind.Relative,
44+
path: `project-${index}`,
45+
basePath: workspaceDir,
46+
getDirectoryName: () => `project-${index}`,
47+
getAbsolutePath: () => path.join(workspaceDir, `project-${index}`)
48+
}
49+
}))
50+
},
51+
aindexDir: path.join(workspaceDir, 'aindex')
52+
}
53+
} as OutputCleanContext
54+
}
55+
56+
function createBenchmarkPlugin(workspaceDir: string): OutputPlugin {
57+
return {
58+
type: pluginCore.PluginKind.Output,
59+
name: 'BenchmarkOutputPlugin',
60+
log: createMockLogger(),
61+
declarativeOutput: true,
62+
outputCapabilities: {},
63+
async declareOutputFiles() {
64+
return Array.from({length: 40}, (_, projectIndex) => ([
65+
{path: path.join(workspaceDir, `project-${projectIndex}`, 'AGENTS.md'), source: {}},
66+
{path: path.join(workspaceDir, `project-${projectIndex}`, 'commands', 'AGENTS.md'), source: {}}
67+
])).flat()
68+
},
69+
async declareCleanupPaths(): Promise<OutputCleanupDeclarations> {
70+
return {
71+
delete: [{
72+
kind: 'glob',
73+
path: path.join(workspaceDir, '.codex', 'skills', '*'),
74+
excludeBasenames: ['.system']
75+
}, {
76+
kind: 'glob',
77+
path: path.join(workspaceDir, '.claude', '**', 'CLAUDE.md')
78+
}],
79+
protect: [{
80+
kind: 'directory',
81+
path: path.join(workspaceDir, '.codex', 'skills', '.system'),
82+
protectionMode: 'recursive'
83+
}]
84+
}
85+
},
86+
async convertContent() {
87+
return 'benchmark'
88+
}
89+
}
90+
}
91+
92+
async function measure(label: string, iterations: number, run: () => Promise<void>): Promise<number> {
93+
const start = performance.now()
94+
for (let index = 0; index < iterations; index += 1) {
95+
await run()
96+
}
97+
const total = performance.now() - start
98+
const average = total / iterations
99+
process.stdout.write(`${label}: total=${total.toFixed(2)}ms avg=${average.toFixed(2)}ms\n`)
100+
return average
101+
}
102+
103+
async function main(): Promise<void> {
104+
if (!cleanupModule.hasNativeCleanupBinding()) {
105+
throw new Error('Native cleanup binding is unavailable. Build the CLI NAPI module first.')
106+
}
107+
108+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tnmsc-benchmark-cleanup-'))
109+
const workspaceDir = path.join(tempDir, 'workspace')
110+
111+
try {
112+
for (let projectIndex = 0; projectIndex < 40; projectIndex += 1) {
113+
const rootFile = path.join(workspaceDir, `project-${projectIndex}`, 'AGENTS.md')
114+
const childFile = path.join(workspaceDir, `project-${projectIndex}`, 'commands', 'AGENTS.md')
115+
fs.mkdirSync(path.dirname(childFile), {recursive: true})
116+
fs.writeFileSync(rootFile, '# root', 'utf8')
117+
fs.writeFileSync(childFile, '# child', 'utf8')
118+
}
119+
120+
const skillsDir = path.join(workspaceDir, '.codex', 'skills')
121+
fs.mkdirSync(path.join(skillsDir, '.system'), {recursive: true})
122+
for (let index = 0; index < 80; index += 1) {
123+
const skillDir = path.join(skillsDir, `legacy-${index}`)
124+
fs.mkdirSync(skillDir, {recursive: true})
125+
fs.writeFileSync(path.join(skillDir, 'SKILL.md'), '# stale', 'utf8')
126+
}
127+
128+
for (let index = 0; index < 40; index += 1) {
129+
const claudeFile = path.join(workspaceDir, '.claude', `project-${index}`, 'CLAUDE.md')
130+
fs.mkdirSync(path.dirname(claudeFile), {recursive: true})
131+
fs.writeFileSync(claudeFile, '# claude', 'utf8')
132+
}
133+
134+
const plugin = createBenchmarkPlugin(workspaceDir)
135+
const cleanCtx = createCleanContext(workspaceDir)
136+
const iterations = 25
137+
138+
process.stdout.write(`cleanup benchmark iterations=${iterations}\n`)
139+
const fallbackAvg = await measure('fallback-plan', iterations, async () => {
140+
await fallbackModule.collectDeletionTargets([plugin], cleanCtx)
141+
})
142+
const nativeAvg = await measure('native-plan', iterations, async () => {
143+
await cleanupModule.collectDeletionTargets([plugin], cleanCtx)
144+
})
145+
146+
const delta = nativeAvg - fallbackAvg
147+
process.stdout.write(`delta=${delta.toFixed(2)}ms (${((delta / fallbackAvg) * 100).toFixed(2)}%)\n`)
148+
}
149+
finally {
150+
fs.rmSync(tempDir, {recursive: true, force: true})
151+
}
152+
}
153+
154+
await main()
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import type {ILogger, OutputCleanContext, OutputCleanupDeclarations, OutputPlugin} from '../src/plugins/plugin-core'
2+
import * as fs from 'node:fs'
3+
import * as os from 'node:os'
4+
import * as path from 'node:path'
5+
import glob from 'fast-glob'
6+
7+
process.env['TNMSC_FORCE_NATIVE_BINDING'] = '1'
8+
delete process.env['VITEST']
9+
delete process.env['VITEST_WORKER_ID']
10+
11+
const cleanupModule = await import('../src/commands/CleanupUtils')
12+
const fallbackModule = await import('../src/commands/CleanupUtils.fallback')
13+
const pluginCore = await import('../src/plugins/plugin-core')
14+
15+
function createMockLogger(): ILogger {
16+
return {
17+
trace: () => {},
18+
debug: () => {},
19+
info: () => {},
20+
warn: () => {},
21+
error: () => {},
22+
fatal: () => {}
23+
} as ILogger
24+
}
25+
26+
function createCleanContext(workspaceDir: string): OutputCleanContext {
27+
return {
28+
logger: createMockLogger(),
29+
fs,
30+
path,
31+
glob,
32+
collectedOutputContext: {
33+
workspace: {
34+
directory: {
35+
pathKind: pluginCore.FilePathKind.Absolute,
36+
path: workspaceDir,
37+
getDirectoryName: () => path.basename(workspaceDir),
38+
getAbsolutePath: () => workspaceDir
39+
},
40+
projects: [{
41+
dirFromWorkspacePath: {
42+
pathKind: pluginCore.FilePathKind.Relative,
43+
path: 'project-a',
44+
basePath: workspaceDir,
45+
getDirectoryName: () => 'project-a',
46+
getAbsolutePath: () => path.join(workspaceDir, 'project-a')
47+
}
48+
}]
49+
},
50+
aindexDir: path.join(workspaceDir, 'aindex')
51+
}
52+
} as OutputCleanContext
53+
}
54+
55+
function createSmokePlugin(workspaceDir: string): OutputPlugin {
56+
return {
57+
type: pluginCore.PluginKind.Output,
58+
name: 'SmokeOutputPlugin',
59+
log: createMockLogger(),
60+
declarativeOutput: true,
61+
outputCapabilities: {},
62+
async declareOutputFiles() {
63+
return [
64+
{path: path.join(workspaceDir, 'project-a', 'AGENTS.md'), source: {}},
65+
{path: path.join(workspaceDir, 'project-a', 'commands', 'AGENTS.md'), source: {}}
66+
]
67+
},
68+
async declareCleanupPaths(): Promise<OutputCleanupDeclarations> {
69+
return {
70+
delete: [{
71+
kind: 'glob',
72+
path: path.join(workspaceDir, '.codex', 'skills', '*'),
73+
excludeBasenames: ['.system']
74+
}]
75+
}
76+
},
77+
async convertContent() {
78+
return 'smoke'
79+
}
80+
}
81+
}
82+
83+
async function main(): Promise<void> {
84+
if (!cleanupModule.hasNativeCleanupBinding()) {
85+
throw new Error('Native cleanup binding is unavailable. Build the CLI NAPI module first.')
86+
}
87+
88+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tnmsc-native-cleanup-smoke-'))
89+
const workspaceDir = path.join(tempDir, 'workspace')
90+
const legacySkillDir = path.join(workspaceDir, '.codex', 'skills', 'legacy')
91+
const preservedSkillDir = path.join(workspaceDir, '.codex', 'skills', '.system')
92+
const rootOutput = path.join(workspaceDir, 'project-a', 'AGENTS.md')
93+
const childOutput = path.join(workspaceDir, 'project-a', 'commands', 'AGENTS.md')
94+
95+
fs.mkdirSync(path.dirname(rootOutput), {recursive: true})
96+
fs.mkdirSync(path.dirname(childOutput), {recursive: true})
97+
fs.mkdirSync(legacySkillDir, {recursive: true})
98+
fs.mkdirSync(preservedSkillDir, {recursive: true})
99+
fs.writeFileSync(rootOutput, '# root', 'utf8')
100+
fs.writeFileSync(childOutput, '# child', 'utf8')
101+
fs.writeFileSync(path.join(legacySkillDir, 'SKILL.md'), '# stale', 'utf8')
102+
fs.writeFileSync(path.join(preservedSkillDir, 'SKILL.md'), '# keep', 'utf8')
103+
104+
try {
105+
const plugin = createSmokePlugin(workspaceDir)
106+
const cleanCtx = createCleanContext(workspaceDir)
107+
108+
const nativePlan = await cleanupModule.collectDeletionTargets([plugin], cleanCtx)
109+
const fallbackPlan = await fallbackModule.collectDeletionTargets([plugin], cleanCtx)
110+
111+
const sortPaths = (value: {filesToDelete: string[], dirsToDelete: string[], excludedScanGlobs: string[]}) => ({
112+
...value,
113+
filesToDelete: [...value.filesToDelete].sort(),
114+
dirsToDelete: [...value.dirsToDelete].sort(),
115+
excludedScanGlobs: [...value.excludedScanGlobs].sort()
116+
})
117+
118+
if (JSON.stringify(sortPaths(nativePlan)) !== JSON.stringify(sortPaths(fallbackPlan))) {
119+
throw new Error(`Native cleanup plan mismatch.\nNative: ${JSON.stringify(nativePlan, null, 2)}\nFallback: ${JSON.stringify(fallbackPlan, null, 2)}`)
120+
}
121+
122+
const result = await cleanupModule.performCleanup([plugin], cleanCtx, createMockLogger())
123+
if (result.deletedFiles !== 2 || result.deletedDirs !== 1 || result.errors.length > 0) {
124+
throw new Error(`Unexpected native cleanup result: ${JSON.stringify(result, null, 2)}`)
125+
}
126+
127+
if (fs.existsSync(rootOutput) || fs.existsSync(childOutput) || fs.existsSync(legacySkillDir)) {
128+
throw new Error('Native cleanup did not remove the expected outputs')
129+
}
130+
if (!fs.existsSync(preservedSkillDir)) {
131+
throw new Error('Native cleanup removed the preserved .system skill directory')
132+
}
133+
134+
process.stdout.write('cleanup-native-smoke: ok\n')
135+
}
136+
finally {
137+
fs.rmSync(tempDir, {recursive: true, force: true})
138+
}
139+
}
140+
141+
await main()

0 commit comments

Comments
 (0)