fix(javascript): preserve per-field publication and provenance coverage

This commit is contained in:
morluto
2026-10-24 03:01:43 +08:00
parent 2206822109
commit f1a9d85de5
8 changed files with 185 additions and 56 deletions
+11 -4
View File
@@ -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
@@ -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<string, ReceiverStatus>;
}
/** 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<t.Node>([initial]);
const values = new WeakMap<t.Node, Identity>();
const captures = new Map<CommonJsExportSite, Identity>();
const describedFields = new Map<Identity, Set<string>>();
const deletions = new Map<
Identity,
Map<string, { readonly node: t.Node; readonly mode: Execution }>
@@ -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<string>();
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<CommonJsExportSite, CommonJsExportReceiverEffect>();
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<string, ReceiverStatus>();
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;
}
@@ -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),
);
};
};
@@ -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,
});
});
@@ -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,
});
});
@@ -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 = (
@@ -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"),
@@ -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<ReturnType<typeof analyzeGraph>>;
const compare = (