diff --git a/docs/design/context-carriers.tdd.md b/docs/design/context-carriers.tdd.md index 8655fee37..76ee4c878 100644 --- a/docs/design/context-carriers.tdd.md +++ b/docs/design/context-carriers.tdd.md @@ -76,3 +76,29 @@ Lower-priority review suggestions remain explicit follow-ups: failing projection ## Claims deliberately left unobserved Native discovery, exact native exclusions, invocation, executable-mode needs, workflow outcomes, activation, hook consent, rollback, automatic routing and actual token savings still require their own gates. Schema validation checks shape; it cannot certify supplied artifact semantics. The independently compiled fixture checks exact layout, selection, file set and bytes. It uses a trusted private temporary parent and does not certify an arbitrary-destination transaction writer against hostile concurrent mutation. No native provider, model, container or VM was launched, and no package was published. + +## October 8 static runtime-closure gate + +Date: October 8, 2026. `scripts/ci/validate-carrier-closure.js` now plans every supported carrier layout for both Lean and Full and checks that each planned JavaScript file's relative `require`/`import` specifiers resolve to a file the same carrier plans. The static require-graph extraction technique is adapted from community PR #2788; that work's staged load smoke, which materializes a carrier and actually loads entry scripts from inside it, is deliberately not ported, and neither is command pruning. Both wait for carrier scope to widen beyond skills. + +RED: `tests/scripts/validate-carrier-closure.test.js` added 8 `node:test` cases; all 8 failed, seven because the validator module was absent and the eighth because the npm gate was unregistered. GREEN: all 8 pass. A second RED for absolute specifiers and package imports failed 3 cases (the absolute path was ignored, packages were ignored, warnings had no kind); GREEN passes them. The supported-layout list is derived by planning every registry target and keeping the ones that return a layout, so a new layout is picked up without editing the validator. + +Against the real registry the gate reports 5 supported layouts of 16 declared targets — `claude-plugin@1`, `codex-plugin@1`, `pi-package@1`, `opencode-project@1` and `cursor-project@1` — giving 10 projections and 2,941 planned files. 65 of those planned files are JavaScript: 13 per Full projection and none in Lean. They declare 35 relative specifiers, all 35 resolving inside their own carrier; every one is a sibling `./shared.mjs` import within `skills/ck`. The validator finds zero escapes and zero dynamic requires. + +Zero escapes is the expected result and not evidence the check is idle. Carriers are skill-only today, so no skill script has a reason to reach past its own directory. The gate exists so that the first command, agent, rule or hook admitted into carrier scope cannot silently ship a script reference that the projection does not contain. Detection is pinned by fixtures rather than by the live inventory: a planted skill script requiring `../../../../../scripts/lib/context-profile-support` fails as `outside-carrier-tree` in all five layouts and names the specifier, a planted sibling-skill reference fails as `unplanned-target`, a planted non-literal `require` warns without failing, and a `require` shape quoted inside a string literal produces neither. + +Absolute specifiers fail as `outside-carrier-tree`. A package import warns rather than fails, because carriers ship no `node_modules`; Node builtins, bare or `node:`-prefixed, are exempt. No skill script imports a package today. + +Limits. Nothing is executed; the check reads bytes and resolves paths. A package warning names the dependency but cannot tell whether the host environment provides it. Resolution assumes Node's CommonJS candidates for `require` in `.js` and `.cjs` (the path, `.js`, `.json`, then a directory's `index.js` or `index.json`), and the exact specifier for `import()`, static `import`/`from`, and anything in `.mjs`; it does not model conditional exports, a nested `package.json` type field, or import maps. A non-literal `require` is reported, not resolved, so a carrier can still fail at load time through a path computed at runtime. Native discovery, executable mode and actual loading remain unobserved, exactly as before. + +The scanner is pinned by twelve edge cases (regex versus division, regex classes and quotes, template interpolations with object braces, block comments, multi-line and re-exported `from`, and `path.join(__dirname, …)`). Across all 655 JavaScript files in the repository it found every relative specifier a plain pattern match finds outside comments and strings, with no scanner errors. The suite validates the real registry once, through the CLI, and shares that receipt between its real-registry cases: 4.8 s per run instead of 9.1 s, which matters because the test job runs in about 33 matrix cells. + +Review follow-up. A third RED added 10 cases from review: spaced calls (`require ('./x')`, `require\n(...)`, `import (...)`, and `path.join (__dirname, ...)`), escaped specifiers (`\u002e`, `\x2e`, `\u{2e}`, escaped quotes and backslashes), Windows drive, UNC and `file:` URL specifiers, and a directory resolving through `index.json`. All 10 failed. GREEN cooks string and template escapes as the parser does, allows whitespace before call parentheses, treats drive, UNC and `file:` specifiers as absolute, normalizes backslash separators in relative specifiers and adds the `index.json` candidate. A fourth RED/GREEN stops `from` and `import` patterns matching across a closed `${...}` interpolation. Rescanning all 802 repository JavaScript files against the previous scanner changed only three results, each a false package warning removed (`${from}` and similar identifiers before a template chunk). The real registry still reports 35/35 resolved, zero escapes, warnings or package imports, in 3.5 s. + +Second review follow-up. Review of that head found four ways the gate still passed a carrier that fails at run time, each reproduced before any change: an extensionless `import('./helper')` and a directory `import('./data')` resolved through the CommonJS candidates, although Node's ESM resolver does neither even from a CommonJS file; `require('./lib\\helper.js')` resolved to `lib/helper.js`, although Linux and macOS read the backslash as part of the filename; and in `if (ok) /['"]/.test(text); require('./missing')` the regex after the condition was read as division, so its quote opened a fake string that swallowed the `require`. A fifth RED added 12 cases, 9 failing: the four findings, the two regex conditions, and the `imports` shape. The three controls already passed: an exact `import()`, an extensionless `require`, and division after `a.if(x)` or a call inside a condition. GREEN records which specifiers arrive through `import` and resolves those by exact path. It reverses the earlier backslash normalization: a relative specifier containing a backslash now fails as `non-portable-separator`. It also tracks whether each closing paren ends an `if`, `while`, `for` or `with` condition. Rescanning all 802 repository JavaScript files against the previous scanner changed no result; 18 of them declare `import` specifiers. The real registry still reports 35/35 resolved and zero escapes, warnings or package imports, unchanged on every receipt count. + +Third review follow-up. Review of that head found four more problems, each reproduced first. Two were older: an extensionless `require` still offered `.cjs` and `.mjs` candidates, which Node's CommonJS search never tries; and a regex directly after a block (`if (ok) {} /"/`) was read as division, so its quote swallowed a later `require`. Two had been introduced by the previous fix. The condition check looked only at the character before `if`, so `obj.` + newline + `if(x) / 2` read as a condition and its division as a regex. Exact import resolution also turned a tagged template named `from` into a failing import. A sixth RED added 9 cases, 7 failing. Its two controls passed: `require` resolving to `.json`, and a template passed to `import()` still counting as a dependency. GREEN limits CommonJS candidates to the path, `.js`, `.json` and a directory's `index.js`/`index.json`. It treats `}` as a statement boundary before a regex, and decides a condition from the token before the keyword, ignoring whitespace. Static `from` and side-effect `import` now accept only quoted strings, never template slots. Rescanning all 802 repository JavaScript files against the previous head changed no result. The real registry is unchanged on every receipt count: 35/35 resolved, zero failures or warnings. + +Two first-round findings had stayed open and still reproduced. A member call such as `records.require('./label')` matched the call pattern, because the dot satisfies a word boundary, so a self-contained object method failed every projection. And the dynamic-argument pattern allowed only one level of nested parentheses, so `require(path.join(__dirname, getName()))` produced no warning while `require(getName())` did. A seventh RED added 3 cases, all failing. GREEN skips calls preceded by a dot, including after whitespace or optional chaining, and finds each call's argument by balanced-paren scanning over the string-free skeleton. Rescanning the 802 files changed 6 results, each a newly reported nested dynamic argument in a docker eval check or a test, none of them in a carrier. The real registry is unchanged. + +Focused verification: 22 carrier cases, 12 compiler cases, 57 closure cases, 3 CI cases and 2 publish-surface cases pass with zero failures or skips. ESLint passes on the validator and the new test. The validator is CI-only and stays out of the packed surface, matching `scripts/ci/validate-context-profiles.js`. diff --git a/docs/design/context-profile-delivery.md b/docs/design/context-profile-delivery.md index 82d6151fe..3b896f1da 100644 --- a/docs/design/context-profile-delivery.md +++ b/docs/design/context-profile-delivery.md @@ -89,3 +89,7 @@ These are attributed adaptations of concepts; contributor commits have not been The store recovers actual process exits at five durable boundaries: prepared journal, file publication, generation publication, receipt publication and state publication. An interruption before the initial ownership marker is published, or a corrupted partial kernel write, is preserved for inspection. These cases do not receive an automatic recovery claim. Small authenticated Claude pilots now provide task and token observations, but they are descriptive and the evaluation gate remains `review-required`. Adequately powered task-quality canaries and whole-context measurements need additional evidence. The opt-in interactive bootstrap has local source, discovery and terminal-start evidence, but authenticated task behavior and native skill invocation remain unobserved. Isolated Codex registration, switching, refresh and rollback have local native evidence; changing a live user installation still requires its own ownership and recovery contract. Fresh-install default changes, existing-user migration, other-provider activation, hook plans, ECC Tools compatibility, hosted rollout and package publication remain outside this local preview. + +### Static carrier dependency gate + +`npm run carrier-closure:check` parses planned JavaScript files with the pinned TypeScript development parser and checks literal module references against each carrier's planned files. It executes no carrier code. Invalid source syntax fails closed; dynamic module expressions and package imports are reported separately. Literal CommonJS and ESM resolution use their distinct path rules, including ESM file URL queries and fragments. Arbitrary loader aliases and shadowed bindings are outside this static check. A successful receipt is a static closure result, while provider-native loading and execution require their own runtime evidence. diff --git a/docs/design/context-profile-delivery.tdd.md b/docs/design/context-profile-delivery.tdd.md index 725eb94a2..0d4dd7f53 100644 --- a/docs/design/context-profile-delivery.tdd.md +++ b/docs/design/context-profile-delivery.tdd.md @@ -89,3 +89,11 @@ Current combined verification after recovery: - No real AI outcome call ran because `CODEX_API_KEY` was absent. Host ChatGPT authentication was neither copied nor exposed to the disposable evaluator. These boundaries keep the shipped behavior distinct from the M1 release gate. Authenticated outcome observations, a complete Tier 2 disk diff, live-install migration, other-provider activation, whole-context token truth and release defaults remain unverified until their explicit prerequisites are available. + +## October 10 carrier closure parser follow-up + +Independent review reproduced literal loads hidden after object-literal division and postfix increment, native `module.require` calls being skipped, and malformed source yielding a successful empty scan. The original hand-written lexer returned no dependencies for all four RED fixtures. + +The maintained parser in the existing pinned TypeScript development dependency now reads source without evaluating it. The gate traverses literal `require`, native `module.require`, dynamic `import`, static imports/re-exports, and literal `path.join(__dirname, ...)` calls. Existing regex, template, escape and nested-dynamic cases remain covered. Parse diagnostics fail the projection closed. ESM URL queries and fragments identify the same planned file; encoded separators and malformed escapes fail rather than normalize into a safe path. + +Focused validation passes 67 cases under Node 24.9.0 and Node 18.20.8. The actual CLI projects the current 304-skill registry into 10 profile/layout combinations, with 3,016 planned files, 65 JavaScript scripts and 35/35 literal relative specifiers resolved, zero escapes and zero warnings. Script execution remains deferred. Dynamic expressions are reported as warnings; arbitrary loader aliases, shadowed bindings and provider-native runtime behavior remain outside this literal-dependency gate. diff --git a/package.json b/package.json index 5b3a35594..2691cd92e 100644 --- a/package.json +++ b/package.json @@ -490,6 +490,7 @@ "welcome": "echo '\\n ecc-universal installed!\\n Run: ecc typescript\\n Compat: ecc-install typescript\\n Docs: https://github.com/affaan-m/ECC\\n Run or self-host any open-source model.\\n Compute: It\u00f4 is the preferred compute sponsor \u2014 https://compute.itomarkets.com\\n Any GPU provider works. This sponsorship link is passive: it does not invoke an RFQ, reserve capacity, provision compute, or configure serving.\\n Separately, the opt-in ecc ito find bridge invokes the explicitly configured canonical It\u00f4 CLI and submits a live authenticated RFQ; it does not reserve capacity.\\n Managed inference through It\u00f4 is not live yet.\\n'", "catalog:check": "node scripts/ci/catalog.js --text", "context-profiles:check": "node scripts/ci/validate-context-profiles.js", + "carrier-closure:check": "node scripts/ci/validate-carrier-closure.js", "catalog:sync": "node scripts/ci/catalog.js --write --text", "command-registry:generate": "node scripts/ci/generate-command-registry.js", "command-registry:write": "node scripts/ci/generate-command-registry.js --write", @@ -513,10 +514,10 @@ "orchestrate:status": "node scripts/orchestration-status.js", "orchestrate:worker": "bash scripts/orchestrate-codex-worker.sh", "orchestrate:tmux": "node scripts/orchestrate-worktrees.js", - "test:validators": "node scripts/ci/check-unicode-safety.js && node scripts/ci/validate-agents.js && node scripts/ci/validate-commands.js && node scripts/ci/validate-rules.js && node scripts/ci/validate-skills.js && node scripts/ci/validate-hooks.js && node scripts/ci/check-hooks-schema-keys.js && node scripts/ci/validate-install-manifests.js && node scripts/ci/validate-context-profiles.js && node scripts/ci/validate-no-personal-paths.js && npm run catalog:check && npm run command-registry:check", + "test:validators": "node scripts/ci/check-unicode-safety.js && node scripts/ci/validate-agents.js && node scripts/ci/validate-commands.js && node scripts/ci/validate-rules.js && node scripts/ci/validate-skills.js && node scripts/ci/validate-hooks.js && node scripts/ci/check-hooks-schema-keys.js && node scripts/ci/validate-install-manifests.js && node scripts/ci/validate-context-profiles.js && node scripts/ci/validate-carrier-closure.js && node scripts/ci/validate-no-personal-paths.js && npm run catalog:check && npm run command-registry:check", "test:js": "node tests/run-all.js", "test:python": "python3 -m pytest tests/ -q", - "test": "node scripts/ci/check-unicode-safety.js && node scripts/ci/validate-agents.js && node scripts/ci/validate-commands.js && node scripts/ci/validate-rules.js && node scripts/ci/validate-skills.js && node scripts/ci/validate-hooks.js && node scripts/ci/check-hooks-schema-keys.js && node scripts/ci/validate-install-manifests.js && node scripts/ci/validate-context-profiles.js && node scripts/ci/validate-no-personal-paths.js && npm run catalog:check && npm run command-registry:check && node tests/run-all.js", + "test": "node scripts/ci/check-unicode-safety.js && node scripts/ci/validate-agents.js && node scripts/ci/validate-commands.js && node scripts/ci/validate-rules.js && node scripts/ci/validate-skills.js && node scripts/ci/validate-hooks.js && node scripts/ci/check-hooks-schema-keys.js && node scripts/ci/validate-install-manifests.js && node scripts/ci/validate-context-profiles.js && node scripts/ci/validate-carrier-closure.js && node scripts/ci/validate-no-personal-paths.js && npm run catalog:check && npm run command-registry:check && node tests/run-all.js", "coverage:js": "c8 --all --include=\"scripts/**/*.js\" --include=\"scripts/**/*.mjs\" --check-coverage --lines 80 --functions 80 --branches 80 --statements 80 --reporter=text --reporter=lcov node tests/run-all.js", "coverage:python": "python3 -m pytest tests/ -q --cov=src --cov-report=term --cov-fail-under=80", "coverage": "c8 --all --include=\"scripts/**/*.js\" --include=\"scripts/**/*.mjs\" --check-coverage --lines 80 --functions 80 --branches 80 --statements 80 --reporter=text --reporter=lcov node tests/run-all.js", diff --git a/scripts/ci/validate-carrier-closure.js b/scripts/ci/validate-carrier-closure.js new file mode 100644 index 000000000..3195a8416 --- /dev/null +++ b/scripts/ci/validate-carrier-closure.js @@ -0,0 +1,228 @@ +#!/usr/bin/env node +/** + * Carrier runtime-closure validator. + * + * Carriers are read-only file projections. Nothing here executes a planned + * script: literal require/import/export dependencies are parsed statically and checked + * against the planned file set of the carrier it would ship in. + * + * The static require-graph extraction technique is adapted from community + * PR #2788 (affaan-m/ECC). The staged load smoke from that work -- actually + * loading entry scripts from inside a materialized carrier -- is deliberately + * not ported here, and neither is command pruning. + */ + +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); +const { builtinModules } = require('node:module'); +const { planContextCarrier } = require('../lib/context-carriers'); +const { loadContextRegistry } = require('../lib/context-pack-registry'); +const { DEFAULT_REPO_ROOT } = require('../lib/context-profile-support'); + +const PROFILES = Object.freeze(['lean@1', 'full@1']); +const BUILTINS = new Set(builtinModules); +const SCRIPT_EXTENSIONS = new Set(['.js', '.cjs', '.mjs']); +const ts = require('typescript'); + +function isAbsoluteSpecifier(value) { + return value.startsWith('/') || value.startsWith('\\\\') || /^[A-Za-z]:[\\/]/.test(value) || /^file:/i.test(value); +} + +/** Parse without evaluating source, and reject incomplete or invalid syntax. */ +function scanSource(rawSource) { + const source = ts.createSourceFile('carrier.js', String(rawSource || ''), + ts.ScriptTarget.Latest, true, ts.ScriptKind.JS); + if (source.parseDiagnostics.length) { + const diagnostic = source.parseDiagnostics[0]; + const error = new SyntaxError(ts.flattenDiagnosticMessageText(diagnostic.messageText, ' ')); + error.position = diagnostic.start; + throw error; + } + return source; +} + +/** Extract literal dependencies and report dynamic expressions without execution. */ +function extractReferences(rawSource) { + const source = scanSource(rawSource); + const specifiers = new Set(), imports = new Set(), packages = new Set(), dynamic = new Set(); + const record = (value, viaImport) => { + if (value.startsWith('.') || isAbsoluteSpecifier(value)) { + specifiers.add(value); + if (viaImport) imports.add(value); + } else if (!value.startsWith('node:') && !BUILTINS.has(value.split('/')[0])) { + packages.add(value.startsWith('@') ? value.split('/').slice(0, 2).join('/') : value.split('/')[0]); + } + }; + const identifier = (node, name) => ts.isIdentifier(node) && node.text === name; + const member = (node, receiver, name) => ts.isPropertyAccessExpression(node) + && identifier(node.expression, receiver) && node.name.text === name; + const literal = node => ts.isStringLiteralLike(node) ? node.text : null; + const staticJoin = node => { + if (!ts.isCallExpression(node) || !member(node.expression, 'path', 'join') + || !node.arguments.length || !identifier(node.arguments[0], '__dirname')) return null; + const parts = node.arguments.slice(1).map(literal); + return parts.every(value => value !== null) ? `./${parts.join('/')}` : null; + }; + const visit = node => { + if ((ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && node.moduleSpecifier) { + record(node.moduleSpecifier.text, true); + } else if (ts.isCallExpression(node)) { + const viaImport = node.expression.kind === ts.SyntaxKind.ImportKeyword; + if (viaImport || identifier(node.expression, 'require') || member(node.expression, 'module', 'require')) { + const argument = node.arguments[0]; + if (argument) { + const value = literal(argument); + const joined = viaImport ? null : staticJoin(argument); + if (value !== null) record(value, viaImport); + else if (joined !== null) record(joined, false); + else dynamic.add(argument.getText(source)); + } + } + } + ts.forEachChild(node, visit); + }; + visit(source); + return { specifiers: [...specifiers], imports: [...imports], packages: [...packages], dynamic: [...dynamic] }; +} + +function candidateDestinations(destinationPath, specifier, exact) { + if (exact && !isAbsoluteSpecifier(specifier)) { + // ESM resolves file URLs: query/fragment change identity, not the file path. + // Node rejects encoded separators; never normalize them into a safe path. + if (/%2f|%5c/i.test(specifier)) return { base: specifier, candidates: null, reason: 'invalid-url-separator' }; + try { specifier = decodeURIComponent(specifier.split(/[?#]/)[0]); } + catch (_error) { return { base: specifier, candidates: null, reason: 'invalid-url-encoding' }; } + } + if (isAbsoluteSpecifier(specifier)) return { base: specifier, candidates: null, reason: 'outside-carrier-tree' }; + if (specifier.includes('\\')) return { base: specifier, candidates: null, reason: 'non-portable-separator' }; + const base = path.posix.join(path.posix.dirname(destinationPath), specifier); + if (base === '..' || base.startsWith('../')) return { base, candidates: null, reason: 'outside-carrier-tree' }; + const candidates = exact + ? [base] + : [base, `${base}.js`, `${base}.json`, `${base}/index.js`, `${base}/index.json`]; + return { base, candidates, reason: null }; +} + +function sourceReader(repoRoot) { + const cache = new Map(); + return file => { + if (file.kind === 'generated') return file.content; + if (!cache.has(file.sourcePath)) { + cache.set(file.sourcePath, fs.readFileSync(path.join(repoRoot, ...file.sourcePath.split('/')), 'utf8')); + } + return cache.get(file.sourcePath); + }; +} + +function inspectProjection(carrier, profileId, read, report) { + const planned = new Set(carrier.files.map(file => file.destinationPath)); + const scripts = carrier.files.filter(file => SCRIPT_EXTENSIONS.has(path.posix.extname(file.destinationPath))); + for (const file of scripts) { + let references; + try { references = extractReferences(read(file)); } + catch (error) { + report.failures.push({ target: carrier.target, layout: carrier.layout.id, profileId, + file: file.destinationPath, source: file.sourcePath || null, reason: 'parse-error', diagnostic: error.message }); + continue; + } + const { specifiers, imports, packages, dynamic } = references; + const exactOnly = new Set(imports); + const isModule = file.destinationPath.endsWith('.mjs'); + const location = { + target: carrier.target, layout: carrier.layout.id, profileId, + file: file.destinationPath, source: file.sourcePath || null, + }; + for (const specifier of specifiers) { + report.specifierCount += 1; + const exact = isModule || exactOnly.has(specifier); + const { base, candidates, reason } = candidateDestinations(file.destinationPath, specifier, exact); + if (!candidates) { + report.failures.push({ ...location, specifier, resolved: base, reason }); + } else if (candidates.some(candidate => planned.has(candidate))) { + report.resolvedCount += 1; + } else { + report.failures.push({ ...location, specifier, resolved: base, reason: 'unplanned-target' }); + } + } + for (const expression of dynamic) { + report.dynamicCount += 1; + report.warnings.push({ ...location, kind: 'dynamic', expression }); + } + for (const name of packages) { + report.packageCount += 1; + report.warnings.push({ ...location, kind: 'package', package: name }); + } + } + report.plannedFileCount += carrier.files.length; + report.scriptCount += scripts.length; +} + +function validate(repoRoot = DEFAULT_REPO_ROOT) { + const registry = loadContextRegistry({ repoRoot }); + const read = sourceReader(repoRoot); + const report = { + plannedFileCount: 0, scriptCount: 0, specifierCount: 0, resolvedCount: 0, dynamicCount: 0, packageCount: 0, + failures: [], warnings: [], + }; + const layouts = []; + const unsupportedTargets = []; + + for (const target of registry.targets) { + let supported = null; + for (const profileId of PROFILES) { + const carrier = planContextCarrier({ repoRoot, profileId, target }); + if (!carrier.layout) break; + supported = { target, layout: carrier.layout.id, skillRoot: carrier.layout.skillRoot }; + inspectProjection(carrier, profileId, read, report); + } + if (supported) layouts.push(supported); else unsupportedTargets.push(target); + } + + return { + schemaVersion: 'ecc.carrier-closure.v1', + status: report.failures.length ? 'failure' : 'success', + profiles: [...PROFILES], layouts, unsupportedTargets, + projectionCount: layouts.length * PROFILES.length, + registryDigest: registry.registryDigest, + loadSmoke: 'deferred', + limitations: ['Dynamic dependency expressions and arbitrary aliases are warnings or outside this literal-dependency gate.', + 'Parsing does not execute scripts or certify provider-native runtime behavior.'], + lineage: 'static require-graph technique adapted from community PR #2788', + ...report, + }; +} + +function uniqueLines(entries, render) { + return [...new Set(entries.map(render))]; +} + +function main(args = process.argv.slice(2)) { + try { + for (const arg of args) { + if (arg !== '--json') throw new Error(`Unknown argument: ${arg}`); + } + const result = validate(); + if (args.includes('--json')) { + console.log(JSON.stringify(result, null, 2)); + } else { + for (const line of uniqueLines(result.warnings, w => (w.kind === 'package' + ? `Package import, not shipped in carriers: ${w.file} -> ${w.package}` + : `Dynamic require, not resolvable statically: ${w.file} -> ${w.expression}`))) { + console.warn(line); + } + for (const line of uniqueLines(result.failures, f => `${f.layout} ${f.profileId}: ${f.file} -> ${f.specifier} (${f.reason})`)) { + console.error(line); + } + console.log(`Carrier closure ${result.status}: ${result.layouts.length} layouts x ${result.profiles.length} profiles = ${result.projectionCount} projections, ${result.plannedFileCount} planned files, ${result.scriptCount} scripts, ${result.resolvedCount}/${result.specifierCount} relative specifiers resolved, ${result.failures.length} escapes, ${result.dynamicCount} dynamic requires, ${result.packageCount} package imports. Load smoke: deferred.`); + } + return result.status === 'success' ? 0 : 1; + } catch (error) { + console.error(`Carrier closure validation failed: ${error.message}`); + return 1; + } +} + +if (require.main === module) process.exitCode = main(); +module.exports = { extractReferences, main, scanSource, validate }; diff --git a/tests/scripts/validate-carrier-closure.test.js b/tests/scripts/validate-carrier-closure.test.js new file mode 100644 index 000000000..8624efa5b --- /dev/null +++ b/tests/scripts/validate-carrier-closure.test.js @@ -0,0 +1,312 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const path = require('node:path'); +const childProcess = require('node:child_process'); +const test = require('node:test'); +const { withFixture, write } = require('../lib/helpers/context-fixture'); + +const REPO_ROOT = path.resolve(__dirname, '../..'); +const SCRIPT_PATH = path.join(REPO_ROOT, 'scripts/ci/validate-carrier-closure.js'); + +function validator() { + return require(SCRIPT_PATH); +} + +let realRun = null; +function realRegistryRun() { + if (!realRun) { + const result = childProcess.spawnSync(process.execPath, [SCRIPT_PATH, '--json'], { + cwd: REPO_ROOT, encoding: 'utf8', timeout: 120_000, + }); + realRun = { status: result.status, stderr: result.stderr, receipt: JSON.parse(result.stdout) }; + } + return realRun; +} + +function plantSkillScript(root, source) { + write(root, 'skills/ecc-guide/scripts/tool.js', source); +} + +function specifiersOf(failures) { + return failures.map(failure => failure.specifier); +} + +test('a planted require that leaves the carrier tree fails with its specifier', () => withFixture(root => { + const escape = '../../../../../scripts/lib/context-profile-support'; + plantSkillScript(root, `'use strict';\n\nconst support = require('${escape}');\n\nmodule.exports = { support };\n`); + const result = validator().validate(root); + assert.equal(result.status, 'failure'); + assert.ok(specifiersOf(result.failures).includes(escape)); + assert.ok(result.failures.some(failure => failure.reason === 'outside-carrier-tree')); + assert.ok(result.failures.every(failure => failure.file.endsWith('skills/ecc-guide/scripts/tool.js'))); +})); + +test('a relative require that stays inside the tree but is unplanned fails', () => withFixture(root => { + plantSkillScript(root, "'use strict';\n\nrequire('../../shared/helper');\n"); + const result = validator().validate(root); + assert.equal(result.status, 'failure'); + assert.ok(specifiersOf(result.failures).includes('../../shared/helper')); + assert.ok(result.failures.some(failure => failure.reason === 'unplanned-target')); +})); + +test('a dynamic require warns without failing', () => withFixture(root => { + plantSkillScript(root, "'use strict';\n\nmodule.exports = require(process.env.ECC_PLUGIN_PATH);\n"); + const result = validator().validate(root); + assert.equal(result.status, 'success'); + assert.deepEqual(result.failures, []); + assert.ok(result.warnings.length > 0); + assert.ok(result.warnings.every(warning => warning.kind === 'dynamic' && warning.expression === 'process.env.ECC_PLUGIN_PATH')); + assert.ok(result.warnings.every(warning => warning.file.endsWith('skills/ecc-guide/scripts/tool.js'))); + assert.equal(result.dynamicCount, result.warnings.length); +})); + +test('an absolute require leaves the carrier tree and fails', () => withFixture(root => { + plantSkillScript(root, "'use strict';\n\nrequire('/opt/shared/helper');\n"); + const result = validator().validate(root); + assert.equal(result.status, 'failure'); + assert.ok(specifiersOf(result.failures).includes('/opt/shared/helper')); + assert.ok(result.failures.every(failure => failure.reason === 'outside-carrier-tree')); +})); + +for (const specifier of ['C:/shared/helper', 'C:\\shared\\helper', '\\\\server\\share\\helper', 'file:///opt/helper.js']) { + test(`an absolute Windows or file URL require fails: ${specifier}`, () => withFixture(root => { + plantSkillScript(root, `'use strict';\n\nrequire(${JSON.stringify(specifier)});\n`); + const result = validator().validate(root); + assert.equal(result.status, 'failure'); + assert.ok(result.failures.length > 0); + assert.ok(result.failures.every(failure => failure.reason === 'outside-carrier-tree')); + assert.deepEqual(result.warnings, []); + })); +} + +test('a directory require resolves through index.json', () => withFixture(root => { + write(root, 'skills/ecc-guide/scripts/data/index.json', '{}\n'); + plantSkillScript(root, "'use strict';\n\nmodule.exports = require('./data');\n"); + const result = validator().validate(root); + assert.equal(result.status, 'success', JSON.stringify(result.failures)); +})); + +// Node resolves import() and static imports by exact path: no extension +// search and no directory index, even when the calling file is CommonJS. +for (const [label, planned, source] of [ + ['an extensionless import()', 'helper.js', "module.exports = import('./helper');\n"], + ['a directory import() through index.json', 'data/index.json', "module.exports = import('./data');\n"], + ['a directory import() through index.js', 'data/index.js', "module.exports = import('./data');\n"], + ['an extensionless static import', 'helper.js', "import helper from './helper';\nexport default helper;\n"], +]) { + test(`${label} fails even when the require form would resolve`, () => withFixture(root => { + write(root, `skills/ecc-guide/scripts/${planned}`, planned.endsWith('.json') ? '{}\n' : 'module.exports = 1;\n'); + plantSkillScript(root, source); + const result = validator().validate(root); + assert.equal(result.status, 'failure'); + assert.ok(result.failures.every(failure => failure.reason === 'unplanned-target')); + })); +} + +test('an import() naming the planned file exactly resolves', () => withFixture(root => { + write(root, 'skills/ecc-guide/scripts/helper.js', 'module.exports = 1;\n'); + plantSkillScript(root, "module.exports = import('./helper.js');\n"); + const result = validator().validate(root); + assert.equal(result.status, 'success', JSON.stringify(result.failures)); +})); + +test('an extensionless require still resolves through the CommonJS extension search', () => withFixture(root => { + write(root, 'skills/ecc-guide/scripts/helper.js', 'module.exports = 1;\n'); + plantSkillScript(root, "module.exports = require('./helper');\n"); + const result = validator().validate(root); + assert.equal(result.status, 'success', JSON.stringify(result.failures)); +})); + +// An extensionless require searches only .js, .json and .node, then a +// directory's index; .cjs and .mjs must be named explicitly. +for (const extension of ['cjs', 'mjs']) { + test(`an extensionless require does not resolve to a .${extension} file`, () => withFixture(root => { + write(root, `skills/ecc-guide/scripts/helper.${extension}`, 'module.exports = 1;\n'); + plantSkillScript(root, "module.exports = require('./helper');\n"); + const result = validator().validate(root); + assert.equal(result.status, 'failure'); + assert.ok(result.failures.every(failure => failure.reason === 'unplanned-target')); + })); +} + +test('an extensionless require resolves to a .json file', () => withFixture(root => { + write(root, 'skills/ecc-guide/scripts/helper.json', '{}\n'); + plantSkillScript(root, "module.exports = require('./helper');\n"); + const result = validator().validate(root); + assert.equal(result.status, 'success', JSON.stringify(result.failures)); +})); + +test('an object method named require is not a module load', () => withFixture(root => { + plantSkillScript(root, "const records = { require: name => name };\nmodule.exports = records.require('./label');\n"); + const result = validator().validate(root); + assert.equal(result.status, 'success', JSON.stringify(result.failures)); + assert.deepEqual(result.warnings, []); +})); + +test('a tagged template named from is not an import', () => withFixture(root => { + plantSkillScript(root, 'const from = parts => parts[0];\nmodule.exports = from`./helper`;\n'); + const result = validator().validate(root); + assert.equal(result.status, 'success', JSON.stringify(result.failures)); + assert.deepEqual(result.warnings, []); +})); + +test('a backslash-separated relative require fails: Linux and macOS read it as one filename', () => withFixture(root => { + write(root, 'skills/ecc-guide/scripts/lib/helper.js', 'module.exports = 1;\n'); + plantSkillScript(root, "module.exports = require('./lib\\\\helper.js');\n"); + const result = validator().validate(root); + assert.equal(result.status, 'failure'); + assert.ok(specifiersOf(result.failures).includes('./lib\\helper.js')); + assert.ok(result.failures.every(failure => failure.reason === 'non-portable-separator')); +})); + +test('a package import warns because carriers ship no node_modules; builtins do not', () => withFixture(root => { + plantSkillScript(root, "'use strict';\n\nconst yaml = require('js-yaml');\nconst { x } = require('@scope/pkg/deep');\n" + + "const fs = require('node:fs');\nconst path = require('path');\nmodule.exports = { yaml, x, fs, path };\n"); + const result = validator().validate(root); + assert.equal(result.status, 'success'); + const packages = result.warnings.filter(warning => warning.kind === 'package').map(warning => warning.package); + assert.deepEqual([...new Set(packages)].sort(), ['@scope/pkg', 'js-yaml']); + assert.equal(result.packageCount, packages.length); +})); + +test('a require shape quoted inside a string literal is not a dependency', () => withFixture(root => { + plantSkillScript(root, "'use strict';\n\nconst snippet = \"require('../../../../../etc/passwd')\";\n\nmodule.exports = { snippet };\n"); + const result = validator().validate(root); + assert.equal(result.status, 'success'); + assert.deepEqual(result.warnings, []); +})); + +test('--json emits a machine-readable receipt and exits zero on a clean registry', () => { + const { status, stderr, receipt } = realRegistryRun(); + assert.equal(status, 0, stderr); + assert.equal(receipt.schemaVersion, 'ecc.carrier-closure.v1'); + assert.equal(receipt.projectionCount, receipt.layouts.length * receipt.profiles.length); + assert.ok(Array.isArray(receipt.warnings)); + assert.ok(Number.isInteger(receipt.scriptCount)); + assert.equal(receipt.loadSmoke, 'deferred'); +}); + +test('the real registry closes: every planned script resolves inside its carrier', () => { + const { receipt } = realRegistryRun(); + assert.equal(receipt.status, 'success'); + assert.deepEqual(receipt.failures, []); + assert.equal(receipt.layouts.length, 5); + assert.deepEqual(receipt.profiles, ['lean@1', 'full@1']); + assert.ok(receipt.scriptCount > 0, 'expected planned JavaScript files to inspect'); + assert.ok(receipt.resolvedCount > 0, 'expected at least one resolved relative specifier'); + assert.ok(receipt.plannedFileCount > receipt.scriptCount); +}); + +const SCANNER_CASES = [ + ['division before a require', "const r = a / b; require('./after-division');", ['./after-division']], + ['regex holding a quote', "const re = /it's/; require('./after-regex');", ['./after-regex']], + ['regex holding a slash in a class', "const re = /[/]/g; require('./after-class');", ['./after-class']], + ['regex after return', "function f() { return /x/.test(y); } require('./after-return');", ['./after-return']], + ['division after a closing paren', "const v = (a) / 2 / 3; require('./after-paren');", ['./after-paren']], + ['object braces inside an interpolation', "const s = `a ${ {k: 1}.k } b`; require('./after-template');", ['./after-template']], + ['require text inside a template', "const s = `require('./fake')`;", []], + ['require inside an interpolation', "const s = `${require('./in-interpolation')}`;", ['./in-interpolation']], + ['block comment', "/* require('./commented') */ require('./real');", ['./real']], + ['multi-line import', "import {\n a,\n b\n} from\n './multi';", ['./multi']], + ['re-export', 'export * from "./reexport.js";', ['./reexport.js']], + ['path.join from __dirname', "require(path.join(__dirname, '..', 'lib', 'x.js'));", ['./../lib/x.js']], + ['whitespace before the call paren', "require ('./spaced'); require\n('./newline'); import ('./spaced-import');", + ['./spaced', './newline', './spaced-import']], + ['whitespace inside path.join from __dirname', "require (path.join (__dirname, 'lib', 'y.js'));", ['./lib/y.js']], + ['unicode escape in a specifier', "require('\\u002e./escaped');", ['../escaped']], + ['hex and braced escapes in a specifier', "require('\\x2e/a'); require(`\\u{2e}/b`);", ['./a', './b']], + ['escaped quote and backslash', "require('./it\\'s'); require('.\\\\win');", ["./it's", '.\\win']], + ['regex after an if condition', "if (ok) /['\"]/.test(text); require('./missing');", ['./missing']], + ['regex after a while condition', "while (more) /\"/.test(s); require('./after-while');", ['./after-while']], + ['division after a method named if', "const v = a.if(x) / 2, q = \"/\"; require('./after-method');", ['./after-method']], + ['division after a call inside a condition', "if (f(a) / 2 > 1) { q = \"/\"; } require('./after-nested');", ['./after-nested']], + ['regex after a block', "if (ok) {} /\"/.test(text); require('./after-block');", ['./after-block']], + ['division after a method named if across a line break', + "const n = obj.\nif(x) / 2, q = \"/\"; require('./after-spaced-method');", ['./after-spaced-method']], + ['division after an optional-chained method named while', + "const n = obj?. while(x) / 2, q = \"/\"; require('./after-optional');", ['./after-optional']], + ['a tagged template named from', 'const from = p => p[0]; const s = from`./helper`;', []], + ['a template import() is still a dependency', 'import(`./templated`);', ['./templated']], + ['member calls named require or import', + "r.require('./label'); obj?.import('./x'); obj . require('./y'); obj.\nimport('./z'); require('./real');", ['./real']], +]; + +test('scanner: computed arguments nested more than one level deep still warn', () => { + const { dynamic } = validator().extractReferences( + "require(path.join(__dirname, getName())); require(a(b(c()))); obj.require(hidden());"); + assert.deepEqual(dynamic.sort(), ['a(b(c()))', 'path.join(__dirname, getName())']); +}); + +for (const [label, source, expected] of SCANNER_CASES) { + test(`scanner: ${label}`, () => { + assert.deepEqual(validator().extractReferences(source).specifiers.sort(), [...expected].sort()); + }); +} + +test('scanner: an identifier named from before a template chunk is not an import', () => { + assert.deepEqual(validator().extractReferences('const s = `${from} is shipped as ${to}`;'), + { specifiers: [], imports: [], packages: [], dynamic: [] }); +}); + +test('scanner: import() and static imports are reported apart from require', () => { + const { specifiers, imports } = validator().extractReferences( + "require('./a'); import('./b'); import c from './c'; import './d'; require(path.join(__dirname, 'e.js'));"); + assert.deepEqual(specifiers.sort(), ['./a', './b', './c', './d', './e.js']); + assert.deepEqual(imports.sort(), ['./b', './c', './d']); +}); + +test('unknown flags are rejected', () => { + const result = childProcess.spawnSync(process.execPath, [SCRIPT_PATH, '--write'], { + cwd: REPO_ROOT, encoding: 'utf8', timeout: 120_000, + }); + assert.equal(result.status, 1); + assert.match(result.stderr, /Unknown argument/); +}); + +test('the gate is registered in the normal test workflow', () => { + const { scripts } = require('../../package.json'); + assert.equal(scripts['carrier-closure:check'], 'node scripts/ci/validate-carrier-closure.js'); + assert.ok(scripts.test.includes('validate-carrier-closure.js')); +}); + +for (const [label, source] of [ + ['division after an object literal', "const ratio = {} / 2; require('./missing');"], + ['division after a postfix increment', "let i = 1; i++ / 2; require('./missing');"], + ['the native module.require entrypoint', "module.require('./missing');"], +]) { + test(`parser: ${label} retains its literal dependency`, () => withFixture(root => { + plantSkillScript(root, source); + const result = validator().validate(root); + assert.equal(result.status, 'failure'); + assert.ok(result.failures.some(failure => failure.specifier === './missing')); + })); +} + +test('parser: malformed carrier source fails instead of producing an empty success', () => withFixture(root => { + plantSkillScript(root, 'const = ;'); + const result = validator().validate(root); + assert.equal(result.status, 'failure'); + assert.ok(result.failures.every(failure => failure.reason === 'parse-error')); +})); + +test('parser: import options preserve the literal module dependency', () => { + assert.deepEqual(validator().extractReferences("import('./x.json', { with: { type: 'json' } });").imports, ['./x.json']); +}); + +for (const [specifier, file] of [['./helper.js?version=1#tag', 'helper.js'], ['./with%20space.js', 'with space.js']]) { + test(`ESM URL path resolves a planned file: ${specifier}`, () => withFixture(root => { + write(root, `skills/ecc-guide/scripts/${file}`, 'module.exports = 1;'); + plantSkillScript(root, `import(${JSON.stringify(specifier)});`); + assert.equal(validator().validate(root).status, 'success'); + })); +} + +for (const specifier of ['./lib%2fhelper.js', './lib%5chelper.js', './invalid%zz.js']) { + test(`ESM invalid URL encoding fails: ${specifier}`, () => withFixture(root => { + plantSkillScript(root, `import(${JSON.stringify(specifier)});`); + const result = validator().validate(root); + assert.equal(result.status, 'failure'); + assert.ok(result.failures.every(failure => failure.reason.startsWith('invalid-url-'))); + })); +}