diff --git a/docs/cli.md b/docs/cli.md index 32b15ba9b1..bff60650c4 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -504,6 +504,12 @@ identity after relocation while preserving original source diagnostics in full output and visible summary outline entries. Other substantive limitations still participate in the graph commitment. +An unreadable child directory remains a partial inventory entry. Directory-scoped +ignore policy is required before enumerating a directory that can be opened; +an unreadable or invalid policy still fails the import. Project negations cannot +override defaults or caller exclusions; caller patterns retain their own ordered +negations. Configured secret exclusions remain final. + `--outline-depth` requires `--detail summary` and accepts a nonnegative safe integer. Depth 1 shows root children, depth 2 (the default) also shows their children, and depth 0 omits all outline entries. Paths retain canonical Unicode @@ -585,13 +591,16 @@ base are proven: this includes the exact `createRequire(new URL("./file.cjs", import.meta.url))` form and `require.resolve` with `{ paths: [__dirname] }`. Exact optional-call forms such as `require.resolve?.("./dep.cjs")` use the same loader and mutation facts. The -nearest inventoried package manifest supplies `.js` module scope; an explicit -`type: "module"` supplies no CommonJS wrapper, while an unreadable, excluded or -malformed manifest, or package scope outside the selected inventory, leaves -candidate loader resolution unknown. A nearest manifest without a `type` field -uses the shared parser's JavaScript module-syntax facts. Other custom bases and search -options remain partial with unknown resolution and the literal requested -specifier. Overwritten resolver methods retain that uncertainty; they are not +nearest inventoried package manifest supplies `.js`/`.ts` package facts to the +shared parser; explicit filename modes take precedence. A known ES module has no +implicit CommonJS wrapper, including when a factory is derived from an unbound +`require`. An unreadable, excluded or malformed manifest, or package scope outside +the selected inventory, leaves otherwise unproven loader resolution unknown with +the requested specifier and a reason. A nearest manifest without a `type` field +uses the shared parser's JavaScript module-syntax facts. Unresolved TypeScript, +JSX compilation and declaration contexts do not establish runtime wrapper bases. +Other custom bases and search options remain partial with unknown resolution and +the literal requested specifier. Overwritten resolver methods retain that uncertainty; they are not attributed to Node. `require.main` is a module object, so calling it does not establish a loader reference. These relationships do not establish execution; Node's [`require.resolve`](https://nodejs.org/docs/v24.22.1/api/modules.html#requireresolverequest-options) diff --git a/docs/javascript-application-workflows.md b/docs/javascript-application-workflows.md index 21bd72ca62..776ac8bc61 100644 --- a/docs/javascript-application-workflows.md +++ b/docs/javascript-application-workflows.md @@ -347,6 +347,11 @@ remain `changed` while retaining any separate relationship uncertainty. An unknown item with no observed content, property, or relationship difference does not emit a `changed_from` edge; its pairing and uncertainty remain in the item. +Within a valid `file_occurrence`, `container_sha256` records provenance and does +not by itself count as a semantic property change. The graph retains the original +occurrence; its path and source digest still participate in comparison. +Unrecognized occurrence metadata remains part of property comparison. + One-sided absence is `added` or `removed` only when the opposite input graph has complete coverage. With partial, unavailable, or truncated input, the same condition is `unknown`. The comparison retains every classified item and diff --git a/src/application/ReferenceSourceImportEntries.test.ts b/src/application/ReferenceSourceImportEntries.test.ts index decbe02a98..d0c50b64d2 100644 --- a/src/application/ReferenceSourceImportEntries.test.ts +++ b/src/application/ReferenceSourceImportEntries.test.ts @@ -162,7 +162,7 @@ it("resolves declared package mains and index fallback from source-owned fields" 'require("./pkg");\nrequire("./dep");\nrequire("./fallback");\n', ], [ - "main.ts", + "main.cts", 'import dep = require("./typed");\n' + 'require("./typed");\n' + 'import data = require("./json-package");\n' + @@ -239,28 +239,28 @@ it("resolves declared package mains and index fallback from source-owned fields" parse_state: "parsed", }); expect(parsed.relationships).toContainEqual({ - from_path: "main.ts", + from_path: "main.cts", to: "typed/index.ts", kind: "requires", resolution: "internal", parse_state: "parsed", }); expect(parsed.relationships).toContainEqual({ - from_path: "main.ts", + from_path: "main.cts", to: "typed.js", kind: "requires", resolution: "internal", parse_state: "parsed", }); expect(parsed.relationships).toContainEqual({ - from_path: "main.ts", + from_path: "main.cts", to: "json-package/data.json", kind: "requires", resolution: "internal", parse_state: "parsed", }); expect(parsed.relationships).toContainEqual({ - from_path: "main.ts", + from_path: "main.cts", to: "emitted.ts", kind: "requires", resolution: "internal", diff --git a/src/application/ReferenceSourceImportEntries.ts b/src/application/ReferenceSourceImportEntries.ts index 60be947dfc..61d09534f3 100644 --- a/src/application/ReferenceSourceImportEntries.ts +++ b/src/application/ReferenceSourceImportEntries.ts @@ -2,7 +2,7 @@ import { err, ok, type Result } from "../domain/result.js"; import { createHash } from "node:crypto"; import { posix } from "node:path"; -import type { ReferenceSourcePackageType } from "../domain/referenceSourceRequireBindings.js"; +import type { JavaScriptPackageType } from "../domain/javascript/javascriptSourceParser.js"; import { decodeJavaScriptModuleUrlPath } from "../domain/javascript/javascriptModuleSpecifier.js"; import { logicalPathEscapesRoot } from "../domain/artifactIdentity.js"; import { @@ -113,7 +113,7 @@ type PackageMain = interface PackageMetadata { readonly main: PackageMain; - readonly type: ReferenceSourcePackageType; + readonly type: JavaScriptPackageType; } const UNKNOWN_PACKAGE: PackageMetadata = { main: { state: "unknown" }, @@ -409,7 +409,7 @@ type ReadFile = Extract; const packageTypeForFile = ( path: string, context: SpecifierContext, -): ReferenceSourcePackageType => { +): JavaScriptPackageType => { let directory = posix.dirname(path); while (true) { const manifest = packageMainForDirectory(directory, context); diff --git a/src/application/ReferenceSourceImportPolicy.ts b/src/application/ReferenceSourceImportPolicy.ts index 704a01a4d2..b4adfd2737 100644 --- a/src/application/ReferenceSourceImportPolicy.ts +++ b/src/application/ReferenceSourceImportPolicy.ts @@ -289,7 +289,9 @@ const buildIgnored = async ( ignored: { test: (path) => { const mandatoryMatch = mandatory.test(path); - if (mandatoryMatch.ignored) return mandatoryMatch; + // Caller negations override project rules just as caller exclusions do. + if (mandatoryMatch.ignored || mandatoryMatch.unignored) + return mandatoryMatch; let result = mandatoryMatch; const pieces = path.replace(/\/$/u, "").split("/"); for (let depth = 0; depth < pieces.length; depth += 1) { diff --git a/src/domain/javascript/javascriptSemanticAnalysis.dataflow.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.dataflow.test.ts index 342353396d..6ec889bff4 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.dataflow.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.dataflow.test.ts @@ -347,7 +347,7 @@ describe("JavaScript semantic analysis: dataflow 2", () => { }); }); - it("fails closed on assignment ambiguity and alias cycles", () => { + it("preserves initializer uncertainty, assignment ambiguity and alias cycles", () => { const ambiguous = analyzeJavaScriptSemantics(` let current = "first"; current = "second"; @@ -358,7 +358,8 @@ describe("JavaScript semantic analysis: dataflow 2", () => { status: "ambiguous", }); expect(topLevelBinding(ambiguous, "left").value).toMatchObject({ - status: "cycle", + status: "unknown", + reason: "Binding right has no proven initializer at this read.", }); expect(topLevelBinding(ambiguous, "right").provenance).toMatchObject({ status: "cycle", diff --git a/src/domain/javascript/javascriptSemanticRequireOrigin.ts b/src/domain/javascript/javascriptSemanticRequireOrigin.ts index afc8bfb41f..0d7ffd3dba 100644 --- a/src/domain/javascript/javascriptSemanticRequireOrigin.ts +++ b/src/domain/javascript/javascriptSemanticRequireOrigin.ts @@ -10,6 +10,7 @@ import * as t from "@babel/types"; import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js"; import { BUILTIN_RESOURCE_METHODS } from "./javascriptBuiltinResourceMethods.js"; +import { javaScriptRuntimeContext } from "./javascriptSourceParser.js"; import type { JavaScriptModuleOrigin } from "./javascriptSemanticIr.js"; import { resolveSemanticBindingState, @@ -163,7 +164,11 @@ const requireLoaderValueAt = ( return "create-require"; if (loader.name !== "require") return "local"; if (binding === undefined) { - if (semanticResolutionBlocked(state, loader, "require")) return "unknown"; + if ( + javaScriptRuntimeContext(state.program) === "module" || + semanticResolutionBlocked(state, loader, "require") + ) + return "unknown"; return (state.ambientBindingWrites.get("require") ?? []).some( (write) => semanticEffectTiming(node, write, state) !== "after", ) @@ -1525,7 +1530,8 @@ export const semanticRequireOrigin = ( !isUnmodifiedCommonJsWrapperBinding(current.callee, "require", state) && !isWrapperRequireInitializerCall(requireBinding, current, state)) || (requireBinding === undefined && - semanticResolutionBlocked(state, current.callee, "require")) || + (javaScriptRuntimeContext(state.program) === "module" || + semanticResolutionBlocked(state, current.callee, "require"))) || (state.ambientBindingWrites.get("require") ?? []).some( (write) => !semanticTopLevelEffectFollowsCapture( diff --git a/src/domain/javascript/javascriptSemanticState.ts b/src/domain/javascript/javascriptSemanticState.ts index ce536d5489..ae999625e5 100644 --- a/src/domain/javascript/javascriptSemanticState.ts +++ b/src/domain/javascript/javascriptSemanticState.ts @@ -602,16 +602,13 @@ export const semanticBindingHasPriorValueWrite = ( ); }; -/** Program `var` redeclarations retain wrapper identity until a source write. */ +/** Explicit wrapper facts and program `var` redeclarations retain their lexical identity. */ export const commonJsWrapperBindingDeclaration = ( node: t.Node, name: string, state: JavaScriptSemanticAnalysisState, ): JavaScriptSemanticBindingState | undefined => { - // Parser sourceType separates explicit modules from script/CommonJS source; - // specifier resolution mode does not establish that distinction. if ( - state.program.sourceType !== "script" || !["module", "exports", "require", "__dirname", "__filename"].includes( name, ) || @@ -621,9 +618,17 @@ export const commonJsWrapperBindingDeclaration = ( const binding = resolveSemanticBindingState(state, node, name); if (binding === undefined) return undefined; const scope = state.scopesById.get(binding.scopeId); - return scope?.kind === "program" && - binding.declarationKinds.size > 0 && - [...binding.declarationKinds].every((kind) => kind === "var") + if ( + scope?.kind !== "program" || + ![...binding.declarationKinds].every((kind) => kind === "var") + ) + return undefined; + // The index owns explicit wrapper parameters, including ones with no source + // redeclaration. Babel's module classification does not invalidate that fact. + if (binding.hostOrigin === "commonjs-wrapper") return binding; + // Without an explicit host fact, preserve the legacy script-var inference. + return state.program.sourceType === "script" && + binding.declarationKinds.size > 0 ? binding : undefined; }; diff --git a/src/domain/javascript/javascriptSourceParser.ts b/src/domain/javascript/javascriptSourceParser.ts index e71f671a82..70c66b9a82 100644 --- a/src/domain/javascript/javascriptSourceParser.ts +++ b/src/domain/javascript/javascriptSourceParser.ts @@ -15,6 +15,13 @@ export type ParsedJavaScriptSource = ReturnType; // Babel's module classification includes erased TypeScript syntax. Keep the // runtime proof separate, including explicit filename mode, for every parser entry. type JavaScriptRuntimeContext = "module" | "commonjs" | "unknown"; +/** Package scope observed inside a source inventory; missing scope is unknown. */ +export type JavaScriptPackageType = + | "module" + | "commonjs" + | "unspecified" + | "unknown"; + const runtimeContextByProgram = new WeakMap< t.Program, JavaScriptRuntimeContext @@ -53,6 +60,7 @@ const parseWithRuntimeContext = ( source: string, sourcePath: string | undefined, options: ParserOptions, + packageContext?: JavaScriptPackageType, ): ParsedJavaScriptSource => { const file = parse(source, options); runtimeContextByProgram.set( @@ -63,13 +71,19 @@ const parseWithRuntimeContext = ( : "commonjs" : isExplicitModuleSourcePath(sourcePath) ? "module" - : file.program.body.some((node) => t.isTSExportAssignment(node)) - ? // CommonJS isolation does not establish strict ESM semantics. - "commonjs" - : file.program.sourceType === "module" && - hasRuntimeModuleDeclaration(file.program) - ? "module" - : "unknown", + : packageContext === "module" || packageContext === "commonjs" + ? packageContext + : packageContext === "unspecified" && /\.js$/iu.test(sourcePath ?? "") + ? file.program.sourceType === "module" + ? "module" + : "commonjs" + : file.program.body.some((node) => t.isTSExportAssignment(node)) + ? // CommonJS isolation does not establish strict ESM semantics. + "commonjs" + : file.program.sourceType === "module" && + hasRuntimeModuleDeclaration(file.program) + ? "module" + : "unknown", ); return file; }; @@ -141,8 +155,25 @@ export const parseRecoveredJavaScript = ( source: string, sourcePath: string | undefined, options: Omit, + observedPackageType?: JavaScriptPackageType, ): RecoveredJavaScriptParse => { - const sourceType = runtimeSourceTypeForPath(sourcePath); + const filenameType = runtimeSourceTypeForPath(sourcePath); + // Node package facts apply to .js/.ts sources, not explicit suffixes, + // declaration wrappers, or an unknown JSX compilation pipeline. + const packageContext = + /\.(?:js|ts)$/iu.test(sourcePath ?? "") && + !/\.d\.ts$/iu.test(sourcePath ?? "") + ? observedPackageType + : undefined; + // Retain TypeScript import syntax for later CommonJS compilation. + const sourceType = + filenameType !== "unambiguous" + ? filenameType + : packageContext === "module" + ? "module" + : packageContext === "commonjs" && /\.js$/iu.test(sourcePath ?? "") + ? "commonjs" + : "unambiguous"; if (sourceType !== "unambiguous") { const normalizedOptions = { ...options }; if (sourceType === "commonjs") { @@ -153,32 +184,50 @@ export const parseRecoveredJavaScript = ( delete normalizedOptions.allowNewTargetOutsideFunction; } return { - file: parseWithRuntimeContext(source, sourcePath, { - ...normalizedOptions, - sourceType, - errorRecovery: true, - }), + file: parseWithRuntimeContext( + source, + sourcePath, + { + ...normalizedOptions, + sourceType, + errorRecovery: true, + }, + packageContext, + ), moduleOnlyErrors: [], }; } - const file = parseWithRuntimeContext(source, sourcePath, { - ...options, - sourceType: "unambiguous", - errorRecovery: true, - }); + const file = parseWithRuntimeContext( + source, + sourcePath, + { + ...options, + sourceType: "unambiguous", + errorRecovery: true, + }, + packageContext, + ); if (file.program.sourceType !== "script" || file.errors.length === 0) return { file, moduleOnlyErrors: [] }; - const script = parseWithRuntimeContext(source, sourcePath, { - ...options, - sourceType: "script", - errorRecovery: true, - }); + const script = parseWithRuntimeContext( + source, + sourcePath, + { + ...options, + sourceType: "script", + errorRecovery: true, + }, + packageContext, + ); const scriptErrors = new Set(script.errors.map((error) => error.message)); return { file: script, - moduleOnlyErrors: file.errors - .map((error) => error.message) - .filter((message) => !scriptErrors.has(message)), + moduleOnlyErrors: + javaScriptRuntimeContext(script.program) === "commonjs" + ? [] + : file.errors + .map((error) => error.message) + .filter((message) => !scriptErrors.has(message)), }; }; diff --git a/src/domain/javascript/javascriptSourceParserContext.test.ts b/src/domain/javascript/javascriptSourceParserContext.test.ts new file mode 100644 index 0000000000..985d4b798f --- /dev/null +++ b/src/domain/javascript/javascriptSourceParserContext.test.ts @@ -0,0 +1,124 @@ +import { expect, it } from "vitest"; +import * as t from "@babel/types"; + +import { + analyzeParsedJavaScriptSemantics, + indexJavaScriptBindingsWithReceiversSteps, +} from "./javascriptSemanticAnalysis.js"; +import { + semanticRequireLoaderAt, + semanticRequireOrigin, +} from "./javascriptSemanticRequireOrigin.js"; +import { + completeSemanticSteps, + traverseJavaScriptAst, +} from "./javascriptSemanticTraversal.js"; +import { referenceSourceRequireCalls } from "../referenceSourceRequireBindings.js"; + +import { + javaScriptRuntimeContext, + parseRecoveredJavaScript, + parserPluginsForPath, +} from "./javascriptSourceParser.js"; + +it.each([ + ["entry.cjs", "module", "commonjs"], + ["entry.cts", "module", "commonjs"], + ["entry.mjs", "commonjs", "module"], + ["entry.mts", "commonjs", "module"], + ["entry.d.cts", "module", "unknown"], + ["entry.d.ts", "commonjs", "unknown"], + ["entry.d.mts", "commonjs", "module"], + ["entry.jsx", "commonjs", "unknown"], + ["entry.tsx", "commonjs", "unknown"], + ["entry.js", "module", "module"], + ["entry.ts", "commonjs", "commonjs"], + ["entry.js", "unspecified", "commonjs"], + ["entry.ts", "unspecified", "unknown"], + ["entry.js", "unknown", "unknown"], +] as const)( + "preserves parser-owned %s context over an observed %s package type", + (path, observed, expected) => { + const parsed = parseRecoveredJavaScript( + "", + path, + { + plugins: parserPluginsForPath(path), + }, + observed, + ); + expect(parsed.file.errors).toEqual([]); + expect(parsed.moduleOnlyErrors).toEqual([]); + expect(javaScriptRuntimeContext(parsed.file.program)).toBe(expected); + }, +); + +it("does not derive factory authority from an ambient require in a proven module", () => { + const source = [ + 'const { createRequire } = require("node:module");', + 'const load = createRequire(import.meta.url); load("./dep.cjs");', + ].join("\n"); + const { file } = parseRecoveredJavaScript(source, "entry.js", {}, "module"); + expect(file.errors).toEqual([]); + expect(analyzeParsedJavaScriptSemantics(file).moduleLinks).toEqual([]); + expect([...referenceSourceRequireCalls(file, "entry.js")]).toEqual([]); +}); + +it.each([ + ["without source redeclarations", "", "", true], + ["with a var redeclaration", "var require;\n", "", true], + ["after a prior write", "require = custom;\n", "", false], + ["before a later write", "", "\nrequire = custom;", true], + ["behind a lexical declaration", "const require = custom;\n", "", false], + ["behind a nested parameter", "function shadow(require) {\n", "\n}", false], +] as const)( + "uses the seeded wrapper fact consistently %s", + (_name, prefix, suffix, expected) => { + const source = + prefix + + [ + 'const { createRequire } = require("node:module");', + 'const load = createRequire(import.meta.url); load("./dep.cjs");', + ].join("\n") + + suffix; + const { file } = parseRecoveredJavaScript(source, "entry.js", {}); + expect(file.errors).toEqual([]); + expect(file.program.sourceType).toBe("module"); + expect(javaScriptRuntimeContext(file.program)).toBe("unknown"); + const index = completeSemanticSteps( + indexJavaScriptBindingsWithReceiversSteps( + file.program, + "commonjs-wrapper", + ), + ); + completeSemanticSteps(index.indexMemberMutationFactsSteps()); + let required: t.CallExpression | undefined; + let loaded: t.CallExpression | undefined; + traverseJavaScriptAst(file.program, { + enter: (node) => { + if (!t.isCallExpression(node)) return; + if (t.isIdentifier(node.callee, { name: "require" })) required = node; + if (t.isIdentifier(node.callee, { name: "load" })) loaded = node; + }, + }); + if (required === undefined || loaded === undefined) + throw new Error("Expected loader fixture calls"); + expect( + semanticRequireOrigin(required, index.bindings)?.origin.specifier, + ).toBe(expected ? "node:module" : undefined); + expect(semanticRequireLoaderAt(loaded, index.bindings, new Set())).toBe( + expected ? "create-require" : "local", + ); + }, +); + +it("detects JavaScript module syntax within an observed unspecified package", () => { + const { file } = parseRecoveredJavaScript( + "void import.meta.url;", + "entry.js", + {}, + "unspecified", + ); + expect(file.errors).toEqual([]); + expect(javaScriptRuntimeContext(file.program)).toBe("module"); +}); diff --git a/src/domain/referenceSourceImportParsing.ts b/src/domain/referenceSourceImportParsing.ts index 3a3ad18413..6de46d7dc8 100644 --- a/src/domain/referenceSourceImportParsing.ts +++ b/src/domain/referenceSourceImportParsing.ts @@ -9,11 +9,11 @@ import { readExactJavaScriptLiteral } from "./javascript/javascriptAstValues.js" import { parseRecoveredJavaScript, parserPluginsForPath, + type JavaScriptPackageType, } from "./javascript/javascriptSourceParser.js"; import { referenceSourceRequireCalls, type ReferenceSourceRequireCall, - type ReferenceSourcePackageType, } from "./referenceSourceRequireBindings.js"; import { isRelativeJavaScriptModuleSpecifier } from "./javascript/javascriptModuleSpecifier.js"; import { @@ -198,6 +198,11 @@ const extractImports = ( if (requireCall === undefined) return; const specifier = exactStringModuleName(requireCall.arguments?.[0]); if (specifier === undefined) return; + if ( + requireCall.limitation !== undefined && + !limitations.includes(requireCall.limitation) + ) + limitations.push(requireCall.limitation); const uncertainResolution = requireCall.resolutionBase === "unknown"; appendRelationship(relationships, limitations, { fromPath: from_path, @@ -220,16 +225,13 @@ const extractImports = ( const parseWithBabel = ( path: string, source: string, - language: string | null, + packageType: JavaScriptPackageType | undefined, ): { ast: File | undefined; reasons: readonly string[]; moduleOnlyErrors: readonly string[]; } => { - // Single parser-mode owner: path-driven plugins (dts/JSX/mts) shared with - // the semantic pipeline. `language` only gates non-JS/TS callers upstream; - // TypeScript syntax must parse identically here and in semantic analysis. - void language; + // The shared parser owns filename precedence and observed package context. const plugins = parserPluginsForPath(path); try { @@ -242,6 +244,7 @@ const parseWithBabel = ( allowUndeclaredExports: true, plugins, }, + packageType, ); return { ast, @@ -262,7 +265,7 @@ export const parseReferenceSourceImports = ( path: string, bytes: Uint8Array, language: string | null, - packageType?: ReferenceSourcePackageType, + packageType?: JavaScriptPackageType, ): ReferenceSourceImportParseResult => { if (language !== null && !PARSEABLE_REFERENCE_SOURCE_LANGUAGES.has(language)) return { relationships: [], parse_failures: [], limitations: [] }; @@ -287,7 +290,7 @@ export const parseReferenceSourceImports = ( const { ast, reasons, moduleOnlyErrors } = parseWithBabel( path, source, - language, + packageType, ); const limitations = moduleOnlyErrors.length === 0 @@ -315,7 +318,7 @@ export const parseReferenceSourceImports = ( path, relationships, limitations, - referenceSourceRequireCalls(ast, path, packageType), + referenceSourceRequireCalls(ast, path), ); return { diff --git a/src/domain/referenceSourceRequireBindings.ts b/src/domain/referenceSourceRequireBindings.ts index 08679f0875..b9ce67f493 100644 --- a/src/domain/referenceSourceRequireBindings.ts +++ b/src/domain/referenceSourceRequireBindings.ts @@ -18,18 +18,10 @@ import { traverseJavaScriptAst, } from "./javascript/javascriptSemanticTraversal.js"; import { - isExplicitModuleSourcePath, javaScriptRuntimeContext, type ParsedJavaScriptSource, } from "./javascript/javascriptSourceParser.js"; -/** Package scope observed inside the selected inventory; missing scope stays unknown. */ -export type ReferenceSourcePackageType = - | "module" - | "commonjs" - | "unspecified" - | "unknown"; - export type ReferenceSourceRequireResolutionBase = | "source-file" | { readonly kind: "source-relative-file"; readonly path: string } @@ -38,24 +30,24 @@ export type ReferenceSourceRequireResolutionBase = export interface ReferenceSourceRequireCall { readonly arguments: JavaScriptRequireCallFact["arguments"]; readonly resolutionBase: ReferenceSourceRequireResolutionBase; + readonly limitation?: string; } /** Identify exact calls through a source-owned Node loader and their resolver base. */ export const referenceSourceRequireCalls = ( file: Pick, path: string, - packageType: ReferenceSourcePackageType = "unknown", ): ReadonlyMap< t.CallExpression | t.OptionalCallExpression, ReferenceSourceRequireCall > => { // Unmarked paths depend on package scope or compiler options. Preserve their // candidate CommonJS edges; explicit module paths have no wrapper loader. - const wrapperMode = commonJsWrapperMode(path, file.program, packageType); + const runtimeContext = javaScriptRuntimeContext(file.program); const index = completeSemanticSteps( indexJavaScriptBindingsWithReceiversSteps( file.program, - wrapperMode === "absent" ? undefined : "commonjs-wrapper", + runtimeContext === "module" ? undefined : "commonjs-wrapper", ), ); const factoryBindings: ReadonlySet = new Set(); @@ -64,6 +56,7 @@ export const referenceSourceRequireCalls = ( readonly identifier: t.Identifier; readonly arguments: JavaScriptRequireCallFact["arguments"]; readonly resolutionBase: ReferenceSourceRequireResolutionBase; + readonly limitation?: string; }[] = []; traverseJavaScriptAst(file.program, { enter: (node) => { @@ -85,7 +78,6 @@ export const referenceSourceRequireCalls = ( ); if (callFact === undefined || callFact.loader === "local") return; if (fact !== "create-require" && fact !== "commonjs-loader") return; - if (fact === "commonjs-loader" && wrapperMode === "absent") return; const sourceRelativePath = callFact.sourceRelativePath; const normalizedSourceRelativePath = sourceRelativePath === undefined @@ -94,38 +86,45 @@ export const referenceSourceRequireCalls = ( posix.join(posix.dirname(path), sourceRelativePath), ); const base: ReferenceSourceRequireResolutionBase = - callFact.loader === "create-require" - ? sourceRelativePath === undefined - ? callFact.sourceRelative === true - ? "source-file" - : "unknown" - : normalizedSourceRelativePath === undefined || - normalizedSourceRelativePath === ".." || - normalizedSourceRelativePath.startsWith("../") - ? "unknown" - : normalizedSourceRelativePath === path + runtimeContext === "unknown" + ? "unknown" + : callFact.loader === "create-require" + ? sourceRelativePath === undefined + ? callFact.sourceRelative === true ? "source-file" - : { - kind: "source-relative-file", - path: normalizedSourceRelativePath, - } - : fact === "commonjs-loader" && callFact.loader === "commonjs-loader" - ? path.endsWith(".js") && wrapperMode === "unknown" - ? "unknown" - : "source-file" - : "unknown"; + : "unknown" + : normalizedSourceRelativePath === undefined || + normalizedSourceRelativePath === ".." || + normalizedSourceRelativePath.startsWith("../") + ? "unknown" + : normalizedSourceRelativePath === path + ? "source-file" + : { + kind: "source-relative-file", + path: normalizedSourceRelativePath, + } + : fact === "commonjs-loader" && + callFact.loader === "commonjs-loader" && + runtimeContext === "commonjs" + ? "source-file" + : "unknown"; const searchBase = requireResolveSearchBase( callFact.operation, callFact.arguments, index.bindings, base, - wrapperMode === "present", + runtimeContext === "commonjs", ); candidates.push({ call: node, identifier, arguments: callFact.arguments, resolutionBase: searchBase, + ...(runtimeContext === "unknown" + ? { + limitation: `Module context is unknown in ${path}; the requested dependency was retained without a proven loader base.`, + } + : {}), }); }, }); @@ -133,11 +132,18 @@ export const referenceSourceRequireCalls = ( completeSemanticSteps(index.indexMemberMutationFactsSteps()); return new Map( candidates.map( - ({ call, identifier, arguments: callArguments, resolutionBase }) => + ({ + call, + identifier, + arguments: callArguments, + resolutionBase, + limitation, + }) => [ call, { arguments: callArguments, + ...(limitation === undefined ? {} : { limitation }), resolutionBase: !t.isIdentifier(call.callee) && hasPriorMemberMutation(index, call, identifier) @@ -179,25 +185,6 @@ const requireResolveSearchBase = ( : "unknown"; }; -type CommonJsWrapperMode = "present" | "absent" | "unknown"; - -const commonJsWrapperMode = ( - path: string, - program: t.Program, - packageType: ReferenceSourcePackageType, -): CommonJsWrapperMode => { - if (path.endsWith(".cjs")) return "present"; - if (isExplicitModuleSourcePath(path)) return "absent"; - if (path.endsWith(".js")) { - if (packageType === "module") return "absent"; - if (packageType === "commonjs") return "present"; - if (packageType === "unknown") return "unknown"; - if (javaScriptRuntimeContext(program) === "module") return "absent"; - return "present"; - } - return "unknown"; -}; - const hasPriorMemberMutation = ( index: JavaScriptReceiverIndex, site: t.CallExpression | t.OptionalCallExpression, diff --git a/src/reference/ReferenceSourceReaderEntries.ts b/src/reference/ReferenceSourceReaderEntries.ts index 40bdd668e4..bfa70db9e6 100644 --- a/src/reference/ReferenceSourceReaderEntries.ts +++ b/src/reference/ReferenceSourceReaderEntries.ts @@ -76,11 +76,6 @@ export const traverseDirectory = async ( ); return { ok: true, value: undefined }; } - const policy = await state.beforeDirectory?.( - pathFromRoot(state.root, current.path), - ); - if (policy !== undefined && !policy.ok) return policy; - if (isAborted(state.signal)) return err(cancelled()); const directories: PendingDirectory[] = []; let readFailure: FilesystemFailureDetail | undefined; let unrepresentableNameCount = 0; @@ -101,7 +96,15 @@ export const traverseDirectory = async ( let result: ReferenceSourceResult = ok(undefined); let cleanup: ArtifactCleanupAttempt; try { - for (;;) { + // A directory that cannot be opened retains its ordinary partial-entry + // failure; only an opened directory needs policy before enumeration. + result = isAborted(state.signal) + ? err(cancelled()) + : ((await state.beforeDirectory?.( + pathFromRoot(state.root, current.path), + )) ?? ok(undefined)); + if (result.ok && isAborted(state.signal)) result = err(cancelled()); + while (result.ok) { let child: Dirent | null; try { child = await handle.read(); @@ -123,7 +126,6 @@ export const traverseDirectory = async ( continue; } result = await processEntry(state, current, decoded.name, directories); - if (!result.ok) break; } } finally { cleanup = await state.resources.release({ diff --git a/src/reference/ReferenceSourceReaderTypes.ts b/src/reference/ReferenceSourceReaderTypes.ts index 8038a5c8f3..6899259842 100644 --- a/src/reference/ReferenceSourceReaderTypes.ts +++ b/src/reference/ReferenceSourceReaderTypes.ts @@ -18,7 +18,7 @@ export type ReferenceSourceExclusionKind = ReferenceSourceEntryKind | "unknown"; export interface ReferenceSourceReaderOptions { readonly signal?: AbortSignal; - /** Prepare directory-scoped policy after directory identity admission, before enumeration. */ + /** Prepare policy after identity admission and opening, before enumeration; refusal closes the owned handle. */ readonly beforeDirectory?: ( path: string, ) => Promise>; @@ -101,7 +101,7 @@ export type TraversalState = { readonly root: string; readonly rootIdentity: BigIntStats; readonly signal?: AbortSignal; - /** Prepare directory-scoped policy after directory identity admission, before enumeration. */ + /** Prepare policy after identity admission and opening, before enumeration; refusal closes the owned handle. */ readonly beforeDirectory?: ( path: string, ) => Promise>; diff --git a/tests/boundary/cli/referenceSourceDirectoryImports.test.ts b/tests/boundary/cli/referenceSourceDirectoryImports.test.ts index 491722c68b..cb2395a15a 100644 --- a/tests/boundary/cli/referenceSourceDirectoryImports.test.ts +++ b/tests/boundary/cli/referenceSourceDirectoryImports.test.ts @@ -49,10 +49,10 @@ const prepareReferenceSourceDirectoryFixture = ["json-main.cjs", "./jpkg", "jpkg/config.json"], ] as const satisfies readonly ResolutionCase[]; const unresolved = [ - ["missing.cjs", "./missing/", "missing/"], - ["escape.cjs", "../outside/", "../outside/"], - ["main.ts", "./folder.js/.", "folder.js"], - ["only-bare.cjs", "./only/", "only/"], + ["missing.cjs", "./missing/", "missing"], + ["escape.cjs", "../outside/", "../outside"], + ["main.cts", "./folder.js/.", "folder.js"], + ["only-bare.cjs", "./only/", "only"], ["invalid-manifest.cjs", "./broken", "broken"], ["absolute-main.cjs", "./absolute", outside], ] as const satisfies readonly ResolutionCase[]; @@ -127,7 +127,7 @@ const assertNodeResolutionControls = ( createRequire(join(root, "missing.cjs")).resolve("./missing/"), ).toThrow(); expect(() => - createRequire(join(root, "main.ts")).resolve("./folder.js/."), + createRequire(join(root, "main.cts")).resolve("./folder.js/."), ).toThrow(); expect(() => createRequire(join(root, "only-bare.cjs")).resolve("./only/"), diff --git a/tests/boundary/cli/referenceSourceRuntimeAuthority.test.ts b/tests/boundary/cli/referenceSourceRuntimeAuthority.test.ts new file mode 100644 index 0000000000..ad0ea36961 --- /dev/null +++ b/tests/boundary/cli/referenceSourceRuntimeAuthority.test.ts @@ -0,0 +1,222 @@ +import { createHash } from "node:crypto"; +import { mkdir, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; + +import { expect } from "vitest"; + +import { historicalSourceGraphSchema } from "../../../src/domain/referenceSourceGraph.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest, type TestCli } from "../../support/cli/cliFixture.js"; + +const writeSources = async (root: string, sources: Record) => { + await Promise.all( + Object.entries(sources).map(async ([path, source]) => { + await mkdir(dirname(join(root, path)), { recursive: true }); + await writeFile(join(root, path), source); + }), + ); +}; + +const importGraph = async (cli: TestCli, root: string) => { + const config = await createTestTempDirectory( + "rea-reference-authority-config-", + ); + const result = await cli.run({ + arguments: ["import-reference-source", root, "--json"], + environment: { + HOME: config, + XDG_CONFIG_HOME: config, + XDG_CACHE_HOME: config, + }, + }); + expect(result.exitCode, result.stderr + JSON.stringify(result.json)).toBe(0); + return historicalSourceGraphSchema.parse(result.json); +}; + +cliTest( + "shares runtime authority without promoting unresolved source contexts", + async ({ cli }) => { + const root = await createTestTempDirectory( + "rea-reference-runtime-authority-", + ); + const request = 'require("./dep.cjs");\n'; + const derived = [ + 'const { createRequire } = require("node:module");', + 'const load = createRequire(import.meta.url); load("./dep.cjs");', + ].join("\n"); + const sources = { + "package.json": '{"type":"module"}\n', + ".gitignore": "excluded/package.json\n", + "main.js": request, + "main.ts": request, + "derived.js": derived, + "derived.mjs": derived, + "main.cjs": request, + "main.cts": request, + "dep.cjs": "module.exports = 1;\n", + "untyped/package.json": "{}\n", + "untyped/main.js": request, + "untyped/dep.cjs": "module.exports = 2;\n", + "untyped/syntax.js": request + "export {};\n", + "untyped/erased.ts": 'import type { Stats } from "node:fs";\n' + request, + "unknown/package.json": "{\n", + "unknown/main.ts": request, + "unknown/main.jsx": request, + "unknown/main.tsx": request, + "unknown/factory.js": derived, + "unknown/dep.cjs": "module.exports = 3;\n", + "excluded/package.json": '{"type":"commonjs"}\n', + "excluded/main.js": request, + "excluded/dep.cjs": "module.exports = 4;\n", + "factory.mjs": [ + 'import { createRequire } from "node:module";', + 'const load = createRequire(import.meta.url); load("./dep.cjs");', + ].join("\n"), + }; + await writeSources(root, sources); + const graph = await importGraph(cli, root); + expect(graph.parse_failures).toEqual([]); + const requires = graph.relationships.filter( + ({ kind }) => kind === "requires", + ); + for (const [from_path, to] of [ + ["main.cjs", "dep.cjs"], + ["main.cts", "dep.cjs"], + ["untyped/main.js", "untyped/dep.cjs"], + ["factory.mjs", "dep.cjs"], + ]) + expect(requires).toContainEqual({ + from_path, + to, + kind: "requires", + resolution: "internal", + parse_state: "parsed", + }); + for (const from_path of [ + "untyped/erased.ts", + "unknown/main.ts", + "unknown/main.jsx", + "unknown/main.tsx", + "unknown/factory.js", + "excluded/main.js", + ]) { + expect(requires).toContainEqual({ + from_path, + to: "./dep.cjs", + kind: "requires", + resolution: "unknown", + parse_state: "partial", + }); + expect(graph.entries).toContainEqual( + expect.objectContaining({ + path: from_path, + limitations: [expect.stringContaining("Module context is unknown")], + }), + ); + } + expect(requires).toContainEqual({ + from_path: "unknown/factory.js", + to: "node:module", + kind: "requires", + resolution: "unknown", + parse_state: "partial", + }); + expect(requires).toHaveLength(11); + for (const path of [ + "main.js", + "main.ts", + "derived.js", + "derived.mjs", + "untyped/syntax.js", + ]) + expect(requires.filter(({ from_path }) => from_path === path)).toEqual( + [], + ); + expect(graph.entries).toContainEqual( + expect.objectContaining({ + path: "derived.js", + sha256: createHash("sha256").update(derived).digest("hex"), + content_state: "hashed", + }), + ); + }, +); + +cliTest( + "preserves owned CommonJS factories through erased TypeScript syntax", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-reference-ts-factory-"); + const source = [ + 'import type { Stats } from "node:fs";', + 'const { createRequire } = require("node:module");', + 'const load = createRequire(__filename); load("./dep.cjs");', + ].join("\n"); + await writeSources(root, { + "package.json": '{"type":"module"}\n', + "main.cts": source, + "dep.cjs": "module.exports = 1;\n", + "cjs/package.json": '{"type":"commonjs"}\n', + "cjs/main.ts": source, + "cjs/dep.cjs": "module.exports = 2;\n", + }); + const graph = await importGraph(cli, root); + expect(graph.parse_failures).toEqual([]); + expect(graph.relationships).toHaveLength(6); + for (const from_path of ["main.cts", "cjs/main.ts"]) { + expect(graph.relationships).toContainEqual({ + from_path, + to: "node:fs", + kind: "imports", + resolution: "external", + parse_state: "parsed", + }); + expect(graph.relationships).toContainEqual({ + from_path, + to: "node:module", + kind: "requires", + resolution: "external", + parse_state: "parsed", + }); + // __filename remains outside the supported createRequire base forms. + expect(graph.relationships).toContainEqual({ + from_path, + to: "./dep.cjs", + kind: "requires", + resolution: "unknown", + parse_state: "partial", + }); + } + }, +); + +cliTest( + "retains unknown bases when the selected root omits its package scope", + async ({ cli }) => { + const parent = await createTestTempDirectory( + "rea-reference-unseen-authority-", + ); + await writeSources(parent, { + "package.json": '{"type":"module"}\n', + "selected/main.js": 'require("./dep.cjs");\n', + "selected/dep.cjs": "module.exports = 1;\n", + }); + const graph = await importGraph(cli, join(parent, "selected")); + expect(graph.parse_failures).toEqual([]); + expect(graph.relationships).toEqual([ + { + from_path: "main.js", + to: "./dep.cjs", + kind: "requires", + resolution: "unknown", + parse_state: "partial", + }, + ]); + expect(graph.entries.map(({ path }) => path)).toEqual([ + "dep.cjs", + "main.js", + ]); + expect(graph.limitations).toContainEqual( + expect.stringContaining("Module context is unknown"), + ); + }, +); diff --git a/tests/boundary/filesystem/referenceSourceCleanupOwnership.test.ts b/tests/boundary/filesystem/referenceSourceCleanupOwnership.test.ts index e471bceafe..e4c3079969 100644 --- a/tests/boundary/filesystem/referenceSourceCleanupOwnership.test.ts +++ b/tests/boundary/filesystem/referenceSourceCleanupOwnership.test.ts @@ -13,6 +13,7 @@ import { ArtifactResourceScope } from "../../../src/artifacts/ArtifactResourceSc import { importReferenceSource } from "../../../src/application/ReferenceSourceImport.js"; import { readReferenceSource } from "../../../src/reference/ReferenceSourceReader.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { err, ok } from "../../../src/domain/result.js"; vi.mock("node:fs/promises", async (importOriginal) => { const actual = await importOriginal(); @@ -41,6 +42,86 @@ afterEach(() => { opendirMock.mockReset(); }); +it.each([ + ["refusal", false], + ["refusal", true], + ["cancel-after-policy", false], + ["cancel-after-policy", true], + ["refusal-late-cancel", true], +] as const)( + "owns directory cleanup before policy %s (close fails=%s)", + async (outcome, closeFails) => { + const root = await createTestTempDirectory("rea-reference-policy-owner-"); + await writeFile(join(root, "unexamined.ts"), "export const value = 1;\n"); + const resources = new ArtifactResourceScope(); + onTestFinished(() => resources.close().catch(() => undefined)); + const controller = new AbortController(); + const reason = new Error("cancelled at the policy boundary"); + const policyCause = new Error("scoped policy read failed"); + const closeFailure = new Error("directory close rejected after policy"); + let closeCalls = 0; + let readCalls = 0; + opendirMock.mockImplementation(async (...args) => { + const directory = await actual.opendir(...args); + const read = directory.read.bind(directory); + vi.spyOn(directory, "read").mockImplementation(async () => { + readCalls += 1; + return read(); + }); + const close = directory.close.bind(directory); + vi.spyOn(directory, "close").mockImplementation(async () => { + closeCalls += 1; + if (outcome === "refusal-late-cancel") controller.abort(reason); + await close(); + if (closeFails) throw closeFailure; + }); + return directory; + }); + const result = await readReferenceSource(root, resources, { + signal: controller.signal, + beforeDirectory: async () => { + expect(opendirMock).toHaveBeenCalledTimes(1); + if (outcome === "cancel-after-policy") { + controller.abort(reason); + return ok(undefined); + } + return err({ + tag: "reference-source-reader", + code: "io", + message: "Scoped policy could not be read", + cause: policyCause, + }); + }, + }); + if (result.ok) throw new Error("Policy outcome was ignored"); + expect(result.error.code).toBe( + outcome === "cancel-after-policy" ? "cancelled" : "io", + ); + if (outcome !== "cancel-after-policy") { + expect(result.error.message).toBe("Scoped policy could not be read"); + expect(result.error.cause).toBe(policyCause); + } + expect(result.error.partial).toMatchObject({ entries: [], bytesRead: 0 }); + expect(readCalls).toBe(0); + expect(openMock).not.toHaveBeenCalled(); + expect(closeCalls).toBe(1); + expect(resources.hasPending).toBe(closeFails); + if (closeFails) { + expect(result.error.cleanup).toEqual({ + resources: [root], + reason: expect.stringContaining(closeFailure.message), + }); + await expect(resources.close()).rejects.toMatchObject({ + cleanup: { resources: [root] }, + }); + } else { + expect(result.error.cleanup).toBeUndefined(); + await resources.close(); + } + expect(closeCalls).toBe(1); + }, +); + it("preserves admission EIO and retains an invalidated failed-close owner", async () => { const root = await createTestTempDirectory("rea-reference-admission-owner-"); const file = join(root, "source.ts"); diff --git a/tests/boundary/filesystem/referenceSourceComputedRequire.test.ts b/tests/boundary/filesystem/referenceSourceComputedRequire.test.ts index 3ad267d563..1c1921678e 100644 --- a/tests/boundary/filesystem/referenceSourceComputedRequire.test.ts +++ b/tests/boundary/filesystem/referenceSourceComputedRequire.test.ts @@ -87,7 +87,8 @@ it("distinguishes computed require member values from literal members in source ].join("\n"), }; for (const [index, callee] of literalCallees.entries()) - sources[`member-${index}.cjs`] = `${callee}("./dep.js");\n`; + sources[`member-${String(index).padStart(2, "0")}.cjs`] = + `${callee}("./dep.js");\n`; await Promise.all( Object.entries(sources).map(([path, source]) => writeFile(join(root, path), source), @@ -137,7 +138,7 @@ it("distinguishes computed require member values from literal members in source ? [] : [ { - from_path: `member-${index}.cjs`, + from_path: `member-${String(index).padStart(2, "0")}.cjs`, to: "dep.js", kind: "requires", resolution: "internal", diff --git a/tests/boundary/filesystem/referenceSourceDynamicImports.test.ts b/tests/boundary/filesystem/referenceSourceDynamicImports.test.ts index 4ed88bb911..4958e3e2c4 100644 --- a/tests/boundary/filesystem/referenceSourceDynamicImports.test.ts +++ b/tests/boundary/filesystem/referenceSourceDynamicImports.test.ts @@ -39,10 +39,9 @@ it("resolves dynamic source imports while retaining computed targets as unknown" }); if (!result.ok) throw result.error; expect(result.value.parse_failures).toEqual([]); - expect(result.value.relationships).toHaveLength(8); + expect(result.value.relationships).toHaveLength(7); for (const [to, kind] of [ ["static.ts", "imports"], - ["common.js", "requires"], ["lazy.ts", "imports"], ["template-lazy.mjs", "imports"], ["wrapped-lazy.mjs", "imports"], diff --git a/tests/boundary/filesystem/referenceSourceIgnorePolicy.test.ts b/tests/boundary/filesystem/referenceSourceIgnorePolicy.test.ts index e9311d574b..86b11eead4 100644 --- a/tests/boundary/filesystem/referenceSourceIgnorePolicy.test.ts +++ b/tests/boundary/filesystem/referenceSourceIgnorePolicy.test.ts @@ -15,6 +15,58 @@ const importTree = async (root: string) => { return result.value; }; +it("retains caller negations across project scopes while secrets remain excluded", async () => { + const root = await createTestTempDirectory("rea-reference-caller-policy-"); + await mkdir(join(root, "nested")); + await writeFile(join(root, ".gitignore"), "*.log\n"); + await writeFile(join(root, "nested", ".gitignore"), "*.log\n"); + for (const path of ["keep.log", "nested/keep.log", "secret.log", "drop.log"]) + await writeFile(join(root, path), "caller-selected bytes\n"); + const result = await importReferenceSource({ + root, + caller: "caller-policy-test", + policy: { secretPatterns: ["secret.log"] }, + excludePaths: ["!keep.log", "!secret.log"], + }); + if (!result.ok) throw result.error; + for (const path of ["keep.log", "nested/keep.log"]) + expect(result.value.entries).toContainEqual( + expect.objectContaining({ path, kind: "file", content_state: "hashed" }), + ); + expect(result.value.exclusions).toContainEqual({ + path: "secret.log", + entry_kind: "file", + reason: "configured-secret", + pattern: "secret.log", + }); + expect(result.value.exclusions).toContainEqual({ + path: "drop.log", + entry_kind: "file", + reason: "default-ignored", + pattern: "*.log", + }); +}); + +it("keeps unreadable scoped policy fatal after opening a readable directory", async () => { + const root = await createTestTempDirectory( + "rea-reference-scoped-policy-refusal-", + ); + const child = join(root, "child"); + await mkdir(join(child, ".gitignore"), { recursive: true }); + await writeFile(join(child, "unexamined.ts"), "export const value = 1;\n"); + const result = await importReferenceSource({ + root, + caller: "scoped-policy-refusal-test", + policy: { secretPatterns: [] }, + }); + if (result.ok) throw new Error("Invalid scoped policy was ignored"); + expect(result.error.code).toBe("io"); + expect(result.error.message).toContain(join(child, ".gitignore")); + expect(result.error.partial?.entries).not.toContainEqual( + expect.objectContaining({ path: "child/unexamined.ts" }), + ); +}); + it("scopes nested ignore rules and negations like Git while preserving mandatory exclusions", async () => { const root = await createTestTempDirectory("rea-reference-scoped-policy-"); await execFileOutput("git", ["init", "--initial-branch=main", root]); diff --git a/tests/boundary/filesystem/referenceSourceRecovery.test.ts b/tests/boundary/filesystem/referenceSourceRecovery.test.ts index 3eea3ecaa8..4b11272958 100644 --- a/tests/boundary/filesystem/referenceSourceRecovery.test.ts +++ b/tests/boundary/filesystem/referenceSourceRecovery.test.ts @@ -128,13 +128,6 @@ it("preserves recovered diagnostics and file evidence through filesystem import" resolution: "unknown", parse_state: "partial", }, - { - from_path: "src/recovered.js", - to: "node:fs", - kind: "requires", - resolution: "external", - parse_state: "partial", - }, { from_path: "src/recovered.js", to: "src/lazy.js", diff --git a/tests/boundary/mcp/webModuleGrammarMcp.test.ts b/tests/boundary/mcp/webModuleGrammarMcp.test.ts new file mode 100644 index 0000000000..dbdde6cddd --- /dev/null +++ b/tests/boundary/mcp/webModuleGrammarMcp.test.ts @@ -0,0 +1,126 @@ +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect, it, onTestFinished } from "vitest"; + +import { WebModuleTraceService } from "../../../src/application/WebModuleTraceService.js"; +import { LocalWebModuleArtifacts } from "../../../src/browser/modules/WebModuleArtifacts.js"; +import { parseEvidence } from "../../../src/domain/evidence.js"; +import { + webModuleTraceResultSchema, + type WebModuleResolutionRequest, +} from "../../../src/domain/webModuleTrace.js"; +import { createServer } from "../../../src/server/createServer.js"; +import { createTestBinarySession } from "../../fixtures/binarySession.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { + webModuleArtifactsFixture, + webModuleResolverFixture, +} from "../../fixtures/webModuleTrace.js"; + +it.each([ + { + source: 'const order = left < import("./dep.js") > (right);', + expected: ["./dep.js"], + }, + { + source: + 'const fn = c ? x => import("./a.js") : y => import("./b.js"); import("./c.js");', + expected: ["./a.js", "./b.js", "./c.js"], + }, +])( + "retains JavaScript imports and requests resolution through public MCP: $source", + async ({ source, expected }) => { + const root = await createTestTempDirectory("rea-module-grammar-"); + const original = webModuleArtifactsFixture(source); + const manifest = { + ...original.manifest, + output_directory: root, + scripts: original.manifest.scripts.map((script) => ({ + ...script, + url: "https://app.test/main.ts?build=1#entry", + })), + }; + const sourcePath = join(root, "files", "modules", "main.js"); + const manifestPath = join(root, "manifest.json"); + await mkdir(join(root, "files", "modules"), { recursive: true }); + await writeFile(sourcePath, source); + const manifestText = JSON.stringify(manifest); + await writeFile(manifestPath, manifestText); + const requests: WebModuleResolutionRequest[] = []; + const service = new WebModuleTraceService(new LocalWebModuleArtifacts(), { + resolve: async (request) => { + requests.push(request); + return webModuleResolverFixture.resolve(request); + }, + }); + const session = createTestBinarySession(() => { + throw new Error( + "Retained module tracing must not acquire a binary provider", + ); + }); + const server = createServer( + { kind: "session", session }, + { webModuleTrace: service }, + ); + const client = new Client({ name: "module-grammar", version: "1" }); + onTestFinished(async () => { + await client.close(); + await server.close(); + await session.close(); + }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); + const response = await client.callTool({ + name: "trace_web_module_imports", + arguments: { manifest_path: manifestPath, script_index: 0 }, + }); + expect(response.isError, JSON.stringify(response)).not.toBe(true); + const evidence = parseEvidence(response.structuredContent); + const result = webModuleTraceResultSchema.parse(evidence.normalized_result); + expect(requests).toEqual([ + { + importerUrl: manifest.scripts[0]!.url, + importMap: null, + specifiers: expected, + }, + ]); + expect(result).toMatchObject({ + source: { + path: sourcePath, + sha256: original.sourceFile.sha256, + bytes: original.sourceFile.bytes, + }, + parser: { state: "parsed", diagnostics: [] }, + engine: { id: "fixture", version: "1" }, + }); + expect( + result.imports.map(({ kind, specifier, resolution, execution }) => ({ + kind, + specifier, + resolution, + execution, + })), + ).toEqual( + expected.map((specifier) => ({ + kind: "dynamic-import", + specifier, + resolution: { + state: "resolved", + url: new URL(specifier, manifest.scripts[0]!.url).href, + }, + execution: "unknown", + })), + ); + for (const reference of result.imports) + expect(source.slice(reference.start.offset, reference.end.offset)).toBe( + reference.expression, + ); + expect(session.evidenceById(evidence.evidence_id)).toEqual(evidence); + expect(await readFile(sourcePath, "utf8")).toBe(source); + expect(await readFile(manifestPath, "utf8")).toBe(manifestText); + await client.ping(); + }, +); diff --git a/tests/fixtures/javascriptArtifactApplication.ts b/tests/fixtures/javascriptArtifactApplication.ts index d91b0efa68..f6e380812c 100644 --- a/tests/fixtures/javascriptArtifactApplication.ts +++ b/tests/fixtures/javascriptArtifactApplication.ts @@ -27,8 +27,8 @@ export const writeJavaScriptArtifactFixture = async ( writeFile( join(root, "main.js"), ` - import { BrowserWindow } from "electron"; - import path from "node:path"; + const { BrowserWindow } = require("electron"); + const path = require("node:path"); const window = new BrowserWindow({ webPreferences: { preload: path.join(__dirname, "preload.js") } }); diff --git a/tests/fixtures/javascriptPrimitiveExpansion.ts b/tests/fixtures/javascriptPrimitiveExpansion.ts index 03f2d3c120..fa3f58a97f 100644 --- a/tests/fixtures/javascriptPrimitiveExpansion.ts +++ b/tests/fixtures/javascriptPrimitiveExpansion.ts @@ -1,6 +1,9 @@ -/** Expression whose unresolved alternatives exceed primitive candidate storage. */ +/** Independent unknown host inputs keep both conditional arms reachable. */ export const primitiveCandidateExpansionExpression = (): string => - Array.from({ length: 20 }, () => '(choice ? "a" : "b")').join(" + "); + Array.from( + { length: 20 }, + (_, index) => `(globalThis.choice${String(index)} ? "a" : "b")`, + ).join(" + "); /** Source whose repeated doubling exceeds derived primitive string storage. */ export const primitiveByteExpansionSource = (): string => {