diff --git a/scripts/lib/install/opencode-script-boundary.js b/scripts/lib/install/opencode-script-boundary.js index 48dcb24dc..d5382dc24 100644 --- a/scripts/lib/install/opencode-script-boundary.js +++ b/scripts/lib/install/opencode-script-boundary.js @@ -12,7 +12,9 @@ const isScriptBoundary = (operation, root) => operation.kind === 'copy-file' // A nested package owns its own module scope. Symlinks and uncertain trees are // preserved conservatively rather than followed while deciding scope changes. -function scopedJavaScriptFiles(directory, { nested = false, depth = 0 } = {}) { +// Native .ts and transpiled JSX/TSX inherit package type too. Explicit +// .mjs/.cjs/.mts/.cts formats remain independent of this boundary. +function scopedScriptFiles(directory, { nested = false, depth = 0 } = {}) { if (depth > 40) throw new Error('OpenCode scripts tree exceeds the safe inspection depth'); const stat = fs.lstatSync(directory, { throwIfNoEntry: false }); if (!stat) return []; @@ -23,8 +25,8 @@ function scopedJavaScriptFiles(directory, { nested = false, depth = 0 } = {}) { for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { const file = path.join(directory, entry.name); if (entry.isSymbolicLink()) throw new Error(`Unsafe OpenCode scripts symlink: ${file}`); - if (entry.isDirectory()) files.push(...scopedJavaScriptFiles(file, { nested: true, depth: depth + 1 })); - else if (entry.isFile() && entry.name.toLowerCase().endsWith('.js')) files.push(file); + if (entry.isDirectory()) files.push(...scopedScriptFiles(file, { nested: true, depth: depth + 1 })); + else if (entry.isFile() && /\.(?:js|jsx|ts|tsx)$/i.test(entry.name)) files.push(file); if (files.length > 12000) throw new Error('OpenCode scripts tree exceeds the safe inspection count'); } return files; @@ -49,7 +51,7 @@ function assertCommonJSMetadata(content, 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; + if (operation.kind !== 'copy-file' || !/\.(?:js|jsx|ts|tsx)$/i.test(operation.destinationPath)) 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 @@ -92,7 +94,7 @@ function assertScriptBoundary(plan, { readFile, previousOperations = [], written .map(operation => [key(operation.destinationPath), operation])); const written = new Set([...writtenDestinations].map(key)); const planned = new Map(plan.operations.map(operation => [key(operation.destinationPath), operation])); - for (const file of scopedJavaScriptFiles(path.dirname(marker))) { + for (const file of scopedScriptFiles(path.dirname(marker))) { const operation = previous.get(key(file)); const bytes = readFile(file); const expected = written.has(key(file)) @@ -101,14 +103,14 @@ 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 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/TypeScript at ${file} would change module scope. Give user scripts their own package.json scope before retrying.`); } } } function retainScriptBoundary(operation, targetRoot) { if (!isScriptBoundary(operation, targetRoot)) return false; - try { return scopedJavaScriptFiles(path.dirname(operation.destinationPath)).length > 0; } + try { return scopedScriptFiles(path.dirname(operation.destinationPath)).length > 0; } catch { return true; } } diff --git a/tests/scripts/command-runtime.test.js b/tests/scripts/command-runtime.test.js index 0918fd7a3..dc3b6d651 100644 --- a/tests/scripts/command-runtime.test.js +++ b/tests/scripts/command-runtime.test.js @@ -100,14 +100,15 @@ for (const [scope, bytes] of [ 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 => { +for (const userFile of ['user.js', 'lib/user.js', 'lib/skill-evolution/user.js', 'ci/user.js', 'lib/utils.js', + 'user.ts', 'lib/user.ts', 'ci/user.ts', 'user.jsx', 'user.tsx']) { + test(`refuses unowned script ${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); + if (userFile.endsWith('.js')) 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'); + assert.deepStrictEqual(snapshot(f.dir), before); if (userFile.endsWith('.js')) assert.strictEqual(f.probe(file), '42'); }); } for (const bytes of ['{ "type": "commonjs", "user": true }\n', '{"user":true}\n']) { @@ -157,16 +158,18 @@ 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 => { +for (const extension of ['js', 'ts', 'jsx', 'tsx']) { +test(`uninstall retains owned boundary for newly added user ${extension} 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'); + f.ok(f.install()); const file = path.join(f.scripts, `user.${extension}`); 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'); + assert.ok(fs.existsSync(f.marker)); if (extension === 'js') 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'); @@ -193,4 +196,15 @@ for (const scope of ['package.json', 'lib/package.json']) { assert.notStrictEqual(f.install().status, 0); assert.deepStrictEqual(snapshot(f.dir), before); }); } +for (const extension of ['mjs', 'cjs', 'mts', 'cts']) { + test(`explicit user ${extension} format remains independent of installed boundary`, f => { + f.write(path.join(f.project, 'package.json'), '{"type":"module"}\n'); + const file = path.join(f.scripts, `user.${extension}`); + const bytes = extension.startsWith('m') ? 'export const value = 48;\n' : 'module.exports = 48;\n'; + f.write(file, bytes); f.ok(f.install()); f.health(); + assert.strictEqual(fs.readFileSync(file, 'utf8'), bytes); + f.ok(f.uninstall()); assert.ok(!fs.existsSync(f.marker)); + assert.strictEqual(fs.readFileSync(file, 'utf8'), bytes); + }); +} console.log(`Passed: ${passed}`); console.log(`Failed: ${failed}`); process.exitCode = failed ? 1 : 0;