Compare commits

..
Author SHA1 Message Date
Cursor Agent 5fb8939a28 fix(javascript): keep Object.defineProperty identified at its own call
The call that escapes Object was treated as a prior replacement of
defineProperty, so a descriptor value never replaced the exported sender
and bundled IPC paths were dropped.
2026-11-11 02:35:53 +00:00
Cursor Agent f366d21446 chore: merge main into codex/audit-javascript-semantics 2026-11-11 02:26:35 +00:00
Cursor Agent 881aec416a fix(javascript): keep source accessors known after a copied value escapes
An unknown computed getter makes accessor lookup consider every property.
A spread copy already records a value escape, and that escape was treated
as an unknown getter on the source, so the next read escaped the whole
object. Accessor lookup still fails closed for unknown writes and calls.
2026-11-11 01:18:58 +00:00
Cursor Agent f8d17481e8 test(javascript): include the request link in endpoint traces
Response consumers now also link the originating request to the response, so an endpoint trace reports both that association and the response's json consumer.
2026-11-10 23:34:45 +00:00
morluto 199b006cc3 fix(javascript): retain semantic graph evidence and coverage 2026-11-10 17:32:25 -05:00
morluto db1db1a965 fix(javascript): preserve bundler identities and helper bindings 2026-11-10 17:32:25 -05:00
morluto bf7f518e2d fix(javascript): preserve semantic facts across writes and implicit effects 2026-11-10 17:32:25 -05:00
91 changed files with 2339 additions and 2158 deletions

No files matched your search

