From bd966c9bfa2b4e535fca47093753e883f4221794 Mon Sep 17 00:00:00 2001 From: haelyra <49814733+haelyra@users.noreply.github.com> Date: Sun, 11 Oct 2026 02:57:28 -0400 Subject: [PATCH 1/2] test: reproduce commands-only ESM runtime and scope conflicts Actual installed skills-health --help fails with require undefined in ESM Claude project and home fixtures. RED evidence: command-runtime-evidence/red-final.log; bounded repair remains pending. --- tests/scripts/command-runtime.test.js | 157 ++++++++++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 tests/scripts/command-runtime.test.js diff --git a/tests/scripts/command-runtime.test.js b/tests/scripts/command-runtime.test.js new file mode 100644 index 000000000..765f971ba --- /dev/null +++ b/tests/scripts/command-runtime.test.js @@ -0,0 +1,157 @@ +// Actual commands-only install lifecycle and module-scope regression coverage. +'use strict'; +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { spawnSync } = require('child_process'); +const { pathToFileURL } = require('url'); +const { createManifestInstallPlan, applyInstallPlan, previewInstallPlan } = require('../../scripts/lib/install-executor'); +const { withHookConsent } = require('../../scripts/lib/install/hook-consent'); +const { repairInstalledStates } = require('../../scripts/lib/install-lifecycle'); +const repo = path.resolve(__dirname, '../..'); +let passed = 0; +let failed = 0; +function snapshot(root) { + if (!fs.existsSync(root)) return {}; + return Object.fromEntries(fs.readdirSync(root, { recursive: true, withFileTypes: true }) + .filter(entry => entry.isFile()) + .map(entry => { const file = path.join(entry.parentPath || entry.path, entry.name); + return [path.relative(root, file), require('crypto').createHash('sha256').update(fs.readFileSync(file)).digest('hex')]; }).sort()); +} +function test(name, fn, target = 'claude-project') { + const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-command-runtime-'))); + const home = path.join(dir, 'home'); + const project = path.join(dir, 'project'); + fs.mkdirSync(home); fs.mkdirSync(project); + const root = target === 'claude' ? path.join(home, '.claude') : path.join(project, '.claude'); + const scripts = path.join(root, 'scripts'); + const marker = path.join(scripts, 'package.json'); + const write = (file, bytes) => { fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, bytes); }; + const env = { ...process.env, HOME: home, USERPROFILE: home, CLAUDE_CONFIG_DIR: path.join(home, '.claude') }; + const run = (file, args) => spawnSync(process.execPath, [file, ...args], { + cwd: project, env, encoding: 'utf8', timeout: 30000, maxBuffer: 8 * 1024 * 1024 }); + const install = () => run(path.join(repo, 'scripts/install-apply.js'), ['--target', target, '--modules', 'commands-core', '--no-hooks', '--json']); + const uninstall = () => run(path.join(repo, 'scripts/uninstall.js'), ['--target', target, '--json']); + const plan = () => withHookConsent(createManifestInstallPlan({ sourceRoot: repo, homeDir: home, + projectRoot: project, env: {}, target, moduleIds: ['commands-core'] }), 'declined'); + const statePath = path.join(root, 'ecc/install-state.json'); + const state = () => JSON.parse(fs.readFileSync(statePath, 'utf8')); + const ok = result => assert.strictEqual(result.status, 0, result.stderr || result.stdout); + const health = () => { + const file = path.join(scripts, 'skills-health.js'); + const help = run(file, ['--help']); ok(help); assert.match(help.stdout, /Usage:/); + const result = run(file, ['--json', '--home', home, '--skills-root', path.join(dir, 'none'), + '--learned-root', path.join(dir, 'none2'), '--imported-root', path.join(dir, 'none3')]); + ok(result); assert.deepStrictEqual(JSON.parse(result.stdout).skills, []); + }; + const probe = file => { + const result = run('--input-type=module', ['-e', `const m=await import(${JSON.stringify(pathToFileURL(file).href)}); console.log(m.default ?? m.value);`]); + ok(result); return result.stdout.trim(); + }; + const noPayload = () => { + for (const file of ['settings.json', '.mcp.json', 'hooks/hooks.json', 'scripts/hooks']) { + assert.ok(!fs.existsSync(path.join(root, file)), `Unexpected ${file}`); + } + assert.ok(!state().operations.some(op => ['hooks-runtime', 'mcp-configs'].includes(op.moduleId))); + }; + try { fn({ dir, home, project, root, scripts, marker, write, install, uninstall, plan, + state, statePath, ok, health, probe, noPayload, run, target }); passed++; console.log(`PASS ${name}`); } + catch (error) { failed++; console.error(`FAIL ${name}: ${error.stack}`); } + finally { fs.rmSync(dir, { recursive: true, force: true }); } +} +for (const target of ['claude-project', 'claude']) { + test(`${target}: fresh ESM commands-only CLI, receipt, reinstall, uninstall`, f => { + const projectBytes = '{ "type": "module", "user": "project" }\n'; + const rootBytes = '{ "type": "module", "user": "claude" }\n'; + f.write(path.join(f.project, 'package.json'), projectBytes); + f.write(path.join(f.root, 'package.json'), rootBytes); + f.ok(f.install()); f.health(); f.noPayload(); + assert.deepStrictEqual(JSON.parse(fs.readFileSync(f.marker, 'utf8')), { type: 'commonjs' }); + const owned = f.state().operations.filter(op => op.destinationPath === f.marker); + assert.strictEqual(owned.length, 1); assert.strictEqual(owned[0].ownership, 'managed'); + assert.strictEqual(owned[0].moduleId, 'commands-core'); + assert.ok(f.state().operations.some(op => op.moduleId === 'shared-script-utils' && op.destinationPath.endsWith('utils.js'))); + assert.ok(!fs.existsSync(path.join(f.scripts, 'lib/package.json'))); + f.ok(f.install()); f.health(); f.noPayload(); + assert.strictEqual(f.state().operations.filter(op => op.destinationPath === f.marker).length, 1); + f.ok(f.uninstall()); assert.ok(!fs.existsSync(f.marker)); assert.ok(!fs.existsSync(f.statePath)); + assert.strictEqual(fs.readFileSync(path.join(f.project, 'package.json'), 'utf8'), projectBytes); + assert.strictEqual(fs.readFileSync(path.join(f.root, 'package.json'), 'utf8'), rootBytes); + }, target); +} +for (const [scope, bytes] of [ + ['package.json', '{"type":"module"}\n'], ['package.json', '{broken'], + ['lib/package.json', '{"type":"module"}\n'], ['lib/package.json', '{broken'], + ['lib/skill-evolution/package.json', '{"type":"module"}\n'], + ['lib/astra-review/package.json', '{"type":"module"}\n'], + ['ci/package.json', '{"type":"module"}\n'], + ['lib/package.json', '[]'], ['lib/package.json', '{"type":"unexpected"}'], +]) { + test(`refuses incompatible ${scope} ${bytes} before writes`, f => { + f.write(path.join(f.project, 'package.json'), '{"type":"module"}\n'); + f.write(path.join(f.scripts, scope), bytes); + const before = snapshot(f.dir); + const result = f.install(); assert.notStrictEqual(result.status, 0, 'Install falsely reported success'); + assert.deepStrictEqual(snapshot(f.dir), before); + assert.throws(() => previewInstallPlan(f.plan())); assert.deepStrictEqual(snapshot(f.dir), before); + }); +} +for (const userFile of ['user.js', 'lib/user.js', 'lib/skill-evolution/user.js', 'ci/user.js', 'lib/utils.js']) { + test(`refuses unowned JS ${userFile} without scope mutation`, f => { + f.write(path.join(f.project, 'package.json'), '{"type":"module"}\n'); + const file = path.join(f.scripts, userFile); f.write(file, 'export const value = 42;\n'); + assert.strictEqual(f.probe(file), '42'); const before = snapshot(f.dir); + const result = f.install(); assert.notStrictEqual(result.status, 0); + assert.match(result.stderr + result.stdout, /unowned or edited JavaScript/); + assert.deepStrictEqual(snapshot(f.dir), before); assert.strictEqual(f.probe(file), '42'); + }); +} +for (const bytes of ['{ "type": "commonjs", "user": true }\n', '{"user":true}\n']) { + test('compatible scripts metadata remains unchanged and unowned', f => { + f.write(f.marker, bytes); const file = path.join(f.scripts, 'user.js'); f.write(file, 'module.exports = 43;\n'); + f.ok(f.install()); f.health(); + assert.ok(!f.state().operations.some(op => op.destinationPath === f.marker)); + f.ok(f.install()); f.health(); f.ok(f.uninstall()); + assert.strictEqual(fs.readFileSync(f.marker, 'utf8'), bytes); assert.strictEqual(f.probe(file), '43'); + }); +} +test('unrelated nested ESM package keeps interpretation and bytes', f => { + f.write(path.join(f.project, 'package.json'), '{"type":"module"}\n'); + const nested = path.join(f.scripts, 'user/package.json'); const bytes = '{"type":"module"}\n'; + const file = path.join(f.scripts, 'user/index.js'); f.write(nested, bytes); f.write(file, 'export const value = 44;\n'); + f.ok(f.install()); f.health(); assert.strictEqual(f.probe(file), '44'); + f.ok(f.uninstall()); assert.strictEqual(fs.readFileSync(nested, 'utf8'), bytes); assert.strictEqual(f.probe(file), '44'); +}); +test('compatible nested helper scope remains unchanged and unowned', f => { + const file = path.join(f.scripts, 'lib/package.json'); const bytes = '{"type":"commonjs","keep":true}\n'; + f.write(file, bytes); f.ok(f.install()); f.health(); f.ok(f.uninstall()); + assert.strictEqual(fs.readFileSync(file, 'utf8'), bytes); +}); +test('edited owned JS rejects missing-marker reinstall before any writes', f => { + f.ok(f.install()); fs.unlinkSync(f.marker); + f.write(path.join(f.scripts, 'lib/utils.js'), 'export const value = 45;\n'); + const before = snapshot(f.dir); const result = f.install(); assert.notStrictEqual(result.status, 0); + assert.match(result.stderr + result.stdout, /unowned or edited JavaScript/); assert.deepStrictEqual(snapshot(f.dir), before); +}); +test('missing owned marker restores safely on reinstall', f => { + f.ok(f.install()); fs.unlinkSync(f.marker); f.ok(f.install()); f.health(); f.ok(f.uninstall()); +}); +test('repair restores missing owned marker', f => { + f.ok(f.install()); fs.unlinkSync(f.marker); + const result = repairInstalledStates({ repoRoot: repo, homeDir: f.home, projectRoot: f.project, env: {}, targets: [f.target] }); + assert.strictEqual(result.summary.errorCount, 0); f.health(); f.ok(f.uninstall()); +}); +test('publication rechecks newly introduced unowned JS', f => { + assert.throws(() => applyInstallPlan(f.plan(), { beforeOperationWrite({ operation }) { + if (operation.destinationPath === f.marker) f.write(path.join(f.scripts, 'user.js'), 'export const value = 46;\n'); + } }), /unowned or edited JavaScript/); + assert.ok(!fs.existsSync(f.marker)); +}); +test('publication rechecks nested helper package conflict', f => { + assert.throws(() => applyInstallPlan(f.plan(), { beforeOperationWrite({ operation }) { + if (operation.destinationPath === f.marker) f.write(path.join(f.scripts, 'lib/package.json'), '{"type":"module"}\n'); + } }), /module boundary/); + assert.ok(!fs.existsSync(f.marker)); +}); +console.log(`Passed: ${passed}`); console.log(`Failed: ${failed}`); process.exitCode = failed ? 1 : 0; From dbfd6ab009a781e5a380936185f93241bc9dacc6 Mon Sep 17 00:00:00 2001 From: haelyra <49814733+haelyra@users.noreply.github.com> Date: Sun, 11 Oct 2026 03:04:04 -0400 Subject: [PATCH 2/2] fix: protect commands-only CommonJS runtime in Claude installs Reuse the existing scripts boundary asset and guard for commands-core without hooks. Preflight selected helper scopes and preserve user module metadata, unowned/edited JS interpretation, and boundary ownership through uninstall. Actual installed ESM help/JSON, receipt, reinstall/uninstall and 34 focused cases pass. OpenCode boundary 8/8, adapters 51/51, manifests 43/43; focused coverage exceeds 80%. Two root-owned legacy ESM core-profile fixtures require correction; no unsupported states weakened. --- scripts/lib/install-lifecycle.js | 10 ++-- scripts/lib/install-targets/claude-home.js | 11 +++- scripts/lib/install-targets/claude-project.js | 19 +++++-- scripts/lib/install/apply.js | 6 +- .../lib/install/opencode-script-boundary.js | 57 ++++++++++++++++--- tests/scripts/command-runtime.test.js | 45 ++++++++++++++- 6 files changed, 126 insertions(+), 22 deletions(-) diff --git a/scripts/lib/install-lifecycle.js b/scripts/lib/install-lifecycle.js index ffbcd4901..1ef50b03c 100644 --- a/scripts/lib/install-lifecycle.js +++ b/scripts/lib/install-lifecycle.js @@ -2003,12 +2003,12 @@ function uninstallInstalledStates(options = {}) { const { isScriptBoundary, retainScriptBoundary } = require('./install/opencode-script-boundary'); // Remove owned script bodies first. Keep their CommonJS scope while any // retained or user-created JavaScript still relies on that boundary. - const orderedOperations = record.adapter.target === 'opencode' - ? [...operations.filter(operation => !isScriptBoundary(operation, record.targetRoot)), - ...operations.filter(operation => isScriptBoundary(operation, record.targetRoot))] - : operations; + const orderedOperations = [ + ...operations.filter(operation => !isScriptBoundary(operation, record.targetRoot)), + ...operations.filter(operation => isScriptBoundary(operation, record.targetRoot)), + ]; for (const operation of orderedOperations) { - if (record.adapter.target === 'opencode' && retainScriptBoundary(operation, record.targetRoot)) { + if (retainScriptBoundary(operation, record.targetRoot)) { retainedPaths.push(operation.destinationPath); continue; } diff --git a/scripts/lib/install-targets/claude-home.js b/scripts/lib/install-targets/claude-home.js index 2de35ec58..3edc41249 100644 --- a/scripts/lib/install-targets/claude-home.js +++ b/scripts/lib/install-targets/claude-home.js @@ -65,7 +65,7 @@ module.exports = createInstallTargetAdapter({ homeDir: input.homeDir, }; - return modules.flatMap(module => { + const operations = modules.flatMap(module => { const paths = Array.isArray(module.paths) ? module.paths : []; return paths .filter(p => !isForeignPlatformPath(p, adapter.target) && !adapter.excludesSourcePath(p)) @@ -96,5 +96,14 @@ module.exports = createInstallTargetAdapter({ return [adapter.createScaffoldOperation(module.id, sourceRelativePath, planningInput)]; }); }); + if (!operations.some(operation => operation.moduleId === 'commands-core' + && normalizeRelativePath(operation.sourceRelativePath).startsWith('scripts/'))) return operations; + return [...operations, createRemappedOperation( + adapter, + 'commands-core', + 'manifests/install-assets/commonjs-scripts-package.json', + path.join(adapter.resolveRoot(planningInput), 'scripts', 'package.json'), + { strategy: 'preserve-relative-path' } + )]; }, }); diff --git a/scripts/lib/install-targets/claude-project.js b/scripts/lib/install-targets/claude-project.js index afd413716..79623bcd3 100644 --- a/scripts/lib/install-targets/claude-project.js +++ b/scripts/lib/install-targets/claude-project.js @@ -64,9 +64,9 @@ module.exports = createInstallTargetAdapter({ homeDir: input.homeDir, }; - return modules.flatMap(module => { + const operations = modules.flatMap(module => { const paths = Array.isArray(module.paths) ? module.paths : []; - const operations = paths + const moduleOperations = paths .filter(p => !isForeignPlatformPath(p, 'claude')) .flatMap(sourceRelativePath => { if ( @@ -96,7 +96,7 @@ module.exports = createInstallTargetAdapter({ }); if (module.id !== 'hooks-runtime') { - return operations; + return moduleOperations; } return [ @@ -107,8 +107,19 @@ module.exports = createInstallTargetAdapter({ path.join(adapter.resolveRoot(planningInput), 'scripts', directory, 'package.json'), { strategy: 'preserve-relative-path' } )), - ...operations, + ...moduleOperations, ]; }); + if (!operations.some(operation => operation.moduleId === 'commands-core' + && normalizeRelativePath(operation.sourceRelativePath).startsWith('scripts/'))) return operations; + // Standalone command entries and their helpers need the same scope even + // when hooks are excluded. Reuse the shared boundary asset and owner. + return [...operations, createRemappedOperation( + adapter, + 'commands-core', + 'manifests/install-assets/commonjs-scripts-package.json', + path.join(adapter.resolveRoot(planningInput), 'scripts', 'package.json'), + { strategy: 'preserve-relative-path' } + )]; }, }); diff --git a/scripts/lib/install/apply.js b/scripts/lib/install/apply.js index fddd3298f..ff49ddf53 100644 --- a/scripts/lib/install/apply.js +++ b/scripts/lib/install/apply.js @@ -58,7 +58,7 @@ const { } = require('./stale-operations-reconciliation'); const { buildInstallIndex, rewriteRelativeLinks } = require('./link-rewrite'); const { transformInstallContent } = require('./content-transform'); -const { assertScriptBoundary } = require('./opencode-script-boundary'); +const { assertScriptBoundary, isScriptBoundary } = require('./opencode-script-boundary'); function isMarkdownPath(filePath) { return /\.(md|mdx|markdown)$/i.test(String(filePath || '')); @@ -297,7 +297,7 @@ function readPreviousInstallState(plan) { } function assertOpenCodeScriptBoundary(plan, writtenDestinations = new Set()) { - if (plan.adapter?.target !== 'opencode') return; + if (!plan.operations.some(operation => isScriptBoundary(operation, plan.targetRoot))) return; assertSafeInstallOperation(plan, { destinationPath: plan.installStatePath }); assertScriptBoundary(plan, { readFile: destinationPath => readInstalledFileNoFollow(plan, { destinationPath }), @@ -742,6 +742,8 @@ function previewInstallPlan(plan) { function applyInstallPlan(plan, dependencies = {}) { assertHookConsentReady(plan); + // Reject conflicts before locks can create any target directories/files. + assertOpenCodeScriptBoundary(plan); if (plan.adapter?.target === 'opencode') { assertSafeInstallOperation(plan, { destinationPath: plan.installStatePath }); return withOpenCodeInstallLocks( diff --git a/scripts/lib/install/opencode-script-boundary.js b/scripts/lib/install/opencode-script-boundary.js index 99f15e759..48dcb24dc 100644 --- a/scripts/lib/install/opencode-script-boundary.js +++ b/scripts/lib/install/opencode-script-boundary.js @@ -30,16 +30,59 @@ function scopedJavaScriptFiles(directory, { nested = false, depth = 0 } = {}) { return files; } +function assertCommonJSMetadata(content, file) { + if (content === null) { + if (fs.lstatSync(file, { throwIfNoEntry: false })) { + throw new Error(`Refusing CommonJS runtime migration: invalid user module boundary at ${file}.`); + } + return; + } + let metadata; + try { metadata = JSON.parse(content.toString('utf8')); } + catch { throw new Error(`Refusing CommonJS runtime migration: invalid user module boundary at ${file}.`); } + if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata) + || (metadata.type !== undefined && metadata.type !== 'commonjs')) { + throw new Error(`Refusing CommonJS runtime migration: preserve the existing user module boundary at ${file}.`); + } +} + +function assertSelectedScriptScopes(plan, readFile, scriptsRoot) { + const inspected = new Set(); + for (const operation of plan.operations) { + if (operation.kind !== 'copy-file' || !operation.destinationPath.endsWith('.js')) continue; + const relative = path.relative(scriptsRoot, operation.destinationPath); + if (relative.startsWith(`..${path.sep}`) || relative === '..' || path.isAbsolute(relative)) continue; + // Check every selected entry/helper, including nested library boundaries + // which the unrelated-user-script scope walk intentionally skips. + let directory = path.dirname(operation.destinationPath); + while (key(directory) !== key(scriptsRoot)) { + const file = path.join(directory, 'package.json'); + if (!inspected.has(key(file))) { + assertCommonJSMetadata(readFile(file), file); + inspected.add(key(file)); + } + directory = path.dirname(directory); + } + } +} + function assertScriptBoundary(plan, { readFile, previousOperations = [], writtenDestinations = new Set(), expectedContent }) { - if (plan.adapter?.target !== 'opencode' - || !plan.operations.some(operation => isScriptBoundary(operation, plan.targetRoot))) return; + if (!plan.operations.some(operation => isScriptBoundary(operation, plan.targetRoot))) return; const marker = markerPath(plan.targetRoot); + assertSelectedScriptScopes(plan, readFile, path.dirname(marker)); const content = readFile(marker); + assertCommonJSMetadata(content, marker); if (content !== null) { - const metadata = JSON.parse(content.toString('utf8')); - if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata) - || (metadata.type !== undefined && metadata.type !== 'commonjs')) { - throw new Error(`Refusing OpenCode CommonJS runtime migration: preserve the existing user module boundary at ${marker}.`); + const ownedMarker = previousOperations.find(operation => operation.ownership === 'managed' + && isScriptBoundary(operation, plan.targetRoot)); + // A reinstall bridge may omit digests after preflight validated the old + // receipt. At publication the fixed boundary asset still proves its bytes. + const expectedMarker = ownedMarker && !ownedMarker.contentSha256 + ? expectedContent(plan.operations.find(operation => isScriptBoundary(operation, plan.targetRoot))) : null; + const markerDigest = ownedMarker?.contentSha256 || (expectedMarker + ? crypto.createHash('sha256').update(expectedMarker).digest('hex') : null); + if (ownedMarker && crypto.createHash('sha256').update(content).digest('hex') !== markerDigest) { + throw new Error(`Refusing CommonJS runtime migration: preserve the edited user module boundary at ${marker}.`); } // Existing user-owned CommonJS metadata is skipped by the ownership guard; // retaining its bytes does not change anyone's module interpretation. @@ -58,7 +101,7 @@ function assertScriptBoundary(plan, { readFile, previousOperations = [], written const expectedDigest = expected === null ? operation?.contentSha256 : crypto.createHash('sha256').update(expected).digest('hex'); if (!expectedDigest || digest !== expectedDigest) { - throw new Error(`Refusing OpenCode CommonJS runtime migration: unowned or edited JavaScript at ${file} would change module scope. Give user scripts their own package.json scope before retrying.`); + throw new Error(`Refusing CommonJS runtime migration: unowned or edited JavaScript at ${file} would change module scope. Give user scripts their own package.json scope before retrying.`); } } } diff --git a/tests/scripts/command-runtime.test.js b/tests/scripts/command-runtime.test.js index 765f971ba..0918fd7a3 100644 --- a/tests/scripts/command-runtime.test.js +++ b/tests/scripts/command-runtime.test.js @@ -15,9 +15,10 @@ let failed = 0; function snapshot(root) { if (!fs.existsSync(root)) return {}; return Object.fromEntries(fs.readdirSync(root, { recursive: true, withFileTypes: true }) - .filter(entry => entry.isFile()) .map(entry => { const file = path.join(entry.parentPath || entry.path, entry.name); - return [path.relative(root, file), require('crypto').createHash('sha256').update(fs.readFileSync(file)).digest('hex')]; }).sort()); + const value = entry.isDirectory() ? '[directory]' : entry.isSymbolicLink() + ? `[symlink:${fs.readlinkSync(file)}]` : require('crypto').createHash('sha256').update(fs.readFileSync(file)).digest('hex'); + return [path.relative(root, file), value]; }).sort()); } function test(name, fn, target = 'claude-project') { const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-command-runtime-'))); @@ -50,9 +51,11 @@ function test(name, fn, target = 'claude-project') { ok(result); return result.stdout.trim(); }; const noPayload = () => { - for (const file of ['settings.json', '.mcp.json', 'hooks/hooks.json', 'scripts/hooks']) { + for (const file of ['.mcp.json', 'hooks/hooks.json', 'scripts/hooks']) { assert.ok(!fs.existsSync(path.join(root, file)), `Unexpected ${file}`); } + const settings = path.join(root, 'settings.json'); + if (fs.existsSync(settings)) assert.ok(!JSON.parse(fs.readFileSync(settings, 'utf8')).hooks); assert.ok(!state().operations.some(op => ['hooks-runtime', 'mcp-configs'].includes(op.moduleId))); }; try { fn({ dir, home, project, root, scripts, marker, write, install, uninstall, plan, @@ -154,4 +157,40 @@ test('publication rechecks nested helper package conflict', f => { } }), /module boundary/); assert.ok(!fs.existsSync(f.marker)); }); +test('uninstall retains owned boundary for newly added user JS until it is removed', f => { + f.write(path.join(f.project, 'package.json'), '{"type":"module"}\n'); + f.ok(f.install()); const file = path.join(f.scripts, 'user.js'); f.write(file, 'module.exports = 47;\n'); + const partial = JSON.parse(f.uninstall().stdout); + assert.strictEqual(partial.results[0].status, 'partial'); + assert.strictEqual(partial.summary.errorCount, 0); + assert.ok(partial.results[0].retainedPaths.includes(f.marker)); + assert.ok(fs.existsSync(f.marker)); assert.strictEqual(f.probe(file), '47'); + fs.unlinkSync(file); f.ok(f.uninstall()); assert.ok(!fs.existsSync(f.marker)); assert.ok(!fs.existsSync(f.statePath)); +}); +for (const scope of ['lib/package.json', 'lib/skill-evolution/package.json', 'ci/package.json']) { + test(`existing CommonJS parent still rejects incompatible selected ${scope}`, f => { + f.write(f.marker, '{"type":"commonjs","user":true}\n'); + f.write(path.join(f.scripts, scope), '{"type":"module"}\n'); const before = snapshot(f.dir); + assert.notStrictEqual(f.install().status, 0); assert.deepStrictEqual(snapshot(f.dir), before); + }); +} +test('edited owned CommonJS package bytes are preserved by rejection', f => { + f.ok(f.install()); f.write(f.marker, '{"type":"commonjs","userAdded":true}\n'); + const before = snapshot(f.dir); const result = f.install(); assert.notStrictEqual(result.status, 0); + assert.match(result.stderr + result.stdout, /edited.*module boundary/); assert.deepStrictEqual(snapshot(f.dir), before); +}); +for (const scope of ['package.json', 'lib/package.json']) { + test(`symlinked ${scope} refuses before mutation`, f => { + const file = path.join(f.scripts, scope); const outside = path.join(f.dir, 'user-package.json'); + f.write(outside, '{"type":"commonjs"}\n'); fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.symlinkSync(outside, file); const before = snapshot(f.dir); + assert.notStrictEqual(f.install().status, 0); assert.deepStrictEqual(snapshot(f.dir), before); + }); +} +for (const scope of ['package.json', 'lib/package.json']) { + test(`non-file ${scope} refuses before mutation`, f => { + fs.mkdirSync(path.join(f.scripts, scope), { recursive: true }); const before = snapshot(f.dir); + assert.notStrictEqual(f.install().status, 0); assert.deepStrictEqual(snapshot(f.dir), before); + }); +} console.log(`Passed: ${passed}`); console.log(`Failed: ${failed}`); process.exitCode = failed ? 1 : 0;