diff --git a/docs/javascript-application-workflows.md b/docs/javascript-application-workflows.md index b22d527790..e0441b15ea 100644 --- a/docs/javascript-application-workflows.md +++ b/docs/javascript-application-workflows.md @@ -392,7 +392,10 @@ name on each authenticated graph. Missing or duplicate exact selectors return candidate inventory and an unknown result; the tool never chooses a fuzzy match. An export is linked to a callable only through exact lexical/module relationships recovered by the AST analysis. A later named CommonJS deletion -invalidates the earlier publication at the receiver owner. Conditional or +invalidates the earlier publication per exported field at the receiver owner; +surviving fields from the same assignment stay available. Descriptor-defined +fields retain unknown deletion success unless configurability is established. +Conditional or dynamic deletions retain uncertainty. Unresolved invocation spreads remain unknown source sequences and cannot publish a phantom array index. @@ -401,8 +404,10 @@ through an execution-free value lattice. Literal object fields and direct return sites are represented in the result. Calls, dynamic spreads, computed keys, and parser recovery remain partial or unknown. Known conditional and logical tests select only the established branch; uncertain tests retain -alternatives. Object copies preserve known own data fields at copy time while -unknown sources retain incomplete coverage. +alternatives. Object copies preserve known own primitive data fields at copy time while +unknown sources retain incomplete coverage. Array holes do not overwrite own +fields during a copy. Copied reference fields keep explicit uncertainty; shared +reference projection remains responsible for their later member reads. Declaration initializers and later assignments share the binding-at-read owner; a declaration-only `var` read before its initializer does not inherit the future value. Earlier `break`/`continue` paths can leave a var initializer unexecuted. @@ -483,7 +488,9 @@ escape uncertainty. External prototype changes and unsupported runtime protocols remain unknown rather than supplying exact post-invocation values. Identifier references and closure captures share Babel syntax-role facts plus explicit binding/write roles; noncomputed declaration keys and import/meta names -are not lexical reads. Direct generator and async calls produce iterators and +are not lexical reads. Provenance-only expression-depth omissions enter the same typed source coverage +as value-evaluation bounds and remain visible in retained application Evidence. +Direct generator and async calls produce iterators and Promises; body returns do not become direct call-result flows. Their unresolved consumption remains an explicit frontier. Replaced emitter bindings select their initializer lifetime at each operation. A lexical `this` owner remains partial diff --git a/src/domain/javascript/javascriptCommonJsExportReceivers.ts b/src/domain/javascript/javascriptCommonJsExportReceivers.ts index 68eb46387a..d435907246 100644 --- a/src/domain/javascript/javascriptCommonJsExportReceivers.ts +++ b/src/domain/javascript/javascriptCommonJsExportReceivers.ts @@ -51,10 +51,18 @@ const commonJsExportInvocation = ( return interop.intrinsicInvocation(node); }; -export type CommonJsExportReceiverEffect = - | "connected" - | "detached" - | "uncertain"; +type ReceiverStatus = "connected" | "detached" | "uncertain"; +export interface CommonJsExportReceiverEffect { + readonly receiver: ReceiverStatus; + readonly fields?: ReadonlyMap; +} + +/** Publication is selected per field; one assign site can publish many fields. */ +export const commonJsExportApplicability = ( + effect: CommonJsExportReceiverEffect | undefined, + name: string, +): ReceiverStatus => + effect?.fields?.get(name) ?? effect?.receiver ?? "connected"; /** A CommonJS export assignment or `Object.defineProperty` export call. */ export type CommonJsExportSite = t.AssignmentExpression | t.CallExpression; @@ -366,6 +374,7 @@ export function* indexCommonJsExportReceiversSteps( const fresh = new WeakSet([initial]); const values = new WeakMap(); const captures = new Map(); + const describedFields = new Map>(); const deletions = new Map< Identity, Map @@ -463,9 +472,20 @@ export function* indexCommonJsExportReceiversSteps( : publicIdentity, ); } - const defined = - commonJsDefinedExport(node, state) ?? - commonJsAssignedExports(node, state); + const descriptor = commonJsDefinedExport(node, state); + if (descriptor !== undefined) { + const receiver = isUnshadowedGlobal( + descriptor.receiver, + state, + "exports", + ) + ? exportsIdentity + : publicIdentity; + const names = describedFields.get(receiver) ?? new Set(); + names.add(descriptor.exportedName); + describedFields.set(receiver, names); + } + const defined = descriptor ?? commonJsAssignedExports(node, state); if (defined !== undefined && t.isCallExpression(node)) captures.set( node, @@ -513,7 +533,7 @@ export function* indexCommonJsExportReceiversSteps( const name = commonJsExportName(node.argument, state); if ( name !== undefined && - name !== "default" && + !isCommonJsModuleExports(node.argument, state) && (t.isMemberExpression(node.argument) || t.isOptionalMemberExpression(node.argument)) ) { @@ -554,37 +574,46 @@ export function* indexCommonJsExportReceiversSteps( }); const effects = new Map(); for (const [node, receiver] of captures) { - const removed = commonJsRuntimeExportValues(node, state).flatMap( - ({ exportedName }) => { - const fields = deletions.get(receiver); - return [fields?.get(exportedName), fields?.get("*")].filter( - (deletion) => - deletion !== undefined && - (deletion.mode === "deferred" || - (deletion.node.start ?? -1) > (node.start ?? -1)), - ); - }, - ); - if (removed.length > 0) { - effects.set( - node, - removed.some((deletion) => deletion?.mode !== "ordered") || - receiver === null - ? "uncertain" - : "detached", - ); - continue; - } - effects.set( - node, + const status: ReceiverStatus = deferredReplacement || receiver === null || publicIdentity === null ? "uncertain" : receiver === publicIdentity ? "connected" : fresh.has(receiver) && fresh.has(publicIdentity) ? "detached" - : "uncertain", - ); + : "uncertain"; + const fields = new Map(); + for (const { exportedName } of commonJsRuntimeExportValues(node, state)) { + const removed = [ + deletions.get(receiver)?.get(exportedName), + deletions.get(receiver)?.get("*"), + ].filter( + (deletion) => + deletion !== undefined && + (deletion.mode === "deferred" || + (deletion.node.start ?? -1) > (node.start ?? -1)), + ); + if (removed.length === 0) continue; + // An ordinary publication creates configurable data slots. Descriptor + // definitions do not establish deletion success without their flags. + const configurable = + (t.isAssignmentExpression(node) || + commonJsAssignedExports(node, state) !== undefined) && + !describedFields.get(receiver)?.has(exportedName) && + !describedFields.get(receiver)?.has("*"); + fields.set( + exportedName, + configurable && + status !== "uncertain" && + removed.every((deletion) => deletion?.mode === "ordered") + ? "detached" + : "uncertain", + ); + } + effects.set(node, { + receiver: status, + ...(fields.size === 0 ? {} : { fields }), + }); } return effects; } diff --git a/src/domain/javascript/javascriptElectronAppPathChanges.ts b/src/domain/javascript/javascriptElectronAppPathChanges.ts index ff0e655859..75d5f97437 100644 --- a/src/domain/javascript/javascriptElectronAppPathChanges.ts +++ b/src/domain/javascript/javascriptElectronAppPathChanges.ts @@ -31,6 +31,7 @@ import { assignmentValueSources } from "./javascriptSemanticRequireOrigin.js"; import { semanticRuntimeExportValueSources } from "./javascriptModuleDeclaration.js"; import { commonJsRuntimeExportValues, + commonJsExportApplicability, indexCommonJsExportReceiversSteps, type CommonJsExportReceiverEffect, type CommonJsExportSite, @@ -366,13 +367,14 @@ export const createSemanticElectronAppPathChangeQuery = ( commonJsReceivers ??= completeSemanticSteps( indexCommonJsExportReceiversSteps(state), ); - return ( - commonJsReceivers.get(node) !== "detached" && - exported.some( - ({ value, projection, ownEnumerable }) => - value !== null && - possiblePath(value, true, projection, ownEnumerable), - ) + return exported.some( + ({ exportedName, value, projection, ownEnumerable }) => + commonJsExportApplicability( + commonJsReceivers.get(node), + exportedName, + ) !== "detached" && + value !== null && + possiblePath(value, true, projection, ownEnumerable), ); }; }; diff --git a/src/domain/javascript/javascriptSemanticAnalysis.export_lifetimes.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.export_lifetimes.test.ts index ff05e29772..29c7d93a76 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.export_lifetimes.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.export_lifetimes.test.ts @@ -60,3 +60,32 @@ it("retains captured object-copy literals and known logical/conditional values", ], }); }); + +it("selects deletion applicability per published field and preserves descriptor uncertainty", () => { + const ir = analyzeJavaScriptSemantics(` + Object.assign(exports,{deleted:()=>1,intact:()=>2}); delete exports.deleted; + exports.default=()=>3; delete exports.default; + Object.defineProperty(exports,"protected",{value:()=>4,enumerable:true}); delete exports.protected; + `); + const exports = ir.moduleLinks.filter( + ({ kind }) => kind === "commonjs-export", + ); + expect(exports.map(({ exportedName }) => exportedName).sort()).toEqual([ + "intact", + "protected", + ]); + expect( + exports.find(({ exportedName }) => exportedName === "protected") + ?.callableId, + ).toBeNull(); +}); + +it("does not copy array holes over retained own object properties", () => { + const ir = analyzeJavaScriptSemantics( + `export function observe(){ const copied={0:1,...[,]}; return copied[0]; }`, + ); + expect(onlyCallable(ir, "observe").returnSites[0]?.value).toEqual({ + status: "literal", + value: 1, + }); +}); diff --git a/src/domain/javascript/javascriptSemanticAnalysis.provenance_limits.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.provenance_limits.test.ts new file mode 100644 index 0000000000..8b03bdc024 --- /dev/null +++ b/src/domain/javascript/javascriptSemanticAnalysis.provenance_limits.test.ts @@ -0,0 +1,26 @@ +import { expect, it } from "vitest"; +import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js"; +import { topLevelBinding } from "./javascriptSemanticAnalysis.fixture.js"; + +it("retains a provenance-only depth limit in canonical file coverage", () => { + const analyze = (count: number) => + analyzeJavaScriptSemantics( + `const deep=${"a=".repeat(count)}"x"; const stable=7;`, + ); + const shallow = analyze(10); + const deep = analyze(300); + expect(shallow.coverage.status).toBe("complete"); + expect(deep.coverage).toMatchObject({ + status: "partial", + omittedCount: null, + resourceLimits: ["expression-depth"], + }); + expect(topLevelBinding(deep, "deep").provenance).toMatchObject({ + status: "unknown", + reason: expect.stringContaining("depth budget"), + }); + expect(topLevelBinding(deep, "stable").value).toEqual({ + status: "literal", + value: 7, + }); +}); diff --git a/src/domain/javascript/javascriptSemanticProjection.ts b/src/domain/javascript/javascriptSemanticProjection.ts index 34b8a93b7e..b30de20651 100644 --- a/src/domain/javascript/javascriptSemanticProjection.ts +++ b/src/domain/javascript/javascriptSemanticProjection.ts @@ -40,6 +40,7 @@ import { } from "./javascriptModuleDeclaration.js"; import { commonJsRuntimeExportValues, + commonJsExportApplicability, type CommonJsExportReceiverEffect, type CommonJsExportSite, } from "./javascriptCommonJsExportReceivers.js"; @@ -468,17 +469,21 @@ const collectCommonJsExport = ( state: JavaScriptSemanticAnalysisState, receiverEffect: CommonJsExportReceiverEffect | undefined, ): void => { - if (receiverEffect === "detached") return; - // Compound and logical writes also depend on the previous value, so remain - // unknown. - for (const exported of commonJsRuntimeExportValues(node, state)) + // Compound and logical writes also depend on the previous value, so remain unknown. + for (const exported of commonJsRuntimeExportValues(node, state)) { + const applicability = commonJsExportApplicability( + receiverEffect, + exported.exportedName, + ); + if (applicability === "detached") continue; addCommonJsExport( node, state, exported.exportedName, - receiverEffect === "uncertain" ? null : exported.value, + applicability === "uncertain" ? null : exported.value, exported.projection, ); + } }; const addCommonJsExport = ( diff --git a/src/domain/javascript/javascriptSemanticValues.ts b/src/domain/javascript/javascriptSemanticValues.ts index b1c6101a21..c76f701a11 100644 --- a/src/domain/javascript/javascriptSemanticValues.ts +++ b/src/domain/javascript/javascriptSemanticValues.ts @@ -18,7 +18,10 @@ import { semanticBindingAtRead, } from "./javascriptSemanticMutationInitializers.js"; -import { semanticSlotAtPath } from "./javascriptSemanticSlots.js"; +import { + semanticSlotAtPath, + semanticContainer, +} from "./javascriptSemanticSlots.js"; import { invalidateSemanticEscapedPath, @@ -810,9 +813,10 @@ const evaluateObject = ( }; for (const property of node.properties) { if (t.isSpreadElement(property)) { - const copied = evaluateCapturedExpression( + const copied = evaluateExpression( property.argument, - nestedContext(context), + captureContextAt(property.argument, nestedContext(context)) ?? + nestedContext(context), ); if (copied.status === "object" || copied.status === "array") { const unknown = @@ -824,10 +828,19 @@ const evaluateObject = ( omittedProperties = null; invalidateEarlierProperties(); } - for (const field of copied.status === "object" - ? copied.properties - : copied.items) - propertiesByName.set(field.name, field); + for (const field of semanticContainer(copied).slots) { + if (field.presence === "absent") continue; + propertiesByName.set(field.name, { + ...field, + value: isPrimitive(field.value) + ? field.value + : { + status: "unknown", + reason: + "Copied reference fields retain live identity rather than a scalar snapshot.", + }, + }); + } } else if ( copied.status !== "literal" || typeof copied.value === "string" @@ -1205,6 +1218,7 @@ const provenanceForExpression = ( ): JavaScriptBindingProvenance => { if (context.expressionDepth > SEMANTIC_EXPRESSION_DEPTH_LIMIT) { context.dependence.lowest = EVERY_CALLER; + context.state.valueResourceLimits.add("expression-depth"); return semanticUnresolvedProvenance( "unknown", semanticResourceLimitReason("expression-depth"), @@ -1217,6 +1231,7 @@ const provenanceForExpression = ( SEMANTIC_EXPRESSION_DEPTH_LIMIT ) { context.dependence.lowest = EVERY_CALLER; + context.state.valueResourceLimits.add("expression-depth"); return semanticUnresolvedProvenance( "unknown", semanticResourceLimitReason("expression-depth"), diff --git a/tests/boundary/filesystem/javascriptExportShapeComparison.test.ts b/tests/boundary/filesystem/javascriptExportShapeComparison.test.ts index fbc86110de..3df4079624 100644 --- a/tests/boundary/filesystem/javascriptExportShapeComparison.test.ts +++ b/tests/boundary/filesystem/javascriptExportShapeComparison.test.ts @@ -1234,6 +1234,22 @@ it("preserves export lifetime and selected value facts through retained comparis }); }); +it("retains provenance-only depth omissions alongside unaffected export facts", async () => { + const graphs = await analyzeSources({ + left: `const deep=${"a=".repeat(300)}"x"; export default()=>({type:"stable",value:7});`, + right: `const deep=${"a=".repeat(10)}"x"; export default()=>({type:"stable",value:7});`, + }); + expect(graphs[0].graph.coverage).toMatchObject({ + status: "partial", + omitted_count: null, + limits: expect.arrayContaining([ + expect.objectContaining({ name: "javascript_semantic_expression_depth" }), + ]), + }); + expect(graphs[1].graph.coverage.status).toBe("complete"); + expect(compare(...graphs).changes).toEqual([]); +}); + type GraphSource = Awaited>; const compare = (