@@ -25,7 +25,6 @@ import {
import { pageBound } from "../../domain/analysisView/environmentStatePaging.js";
import { replayReason } from "../../domain/analysisView/environmentStateVariants.js";
import { isAbsence } from "../../domain/analysisView/environmentStateWriteChecks.js";
import { updatedEntity } from "../../domain/analysisView/environmentStateWriteApply.js";
import {
evaluateQuery,
renderQueryList,
@@ -292,6 +291,16 @@ export const softDeleteFlag = (
return undefined;
};
/** Fields as an application that soft-deletes shows them once deleted: with the type's deleted flag set, when it has one. */
const deletedFields = (
serving: StateServing,
type: string,
fields: JsonObject,
): JsonObject => {
const flag = softDeleteFlag(serving, type);
return flag === undefined ? fields : { ...fields, [flag]: true };
};
/** Fields as a delete's answer shows them: with the type's deleted flag as its recorded answer `shown` gave it, if it did. */
export const deleteAnswerFields = (
serving: StateServing,
@@ -317,11 +326,9 @@ const deletedEntity = (
call,
(collection, id) => store.deleted(type, collection, id),
);
if (entity === undefined) return undefined;
const flag = softDeleteFlag(serving, type);
return flag === undefined
? entity
: updatedEntity(entity, { ...entity.fields, [flag]: true });
return entity === undefined
? undefined
: { ...entity, fields: deletedFields(serving, type, entity.fields) };
};
/** The answer for an entity the state does not hold: the tool's latest recorded 400, 404 or 410, else a tool error. */
@@ -33,44 +33,6 @@ const objectOf = (body: JsonValue | undefined): Record<string, JsonValue> => {
return body;
};
describe("served path-derived fields", () => {
it.each([false, true])(
"preserves repeated parameter occurrences (ambiguous: %s)",
(ambiguous) => {
const project = ambiguous ? 1 : 3;
const path = `/api/orgs/1/projects/${project}/tasks`;
const first = { id: 1, name: "Old", orgId: 1, projectId: project };
const made = { id: 2, name: "Recorded", orgId: 1, projectId: project };
const tasks = servedFrom([
["GET", path, [first]],
["GET", `${path}/1`, first],
["GET", "/api/orgs/2/projects/4/tasks", []],
["POST", path, made, 201, { name: "Recorded" }],
["GET", `${path}/2`, made],
["GET", path, [first, made]],
]);
const tool = "post_orgs_projects_tasks_by_id_id_2";
const args = { id: "2", id_2: "4", body: { name: "Elsewhere" } };
if (ambiguous) {
// Equal recorded values establish no choice between the path positions.
const before = tasks.snapshot();
expect(tasks.call(tool, args).ok).toBe(false);
expect(tasks.snapshot()).toBe(before);
expect(
tasks.answer(tool, { id: "1", id_2: "1", body: { name: "Here" } })
.body,
).toMatchObject({ name: "Here", orgId: 1, projectId: 1 });
} else {
expect(tasks.answer(tool, args).body).toMatchObject({
name: "Elsewhere",
orgId: 2,
projectId: 4,
});
}
},
);
});
describe("served values an application derives from others", () => {
it("stamps a created tag's clocks with trailing fraction zeros dropped, as the capture spelled them", () => {
const tags = servedFrom(TAG_ROWS);
@@ -181,47 +181,3 @@ describe("served writes modelled on later reads", () => {
});
});
});
it.each([
["item", SOFT_DELETED_ROWS, "delete_contacts_by_id", { id: "1" }],
["bulk", STALE_BULK_DELETE_ROWS, "delete_contacts", { body: [{ id: 1 }] }],
] as const)(
"invalidates the deletion flag override after a served %s delete",
(_kind, rows, tool, args) => {
const contacts = servedFrom(rows);
const snapshot = contacts.store.snapshot();
expect(
contacts.store.replace({
rea_state: {
...snapshot.rea_state,
entities: snapshot.rea_state.entities.map((entity) =>
entity.id === "1"
? {
...entity,
view_fields: [
{
tool: "get_contacts_by_id",
fields: { deleted: false, name: "Item display" },
},
],
}
: entity,
),
},
}).ok,
).toBe(true);
expect(
contacts.answer("get_contacts_by_id", { id: "1" }).body,
).toMatchObject({
deleted: false,
});
contacts.answer(tool, args);
expect(
contacts.answer("get_contacts_by_id", { id: "1" }).body,
).toMatchObject({
id: 1,
name: "Item display",
deleted: true,
});
},
);
@@ -32,7 +32,6 @@ import {
} from "../../domain/analysisView/environmentStateWriteLinks.js";
import {
pathValuesOf,
pathParameterKeys,
type GeneratedRule,
type StateWritePlan,
type WriteCall,
@@ -107,12 +106,14 @@ export const pathValues = (
args: ReplayArguments,
): Map<string, string> => {
const names = environmentToolArguments(tool).path;
const keys = pathParameterKeys(tool.path_template);
const template = tool.path_template
.slice(tool.path_template.indexOf("/") + 1)
.split("/");
const values = new Map<string, string>();
for (const param of tool.params) {
const value = ownMember(args, names.get(param.name) ?? param.name);
const key = keys.get(param.index);
if (typeof value === "string" && key !== undefined) values.set(key, value);
const segment = template[param.index] ?? "";
if (typeof value === "string") values.set(segment.slice(1, -1), value);
}
return values;
};
@@ -11,8 +11,6 @@ import {
type Book,
} from "../../../tests/fixtures/environmentStateWrites.js";
import { servedCatalog } from "./environmentState.js";
import { environmentToolAnswer } from "./environmentToolAnswer.js";
import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
const RENAMED = renamedBook(FIRST_BOOK, "Renamed Book", 3);
@@ -74,33 +72,6 @@ const toolOf = (rows: readonly TimedCaptureRow[], name: string) => {
};
describe("served write queries", () => {
it.each([
"?notify=quiet&unmodeled=yes",
"?notify=quiet&notify=quiet",
"?notify=quiet&unmodeled=yes&unmodeled=no",
])("refuses unsupported observed query %s before changing state", (query) => {
const { served } = toolOf(PINNED_ROWS, "patch_books_by_id");
const tool = served.source.manifest.tools.find(
(candidate) => candidate.name === "patch_books_by_id",
);
if (tool === undefined) throw new Error("Expected patch tool");
const before = served.snapshot();
const pending = served.store.pendingWrite();
const respond = environmentToolAnswer(served.source, served.store);
const result = respond(
tool,
{ id: "2", notify: "quiet", body: { name: "Changed" } },
query,
);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected pinned-query refusal");
expect(projectAnalysisError(result.error).message).toMatch(
/query|repeated/u,
);
expect(served.snapshot()).toBe(before);
expect(served.store.pendingWrite()).toEqual(pending);
});
it("advertises the recorded query as optional so omission reaches its default", () => {
const { served, tool } = toolOf(PINNED_ROWS, "patch_books_by_id");
expect(tool.inputSchema.properties["notify"]).toMatchObject({
@@ -1,6 +1,5 @@
import { pinnedQuery } from "../../domain/analysisView/environmentStateWriteStamps.js";
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
import { iterateUrlQueryParameters } from "../../domain/recordedTransportUrl.js";
import { err, ok, type Result } from "../../domain/result.js";
import { refused, type StateServing } from "./environmentStateAnswer.js";
import { callQuery } from "./environmentStateCallLines.js";
@@ -16,16 +15,7 @@ export const pinnedRequest = (
request: WriteRequest,
): Result<WriteRequest, AnalysisError> => {
const { tool, plan, args } = request;
const supplied = callQuery(tool, args);
const observed = new Map<string, string>();
let repeated = hasRepeatedQueryArgument(tool, args);
for (const [name, value] of iterateUrlQueryParameters(
request.observedQuery ?? "",
)) {
if (observed.has(name)) repeated = true;
observed.set(name, value);
}
if (repeated)
if (hasRepeatedQueryArgument(tool, args))
return err(
refused(
tool,
@@ -34,9 +24,7 @@ export const pinnedRequest = (
);
const query = pinnedQuery(
serving.source.exchanges.get(plan.exemplar_ordinal ?? -1)?.query,
// Comparison supplies the raw query as well as translated known arguments.
// Keep unmodeled parameters visible without replacing those translations.
new Map([...observed, ...supplied]),
callQuery(tool, args),
);
if (!query.ok) return err(refused(tool, `Tool ${tool.name} ${query.error}`));
const names = environmentToolArguments(tool).query;
@@ -10,6 +10,7 @@ import {
addSemanticGraphUnknown,
} from "./JavaScriptSemanticGraphConstruction.js";
import {
inferredSemanticEvidenceAt,
unknownSemanticEvidence,
builtinSemanticNode,
builtinSemanticRelation,
@@ -39,6 +40,7 @@ export const projectSemanticEvents = (
source: eventEmitterNode(context, operation),
target: event,
relation,
evidence: inferredSemanticEvidenceAt(context.file, operation.location),
resolution:
operation.resolution === "complete" ? "resolved" : "candidate",
});
@@ -48,6 +50,7 @@ export const projectSemanticEvents = (
source: event,
target: listener,
relation,
evidence: inferredSemanticEvidenceAt(context.file, operation.location),
resolution:
operation.resolution === "complete" ? "resolved" : "candidate",
});
@@ -480,10 +480,8 @@ export const createJavaScriptSemanticGraphProjection = (
limit,
);
}
state.fileNodeBudget = Math.min(
fileNodeCeiling,
Math.max(0, nodeCeiling - state.nodes.size),
);
const remainingTreeBudget = Math.max(0, nodeCeiling - state.nodes.size);
state.fileNodeBudget = Math.min(fileNodeCeiling, remainingTreeBudget);
const module = retainNode(state, file, {
kind: "module",
roleKey: "module",
@@ -493,6 +491,14 @@ export const createJavaScriptSemanticGraphProjection = (
});
state.fileNodeBudget = null;
if (module !== null) state.roots.add(module.node_id);
else {
truncatedFiles += 1;
noteSemanticCeilingReached(
nodeCeilingsReached,
remainingTreeBudget,
fileNodeCeiling,
);
}
const evidence = {
...observedSemanticEvidence(file, null),
authority: "unknown" as const,
@@ -10,6 +10,7 @@ import {
addSemanticGraphUnknown,
} from "./JavaScriptSemanticGraphConstruction.js";
import {
inferredSemanticEvidenceAt,
builtinSemanticNode,
builtinSemanticRelation,
unknownSemanticEvidence,
@@ -135,7 +136,7 @@ export const projectSemanticRequests = (
if (request === undefined) continue;
if (operation.kind === "request")
projectRequestConstruction(context, operation, request);
else projectResponseConsumer(context, operation, request);
else projectResponseConsumer(context, operation, request, requestNodes);
if (operation.resolution !== "complete")
addDataUnknown({
context,
@@ -224,16 +225,31 @@ const projectResponseConsumer = (
context: SemanticFlowProjectionContext,
operation: JavaScriptSemanticRequestOperation,
response: JavaScriptSemanticGraphNode,
requestNodes: ReadonlyMap<string, JavaScriptSemanticGraphNode>,
): void => {
const authority = builtinSemanticRelation(
context.file,
operation,
operation.resolution === "complete" ? "resolved" : "candidate",
);
const evidence =
authority.evidence ??
inferredSemanticEvidenceAt(context.file, operation.location);
for (const requestId of operation.linkedRequestIds)
addSemanticGraphRelation(context.state, {
source: requestNodes.get(requestId),
target: response,
relation: "consumed-by",
...authority,
evidence,
properties: { method: operation.method },
});
addSemanticGraphRelation(context.state, {
source: response,
target: context.callSiteAt(operation.location),
relation: "consumed-by",
...builtinSemanticRelation(
context.file,
operation,
operation.resolution === "complete" ? "resolved" : "candidate",
),
...authority,
evidence,
properties: { method: operation.method },
});
};
@@ -14,7 +14,10 @@ import {
addSemanticGraphRelation,
addSemanticGraphUnknown,
} from "./JavaScriptSemanticGraphConstruction.js";
import { unknownSemanticEvidence } from "./JavaScriptSemanticGraphEvidence.js";
import {
inferredSemanticEvidenceAt,
unknownSemanticEvidence,
} from "./JavaScriptSemanticGraphEvidence.js";
import type { SemanticFlowProjectionContext } from "./JavaScriptSemanticGraphFlowProjection.js";
import { semanticPropertySlot } from "./JavaScriptSemanticGraphValueProjection.js";
@@ -47,6 +50,7 @@ export function* projectSemanticObjects(
...ends,
relation: OBJECT_RELATIONS[operation.kind],
resolution: resolved ? "resolved" : "candidate",
evidence: inferredSemanticEvidenceAt(context.file, operation.location),
});
if (!resolved)
addObjectUnknown(context, operation, target.slot ?? occurrence, target);
@@ -340,3 +340,37 @@ it("bounds reads within unmodeled nested operator operands", () => {
);
expect(trace.status).not.toBe("found");
});
it.each([1, 2])(
"reports unavailable-module node omission only when total capacity %s rejects it",
(totalNodes) => {
const projection = createJavaScriptSemanticGraphProjection({
totalNodes,
fileNodes: 10,
});
const retained = source("retained.js", "");
projection.projectFile(retained, analyzeJavaScriptSemantics(""));
projection.recordUnavailableFile(
source("unavailable.js"),
"Source unavailable",
null,
"incomplete-module",
);
const graph = projection.finish(digest, application);
expect(graph.nodes).toHaveLength(totalNodes);
expect(graph.unknowns).toHaveLength(12);
expect(graph.coverage).toMatchObject({
status: "partial",
truncated: totalNodes === 1,
omitted_nodes: null,
});
if (totalNodes === 1) {
expect(graph.coverage.limits).toContainEqual({
name: "max_semantic_nodes",
value: 1,
unit: "items",
});
expect(graph.limitations.join("\n")).toContain("max_semantic_nodes is 1");
} else expect(graph.coverage.limits).toEqual([]);
},
);
@@ -120,48 +120,3 @@ it("charges the group key before building it so an oversized request method fail
});
expect(finished(accumulator).tools).toHaveLength(0);
});
it("counts only admitted scenario transactions and refreshes their snapshot limitations", () => {
const accumulator = new EnvironmentManifestAccumulator(32 * 1024);
expect(finished(accumulator).origin).toBe("");
expect(accumulator.add(sample(0, "/api/items"), "absent").ok).toBe(true);
const before = finished(accumulator);
expect(before.limitations.join(" ")).not.toContain("no primary origin");
expect(
accumulator.add(
{
...sample(1, "/api/items", `{"name":"${"x".repeat(2_000)}"}`),
limitations: ["rejected-only limitation"],
},
"redacted",
).ok,
).toBe(false);
expect(finished(accumulator)).toEqual(before);
expect(
accumulator.add({
...sample(1, "/api/items"),
limitations: ["uncommitted limitation", "x".repeat(100_000)],
}).ok,
).toBe(false);
expect(finished(accumulator)).toEqual(before);
expect(
accumulator.add(
{
...sample(2, "/api/items", '{"name":"n"}'),
limitations: ["accepted record limitation"],
},
"redacted",
).ok,
).toBe(true);
const after = finished(accumulator);
expect(after.limitations).toContain("accepted record limitation");
expect(after.records.map((record) => record.ordinal)).toEqual([0, 2]);
expect(
after.limitations.filter((limitation) =>
limitation.includes("scenario transactions with a usable request line"),
),
).toEqual([
"1 of 2 scenario transactions with a usable request line retained a response body with declared secret values replaced by redaction markers; tools citing them replay the redacted bytes.",
"1 of 2 scenario transactions with a usable request line retained no response body; tools citing them replay only the recorded status and content type, either of which may be absent.",
]);
});
@@ -215,11 +215,9 @@ export class EnvironmentManifestAccumulator extends WebNetworkProjectionAccumula
record: WebNetworkCaptureRecord,
scenarioResponseBody?: ScenarioResponseBodyRetention,
): Result<void, AnalysisError> {
const rollbackAdmission = this.retainedCheckpoint();
let committed = false;
const limitations = this.addLimitations(record.limitations);
if (!limitations.ok) return limitations;
try {
const limitations = this.prepareLimitations(record.limitations);
if (!limitations.ok) return limitations;
const line = readRecordedRequestLine(
record.reported,
this.includeQueryParameterNames,
@@ -228,14 +226,16 @@ export class EnvironmentManifestAccumulator extends WebNetworkProjectionAccumula
if (line === null) return this.overBudget();
const releaseGrouping = this.temporaryCheckpoint();
if (line.kind === "excluded") {
releaseGrouping();
const prepared = this.prepareRecordedRequestLine(record, line);
if (!prepared.ok) return prepared;
prepared.value();
limitations.value();
this.#unrepresented += 1;
committed = true;
return ok(undefined);
releaseGrouping();
return this.onRecordedRequestLine(record, line);
}
// Coverage uses the same admitted request line as grouping, including origin and raw path.
if (scenarioResponseBody !== undefined) {
this.#scenarioTransactions += 1;
if (scenarioResponseBody === "absent") this.#scenarioWithoutBody += 1;
if (scenarioResponseBody === "redacted")
this.#scenarioRedactedBodies += 1;
}
const route = scanRouteTemplate(line.path, this.scratch);
if (route === null) return this.overBudget();
@@ -266,7 +266,7 @@ export class EnvironmentManifestAccumulator extends WebNetworkProjectionAccumula
? this.#bodyShape(record.reported)
: ok(null);
if (!bodyShape.ok) return bodyShape;
const grouped = this.#prepareAppend(
const grouped = this.#append(
record.ordinal,
line,
route,
@@ -277,33 +277,18 @@ export class EnvironmentManifestAccumulator extends WebNetworkProjectionAccumula
if (!grouped.ok) return grouped;
// Keep the parsed line's admission while releasing grouping-only scratch.
releaseGrouping();
const prepared = this.prepareRecordedRequestLine(record, line);
if (!prepared.ok) return prepared;
// Both owners admitted their facts; neither commit can reject the record.
grouped.value();
prepared.value();
limitations.value();
// Coverage uses the same admitted request line as grouping, including origin and raw path.
if (scenarioResponseBody !== undefined) {
this.#scenarioTransactions += 1;
if (scenarioResponseBody === "absent") this.#scenarioWithoutBody += 1;
if (scenarioResponseBody === "redacted")
this.#scenarioRedactedBodies += 1;
}
committed = true;
return ok(undefined);
return this.onRecordedRequestLine(record, line);
} finally {
if (!committed) rollbackAdmission();
this.releaseTemporary();
}
}
/** Admit derived facts from the owner's parsed line, then commit only after all owners admit it. */
protected prepareRecordedRequestLine(
/** Consume the owner's parsed request line after successful grouping, including exclusions. */
protected onRecordedRequestLine(
_record: WebNetworkCaptureRecord,
_line: RecordedRequestLine,
): Result<() => void, AnalysisError> {
return ok(() => {});
): Result<void, AnalysisError> {
return ok(undefined);
}
/** Read the top-level key types of the group's first recorded JSON-typed request body; nested values are never walked. */
@@ -331,14 +316,14 @@ export class EnvironmentManifestAccumulator extends WebNetworkProjectionAccumula
return ok({ type: "object", properties: Object.fromEntries(entries) });
}
#prepareAppend(
#append(
ordinal: number,
line: RequestLine,
route: RouteTemplate,
queryNames: ReadonlySet<string> | undefined,
bucket: { readonly key: string; readonly group: GroupState | undefined },
exchange: { readonly shape: BodyShape | null; readonly empty: boolean },
): Result<() => void, AnalysisError> {
): Result<void, AnalysisError> {
const bodyShape = exchange.shape;
const mime = mediaTypeEssence(line.rawMime);
let group = bucket.group;
@@ -381,49 +366,48 @@ export class EnvironmentManifestAccumulator extends WebNetworkProjectionAccumula
bytes += ENTRY_BYTES + textBytes(name.length);
// Reserve the whole mutation while route scratch is still live.
if (!this.retain(bytes)) return this.overBudget();
return ok(() => {
const position = this.#records.length;
this.#records.push({
ordinal,
method: line.method,
path: line.path,
status: line.status,
contentType: mime,
});
const jsonLike = isJsonLike(mime);
const stats = origin ?? { records: 0, jsonLike: 0 };
stats.records += 1;
if (jsonLike) stats.jsonLike += 1;
this.#origins.set(line.origin, stats);
if (group === undefined) {
group = {
method: line.method,
origin: line.origin,
api_surface_template: route.template,
parts: routeParts(route),
positions: [],
statuses: new Set(),
contentTypes: new Set(),
queryNames: new Set(),
bodyShape,
apiPath: isApiPath(route.template),
jsonLike: false,
bodiless: BODILESS_WRITE_METHODS.has(line.method),
};
this.#groups.set(bucket.key, group);
} else {
for (const segment of route.segments) {
const part = group.parts[segment.segment_index];
if (part?.kind === "param") part.values.add(segment.value);
}
}
group.positions.push(position);
group.jsonLike ||= jsonLike;
group.bodiless &&= exchange.empty;
if (line.status !== null) group.statuses.add(line.status);
if (mime !== null) group.contentTypes.add(mime);
for (const name of queryNames ?? []) group.queryNames.add(name);
const position = this.#records.length;
this.#records.push({
ordinal,
method: line.method,
path: line.path,
status: line.status,
contentType: mime,
});
const jsonLike = isJsonLike(mime);
const stats = origin ?? { records: 0, jsonLike: 0 };
stats.records += 1;
if (jsonLike) stats.jsonLike += 1;
this.#origins.set(line.origin, stats);
if (group === undefined) {
group = {
method: line.method,
origin: line.origin,
api_surface_template: route.template,
parts: routeParts(route),
positions: [],
statuses: new Set(),
contentTypes: new Set(),
queryNames: new Set(),
bodyShape,
apiPath: isApiPath(route.template),
jsonLike: false,
bodiless: BODILESS_WRITE_METHODS.has(line.method),
};
this.#groups.set(bucket.key, group);
} else {
for (const segment of route.segments) {
const part = group.parts[segment.segment_index];
if (part?.kind === "param") part.values.add(segment.value);
}
}
group.positions.push(position);
group.jsonLike ||= jsonLike;
group.bodiless &&= exchange.empty;
if (line.status !== null) group.statuses.add(line.status);
if (mime !== null) group.contentTypes.add(mime);
for (const name of queryNames ?? []) group.queryNames.add(name);
return ok(undefined);
}
#tools(groups: readonly ToolGroup[]): Tool[] {
@@ -529,8 +513,7 @@ export class EnvironmentManifestAccumulator extends WebNetworkProjectionAccumula
compare(left.method, right.method) ||
compare(left.reason, right.reason),
);
// Derived limitations describe this snapshot, not an earlier finish.
const limitations = this.snapshotLimitations([
const limitations = this.addLimitations([
...(this.reportsBodyShapes &&
tools.some((tool) => tool.body_shape !== null)
? [
@@ -538,16 +521,6 @@ export class EnvironmentManifestAccumulator extends WebNetworkProjectionAccumula
]
: []),
...this.#segmentLimitLimitations(toolGroups),
...(this.#unrepresented > 0
? [
`${String(this.#unrepresented)} retained records without a usable request line are not represented in the manifest.`,
]
: []),
...(primary === null
? [
"No retained record had a parseable origin; the manifest names no primary origin.",
]
: []),
...(this.#scenarioWithoutBody > 0
? [
`${String(this.#scenarioWithoutBody)} of ${String(this.#scenarioTransactions)} scenario transactions with a usable request line retained no response body; tools citing them replay only the recorded status and content type, either of which may be absent.`,
@@ -558,6 +531,16 @@ export class EnvironmentManifestAccumulator extends WebNetworkProjectionAccumula
`${String(this.#scenarioRedactedBodies)} of ${String(this.#scenarioTransactions)} scenario transactions with a usable request line retained a response body with declared secret values replaced by redaction markers; tools citing them replay the redacted bytes.`,
]
: []),
...(this.#unrepresented > 0
? [
`${String(this.#unrepresented)} retained records without a usable request line are not represented in the manifest.`,
]
: []),
...(primary === null
? [
"No retained record had a parseable origin; the manifest names no primary origin.",
]
: []),
]);
if (!limitations.ok) return limitations;
return ok({
@@ -584,7 +567,7 @@ export class EnvironmentManifestAccumulator extends WebNetworkProjectionAccumula
tools.filter((tool) => tool.params.length > 0).length +
excluded.filter((exclusion) => exclusion.templated).length,
coverage: completeWithinViewCoverage(tools.length, tools.length),
limitations: limitations.value,
limitations: this.sortedLimitations(),
});
}
}
@@ -3,7 +3,6 @@ import type { AnalysisError } from "../analysisErrorBase.js";
import { parseExactJson } from "../exactJsonNumber.js";
import { MAX_JSON_DEPTH, type JsonValue } from "../jsonValue.js";
import {
addOffsetDateTimeMilliseconds,
compareOffsetDateTimeInstants,
readRecordedStart,
} from "../offsetDateTime.js";
@@ -30,7 +29,6 @@ import {
base64PrefixChars,
readRecordedResponseBody,
readRecordedResponseDeclaration,
recordedResponseStatus,
recordedResponseBodyIsEmpty,
type RecordedResponseBody,
} from "./recordedResponseBody.js";
@@ -47,9 +45,6 @@ import {
* room under the JSON depth limit to be sealed and served.
*/
const STATE_NESTING_DEPTH = 16;
// Number.MIN_VALUE milliseconds needs this many decimal places in seconds;
// this also covers the whole-second digits of every finite number duration.
const DURATION_DIGITS = 3 - Math.floor(Math.log10(Number.MIN_VALUE));
/** A recorded response body as state JSON: a JSON-like media type, complete UTF-8 text within the state body limit, exact numbers; otherwise undefined. */
export const stateBodyJson = (
@@ -74,14 +69,14 @@ export const stateBodyJson = (
const startSchema = z.object({ startedDateTime: z.string() });
const durationSchema = z.object({ time: z.number().nonnegative() });
const unlocatedWriteSchema = z.object({
const redactedWriteSchema = z.object({
request: z.object({ method: z.string() }),
response: z.object({ status: z.number() }).optional(),
});
interface RecordEntry {
readonly facts: StateRecordFacts;
/** Null for a write without a usable URL: its unknown target bounds every type. */
/** Null for a write whose URL was redacted: its target is unknown, so it bounds every type. */
readonly origin: string | null;
readonly targetExclusion?: "url-redacted" | "url-unparseable";
readonly contentType: string;
readonly startInterpretation:
| NonNullable<ReturnType<typeof readRecordedStart>>["interpretation"]
@@ -124,10 +119,10 @@ export class EnvironmentStateAccumulator extends EnvironmentManifestAccumulator
return false;
}
protected override prepareRecordedRequestLine(
protected override onRecordedRequestLine(
record: WebNetworkCaptureRecord,
line: RecordedRequestLine,
): Result<() => void, AnalysisError> {
): Result<void, AnalysisError> {
const started = startSchema.safeParse(record.reported);
const duration = durationSchema.safeParse(record.reported);
// Admit normalization and fraction parsing before materializing their strings.
@@ -153,52 +148,28 @@ export class EnvironmentStateAccumulator extends EnvironmentManifestAccumulator
startMs === undefined || !duration.success
? undefined
: startMs + duration.data.time;
// Exact completion keeps arbitrary source precision. Reserve digit-array and
// string expansion before the shared decimal addition allocates them.
if (
startInstant !== undefined &&
duration.success &&
duration.data.time !== 0 &&
!this.scratch(
RECORD_SCRATCH_BYTES +
textBytes(Math.max(startInstant.fraction.length, DURATION_DIGITS)) *
3,
)
)
return this.overBudget();
const endInstant =
startInstant === undefined || !duration.success
? undefined
: addOffsetDateTimeMilliseconds(startInstant, duration.data.time);
const timing: StateRecordFacts["timing"] = {
start:
startInstant === undefined || startMs === undefined
? undefined
: { instant: startInstant, ms: startMs },
end: endInstant,
endMs: end !== undefined && Number.isFinite(end) ? end : undefined,
};
// A fraction substring may retain its normalized timestamp's backing store,
// including trailing zeroes removed from the exact comparison value.
const instantBytes =
(started.success && startInstant !== undefined
started.success && startInstant !== undefined
? ENTRY_BYTES + textBytes(started.data.startedDateTime.length + 6)
: 0) +
(endInstant !== undefined && endInstant !== startInstant
? ENTRY_BYTES + textBytes(endInstant.fraction.length)
: 0);
: 0;
if (line.kind === "excluded")
return line.reason === "url-redacted" ||
line.reason === "url-unparseable" ||
line.reason === "request-unavailable"
? this.#unlocatedWrite(
return line.reason === "url-redacted"
? this.#redactedWrite(
record,
line.reason === "url-redacted" ? "url-redacted" : "url-unparseable",
timing,
instantBytes,
reading?.interpretation,
)
: ok(() => undefined);
: ok(undefined);
// The entry keeps the line's strings and a reference to the already admitted record.
if (
!this.retain(
@@ -214,62 +185,56 @@ export class EnvironmentStateAccumulator extends EnvironmentManifestAccumulator
)
)
return this.overBudget();
return ok(() => {
this.#entries.set(record.ordinal, {
facts: {
ordinal: record.ordinal,
method: line.method,
path: line.path,
query: line.query === "" ? undefined : line.query,
status: line.status,
timing,
},
origin: line.origin,
contentType: line.rawMime,
startInterpretation: reading?.interpretation,
record,
});
this.#entries.set(record.ordinal, {
facts: {
ordinal: record.ordinal,
method: line.method,
path: line.path,
query: line.query === "" ? undefined : line.query,
status: line.status,
timing,
},
origin: line.origin,
contentType: line.rawMime,
startInterpretation: reading?.interpretation,
record,
});
return ok(undefined);
}
/** Prepare a successful write with unknown target, whose empty root relates to every type. */
#unlocatedWrite(
/** Keep a successful write whose URL was redacted, with an empty path whose root relates to every type. */
#redactedWrite(
record: WebNetworkCaptureRecord,
reason: "url-redacted" | "url-unparseable",
timing: StateRecordFacts["timing"],
instantBytes: number,
startInterpretation: RecordEntry["startInterpretation"],
): Result<() => void, AnalysisError> {
const parsed = unlocatedWriteSchema.safeParse(record.reported);
if (!parsed.success) return ok(() => undefined);
const status = recordedResponseStatus(
readRecordedResponseDeclaration(record.reported),
);
): Result<void, AnalysisError> {
const parsed = redactedWriteSchema.safeParse(record.reported);
if (!parsed.success) return ok(undefined);
const status = parsed.data.response?.status ?? 0;
const facts: StateRecordFacts = {
ordinal: record.ordinal,
method: parsed.data.request.method,
path: "",
query: undefined,
status,
status: status > 0 ? status : null,
timing,
};
if (!isStateWrite(facts)) return ok(() => undefined);
if (!isStateWrite(facts)) return ok(undefined);
if (
!this.retain(
ENTRY_BYTES * 8 + textBytes(facts.method.length) + instantBytes,
)
)
return this.overBudget();
return ok(() => {
this.#entries.set(record.ordinal, {
facts,
origin: null,
targetExclusion: reason,
contentType: "",
startInterpretation,
record,
});
this.#entries.set(record.ordinal, {
facts,
origin: null,
contentType: "",
startInterpretation,
record,
});
return ok(undefined);
}
/** Parse one retained response body as state JSON, charging its decoding and tree as temporary work. */
@@ -365,10 +330,7 @@ export class EnvironmentStateAccumulator extends EnvironmentManifestAccumulator
entry.origin === manifest.value.origin || entry.origin === null,
);
const redactedWrites = entries.filter(
(entry) => entry.targetExclusion === "url-redacted",
).length;
const unparseableWrites = entries.filter(
(entry) => entry.targetExclusion === "url-unparseable",
(entry) => entry.origin === null,
).length;
const timeline: StateTimeline =
entries.length > 0 &&
@@ -399,8 +361,7 @@ export class EnvironmentStateAccumulator extends EnvironmentManifestAccumulator
},
}).run();
if (state === null) return this.overBudget();
const limitations = this.snapshotLimitations([
...manifest.value.limitations,
const limitations = this.addLimitations([
...(entries.some((entry) => entry.startInterpretation === "native-date")
? [
"Some recorded start spellings use native Date parsing at millisecond precision; spellings without an explicit zone depend on the host timezone.",
@@ -411,11 +372,6 @@ export class EnvironmentStateAccumulator extends EnvironmentManifestAccumulator
`${String(redactedWrites)} of the recorded writes had a redacted URL; each ends the initial state of every type, since its target is unknown.`,
]
: []),
...(unparseableWrites > 0
? [
`${String(unparseableWrites)} of the recorded writes had an unusable URL; each ends the initial state of every type, since its target is unknown.`,
]
: []),
...(timeline === "record-order"
? [
"Some records carry no start time, so the state's timeline is record order and overlapping requests are not detected.",
@@ -441,7 +397,7 @@ export class EnvironmentStateAccumulator extends EnvironmentManifestAccumulator
...state,
unknowns: UNKNOWNS.map((unknown) => ({ ...unknown })),
coverage: manifest.value.coverage,
limitations: limitations.value,
limitations: this.sortedLimitations(),
});
}
}
@@ -1418,7 +1418,7 @@ describe("encoded parent spellings", () => {
path: "/api/orgs/uid/acme1/books",
query: undefined,
status: 200,
timing: { start: undefined, end: undefined, endMs: undefined },
timing: { start: undefined, endMs: undefined },
};
const lines = checkedLines({
tools: [tool],
@@ -1621,7 +1621,7 @@ describe("resolved encoded item observations", () => {
path: "/api/orgs/acme1/books/%61",
query: undefined,
status: 204,
timing: { start: undefined, end: undefined, endMs: undefined },
timing: { start: undefined, endMs: undefined },
};
const target = itemTarget(state, "/api/orgs/{org_id}/books", write);
const itemView = {
@@ -1642,7 +1642,7 @@ describe("resolved encoded item observations", () => {
path: "/api/orgs/acme1/books/%61",
query: undefined,
status: 200,
timing: { start: undefined, end: undefined, endMs: undefined },
timing: { start: undefined, endMs: undefined },
},
];
const search: ObservationSearch = {
@@ -2308,53 +2308,23 @@ describe("reads and calls on an entity an unmodelled write left unknown", () =>
{},
];
it.each([
{ label: "scalar", read: "unavailable", served: false },
{ label: "array", read: [], served: false },
{ label: "object", read: named("A"), served: true },
])(
"requires a usable item baseline after an unmodelled write ($label)",
({ read, served }) => {
const rows: TimedCaptureRow[] = [
["GET", "/api/books", [named("A"), other]],
["GET", "/api/books/1", named("A")],
["GET", "/api/books/2", other],
ARCHIVE,
["GET", "/api/books/1", read],
["PATCH", "/api/books/1", named("Z"), 200, { name: "Z" }],
["GET", "/api/books/1", named("Z")],
];
expect(gated(rows, "patch_books_by_id")).toMatchObject({
served,
checked_record_ordinals: served ? [6] : [],
});
},
);
it.each([
{ status: 503, body: { error: "unavailable" } },
{ status: 200, body: "unavailable" },
{ status: 200, body: [] },
])(
"keeps pending effects through an unusable item read ($status, $body)",
({ status, body }) => {
const rows: TimedCaptureRow[] = [
["GET", "/api/books", [named("A"), other]],
["GET", "/api/books/1", named("A")],
["GET", "/api/books/2", other],
["PATCH", "/api/books/1", named("Z"), 200, { name: "Z" }],
ARCHIVE,
// Neither failure nor a successful non-object response shows the book.
["GET", "/api/books/1", body, status],
["GET", "/api/books/1", named("Z")],
];
expect(gated(rows, "patch_books_by_id")).toMatchObject({
served: true,
checked_record_ordinals: [6],
contradicted_by: null,
});
},
);
it("keeps the entity unknown through a failed read that is no absence, for a later read to check", () => {
const rows: TimedCaptureRow[] = [
["GET", "/api/books", [named("A"), other]],
["GET", "/api/books/1", named("A")],
["GET", "/api/books/2", other],
["PATCH", "/api/books/1", named("Z"), 200, { name: "Z" }],
ARCHIVE,
// The application was briefly unavailable, so this answer shows nothing of the book.
["GET", "/api/books/1", { error: "unavailable" }, 503],
["GET", "/api/books/1", named("Z")],
];
expect(gated(rows, "patch_books_by_id")).toMatchObject({
served: true,
checked_record_ordinals: [6],
contradicted_by: null,
});
});
it("confirms no update of a volatile field by a read that shows the entity again, as no other read does", () => {
const seen = (id: number, count: number) => ({
@@ -2498,7 +2468,7 @@ describe("entities an unmodelled write made", () => {
path: "/api/books/3",
query: undefined,
status: 200,
timing: { start: undefined, end: undefined, endMs: undefined },
timing: { start: undefined, endMs: undefined },
};
expect(
unseen.adopt(view as unknown as StateItemView, { record, index: 1 }),
@@ -2523,7 +2493,7 @@ describe("entities an unmodelled write made", () => {
path: "/api/books/3",
query: undefined,
status: 200,
timing: { start: undefined, end: undefined, endMs: undefined },
timing: { start: undefined, endMs: undefined },
},
index,
});
@@ -3231,7 +3201,7 @@ it("learns omitted fields using the raw ID resolved by the item owner", () => {
path: "/api/books/%63",
query: undefined,
status: 200,
timing: { start: undefined, end: undefined, endMs: undefined },
timing: { start: undefined, endMs: undefined },
};
const target = itemTarget(state, view.type, record);
expect(target.id).toBe("%63");
@@ -555,6 +555,8 @@ export class EnvironmentStateWriteSimulation {
): void {
const target = entityTarget(entity.type, entity.collection, entity.id);
const writes = this.#touched.get(target) ?? new Map();
this.#taint.known(entity.type, entity.collection, entity.id);
this.#touched.delete(target);
if (
isAbsence(record.status) ||
this.#input.io.withBody(record.ordinal, (body) =>
@@ -565,16 +567,12 @@ export class EnvironmentStateWriteSimulation {
),
)
) {
this.#taint.known(entity.type, entity.collection, entity.id);
this.#touched.delete(target);
this.#removeEntity(entity);
return;
}
const object = this.#checkFields(view, record, entity, writes);
if (object === undefined) return;
this.#taint.known(entity.type, entity.collection, entity.id);
this.#touched.delete(target);
this.#state.put(updatedEntity(entity, { ...entity.fields, ...object }));
if (object !== undefined)
this.#state.put(updatedEntity(entity, { ...entity.fields, ...object }));
}
/** Whether an item read shows an entity an unmodelled write made, or one left unknown, again rather than checks it. */
@@ -30,9 +30,9 @@ it("bounds query parser expansion before publishing or merging observed names",
const accumulator = new WebNetworkApiSurfaceAccumulator(64 * 1024, true);
add(accumulator, record(0, "/products?kept=1"));
const before = accumulator.page(0, 1);
const rejected = record(1, `/products?${"x=1&".repeat(200)}`);
rejected.limitations = ["Uncommitted rejected record caveat"];
expect(accumulator.add(rejected)).toMatchObject({
expect(
accumulator.add(record(1, `/products?${"x=1&".repeat(200)}`)),
).toMatchObject({
ok: false,
error: { _tag: "AnalysisResourceConstraintError" },
});
@@ -92,10 +92,8 @@ export class WebNetworkApiSurfaceAccumulator extends WebNetworkProjectionAccumul
}
add(record: WebNetworkCaptureRecord): Result<void, AnalysisError> {
const rollbackAdmission = this.retainedCheckpoint();
const limitations = this.prepareLimitations(record.limitations);
const limitations = this.addLimitations(record.limitations);
if (!limitations.ok) return limitations;
let committed = false;
try {
const line = readRecordedRequestLine(
record.reported,
@@ -103,23 +101,14 @@ export class WebNetworkApiSurfaceAccumulator extends WebNetworkProjectionAccumul
this.scratch,
);
if (line === null) return this.overBudget();
let appended: Result<void, AnalysisError>;
if (line.kind === "excluded")
appended = this.#exclude(record.ordinal, line.reason);
else {
const route = scanRouteTemplate(line.path, this.scratch);
if (route === null) return this.overBudget();
const queryNames = readQueryParameterNames(line.query, this.scratch);
if (queryNames === null) return this.overBudget();
appended = this.#append(record.ordinal, line, route, queryNames);
}
if (appended.ok) {
limitations.value();
committed = true;
}
return appended;
return this.#exclude(record.ordinal, line.reason);
const route = scanRouteTemplate(line.path, this.scratch);
if (route === null) return this.overBudget();
const queryNames = readQueryParameterNames(line.query, this.scratch);
if (queryNames === null) return this.overBudget();
return this.#append(record.ordinal, line, route, queryNames);
} finally {
if (!committed) rollbackAdmission();
this.releaseTemporary();
}
}
@@ -105,52 +105,13 @@ export abstract class WebNetworkProjectionAccumulator {
}
addLimitations(limitations: readonly string[]): Result<void, AnalysisError> {
const prepared = this.prepareLimitations(limitations);
if (!prepared.ok) return prepared;
prepared.value();
return ok(undefined);
}
/** Admit record qualifications without publishing them before the record commits. */
protected prepareLimitations(
limitations: readonly string[],
): Result<() => void, AnalysisError> {
const rollbackAdmission = this.retainedCheckpoint();
const pending = new Set<string>();
for (const limitation of limitations) {
if (this.#limitations.has(limitation) || pending.has(limitation))
continue;
if (!this.retain(ENTRY_BYTES + textBytes(limitation.length))) {
rollbackAdmission();
if (this.#limitations.has(limitation)) continue;
if (!this.retain(ENTRY_BYTES + textBytes(limitation.length)))
return this.overBudget();
}
pending.add(limitation);
}
return ok(() => {
for (const limitation of pending) this.#limitations.add(limitation);
});
}
/** Derive budgeted qualifications for this snapshot without retaining old snapshots. */
protected snapshotLimitations(
derived: readonly string[],
): Result<string[], AnalysisError> {
const release = this.temporaryCheckpoint();
if (
!this.scratch(
derived.reduce(
(bytes, limitation) =>
bytes + ENTRY_BYTES + textBytes(limitation.length),
0,
),
)
)
return this.overBudget();
try {
return ok([...new Set([...this.#limitations, ...derived])].sort(compare));
} finally {
release();
this.#limitations.add(limitation);
}
return ok(undefined);
}
protected sortedLimitations(): string[] {
@@ -67,19 +67,6 @@ const nativeParent = (
artifact: { path: "/fixtures/example.exe", sha256: "a".repeat(64) },
});
it("returns input errors for malformed parent Evidence identifiers before projection", () => {
for (const evidenceId of [`xx_${"a".repeat(64)}`, "ev_bad"])
expect(
projectAnalysisView(
{ ...nativeParent(), evidenceId },
{ kind: "summary" },
),
).toMatchObject({ ok: false, error: { _tag: "AnalysisInputError" } });
expect(projectAnalysisView(nativeParent(), { kind: "summary" }).ok).toBe(
true,
);
});
it("projects native procedure and bounded assembly and high-pcode with exact parent identity", () => {
const parent = nativeParent();
const procedure = projectAnalysisView(parent, {
+4 -3
View File
@@ -53,7 +53,7 @@ import {
analysisViewInputError,
} from "./analysisViewErrors.js";
import { digestCanonicalValue } from "../canonicalDigest.js";
import { prefixedDigestSchema } from "../digests.js";
import { digestSchema } from "../digests.js";
import { err, ok, type Result } from "../result.js";
import type { AnalysisError } from "../analysisErrorBase.js";
import { safeParseOwnedBinaryLayout } from "../native/binaryLayout.js";
@@ -269,7 +269,7 @@ export const sealAnalysisView = (
view_digest: digestCanonicalValue(projection, "Analysis view"),
});
const parentEvidenceIdSchema = prefixedDigestSchema("ev");
const parentDigest = (evidenceId: string): string => evidenceId.slice(3);
const parseJavaScriptParent = (
input: unknown,
@@ -473,7 +473,8 @@ export const projectAnalysisView = (
view: AnalysisViewRequest,
accumulators: AnalysisViewAccumulators = {},
): Result<AnalysisViewResult, AnalysisError> => {
if (!parentEvidenceIdSchema.safeParse(parent.evidenceId).success)
const digest = parentDigest(parent.evidenceId);
if (!digestSchema.safeParse(digest).success)
return err(
analysisViewInputError([
{
@@ -3,7 +3,6 @@ import {
checkItemPlan,
type StateGateContext,
} from "./environmentStateGate.js";
import { locateItem, locateItems } from "./environmentStateRules.js";
import type {
JsonObject,
StateEntity,
@@ -22,7 +21,7 @@ const check = (
path: `/api/books/${ordinal + 1}`,
query: undefined,
status: 200,
timing: { start: undefined, end: undefined, endMs: undefined },
timing: { start: undefined, endMs: undefined },
}));
const bodies: JsonObject[] = clocks.map(([, stamp], index) => ({
id: index + 1,
@@ -117,37 +116,3 @@ describe("clock spelling facts at the read gate", () => {
expect(large.bytes).toBe(small.bytes);
});
});
describe("item response identity selection", () => {
it("uses the known identity field before choosing an envelope tier", () => {
const body = { request_id: "42", data: { id: "42", name: "Book" } };
expect(locateItem(body, "42", "id")).toEqual({
pointer: "/data",
keys: ["id"],
});
expect(locateItem(body, "42")).toEqual({
pointer: "",
keys: ["request_id"],
});
expect(locateItem(body, "42", "uid")).toBeUndefined();
});
it("retains ambiguity between objects carrying the known identity", () => {
const body = { request_id: "42", left: { id: "42" }, right: { id: "42" } };
expect(locateItem(body, "42", "id")).toBeUndefined();
expect(locateItems(body, "42", "id")).toEqual([
{ pointer: "/left", keys: ["id"] },
{ pointer: "/right", keys: ["id"] },
]);
expect(locateItems(body, "42", "uid")).toEqual([]);
});
it("admits an explicitly known custom identity in a sole array element", () => {
const body = { data: [{ bookNumber: "42", name: "Book" }] };
expect(locateItem(body, "42", "bookNumber")).toEqual({
pointer: "/data/0",
keys: ["bookNumber"],
});
expect(locateItem(body, "42")).toBeUndefined();
});
});
@@ -108,75 +108,3 @@ it("takes no nested member for a counter unless it equals the number listed", ()
},
);
});
it.each([{ nextCursor: "cursor-2" }, { has_more: true }, { nextPage: 2 }])(
"keeps membership recorded when paging metadata says more items follow: %j",
(pageInfo) => {
const state = environmentStateViewOf(
timedCaptureEvidence([
["GET", "/api/records", { list: SEEDED, pageInfo }],
...SEEDED.map((item): TimedCaptureRow => [
"GET",
`/api/records/${String(item["Id"])}`,
item,
]),
]),
);
expect(
state.views.find((view) => view.tool === "get_records"),
).toMatchObject({
shape: "list",
membership: "recorded",
});
},
);
it("keeps completed paging envelopes whole and does not read entity fields as continuation metadata", () => {
const items = SEEDED.map((item): JsonObject => ({
...item,
nextCursor: "entity-value",
}));
const state = environmentStateViewOf(
timedCaptureEvidence([
[
"GET",
"/api/records",
{ list: items, pageInfo: { nextCursor: null, has_more: false } },
],
...items.map((item): TimedCaptureRow => [
"GET",
`/api/records/${String(item["Id"])}`,
item,
]),
]),
);
expect(state.views.find((view) => view.tool === "get_records")).toMatchObject(
{
shape: "list",
membership: "all",
},
);
});
it("keeps a direction-only recording on its recorded members instead of learning an unused direction", () => {
const state = environmentStateViewOf(
timedCaptureEvidence([
["GET", "/api/records", SEEDED],
["GET", "/api/records?direction=desc", SEEDED],
...SEEDED.map((item): TimedCaptureRow => [
"GET",
`/api/records/${String(item["Id"])}`,
item,
]),
]),
);
const view = state.views.find(
(candidate) => candidate.tool === "get_records",
);
expect(view).toMatchObject({
shape: "list",
membership: "recorded",
checked_record_ordinals: [0, 1],
});
expect(view).not.toHaveProperty("query.parameters.direction");
});
@@ -72,7 +72,7 @@ export const listFacts = (
counted.some(
([, value]) => recordedItemCount(value) !== items.length,
) ||
hasContinuation(parent, plan.pointer)
Object.keys(parent).some((name) => continues(name, parent[name]))
)
return;
const ids = items.map((item) => scalarText(ownMember(item, field)) ?? "");
@@ -150,15 +150,3 @@ const continues = (name: string, value: JsonValue | undefined): boolean =>
value !== null &&
value !== false &&
value !== "";
/** Continuation facts in the envelope or its sibling paging objects, never inside the list payload. */
const hasContinuation = (parent: JsonObject, listPointer: string): boolean => {
const root = pointerTokens(listPointer)[0];
return Object.entries(parent).some(
([name, value]) =>
continues(name, value) ||
(name !== root &&
isObject(value) &&
Object.entries(value).some(([inner, flag]) => continues(inner, flag))),
);
};
@@ -376,16 +376,6 @@ describe("queries the state can always answer", () => {
});
describe("parameters that ask for an order", () => {
it("cannot apply a direction without a sort field, even when the recorded order happens to match", () => {
expect(explainLine(line("?direction=desc", [1, 2, 3, 4]))).toBeUndefined();
const parameters = { direction: { role: "direction" } } as const;
expect(ids("?direction=asc", parameters)).toBeUndefined();
expect(ids("?direction=desc", parameters)).toBeUndefined();
expect(
ids("?sort=id&direction=desc", { ...parameters, sort: { role: "sort" } }),
).toEqual(["4", "3", "2", "1"]);
});
it("reads a sort field in any spelling as a sort, never as a parameter without effect", () => {
expect(explainLine(line("?_sort=id&_order=DESC", [4, 3, 2, 1]))).toEqual({
_sort: { role: "sort" },
@@ -206,11 +206,7 @@ const readCall = (
call.page = { index: number, first: parameter.first };
else return undefined;
}
// A direction cannot identify the field being ordered. Leaving it unused
// would turn a recorded direction into permission to answer its opposite.
return call.direction !== undefined && call.sort === undefined
? undefined
: call;
return call;
};
/** Entities in the order a sort value names, ties in state order, and those whose place state order decides: all without a sort, else those tied with a neighbour; undefined when it names no field. */
@@ -51,12 +51,6 @@ const readOnlyOf = (
),
ms: call.start ?? index,
},
end:
call.end === null
? undefined
: parseOffsetDateTimeInstant(
new Date(call.end ?? index).toISOString(),
),
endMs: call.end === null ? undefined : (call.end ?? index),
},
}));
@@ -511,7 +505,6 @@ describe("read-only decisions over many calls", () => {
),
ms: index,
},
end: parseOffsetDateTimeInstant(new Date(index).toISOString()),
endMs: index,
},
};
@@ -580,7 +573,6 @@ describe("read-only decisions over a long run of calls", () => {
),
ms: index,
},
end: parseOffsetDateTimeInstant(new Date(index).toISOString()),
endMs: index,
},
};
@@ -649,7 +641,6 @@ describe("read-only decisions over many lines", () => {
),
ms: index,
},
end: parseOffsetDateTimeInstant(new Date(index).toISOString()),
endMs: index,
},
};
@@ -24,10 +24,6 @@ const record = (
instant: parseOffsetDateTimeInstant(new Date(start).toISOString()),
ms: start,
},
end:
endMs === undefined
? undefined
: parseOffsetDateTimeInstant(new Date(endMs).toISOString()),
endMs,
},
});
@@ -31,8 +31,6 @@ export interface StateRecordFacts {
readonly start:
| { readonly instant: OffsetDateTimeInstant; readonly ms: number }
| undefined;
/** Exact completion, independent of the millisecond chronology policy. */
readonly end: OffsetDateTimeInstant | undefined;
readonly endMs: number | undefined;
};
}
@@ -632,22 +630,19 @@ export const replaceAt = (
const matchingKeys = (object: JsonObject, value: string): string[] =>
Object.keys(object).filter((key) => scalarText(object[key]) === value);
/** Candidate entity objects at the first matching envelope tier. A known identity field constrains admission before choosing a tier; multiple candidates remain ambiguous. */
export const locateItems = (
/** The entity object an item response carries with the keys repeating its path value: at the root or in one wrapper member, else as the element of a one-element array there. */
export const locateItem = (
body: JsonValue,
value: string,
idField?: string,
): readonly { readonly pointer: string; readonly keys: string[] }[] => {
): { readonly pointer: string; readonly keys: string[] } | undefined => {
const located = (
object: JsonValue | undefined,
pointer: string,
named = false,
) => {
const keys = isObject(object)
? matchingKeys(object, value).filter((key) =>
idField !== undefined
? key === idField
: !named || IDENTITY_NAMES.includes(key),
? matchingKeys(object, value).filter(
(key) => !named || IDENTITY_NAMES.includes(key),
)
: [];
return keys.length > 0 ? [{ pointer, keys }] : [];
@@ -663,17 +658,8 @@ export const locateItems = (
located(soleElement(item), `${pointerTo(name)}/0`, true),
),
];
return tiers.find((tier) => tier.length > 0) ?? [];
};
/** The unique entity object at the first matching envelope tier; absent or ambiguous otherwise. */
export const locateItem = (
body: JsonValue,
value: string,
idField?: string,
): { readonly pointer: string; readonly keys: string[] } | undefined => {
const candidates = locateItems(body, value, idField);
return candidates.length === 1 ? candidates[0] : undefined;
const chosen = tiers.find((tier) => tier.length > 0);
return chosen?.length === 1 ? chosen[0] : undefined;
};
/** Whether every item carries `name` as a distinct string or number. */
@@ -12,7 +12,7 @@ import {
scenarioCaptureFixture,
} from "../../../tests/fixtures/browserScenarioCapture.js";
import type { Evidence } from "../evidence.js";
import { ownedJsonObjectSchema, type JsonValue } from "../jsonValue.js";
import type { JsonValue } from "../jsonValue.js";
import { webNetworkCaptureSchema } from "../webNetworkCapture.js";
import {
analysisViewResultSchema,
@@ -842,7 +842,7 @@ it("leaves every line checked rather than compare more lines than the variant pa
path: "/api/items",
query: `?view=${String(ordinal)}`,
status: 200,
timing: { start: undefined, end: undefined, endMs: undefined },
timing: { start: undefined, endMs: undefined },
}));
const shape = {
entities: new Map(
@@ -869,7 +869,7 @@ it("tells apart member names that hold the separator, so a line spelling them ot
path: "/api/items/1",
query,
status: 200,
timing: { start: undefined, end: undefined, endMs: undefined },
timing: { start: undefined, endMs: undefined },
});
const observed = (ordinal: number, object: JsonObject) => ({
position: ordinal,
@@ -909,7 +909,7 @@ it("leaves every line checked when comparing many mostly empty lines would excee
method: "GET",
path: "/api/items",
status: 200,
timing: { start: undefined, end: undefined, endMs: undefined },
timing: { start: undefined, endMs: undefined },
};
return Object.defineProperty(record, "query", {
enumerable: true,
@@ -950,7 +950,7 @@ const expandedItemLines = (items: number) => {
path: `/api/items/${String(ordinal % items)}`,
query: ordinal < items ? undefined : "?expand=shelf",
status: 200,
timing: { start: undefined, end: undefined, endMs: undefined },
timing: { start: undefined, endMs: undefined },
}),
);
const shapes = new Map(
@@ -1144,7 +1144,6 @@ it("finds each span's earliest write without scanning the writes before it", ()
return start;
},
},
end: offsetDateTimeInstantFromEpochMilliseconds(end),
get endMs() {
reads += 1;
return end;
@@ -2021,168 +2020,3 @@ it.each([
if (missingDuration) expect(state.write_clock).toBeNull();
},
);
it.each(["not a URL", "", 42, undefined])(
"bounds initial state at a successful write whose URL is unavailable: %j",
(url) => {
const source = captureOf([
["GET", "/api/items/1", { id: 1, title: "initial" }],
["PUT", "/api/items/1", { id: 1 }],
["GET", "/api/items/1", { id: 1, title: "after" }],
]);
const capture = webNetworkCaptureSchema.parse(source.normalizedResult);
const reported = ownedJsonObjectSchema.parse(capture.records[1]!.reported);
const request = ownedJsonObjectSchema.parse(reported.request);
if (url === undefined) delete request.url;
else request.url = url;
const before = structuredClone(capture);
const state = stateOf(parentOf(analysisViewCaptureEvidence(capture)));
expect(state.types).toMatchObject([
{ init_before_ordinal: 1, volatile_fields: [] },
]);
expect(state.entities).toMatchObject([
{ fields: { title: "initial" }, record_ordinals: [0] },
]);
expect(state.limitations).toContainEqual(
expect.stringMatching(/1.*writes.*unusable URL.*target is unknown/iu),
);
expect(
state.limitations.some((text) => text.includes("redacted URL")),
).toBe(false);
expect(capture).toEqual(before);
},
);
it("commits neither a manifest line nor state facts when inherited state admission fails", () => {
const records = webNetworkCaptureSchema.parse(
environmentManifestCaptureFixture([
{
startedDateTime: "2026-10-20T09:00:00Z",
time: 0,
request: { method: "GET", url: "https://manifest.test/api/health" },
response: {
status: 200,
content: { mimeType: "application/json", text: '{"healthy":true}' },
},
},
{
startedDateTime: "x".repeat(5000),
time: 0,
request: { method: "GET", url: "https://manifest.test/api/items" },
response: {
status: 200,
content: { mimeType: "application/json", text: "[]" },
},
},
]),
).records;
const accumulator = new EnvironmentStateAccumulator(32 * 1024);
expect(accumulator.add(records[0]!).ok).toBe(true);
expect(accumulator.add(records[1]!).ok).toBe(false);
const admitted = accumulator.finish();
if (!admitted.ok) throw admitted.error;
expect(admitted.value.records.map(({ ordinal }) => ordinal)).toEqual([0]);
const retry = structuredClone(records[1]!);
if (
retry.reported === null ||
typeof retry.reported !== "object" ||
Array.isArray(retry.reported)
)
throw new Error("Expected fixture object");
retry.reported.startedDateTime = "2026-10-20T09:00:01Z";
expect(accumulator.add(retry).ok).toBe(true);
const state = accumulator.finishState();
if (!state.ok) throw state.error;
expect(state.value.documents.map(({ path }) => path)).toEqual([
"/api/health",
"/api/items",
]);
});
it.each([
{ method: "GET", status: 200 },
{ method: "PUT", status: 500 },
])(
"does not infer a successful state write from an excluded $method/$status record",
({ method, status }) => {
const source = captureOf([
["GET", "/api/items/1", { id: 1, title: "initial" }],
[method, "/api/items/1", {}, status],
["GET", "/api/items/1", { id: 1, title: "after" }],
]);
const capture = webNetworkCaptureSchema.parse(source.normalizedResult);
const reported = ownedJsonObjectSchema.parse(capture.records[1]!.reported);
ownedJsonObjectSchema.parse(reported.request).url = "not a URL";
const state = stateOf(parentOf(analysisViewCaptureEvidence(capture)));
expect(state.types).toMatchObject([
{ init_before_ordinal: null, volatile_fields: ["title"] },
]);
expect(state.entities).toMatchObject([{ fields: { title: "after" } }]);
expect(
state.limitations.some((text) =>
text.includes("writes had an unusable URL"),
),
).toBe(false);
},
);
it.each([{}, { status: "unknown" }, { status: null }])(
"keeps a possible unlocated write when its response status is unknown: %j",
(response) => {
const source = captureOf([
["GET", "/api/items/1", { id: 1, title: "initial" }],
["PUT", "/api/items/1", {}],
["GET", "/api/items/1", { id: 1, title: "after" }],
]);
const capture = webNetworkCaptureSchema.parse(source.normalizedResult);
const reported = ownedJsonObjectSchema.parse(capture.records[1]!.reported);
ownedJsonObjectSchema.parse(reported.request).url = "not a URL";
reported.response = ownedJsonObjectSchema.parse(response);
const state = stateOf(parentOf(analysisViewCaptureEvidence(capture)));
expect(state.types).toMatchObject([
{ init_before_ordinal: 1, volatile_fields: [] },
]);
expect(state.entities).toMatchObject([
{ fields: { title: "initial" }, record_ordinals: [0] },
]);
},
);
it.each([true, false])(
"derives timeline limitations from the current state snapshot (timed: %s)",
(timed) => {
const accumulator = new EnvironmentStateAccumulator();
const empty = accumulator.finishState();
if (!empty.ok) throw empty.error;
expect(empty.value.timeline).toBe("record-order");
const record = webNetworkCaptureSchema.parse(
environmentManifestCaptureFixture([
{
...(timed ? { startedDateTime: "2026-10-20T09:00:00Z" } : {}),
time: 0,
request: { method: "GET", url: "https://snapshot.test/api/items" },
response: {
status: 200,
content: { mimeType: "application/json", text: "[]" },
},
},
]),
).records[0]!;
expect(accumulator.add(record).ok).toBe(true);
const current = accumulator.finishState();
if (!current.ok) throw current.error;
expect(current.value.timeline).toBe(
timed ? "recorded-start-time" : "record-order",
);
expect(
current.value.limitations.some((text) =>
text.includes("Some records carry no start time"),
),
).toBe(!timed);
expect(
current.value.limitations.some((text) =>
text.includes("No retained record had a parseable origin"),
),
).toBe(false);
},
);
@@ -195,34 +195,6 @@ describe("item actions", () => {
});
});
it("locates an action's entity by its known id field before response metadata", () => {
const response = { request_id: 1, data: ARCHIVED };
expect(
planOf(
[
[
"GET",
"/api/books",
[shelved(FIRST_BOOK, false), shelved(SECOND_BOOK, false)],
],
["GET", "/api/books/1", { data: shelved(FIRST_BOOK, false) }],
["GET", "/api/books/2", { data: shelved(SECOND_BOOK, false) }],
["POST", "/api/books/1/archive", response],
["GET", "/api/books/1", { data: ARCHIVED }],
["POST", "/api/books/1/archive", response],
["GET", "/api/books/1", { data: ARCHIVED }],
],
"post_books_archive_by_id",
),
).toMatchObject({
effect: "update",
served: true,
address: { by: "action", segment: "archive" },
response: { shape: "entity", pointer: "/data" },
rules: [{ field: "archived", rule: "set", value: true }],
});
});
it("serves no action seen changing its field only once, which may toggle it", () => {
expect(
planOf(
@@ -18,7 +18,6 @@ import { entityTarget, succeeded } from "./environmentStateWriteChecks.js";
import type { WriteIo } from "./environmentStateWriteEffects.js";
import {
pathValuesOf,
pathParameterKeys,
type BodyAddress,
type StateWritePlan,
type WriteAddress,
@@ -96,20 +95,19 @@ export const itemAction = (
)
return undefined;
const views = facts.itemViews(target);
const itemKey = pathParameterKeys(tool.path_template).get(
segments.length - 2,
)!;
const showsEntity = (record: StateRecordFacts): boolean => {
const id = pathValuesOf(tool.path_template, record.path)?.get(itemKey);
const id = pathValuesOf(tool.path_template, record.path)?.get(
item.slice(1, -1),
);
if (id === undefined || record.status === 201) return false;
if (facts.emptyResponse(record.ordinal)) return true;
return facts.withBody(record.ordinal, (body) => {
if (body === undefined || body === null) return true;
const located = locateItem(body, id, idField);
const located = locateItem(body, id);
const object =
located === undefined ? undefined : at(body, located.pointer);
return (
located !== undefined &&
located?.keys.includes(idField) === true &&
isObject(object) &&
views.some(
(view) =>
@@ -152,7 +152,7 @@ export class FailedReadBlame {
const response =
call === undefined
? undefined
: this.#responseObject(records[call]!, item.entity);
: this.#responseObject(records[call]!, item.entity.id);
if (response !== undefined) responses.set(name, response);
}
return fieldBlame(
@@ -169,13 +169,10 @@ export class FailedReadBlame {
/** The entity a write's response shows, located by its id. */
#responseObject(
record: StateRecordFacts,
entity: StateEntity,
id: string,
): JsonObject | undefined {
return this.#facts.io.withBody(record.ordinal, (body) => {
const located =
body === undefined
? undefined
: locateItem(body, entity.id, this.#facts.idField(entity.type));
const located = body === undefined ? undefined : locateItem(body, id);
const object =
located === undefined ? undefined : at(body!, located.pointer);
return isObject(object) ? object : undefined;
@@ -119,22 +119,6 @@ it(
);
describe("coincidences no member derives a created field from", () => {
it.each([
["before", (value: string) => `𐐀${value}`],
["after", (value: string) => `${value}𐐀`],
])(
"keeps a member accepted when an astral letter joins its text %s",
(_, label) => {
const value = "harbour-notes";
const body = create(
{ id: 9, name: value, label: label(value) },
{ name: value },
);
expect(body.accepted).toEqual(["name"]);
expect(body.fixed).toEqual([]);
},
);
it("keeps a reference accepted when a constant repeats another reference of the named entity", () => {
const channel = "1bdxw3qoyj8zjyyhdjbdobdf4r";
const user = "c10c054bc2a4478bb78e0210549376b1";
@@ -8,12 +8,7 @@ import {
} from "./environmentStateRules.js";
import { GENERATED } from "./environmentStateWriteGenerated.js";
import type { FieldRule } from "./environmentStateWriteRules.js";
import {
delimitedOffsets,
joinsAfter,
joinsBefore,
readClock,
} from "./environmentStateWriteValues.js";
import { delimitedOffsets, readClock } from "./environmentStateWriteValues.js";
/** Shorter body strings are too common to show that a field copied one. */
export const COPY_MIN_LENGTH = 8;
@@ -57,6 +52,12 @@ export const storedMembers = (
}),
);
/** A letter or digit, which joins a token to the text around it. */
const WORD = /[\p{L}\p{N}]/u;
const joins = (character: string | undefined): boolean =>
character !== undefined && WORD.test(character);
/**
* The texts that hold each token delimited, read in one pass per text: from each start after a
* delimiter, each length a token has that a delimiter ends, rather than a search per token and text.
@@ -71,10 +72,10 @@ const holdersOf = (
const holders = new Map<string, Set<string>>();
for (const [field, text] of texts)
for (let at = 0; at < text.length; at += 1) {
if (joinsBefore(text, at)) continue;
if (joins(text[at - 1])) continue;
for (const length of lengths) {
if (at + length > text.length) break;
if (joinsAfter(text, at + length)) continue;
if (joins(text[at + length])) continue;
const token = text.slice(at, at + length);
if (tokens.has(token))
holders.set(token, (holders.get(token) ?? new Set()).add(field));
@@ -18,7 +18,6 @@ const write = (path: string, ordinal: number): StateRecordFacts => ({
instant: offsetDateTimeInstantFromEpochMilliseconds(ordinal),
ms: ordinal,
},
end: offsetDateTimeInstantFromEpochMilliseconds(ordinal),
endMs: ordinal,
},
});
@@ -1,118 +0,0 @@
import { expect, it } from "vitest";
import {
timedCaptureEvidence,
type TimedCaptureRow,
} from "../../../tests/fixtures/analysisView.js";
import {
FIRST_BOOK,
environmentStateViewOf,
} from "../../../tests/fixtures/environmentStateWrites.js";
it.each([
{ fraction: "123456790", served: true, label: "one nanosecond later" },
{ fraction: "123456788", served: false, label: "one nanosecond earlier" },
{ fraction: "123456789", served: false, label: "the same instant" },
{ fraction: "124456789", served: true, label: "one millisecond later" },
])("checks an update clock at $label", ({ fraction, served }) => {
const first = {
...FIRST_BOOK,
name: "Second title",
updated_at: "2026-10-17T18:00:03.123456789Z",
};
const second = {
...first,
name: "Third title",
updated_at: `2026-10-17T18:00:03.${fraction}Z`,
};
const state = environmentStateViewOf(
timedCaptureEvidence([
["GET", "/api/books", [FIRST_BOOK]],
["GET", "/api/books/1", FIRST_BOOK],
["PATCH", "/api/books/1", first, 200, { name: first.name }],
["GET", "/api/books/1", first],
["PATCH", "/api/books/1", second, 200, { name: second.name }],
["GET", "/api/books/1", second],
]),
);
expect(
state.writes.find((plan) => plan.tool === "patch_books_by_id"),
).toMatchObject({
served,
reason: served ? null : "unexplained-change",
checked_record_ordinals: served ? [3, 5] : [3],
rules: expect.arrayContaining([
{
field: "updated_at",
rule: "clock",
format: { kind: "iso", separator: "T", fraction: 9, zone: "Z" },
},
]),
});
});
it.each([false, true])(
"learns only after an unknown entity is observed again (update while unknown: %s)",
(updateWhileUnknown) => {
const initial = { ...FIRST_BOOK, revision: 1 };
const observed = { ...initial, name: "Second title", revision: 2 };
const final = { ...observed, name: "Third title" };
const unknownCall: TimedCaptureRow[] = updateWhileUnknown
? [["PATCH", "/api/books/1", observed, 200, { name: observed.name }]]
: [];
const state = environmentStateViewOf(
timedCaptureEvidence([
["GET", "/api/books", [initial]],
["GET", "/api/books/1", initial],
["POST", "/api/books/1/import", { queued: true }, 302, {}],
...unknownCall,
["GET", "/api/books/1", observed],
["PATCH", "/api/books/1", final, 200, { name: final.name }],
["GET", "/api/books/1", final],
]),
);
expect(
state.writes.find((plan) => plan.tool === "patch_books_by_id"),
).toMatchObject({
served: true,
reason: null,
exemplar_ordinal: updateWhileUnknown ? 5 : 4,
checked_record_ordinals: [updateWhileUnknown ? 6 : 5],
rules: [{ field: "name", rule: "echo" }],
});
},
);
it.each([
{ requestId: 1, ambiguous: false, served: true },
{ requestId: 2, ambiguous: false, served: true },
{ requestId: 1, ambiguous: true, served: false },
])(
"uses the known identity field for response observation ($requestId, ambiguous: $ambiguous)",
({ requestId, ambiguous, served }) => {
const changed = { ...FIRST_BOOK, name: "Second title" };
const response = {
request_id: requestId,
data: changed,
...(ambiguous ? { other: changed } : {}),
};
const state = environmentStateViewOf(
timedCaptureEvidence([
["GET", "/api/books", [FIRST_BOOK]],
["GET", "/api/books/1", FIRST_BOOK],
["PATCH", "/api/books/1", response, 200, { name: changed.name }],
["GET", "/api/books/1", changed],
]),
);
const plan = state.writes.find(
(candidate) => candidate.tool === "patch_books_by_id",
);
expect(plan?.served).toBe(served);
if (served)
expect(plan).toMatchObject({
observation: { ordinal: 2, pointer: "/data" },
response: { ordinal: 2, shape: "entity", pointer: "/data" },
checked_record_ordinals: [3],
});
},
);
@@ -28,7 +28,6 @@ import {
type SimulatedState,
} from "./environmentStateWriteChecks.js";
import {
compareValues,
explainUpdate,
responseShapeOf,
} from "./environmentStateWriteExplain.js";
@@ -368,12 +367,11 @@ const updatedObservation = (
step.record.ordinal,
(body): Observed | undefined => {
const located =
body === undefined
? undefined
: locateItem(body, entity.id, entity.idField);
body === undefined ? undefined : locateItem(body, entity.id);
const object =
located === undefined ? undefined : at(body!, located.pointer);
return located !== undefined && isObject(object)
return located?.keys.includes(entity.idField) === true &&
isObject(object)
? {
ordinal: step.record.ordinal,
pointer: located.pointer,
@@ -659,9 +657,6 @@ export const updateEntity = (
return UNKNOWN;
}
const { targets, target, recorded } = outcomes(context, entity, after.object);
// An unknown baseline cannot establish this call's changes. Keep its outcome
// without creating or refining rules from values another write may have changed.
if (context.unknown(type, collection, id)) return recorded();
// Only confirmed absence makes a bodyless action; malformed or unretained content stays unknown.
const body =
"body" in named
@@ -675,6 +670,7 @@ export const updateEntity = (
fail(draft, step, "body-unusable");
return recorded();
}
const unknown = context.unknown(type, collection, id);
const call = { ...callOf(step, body), body };
const fitted = fitUpdateClocks(
draft,
@@ -708,7 +704,7 @@ export const updateEntity = (
const explained = { ...fitted.explained, rules: later };
for (const member of keptOrders(call.body, after.object))
draft.kept.add(member);
if (explained.unexplained.length > 0) {
if (explained.unexplained.length > 0 && !unknown) {
refuse(draft, step, "unexplained-change");
return recorded();
}
@@ -717,15 +713,16 @@ export const updateEntity = (
searchFrom(context, step),
itemReads(context, { type, idField, collection, id }),
)?.object;
// Each call with a known baseline must agree with the plan.
// Each call must agree with the plan; one on an unknown entity cannot be checked, so it adds nothing to the plan.
if (
built(draft) &&
(contradictsPlan(
{ ...draft, plan: { ...draft.plan, rules } },
entity.fields,
after.object,
explained,
) ||
(unknown ||
contradictsPlan(
{ ...draft, plan: { ...draft.plan, rules } },
entity.fields,
after.object,
explained,
) ||
// A member calls left out is cleared by every one or by none.
// A reference this call or an earlier one showed counts its member as the field's, whichever came first.
defaultsDisagree(explained.body.defaults, { earlier: draft.updates }, [
@@ -738,7 +735,7 @@ export const updateEntity = (
[...rules, ...explained.rules],
))
) {
refuse(draft, step, "unexplained-change");
if (!unknown) refuse(draft, step, "unexplained-change");
return recorded();
}
draft.plan.rules = rules;
@@ -781,10 +778,9 @@ export const updateEntity = (
after: after.object,
kept,
});
if (!written.ok || disagrees) {
if (!written.ok || (disagrees && !unknown))
refuse(draft, step, "unexplained-change");
return recorded();
}
if (!written.ok || disagrees || unknown) return recorded();
rememberCall(
context.io.retain,
draft,
@@ -897,7 +893,7 @@ const contradictsGeneratedClock = (
candidate.field === rule.field && candidate.rule === "clock",
) ||
next === undefined ||
compareValues(after[rule.field], ownMember(before, rule.field))! <= 0
next.ms <= previous.ms
);
});
@@ -13,7 +13,6 @@ import {
type CreateEvidence,
} from "./environmentStateWriteExplain.js";
import type { UpdateEvidence } from "./environmentStateWriteChanges.js";
import { writtenResponse } from "./environmentStateWriteApply.js";
import {
callRenewal,
unexplainedRenewals,
@@ -505,35 +504,6 @@ it.each<[string, string[]]>([
type Shaped = [WriteResponse["shape"], string | null];
it.each([false, true])(
"keeps response entity selection distinct from repeated envelope metadata, ambiguous=%s",
(ambiguous) => {
const recorded = {
request_id: "42",
data: { id: "42", title: "Captured title" },
...(ambiguous ? { other: { id: "42", title: "Another entity" } } : {}),
};
const shape = responseShapeOf(3, recorded, false, {
idField: "id",
id: "42",
});
expect(
writtenResponse(shape, recorded, {
id: "43",
fields: { id: "43", title: "Authored title" },
}),
).toEqual({
ok: true,
value: ambiguous
? recorded
: {
request_id: "42",
data: { id: "43", title: "Authored title" },
},
});
},
);
it.each<[string, JsonValue | undefined, boolean, Shaped]>([
["no body", undefined, true, ["empty", null]],
["an empty response with a body", { id: "42" }, true, ["empty", null]],
@@ -4,7 +4,7 @@ import { compareUnicodeCodePoints as compare } from "../unicodeCodePointOrder.js
import {
ownMember,
isObject,
locateItems,
locateItem,
pointerTo,
same,
scalarText,
@@ -174,7 +174,7 @@ const requestRule = (
index: BodyIndex,
field: string,
value: JsonValue,
): FieldRule | "ambiguous-path" | undefined => {
): FieldRule | undefined => {
const { body, pathValues } = evidence.call;
if (Object.hasOwn(body, field) && same(body[field], value))
return { field, rule: "echo" };
@@ -185,11 +185,9 @@ const requestRule = (
if (from !== undefined) return { field, rule: "copy", from };
}
const text = scalarText(value);
const paths = [...pathValues].filter(
([, value]) => text !== undefined && text === value,
);
if (paths.length > 1) return "ambiguous-path";
if (paths.length === 1) return { field, rule: "path", from: paths[0]![0] };
for (const [name, pathValue] of pathValues)
if (text !== undefined && text === pathValue)
return { field, rule: "path", from: name };
const slugged = typeof value === "string" && value.length >= 3;
const source = slugged ? index.slugs.get(value) : undefined;
return source === undefined ? undefined : slugRule(field, source);
@@ -359,14 +357,8 @@ export const explainCreate = (
const index = indexBody(evidence.call.body);
// The member each slug the held entities' `&` spellings refused was slugged from.
const refusedSlugs = new Map<string, string>();
let ambiguousPath = false;
for (const [field, value] of Object.entries(evidence.entity)) {
const requested = requestRule(evidence, index, field, value);
if (requested === "ambiguous-path") {
ambiguousPath = true;
unexplained.push(field);
continue;
}
const held = heldSlugRule(requested, evidence.call.body, evidence.type);
if (requested?.rule === "slug" && held === undefined)
refusedSlugs.set(field, requested.from);
@@ -476,14 +468,12 @@ export const explainCreate = (
rules,
body: blank.length === 0 ? shaped : { ...shaped, blank },
// A template rebuilds only its generated value, so other text it keeps ties it like a constant.
tied:
ambiguousPath ||
[
...[...constants, ...templates].map((field) =>
ownMember(evidence.entity, field)!,
),
...literals,
].some((text) => tokens.some((token) => containsDelimited(text, token))),
tied: [
...[...constants, ...templates].map((field) =>
ownMember(evidence.entity, field)!,
),
...literals,
].some((text) => tokens.some((token) => containsDelimited(text, token))),
};
};
@@ -656,11 +646,8 @@ export const responseShapeOf = (
): WriteResponse => {
if (empty) return { ordinal, shape: "empty", pointer: null };
if (body === undefined) return { ordinal, shape: "recorded", pointer: null };
const candidates = locateItems(body, entity.id, entity.idField);
if (candidates.length > 1)
return { ordinal, shape: "recorded", pointer: null };
const located = candidates[0];
if (located !== undefined)
const located = locateItem(body, entity.id);
if (located?.keys.includes(entity.idField) === true)
return { ordinal, shape: "entity", pointer: located.pointer };
if (scalarText(body) === entity.id)
return { ordinal, shape: "identity", pointer: "" };
@@ -22,7 +22,6 @@ const record = (
instant: parseOffsetDateTimeInstant(new Date(start).toISOString()),
ms: start,
},
end: parseOffsetDateTimeInstant(new Date(end).toISOString()),
endMs: end,
},
});
@@ -69,7 +68,7 @@ describe("short-id creation limits", () => {
const prior = record(0, "GET", "/api/projects", [0, 5]);
const unknown = {
...prior,
timing: { ...prior.timing, end: undefined, endMs: undefined },
timing: { ...prior.timing, endMs: undefined },
};
const post = record(1, "POST", "/api/imports", [10, 11]);
const later = record(2, "GET", "/api/projects/7", [20, 21]);
@@ -23,7 +23,6 @@ import {
import { ENTRY_BYTES, textBytes } from "./webNetworkRouteTemplates.js";
import {
pathValuesOf,
pathParameterKeys,
type FieldRule,
type WriteEffect,
} from "./environmentStateWriteRules.js";
@@ -415,9 +414,13 @@ export const anyLinkedSource = (
plan.collection !== null &&
normalizedPath(plan.collection) === plan.target &&
!segmentsOf(plan.target).some(isParam) &&
[...pathParameterKeys(template).values()].every((name) =>
plan.rules.some((rule) => rule.rule === "path" && rule.from === name),
);
segmentsOf(template)
.filter(isParam)
.every((segment) =>
plan.rules.some(
(rule) => rule.rule === "path" && rule.from === segment.slice(1, -1),
),
);
/**
* Whether a linked create's call sends the path values its exemplar did, each as recorded or in the
@@ -407,23 +407,6 @@ export interface WriteCall {
readonly body: JsonObject | undefined;
}
/** Parameter lookup keys preserve repeated placeholder occurrences by segment position. */
export const pathParameterKeys = (template: string): Map<number, string> => {
const parameters = segmentsOf(template).flatMap((segment, index) =>
isParam(segment) ? [[index, segment.slice(1, -1)] as const] : [],
);
const repeated =
new Set(parameters.map(([, name]) => name)).size !== parameters.length;
// If any name repeats, encode every key so a literal name cannot collide with
// an indexed key. Unique-name templates retain their established lookup keys.
return new Map(
parameters.map(([index, name]) => [
index,
repeated ? JSON.stringify([index, name]) : name,
]),
);
};
/** The value each template parameter takes in a concrete path, decoded; undefined when the path does not instantiate it. */
export const pathValuesOf = (
template: string,
@@ -433,14 +416,13 @@ export const pathValuesOf = (
const concrete = segmentsOf(path);
if (pattern.length !== concrete.length) return undefined;
const values = new Map<string, string>();
const keys = pathParameterKeys(template);
for (const [index, segment] of pattern.entries()) {
const value = concrete[index]!;
if (!isParam(segment)) {
if (segment !== value) return undefined;
continue;
}
values.set(keys.get(index)!, decodedSegment(value));
values.set(segment.slice(1, -1), decodedSegment(value));
}
return values;
};
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { jsonObjectSchema, type JsonValue } from "../jsonValue.js";
import type { JsonValue } from "../jsonValue.js";
import { webNetworkCaptureSchema } from "../webNetworkCapture.js";
import { EnvironmentStateAccumulator } from "./EnvironmentStateAccumulator.js";
import {
@@ -86,43 +86,6 @@ it("retains learned clock rules but withholds their serving when the global cloc
).toEqual(plan);
});
it.each([
["000000000", 0, "2026-10-17T18:00:12.000Z"],
["000001", 0, "2026-10-17T18:00:13.000Z"],
["000000001", 0, "2026-10-17T18:00:13.000Z"],
["123000000", 0, "2026-10-17T18:00:13.000Z"],
["999999", 0.001, "2026-10-17T18:00:13.000Z"],
["999999", 0.001001, "2026-10-17T18:00:14.000Z"],
])(
"starts the served clock after a final captured fractional instant .%s plus %sms",
(fraction, duration, expected) => {
const evidence = timedCaptureEvidence([
...BOOK_ROWS,
["GET", "/api/health", { ready: true }, 200, undefined, 0],
]);
const capture = webNetworkCaptureSchema.parse(evidence.normalized_result);
const state = environmentStateViewOf({
...evidence,
normalized_result: {
...capture,
records: capture.records.map((record, index) =>
index === capture.records.length - 1
? {
...record,
reported: {
...jsonObjectSchema.parse(record.reported),
startedDateTime: `2026-10-17T18:00:12.${fraction}Z`,
time: duration,
},
}
: record,
),
},
});
expect(state.write_clock).toBe(expected);
},
);
it.each(["delete", "mismatched-query"])(
"does not parse unused request content for %s under a bounded state allowance",
(kind) => {
@@ -2,10 +2,6 @@ import type { ExtractedState } from "./environmentStateRules.js";
import { EnvironmentStateWriteSimulation } from "./EnvironmentStateWriteSimulation.js";
import type { WriteSimulationInput } from "./environmentStateWriteEffects.js";
import { usesWriteClock } from "./environmentStateWriteRules.js";
import {
compareOffsetDateTimeInstants,
type OffsetDateTimeInstant,
} from "../offsetDateTime.js";
/** The latest end a record can have and still round up to a whole second a date can hold. */
const LATEST_END_MS = 8_640_000_000_000_000 - 1000;
@@ -17,23 +13,17 @@ const clockStart = (
// The clock is global: any unknown completion may extend beyond every known end.
if (records.some((record) => record.timing.endMs === undefined)) return null;
// A record timed past the range of a date carries no usable time, so the clock starts from the others.
let latest: OffsetDateTimeInstant | undefined;
for (const record of records) {
if (
record.timing.endMs === undefined ||
Math.abs(record.timing.endMs) > LATEST_END_MS
)
continue;
const end = record.timing.end;
if (end === undefined) return null;
if (latest === undefined || compareOffsetDateTimeInstants(end, latest) > 0)
latest = end;
}
return latest === undefined
? null
: new Date(
(latest.epochSecond + (latest.fraction === "" ? 0 : 1)) * 1000,
).toISOString();
const end = records.reduce(
(latest, record) =>
record.timing.endMs !== undefined &&
Math.abs(record.timing.endMs) <= LATEST_END_MS
? Math.max(latest, record.timing.endMs)
: latest,
Number.NEGATIVE_INFINITY,
);
return Number.isFinite(end)
? new Date(Math.ceil(end / 1000) * 1000).toISOString()
: null;
};
/**
@@ -44,14 +44,6 @@ export type RecordedResponseDeclaration = ReturnType<
typeof readRecordedResponseDeclaration
>;
/** HAR's zero status sentinel and nonnumeric declarations do not prove a response status. */
export const recordedResponseStatus = (
declaration: RecordedResponseDeclaration,
): number | null => {
const status = declaration?.status;
return typeof status === "number" && status !== 0 ? status : null;
};
/** Strict, and keeping a leading byte-order mark, so callers see the recorded text unchanged. */
const strictUtf8 = (): TextDecoder =>
new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
@@ -91,28 +83,6 @@ export const readRecordedResponseBody = (
): RecordedResponseBody => {
const content = declaration?.content;
if (content == null) return { bodyText: null };
const bodyCoordinates = [
"/response/content/text",
"/response/content/encoding",
];
const within = (pointer: string, ancestor: string): boolean =>
pointer.startsWith(ancestor) &&
(pointer.length === ancestor.length || pointer[ancestor.length] === "/");
// Explicit value exclusions also govern imported retained facts. Keep their
// source text in complete Evidence, but never promote it into replay/state.
if (
record.redactions.some(
(redaction) =>
!("scope" in redaction) &&
bodyCoordinates.some((pointer) => within(pointer, redaction.pointer)),
) ||
record.binary_fields.some(
(field) =>
field.state === "redacted" &&
bodyCoordinates.some((pointer) => within(pointer, field.pointer)),
)
)
return { bodyText: null };
const { text, encoding } = content;
if (typeof text !== "string") return { bodyText: null };
if (encoding === undefined) {
@@ -3,11 +3,7 @@ import { createHash } from "node:crypto";
import { createEvidence } from "../evidence.js";
import { freezeJsonSnapshot } from "../immutableJson.js";
import { webNetworkCaptureSchema } from "../webNetworkCapture.js";
import {
projectAnalysisView,
analysisViewRequestSchema,
} from "./analysisView.js";
import { timedCaptureEvidence } from "../../../tests/fixtures/analysisView.js";
import { projectAnalysisView } from "./analysisView.js";
const capture = () => {
const body = Buffer.from("original body");
@@ -804,6 +800,18 @@ it.each([
"bytes that are not its text",
{ binaryFields: [retainedBytes("/request/path", "/b")] },
],
[
"a byte count that is not its text",
{ binaryFields: [{ ...retainedBytes("/request/path", "/a"), bytes: 3 }] },
],
[
"a digest that is not its text",
{
binaryFields: [
{ ...retainedBytes("/request/path", "/a"), sha256: "0".repeat(64) },
],
},
],
] as const)(
"keeps a mitmproxy path with %s out of the preview",
(_label, facets) => {
@@ -1032,111 +1040,3 @@ it.each(["scheme", "host", "port", "authority"])(
expect(JSON.stringify(reported)).not.toContain("password");
},
);
it.each(["base64", "bytes", "sha256", "duplicate"] as const)(
"refuses inconsistent retained binary %s at parent admission",
(fault) => {
const { parent, report } = capture();
const record = report.records[0]!;
const binary = record.binary_fields[0]!;
if (binary.state !== "retained")
throw new Error("Expected retained source bytes");
const changed = {
...binary,
...(fault === "base64"
? { content_base64: "!!!!" }
: fault === "bytes"
? { bytes: binary.bytes + 1 }
: { sha256: "0".repeat(64) }),
};
const invalid = {
...report,
records: [
{
...record,
binary_fields: fault === "duplicate" ? [binary, binary] : [changed],
},
],
};
const projected = projectAnalysisView(
{ ...parent, normalizedResult: invalid },
{ kind: "capture", detail: "preview", offset: 0, limit: 1 },
);
expect(projected).toMatchObject({
ok: false,
error: { _tag: "AnalysisInputError" },
});
},
);
it.each([
"/response/content/text",
"/response/content",
"/response",
"",
"binary-redaction",
"control",
])(
"respects retained response-body authority without altering raw facts: %s",
(marker) => {
const seed = timedCaptureEvidence([
["GET", "/api/items", [{ id: 1, name: "withheld-value" }]],
]);
const report = webNetworkCaptureSchema.parse(seed.normalized_result);
const record = report.records[0]!;
if (marker === "binary-redaction")
record.binary_fields.push({
pointer: "/response/content/text",
representation: "har-base64-content",
state: "redacted",
content_base64: null,
bytes: null,
sha256: null,
});
else if (marker !== "control")
record.redactions.push({
pointer: marker,
reason: "explicit-sensitive-value",
});
const evidence = createEvidence(
{
path: report.artifact.path,
sha256: report.artifact.sha256!,
format: "file",
},
report.decoder,
{
operation: seed.operation,
parameters: seed.parameters,
result: report,
},
);
const parent = {
predicateType: evidence.predicate_type,
evidenceId: evidence.evidence_id,
operation: evidence.operation,
normalizedResult: evidence.normalized_result,
subject: evidence.subject,
parameters: evidence.parameters,
limitations: evidence.limitations,
};
const state = projectAnalysisView(
parent,
analysisViewRequestSchema.parse({ kind: "environment-state" }),
);
if (!state.ok || state.value.kind !== "environment-state")
throw new Error("Expected retained state projection");
expect(
JSON.stringify(state.value.entities).includes("withheld-value"),
).toBe(marker === "control");
const complete = projectAnalysisView(parent, {
kind: "capture",
detail: "complete",
offset: 0,
limit: 1,
});
expect(
complete.ok && JSON.stringify(complete.value).includes("withheld-value"),
).toBe(true);
},
);
@@ -1,8 +1,5 @@
import { z } from "zod";
import {
readRecordedResponseDeclaration,
recordedResponseStatus,
} from "./recordedResponseBody.js";
import { readRecordedResponseDeclaration } from "./recordedResponseBody.js";
import {
parseRecordedTransportUrl,
parseUrlQueryParameterNames,
@@ -151,6 +148,7 @@ export const readRecordedRequestLine = (
return excluded("method-unavailable");
if (parsed.path === null) return excluded("url-unparseable");
const response = readRecordedResponseDeclaration(reported);
const status = response?.status;
const mime = response?.content?.mimeType;
const rawMime = typeof mime === "string" ? mime : "";
if (!temporary(textBytes(rawMime.length))) return null;
@@ -160,7 +158,7 @@ export const readRecordedRequestLine = (
origin: parsed.origin,
path: parsed.path,
query: parsed.query,
status: recordedResponseStatus(response),
status: typeof status === "number" && status !== 0 ? status : null,
rawMime,
};
};
@@ -650,6 +650,32 @@ it("connects exposed closures to IPC sends and text clipboard calls", () => {
expect(ipcPath?.steps.at(-1)).toBeDefined();
expect(ipcFinding?.location).toEqual(ipcPath?.steps.at(-1)?.location);
});
it("uses a defineProperty value that replaces an exported sender", () => {
const result = analyzeJavaScriptStaticSource(`(() => {
const modules = {
2(module) { module.exports = require("electron"); },
9(module,exports,load) {
const electron=load(2),helper=load(5);
electron.contextBridge.exposeInMainWorld("desktop", {copy:helper.sender("defined:copy")});
},
5(module,exports,load) {
const electron=load(2);
exports.sender=function(){return ()=>{};};
Object.defineProperty(exports,"sender",{value:function(channel){return (...args)=>send(channel,...args);}});
function send(channel,...args){electron.ipcRenderer.send(channel,...args);}
}
};
function load(id) { const m={exports:{}}; modules[id].call(m.exports,m,m.exports,load); return m.exports; }
load(9);
})();`);
expect(result.electron.call_paths).toContainEqual(
expect.objectContaining({
entry: "desktop.copy",
channel: "defined:copy",
terminal: "electron.ipcRenderer.send",
}),
);
});
it("resolves a helper factory listed after its bundle consumer", () => {
const result = analyzeJavaScriptStaticSource(`(() => {
const modules = {
@@ -1,7 +1,11 @@
import * as t from "@babel/types";
import type { javascriptBootstrapAt } from "./javascriptBootstrap.js";
import { semanticStaticPropertyKey } from "./javascriptAstValues.js";
import {
semanticStaticPropertyKey,
semanticWriteSiteTarget,
semanticWriteTargets,
} from "./javascriptAstValues.js";
import { semanticIdentifierRole } from "./javascriptSemanticProjection.js";
import {
resolveSemanticBindingState,
@@ -285,8 +289,13 @@ const moduleObjectContained = (
state: JavaScriptSemanticAnalysisState,
): boolean => {
let contained = true;
const writtenTargets = new Set<t.Node>();
traverseJavaScriptValueAst(loader.body, state.erasedSyntaxNodes, {
enter: (node, parent, readAncestors) => {
const target = semanticWriteSiteTarget(node);
if (target !== null)
for (const leaf of semanticWriteTargets(target))
writtenTargets.add(leaf);
if (!contained || !t.isIdentifier(node) || parent === null) return;
const ancestors = readAncestors();
if (semanticIdentifierRole(node, parent, () => ancestors) === null)
@@ -294,7 +303,7 @@ const moduleObjectContained = (
if (bindingOf(state, node) !== moduleId) return;
contained =
!ancestors.some((ancestor) => t.isFunction(ancestor)) &&
moduleUse(node, [parent, ancestors.at(-2)], dispatch);
moduleUse(node, parent, dispatch, writtenTargets.has(parent));
},
});
return contained;
@@ -302,21 +311,18 @@ const moduleObjectContained = (
const moduleUse = (
node: t.Identifier,
[parent, grandparent]: readonly [t.Node, t.Node | undefined],
parent: t.Node,
dispatch: t.CallExpression,
written: boolean,
): boolean => {
if (parent === dispatch) return dispatch.arguments.includes(node);
if (!t.isMemberExpression(parent) || parent.object !== node) return false;
const key = semanticStaticPropertyKey(parent.property, parent.computed);
const written =
(t.isAssignmentExpression(grandparent) && grandparent.left === parent) ||
t.isUpdateExpression(grandparent) ||
t.isUnaryExpression(grandparent, { operator: "delete" });
if (written) return key !== null && key !== "exports";
if (key !== "exports") return false;
// `module.exports` passed to the factory, or read after the dispatch ran.
return (
grandparent === dispatch ||
dispatch.arguments.some((argument) => argument === parent) ||
(parent.start ?? -1) > (dispatch.end ?? Number.POSITIVE_INFINITY)
);
};
@@ -10,9 +10,54 @@ import {
completeSemanticSteps,
traverseJavaScriptAst,
} from "./javascriptSemanticTraversal.js";
import { createGlobalReferenceReader } from "./javascriptSemanticAmbientWrites.js";
import {
createGlobalPathReader,
createGlobalReferenceReader,
} from "./javascriptSemanticAmbientWrites.js";
import { parseJavaScriptSource } from "./javascriptSourceParser.js";
it.each([
[
"await value; Object.prototype.then=null; await value;",
["unmodified", "null"],
],
[
"Object.prototype.then=function(resolve){resolve()}; await value;",
["function"],
],
["if(flag) Object.prototype.then=null; await value;", ["unknown"]],
["mutate(Object.prototype); await value;", ["unknown"]],
["await value; mutate(Object.prototype);", ["unmodified"]],
["Array.prototype.then=null; await value;", ["unmodified"]],
] as const)(
"reads intrinsic property facts at implicit await sites: %s",
(source, expected) => {
const parsed = parseJavaScriptSource(source, "app.mjs");
if (parsed === null) throw new Error("Expected implicit read fixture");
const state = completeSemanticSteps(
indexJavaScriptBindingsSteps(parsed.program),
);
const read = createGlobalPathReader(state, ["Object", "prototype", "then"]);
const statuses: string[] = [];
traverseJavaScriptAst(parsed.program, {
enter(node) {
if (!t.isAwaitExpression(node)) return;
const fact = read(node);
statuses.push(
fact.status === "replacement"
? t.isNullLiteral(fact.value)
? "null"
: t.isFunction(fact.value)
? "function"
: "other"
: fact.status,
);
},
});
expect(statuses).toEqual(expected);
},
);
it("keeps wrapper module aliases distinct from global-object module properties", () => {
const parsed = parseJavaScriptSource(
`
@@ -306,6 +351,11 @@ it.each([
"Object.defineProperty=()=>{};inspect(globalThis.Object.defineProperty);",
["replacement"],
],
[
"an escaped global owner",
"mutate(Object);inspect(Object.defineProperty);",
["unknown"],
],
[
"a namespaced global",
"inspect(globalThis.Object.defineProperty);globalThis.Object.defineProperty=()=>{};inspect(globalThis.Object.defineProperty);",
@@ -902,37 +902,92 @@ export const createGlobalReferenceReader = (
state: JavaScriptSemanticAnalysisState,
path: readonly [string, ...string[]],
): ((callee: t.Node) => GlobalReferenceRead) => {
const selected = ["global", ...path];
let generation = -1;
const byNamespace = new Map<
string | null,
typeof state.ambientWriteEffects
>();
const read = createGlobalPathRead(state, path);
return (callee) => {
const namespace = globalReferenceNamespace(callee, state, path);
if (namespace === undefined) return { status: "unrelated" };
return namespace === undefined
? { status: "unrelated" }
: read(callee, namespace);
};
};
/** Read an implicit intrinsic property at its actual source site, without inventing a callee. */
export const createGlobalPathReader = (
state: JavaScriptSemanticAnalysisState,
path: readonly [string, ...string[]],
): ((site: t.Node) => GlobalReferenceRead) => {
const read = createGlobalPathRead(state, path);
return (site) => read(site, null);
};
const createGlobalPathRead = (
state: JavaScriptSemanticAnalysisState,
path: readonly [string, ...string[]],
): ((site: t.Node, namespace: string | null) => GlobalReferenceRead) => {
const selected = ["global", ...path];
let writesGeneration = -1;
let escapesGeneration = -1;
const byNamespace = new Map<
string | null,
{
writes: typeof state.ambientWriteEffects;
escapes: typeof state.ambientEscapeEffects;
}
>();
return (site, namespace) => {
const covered = (paths: typeof state.ambientWrites): boolean =>
paths.covers(selected) ||
(namespace !== null && paths.covers(["global", namespace]));
const writesCovered = covered(state.ambientWrites);
const escapesCovered = covered(state.ambientEscapes);
if (!writesCovered && !escapesCovered) return { status: "unmodified" };
if (
!state.ambientWrites.covers(selected) &&
(namespace === null || !state.ambientWrites.covers(["global", namespace]))
)
return { status: "unmodified" };
if (generation !== state.ambientWriteEffects.length) {
generation = state.ambientWriteEffects.length;
writesGeneration !== state.ambientWriteEffects.length ||
escapesGeneration !== state.ambientEscapeEffects.length
) {
writesGeneration = state.ambientWriteEffects.length;
escapesGeneration = state.ambientEscapeEffects.length;
byNamespace.clear();
}
let effects = byNamespace.get(namespace);
if (effects === undefined) {
effects = state.ambientWriteEffects.filter(
(effect) =>
ambientEffectCovers(effect, selected) ||
(namespace !== null &&
ambientEffectCovers(effect, ["global", namespace])),
);
const matches = (
effect: (typeof state.ambientWriteEffects)[number],
): boolean =>
ambientEffectCovers(effect, selected) ||
(namespace !== null &&
ambientEffectCovers(effect, ["global", namespace]));
effects = {
writes: state.ambientWriteEffects.filter(matches),
escapes: state.ambientEscapeEffects.filter(matches),
};
byNamespace.set(namespace, effects);
}
if (effects.length === 0) return { status: "unknown" };
const active = effects.filter(
(effect) => semanticEffectTiming(callee, effect.node, state) !== "after",
// The call that escapes a receiver contains the callee being identified.
// That escape cannot have replaced the callee before the call selects it.
const escapePrecedesRead = effects.escapes.some((effect) => {
const escape = effect.node;
if (
escape !== undefined &&
escape !== site &&
typeof escape.start === "number" &&
typeof escape.end === "number" &&
typeof site.start === "number" &&
typeof site.end === "number" &&
site.start >= escape.start &&
site.end <= escape.end
)
return false;
return semanticEffectTiming(site, escape, state) !== "after";
});
if (
(escapesCovered && effects.escapes.length === 0) ||
escapePrecedesRead ||
(writesCovered && effects.writes.length === 0)
)
return { status: "unknown" };
const active = effects.writes.filter(
(effect) => semanticEffectTiming(site, effect.node, state) !== "after",
);
if (active.length === 0) return { status: "unmodified" };
if (
@@ -943,47 +998,57 @@ export const createGlobalReferenceReader = (
)
)
return { status: "unknown" };
let replacement: t.AssignmentExpression | undefined;
for (const effect of active) {
const write = effect.node;
const writeNamespace = t.isAssignmentExpression(write)
? globalReferenceNamespace(write.left, state, path)
: undefined;
if (
t.isAssignmentExpression(write, { operator: "=" }) &&
writeNamespace !== undefined &&
(writeNamespace === null ||
!state.ambientWrites.covers(["global", writeNamespace])) &&
semanticDefiniteEffectPrecedesReference(
write,
callee,
state.parentsByNode,
) &&
(replacement === undefined ||
semanticDefiniteEffectPrecedesReference(
replacement,
write,
state.parentsByNode,
))
)
replacement = write;
}
if (replacement === undefined) return { status: "unknown" };
const confirmed = replacement;
if (
active.some(
(effect) =>
effect.node !== confirmed &&
semanticEffectTiming(confirmed, effect.node, state) !== "before" &&
(effect.node === undefined ||
!semanticDefiniteEffectPrecedesReference(
effect.node,
confirmed,
state.parentsByNode,
)),
)
)
return { status: "unknown" };
return { status: "replacement", value: confirmed.right };
return globalPathReplacement(state, path, site, active);
};
};
/** Select a definite replacement only when every competing write is proven earlier. */
const globalPathReplacement = (
state: JavaScriptSemanticAnalysisState,
path: readonly [string, ...string[]],
site: t.Node,
effects: typeof state.ambientWriteEffects,
): GlobalReferenceRead => {
let replacement: t.AssignmentExpression | undefined;
for (const effect of effects) {
const write = effect.node;
const writeNamespace = t.isAssignmentExpression(write)
? globalReferenceNamespace(write.left, state, path)
: undefined;
if (
t.isAssignmentExpression(write, { operator: "=" }) &&
writeNamespace !== undefined &&
(writeNamespace === null ||
!state.ambientWrites.covers(["global", writeNamespace])) &&
semanticDefiniteEffectPrecedesReference(
write,
site,
state.parentsByNode,
) &&
(replacement === undefined ||
semanticDefiniteEffectPrecedesReference(
replacement,
write,
state.parentsByNode,
))
)
replacement = write;
}
if (replacement === undefined) return { status: "unknown" };
const confirmed = replacement;
if (
effects.some(
(effect) =>
effect.node !== confirmed &&
semanticEffectTiming(confirmed, effect.node, state) !== "before" &&
(effect.node === undefined ||
!semanticDefiniteEffectPrecedesReference(
effect.node,
confirmed,
state.parentsByNode,
)),
)
)
return { status: "unknown" };
return { status: "replacement", value: confirmed.right };
};
@@ -249,3 +249,20 @@ it("does not initialize a class self name during computed-key evaluation", () =>
expect(ir.callSites[0]).toBeDefined();
expect(ir.callSites[0]?.resolution).not.toBe("exact");
});
it.each(["__proto__", '"__proto__"', "['__proto__']"])(
"distinguishes prototype setters from own data keys in loader-write scoping: %s",
(key) => {
const ir = analyze(`
with ({require: local, [Symbol.unscopables]: {${key}: {require: true}}}) {
require = replacement;
}
const selected = require("node:fs");
`);
const selected = onlyBinding(ir, "selected");
if (key.startsWith("[")) {
// A computed key is an own data property, so it supplies no inherited exclusion.
expect(selected.provenance.status).toBe("module");
} else expect(selected.provenance.status).not.toBe("module");
},
);
@@ -1,7 +1,11 @@
import { describe, expect, it } from "vitest";
import { runInNewContext } from "node:vm";
import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js";
import { onlyCallable } from "./javascriptSemanticAnalysis.fixture.js";
import {
onlyBinding,
onlyCallable,
} from "./javascriptSemanticAnalysis.fixture.js";
const resultValue = (body: string) =>
onlyCallable(
@@ -9,6 +13,128 @@ const resultValue = (body: string) =>
"result",
).returnSites[0]?.value;
it.each([
[
"own getter",
"const value = { count: 1, get then() { this.count = 2; } };",
2,
],
[
"own method",
"const value = { count: 1, then(resolve) { this.count = 2; resolve(); } };",
2,
],
[
"function-valued slot",
"const value = { count: 1, then: function(resolve) { this.count = 2; resolve(); } };",
2,
],
[
"getter returning a method",
"const value = { count: 1, get then() { return function(resolve) { this.count = 2; resolve(); }; } };",
2,
],
[
"inherited getter",
"const proto = { get then() { this.count = 2; } }; const value = { __proto__: proto, count: 1 };",
2,
],
[
"inherited method",
"const proto = { then(resolve) { this.count = 2; resolve(); } }; const value = { __proto__: proto, count: 1 };",
2,
],
[
"class method",
"class Value { count = 1; then(resolve) { this.count = 2; resolve(); } } const value = new Value();",
2,
],
[
"stored method",
"const value = { count: 1 }; value.then = function(resolve) { this.count = 2; resolve(); };",
2,
],
[
"method stored on a copied shared child",
"const source = { child: { count: 1 } }; const box = { ...source }; source.child.then = function(resolve) { this.count = 2; resolve(); }; const value = box.child;",
2,
],
[
"copied method retained after source replacement",
"const source = { count: 1, then(resolve) { this.count = 2; resolve(); } }; const value = { ...source }; source.then = null;",
2,
],
["inert object", "const value = { count: 1 };", 1],
["noncallable then", "const value = { count: 1, then: null };", 1],
[
"noncallable getter result",
"const value = { count: 1, get then() { return 3; } };",
1,
],
[
"own noncallable shadow",
"const proto = { get then() { this.count = 2; } }; const value = { __proto__: proto, count: 1, then: null };",
1,
],
[
"method replaced before await",
"const value = { count: 1, then(resolve) { this.count = 2; resolve(); } }; value.then = null;",
1,
],
] as const)(
"accounts for thenable effects at await: %s",
async (_kind, declaration, expected) => {
const body = `${declaration} await value; return value.count;`;
expect(await runInNewContext(`(async () => { ${body} })()`)).toBe(expected);
const ir = analyzeJavaScriptSemantics(
`export async function result() { ${body} }`,
);
const value = onlyCallable(ir, "result").returnSites[0]?.value;
if (expected === 2) expect(value?.status).toBe("unknown");
else expect(value).toEqual({ status: "literal", value: 1 });
},
);
it("keeps a then slot captured before a later top-level source write", async () => {
const body = `
const source = { count: 1 };
const value = { ...source };
source.then = function(resolve) { this.count = 2; resolve(); };
await value;
const observed = value.count;
`;
expect(
await runInNewContext(`(async () => { ${body} return observed; })()`),
).toBe(1);
expect(
onlyBinding(analyzeJavaScriptSemantics(body), "observed").value,
).toEqual({ status: "literal", value: 1 });
});
it.each([
"const value = { count: 1, then: external };",
"const value = { count: 1, then: null }; value.then = external;",
])("keeps an externally supplied then callable possible: %s", async (slot) => {
const body = `${slot} await value; return value.count;`;
for (const external of [
null,
function (this: { count: number }, resolve: () => void) {
this.count = 2;
resolve();
},
]) {
expect(
await runInNewContext(`(async () => { ${body} })()`, { external }),
).toBe(external === null ? 1 : 2);
}
const ir = analyzeJavaScriptSemantics(
`export async function result(external) { ${body} }`,
);
expect(onlyCallable(ir, "result").returnSites[0]?.value.status).toBe(
"unknown",
);
});
it.each([
["field = shared;", "declare field: { mode: string };", "new Derived()"],
[
@@ -41,6 +167,83 @@ it.each([
},
);
it("retains then invocation effects through a getter-invoked async callable", async () => {
const body = `
const value = {
count: 1,
get start() { (async () => { await this; })(); return null; },
then(resolve) { this.count = 2; resolve(); },
};
await value.start;
return value.count;
`;
expect(await runInNewContext(`(async () => { ${body} })()`)).toBe(2);
const ir = analyzeJavaScriptSemantics(
`export async function result() { ${body} }`,
);
expect(onlyCallable(ir, "result").returnSites[0]?.value.status).toBe(
"unknown",
);
});
it.each([
[
"Object.prototype.then = function(resolve) { this.count = 2; resolve(); }; const value = { count: 1 }; await value; return value.count;",
2,
],
[
"Array.prototype.then = function(resolve) { this[0] = 2; resolve(); }; const value = [1]; await value; return value[0];",
2,
],
[
"Object.prototype.then = function(resolve) { this.count = 2; resolve(); }; const value = { count: 1, then: null }; await value; return value.count;",
1,
],
[
"Object.prototype.then = function(resolve) { this.count = 2; resolve(); }; const value = { __proto__: null, count: 1 }; await value; return value.count;",
1,
],
[
"Array.prototype.then = function(resolve) { this[0] = 2; resolve(); }; const value = [1]; value.then = null; await value; return value[0];",
1,
],
[
"Object.prototype.then = function(resolve) { this[0] = 2; resolve(); }; const value = [1]; await value; return value[0];",
2,
],
[
"Object.prototype.then = function(resolve) { this[0] = 2; resolve(); }; Array.prototype.then = null; const value = [1]; await value; return value[0];",
1,
],
[
"Object.prototype.then = null; const value = { count: 1 }; await value; return value.count;",
1,
],
[
"Object.defineProperty(Object.prototype, 'then', { get() { this.count = 2; } }); const value = { count: 1 }; await value; return value.count;",
2,
],
[
"Array.prototype.__proto__ = { then(resolve) { this[0] = 2; resolve(); } }; const value = [1]; await value; return value[0];",
2,
],
[
"Object.prototype.then = function(resolve) { this[0] = 2; resolve(); }; Array.prototype.__proto__ = null; const value = [1]; await value; return value[0];",
1,
],
] as const)(
"retains source-written prototype then invocation: %s",
async (body, expected) => {
expect(await runInNewContext(`(async () => { ${body} })()`)).toBe(expected);
const ir = analyzeJavaScriptSemantics(
`export async function result() { ${body} }`,
);
const value = onlyCallable(ir, "result").returnSites[0]?.value;
if (expected === 2) expect(value?.status).toBe("unknown");
else expect(value).toEqual({ status: "literal", value: 1 });
},
);
it.each(["field;", "field = { mode: 'other' };"])(
"preserves a real derived field's shadowing: %s",
(field) => {
@@ -269,3 +269,18 @@ describe("JavaScript semantic analysis: configuration value escapes", () => {
expect(configurationReads(body)).toEqual([]);
});
});
it.each([
["if (flag) child = spawn('tool');", "partial"],
["child = spawn('tool');", "complete"],
] as const)(
"keeps child-process incoming parameter uncertainty after %s",
(assignment, resolution) => {
const ir = analyzeJavaScriptSemantics(
`import { spawn } from 'node:child_process'; function stop(child, flag) { ${assignment} child.kill(); }`,
);
expect(ir.childProcessInteractions).toEqual([
expect.objectContaining({ kind: "signal", resolution }),
]);
},
);
@@ -28,6 +28,13 @@ const eventsFor = (call: string) =>
}));
it.each([
["a missing on listener", `e.on("x")`],
["a missing once listener", `e.once("x")`],
["a missing addListener listener", `e.addListener("x")`],
["a missing prependListener listener", `e.prependListener("x")`],
["a missing prependOnceListener listener", `e.prependOnceListener("x")`],
["a missing removeListener listener", `e.removeListener("x")`],
["a missing off listener", `e.off("x")`],
["a numeric listener", `e.on("x", 1)`],
["a string listener", `e.on("x", "fn")`],
["a template-literal listener", 'e.on("x", `fn`)'],
@@ -22,6 +22,54 @@ const releases = (source: string) =>
resolution,
}));
it.each([
["net", "connect", "-0"],
["net", "createConnection", "-0.0"],
["tls", "connect", "-0"],
["net", "connect", "typeof 1.5"],
["net", "connect", "~(-1.5)"],
["tls", "connect", "void 0, { port: 443 }"],
] as const)(
"retains %s.%s(%s) and its socket release when the unary result is valid",
(module, method, argument) => {
expect(
operations(
`import socket from "node:${module}"; const handle = socket.${method}(${argument}); handle.destroy();`,
).map(({ kind, method, resolution }) => ({ kind, method, resolution })),
).toEqual([
{ kind: "acquire", method, resolution: "complete" },
{ kind: "release", method: "destroy", resolution: "complete" },
]);
},
);
it.each([
["connect", "void 0"],
["createConnection", "void value"],
["connect", "undefined"],
["connect", "void 0, { port: 443 }"],
] as const)(
"emits no net.%s acquisition or release for a missing first argument: %s",
(method, argument) => {
expect(
operations(
`import net from 'node:net'; const handle = net.${method}(${argument}); handle.destroy();`,
),
).toEqual([]);
},
);
it("retains unknown net argument positions and shadowed undefined values", () => {
for (const body of [
"function use(undefined) { const handle=net.connect(undefined);handle.destroy(); }",
"const handle=net.connect(...args, void 0);handle.destroy();",
])
expect(operations(`import net from 'node:net';${body}`)).toEqual([
expect.objectContaining({ kind: "acquire", method: "connect" }),
expect.objectContaining({ kind: "release", method: "destroy" }),
]);
});
const commonJsPromise = `const { open } = require("node:fs/promises"); const h = open("input.txt"); h.close();`;
it("keeps conditional release facts when the loader is unknown", () => {
@@ -198,3 +246,17 @@ it("keeps an awaited handle release uncertain after an opaque receiver write", (
),
).toEqual([{ method: "close", linked: 1, resolution: "partial" }]);
});
it.each([
["if (flag) handle = createReadStream('input.txt');", "partial"],
["handle = createReadStream('input.txt');", "complete"],
] as const)(
"preserves incoming parameter uncertainty before release: %s",
(assignment, resolution) => {
expect(
releases(
`import { createReadStream } from 'node:fs'; function release(handle, flag) { ${assignment} handle.destroy(); }`,
),
).toEqual([{ method: "destroy", linked: 1, resolution }]);
},
);
@@ -16,7 +16,8 @@ import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js";
* net.connect("") -> THREW ERR_SOCKET_BAD_PORT
* net.connect("/tmp/x.sock") -> Socket created (Unix socket path)
* net.connect({ port: 80 }) -> Socket created
* net.connect(undefined) / {} / [] -> THREW ERR_MISSING_ARGS (undecided here)
* net.connect() / undefined / void 0 -> THREW ERR_MISSING_ARGS
* net.connect({}) / [] -> THREW ERR_MISSING_ARGS (undecided here)
*/
const socketAcquisitionsFor = (call: string) =>
analyzeJavaScriptSemantics(
@@ -34,6 +35,10 @@ it.each([
["a boolean port", `net.connect(true)`],
["a null port", `net.connect(null)`],
["an empty path", `net.connect("")`],
["an omitted argument", `net.connect()`],
["an explicit undefined port", `net.connect(undefined)`],
["a void port", `net.connect(void 0)`],
["an omitted createConnection argument", `net.createConnection()`],
["a tls port above the range", `tls.connect(65536)`],
["a tls negative port", `tls.connect(-1)`],
["a tls NaN port", `tls.connect(NaN)`],
@@ -49,8 +54,12 @@ it.each([
["the highest port", `net.connect(65535)`, "connect"],
["a Unix socket path", `net.connect("/tmp/x.sock")`, "connect"],
["an options object", `net.connect({ port: 80 })`, "connect"],
["an omitted argument", `net.connect()`, "connect"],
["an explicit undefined port", `net.connect(undefined)`, "connect"],
["a computed argument", `net.connect(p)`, "connect"],
[
"a TLS port supplied by later options",
`tls.connect(void 0, { port: 443 })`,
"connect",
],
["a tls port", `tls.connect(80)`, "connect"],
["a createConnection port", `net.createConnection(80)`, "createConnection"],
] as const)("keeps the socket acquisition for %s", (_name, call, method) => {
@@ -16,7 +16,6 @@ import {
resolveSemanticBindingState,
semanticResolutionBlocked,
type JavaScriptSemanticAnalysisState,
type JavaScriptSemanticBindingState,
} from "./javascriptSemanticState.js";
import { semanticBindingAtRead } from "./javascriptSemanticMutationInitializers.js";
import { traverseJavaScriptValueAst } from "./javascriptSemanticTraversal.js";
@@ -160,7 +159,10 @@ export const collectJavaScriptSemanticAsyncEffects = (
*
* A class expression passes Node's own `typeof` check, so it is kept.
*/
const rejectsEventListener = (listener: t.Node): boolean => {
export const rejectsSemanticEventListener = (
listener: t.Node | undefined,
): boolean => {
if (listener === undefined) return true;
if (t.isSpreadElement(listener)) return false;
const value = unwrapJavaScriptExpression(listener).node;
if (t.isFunctionExpression(value) || t.isArrowFunctionExpression(value))
@@ -204,9 +206,9 @@ const collectEvent = (
// provably the listener: `e.on(...["x", fn], 1)` registers `fn` and ignores the
// trailing `1`.
if (
EVENT_LISTENER_METHODS.includes(method) &&
!t.isSpreadElement(eventArgument) &&
listenerArgument !== undefined &&
rejectsEventListener(listenerArgument)
rejectsSemanticEventListener(listenerArgument)
)
return;
const emitter = emitterIdentity(member.object, state);
@@ -7,6 +7,25 @@ const analyze = (source: string) =>
`import { spawn } from 'node:child_process';\n${source}`,
);
it.each(
["on", "once", "addListener"].flatMap((method) =>
["", ", 1", ", null", ", {}", ", void 0"].map((listener) => ({
method,
listener,
})),
),
)(
"does not invent a child $method registration with rejected listener $listener",
({ method, listener }) => {
const ir = analyze(
`const child = spawn('worker'); child.${method}('exit'${listener});`,
);
expect(ir.childProcessSpawns).toHaveLength(1);
expect(ir.childProcessInteractions).toEqual([]);
expect(ir.eventOperations).toEqual([]);
},
);
it("keeps interactions before replacement resolved and later ownership uncertain", () => {
const ir = analyze(`
let child = spawn('old');
@@ -7,6 +7,7 @@ import type {
JavaScriptSemanticChildProcessSpawn,
} from "./javascriptSemanticIr.js";
import { createSemanticCallableIdStack } from "./javascriptSemanticCallableStack.js";
import { rejectsSemanticEventListener } from "./javascriptSemanticAsyncEffects.js";
import {
createSemanticExpressionShapeResolver,
optionsAfterOptionalArgv,
@@ -225,6 +226,11 @@ const childInteraction = (
const method = semanticStaticPropertyName(member.property, member.computed);
const classification = classifyInteraction(method, node.arguments[0]);
if (classification === null) return null;
if (
classification.kind === "listener" &&
rejectsSemanticEventListener(node.arguments[1])
)
return null;
const binding = objectBinding(member.object, state);
const { candidates: linked, complete } = resolveChildCandidates(
member.object,
@@ -30,6 +30,8 @@ export interface SemanticPropertyReadSource
extends IterationSourceValue, SourceGuards {
readonly mutation: t.Node;
readonly lookup: "prototype" | "own";
/** The language protocol invokes a callable value read from this slot. */
readonly invoke?: true;
}
interface PatternSource extends IterationSourceValue {
@@ -337,6 +339,7 @@ const semanticSourceFacts = (
t.isAssignmentExpression(node) ||
t.isForOfStatement(node) ||
t.isSpreadElement(node) ||
t.isAwaitExpression(node) ||
t.isYieldExpression(node) ||
t.isCatchClause(node) ||
t.isFunction(node)
@@ -372,11 +375,15 @@ const semanticSourceFacts = (
mutation: node,
lookup: "own",
});
} else if (
t.isYieldExpression(node) &&
node.delegate &&
node.argument != null
)
} else if (t.isAwaitExpression(node))
propertyReads.push({
node: node.argument,
projection: ["then"],
mutation: node,
lookup: "prototype",
invoke: true,
});
else if (t.isYieldExpression(node) && node.delegate && node.argument != null)
add(sources, { node: node.argument, projection: [] }, node, {});
else if (
t.isCatchClause(node) &&
@@ -2,6 +2,7 @@ import * as t from "@babel/types";
import { semanticKnownSymbolKey } from "./javascriptSemanticPropertyKeys.js";
import {
createAmbientPathResolver,
createGlobalPathReader,
retainSemanticAmbientEffect,
} from "./javascriptSemanticAmbientWrites.js";
import { isIntrinsicMapLookup } from "./javascriptSemanticMapOperations.js";
@@ -37,6 +38,7 @@ import {
import { semanticContainer } from "./javascriptSemanticSlots.js";
import {
semanticProjectedSourceFactsAtRead,
semanticCapturedSelectionsAfterPropertyRead,
semanticShiftCapturedSelections,
semanticNormalizeCapturedSelections,
type SemanticCapturedSelection,
@@ -49,6 +51,7 @@ import {
semanticClassAccessorReferences,
semanticObjectPropertyReferences,
semanticObjectAccessorReferences,
semanticNativePrototypeChain,
type JavaScriptSemanticObjectReference,
} from "./javascriptSemanticObjectReferences.js";
import {
@@ -122,27 +125,55 @@ const arrayPrototypeIterationMayChange = (path: PropertyPath): boolean => {
/** Preserve uncertainty from property writes and references exposed to calls, yielding during traversal and fixpoint iteration. */
/** Accessor selection uses the same slots, prototype shadowing and array indices as aliases. */
const localAccessorMethods = (
/** Local methods use the same slots, prototype shadowing and array indices as aliases. */
const localPropertyMethods = (
root: t.Node,
path: PropertyPath,
accessor: "get" | "set",
accessor: "get" | "set" | "invoke",
state: JavaScriptSemanticAnalysisState,
keyMayExist: (
key: PropertyPath[number] | undefined,
kind: "get" | "set",
) => boolean,
lookup: "prototype" | "own" = "prototype",
): readonly (t.ObjectMethod | t.ClassMethod)[] | undefined => {
if (!keyMayExist(path.at(-1), accessor)) return [];
const methods = new Set<t.ObjectMethod | t.ClassMethod>();
const pending = [{ node: root, path }];
): readonly t.Function[] | undefined => {
if (accessor !== "invoke" && !keyMayExist(path.at(-1), accessor)) return [];
const methods = new Set<t.Function>();
const pending: (MutationSelectionContext & {
node: t.Node;
path: PropertyPath;
})[] = [{ node: root, path, memberReadAt: root }];
const examined = new WeakMap<t.Node, Set<string>>();
const nativeReaders = new Map<
string,
ReturnType<typeof createGlobalPathReader>
>();
const nativeRead = (
prototype: "Object" | "Array",
key: string,
at: t.Node,
) => {
const identity = JSON.stringify([prototype, key]);
let read = nativeReaders.get(identity);
if (read === undefined) {
read = createGlobalPathReader(state, [prototype, "prototype", key]);
nativeReaders.set(identity, read);
}
return read(at);
};
let selections = 0;
for (let cursor = 0; cursor < pending.length; cursor++) {
const current = pending[cursor]!;
const node = unwrapJavaScriptExpression(current.node).node;
const key = JSON.stringify(current.path);
const key = JSON.stringify([
current.path,
current.capturedSelections?.map(({ at, depth, ownEnumerable }) => [
at.start,
at.end,
depth,
ownEnumerable,
]),
]);
const paths = examined.get(node) ?? new Set<string>();
if (paths.has(key)) continue;
paths.add(key);
@@ -152,7 +183,10 @@ const localAccessorMethods = (
return undefined;
}
if (
(t.isObjectMethod(node) || t.isClassMethod(node)) &&
t.isFunction(node) &&
(accessor === "invoke" ||
t.isObjectMethod(node) ||
t.isClassMethod(node)) &&
current.path.length === 0
) {
methods.add(node);
@@ -160,15 +194,42 @@ const localAccessorMethods = (
}
if (t.isIdentifier(node)) {
const binding = resolveSemanticBindingState(state, node, node.name);
if (binding === undefined) continue;
const selected = semanticBindingAtRead(binding, node, state).binding;
for (const source of [
...selected.initializers,
...selected.referenceInitializers,
])
if (binding === undefined) {
if (accessor === "invoke") return undefined;
continue;
}
if (
accessor === "invoke" &&
!semanticBindingAtRead(binding, node, state).hasDefiniteInitializer
)
return undefined;
const selected = semanticProjectedSourceFactsAtRead(
binding,
node,
current.path,
state,
true,
current.memberReadAt,
current.capturedSelections,
false,
// A spread copy escapes the copied values. That must not make the
// source's own accessors unknown, or the next read escapes the
// receiver itself.
accessor === "invoke",
);
if (selected === undefined) return undefined;
for (const source of selected.sources)
pending.push({
node: source.node,
path: [...source.projection, ...current.path],
path: source.path,
memberReadAt: current.memberReadAt,
capturedSelections: semanticNormalizeCapturedSelections([
...(semanticShiftCapturedSelections(
current.capturedSelections,
source.path.length - current.path.length,
) ?? []),
...(source.capturedSelections ?? []),
]),
});
} else if (
t.isMemberExpression(node) ||
@@ -180,15 +241,57 @@ const localAccessorMethods = (
semanticStaticPropertyKey(node.property, node.computed),
...current.path,
],
memberReadAt: current.memberReadAt,
capturedSelections: semanticShiftCapturedSelections(
current.capturedSelections,
1,
),
});
} else if (
current.path.length > 0 &&
(t.isObjectExpression(node) || t.isArrayExpression(node))
) {
const [selected, ...remaining] = current.path;
if (
accessor === "invoke" &&
lookup === "prototype" &&
!current.capturedSelections?.some(
(capture) => capture.depth === 1 && capture.ownEnumerable,
)
) {
for (const prototype of semanticNativePrototypeChain(node, selected!)) {
if (selected === null || typeof selected === "object")
return undefined;
const at = current.memberReadAt ?? root;
const member = nativeRead(prototype, String(selected), at);
if (member.status === "unknown" || member.status === "unrelated")
return undefined;
if (member.status === "replacement") {
pending.push({
node: member.value,
path: remaining,
memberReadAt: at,
capturedSelections: semanticShiftCapturedSelections(
current.capturedSelections,
-1,
),
});
break;
}
// A changed native link selects its retained source rather than the
// default next prototype. Null naturally ends that lookup.
const link = nativeRead(prototype, "__proto__", at);
if (link.status === "unknown" || link.status === "unrelated")
return undefined;
if (link.status === "replacement") {
pending.push({ ...current, node: link.value });
break;
}
}
}
const references = t.isArrayExpression(node)
? semanticArrayPropertyReferences(node, [selected!])
: remaining.length === 0
: remaining.length === 0 && accessor !== "invoke"
? semanticObjectAccessorReferences(
node,
selected!,
@@ -197,11 +300,24 @@ const localAccessorMethods = (
lookup,
)
: semanticObjectPropertyReferences(node, [selected!], state);
for (const source of references)
for (const source of references) {
if (
source.inherited &&
current.capturedSelections?.some(
(capture) => capture.depth === 1 && capture.ownEnumerable,
)
)
continue;
pending.push({
node: source.node,
path: [...source.path, ...remaining],
memberReadAt: current.memberReadAt,
capturedSelections: semanticCapturedSelectionsAfterPropertyRead(
current.capturedSelections,
source,
),
});
}
} else if (
(t.isClass(node) || t.isNewExpression(node)) &&
current.path.length > 0
@@ -218,23 +334,39 @@ const localAccessorMethods = (
const references =
remaining.length > 0
? semanticClassPropertyReferences(selection, [selected!], state)
: lookup === "own"
? []
: semanticClassAccessorReferences(
selection,
selected!,
state,
accessor,
);
: accessor === "invoke"
? semanticClassPropertyReferences(selection, [selected!], state)
: lookup === "own"
? []
: semanticClassAccessorReferences(
selection,
selected!,
state,
accessor,
);
for (const source of references)
pending.push({
node: source.node,
path: [...source.path, ...remaining],
memberReadAt: current.memberReadAt,
capturedSelections: semanticCapturedSelectionsAfterPropertyRead(
current.capturedSelections,
source,
),
});
}
} else {
for (const source of branchReferenceValues(node, current.path))
pending.push(source);
const references = branchReferenceValues(node, current.path);
if (accessor === "invoke" && references.length === 0) {
const value = evaluateSemanticExpression(
node,
state,
current.memberReadAt,
);
if (!["literal", "union", "object", "array"].includes(value.status))
return undefined;
}
for (const source of references) pending.push({ ...current, ...source });
}
}
return [...methods];
@@ -247,6 +379,7 @@ type AccessorEffectOperand =
readonly node: t.Node;
readonly path: PropertyPath;
readonly lookup: "prototype" | "own";
readonly invoke?: true;
readonly guards?: SemanticPropertyReadSource;
}
| {
@@ -333,6 +466,7 @@ const accessorEffectOperands = (
node: source.node,
path: source.projection,
lookup: source.lookup,
...(source.invoke ? { invoke: true } : {}),
guards: source,
});
if (
@@ -383,13 +517,14 @@ export function* collectSemanticMemberMutationsSteps(
);
};
const returnedReferences = createSemanticReturnedReferences(state);
const getterEffects = (
const propertyReadEffects = (
root: t.Node,
path: PropertyPath,
lookup: "prototype" | "own" = "prototype",
invoke = false,
): readonly JavaScriptSemanticObjectReference[] => {
if (!accessorKeyMayExist(path.at(-1), "get")) return [];
const pending = [{ node: root, path, lookup }];
if (!invoke && !accessorKeyMayExist(path.at(-1), "get")) return [];
const pending = [{ node: root, path, lookup, invoke }];
const examined = new Set<string>();
const effects: JavaScriptSemanticObjectReference[] = [];
for (let cursor = 0; cursor < pending.length; cursor++) {
@@ -397,12 +532,14 @@ export function* collectSemanticMemberMutationsSteps(
node: selectedRoot,
path: selected,
lookup: selectedLookup,
invoke: selectedInvoke,
} = pending[cursor]!;
const identity = JSON.stringify([
selectedRoot.start,
selectedRoot.end,
selected,
selectedLookup,
selectedInvoke,
]);
if (examined.has(identity)) continue;
examined.add(identity);
@@ -410,7 +547,20 @@ export function* collectSemanticMemberMutationsSteps(
state.valueResourceLimits.add("alias-selections");
return [{ node: root, path: [] }];
}
const methods = localAccessorMethods(
const receiver = selected.slice(0, -1);
if (
selectedInvoke &&
(localPropertyMethods(
selectedRoot,
selected,
"invoke",
state,
accessorKeyMayExist,
selectedLookup,
)?.length ?? 1) > 0
)
effects.push({ node: selectedRoot, path: receiver });
const methods = localPropertyMethods(
selectedRoot,
selected,
"get",
@@ -418,12 +568,12 @@ export function* collectSemanticMemberMutationsSteps(
accessorKeyMayExist,
selectedLookup,
);
const receiver = selected.slice(0, -1);
if (methods === undefined) {
effects.push({ node: selectedRoot, path: receiver });
continue;
}
for (const method of methods) {
if (!t.isObjectMethod(method) && !t.isClassMethod(method)) continue;
const callables: t.Function[] = [method];
const visitedCallables = new Set<t.Function>();
for (let index = 0; index < callables.length; index++) {
@@ -441,7 +591,8 @@ export function* collectSemanticMemberMutationsSteps(
}
if (
operand.kind === "read" &&
(!accessorKeyMayExist(operand.path.at(-1), "get") ||
((!operand.invoke &&
!accessorKeyMayExist(operand.path.at(-1), "get")) ||
(operand.guards !== undefined &&
semanticReferenceSourcesInactive(operand.guards, state)))
)
@@ -465,6 +616,7 @@ export function* collectSemanticMemberMutationsSteps(
node: targetRoot,
path: [...target, ...operand.path],
lookup: operand.lookup,
invoke: operand.invoke === true,
});
}
}
@@ -1114,7 +1266,7 @@ export function* collectSemanticMemberMutationsSteps(
if (
origins.initializers.some(
(source) =>
(localAccessorMethods(
(localPropertyMethods(
source.node,
[...source.projection, ...selectedPath],
"set",
@@ -1596,7 +1748,7 @@ export function* collectSemanticMemberMutationsSteps(
source.kind === "reference-store" &&
source.target !== undefined &&
!state.escapingReferenceStores.has(source.target) &&
(localAccessorMethods(
(localPropertyMethods(
source.target.object,
[
semanticStaticPropertyKey(
@@ -1658,17 +1810,18 @@ export function* collectSemanticMemberMutationsSteps(
}
for (const source of semanticPropertyReadSources(node, parent, state)) {
if (semanticReferenceSourcesInactive(source, state)) continue;
for (const effect of getterEffects(
for (const effect of propertyReadEffects(
source.node,
source.projection,
source.lookup,
source.invoke === true,
))
markEscaped(effect.node, source.mutation, effect.path);
}
if (t.isMemberExpression(node) || t.isOptionalMemberExpression(node)) {
// The write owner classifies direct and destructuring stores once.
if (!writeOnlyMembers.has(node))
for (const effect of getterEffects(node.object, [
for (const effect of propertyReadEffects(node.object, [
semanticStaticPropertyKey(node.property, node.computed),
]))
markEscaped(effect.node, node, effect.path);
@@ -4,6 +4,7 @@ import { expect, it } from "vitest";
import { indexJavaScriptBindingsSteps } from "./javascriptSemanticAnalysis.js";
import {
semanticClassPropertyReferences,
semanticNativePrototypeChain,
semanticObjectPropertyReferences,
} from "./javascriptSemanticObjectReferences.js";
import { completeSemanticSteps } from "./javascriptSemanticTraversal.js";
@@ -36,6 +37,37 @@ const spelled = (
],
);
it.each([
["{}", "then", ["Object"]],
["{then:null}", "then", []],
["{then}", "then", []],
["{...{then:null}}", "then", []],
["{...unknown}", "then", ["Object"]],
["{__proto__:null}", "then", []],
["{__proto__:{}}", "then", []],
["{__proto__:0}", "then", ["Object"]],
["{__proto__:unknown}", "then", ["Object"]],
['{["__proto__"]:null}', "then", ["Object"]],
["[]", "then", ["Array", "Object"]],
["[]", "length", []],
["[value]", "0", []],
["[,value]", "0", ["Array", "Object"]],
["[value,...unknown]", "0", []],
["[...unknown,value]", "0", ["Array", "Object"]],
] as const)(
"retains possible native prototype lookup for %s[%s]",
(value, key, expected) => {
const parsed = parseJavaScriptSource(`const o=${value};`, "prototype.mjs");
const declaration = parsed?.program.body[0];
if (!t.isVariableDeclaration(declaration))
throw new Error("Expected declaration");
const node = declaration.declarations[0]?.init;
if (!t.isObjectExpression(node) && !t.isArrayExpression(node))
throw new Error("Expected literal");
expect(semanticNativePrototypeChain(node, key)).toEqual(expected);
},
);
it("resolves exact keys with source-order overrides, spreads and accessors", () => {
const fixture = objectAndState(
`const o = { a: 1, ...base, b: 2, a: 3, [dynamic]: 4, __proto__: proto, ...{ b: 5 }, get c() { return 6; }, c: 7 };`,
@@ -54,14 +86,13 @@ it("resolves exact keys with source-order overrides, spreads and accessors", ()
["4", "[]"],
["base", '["missing"]'],
]);
// A wildcard key still scans every property.
// A wildcard keeps possible slots, excluding data replaced by a known spread.
expect(spelled(fixture, null)).toEqual([
["7", "[]"],
["{ b: 5 }", '[{"excludedKeys":["c"]}]'],
["proto", "[null]"],
["4", "[]"],
["3", "[]"],
["2", "[]"],
["base", '[{"excludedKeys":["a","b","c"]}]'],
]);
// Numeric keys use the same exact spelling as string keys.
@@ -70,6 +101,112 @@ it("resolves exact keys with source-order overrides, spreads and accessors", ()
).toEqual([['"one"', "[]"]]);
});
it.each([1, "1"])(
"preserves inherited numeric property selection %j and own shadowing",
(key) => {
expect(
spelled(objectAndState("const o = { __proto__: prototype };"), key),
).toEqual([["prototype", JSON.stringify([String(key)])]]);
expect(
spelled(
objectAndState('const o = { __proto__: prototype, 1: "own" };'),
key,
),
).toEqual([['"own"', "[]"]]);
},
);
it.each([
[
"static",
"static method(){return 1;} method(){return 2;}",
"static method(){return 1;}",
],
[
"instance",
"static method(){return 1;} method(){return 2;}",
"method(){return 2;}",
],
[
"prototype",
"static method(){return 1;} method(){return 2;}",
"method(){return 2;}",
],
["static", "static method(){return 1;} static set method(value){}", null],
["static", "static method=child; static method(){return 1;}", "child"],
[
"static",
"static get method(){return child;} static method(){return 1;}",
"static method(){return 1;}",
],
[
"static",
"static method(){return 1;} static get method(){return child;}",
"child",
],
[
"static",
"static get method(){return child;} static method(){return 1;} static set method(value){}",
null,
],
] as const)(
"selects method-valued %s descriptors with runtime precedence: %s",
(kind, body, expected) => {
const source = `class Box { ${body} }`;
const parsed = parseJavaScriptSource(source, "methods.mjs");
if (parsed === null) throw new Error("Expected a parsed class");
const owner = parsed.program.body[0];
if (!t.isClassDeclaration(owner)) throw new Error("Expected a class");
const state = completeSemanticSteps(
indexJavaScriptBindingsSteps(parsed.program),
);
expect(
semanticClassPropertyReferences({ owner, kind }, ["method"], state).map(
({ node }) => source.slice(node.start ?? 0, node.end ?? 0),
),
).toEqual(expected === null ? [] : [expected]);
},
);
it.each(["method(){return 1;}", "...{method: child}"])(
"keeps an intervening object data definition between accessor pairs: %s",
(definition) => {
expect(
spelled(
objectAndState(
`const o = { get method(){return child;} , ${definition}, set method(value){} };`,
),
"method",
),
).toEqual([]);
},
);
it.each(["static", "instance"] as const)(
"retains inherited %s field ownership for enumerable copies",
(kind) => {
const source = `class Base { ${kind === "static" ? "static" : ""} method=child; } class Box extends Base {}`;
const parsed = parseJavaScriptSource(source, "fields.mjs");
if (parsed === null) throw new Error("Expected a parsed class");
const owner = parsed.program.body[1];
if (!t.isClassDeclaration(owner)) throw new Error("Expected derived class");
const state = completeSemanticSteps(
indexJavaScriptBindingsSteps(parsed.program),
);
const references = semanticClassPropertyReferences(
{ owner, kind, fieldsOnly: true },
["method"],
state,
);
expect(
references.map(({ node }) =>
source.slice(node.start ?? 0, node.end ?? 0),
),
).toEqual(["child"]);
expect(references[0]?.inherited).toBe(kind === "static" ? true : undefined);
},
);
it("preserves references through a small static getter permutation", () => {
const getters = Array.from(
{ length: 5 },
@@ -154,96 +154,73 @@ const classPropertyDescriptors = (
initializedBefore?: t.ClassProperty,
accessor: "get" | "set" = "get",
) => {
const isStatic = kind === "static";
const selectedAccessors: t.ClassMethod[] = [];
const fields: t.ClassProperty[] = [];
const indexedMembers: {
readonly member: t.ClassMethod | t.ClassProperty;
readonly index: number;
readonly key: string | null;
}[] = [];
const methods: t.ClassMethod[] = [];
const accessors: t.ClassMethod[] = [];
const definitions: (t.ClassMethod | t.ClassProperty)[] = [];
const before =
initializedBefore === undefined
? -1
: klass.body.body.indexOf(initializedBefore);
klass.body.body.forEach((member, index) => {
for (const [index, member] of klass.body.body.entries()) {
if (
(t.isClassMethod(member) || t.isClassProperty(member)) &&
propertyName !== null &&
member.computed &&
semanticKnownSymbolKey(member.key, state)
(!t.isClassMethod(member) &&
!(kind === "static" && isRuntimeJavaScriptClassProperty(member))) ||
member.static !== (kind === "static") ||
(before >= 0 &&
isRuntimeJavaScriptClassProperty(member) &&
index >= before) ||
(propertyName !== null &&
member.computed &&
semanticKnownSymbolKey(member.key, state))
)
return;
if (
before >= 0 &&
index >= before &&
isRuntimeJavaScriptClassProperty(member)
)
return;
if (
(t.isClassMethod(member) ||
(kind === "static" && isRuntimeJavaScriptClassProperty(member))) &&
member.static === isStatic
)
indexedMembers.push({
member,
index,
key: semanticStaticPropertyKey(member.key, member.computed),
});
});
const matchingMembers =
propertyName === null
? []
: indexedMembers.filter(({ key }) => key === propertyName);
const uncertainAccessors = indexedMembers.filter(
(entry): entry is typeof entry & { member: t.ClassMethod } => {
const { member, key } = entry;
return (
t.isClassMethod(member) &&
member.kind === accessor &&
(propertyName === null || (member.computed && key === null))
);
},
);
const getters = matchingMembers.filter(
(entry): entry is typeof entry & { member: t.ClassMethod } =>
t.isClassMethod(entry.member) && entry.member.kind === accessor,
);
const getter = getters.at(-1);
const getterIndex = getter?.index ?? -1;
const laterDataMember = matchingMembers.some(
({ member, index }) =>
t.isClassProperty(member) ||
(t.isClassMethod(member) &&
member.kind === "method" &&
index > getterIndex),
);
const seenFields = new Set<string>();
for (const { member, key } of [...indexedMembers].reverse()) {
if (!t.isClassProperty(member)) continue;
if (key !== null && seenFields.has(key)) continue;
if (key !== null) seenFields.add(key);
if (propertyName === null || key === null || key === propertyName)
fields.push(member);
continue;
definitions.push(member);
}
if (getters.length > 0 && !laterDataMember && getter !== undefined)
selectedAccessors.push(getter.member);
for (const { member, index } of uncertainAccessors)
// Methods/accessors are installed before static fields execute, even when a
// field appears earlier in source. Instance fields are owned separately.
const ordered = [
...definitions.filter((member) => t.isClassMethod(member)),
...definitions.filter((member) => t.isClassProperty(member)),
];
const data = new Set<string>();
const getterNames = new Set<string>();
const setterNames = new Set<string>();
let inherits = true;
for (const member of ordered.reverse()) {
const key = semanticStaticPropertyKey(member.key, member.computed);
if (propertyName !== null && key !== null && key !== propertyName) continue;
if (propertyName !== null && key === propertyName) inherits = false;
const descriptor =
t.isClassMethod(member) &&
(member.kind === "get" || member.kind === "set")
? member.kind
: "data";
const selected = key ?? propertyName;
if (
!matchingMembers.some(
({ member: matching, index: matchingIndex }) =>
(t.isClassProperty(matching) ||
(t.isClassMethod(matching) &&
(matching.kind === "method" || matching.kind === accessor))) &&
matchingIndex > index,
)
)
selectedAccessors.push(member);
return {
accessors: selectedAccessors,
fields,
inherits: matchingMembers.length === 0,
};
selected !== null &&
(data.has(selected) ||
(descriptor === "data"
? getterNames.has(selected) || setterNames.has(selected)
: (descriptor === "get" ? getterNames : setterNames).has(selected)))
) {
// A later accessor replaced this data descriptor, but the data definition
// still prevents an earlier accessor from pairing across it.
if (descriptor === "data" && key !== null) data.add(key);
continue;
}
if (t.isClassProperty(member)) fields.push(member);
else if (member.kind === "method") methods.push(member);
else if (member.kind === accessor) accessors.push(member);
if (key !== null)
(descriptor === "data"
? data
: descriptor === "get"
? getterNames
: setterNames
).add(key);
}
return { accessors, fields, methods, inherits };
};
export const semanticClassPropertyReferences = (
@@ -345,9 +322,21 @@ class ClassReferenceWalk {
? descriptors.fields
: [])
if (field.value != null)
this.#references.push({ node: field.value, path: this.#path });
this.#references.push({
node: field.value,
path: this.#path,
...(this.lookup.kind === "static" && klass !== this.lookup.owner
? { inherited: true }
: {}),
});
for (const getter of this.lookup.fieldsOnly ? [] : descriptors.accessors)
this.#getter(klass, getter);
if (this.context.accessor === undefined && !this.lookup.fieldsOnly)
for (const method of descriptors.methods) {
const name = semanticStaticPropertyKey(method.key, method.computed);
if (name !== null && this.#fields.has(name)) continue;
this.#references.push({ node: method, path: this.#path });
}
// Only a definite own descriptor can suppress possible superclass results.
if (descriptors.inherits && klass.superClass != null)
for (const base of semanticClassCandidates(klass.superClass, this.state))
@@ -559,6 +548,18 @@ const expandGetterResult = (
type ObjectLiteralMember = t.ObjectExpression["properties"][number];
/** Only this colon-form definition selects an object literal's prototype. */
const isObjectPrototypeSetter = (
property: ObjectLiteralMember,
name = t.isSpreadElement(property)
? null
: semanticStaticPropertyKey(property.key, property.computed),
): property is t.ObjectProperty =>
t.isObjectProperty(property) &&
!property.computed &&
!property.shorthand &&
name === "__proto__";
/** Property positions grouped by whether an exact key can reach them. */
interface ObjectPropertyIndex {
/** Spreads, computed keys and `__proto__` data entries reach every key. */
@@ -579,13 +580,7 @@ const objectPropertyIndex = (node: t.ObjectExpression): ObjectPropertyIndex => {
const name = t.isSpreadElement(property)
? null
: semanticStaticPropertyKey(property.key, property.computed);
if (
name === null ||
(t.isObjectProperty(property) &&
!property.computed &&
!property.shorthand &&
name === "__proto__")
) {
if (name === null || isObjectPrototypeSetter(property, name)) {
keyIndependent.push(index);
return;
}
@@ -671,7 +666,9 @@ class ObjectReferenceWalk {
readonly accessor?: "get" | "set",
readonly accessorLookup: "prototype" | "own" = "prototype",
) {
[this.#key, ...this.#remaining] = path;
const [key, ...remaining] = path;
this.#key = typeof key === "number" ? String(key) : key;
this.#remaining = remaining;
}
#spreadKey(): PropertyPath[number] {
@@ -691,38 +688,29 @@ class ObjectReferenceWalk {
#spread(property: t.SpreadElement): void {
const selected = this.#key ?? null;
if (typeof selected !== "object" && this.#overwritten.has(String(selected)))
return;
// Spread creates data properties; the source descriptor is not installed.
if (this.accessor === undefined)
if (
this.accessor === undefined &&
(typeof selected === "object" || !this.#overwritten.has(String(selected)))
)
this.#references.push({
node: property.argument,
path: [this.#spreadKey(), ...this.#remaining],
capturedSelectionAt: property.argument,
ownEnumerable: true,
});
if (
selected !== null &&
typeof selected !== "object" &&
t.isObjectExpression(property.argument) &&
objectExpressionDefinesKey(property.argument, String(selected))
) {
this.#overwritten.add(String(selected));
this.#data.add(String(selected));
}
if (t.isObjectExpression(property.argument))
for (const name of objectExpressionDefinedKeys(property.argument)) {
this.#overwritten.add(name);
this.#data.add(name);
}
}
#prototype(
property: t.ObjectMethod | t.ObjectProperty,
name: string | null,
): boolean {
if (
!t.isObjectProperty(property) ||
property.computed ||
property.shorthand ||
name !== "__proto__"
)
return false;
if (!isObjectPrototypeSetter(property, name)) return false;
const selected = this.#key ?? null;
if (this.accessor !== undefined && this.accessorLookup === "own")
return true;
@@ -731,7 +719,7 @@ class ObjectReferenceWalk {
(selected === null ||
typeof selected === "object" ||
(typeof selected === "string" &&
!objectExpressionDefinesKey(this.node, selected)))
!objectExpressionDefinedKeys(this.node).has(selected)))
)
this.#references.push({
node: property.value,
@@ -819,8 +807,10 @@ class ObjectReferenceWalk {
? this.#data.has(name) || this.#accessors.has(name)
: this.#data.has(name) ||
(kind === "get" ? this.#getters : this.#setters).has(name))
)
) {
if (kind === "data") this.#data.add(name);
return;
}
this.#read(property, name);
if (name !== null) this.#record(name, kind);
}
@@ -839,12 +829,12 @@ const objectPropertyReferences = (
): readonly JavaScriptSemanticObjectReference[] =>
new ObjectReferenceWalk(node, path, query).run();
const objectExpressionDefinesKey = (
const objectExpressionDefinedKeys = (
root: t.ObjectExpression,
key: string,
): boolean => {
): ReadonlySet<string> => {
const pending: t.ObjectExpression[] = [root];
const seen = new Set<t.ObjectExpression>();
const names = new Set<string>();
for (let cursor = 0; cursor < pending.length; cursor++) {
const object = pending[cursor];
if (object === undefined || seen.has(object)) continue;
@@ -856,19 +846,11 @@ const objectExpressionDefinesKey = (
continue;
}
const name = semanticStaticPropertyKey(property.key, property.computed);
if (
name === key &&
!(
t.isObjectProperty(property) &&
!property.computed &&
!property.shorthand &&
name === "__proto__"
)
)
return true;
if (name !== null && !isObjectPrototypeSetter(property, name))
names.add(name);
}
}
return false;
return names;
};
export const semanticArrayPropertyReferences = (
@@ -924,3 +906,44 @@ export const semanticArrayPropertyReferences = (
}
return references;
};
/** Possible native fallback after own slots; explicit prototype references are walked separately. */
export const semanticNativePrototypeChain = (
node: t.ObjectExpression | t.ArrayExpression,
key: PropertyPath[number],
): readonly ("Object" | "Array")[] => {
const exact =
key !== null && typeof key !== "object" ? String(key) : undefined;
if (t.isArrayExpression(node)) {
if (exact === "length") return [];
if (exact !== undefined) {
const sources = semanticArrayPropertyReferences(node, [exact]);
if (
sources.length > 0 &&
sources.every((source) => source.capturedSelectionAt === undefined)
)
return [];
}
return ["Array", "Object"];
}
if (exact !== undefined && objectExpressionDefinedKeys(node).has(exact))
return [];
for (const property of node.properties) {
if (!isObjectPrototypeSetter(property)) continue;
const prototype = unwrapJavaScriptExpression(property.value).node;
// Null removes the prototype. Objects select the explicitly retained
// chain, while primitive values are ignored and unknowns may be primitive.
if (
t.isNullLiteral(prototype) ||
t.isObjectExpression(prototype) ||
t.isArrayExpression(prototype) ||
t.isFunctionExpression(prototype) ||
t.isArrowFunctionExpression(prototype) ||
t.isClassExpression(prototype) ||
t.isRegExpLiteral(prototype) ||
t.isNewExpression(prototype)
)
return [];
}
return ["Object"];
};
@@ -190,6 +190,7 @@ export function semanticProjectedSourceFactsAtRead(
memberReadAt?: t.Node,
capturedSelections?: readonly SemanticCapturedSelection[],
retainEligibility?: boolean,
valueEscapesHideSources?: boolean,
):
| {
readonly sources: readonly (Omit<ProjectedSource, "path"> & {
@@ -219,6 +220,9 @@ const projectedSourceFactsAtRead = (
memberReadAt: t.Node = reference,
capturedSelections: readonly SemanticCapturedSelection[] = [],
retainEligibility = false,
// Accessor lookup reads source syntax. A prior value escape does not hide
// that syntax; unknown writes and invocations still fail closed.
valueEscapesHideSources = true,
):
| {
readonly sources: readonly ProjectedSource[];
@@ -365,6 +369,7 @@ const projectedSourceFactsAtRead = (
(replacement.assignedSources?.[0]?.target?.start ?? -1)));
if (
includeUnknown &&
valueEscapesHideSources &&
semanticSomeEffectPathCandidate(
binding.escapedPaths,
path,
@@ -2,6 +2,40 @@ import { expect, it } from "vitest";
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
it.each([
"load('./dep.cjs')",
"load.call(null,'./dep.cjs')",
"load.apply(null,['./dep.cjs'])",
"Reflect.apply(load,null,['./dep.cjs'])",
])("retains a createRequire filename through %s", (call) => {
const result = analyzeJavaScriptStaticSource(
`import {createRequire} from 'node:module';const load=createRequire(new URL('./sub/entry.cjs',import.meta.url));${call};`,
);
expect(result.references.filter(({ kind }) => kind === "require")).toEqual([
expect.objectContaining({
specifier: "./dep.cjs",
resolution_base_path: "./sub/entry.cjs",
}),
]);
});
it.each([
"load.call(null,'./dep.cjs')",
"Reflect.apply(load,null,['./dep.cjs'])",
])("keeps mixed createRequire bases unknown through %s", (call) => {
const result = analyzeJavaScriptStaticSource(
`import {createRequire} from 'node:module';const load=createRequire(flag?import.meta.url:new URL('./sub/entry.cjs',import.meta.url));${call};`,
);
expect(result.references.filter(({ kind }) => kind === "require")).toEqual([
expect.objectContaining({ specifier: null }),
]);
expect(
result.references.every(
(reference) => !("resolution_base_path" in reference),
),
).toBe(true);
});
it.each([
['const h={require:module.require};h.require("./shared.cjs");', true],
[
@@ -30,6 +30,8 @@ import {
import {
semanticStaticPropertyKey,
semanticMemberChain,
semanticWriteSiteTarget,
semanticWriteTargets,
unwrapJavaScriptExpression,
} from "./javascriptAstValues.js";
import { semanticPropertyPathKeyMatches } from "./javascriptSemanticPropertyPaths.js";
@@ -71,30 +73,33 @@ export type {
const possibleCommonJsDelegateWriteStates =
new WeakSet<JavaScriptSemanticAnalysisState>();
/** Admission and indexing share the same direct, projected and iteration leaves. */
function* commonJsDelegateWriteTargets(
node: t.Node,
): Generator<t.MemberExpression | t.OptionalMemberExpression> {
const write = semanticWriteSiteTarget(node);
if (write === null) return;
for (const target of semanticWriteTargets(write)) {
if (!t.isMemberExpression(target) && !t.isOptionalMemberExpression(target))
continue;
const key = semanticStaticPropertyKey(target.property, target.computed);
if (key === "require" || (key === null && target.computed)) yield target;
}
}
/** Source eligibility only; the shared write owner still proves receiver identity. */
export const observePossibleCommonJsDelegateWrite = (
node: t.Node,
state: JavaScriptSemanticAnalysisState,
): void => {
if (possibleCommonJsDelegateWriteStates.has(state)) return;
const target = t.isAssignmentExpression(node)
? node.left
: t.isUpdateExpression(node) ||
t.isUnaryExpression(node, { operator: "delete" })
? node.argument
: undefined;
if (
semanticIterationSources(node, state.parentsByNode.get(node) ?? null).some(
(source) => source.kind === "reference-store",
)
)
possibleCommonJsDelegateWriteStates.add(state);
if (
(t.isMemberExpression(target) || t.isOptionalMemberExpression(target)) &&
["require", null].includes(
semanticStaticPropertyKey(target.property, target.computed),
)
)
if (!commonJsDelegateWriteTargets(node).next().done)
possibleCommonJsDelegateWriteStates.add(state);
else if (t.isCallExpression(node) || t.isOptionalCallExpression(node))
possibleCommonJsDelegateWriteStates.add(state);
@@ -886,6 +891,9 @@ export const semanticRequireCallAt = (
}
const interop = loaderInvocationInterop(state);
if (!t.isNewExpression(node)) {
// Invocation normalization retains the selected path identity. Keep its
// filename alongside that path rather than selecting the outer call again.
const sourceRelativePaths = new Map<readonly string[], string>();
const invocation = interop.intrinsicInvocation(node, (callee) => {
const loader = requireLoaderValueAt(
callee,
@@ -912,22 +920,21 @@ export const semanticRequireCallAt = (
state,
factoryBindingIds,
);
if (selected !== undefined)
return [
if (selected !== undefined) {
const path = [
"require-loader",
selected.loader,
selected.sourceRelative ? "relative" : "unknown",
...(selected.loaderAuthority === "candidate" ? ["candidate"] : []),
];
if (selected.sourceRelativePath !== undefined)
sourceRelativePaths.set(path, selected.sourceRelativePath);
return path;
}
return interop.intrinsicPath(callee);
});
if (invocation?.path[0] === "require-loader") {
const selected = selectedRequireSourcesAt(
node.callee,
node,
state,
factoryBindingIds,
);
const sourceRelativePath = sourceRelativePaths.get(invocation.path);
return {
operation: "load",
loader:
@@ -942,9 +949,9 @@ export const semanticRequireCallAt = (
: {}),
sourceRelative:
invocation.kind === "known" && invocation.path[2] === "relative",
...(selected?.sourceRelativePath === undefined
? {}
: { sourceRelativePath: selected.sourceRelativePath }),
...(invocation.kind === "known" && sourceRelativePath !== undefined
? { sourceRelativePath }
: {}),
};
}
}
@@ -1556,20 +1563,8 @@ const writesModuleRequire = (
};
traverseJavaScriptValueAst(state.program, state.erasedSyntaxNodes, {
enter: (node) => {
const target = t.isAssignmentExpression(node)
? node.left
: t.isUpdateExpression(node) ||
t.isUnaryExpression(node, { operator: "delete" })
? node.argument
: undefined;
if (
t.isMemberExpression(target) ||
t.isOptionalMemberExpression(target)
) {
const key = semanticStaticPropertyKey(target.property, target.computed);
if (key === "require" || (key === null && target.computed))
record(node, target.object);
}
for (const target of commonJsDelegateWriteTargets(node))
record(node, target.object);
if (t.isCallExpression(node) || t.isOptionalCallExpression(node)) {
const invocation = interop.intrinsicInvocation(node);
const owner =
@@ -1583,42 +1578,21 @@ const writesModuleRequire = (
const key = interop.staticString(args?.[1]);
if (key === "require" || key === undefined)
record(node, args?.[0] ?? t.nullLiteral());
} else if (owner === "Reflect" && method === "set") {
const key = interop.staticString(args?.[1]);
if (key === "require" || key === undefined)
record(node, args?.[3] ?? args?.[0] ?? t.nullLiteral());
} else if (owner === "Object" && method === "defineProperties") {
const descriptors = args?.[1];
if (
!t.isObjectExpression(descriptors) ||
descriptors.properties.some(
(property) =>
t.isSpreadElement(property) ||
(t.isObjectProperty(property) &&
semanticStaticPropertyKey(property.key, property.computed) ===
"require"),
)
)
if (objectMayProvideRequire(descriptors))
record(node, args?.[0] ?? t.nullLiteral());
} else if (owner === "Object" && method === "assign") {
const target = args?.[0];
if (target !== undefined) {
const sourceHasRequire = args
?.slice(1)
.some(
(arg) =>
t.isObjectExpression(arg) &&
arg.properties.some(
(property) =>
t.isObjectProperty(property) &&
semanticStaticPropertyKey(
property.key,
property.computed,
) === "require",
),
);
if (
sourceHasRequire ||
args?.slice(1).some((arg) => !t.isObjectExpression(arg))
)
record(node, target);
}
if (
target !== undefined &&
args?.slice(1).some(objectMayProvideRequire)
)
record(node, target);
}
}
},
@@ -1627,6 +1601,15 @@ const writesModuleRequire = (
return writes;
};
/** Methods, spreads and unknown computed keys can all supply an own delegate. */
const objectMayProvideRequire = (node: t.Node | undefined): boolean =>
!t.isObjectExpression(node) ||
node.properties.some((property) => {
if (t.isSpreadElement(property)) return true;
const key = semanticStaticPropertyKey(property.key, property.computed);
return key === "require" || key === null;
});
/** Whether the original global process object can still own effects at this source site. */
export const semanticOriginalGlobalProcessAt = (
node: t.Node,
@@ -78,7 +78,11 @@ export const collectJavaScriptSemanticResources = (
const acquisitions = collectAcquisitions(program, context).filter(
(candidate) =>
!socketPortInFirstArgument(candidate) ||
(!rejectsSocketPortLiteral(candidate.node, context) &&
(!rejectsSocketLiteralArguments(
candidate.node,
candidate.specifier,
context,
) &&
!rejectsTlsProtocolVersionLiteral(
candidate.node,
candidate.specifier,
@@ -447,6 +451,9 @@ const collectReleases = (
resolution:
!opaqueReceiver &&
linked.length === 1 &&
(binding.kind !== "parameter" ||
semanticBindingAtRead(binding, member.object, context.state)
.hasDefiniteInitializer) &&
holdsOnlyAcquisitions(binding, linked, context.state)
? "complete"
: "partial",
@@ -573,16 +580,25 @@ const resourceCandidateId = (candidate: ResourceCandidate): string =>
* readable non-port literal is decided. `""` is a path with no length, which
* Node also rejects.
*
* `undefined` and an empty object throw `ERR_MISSING_ARGS`, but those are a
* missing option rather than a bad port, so they stay undecided here.
* `net` also rejects an omitted or undefined port, before it returns a socket.
* TLS merges later options, so an undefined first argument alone cannot decide
* its admission. Object arguments and spread positions remain undecided here.
*/
const rejectsSocketPortLiteral = (
const rejectsSocketLiteralArguments = (
node: t.CallExpression | t.OptionalCallExpression,
specifier: string,
context: DataEffectTraversalContext,
): boolean => {
const port = node.arguments[0];
if (port === undefined || t.isSpreadElement(port)) return false;
if (port === undefined) return specifier === "net";
if (t.isSpreadElement(port)) return false;
const value = unwrapJavaScriptExpression(port).node;
if (
specifier === "net" &&
(t.isUnaryExpression(value, { operator: "void" }) ||
isUnmodifiedGlobalReference(value, context.state, "undefined"))
)
return true;
if (t.isStringLiteral(value)) return value.value.length === 0;
if (
t.isNullLiteral(value) ||
@@ -601,13 +617,14 @@ const rejectsSocketPortLiteral = (
isUnmodifiedGlobalReference(value, context.state, value.name)
)
return true;
// `-1` and `-1.5` are unary expressions over numeric literals: always out of
// range because a port is never negative.
// Numeric unary operators are judged by their result: `-0` is port zero,
// and `~(-1.5)` is zero too. `typeof` instead yields a nonempty IPC path.
if (t.isUnaryExpression(value)) {
const operand = unwrapJavaScriptExpression(value.argument).node;
if (value.operator === "!" || value.operator === "delete") return true;
const number = unaryOperandNumber(value, context, 0);
return (
t.isNumericLiteral(operand) &&
(value.operator === "-" || !Number.isInteger(operand.value))
number !== undefined &&
(!Number.isInteger(number) || number < 0 || number > 65535)
);
}
return false;
@@ -205,3 +205,21 @@ it("widens a cycle that rewrites the path head while increasing its depth", () =
returnedExpression(parsed.program, "target"),
);
});
it("retains both finite class-prototype callable selections", () => {
const { parsed, state } = indexed(`
class C { first(){return firstResult} second(){return secondResult} }
const selected=flag?C.prototype.first:C.prototype.second;
selected();
`);
const [read] = callReads(parsed.program, "selected");
if (read === undefined)
throw new Error("Missing selected prototype method call");
const result = createSemanticReturnedReferences(state)(read);
expect(result.precision).toBe("exact");
expect(
result.references
.map(({ node }) => (t.isIdentifier(node) ? node.name : null))
.sort(),
).toEqual(["firstResult", "secondResult"]);
});
@@ -24,8 +24,13 @@ import {
semanticKnownArrayIterationAt,
semanticSelectedReferenceSlot,
} from "./javascriptSemanticReferenceSources.js";
import { semanticArrayPropertyReferences } from "./javascriptSemanticObjectReferences.js";
import {
semanticArrayPropertyReferences,
semanticObjectPropertyReferences,
semanticClassPropertyReferences,
} from "./javascriptSemanticObjectReferences.js";
import {
semanticCapturedSelectionsAfterPropertyRead,
semanticNormalizeCapturedSelections,
semanticProjectedSourcesAtRead,
semanticShiftCapturedSelections,
@@ -62,6 +67,7 @@ interface Candidate {
| readonly SemanticCapturedSelection[]
| undefined;
readonly invocation?: JavaScriptSourceInvocationContext;
readonly classKind?: "instance" | "static";
}
export interface SemanticReturnedReferenceResult {
@@ -74,22 +80,6 @@ export interface SemanticReturnedReferenceResult {
readonly precision: "exact" | "widened";
}
interface ObjectTargetIndex {
readonly propertyByName: ReadonlyMap<
string,
t.ObjectMethod | t.ObjectProperty
>;
readonly trailingUnknownsByProperty: ReadonlyMap<
t.ObjectMethod | t.ObjectProperty,
ObjectTargetUnknown
>;
}
interface ObjectTargetUnknown {
readonly node: t.ObjectMethod | t.ObjectProperty | t.SpreadElement;
readonly previous: ObjectTargetUnknown | undefined;
}
/** Resolve local result references conservatively, without claiming call execution. */
export const createSemanticReturnedReferences = (
state: JavaScriptSemanticAnalysisState,
@@ -115,7 +105,6 @@ export const createSemanticReturnedReferences = (
}[];
}
>();
const objectTargets = new WeakMap<t.ObjectExpression, ObjectTargetIndex>();
return (callee, invocation) => {
const existing = results.get(callee);
if (
@@ -141,7 +130,7 @@ export const createSemanticReturnedReferences = (
// shared diamonds and reassignment cycles converge to the graph fixed point.
const projectionDepths = new WeakMap<
t.Node,
Map<string, number | undefined>
Map<string, { depth: number | undefined }>
>();
const expanded = new WeakMap<t.Node, Set<string>>();
let additionalContexts = 0;
@@ -190,6 +179,7 @@ export const createSemanticReturnedReferences = (
readAt?: t.Node,
capturedSelections?: readonly SemanticCapturedSelection[],
invocationContext?: JavaScriptSourceInvocationContext,
classKind?: Candidate["classKind"],
): void => {
const source = unwrapJavaScriptExpression(node).node;
if (contextsWidened) {
@@ -213,20 +203,25 @@ export const createSemanticReturnedReferences = (
readAt?.start ?? -1,
readAt?.end ?? -1,
invocationKey(),
classKind,
]);
const depths = projectionDepths.get(source) ?? new Map();
projectionDepths.set(source, depths);
if (!depths.has(readKey)) {
depths.set(readKey, path?.length);
} else {
const previous = depths.get(readKey);
if (previous === undefined || previous !== path?.length) {
if (previous !== undefined) result.precision = "widened";
depths.set(readKey, undefined);
path = undefined;
}
let context = depths.get(readKey);
if (context === undefined) {
context = { depth: path?.length };
depths.set(readKey, context);
} else if (
context.depth === undefined ||
context.depth !== path?.length
) {
if (context.depth !== undefined) result.precision = "widened";
context.depth = undefined;
path = undefined;
}
if (path !== undefined) path = selections.normalize(source, path);
// Selector joins share the transition's read/invocation/receiver context.
// Joining only by syntax would cross static and instance member names.
if (path !== undefined) path = selections.normalize(context, path);
capturedSelections =
path === undefined
? undefined
@@ -279,9 +274,10 @@ export const createSemanticReturnedReferences = (
readAt?.start ?? -1,
readAt?.end ?? -1,
null,
classKind,
]);
if (sourceGenericInvocation) result.unresolvedInvocation = true;
depths.set(readKey, undefined);
depths.set(readKey, { depth: undefined });
key = expansionKey();
if (paths.has(key)) return;
}
@@ -289,6 +285,7 @@ export const createSemanticReturnedReferences = (
expanded.set(source, paths);
work.push({
node: source,
...(classKind === undefined ? {} : { classKind }),
path,
...(readAt === undefined ? {} : { readAt }),
...(capturedSelections === undefined ? {} : { capturedSelections }),
@@ -382,6 +379,7 @@ export const createSemanticReturnedReferences = (
child.readAt,
child.capturedSelections,
child.invocation ?? current.invocation,
child.classKind ?? current.classKind,
);
}
continue;
@@ -505,9 +503,38 @@ export const createSemanticReturnedReferences = (
result.unresolvedInvocation = true;
}
}
} else if (t.isObjectExpression(node))
selectObjectTargets(node, current, pending, objectTargets);
else if (t.isArrayExpression(node)) {
} else if (t.isObjectExpression(node)) {
const [key, ...suffix] = current.path ?? [];
for (const choice of key === undefined
? [undefined]
: semanticAliasSelectionChoices(key))
for (const source of semanticObjectPropertyReferences(
node,
choice === undefined ? [] : [choice],
state,
)) {
if (source.widenedGetterResult) result.precision = "widened";
if (
source.inherited &&
current.capturedSelections?.some(
(capture) => capture.depth === 1 && capture.ownEnumerable,
)
)
continue;
pending.push({
...current,
node: source.node,
path:
current.path === undefined || source.widenedGetterResult
? undefined
: [...source.path, ...suffix],
capturedSelections: semanticCapturedSelectionsAfterPropertyRead(
current.capturedSelections,
source,
),
});
}
} else if (t.isArrayExpression(node)) {
const [key, ...path] = current.path ?? [];
if (current.path !== undefined && key !== undefined) {
for (const choice of semanticAliasSelectionChoices(key))
@@ -552,6 +579,7 @@ export const createSemanticReturnedReferences = (
child.readAt,
child.capturedSelections,
child.invocation ?? current.invocation,
child.classKind ?? current.classKind,
);
}
continue;
@@ -590,63 +618,83 @@ export const createSemanticReturnedReferences = (
});
});
} else if (t.isNewExpression(node))
pending.push({ ...current, node: node.callee });
pending.push({ ...current, node: node.callee, classKind: "instance" });
else if (t.isClass(node)) {
const [key, ...path] = current.path ?? [];
if (current.path !== undefined && key !== undefined) {
for (const choice of semanticAliasSelectionChoices(key)) {
const prototype =
current.classKind !== "instance" && choice === "prototype";
if (prototype && path.length === 0) continue;
const ownEnumerable = current.capturedSelections?.some(
(capture) =>
capture.depth === (prototype ? 2 : 1) && capture.ownEnumerable,
);
const member = prototype ? path[0] : choice;
if (member === undefined) continue;
for (const selected of semanticAliasSelectionChoices(member))
for (const source of semanticClassPropertyReferences(
{
owner: node,
kind: prototype
? "prototype"
: (current.classKind ?? "static"),
...(ownEnumerable ? { fieldsOnly: true as const } : {}),
},
[selected],
state,
)) {
if (source.inherited && ownEnumerable) continue;
if (source.widenedGetterResult) result.precision = "widened";
pending.push({
...current,
node: source.node,
path: source.widenedGetterResult
? undefined
: [...source.path, ...(prototype ? path.slice(1) : path)],
classKind: "static",
capturedSelections:
semanticCapturedSelectionsAfterPropertyRead(
prototype
? semanticShiftCapturedSelections(
current.capturedSelections,
-1,
)
: current.capturedSelections,
source,
),
});
}
}
for (const child of pending)
enqueue(
child.node,
child.path,
child.readAt,
child.capturedSelections,
child.invocation ?? current.invocation,
child.classKind ?? current.classKind,
);
continue;
}
// Unknown projections retain every possible callable descendant. At
// zero depth only construction contributes a source-local result.
for (const member of node.body.body) {
if (t.isClassMethod(member)) {
if (
((current.path === undefined || current.path.length === 0) &&
member.kind === "constructor") ||
current.path === undefined ||
(key !== undefined &&
semanticAliasSelectionChoices(key).some((choice) => {
const name = semanticStaticPropertyKey(
member.key,
member.computed,
);
return (
name === null ||
semanticPropertyPathKeyMatches(choice, name)
);
}))
)
pending.push({
...current,
node: member,
path: current.path === undefined ? undefined : path,
capturedSelections: semanticShiftCapturedSelections(
current.capturedSelections,
-1,
),
});
} else if (
t.isClassProperty(member) &&
member.value !== null &&
member.value !== undefined &&
(key !== undefined || current.path === undefined) &&
(current.path === undefined ||
(key !== undefined &&
semanticAliasSelectionChoices(key).some((choice) => {
const name = semanticStaticPropertyKey(
member.key,
member.computed,
);
return (
name === null ||
semanticPropertyPathKeyMatches(choice, name)
);
})))
if (
t.isClassMethod(member) &&
(current.path === undefined || member.kind === "constructor")
)
pending.push({
...current,
node: member.value,
path: current.path === undefined ? undefined : path,
capturedSelections: semanticShiftCapturedSelections(
current.capturedSelections,
-1,
),
node: member,
path: current.path === undefined ? undefined : [],
});
else if (
current.path === undefined &&
t.isClassProperty(member) &&
member.value != null
)
pending.push({ ...current, node: member.value, path: undefined });
}
if (node.superClass !== null && node.superClass !== undefined)
pending.push({ ...current, node: node.superClass });
@@ -660,6 +708,7 @@ export const createSemanticReturnedReferences = (
child.readAt,
child.capturedSelections,
child.invocation ?? current.invocation,
child.classKind ?? current.classKind,
);
}
}
@@ -699,139 +748,3 @@ export const createSemanticReturnedReferences = (
return result;
};
};
const selectObjectTargets = (
node: t.ObjectExpression,
item: Candidate,
pending: Candidate[],
indexes: WeakMap<t.ObjectExpression, ObjectTargetIndex>,
): void => {
const [key, ...path] = item.path ?? [];
const childPath = item.path === undefined ? undefined : path;
if (key !== null && typeof key === "object" && "alternatives" in key) {
for (const choice of semanticAliasSelectionChoices(key))
selectObjectTargets(
node,
{ ...item, path: [choice, ...path] },
pending,
indexes,
);
return;
}
if (key !== null && key !== undefined && typeof key !== "object") {
const index = objectTargetIndex(node, indexes);
const property = index.propertyByName.get(String(key));
if (property !== undefined) {
const unknowns: ObjectTargetUnknown[] = [];
for (
let unknown = index.trailingUnknownsByProperty.get(property);
unknown !== undefined;
unknown = unknown.previous
)
unknowns.push(unknown);
for (const { node: unknown } of unknowns.reverse())
pending.push({
...item,
node: t.isSpreadElement(unknown)
? unknown.argument
: t.isObjectProperty(unknown)
? unknown.value
: unknown,
...(t.isSpreadElement(unknown) ? {} : { path: childPath }),
capturedSelections: t.isSpreadElement(unknown)
? [
...(item.capturedSelections ?? []),
{ at: unknown.argument, depth: 1, ownEnumerable: true },
]
: (semanticShiftCapturedSelections(item.capturedSelections, -1) ??
[]),
});
pending.push({
...item,
node: t.isObjectProperty(property) ? property.value : property,
path: childPath,
capturedSelections:
semanticShiftCapturedSelections(item.capturedSelections, -1) ?? [],
});
return;
}
}
let replaced = false;
for (const property of [...node.properties].reverse()) {
if (replaced) break;
if (t.isSpreadElement(property)) {
pending.push({
...item,
node: property.argument,
capturedSelections: [
...(item.capturedSelections ?? []),
{ at: property.argument, depth: 1, ownEnumerable: true },
],
});
continue;
}
const name = semanticStaticPropertyKey(property.key, property.computed);
if (
key !== null &&
key !== undefined &&
name !== null &&
!semanticPropertyPathKeyMatches(key, name)
)
continue;
if (t.isObjectProperty(property))
pending.push({
...item,
node: property.value,
path: childPath,
capturedSelections:
semanticShiftCapturedSelections(item.capturedSelections, -1) ?? [],
});
else
pending.push({
...item,
node: property,
path: childPath,
capturedSelections:
semanticShiftCapturedSelections(item.capturedSelections, -1) ?? [],
});
if (
key !== null &&
key !== undefined &&
typeof key !== "object" &&
name === String(key)
)
replaced = true;
}
};
const objectTargetIndex = (
node: t.ObjectExpression,
indexes: WeakMap<t.ObjectExpression, ObjectTargetIndex>,
): ObjectTargetIndex => {
const existing = indexes.get(node);
if (existing !== undefined) return existing;
const propertyByName = new Map<string, t.ObjectMethod | t.ObjectProperty>();
const trailingUnknownsByProperty = new Map<
t.ObjectMethod | t.ObjectProperty,
ObjectTargetUnknown
>();
let trailingUnknown: ObjectTargetUnknown | undefined;
for (const property of [...node.properties].reverse()) {
if (t.isSpreadElement(property)) {
trailingUnknown = { node: property, previous: trailingUnknown };
continue;
}
const name = semanticStaticPropertyKey(property.key, property.computed);
if (name === null) {
trailingUnknown = { node: property, previous: trailingUnknown };
continue;
}
if (propertyByName.has(name)) continue;
propertyByName.set(name, property);
if (trailingUnknown !== undefined)
trailingUnknownsByProperty.set(property, trailingUnknown);
}
const created = { propertyByName, trailingUnknownsByProperty };
indexes.set(node, created);
return created;
};
@@ -155,3 +155,31 @@ it.each([
}
},
);
it("discloses getter-result widening when selecting returned callable targets", () => {
const parsed = parseJavaScriptSource(
"const box={get item(){return {make:()=>result}}};box.item.make();",
);
if (parsed === null) throw new Error("Expected getter fixture");
const steps = indexJavaScriptBindingsSteps(parsed.program, {
getter_expansions: 1,
});
let step = steps.next();
while (!step.done) step = steps.next();
let call: t.CallExpression | undefined;
t.traverseFast(parsed.program, (node) => {
if (t.isCallExpression(node)) call = node;
});
if (call === undefined)
throw new Error("Expected getter-produced callable invocation");
const result = createSemanticReturnedReferences(step.value)(
call.callee,
call,
);
expect(result.precision).toBe("widened");
expect(result.references).toContainEqual({
node: expect.objectContaining({ type: "Identifier", name: "result" }),
projection: [],
});
expect(step.value.returnedReferenceWidenings.has(call.callee)).toBe(true);
});
@@ -81,3 +81,29 @@ it.each([
);
},
);
it.each([
["const box={...{__proto__:{make:()=>module}}};box.make()", false],
["const box={...{get make(){return ()=>module}}};box.make()", true],
["class C{static make(){return module}};const box={...C};box.make()", false],
["class C{make(){return module}};const box={...new C()};box.make()", false],
[
"class Base{static make=()=>module};class C extends Base{};const box={...C};box.make()",
false,
],
["class C{static make=()=>module};const box={...C};box.make()", true],
["class C{make=()=>module};const box={...new C()};box.make()", true],
] as const)(
"copies only own enumerable callable values: %s",
(expression, expected) => {
const analysis = analyzeJavaScriptStaticSource(
`${expression}.require('./shared.cjs');`,
);
expect(analysis.parse_error_count).toBe(0);
expect(analysis.references).toEqual(
expected
? [expect.objectContaining({ kind: "require", specifier: null })]
: [],
);
},
);
@@ -201,3 +201,82 @@ it("refreshes cached returned targets after the shared member index settles", ()
}),
]);
});
it.each([
["const box={set make(value){return module}};box.make()", false],
["const box={get make(){return module}};box.make()", false],
["const box={get make(){return ()=>module}};box.make()", true],
[
"const box={get make(){return ()=>module},set make(value){}};box.make()",
true,
],
["const box={get make(){return ()=>module},make:()=>({})};box.make()", false],
["const box={__proto__:{make:()=>module}};box.make()", true],
["const box={__proto__:{make:()=>module},make:()=>({})};box.make()", false],
] as const)(
"uses property-read ownership before invoking a returned callable: %s",
(expression, expected) => {
const analysis = analyzeJavaScriptStaticSource(
`${expression}.require('./shared.cjs');`,
);
expect(analysis.parse_error_count).toBe(0);
expect(analysis.references).toEqual(
expected
? [expect.objectContaining({ kind: "require", specifier: null })]
: [],
);
},
);
it.each([
["class C{make(){return module}};C.make()", false],
["class C{static make(){return module}};new C().make()", false],
["class C{static make(){return module}};C.make()", true],
["class C{make(){return module}};new C().make()", true],
["class C{make(){return module}};C.prototype.make()", true],
["class C{get make(){return ()=>module}};new C().make()", true],
["class C{set make(value){return module}};new C().make()", false],
[
"class Base{static make(){return module}};class C extends Base{};C.make()",
true,
],
[
"class Base{make(){return module}};class C extends Base{make=()=>({})};new C().make()",
false,
],
] as const)(
"preserves the class property receiver before invoking its value: %s",
(expression, expected) => {
const analysis = analyzeJavaScriptStaticSource(
`${expression}.require('./shared.cjs');`,
);
expect(analysis.parse_error_count).toBe(0);
expect(analysis.references).toEqual(
expected
? [expect.objectContaining({ kind: "require", specifier: null })]
: [],
);
},
);
it.each([
["C.first:new C().second", false],
["C.second:new C().first", true],
] as const)(
"keeps static and instance selector alternatives correlated: %s",
(selection, expected) => {
const analysis = analyzeJavaScriptStaticSource(`
class C {
static first(){return {}} first(){return module}
static second(){return module} second(){return {}}
}
const selected=flag?${selection};selected().require('./shared.cjs');
`);
expect(analysis.parse_error_count).toBe(0);
expect(analysis.references).toEqual(
expected
? [expect.objectContaining({ kind: "require", specifier: null })]
: [],
);
},
);
@@ -535,7 +535,7 @@ const semanticWithPrototypeKind = (
t.isObjectProperty(property) &&
!property.computed &&
!property.shorthand &&
t.isIdentifier(property.key, { name: "__proto__" })
semanticStaticPropertyKey(property.key, false) === "__proto__"
)
return t.isNullLiteral(unwrapJavaScriptExpression(property.value).node)
? "null"
@@ -3,7 +3,58 @@ import { expect, it } from "vitest";
import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js";
import { topLevelBinding } from "./javascriptSemanticAnalysis.fixture.js";
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
import { analyzeCommonJsStaticFixture } from "../../../tests/fixtures/javascriptCommonJsAnalysis.js";
import {
analyzeCommonJsStaticFixture,
analyzeCommonJsSemanticFixture,
} from "../../../tests/fixtures/javascriptCommonJsAnalysis.js";
it.each([
"Object.assign(module,{require(){return {}}});",
"Object.assign(module,{...replacement});",
"Object.defineProperties(module,{[key]:{value:()=>({})}});",
'Reflect.set(module,"require",()=>({}));',
'Reflect.set({},"require",()=>({}),module);',
"({x:module.require}={x:replacement});",
"[module.require]=[replacement];",
"for(module.require of [replacement]){}",
])("retains uncertainty after a possible delegate replacement: %s", (write) => {
const analysis = analyzeCommonJsStaticFixture(`${write}require("electron");`);
expect(analysis.references).not.toContainEqual(
expect.objectContaining({ kind: "require", specifier: "electron" }),
);
expect(analysis.references).toContainEqual(
expect.objectContaining({ kind: "require", specifier: null }),
);
});
it.each([
"Object.assign(module,{other(){}});",
"Object.defineProperties(module,{other:{value:()=>({})}});",
"Object.assign({},{require(){}});",
'Reflect.set(module,"other",()=>({}));',
'Reflect.set(module,"require",()=>({}),{});',
"({x:module.other}={x:replacement});",
"[module.other]=[replacement];",
"for(module.other of [replacement]){}",
])("preserves the host loader after an unrelated write: %s", (write) => {
const analysis = analyzeCommonJsStaticFixture(`${write}require("electron");`);
expect(analysis.references).toEqual([
expect.objectContaining({ kind: "require", specifier: "electron" }),
]);
});
it.each([
"({x:module.require}={x:replacement});",
"[module.require]=[replacement];",
"for(module.require of [replacement]){}",
])("preserves a builtin captured before its delegate write: %s", (write) => {
const analysis = analyzeCommonJsSemanticFixture(
`const fs=require('node:fs');${write}fs.readFileSync('kept');`,
);
expect(analysis.configurationOperations.map(({ key }) => key)).toEqual([
"kept",
]);
});
it.each([
'var require;require("./before.cjs");',
@@ -76,3 +76,44 @@ it("recognizes a bundler runtime through nested member names", () => {
modules: [expect.objectContaining({ exports: ["ok"] })],
});
});
it.each(["1 as const", "1 satisfies number"])(
"keeps numeric bundle identities through erased syntax: %s",
(entry) => {
const analysis = analyzeJavaScriptStaticSource(`
globalThis.webpackChunkApp.push([[(${entry})], {
1: function(module, exports, req) { req((2 as const)); req.e((3 satisfies number)); },
2: function() {}
}, function(req) { req((${entry})); }]);
`);
expect(analysis.parse_error_count).toBe(0);
expect(analysis.bundler_registrations).toMatchObject([
{
chunk_keys: ["1"],
unknown_chunk_keys: 0,
entry_module_keys: ["1"],
unknown_entry_module_keys: 0,
async_chunk_keys: ["3"],
unknown_async_chunk_keys: 0,
},
]);
expect(analysis.references).toContainEqual(
expect.objectContaining({
specifier: "2",
loader: "factory-loader",
module_key: "1",
}),
);
},
);
it.each(["0, {}", "[], modules"])(
"does not grant factory-loader authority to a rejected registration: %s",
(entry) => {
const analysis = analyzeJavaScriptStaticSource(`
globalThis.webpackChunkApp.push([${entry}, function(req) { req("electron"); }]);
`);
expect(analysis.bundler_registrations).toEqual([]);
expect(analysis.references).toEqual([]);
},
);
@@ -140,7 +140,7 @@ export function* analyzeParsedJavaScriptStaticSourceSteps(
accumulator,
vitePreloadDependencyTable: indexVitePreloadDependencyTable(
file.program,
bindings.erasedSyntaxNodes,
bindings,
),
openReceiverFacts: receivers.openReceivers,
electronBindings: electronIdentity.bindings,
@@ -48,12 +48,12 @@ export const inspectBundlerRegistration = (
if (runtime === undefined || !t.isArrayExpression(entry)) return;
const chunkIds = entry.elements[0];
const table = entry.elements[1];
if (!t.isArrayExpression(chunkIds) || !t.isObjectExpression(table)) return;
const runtimeValue = runtimeMetadata(
entry.elements[2],
bindings,
accumulator,
);
if (!t.isArrayExpression(chunkIds) || !t.isObjectExpression(table)) return;
const recovered = recoverBundlerModules(source, table, accumulator, {
runtime,
bindings,
@@ -43,6 +43,7 @@ import type {
import { VITE_OUTPUT_DEPENDENCY_PREFIX } from "./javascriptStaticAnalysisTypes.js";
import {
resolveSemanticBindingState,
semanticBindingLookupAt,
semanticStableInitializer,
type JavaScriptBindingIndex,
type JavaScriptSemanticAnalysisState,
@@ -411,7 +412,7 @@ const chunkLoadSpecifier = (
const vitePreloadDependencySpecifiers = (
node: JavaScriptCallLike,
name: string,
table: readonly (string | undefined)[] | null,
table: FindingContext["vitePreloadDependencyTable"],
bindings: JavaScriptSemanticAnalysisState,
): readonly (string | undefined)[] => {
if (
@@ -420,10 +421,18 @@ const vitePreloadDependencySpecifiers = (
!t.isArrayExpression(node.arguments[0])
)
return [];
const callee = unwrapJavaScriptExpression(node.callee).node;
const lookup = semanticBindingLookupAt(bindings, callee, name);
if (
(lookup.kind !== "binding" && lookup.kind !== "uncertain-binding") ||
semanticStableInitializer(lookup.binding, bindings)?.node !== table.helper
)
return [];
const indexes = node.arguments[0].elements;
// Explicit cache overrides change which table the helper reads. Retain the
// call slots as unknown unless every override is statically undefined.
if (
lookup.kind !== "binding" ||
!node.arguments
.slice(1)
.every((argument) => isExplicitDefaultArgument(argument, bindings))
@@ -433,7 +442,7 @@ const vitePreloadDependencySpecifiers = (
if (element === null || !t.isNumericLiteral(element)) return undefined;
const index = element.value;
return Number.isSafeInteger(index) && index >= 0
? viteDependencySpecifier(table[index])
? viteDependencySpecifier(table.dependencies[index])
: undefined;
});
};
@@ -473,8 +482,9 @@ type ViteMapDepsFunction =
/** Index the canonical Vite helper's cache initializer from parsed syntax. */
export const indexVitePreloadDependencyTable = (
program: t.Program,
erasedSyntaxNodes: WeakSet<t.Node>,
): readonly (string | undefined)[] | null => {
bindings: JavaScriptSemanticAnalysisState,
): FindingContext["vitePreloadDependencyTable"] => {
const { erasedSyntaxNodes } = bindings;
const helpers: ViteMapDepsFunction[] = [];
traverseJavaScriptValueAst(program, erasedSyntaxNodes, {
enter: (node) => {
@@ -497,7 +507,7 @@ export const indexVitePreloadDependencyTable = (
const mapIdentifier = t.isAssignmentPattern(mapParameter)
? mapParameter.left
: mapParameter;
if (!t.isIdentifier(mapIdentifier)) return [];
if (!t.isIdentifier(mapIdentifier)) return { helper, dependencies: [] };
const tables: t.ArrayExpression[] = [];
traverseJavaScriptValueAst(helper, erasedSyntaxNodes, {
skip: (node) => t.isFunction(node),
@@ -515,9 +525,9 @@ export const indexVitePreloadDependencyTable = (
tables.push(node.right);
},
});
if (tables.length !== 1) return [];
if (tables.length !== 1) return { helper, dependencies: [] };
const table = tables[0];
if (table === undefined) return [];
if (table === undefined) return { helper, dependencies: [] };
// A spread contributes an unknown number of entries, so every later slot
// has an unknown index; leave those out instead of shifting them.
const spread = table.elements.findIndex((element) =>
@@ -540,6 +550,14 @@ export const indexVitePreloadDependencyTable = (
semanticIdentifierRole(node, parent, readAncestors) === null
)
return;
const lookup = semanticBindingLookupAt(bindings, node, node.name);
if (
lookup.kind === "ambient" ||
lookup.kind === "captured" ||
(lookup.kind === "binding" &&
semanticStableInitializer(lookup.binding, bindings)?.node !== helper)
)
return;
if (
(t.isCallExpression(parent) || t.isOptionalCallExpression(parent)) &&
parent.callee === node
@@ -548,7 +566,12 @@ export const indexVitePreloadDependencyTable = (
escapedOrMutated = true;
},
});
return escapedOrMutated ? dependencies.map(() => undefined) : dependencies;
return {
helper,
dependencies: escapedOrMutated
? dependencies.map(() => undefined)
: dependencies,
};
};
const requestConstructorUrl = (
@@ -769,8 +769,11 @@ export const stringValue = (
/** Return a string or numeric argument literal. */
export const argumentValue = (
node: t.Node | null | undefined,
): string | undefined =>
t.isNumericLiteral(node) ? String(node.value) : stringValue(node);
): string | undefined => {
if (node === null || node === undefined) return undefined;
const value = unwrapJavaScriptExpression(node).node;
return t.isNumericLiteral(value) ? String(value.value) : stringValue(value);
};
/** Prefix a static argument value when one is available. */
export const prefixedArgument = (
@@ -133,7 +133,10 @@ export interface JavaScriptFindingContext {
readonly expressionShapes: JavaScriptExpressionShapeResolver;
readonly source: string;
readonly accumulator: JavaScriptAnalysisAccumulator;
readonly vitePreloadDependencyTable: readonly (string | undefined)[] | null;
readonly vitePreloadDependencyTable: {
readonly helper: t.Node;
readonly dependencies: readonly (string | undefined)[];
} | null;
readonly openReceiverFacts?: ReadonlyMap<number, JavaScriptOpenReceiverFact>;
/** Proven Electron export path segments by root offset. */
readonly electronBindings?: ReadonlyMap<number, readonly string[]>;
@@ -1,6 +1,7 @@
import { expect, it } from "vitest";
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
import { analyzeCommonJsStaticFixture } from "../../../tests/fixtures/javascriptCommonJsAnalysis.js";
const windowPrefix =
"import {BrowserWindow} from 'electron'; import {fileURLToPath} from 'node:url';";
@@ -152,3 +153,52 @@ it("keeps a reused renderer receiver uncertain after its native method call", ()
"independent.html",
]);
});
it.each([
["untouched exports", "", 1],
[
"object-pattern exports write",
"({value: module.exports} = replacement);",
0,
],
["array-pattern exports write", "[module.exports] = replacement;", 0],
["loop-target exports write", "for (module.exports of replacement) {}", 0],
] as const)(
"requires intact bootstrap exports after %s",
(_name, write, expected) => {
const source = `(() => {
const table = {
external(module, exports, load) { module.exports = require('electron'); },
entry(module, exports, load) { new (load('external').BrowserWindow)(); }
};
function load(key) {
const module = {exports: {}};
table[key](module, module.exports, load);
${write}
return module.exports;
}
load('entry');
})();`;
const observed: string[] = [];
const windowClass = (identity: string) =>
class {
constructor() {
observed.push(identity);
}
};
const local = { BrowserWindow: windowClass("replacement") };
runInNewContext(source, {
replacement: Object.assign([local], { value: local }),
require: (specifier: string) => {
expect(specifier).toBe("electron");
return { BrowserWindow: windowClass("electron") };
},
});
expect(observed).toEqual([expected === 1 ? "electron" : "replacement"]);
expect(
analyzeCommonJsStaticFixture(source).electron.browser_windows,
).toHaveLength(expected);
},
);
import { runInNewContext } from "node:vm";
@@ -7,6 +7,7 @@ import {
traverseJavaScriptAst,
} from "./javascriptSemanticTraversal.js";
import { JavaScriptSymbolicBindings } from "./javascriptSymbolicBindings.js";
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
import {
symbolicTruth,
symbolicVariants,
@@ -1076,3 +1077,94 @@ it("retains each external selection guard once across deep member reads", () =>
});
expect(resolver.truncated).toBe(false);
});
it.each([
[
"module property after replacement",
'module.exports={api:null};module.exports.api=require("electron");',
{ kind: "external", loaderAuthority: "candidate", path: ["electron"] },
],
[
"module property overwritten after replacement",
'module.exports={api:require("electron")};module.exports.api=null;',
{ kind: "literal", value: null },
],
[
"detached exports property",
'module.exports={api:null};exports.api=require("electron");',
{ kind: "literal", value: null },
],
[
"rebound exports parameter",
'module.exports={api:null};exports={api:require("electron")};',
{ kind: "literal", value: null },
],
[
"identity-preserving module write",
'module.exports=module.exports;exports.api=require("electron");',
{ kind: "external", loaderAuthority: "candidate", path: ["electron"] },
],
[
"identity-preserving original exports write",
'module.exports=exports;exports.api=require("electron");',
{ kind: "external", loaderAuthority: "candidate", path: ["electron"] },
],
[
"reconnected exports parameter",
'module.exports={api:null};exports=module.exports;exports.api=require("electron");',
{ kind: "external", loaderAuthority: "candidate", path: ["electron"] },
],
[
"rebound module parameter",
'module={exports:{}};module.exports.api=require("electron");',
{ kind: "unknown", reason: "ambiguous export writes" },
],
] as const)("retains namespace write order for %s", (_name, body, expected) => {
const value = inspectOne(`(() => {
const modules={
1(module,exports){${body}},
2(module,exports,load){inspect(load(1).api);}
};
function load(id){const m={exports:{}};modules[id](m,m.exports,load);return m.exports;}
load(2);
})();`);
expect(value).toEqual(expected);
});
it("lists members added to the public module object after its replacement", () => {
const { resolver, values } = inspectFixture(`(() => {
const modules={
1(module,exports){module.exports={first:1};module.exports.later=2;exports.detached=3;},
2(module,exports,load){inspect(load(1));}
};
function load(id){const m={exports:{}};modules[id](m,m.exports,load);return m.exports;}
load(2);
})();`);
expect(values).toHaveLength(1);
expect(resolver.members(values[0]!)).toEqual(["first", "later"]);
});
it("derives bridge paths from the latest public module member writes", () => {
const result = analyzeJavaScriptStaticSource(`(() => {
const modules={
1(module,exports){
const e=require("electron");
module.exports={action:()=>e.ipcRenderer.send("stale")};
module.exports.action=()=>e.ipcRenderer.send("current");
exports.detached=()=>e.ipcRenderer.send("detached");
},
2(module,exports,load){
const api=load(1);
require("electron").contextBridge.exposeInMainWorld("desktop",api);
}
};
function load(id){const m={exports:{}};modules[id](m,m.exports,load);return m.exports;}
load(2);
})();`);
expect(
result.electron.call_paths?.map(({ entry, channel }) => ({
entry,
channel,
})),
).toEqual([{ entry: "desktop.action", channel: "current" }]);
});
@@ -52,6 +52,9 @@ interface PropertyWrite {
readonly path: readonly string[];
readonly value: t.Node;
readonly site: t.Node;
readonly exportReceiver?: "module" | "exports";
readonly exportValueReceiver?: "module" | "exports";
readonly receiverUncertain?: true;
}
interface SymbolicReturnFlow extends SymbolicMetadata {
readonly values: readonly SymbolicValue[];
@@ -198,6 +201,12 @@ export class JavaScriptSymbolicBindings {
}
},
});
for (const writes of this.#writes.values())
writes.sort(
(left, right) =>
(left.site.end ?? Infinity) - (right.site.end ?? Infinity) ||
(right.site.start ?? 0) - (left.site.start ?? 0),
);
for (const table of this.#bootstrapTables)
this.#invalidateBootstrapTable(table.tableAliases, table.modules);
}
@@ -396,19 +405,19 @@ export class JavaScriptSymbolicBindings {
this.unresolved = true;
return symbolicUnknown("module export read boundary is unavailable");
}
const replacement = available.findLast(
(write) => write.path.length === 0,
const write = available.findLast(
(write) =>
write.path.length === 0 ||
(write.path.length === 1 && write.path[0] === key),
);
if (replacement)
if (write?.receiverUncertain)
return symbolicUnknown("uncertain export receiver");
if (write?.path.length === 0)
return this.property(
this.evaluate(replacement.value, value.environment),
this.evaluate(write.value, value.environment),
key,
read,
);
const writes = available.filter(
(w) => w.path.length === 1 && w.path[0] === key,
);
const write = writes.at(-1);
return write
? this.evaluate(write.value, value.environment)
: symbolicUnknown("unresolved module export");
@@ -509,19 +518,23 @@ export class JavaScriptSymbolicBindings {
if (value.kind === "namespace") {
const available = this.#namespaceWrites(value);
if (available === undefined) return [];
const replacement = available.findLast(
const replacementIndex = available.findLastIndex(
(write) => write.path.length === 0,
);
return replacement
? this.members(
this.evaluate(replacement.value, value.environment),
active,
)
: [
...new Set(
available.flatMap((w) => (w.path.length === 1 ? w.path : [])),
),
];
const replacement = available[replacementIndex];
return [
...new Set([
...(replacement === undefined
? []
: this.members(
this.evaluate(replacement.value, value.environment),
active,
)),
...available
.slice(replacementIndex + 1)
.flatMap((write) => (write.path.length === 1 ? write.path : [])),
]),
];
}
if (value.kind !== "object") return [];
return [
@@ -563,6 +576,7 @@ export class JavaScriptSymbolicBindings {
if (this.#mutated.has(value.bindingId)) return 1;
const available = this.#namespaceWrites(value);
if (available === undefined) return 1;
if (available.some((write) => write.receiverUncertain)) return 1;
const replacement = available.findLast(
(write) => write.path.length === 0,
);
@@ -1043,7 +1057,9 @@ export class JavaScriptSymbolicBindings {
if (boundary !== undefined && sourcePoint == null) return undefined;
if (completedAt !== undefined && completedAt.end == null) return undefined;
if (sourcePoint == null && completedAt === undefined)
return read === undefined ? writes : undefined;
return read === undefined
? this.#publicNamespaceWrites(writes)
: undefined;
// An owned snapshot cannot read beyond either its capture or this exit.
const point = Math.min(
sourcePoint ?? Infinity,
@@ -1069,17 +1085,84 @@ export class JavaScriptSymbolicBindings {
}
}
}
return writes.filter(
(write) =>
write.site.end != null &&
write.site.end <= point &&
(changed.size === 0 ||
(write.path.length > 0
? !changed.has(write.path[0]!)
: selectedKey !== undefined && !changed.has(selectedKey))),
return this.#publicNamespaceWrites(
writes.filter(
(write) =>
write.site.end != null &&
write.site.end <= point &&
(changed.size === 0 ||
(write.path.length > 0
? !changed.has(write.path[0]!)
: selectedKey !== undefined && !changed.has(selectedKey))),
),
);
}
/** Keep publication separate from writes through a detached or rebound alias. */
#publicNamespaceWrites(
writes: readonly PropertyWrite[],
): readonly PropertyWrite[] {
let relation: "connected" | "detached" | "uncertain" = "connected";
const published: PropertyWrite[] = [];
for (const write of writes) {
if (write.path.length === 0 && write.exportReceiver === "exports") {
if (write.exportValueReceiver === "module") relation = "connected";
else if (write.exportValueReceiver !== "exports")
relation = "uncertain";
continue;
}
if (write.path.length === 0 && write.exportReceiver === "module") {
if (
write.exportValueReceiver === "module" ||
(write.exportValueReceiver === "exports" && relation === "connected")
)
continue;
relation =
write.exportValueReceiver === "exports"
? "connected"
: relation === "uncertain"
? "uncertain"
: "detached";
}
if (write.exportReceiver === "exports") {
if (relation === "detached") continue;
if (relation === "uncertain") {
published.push({ ...write, receiverUncertain: true });
continue;
}
}
published.push(write);
}
return published;
}
#exportValueReceiver(
input: t.Node,
alias: string,
): "module" | "exports" | undefined {
const expression = unwrapJavaScriptExpression(input).node;
const node = t.isAssignmentExpression(expression, { operator: "=" })
? expression.left
: expression;
const root = t.isMemberExpression(node) ? node.object : node;
if (!t.isIdentifier(root)) return undefined;
const binding = resolveSemanticBindingState(this.state, root, root.name);
if (
binding === undefined ||
this.#exportAliases.get(binding.bindingId) !== alias
)
return undefined;
if (t.isIdentifier(node))
return this.#moduleBindings.has(binding.bindingId)
? undefined
: "exports";
return t.isMemberExpression(node) &&
this.#moduleBindings.has(binding.bindingId) &&
semanticStaticPropertyKey(node.property, node.computed) === "exports"
? "module"
: undefined;
}
#completeNamespace(
value: SymbolicNamespace,
point: t.Node,
@@ -1548,6 +1631,14 @@ export class JavaScriptSymbolicBindings {
const binding = resolveSemanticBindingState(this.state, root, root.name);
if (!binding) return;
const alias = this.#exportAliases.get(binding.bindingId);
if (
alias &&
this.#moduleBindings.has(binding.bindingId) &&
path.length === 0
) {
this.#mutated.add(alias);
return;
}
if (this.#moduleBindings.has(binding.bindingId) && path[0] !== "exports")
return;
const key = alias ?? binding.bindingId;
@@ -1577,7 +1668,25 @@ export class JavaScriptSymbolicBindings {
}
}
const writes = this.#writes.get(key) ?? [];
writes.push({ path: selected, value, site });
const exportValueReceiver =
alias !== undefined &&
selected.length === 0 &&
t.isAssignmentExpression(value, { operator: "=" })
? this.#exportValueReceiver(value.right, alias)
: undefined;
writes.push({
path: selected,
value,
site,
...(alias === undefined
? {}
: {
exportReceiver: this.#moduleBindings.has(binding.bindingId)
? ("module" as const)
: ("exports" as const),
}),
...(exportValueReceiver === undefined ? {} : { exportValueReceiver }),
});
this.#writes.set(key, writes);
}
}
@@ -1,6 +1,10 @@
import { describe, expect, it } from "vitest";
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
import {
analyzeJavaScriptStaticSource,
analyzeParsedJavaScriptStaticSource,
} from "./javascriptStaticAnalysis.js";
import { parseJavaScriptSource } from "./javascriptSourceParser.js";
describe("Vite preload dependency recovery", () => {
it("decodes helper table values and preserves sparse and dynamic slots", () => {
@@ -143,3 +147,84 @@ describe("Vite preload dependency recovery", () => {
]);
});
});
it.each([
{
name: "canonical declaration",
prefix: "",
suffix: "__vite__mapDeps([0]);",
specifiers: ["./canonical.js"],
},
{
name: "captured outer binding",
prefix: "",
suffix: "function load(){return __vite__mapDeps([0]);}load();",
specifiers: ["./canonical.js"],
},
{
name: "shadowed parameter",
prefix: "",
suffix:
"function load(__vite__mapDeps){return __vite__mapDeps([0]);}load(()=>[]);",
specifiers: [],
},
{
name: "out-of-scope declaration",
prefix: "function setup(){",
suffix: "}__vite__mapDeps([0]);",
specifiers: [],
},
{
name: "unrelated shadowed cache mutation",
prefix: "",
suffix:
"function change(__vite__mapDeps){__vite__mapDeps.f=['./other.js'];}__vite__mapDeps([0]);",
specifiers: ["./canonical.js"],
},
{
name: "uncertain with receiver",
prefix: "",
suffix: "with(unknown){__vite__mapDeps([0]);}",
specifiers: [null],
},
{
name: "uncertain with mutation",
prefix: "",
suffix:
"with(unknown){__vite__mapDeps.f=['./other.js'];}__vite__mapDeps([0]);",
specifiers: [null],
},
])(
"uses the canonical helper's lexical identity for $name",
({ prefix, suffix, specifiers }) => {
const source = `${prefix}
const __vite__mapDeps = (indexes, map = __vite__mapDeps, dependencies =
(map.f || (map.f = ["./canonical.js"]))) => indexes.map((index) => dependencies[index]);
${suffix}
`;
const parsed = parseJavaScriptSource(source, "preload.cjs");
expect(parsed).not.toBeNull();
if (parsed === null) throw new Error("preload fixture did not parse");
const analysis = analyzeParsedJavaScriptStaticSource(source, parsed);
expect(analysis.parse_error_count).toBe(0);
expect(
analysis.references
.filter(({ kind }) => kind === "dynamic-import")
.map(({ specifier }) => specifier),
).toEqual(specifiers);
},
);
it("retains the identity of a function-declared preload helper", () => {
const analysis = analyzeJavaScriptStaticSource(`
function __vite__mapDeps(indexes, map = __vite__mapDeps, dependencies =
(map.f || (map.f = ["./canonical.js"]))) { return indexes.map((index) => dependencies[index]); }
__vite__mapDeps([0]);
`);
expect(analysis.parse_error_count).toBe(0);
expect(
analysis.references
.filter(({ kind }) => kind === "dynamic-import")
.map(({ specifier }) => specifier),
).toEqual(["./canonical.js"]);
});
-29
View File
@@ -2,7 +2,6 @@ import { describe, expect, it } from "vitest";
import { z } from "zod";
import {
addOffsetDateTimeMilliseconds,
inspectRecordedDateTime,
offsetDateTimeSchema,
readRecordedDateTime,
@@ -23,34 +22,6 @@ it.each([
expect(inspectRecordedDateTime(text).kind).toBe(kind);
});
it.each([
["000001", 0, 10, "000001"],
["999999", 0.001, 11, ""],
["999999", 0.001001, 11, "000000001"],
["1234567890123456789", 1000.001, 11, "1234577890123456789"],
["", 1e-7, 10, "0000000001"],
["", 999.9999999999999, 10, "9999999999999999"],
])(
"adds duration %s + %sms without losing decimal precision",
(fraction, duration, epochSecond, expectedFraction) => {
expect(
addOffsetDateTimeMilliseconds({ epochSecond: 10, fraction }, duration),
).toEqual({ epochSecond, fraction: expectedFraction });
},
);
it.each([-1, Number.NaN, Number.POSITIVE_INFINITY, Number.MAX_VALUE])(
"keeps unsupported recorded duration %s unknown",
(duration) => {
expect(
addOffsetDateTimeMilliseconds(
{ epochSecond: 10, fraction: "" },
duration,
),
).toBeUndefined();
},
);
const ACCEPTED: readonly (readonly [string, string])[] = [
["a UTC timestamp", "2026-10-09T04:00:00Z"],
["a positive offset", "2026-10-09T04:00:00+02:00"],
+6 -42
View File
@@ -146,14 +146,12 @@ export const offsetDateTimeInstantFromEpochMilliseconds = (
const millisecondsWithinSecond = epochMilliseconds - epochSecond * 1000;
return {
epochSecond,
fraction: secondsFromMilliseconds(millisecondsWithinSecond).fraction,
fraction: fractionalSecondFromMilliseconds(millisecondsWithinSecond),
};
};
const secondsFromMilliseconds = (
milliseconds: number,
): { readonly seconds: number; readonly fraction: string } => {
if (milliseconds === 0) return { seconds: 0, fraction: "" };
const fractionalSecondFromMilliseconds = (milliseconds: number): string => {
if (milliseconds === 0) return "";
const [coefficient = "", exponentText] = String(milliseconds)
.toLowerCase()
.split("e");
@@ -168,43 +166,9 @@ const secondsFromMilliseconds = (
: decimalPosition < digits.length
? `${digits.slice(0, decimalPosition)}.${digits.slice(decimalPosition)}`
: `${digits}${"0".repeat(decimalPosition - digits.length)}`;
const separator = fraction.indexOf(".");
return {
seconds: Number(separator < 0 ? fraction : fraction.slice(0, separator)),
fraction: (separator < 0 ? "" : fraction.slice(separator + 1)).replace(
/0+$/u,
"",
),
};
};
/** Add a recorded nonnegative decimal millisecond duration without rounding away either fraction. */
export const addOffsetDateTimeMilliseconds = (
start: OffsetDateTimeInstant,
milliseconds: number,
): OffsetDateTimeInstant | undefined => {
if (!Number.isFinite(milliseconds) || milliseconds < 0) return undefined;
if (milliseconds === 0) return start;
const duration = secondsFromMilliseconds(milliseconds);
const wholeSeconds = start.epochSecond + duration.seconds;
if (!Number.isSafeInteger(wholeSeconds)) return undefined;
const length = Math.max(start.fraction.length, duration.fraction.length);
const digits: string[] = [];
let carry = 0;
for (let index = length - 1; index >= 0; index--) {
const sum =
(start.fraction.charCodeAt(index) || 48) -
48 +
(duration.fraction.charCodeAt(index) || 48) -
48 +
carry;
digits.push(String(sum % 10));
carry = Math.floor(sum / 10);
}
const epochSecond = wholeSeconds + carry;
return Number.isSafeInteger(epochSecond)
? { epochSecond, fraction: digits.reverse().join("").replace(/0+$/u, "") }
: undefined;
return (
fraction.includes(".") ? fraction.slice(fraction.indexOf(".") + 1) : ""
).replace(/0+$/u, "");
};
/** Compare instants exactly while treating absent fraction digits as zeroes. */
-105
View File
@@ -1,4 +1,3 @@
import { createHash } from "node:crypto";
import { expect, it } from "vitest";
import {
freezeJsonSnapshot,
@@ -117,107 +116,3 @@ it("retains schema normalization when an owned source contains a hidden reported
Object.hasOwn(parsed.data.container.reported as object, "hidden"),
).toBe(false);
});
it.each([
{
name: "malformed base64",
content_base64: "!!!!",
bytes: 0,
sha256: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
},
{
name: "noncanonical pad bits",
content_base64: "AR==",
bytes: 1,
sha256: createHash("sha256")
.update(Buffer.from([1]))
.digest("hex"),
},
{
name: "mismatched byte count",
content_base64: "YQ==",
bytes: 2,
sha256: "ca978112ca1bbdcafac231b39a23dc4da786eff8147c4e72b9807785afee48bb",
},
{
name: "mismatched digest",
content_base64: "YQ==",
bytes: 1,
sha256: "0".repeat(64),
},
])(
"refuses $name in retained binary facts before projection",
({ name: _name, ...binary }) => {
const report = capture();
const record = {
...report.records[0]!,
binary_fields: [
{
pointer: "/response/content/text",
representation: "har-base64-content",
state: "retained",
...binary,
},
],
};
const parsed = webNetworkCaptureSchema.safeParse({
...report,
records: [record],
});
expect(parsed.success).toBe(false);
},
);
it.each([
{ name: "empty", bytes: Buffer.alloc(0) },
{ name: "Unicode", bytes: Buffer.from("雪�") },
{ name: "multiple windows", bytes: Buffer.alloc(100_001, 0xff) },
])(
"retains $name canonical binary identity across bounded digest windows",
({ bytes }) => {
const report = capture();
const source = {
...report,
records: [
{
...report.records[0]!,
binary_fields: [
{
pointer: "/body",
representation: "producer-bytes",
state: "retained",
content_base64: bytes.toString("base64"),
bytes: bytes.length,
sha256: createHash("sha256").update(bytes).digest("hex"),
},
],
},
],
};
const parsed = webNetworkCaptureSchema.parse(source);
expect(parsed).toEqual(source);
},
);
it("refuses multiple binary declarations for one source coordinate", () => {
const report = capture();
const binary = (text: string) => ({
pointer: "/response/content/text",
representation: "har-base64-content",
state: "retained",
content_base64: Buffer.from(text).toString("base64"),
bytes: Buffer.byteLength(text),
sha256: createHash("sha256").update(text).digest("hex"),
});
expect(
webNetworkCaptureSchema.safeParse({
...report,
records: [
{
...report.records[0]!,
binary_fields: [binary("first"), binary("second")],
},
],
}).success,
).toBe(false);
});
+2 -46
View File
@@ -1,5 +1,3 @@
import { createHash } from "node:crypto";
import { canonicalBase64Schema } from "./stringPatterns.js";
import { z } from "zod";
import {
browserDeadlineIncrease,
@@ -217,35 +215,7 @@ export const webNetworkCaptureBinaryDescriptorSchema = z.discriminatedUnion(
[retainedBinaryDescriptorSchema, redactedBinaryDescriptorSchema],
);
const binarySchema = z.discriminatedUnion("state", [
retainedBinaryDescriptorSchema
.extend({ content_base64: canonicalBase64Schema })
.superRefine((field, context) => {
const encoded = field.content_base64;
const decodedBytes = Buffer.byteLength(encoded, "base64");
if (field.bytes !== decodedBytes) {
context.addIssue({
code: "custom",
path: ["bytes"],
message:
"Retained binary byte count differs from its canonical content.",
});
return;
}
// Quartet-aligned windows authenticate every retained byte without
// allocating a second whole body while the capture remains live.
const hash = createHash("sha256");
for (let offset = 0; offset < encoded.length; offset += 65536)
hash.update(
Buffer.from(encoded.slice(offset, offset + 65536), "base64"),
);
if (hash.digest("hex") !== field.sha256)
context.addIssue({
code: "custom",
path: ["sha256"],
message:
"Retained binary SHA-256 differs from its canonical content.",
});
}),
retainedBinaryDescriptorSchema.extend({ content_base64: z.string() }),
redactedBinaryDescriptorSchema.extend({ content_base64: z.null() }),
]);
const numberSchema = z.strictObject({
@@ -274,21 +244,7 @@ export const webNetworkCaptureRecordSchema = z.strictObject({
}),
]),
reported: jsonValueSchema,
binary_fields: z.array(binarySchema).superRefine((fields, context) => {
const pointers = new Set<string>();
for (const [index, field] of fields.entries()) {
if (pointers.has(field.pointer)) {
context.addIssue({
code: "custom",
path: [index, "pointer"],
message:
"Each retained source coordinate can declare binary facts only once.",
});
return;
}
pointers.add(field.pointer);
}
}),
binary_fields: z.array(binarySchema),
numeric_literals: z.array(numberSchema),
redactions: z.array(redactionSchema),
limitations: z.array(z.string()),
@@ -923,12 +923,27 @@ it("traces all endpoint carriers and their response consumers through CLI and MC
"response",
]);
const response = carriers.find(({ kind }) => kind === "response");
if (response === undefined) throw new Error("Expected matching response");
const request = carriers.find(({ kind }) => kind === "request");
if (response === undefined || request === undefined)
throw new Error("Expected matching request and response");
const consumer = graph.relations.find(
({ source_node_id, relation }) =>
source_node_id === response.node_id && relation === "consumed-by",
);
expect(consumer).toMatchObject({ resolution: "resolved" });
const requestLink = graph.relations.find(
({ source_node_id, target_node_id, relation }) =>
source_node_id === request.node_id &&
target_node_id === response.node_id &&
relation === "consumed-by",
);
expect(consumer).toMatchObject({
resolution: "resolved",
properties: { method: "json" },
});
expect(requestLink).toMatchObject({
resolution: "resolved",
properties: { method: "json" },
});
const client = await connectedMcpClient();
const trace = async (seed: JavaScriptSemanticQueryInput["seed"]) => {
const input = {
@@ -980,13 +995,13 @@ it("traces all endpoint carriers and their response consumers through CLI and MC
"request",
"response",
]);
expect(selected.relations).toEqual([consumer]);
expect(selected.relations).toEqual([requestLink, consumer]);
expect(selected).toMatchObject({
status: "ambiguous",
summary: {
total_seed_matches: 2,
traversed_nodes: 3,
traversed_relations: 1,
traversed_relations: 2,
},
coverage: { status: "partial" },
});