Compare commits

..
91 changed files with 2157 additions and 2338 deletions

No files matched your search

@@ -25,6 +25,7 @@ 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,
@@ -291,16 +292,6 @@ 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,
@@ -326,9 +317,11 @@ const deletedEntity = (
call,
(collection, id) => store.deleted(type, collection, id),
);
return entity === undefined
? undefined
: { ...entity, fields: deletedFields(serving, type, entity.fields) };
if (entity === undefined) return undefined;
const flag = softDeleteFlag(serving, type);
return flag === undefined
? entity
: updatedEntity(entity, { ...entity.fields, [flag]: true });
};
/** The answer for an entity the state does not hold: the tool's latest recorded 400, 404 or 410, else a tool error. */
@@ -33,6 +33,44 @@ 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,3 +181,47 @@ 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,6 +32,7 @@ import {
} from "../../domain/analysisView/environmentStateWriteLinks.js";
import {
pathValuesOf,
pathParameterKeys,
type GeneratedRule,
type StateWritePlan,
type WriteCall,
@@ -106,14 +107,12 @@ export const pathValues = (
args: ReplayArguments,
): Map<string, string> => {
const names = environmentToolArguments(tool).path;
const template = tool.path_template
.slice(tool.path_template.indexOf("/") + 1)
.split("/");
const keys = pathParameterKeys(tool.path_template);
const values = new Map<string, string>();
for (const param of tool.params) {
const value = ownMember(args, names.get(param.name) ?? param.name);
const segment = template[param.index] ?? "";
if (typeof value === "string") values.set(segment.slice(1, -1), value);
const key = keys.get(param.index);
if (typeof value === "string" && key !== undefined) values.set(key, value);
}
return values;
};
@@ -11,6 +11,8 @@ 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);
@@ -72,6 +74,33 @@ 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,5 +1,6 @@
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";
@@ -15,7 +16,16 @@ export const pinnedRequest = (
request: WriteRequest,
): Result<WriteRequest, AnalysisError> => {
const { tool, plan, args } = request;
if (hasRepeatedQueryArgument(tool, args))
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)
return err(
refused(
tool,
@@ -24,7 +34,9 @@ export const pinnedRequest = (
);
const query = pinnedQuery(
serving.source.exchanges.get(plan.exemplar_ordinal ?? -1)?.query,
callQuery(tool, args),
// Comparison supplies the raw query as well as translated known arguments.
// Keep unmodeled parameters visible without replacing those translations.
new Map([...observed, ...supplied]),
);
if (!query.ok) return err(refused(tool, `Tool ${tool.name} ${query.error}`));
const names = environmentToolArguments(tool).query;
@@ -10,7 +10,6 @@ import {
addSemanticGraphUnknown,
} from "./JavaScriptSemanticGraphConstruction.js";
import {
inferredSemanticEvidenceAt,
unknownSemanticEvidence,
builtinSemanticNode,
builtinSemanticRelation,
@@ -40,7 +39,6 @@ export const projectSemanticEvents = (
source: eventEmitterNode(context, operation),
target: event,
relation,
evidence: inferredSemanticEvidenceAt(context.file, operation.location),
resolution:
operation.resolution === "complete" ? "resolved" : "candidate",
});
@@ -50,7 +48,6 @@ export const projectSemanticEvents = (
source: event,
target: listener,
relation,
evidence: inferredSemanticEvidenceAt(context.file, operation.location),
resolution:
operation.resolution === "complete" ? "resolved" : "candidate",
});
@@ -480,8 +480,10 @@ export const createJavaScriptSemanticGraphProjection = (
limit,
);
}
const remainingTreeBudget = Math.max(0, nodeCeiling - state.nodes.size);
state.fileNodeBudget = Math.min(fileNodeCeiling, remainingTreeBudget);
state.fileNodeBudget = Math.min(
fileNodeCeiling,
Math.max(0, nodeCeiling - state.nodes.size),
);
const module = retainNode(state, file, {
kind: "module",
roleKey: "module",
@@ -491,14 +493,6 @@ 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,7 +10,6 @@ import {
addSemanticGraphUnknown,
} from "./JavaScriptSemanticGraphConstruction.js";
import {
inferredSemanticEvidenceAt,
builtinSemanticNode,
builtinSemanticRelation,
unknownSemanticEvidence,
@@ -136,7 +135,7 @@ export const projectSemanticRequests = (
if (request === undefined) continue;
if (operation.kind === "request")
projectRequestConstruction(context, operation, request);
else projectResponseConsumer(context, operation, request, requestNodes);
else projectResponseConsumer(context, operation, request);
if (operation.resolution !== "complete")
addDataUnknown({
context,
@@ -225,31 +224,16 @@ 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",
...authority,
evidence,
...builtinSemanticRelation(
context.file,
operation,
operation.resolution === "complete" ? "resolved" : "candidate",
),
properties: { method: operation.method },
});
};
@@ -14,10 +14,7 @@ import {
addSemanticGraphRelation,
addSemanticGraphUnknown,
} from "./JavaScriptSemanticGraphConstruction.js";
import {
inferredSemanticEvidenceAt,
unknownSemanticEvidence,
} from "./JavaScriptSemanticGraphEvidence.js";
import { unknownSemanticEvidence } from "./JavaScriptSemanticGraphEvidence.js";
import type { SemanticFlowProjectionContext } from "./JavaScriptSemanticGraphFlowProjection.js";
import { semanticPropertySlot } from "./JavaScriptSemanticGraphValueProjection.js";
@@ -50,7 +47,6 @@ 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,37 +340,3 @@ 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,3 +120,48 @@ 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,9 +215,11 @@ export class EnvironmentManifestAccumulator extends WebNetworkProjectionAccumula
record: WebNetworkCaptureRecord,
scenarioResponseBody?: ScenarioResponseBodyRetention,
): Result<void, AnalysisError> {
const limitations = this.addLimitations(record.limitations);
if (!limitations.ok) return limitations;
const rollbackAdmission = this.retainedCheckpoint();
let committed = false;
try {
const limitations = this.prepareLimitations(record.limitations);
if (!limitations.ok) return limitations;
const line = readRecordedRequestLine(
record.reported,
this.includeQueryParameterNames,
@@ -226,16 +228,14 @@ export class EnvironmentManifestAccumulator extends WebNetworkProjectionAccumula
if (line === null) return this.overBudget();
const releaseGrouping = this.temporaryCheckpoint();
if (line.kind === "excluded") {
this.#unrepresented += 1;
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 prepared = this.prepareRecordedRequestLine(record, line);
if (!prepared.ok) return prepared;
prepared.value();
limitations.value();
this.#unrepresented += 1;
committed = true;
return ok(undefined);
}
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.#append(
const grouped = this.#prepareAppend(
record.ordinal,
line,
route,
@@ -277,18 +277,33 @@ export class EnvironmentManifestAccumulator extends WebNetworkProjectionAccumula
if (!grouped.ok) return grouped;
// Keep the parsed line's admission while releasing grouping-only scratch.
releaseGrouping();
return this.onRecordedRequestLine(record, line);
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);
} finally {
if (!committed) rollbackAdmission();
this.releaseTemporary();
}
}
/** Consume the owner's parsed request line after successful grouping, including exclusions. */
protected onRecordedRequestLine(
/** Admit derived facts from the owner's parsed line, then commit only after all owners admit it. */
protected prepareRecordedRequestLine(
_record: WebNetworkCaptureRecord,
_line: RecordedRequestLine,
): Result<void, AnalysisError> {
return ok(undefined);
): Result<() => void, AnalysisError> {
return ok(() => {});
}
/** Read the top-level key types of the group's first recorded JSON-typed request body; nested values are never walked. */
@@ -316,14 +331,14 @@ export class EnvironmentManifestAccumulator extends WebNetworkProjectionAccumula
return ok({ type: "object", properties: Object.fromEntries(entries) });
}
#append(
#prepareAppend(
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;
@@ -366,48 +381,49 @@ 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();
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 = {
return ok(() => {
const position = this.#records.length;
this.#records.push({
ordinal,
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);
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);
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);
});
}
#tools(groups: readonly ToolGroup[]): Tool[] {
@@ -513,7 +529,8 @@ export class EnvironmentManifestAccumulator extends WebNetworkProjectionAccumula
compare(left.method, right.method) ||
compare(left.reason, right.reason),
);
const limitations = this.addLimitations([
// Derived limitations describe this snapshot, not an earlier finish.
const limitations = this.snapshotLimitations([
...(this.reportsBodyShapes &&
tools.some((tool) => tool.body_shape !== null)
? [
@@ -521,16 +538,6 @@ export class EnvironmentManifestAccumulator extends WebNetworkProjectionAccumula
]
: []),
...this.#segmentLimitLimitations(toolGroups),
...(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.`,
]
: []),
...(this.#scenarioRedactedBodies > 0
? [
`${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.`,
@@ -541,6 +548,16 @@ export class EnvironmentManifestAccumulator extends WebNetworkProjectionAccumula
"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.`,
]
: []),
...(this.#scenarioRedactedBodies > 0
? [
`${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.`,
]
: []),
]);
if (!limitations.ok) return limitations;
return ok({
@@ -567,7 +584,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: this.sortedLimitations(),
limitations: limitations.value,
});
}
}
@@ -3,6 +3,7 @@ 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";
@@ -29,6 +30,7 @@ import {
base64PrefixChars,
readRecordedResponseBody,
readRecordedResponseDeclaration,
recordedResponseStatus,
recordedResponseBodyIsEmpty,
type RecordedResponseBody,
} from "./recordedResponseBody.js";
@@ -45,6 +47,9 @@ 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 = (
@@ -69,14 +74,14 @@ export const stateBodyJson = (
const startSchema = z.object({ startedDateTime: z.string() });
const durationSchema = z.object({ time: z.number().nonnegative() });
const redactedWriteSchema = z.object({
const unlocatedWriteSchema = z.object({
request: z.object({ method: z.string() }),
response: z.object({ status: z.number() }).optional(),
});
interface RecordEntry {
readonly facts: StateRecordFacts;
/** Null for a write whose URL was redacted: its target is unknown, so it bounds every type. */
/** Null for a write without a usable URL: its unknown target bounds every type. */
readonly origin: string | null;
readonly targetExclusion?: "url-redacted" | "url-unparseable";
readonly contentType: string;
readonly startInterpretation:
| NonNullable<ReturnType<typeof readRecordedStart>>["interpretation"]
@@ -119,10 +124,10 @@ export class EnvironmentStateAccumulator extends EnvironmentManifestAccumulator
return false;
}
protected override onRecordedRequestLine(
protected override prepareRecordedRequestLine(
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.
@@ -148,28 +153,52 @@ 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;
: 0) +
(endInstant !== undefined && endInstant !== startInstant
? ENTRY_BYTES + textBytes(endInstant.fraction.length)
: 0);
if (line.kind === "excluded")
return line.reason === "url-redacted"
? this.#redactedWrite(
return line.reason === "url-redacted" ||
line.reason === "url-unparseable" ||
line.reason === "request-unavailable"
? this.#unlocatedWrite(
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(
@@ -185,56 +214,62 @@ export class EnvironmentStateAccumulator extends EnvironmentManifestAccumulator
)
)
return this.overBudget();
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(() => {
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);
}
/** Keep a successful write whose URL was redacted, with an empty path whose root relates to every type. */
#redactedWrite(
/** Prepare a successful write with unknown target, whose empty root relates to every type. */
#unlocatedWrite(
record: WebNetworkCaptureRecord,
reason: "url-redacted" | "url-unparseable",
timing: StateRecordFacts["timing"],
instantBytes: number,
startInterpretation: RecordEntry["startInterpretation"],
): Result<void, AnalysisError> {
const parsed = redactedWriteSchema.safeParse(record.reported);
if (!parsed.success) return ok(undefined);
const status = parsed.data.response?.status ?? 0;
): Result<() => void, AnalysisError> {
const parsed = unlocatedWriteSchema.safeParse(record.reported);
if (!parsed.success) return ok(() => undefined);
const status = recordedResponseStatus(
readRecordedResponseDeclaration(record.reported),
);
const facts: StateRecordFacts = {
ordinal: record.ordinal,
method: parsed.data.request.method,
path: "",
query: undefined,
status: status > 0 ? status : null,
status,
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();
this.#entries.set(record.ordinal, {
facts,
origin: null,
contentType: "",
startInterpretation,
record,
return ok(() => {
this.#entries.set(record.ordinal, {
facts,
origin: null,
targetExclusion: reason,
contentType: "",
startInterpretation,
record,
});
});
return ok(undefined);
}
/** Parse one retained response body as state JSON, charging its decoding and tree as temporary work. */
@@ -330,7 +365,10 @@ export class EnvironmentStateAccumulator extends EnvironmentManifestAccumulator
entry.origin === manifest.value.origin || entry.origin === null,
);
const redactedWrites = entries.filter(
(entry) => entry.origin === null,
(entry) => entry.targetExclusion === "url-redacted",
).length;
const unparseableWrites = entries.filter(
(entry) => entry.targetExclusion === "url-unparseable",
).length;
const timeline: StateTimeline =
entries.length > 0 &&
@@ -361,7 +399,8 @@ export class EnvironmentStateAccumulator extends EnvironmentManifestAccumulator
},
}).run();
if (state === null) return this.overBudget();
const limitations = this.addLimitations([
const limitations = this.snapshotLimitations([
...manifest.value.limitations,
...(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.",
@@ -372,6 +411,11 @@ 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.",
@@ -397,7 +441,7 @@ export class EnvironmentStateAccumulator extends EnvironmentManifestAccumulator
...state,
unknowns: UNKNOWNS.map((unknown) => ({ ...unknown })),
coverage: manifest.value.coverage,
limitations: this.sortedLimitations(),
limitations: limitations.value,
});
}
}
@@ -1418,7 +1418,7 @@ describe("encoded parent spellings", () => {
path: "/api/orgs/uid/acme1/books",
query: undefined,
status: 200,
timing: { start: undefined, endMs: undefined },
timing: { start: undefined, end: 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, endMs: undefined },
timing: { start: undefined, end: 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, endMs: undefined },
timing: { start: undefined, end: undefined, endMs: undefined },
},
];
const search: ObservationSearch = {
@@ -2308,23 +2308,53 @@ describe("reads and calls on an entity an unmodelled write left unknown", () =>
{},
];
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.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("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) => ({
@@ -2468,7 +2498,7 @@ describe("entities an unmodelled write made", () => {
path: "/api/books/3",
query: undefined,
status: 200,
timing: { start: undefined, endMs: undefined },
timing: { start: undefined, end: undefined, endMs: undefined },
};
expect(
unseen.adopt(view as unknown as StateItemView, { record, index: 1 }),
@@ -2493,7 +2523,7 @@ describe("entities an unmodelled write made", () => {
path: "/api/books/3",
query: undefined,
status: 200,
timing: { start: undefined, endMs: undefined },
timing: { start: undefined, end: undefined, endMs: undefined },
},
index,
});
@@ -3201,7 +3231,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, endMs: undefined },
timing: { start: undefined, end: undefined, endMs: undefined },
};
const target = itemTarget(state, view.type, record);
expect(target.id).toBe("%63");
@@ -555,8 +555,6 @@ 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) =>
@@ -567,12 +565,16 @@ 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)
this.#state.put(updatedEntity(entity, { ...entity.fields, ...object }));
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 }));
}
/** 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);
expect(
accumulator.add(record(1, `/products?${"x=1&".repeat(200)}`)),
).toMatchObject({
const rejected = record(1, `/products?${"x=1&".repeat(200)}`);
rejected.limitations = ["Uncommitted rejected record caveat"];
expect(accumulator.add(rejected)).toMatchObject({
ok: false,
error: { _tag: "AnalysisResourceConstraintError" },
});
@@ -92,8 +92,10 @@ export class WebNetworkApiSurfaceAccumulator extends WebNetworkProjectionAccumul
}
add(record: WebNetworkCaptureRecord): Result<void, AnalysisError> {
const limitations = this.addLimitations(record.limitations);
const rollbackAdmission = this.retainedCheckpoint();
const limitations = this.prepareLimitations(record.limitations);
if (!limitations.ok) return limitations;
let committed = false;
try {
const line = readRecordedRequestLine(
record.reported,
@@ -101,14 +103,23 @@ export class WebNetworkApiSurfaceAccumulator extends WebNetworkProjectionAccumul
this.scratch,
);
if (line === null) return this.overBudget();
let appended: Result<void, AnalysisError>;
if (line.kind === "excluded")
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);
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;
} finally {
if (!committed) rollbackAdmission();
this.releaseTemporary();
}
}
@@ -105,15 +105,54 @@ export abstract class WebNetworkProjectionAccumulator {
}
addLimitations(limitations: readonly string[]): Result<void, AnalysisError> {
for (const limitation of limitations) {
if (this.#limitations.has(limitation)) continue;
if (!this.retain(ENTRY_BYTES + textBytes(limitation.length)))
return this.overBudget();
this.#limitations.add(limitation);
}
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();
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();
}
}
protected sortedLimitations(): string[] {
return [...this.#limitations].sort(compare);
}
@@ -67,6 +67,19 @@ 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, {
+3 -4
View File
@@ -53,7 +53,7 @@ import {
analysisViewInputError,
} from "./analysisViewErrors.js";
import { digestCanonicalValue } from "../canonicalDigest.js";
import { digestSchema } from "../digests.js";
import { prefixedDigestSchema } 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 parentDigest = (evidenceId: string): string => evidenceId.slice(3);
const parentEvidenceIdSchema = prefixedDigestSchema("ev");
const parseJavaScriptParent = (
input: unknown,
@@ -473,8 +473,7 @@ export const projectAnalysisView = (
view: AnalysisViewRequest,
accumulators: AnalysisViewAccumulators = {},
): Result<AnalysisViewResult, AnalysisError> => {
const digest = parentDigest(parent.evidenceId);
if (!digestSchema.safeParse(digest).success)
if (!parentEvidenceIdSchema.safeParse(parent.evidenceId).success)
return err(
analysisViewInputError([
{
@@ -3,6 +3,7 @@ import {
checkItemPlan,
type StateGateContext,
} from "./environmentStateGate.js";
import { locateItem, locateItems } from "./environmentStateRules.js";
import type {
JsonObject,
StateEntity,
@@ -21,7 +22,7 @@ const check = (
path: `/api/books/${ordinal + 1}`,
query: undefined,
status: 200,
timing: { start: undefined, endMs: undefined },
timing: { start: undefined, end: undefined, endMs: undefined },
}));
const bodies: JsonObject[] = clocks.map(([, stamp], index) => ({
id: index + 1,
@@ -116,3 +117,37 @@ 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,3 +108,75 @@ 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,
) ||
Object.keys(parent).some((name) => continues(name, parent[name]))
hasContinuation(parent, plan.pointer)
)
return;
const ids = items.map((item) => scalarText(ownMember(item, field)) ?? "");
@@ -150,3 +150,15 @@ 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,6 +376,16 @@ 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,7 +206,11 @@ const readCall = (
call.page = { index: number, first: parameter.first };
else return undefined;
}
return call;
// 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;
};
/** 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,6 +51,12 @@ 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),
},
}));
@@ -505,6 +511,7 @@ describe("read-only decisions over many calls", () => {
),
ms: index,
},
end: parseOffsetDateTimeInstant(new Date(index).toISOString()),
endMs: index,
},
};
@@ -573,6 +580,7 @@ describe("read-only decisions over a long run of calls", () => {
),
ms: index,
},
end: parseOffsetDateTimeInstant(new Date(index).toISOString()),
endMs: index,
},
};
@@ -641,6 +649,7 @@ describe("read-only decisions over many lines", () => {
),
ms: index,
},
end: parseOffsetDateTimeInstant(new Date(index).toISOString()),
endMs: index,
},
};
@@ -24,6 +24,10 @@ const record = (
instant: parseOffsetDateTimeInstant(new Date(start).toISOString()),
ms: start,
},
end:
endMs === undefined
? undefined
: parseOffsetDateTimeInstant(new Date(endMs).toISOString()),
endMs,
},
});
@@ -31,6 +31,8 @@ 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;
};
}
@@ -630,19 +632,22 @@ export const replaceAt = (
const matchingKeys = (object: JsonObject, value: string): string[] =>
Object.keys(object).filter((key) => scalarText(object[key]) === value);
/** 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 = (
/** 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 = (
body: JsonValue,
value: string,
): { readonly pointer: string; readonly keys: string[] } | undefined => {
idField?: string,
): readonly { readonly pointer: string; readonly keys: string[] }[] => {
const located = (
object: JsonValue | undefined,
pointer: string,
named = false,
) => {
const keys = isObject(object)
? matchingKeys(object, value).filter(
(key) => !named || IDENTITY_NAMES.includes(key),
? matchingKeys(object, value).filter((key) =>
idField !== undefined
? key === idField
: !named || IDENTITY_NAMES.includes(key),
)
: [];
return keys.length > 0 ? [{ pointer, keys }] : [];
@@ -658,8 +663,17 @@ export const locateItem = (
located(soleElement(item), `${pointerTo(name)}/0`, true),
),
];
const chosen = tiers.find((tier) => tier.length > 0);
return chosen?.length === 1 ? chosen[0] : undefined;
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;
};
/** 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 type { JsonValue } from "../jsonValue.js";
import { ownedJsonObjectSchema, 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, endMs: undefined },
timing: { start: undefined, end: 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, endMs: undefined },
timing: { start: undefined, end: 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, endMs: undefined },
timing: { start: undefined, end: 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, endMs: undefined },
timing: { start: undefined, end: undefined, endMs: undefined },
}),
);
const shapes = new Map(
@@ -1144,6 +1144,7 @@ it("finds each span's earliest write without scanning the writes before it", ()
return start;
},
},
end: offsetDateTimeInstantFromEpochMilliseconds(end),
get endMs() {
reads += 1;
return end;
@@ -2020,3 +2021,168 @@ 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,6 +195,34 @@ 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,6 +18,7 @@ import { entityTarget, succeeded } from "./environmentStateWriteChecks.js";
import type { WriteIo } from "./environmentStateWriteEffects.js";
import {
pathValuesOf,
pathParameterKeys,
type BodyAddress,
type StateWritePlan,
type WriteAddress,
@@ -95,19 +96,20 @@ 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(
item.slice(1, -1),
);
const id = pathValuesOf(tool.path_template, record.path)?.get(itemKey);
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);
const located = locateItem(body, id, idField);
const object =
located === undefined ? undefined : at(body, located.pointer);
return (
located?.keys.includes(idField) === true &&
located !== undefined &&
isObject(object) &&
views.some(
(view) =>
@@ -152,7 +152,7 @@ export class FailedReadBlame {
const response =
call === undefined
? undefined
: this.#responseObject(records[call]!, item.entity.id);
: this.#responseObject(records[call]!, item.entity);
if (response !== undefined) responses.set(name, response);
}
return fieldBlame(
@@ -169,10 +169,13 @@ export class FailedReadBlame {
/** The entity a write's response shows, located by its id. */
#responseObject(
record: StateRecordFacts,
id: string,
entity: StateEntity,
): JsonObject | undefined {
return this.#facts.io.withBody(record.ordinal, (body) => {
const located = body === undefined ? undefined : locateItem(body, id);
const located =
body === undefined
? undefined
: locateItem(body, entity.id, this.#facts.idField(entity.type));
const object =
located === undefined ? undefined : at(body!, located.pointer);
return isObject(object) ? object : undefined;
@@ -119,6 +119,22 @@ 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,7 +8,12 @@ import {
} from "./environmentStateRules.js";
import { GENERATED } from "./environmentStateWriteGenerated.js";
import type { FieldRule } from "./environmentStateWriteRules.js";
import { delimitedOffsets, readClock } from "./environmentStateWriteValues.js";
import {
delimitedOffsets,
joinsAfter,
joinsBefore,
readClock,
} from "./environmentStateWriteValues.js";
/** Shorter body strings are too common to show that a field copied one. */
export const COPY_MIN_LENGTH = 8;
@@ -52,12 +57,6 @@ 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.
@@ -72,10 +71,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 (joins(text[at - 1])) continue;
if (joinsBefore(text, at)) continue;
for (const length of lengths) {
if (at + length > text.length) break;
if (joins(text[at + length])) continue;
if (joinsAfter(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,6 +18,7 @@ const write = (path: string, ordinal: number): StateRecordFacts => ({
instant: offsetDateTimeInstantFromEpochMilliseconds(ordinal),
ms: ordinal,
},
end: offsetDateTimeInstantFromEpochMilliseconds(ordinal),
endMs: ordinal,
},
});
@@ -0,0 +1,118 @@
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,6 +28,7 @@ import {
type SimulatedState,
} from "./environmentStateWriteChecks.js";
import {
compareValues,
explainUpdate,
responseShapeOf,
} from "./environmentStateWriteExplain.js";
@@ -367,11 +368,12 @@ const updatedObservation = (
step.record.ordinal,
(body): Observed | undefined => {
const located =
body === undefined ? undefined : locateItem(body, entity.id);
body === undefined
? undefined
: locateItem(body, entity.id, entity.idField);
const object =
located === undefined ? undefined : at(body!, located.pointer);
return located?.keys.includes(entity.idField) === true &&
isObject(object)
return located !== undefined && isObject(object)
? {
ordinal: step.record.ordinal,
pointer: located.pointer,
@@ -657,6 +659,9 @@ 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
@@ -670,7 +675,6 @@ 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,
@@ -704,7 +708,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 && !unknown) {
if (explained.unexplained.length > 0) {
refuse(draft, step, "unexplained-change");
return recorded();
}
@@ -713,16 +717,15 @@ export const updateEntity = (
searchFrom(context, step),
itemReads(context, { type, idField, collection, id }),
)?.object;
// Each call must agree with the plan; one on an unknown entity cannot be checked, so it adds nothing to the plan.
// Each call with a known baseline must agree with the plan.
if (
built(draft) &&
(unknown ||
contradictsPlan(
{ ...draft, plan: { ...draft.plan, rules } },
entity.fields,
after.object,
explained,
) ||
(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 }, [
@@ -735,7 +738,7 @@ export const updateEntity = (
[...rules, ...explained.rules],
))
) {
if (!unknown) refuse(draft, step, "unexplained-change");
refuse(draft, step, "unexplained-change");
return recorded();
}
draft.plan.rules = rules;
@@ -778,9 +781,10 @@ export const updateEntity = (
after: after.object,
kept,
});
if (!written.ok || (disagrees && !unknown))
if (!written.ok || disagrees) {
refuse(draft, step, "unexplained-change");
if (!written.ok || disagrees || unknown) return recorded();
return recorded();
}
rememberCall(
context.io.retain,
draft,
@@ -893,7 +897,7 @@ const contradictsGeneratedClock = (
candidate.field === rule.field && candidate.rule === "clock",
) ||
next === undefined ||
next.ms <= previous.ms
compareValues(after[rule.field], ownMember(before, rule.field))! <= 0
);
});
@@ -13,6 +13,7 @@ import {
type CreateEvidence,
} from "./environmentStateWriteExplain.js";
import type { UpdateEvidence } from "./environmentStateWriteChanges.js";
import { writtenResponse } from "./environmentStateWriteApply.js";
import {
callRenewal,
unexplainedRenewals,
@@ -504,6 +505,35 @@ 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,
locateItem,
locateItems,
pointerTo,
same,
scalarText,
@@ -174,7 +174,7 @@ const requestRule = (
index: BodyIndex,
field: string,
value: JsonValue,
): FieldRule | undefined => {
): FieldRule | "ambiguous-path" | undefined => {
const { body, pathValues } = evidence.call;
if (Object.hasOwn(body, field) && same(body[field], value))
return { field, rule: "echo" };
@@ -185,9 +185,11 @@ const requestRule = (
if (from !== undefined) return { field, rule: "copy", from };
}
const text = scalarText(value);
for (const [name, pathValue] of pathValues)
if (text !== undefined && text === pathValue)
return { field, rule: "path", from: name };
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] };
const slugged = typeof value === "string" && value.length >= 3;
const source = slugged ? index.slugs.get(value) : undefined;
return source === undefined ? undefined : slugRule(field, source);
@@ -357,8 +359,14 @@ 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);
@@ -468,12 +476,14 @@ 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: [
...[...constants, ...templates].map((field) =>
ownMember(evidence.entity, field)!,
),
...literals,
].some((text) => tokens.some((token) => containsDelimited(text, token))),
tied:
ambiguousPath ||
[
...[...constants, ...templates].map((field) =>
ownMember(evidence.entity, field)!,
),
...literals,
].some((text) => tokens.some((token) => containsDelimited(text, token))),
};
};
@@ -646,8 +656,11 @@ export const responseShapeOf = (
): WriteResponse => {
if (empty) return { ordinal, shape: "empty", pointer: null };
if (body === undefined) return { ordinal, shape: "recorded", pointer: null };
const located = locateItem(body, entity.id);
if (located?.keys.includes(entity.idField) === true)
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)
return { ordinal, shape: "entity", pointer: located.pointer };
if (scalarText(body) === entity.id)
return { ordinal, shape: "identity", pointer: "" };
@@ -22,6 +22,7 @@ const record = (
instant: parseOffsetDateTimeInstant(new Date(start).toISOString()),
ms: start,
},
end: parseOffsetDateTimeInstant(new Date(end).toISOString()),
endMs: end,
},
});
@@ -68,7 +69,7 @@ describe("short-id creation limits", () => {
const prior = record(0, "GET", "/api/projects", [0, 5]);
const unknown = {
...prior,
timing: { ...prior.timing, endMs: undefined },
timing: { ...prior.timing, end: undefined, endMs: undefined },
};
const post = record(1, "POST", "/api/imports", [10, 11]);
const later = record(2, "GET", "/api/projects/7", [20, 21]);
@@ -23,6 +23,7 @@ import {
import { ENTRY_BYTES, textBytes } from "./webNetworkRouteTemplates.js";
import {
pathValuesOf,
pathParameterKeys,
type FieldRule,
type WriteEffect,
} from "./environmentStateWriteRules.js";
@@ -414,13 +415,9 @@ export const anyLinkedSource = (
plan.collection !== null &&
normalizedPath(plan.collection) === plan.target &&
!segmentsOf(plan.target).some(isParam) &&
segmentsOf(template)
.filter(isParam)
.every((segment) =>
plan.rules.some(
(rule) => rule.rule === "path" && rule.from === segment.slice(1, -1),
),
);
[...pathParameterKeys(template).values()].every((name) =>
plan.rules.some((rule) => rule.rule === "path" && rule.from === name),
);
/**
* Whether a linked create's call sends the path values its exemplar did, each as recorded or in the
@@ -407,6 +407,23 @@ 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,
@@ -416,13 +433,14 @@ 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(segment.slice(1, -1), decodedSegment(value));
values.set(keys.get(index)!, decodedSegment(value));
}
return values;
};
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import type { JsonValue } from "../jsonValue.js";
import { jsonObjectSchema, type JsonValue } from "../jsonValue.js";
import { webNetworkCaptureSchema } from "../webNetworkCapture.js";
import { EnvironmentStateAccumulator } from "./EnvironmentStateAccumulator.js";
import {
@@ -86,6 +86,43 @@ 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,6 +2,10 @@ 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;
@@ -13,17 +17,23 @@ 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.
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;
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();
};
/**
@@ -44,6 +44,14 @@ 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 });
@@ -83,6 +91,28 @@ 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,7 +3,11 @@ import { createHash } from "node:crypto";
import { createEvidence } from "../evidence.js";
import { freezeJsonSnapshot } from "../immutableJson.js";
import { webNetworkCaptureSchema } from "../webNetworkCapture.js";
import { projectAnalysisView } from "./analysisView.js";
import {
projectAnalysisView,
analysisViewRequestSchema,
} from "./analysisView.js";
import { timedCaptureEvidence } from "../../../tests/fixtures/analysisView.js";
const capture = () => {
const body = Buffer.from("original body");
@@ -800,18 +804,6 @@ 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) => {
@@ -1040,3 +1032,111 @@ 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,5 +1,8 @@
import { z } from "zod";
import { readRecordedResponseDeclaration } from "./recordedResponseBody.js";
import {
readRecordedResponseDeclaration,
recordedResponseStatus,
} from "./recordedResponseBody.js";
import {
parseRecordedTransportUrl,
parseUrlQueryParameterNames,
@@ -148,7 +151,6 @@ 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;
@@ -158,7 +160,7 @@ export const readRecordedRequestLine = (
origin: parsed.origin,
path: parsed.path,
query: parsed.query,
status: typeof status === "number" && status !== 0 ? status : null,
status: recordedResponseStatus(response),
rawMime,
};
};
@@ -650,32 +650,6 @@ 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,11 +1,7 @@
import * as t from "@babel/types";
import type { javascriptBootstrapAt } from "./javascriptBootstrap.js";
import {
semanticStaticPropertyKey,
semanticWriteSiteTarget,
semanticWriteTargets,
} from "./javascriptAstValues.js";
import { semanticStaticPropertyKey } from "./javascriptAstValues.js";
import { semanticIdentifierRole } from "./javascriptSemanticProjection.js";
import {
resolveSemanticBindingState,
@@ -289,13 +285,8 @@ 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)
@@ -303,7 +294,7 @@ const moduleObjectContained = (
if (bindingOf(state, node) !== moduleId) return;
contained =
!ancestors.some((ancestor) => t.isFunction(ancestor)) &&
moduleUse(node, parent, dispatch, writtenTargets.has(parent));
moduleUse(node, [parent, ancestors.at(-2)], dispatch);
},
});
return contained;
@@ -311,18 +302,21 @@ const moduleObjectContained = (
const moduleUse = (
node: t.Identifier,
parent: t.Node,
[parent, grandparent]: readonly [t.Node, t.Node | undefined],
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 (
dispatch.arguments.some((argument) => argument === parent) ||
grandparent === dispatch ||
(parent.start ?? -1) > (dispatch.end ?? Number.POSITIVE_INFINITY)
);
};
@@ -10,54 +10,9 @@ import {
completeSemanticSteps,
traverseJavaScriptAst,
} from "./javascriptSemanticTraversal.js";
import {
createGlobalPathReader,
createGlobalReferenceReader,
} from "./javascriptSemanticAmbientWrites.js";
import { 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(
`
@@ -351,11 +306,6 @@ 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,92 +902,37 @@ export const createGlobalReferenceReader = (
state: JavaScriptSemanticAnalysisState,
path: readonly [string, ...string[]],
): ((callee: t.Node) => GlobalReferenceRead) => {
const read = createGlobalPathRead(state, path);
return (callee) => {
const namespace = globalReferenceNamespace(callee, state, path);
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;
let generation = -1;
const byNamespace = new Map<
string | null,
{
writes: typeof state.ambientWriteEffects;
escapes: typeof state.ambientEscapeEffects;
}
typeof state.ambientWriteEffects
>();
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" };
return (callee) => {
const namespace = globalReferenceNamespace(callee, state, path);
if (namespace === undefined) return { status: "unrelated" };
if (
writesGeneration !== state.ambientWriteEffects.length ||
escapesGeneration !== state.ambientEscapeEffects.length
) {
writesGeneration = state.ambientWriteEffects.length;
escapesGeneration = state.ambientEscapeEffects.length;
!state.ambientWrites.covers(selected) &&
(namespace === null || !state.ambientWrites.covers(["global", namespace]))
)
return { status: "unmodified" };
if (generation !== state.ambientWriteEffects.length) {
generation = state.ambientWriteEffects.length;
byNamespace.clear();
}
let effects = byNamespace.get(namespace);
if (effects === undefined) {
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),
};
effects = state.ambientWriteEffects.filter(
(effect) =>
ambientEffectCovers(effect, selected) ||
(namespace !== null &&
ambientEffectCovers(effect, ["global", namespace])),
);
byNamespace.set(namespace, effects);
}
// 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 (effects.length === 0) return { status: "unknown" };
const active = effects.filter(
(effect) => semanticEffectTiming(callee, effect.node, state) !== "after",
);
if (active.length === 0) return { status: "unmodified" };
if (
@@ -998,57 +943,47 @@ const createGlobalPathRead = (
)
)
return { status: "unknown" };
return globalPathReplacement(state, path, site, active);
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 };
};
};
/** 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,20 +249,3 @@ 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,11 +1,7 @@
import { describe, expect, it } from "vitest";
import { runInNewContext } from "node:vm";
import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js";
import {
onlyBinding,
onlyCallable,
} from "./javascriptSemanticAnalysis.fixture.js";
import { onlyCallable } from "./javascriptSemanticAnalysis.fixture.js";
const resultValue = (body: string) =>
onlyCallable(
@@ -13,128 +9,6 @@ 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()"],
[
@@ -167,83 +41,6 @@ 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,18 +269,3 @@ 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,13 +28,6 @@ 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,54 +22,6 @@ 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", () => {
@@ -246,17 +198,3 @@ 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,8 +16,7 @@ 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 / void 0 -> THREW ERR_MISSING_ARGS
* net.connect({}) / [] -> THREW ERR_MISSING_ARGS (undecided here)
* net.connect(undefined) / {} / [] -> THREW ERR_MISSING_ARGS (undecided here)
*/
const socketAcquisitionsFor = (call: string) =>
analyzeJavaScriptSemantics(
@@ -35,10 +34,6 @@ 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)`],
@@ -54,12 +49,8 @@ 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"],
["a computed argument", `net.connect(p)`, "connect"],
[
"a TLS port supplied by later options",
`tls.connect(void 0, { port: 443 })`,
"connect",
],
["an omitted argument", `net.connect()`, "connect"],
["an explicit undefined port", `net.connect(undefined)`, "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,6 +16,7 @@ import {
resolveSemanticBindingState,
semanticResolutionBlocked,
type JavaScriptSemanticAnalysisState,
type JavaScriptSemanticBindingState,
} from "./javascriptSemanticState.js";
import { semanticBindingAtRead } from "./javascriptSemanticMutationInitializers.js";
import { traverseJavaScriptValueAst } from "./javascriptSemanticTraversal.js";
@@ -159,10 +160,7 @@ export const collectJavaScriptSemanticAsyncEffects = (
*
* A class expression passes Node's own `typeof` check, so it is kept.
*/
export const rejectsSemanticEventListener = (
listener: t.Node | undefined,
): boolean => {
if (listener === undefined) return true;
const rejectsEventListener = (listener: t.Node): boolean => {
if (t.isSpreadElement(listener)) return false;
const value = unwrapJavaScriptExpression(listener).node;
if (t.isFunctionExpression(value) || t.isArrowFunctionExpression(value))
@@ -206,9 +204,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) &&
rejectsSemanticEventListener(listenerArgument)
listenerArgument !== undefined &&
rejectsEventListener(listenerArgument)
)
return;
const emitter = emitterIdentity(member.object, state);
@@ -7,25 +7,6 @@ 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,7 +7,6 @@ import type {
JavaScriptSemanticChildProcessSpawn,
} from "./javascriptSemanticIr.js";
import { createSemanticCallableIdStack } from "./javascriptSemanticCallableStack.js";
import { rejectsSemanticEventListener } from "./javascriptSemanticAsyncEffects.js";
import {
createSemanticExpressionShapeResolver,
optionsAfterOptionalArgv,
@@ -226,11 +225,6 @@ 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,8 +30,6 @@ 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 {
@@ -339,7 +337,6 @@ const semanticSourceFacts = (
t.isAssignmentExpression(node) ||
t.isForOfStatement(node) ||
t.isSpreadElement(node) ||
t.isAwaitExpression(node) ||
t.isYieldExpression(node) ||
t.isCatchClause(node) ||
t.isFunction(node)
@@ -375,15 +372,11 @@ const semanticSourceFacts = (
mutation: node,
lookup: "own",
});
} 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)
} else if (
t.isYieldExpression(node) &&
node.delegate &&
node.argument != null
)
add(sources, { node: node.argument, projection: [] }, node, {});
else if (
t.isCatchClause(node) &&
@@ -2,7 +2,6 @@ import * as t from "@babel/types";
import { semanticKnownSymbolKey } from "./javascriptSemanticPropertyKeys.js";
import {
createAmbientPathResolver,
createGlobalPathReader,
retainSemanticAmbientEffect,
} from "./javascriptSemanticAmbientWrites.js";
import { isIntrinsicMapLookup } from "./javascriptSemanticMapOperations.js";
@@ -38,7 +37,6 @@ import {
import { semanticContainer } from "./javascriptSemanticSlots.js";
import {
semanticProjectedSourceFactsAtRead,
semanticCapturedSelectionsAfterPropertyRead,
semanticShiftCapturedSelections,
semanticNormalizeCapturedSelections,
type SemanticCapturedSelection,
@@ -51,7 +49,6 @@ import {
semanticClassAccessorReferences,
semanticObjectPropertyReferences,
semanticObjectAccessorReferences,
semanticNativePrototypeChain,
type JavaScriptSemanticObjectReference,
} from "./javascriptSemanticObjectReferences.js";
import {
@@ -125,55 +122,27 @@ const arrayPrototypeIterationMayChange = (path: PropertyPath): boolean => {
/** Preserve uncertainty from property writes and references exposed to calls, yielding during traversal and fixpoint iteration. */
/** Local methods use the same slots, prototype shadowing and array indices as aliases. */
const localPropertyMethods = (
/** Accessor selection uses the same slots, prototype shadowing and array indices as aliases. */
const localAccessorMethods = (
root: t.Node,
path: PropertyPath,
accessor: "get" | "set" | "invoke",
accessor: "get" | "set",
state: JavaScriptSemanticAnalysisState,
keyMayExist: (
key: PropertyPath[number] | undefined,
kind: "get" | "set",
) => boolean,
lookup: "prototype" | "own" = "prototype",
): 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 }];
): 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 }];
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,
current.capturedSelections?.map(({ at, depth, ownEnumerable }) => [
at.start,
at.end,
depth,
ownEnumerable,
]),
]);
const key = JSON.stringify(current.path);
const paths = examined.get(node) ?? new Set<string>();
if (paths.has(key)) continue;
paths.add(key);
@@ -183,10 +152,7 @@ const localPropertyMethods = (
return undefined;
}
if (
t.isFunction(node) &&
(accessor === "invoke" ||
t.isObjectMethod(node) ||
t.isClassMethod(node)) &&
(t.isObjectMethod(node) || t.isClassMethod(node)) &&
current.path.length === 0
) {
methods.add(node);
@@ -194,42 +160,15 @@ const localPropertyMethods = (
}
if (t.isIdentifier(node)) {
const binding = resolveSemanticBindingState(state, node, node.name);
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)
if (binding === undefined) continue;
const selected = semanticBindingAtRead(binding, node, state).binding;
for (const source of [
...selected.initializers,
...selected.referenceInitializers,
])
pending.push({
node: source.node,
path: source.path,
memberReadAt: current.memberReadAt,
capturedSelections: semanticNormalizeCapturedSelections([
...(semanticShiftCapturedSelections(
current.capturedSelections,
source.path.length - current.path.length,
) ?? []),
...(source.capturedSelections ?? []),
]),
path: [...source.projection, ...current.path],
});
} else if (
t.isMemberExpression(node) ||
@@ -241,57 +180,15 @@ const localPropertyMethods = (
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 && accessor !== "invoke"
: remaining.length === 0
? semanticObjectAccessorReferences(
node,
selected!,
@@ -300,24 +197,11 @@ const localPropertyMethods = (
lookup,
)
: semanticObjectPropertyReferences(node, [selected!], state);
for (const source of references) {
if (
source.inherited &&
current.capturedSelections?.some(
(capture) => capture.depth === 1 && capture.ownEnumerable,
)
)
continue;
for (const source of references)
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
@@ -334,39 +218,23 @@ const localPropertyMethods = (
const references =
remaining.length > 0
? semanticClassPropertyReferences(selection, [selected!], state)
: accessor === "invoke"
? semanticClassPropertyReferences(selection, [selected!], state)
: lookup === "own"
? []
: semanticClassAccessorReferences(
selection,
selected!,
state,
accessor,
);
: 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 {
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 });
for (const source of branchReferenceValues(node, current.path))
pending.push(source);
}
}
return [...methods];
@@ -379,7 +247,6 @@ type AccessorEffectOperand =
readonly node: t.Node;
readonly path: PropertyPath;
readonly lookup: "prototype" | "own";
readonly invoke?: true;
readonly guards?: SemanticPropertyReadSource;
}
| {
@@ -466,7 +333,6 @@ const accessorEffectOperands = (
node: source.node,
path: source.projection,
lookup: source.lookup,
...(source.invoke ? { invoke: true } : {}),
guards: source,
});
if (
@@ -517,14 +383,13 @@ export function* collectSemanticMemberMutationsSteps(
);
};
const returnedReferences = createSemanticReturnedReferences(state);
const propertyReadEffects = (
const getterEffects = (
root: t.Node,
path: PropertyPath,
lookup: "prototype" | "own" = "prototype",
invoke = false,
): readonly JavaScriptSemanticObjectReference[] => {
if (!invoke && !accessorKeyMayExist(path.at(-1), "get")) return [];
const pending = [{ node: root, path, lookup, invoke }];
if (!accessorKeyMayExist(path.at(-1), "get")) return [];
const pending = [{ node: root, path, lookup }];
const examined = new Set<string>();
const effects: JavaScriptSemanticObjectReference[] = [];
for (let cursor = 0; cursor < pending.length; cursor++) {
@@ -532,14 +397,12 @@ 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);
@@ -547,20 +410,7 @@ export function* collectSemanticMemberMutationsSteps(
state.valueResourceLimits.add("alias-selections");
return [{ node: root, path: [] }];
}
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(
const methods = localAccessorMethods(
selectedRoot,
selected,
"get",
@@ -568,12 +418,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++) {
@@ -591,8 +441,7 @@ export function* collectSemanticMemberMutationsSteps(
}
if (
operand.kind === "read" &&
((!operand.invoke &&
!accessorKeyMayExist(operand.path.at(-1), "get")) ||
(!accessorKeyMayExist(operand.path.at(-1), "get") ||
(operand.guards !== undefined &&
semanticReferenceSourcesInactive(operand.guards, state)))
)
@@ -616,7 +465,6 @@ export function* collectSemanticMemberMutationsSteps(
node: targetRoot,
path: [...target, ...operand.path],
lookup: operand.lookup,
invoke: operand.invoke === true,
});
}
}
@@ -1266,7 +1114,7 @@ export function* collectSemanticMemberMutationsSteps(
if (
origins.initializers.some(
(source) =>
(localPropertyMethods(
(localAccessorMethods(
source.node,
[...source.projection, ...selectedPath],
"set",
@@ -1748,7 +1596,7 @@ export function* collectSemanticMemberMutationsSteps(
source.kind === "reference-store" &&
source.target !== undefined &&
!state.escapingReferenceStores.has(source.target) &&
(localPropertyMethods(
(localAccessorMethods(
source.target.object,
[
semanticStaticPropertyKey(
@@ -1810,18 +1658,17 @@ export function* collectSemanticMemberMutationsSteps(
}
for (const source of semanticPropertyReadSources(node, parent, state)) {
if (semanticReferenceSourcesInactive(source, state)) continue;
for (const effect of propertyReadEffects(
for (const effect of getterEffects(
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 propertyReadEffects(node.object, [
for (const effect of getterEffects(node.object, [
semanticStaticPropertyKey(node.property, node.computed),
]))
markEscaped(effect.node, node, effect.path);
@@ -4,7 +4,6 @@ import { expect, it } from "vitest";
import { indexJavaScriptBindingsSteps } from "./javascriptSemanticAnalysis.js";
import {
semanticClassPropertyReferences,
semanticNativePrototypeChain,
semanticObjectPropertyReferences,
} from "./javascriptSemanticObjectReferences.js";
import { completeSemanticSteps } from "./javascriptSemanticTraversal.js";
@@ -37,37 +36,6 @@ 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 };`,
@@ -86,13 +54,14 @@ it("resolves exact keys with source-order overrides, spreads and accessors", ()
["4", "[]"],
["base", '["missing"]'],
]);
// A wildcard keeps possible slots, excluding data replaced by a known spread.
// A wildcard key still scans every property.
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.
@@ -101,112 +70,6 @@ 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,73 +154,96 @@ const classPropertyDescriptors = (
initializedBefore?: t.ClassProperty,
accessor: "get" | "set" = "get",
) => {
const isStatic = kind === "static";
const selectedAccessors: t.ClassMethod[] = [];
const fields: t.ClassProperty[] = [];
const methods: t.ClassMethod[] = [];
const accessors: t.ClassMethod[] = [];
const definitions: (t.ClassMethod | t.ClassProperty)[] = [];
const indexedMembers: {
readonly member: t.ClassMethod | t.ClassProperty;
readonly index: number;
readonly key: string | null;
}[] = [];
const before =
initializedBefore === undefined
? -1
: klass.body.body.indexOf(initializedBefore);
for (const [index, member] of klass.body.body.entries()) {
klass.body.body.forEach((member, index) => {
if (
(!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))
(t.isClassMethod(member) || t.isClassProperty(member)) &&
propertyName !== null &&
member.computed &&
semanticKnownSymbolKey(member.key, state)
)
continue;
definitions.push(member);
}
// 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;
return;
if (
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);
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);
}
return { accessors, fields, methods, inherits };
if (getters.length > 0 && !laterDataMember && getter !== undefined)
selectedAccessors.push(getter.member);
for (const { member, index } of uncertainAccessors)
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,
};
};
export const semanticClassPropertyReferences = (
@@ -322,21 +345,9 @@ class ClassReferenceWalk {
? descriptors.fields
: [])
if (field.value != null)
this.#references.push({
node: field.value,
path: this.#path,
...(this.lookup.kind === "static" && klass !== this.lookup.owner
? { inherited: true }
: {}),
});
this.#references.push({ node: field.value, path: this.#path });
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))
@@ -548,18 +559,6 @@ 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. */
@@ -580,7 +579,13 @@ const objectPropertyIndex = (node: t.ObjectExpression): ObjectPropertyIndex => {
const name = t.isSpreadElement(property)
? null
: semanticStaticPropertyKey(property.key, property.computed);
if (name === null || isObjectPrototypeSetter(property, name)) {
if (
name === null ||
(t.isObjectProperty(property) &&
!property.computed &&
!property.shorthand &&
name === "__proto__")
) {
keyIndependent.push(index);
return;
}
@@ -666,9 +671,7 @@ class ObjectReferenceWalk {
readonly accessor?: "get" | "set",
readonly accessorLookup: "prototype" | "own" = "prototype",
) {
const [key, ...remaining] = path;
this.#key = typeof key === "number" ? String(key) : key;
this.#remaining = remaining;
[this.#key, ...this.#remaining] = path;
}
#spreadKey(): PropertyPath[number] {
@@ -688,29 +691,38 @@ 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 &&
(typeof selected === "object" || !this.#overwritten.has(String(selected)))
)
if (this.accessor === undefined)
this.#references.push({
node: property.argument,
path: [this.#spreadKey(), ...this.#remaining],
capturedSelectionAt: property.argument,
ownEnumerable: true,
});
if (t.isObjectExpression(property.argument))
for (const name of objectExpressionDefinedKeys(property.argument)) {
this.#overwritten.add(name);
this.#data.add(name);
}
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));
}
}
#prototype(
property: t.ObjectMethod | t.ObjectProperty,
name: string | null,
): boolean {
if (!isObjectPrototypeSetter(property, name)) return false;
if (
!t.isObjectProperty(property) ||
property.computed ||
property.shorthand ||
name !== "__proto__"
)
return false;
const selected = this.#key ?? null;
if (this.accessor !== undefined && this.accessorLookup === "own")
return true;
@@ -719,7 +731,7 @@ class ObjectReferenceWalk {
(selected === null ||
typeof selected === "object" ||
(typeof selected === "string" &&
!objectExpressionDefinedKeys(this.node).has(selected)))
!objectExpressionDefinesKey(this.node, selected)))
)
this.#references.push({
node: property.value,
@@ -807,10 +819,8 @@ 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);
}
@@ -829,12 +839,12 @@ const objectPropertyReferences = (
): readonly JavaScriptSemanticObjectReference[] =>
new ObjectReferenceWalk(node, path, query).run();
const objectExpressionDefinedKeys = (
const objectExpressionDefinesKey = (
root: t.ObjectExpression,
): ReadonlySet<string> => {
key: string,
): boolean => {
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;
@@ -846,11 +856,19 @@ const objectExpressionDefinedKeys = (
continue;
}
const name = semanticStaticPropertyKey(property.key, property.computed);
if (name !== null && !isObjectPrototypeSetter(property, name))
names.add(name);
if (
name === key &&
!(
t.isObjectProperty(property) &&
!property.computed &&
!property.shorthand &&
name === "__proto__"
)
)
return true;
}
}
return names;
return false;
};
export const semanticArrayPropertyReferences = (
@@ -906,44 +924,3 @@ 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,7 +190,6 @@ export function semanticProjectedSourceFactsAtRead(
memberReadAt?: t.Node,
capturedSelections?: readonly SemanticCapturedSelection[],
retainEligibility?: boolean,
valueEscapesHideSources?: boolean,
):
| {
readonly sources: readonly (Omit<ProjectedSource, "path"> & {
@@ -220,9 +219,6 @@ 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[];
@@ -369,7 +365,6 @@ const projectedSourceFactsAtRead = (
(replacement.assignedSources?.[0]?.target?.start ?? -1)));
if (
includeUnknown &&
valueEscapesHideSources &&
semanticSomeEffectPathCandidate(
binding.escapedPaths,
path,
@@ -2,40 +2,6 @@ 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,8 +30,6 @@ import {
import {
semanticStaticPropertyKey,
semanticMemberChain,
semanticWriteSiteTarget,
semanticWriteTargets,
unwrapJavaScriptExpression,
} from "./javascriptAstValues.js";
import { semanticPropertyPathKeyMatches } from "./javascriptSemanticPropertyPaths.js";
@@ -73,33 +71,30 @@ 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 (!commonJsDelegateWriteTargets(node).next().done)
if (
(t.isMemberExpression(target) || t.isOptionalMemberExpression(target)) &&
["require", null].includes(
semanticStaticPropertyKey(target.property, target.computed),
)
)
possibleCommonJsDelegateWriteStates.add(state);
else if (t.isCallExpression(node) || t.isOptionalCallExpression(node))
possibleCommonJsDelegateWriteStates.add(state);
@@ -891,9 +886,6 @@ 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,
@@ -920,21 +912,22 @@ export const semanticRequireCallAt = (
state,
factoryBindingIds,
);
if (selected !== undefined) {
const path = [
if (selected !== undefined)
return [
"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 sourceRelativePath = sourceRelativePaths.get(invocation.path);
const selected = selectedRequireSourcesAt(
node.callee,
node,
state,
factoryBindingIds,
);
return {
operation: "load",
loader:
@@ -949,9 +942,9 @@ export const semanticRequireCallAt = (
: {}),
sourceRelative:
invocation.kind === "known" && invocation.path[2] === "relative",
...(invocation.kind === "known" && sourceRelativePath !== undefined
? { sourceRelativePath }
: {}),
...(selected?.sourceRelativePath === undefined
? {}
: { sourceRelativePath: selected.sourceRelativePath }),
};
}
}
@@ -1563,8 +1556,20 @@ const writesModuleRequire = (
};
traverseJavaScriptValueAst(state.program, state.erasedSyntaxNodes, {
enter: (node) => {
for (const target of commonJsDelegateWriteTargets(node))
record(node, target.object);
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);
}
if (t.isCallExpression(node) || t.isOptionalCallExpression(node)) {
const invocation = interop.intrinsicInvocation(node);
const owner =
@@ -1578,21 +1583,42 @@ 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 (objectMayProvideRequire(descriptors))
if (
!t.isObjectExpression(descriptors) ||
descriptors.properties.some(
(property) =>
t.isSpreadElement(property) ||
(t.isObjectProperty(property) &&
semanticStaticPropertyKey(property.key, property.computed) ===
"require"),
)
)
record(node, args?.[0] ?? t.nullLiteral());
} else if (owner === "Object" && method === "assign") {
const target = args?.[0];
if (
target !== undefined &&
args?.slice(1).some(objectMayProvideRequire)
)
record(node, target);
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);
}
}
}
},
@@ -1601,15 +1627,6 @@ 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,11 +78,7 @@ export const collectJavaScriptSemanticResources = (
const acquisitions = collectAcquisitions(program, context).filter(
(candidate) =>
!socketPortInFirstArgument(candidate) ||
(!rejectsSocketLiteralArguments(
candidate.node,
candidate.specifier,
context,
) &&
(!rejectsSocketPortLiteral(candidate.node, context) &&
!rejectsTlsProtocolVersionLiteral(
candidate.node,
candidate.specifier,
@@ -451,9 +447,6 @@ const collectReleases = (
resolution:
!opaqueReceiver &&
linked.length === 1 &&
(binding.kind !== "parameter" ||
semanticBindingAtRead(binding, member.object, context.state)
.hasDefiniteInitializer) &&
holdsOnlyAcquisitions(binding, linked, context.state)
? "complete"
: "partial",
@@ -580,25 +573,16 @@ const resourceCandidateId = (candidate: ResourceCandidate): string =>
* readable non-port literal is decided. `""` is a path with no length, which
* Node also rejects.
*
* `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.
* `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.
*/
const rejectsSocketLiteralArguments = (
const rejectsSocketPortLiteral = (
node: t.CallExpression | t.OptionalCallExpression,
specifier: string,
context: DataEffectTraversalContext,
): boolean => {
const port = node.arguments[0];
if (port === undefined) return specifier === "net";
if (t.isSpreadElement(port)) return false;
if (port === undefined || 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) ||
@@ -617,14 +601,13 @@ const rejectsSocketLiteralArguments = (
isUnmodifiedGlobalReference(value, context.state, value.name)
)
return true;
// 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.
// `-1` and `-1.5` are unary expressions over numeric literals: always out of
// range because a port is never negative.
if (t.isUnaryExpression(value)) {
if (value.operator === "!" || value.operator === "delete") return true;
const number = unaryOperandNumber(value, context, 0);
const operand = unwrapJavaScriptExpression(value.argument).node;
return (
number !== undefined &&
(!Number.isInteger(number) || number < 0 || number > 65535)
t.isNumericLiteral(operand) &&
(value.operator === "-" || !Number.isInteger(operand.value))
);
}
return false;
@@ -205,21 +205,3 @@ 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,13 +24,8 @@ import {
semanticKnownArrayIterationAt,
semanticSelectedReferenceSlot,
} from "./javascriptSemanticReferenceSources.js";
import { semanticArrayPropertyReferences } from "./javascriptSemanticObjectReferences.js";
import {
semanticArrayPropertyReferences,
semanticObjectPropertyReferences,
semanticClassPropertyReferences,
} from "./javascriptSemanticObjectReferences.js";
import {
semanticCapturedSelectionsAfterPropertyRead,
semanticNormalizeCapturedSelections,
semanticProjectedSourcesAtRead,
semanticShiftCapturedSelections,
@@ -67,7 +62,6 @@ interface Candidate {
| readonly SemanticCapturedSelection[]
| undefined;
readonly invocation?: JavaScriptSourceInvocationContext;
readonly classKind?: "instance" | "static";
}
export interface SemanticReturnedReferenceResult {
@@ -80,6 +74,22 @@ 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,
@@ -105,6 +115,7 @@ export const createSemanticReturnedReferences = (
}[];
}
>();
const objectTargets = new WeakMap<t.ObjectExpression, ObjectTargetIndex>();
return (callee, invocation) => {
const existing = results.get(callee);
if (
@@ -130,7 +141,7 @@ export const createSemanticReturnedReferences = (
// shared diamonds and reassignment cycles converge to the graph fixed point.
const projectionDepths = new WeakMap<
t.Node,
Map<string, { depth: number | undefined }>
Map<string, number | undefined>
>();
const expanded = new WeakMap<t.Node, Set<string>>();
let additionalContexts = 0;
@@ -179,7 +190,6 @@ export const createSemanticReturnedReferences = (
readAt?: t.Node,
capturedSelections?: readonly SemanticCapturedSelection[],
invocationContext?: JavaScriptSourceInvocationContext,
classKind?: Candidate["classKind"],
): void => {
const source = unwrapJavaScriptExpression(node).node;
if (contextsWidened) {
@@ -203,25 +213,20 @@ export const createSemanticReturnedReferences = (
readAt?.start ?? -1,
readAt?.end ?? -1,
invocationKey(),
classKind,
]);
const depths = projectionDepths.get(source) ?? new Map();
projectionDepths.set(source, depths);
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 (!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;
}
}
// 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);
if (path !== undefined) path = selections.normalize(source, path);
capturedSelections =
path === undefined
? undefined
@@ -274,10 +279,9 @@ export const createSemanticReturnedReferences = (
readAt?.start ?? -1,
readAt?.end ?? -1,
null,
classKind,
]);
if (sourceGenericInvocation) result.unresolvedInvocation = true;
depths.set(readKey, { depth: undefined });
depths.set(readKey, undefined);
key = expansionKey();
if (paths.has(key)) return;
}
@@ -285,7 +289,6 @@ export const createSemanticReturnedReferences = (
expanded.set(source, paths);
work.push({
node: source,
...(classKind === undefined ? {} : { classKind }),
path,
...(readAt === undefined ? {} : { readAt }),
...(capturedSelections === undefined ? {} : { capturedSelections }),
@@ -379,7 +382,6 @@ export const createSemanticReturnedReferences = (
child.readAt,
child.capturedSelections,
child.invocation ?? current.invocation,
child.classKind ?? current.classKind,
);
}
continue;
@@ -503,38 +505,9 @@ export const createSemanticReturnedReferences = (
result.unresolvedInvocation = true;
}
}
} 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)) {
} else if (t.isObjectExpression(node))
selectObjectTargets(node, current, pending, objectTargets);
else if (t.isArrayExpression(node)) {
const [key, ...path] = current.path ?? [];
if (current.path !== undefined && key !== undefined) {
for (const choice of semanticAliasSelectionChoices(key))
@@ -579,7 +552,6 @@ export const createSemanticReturnedReferences = (
child.readAt,
child.capturedSelections,
child.invocation ?? current.invocation,
child.classKind ?? current.classKind,
);
}
continue;
@@ -618,83 +590,63 @@ export const createSemanticReturnedReferences = (
});
});
} else if (t.isNewExpression(node))
pending.push({ ...current, node: node.callee, classKind: "instance" });
pending.push({ ...current, node: node.callee });
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) &&
(current.path === undefined || member.kind === "constructor")
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)
);
})))
)
pending.push({
...current,
node: member,
path: current.path === undefined ? undefined : [],
node: member.value,
path: current.path === undefined ? undefined : path,
capturedSelections: semanticShiftCapturedSelections(
current.capturedSelections,
-1,
),
});
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 });
@@ -708,7 +660,6 @@ export const createSemanticReturnedReferences = (
child.readAt,
child.capturedSelections,
child.invocation ?? current.invocation,
child.classKind ?? current.classKind,
);
}
}
@@ -748,3 +699,139 @@ 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,31 +155,3 @@ 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,29 +81,3 @@ 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,82 +201,3 @@ 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 &&
semanticStaticPropertyKey(property.key, false) === "__proto__"
t.isIdentifier(property.key, { name: "__proto__" })
)
return t.isNullLiteral(unwrapJavaScriptExpression(property.value).node)
? "null"
@@ -3,58 +3,7 @@ import { expect, it } from "vitest";
import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js";
import { topLevelBinding } from "./javascriptSemanticAnalysis.fixture.js";
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.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",
]);
});
import { analyzeCommonJsStaticFixture } from "../../../tests/fixtures/javascriptCommonJsAnalysis.js";
it.each([
'var require;require("./before.cjs");',
@@ -76,44 +76,3 @@ 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,
bindings.erasedSyntaxNodes,
),
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,7 +43,6 @@ import type {
import { VITE_OUTPUT_DEPENDENCY_PREFIX } from "./javascriptStaticAnalysisTypes.js";
import {
resolveSemanticBindingState,
semanticBindingLookupAt,
semanticStableInitializer,
type JavaScriptBindingIndex,
type JavaScriptSemanticAnalysisState,
@@ -412,7 +411,7 @@ const chunkLoadSpecifier = (
const vitePreloadDependencySpecifiers = (
node: JavaScriptCallLike,
name: string,
table: FindingContext["vitePreloadDependencyTable"],
table: readonly (string | undefined)[] | null,
bindings: JavaScriptSemanticAnalysisState,
): readonly (string | undefined)[] => {
if (
@@ -421,18 +420,10 @@ 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))
@@ -442,7 +433,7 @@ const vitePreloadDependencySpecifiers = (
if (element === null || !t.isNumericLiteral(element)) return undefined;
const index = element.value;
return Number.isSafeInteger(index) && index >= 0
? viteDependencySpecifier(table.dependencies[index])
? viteDependencySpecifier(table[index])
: undefined;
});
};
@@ -482,9 +473,8 @@ type ViteMapDepsFunction =
/** Index the canonical Vite helper's cache initializer from parsed syntax. */
export const indexVitePreloadDependencyTable = (
program: t.Program,
bindings: JavaScriptSemanticAnalysisState,
): FindingContext["vitePreloadDependencyTable"] => {
const { erasedSyntaxNodes } = bindings;
erasedSyntaxNodes: WeakSet<t.Node>,
): readonly (string | undefined)[] | null => {
const helpers: ViteMapDepsFunction[] = [];
traverseJavaScriptValueAst(program, erasedSyntaxNodes, {
enter: (node) => {
@@ -507,7 +497,7 @@ export const indexVitePreloadDependencyTable = (
const mapIdentifier = t.isAssignmentPattern(mapParameter)
? mapParameter.left
: mapParameter;
if (!t.isIdentifier(mapIdentifier)) return { helper, dependencies: [] };
if (!t.isIdentifier(mapIdentifier)) return [];
const tables: t.ArrayExpression[] = [];
traverseJavaScriptValueAst(helper, erasedSyntaxNodes, {
skip: (node) => t.isFunction(node),
@@ -525,9 +515,9 @@ export const indexVitePreloadDependencyTable = (
tables.push(node.right);
},
});
if (tables.length !== 1) return { helper, dependencies: [] };
if (tables.length !== 1) return [];
const table = tables[0];
if (table === undefined) return { helper, dependencies: [] };
if (table === undefined) return [];
// 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) =>
@@ -550,14 +540,6 @@ 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
@@ -566,12 +548,7 @@ export const indexVitePreloadDependencyTable = (
escapedOrMutated = true;
},
});
return {
helper,
dependencies: escapedOrMutated
? dependencies.map(() => undefined)
: dependencies,
};
return escapedOrMutated ? dependencies.map(() => undefined) : dependencies;
};
const requestConstructorUrl = (
@@ -769,11 +769,8 @@ export const stringValue = (
/** Return a string or numeric argument literal. */
export const argumentValue = (
node: t.Node | null | undefined,
): string | undefined => {
if (node === null || node === undefined) return undefined;
const value = unwrapJavaScriptExpression(node).node;
return t.isNumericLiteral(value) ? String(value.value) : stringValue(value);
};
): string | undefined =>
t.isNumericLiteral(node) ? String(node.value) : stringValue(node);
/** Prefix a static argument value when one is available. */
export const prefixedArgument = (
@@ -133,10 +133,7 @@ export interface JavaScriptFindingContext {
readonly expressionShapes: JavaScriptExpressionShapeResolver;
readonly source: string;
readonly accumulator: JavaScriptAnalysisAccumulator;
readonly vitePreloadDependencyTable: {
readonly helper: t.Node;
readonly dependencies: readonly (string | undefined)[];
} | null;
readonly vitePreloadDependencyTable: readonly (string | undefined)[] | null;
readonly openReceiverFacts?: ReadonlyMap<number, JavaScriptOpenReceiverFact>;
/** Proven Electron export path segments by root offset. */
readonly electronBindings?: ReadonlyMap<number, readonly string[]>;
@@ -1,7 +1,6 @@
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';";
@@ -153,52 +152,3 @@ 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,7 +7,6 @@ import {
traverseJavaScriptAst,
} from "./javascriptSemanticTraversal.js";
import { JavaScriptSymbolicBindings } from "./javascriptSymbolicBindings.js";
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
import {
symbolicTruth,
symbolicVariants,
@@ -1077,94 +1076,3 @@ 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,9 +52,6 @@ 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[];
@@ -201,12 +198,6 @@ 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);
}
@@ -405,19 +396,19 @@ export class JavaScriptSymbolicBindings {
this.unresolved = true;
return symbolicUnknown("module export read boundary is unavailable");
}
const write = available.findLast(
(write) =>
write.path.length === 0 ||
(write.path.length === 1 && write.path[0] === key),
const replacement = available.findLast(
(write) => write.path.length === 0,
);
if (write?.receiverUncertain)
return symbolicUnknown("uncertain export receiver");
if (write?.path.length === 0)
if (replacement)
return this.property(
this.evaluate(write.value, value.environment),
this.evaluate(replacement.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");
@@ -518,23 +509,19 @@ export class JavaScriptSymbolicBindings {
if (value.kind === "namespace") {
const available = this.#namespaceWrites(value);
if (available === undefined) return [];
const replacementIndex = available.findLastIndex(
const replacement = available.findLast(
(write) => write.path.length === 0,
);
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 : [])),
]),
];
return replacement
? this.members(
this.evaluate(replacement.value, value.environment),
active,
)
: [
...new Set(
available.flatMap((w) => (w.path.length === 1 ? w.path : [])),
),
];
}
if (value.kind !== "object") return [];
return [
@@ -576,7 +563,6 @@ 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,
);
@@ -1057,9 +1043,7 @@ 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
? this.#publicNamespaceWrites(writes)
: undefined;
return read === undefined ? writes : undefined;
// An owned snapshot cannot read beyond either its capture or this exit.
const point = Math.min(
sourcePoint ?? Infinity,
@@ -1085,84 +1069,17 @@ export class JavaScriptSymbolicBindings {
}
}
}
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))),
),
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))),
);
}
/** 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,
@@ -1631,14 +1548,6 @@ 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;
@@ -1668,25 +1577,7 @@ export class JavaScriptSymbolicBindings {
}
}
const writes = this.#writes.get(key) ?? [];
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 }),
});
writes.push({ path: selected, value, site });
this.#writes.set(key, writes);
}
}
@@ -1,10 +1,6 @@
import { describe, expect, it } from "vitest";
import {
analyzeJavaScriptStaticSource,
analyzeParsedJavaScriptStaticSource,
} from "./javascriptStaticAnalysis.js";
import { parseJavaScriptSource } from "./javascriptSourceParser.js";
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
describe("Vite preload dependency recovery", () => {
it("decodes helper table values and preserves sparse and dynamic slots", () => {
@@ -147,84 +143,3 @@ 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,6 +2,7 @@ import { describe, expect, it } from "vitest";
import { z } from "zod";
import {
addOffsetDateTimeMilliseconds,
inspectRecordedDateTime,
offsetDateTimeSchema,
readRecordedDateTime,
@@ -22,6 +23,34 @@ 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"],
+42 -6
View File
@@ -146,12 +146,14 @@ export const offsetDateTimeInstantFromEpochMilliseconds = (
const millisecondsWithinSecond = epochMilliseconds - epochSecond * 1000;
return {
epochSecond,
fraction: fractionalSecondFromMilliseconds(millisecondsWithinSecond),
fraction: secondsFromMilliseconds(millisecondsWithinSecond).fraction,
};
};
const fractionalSecondFromMilliseconds = (milliseconds: number): string => {
if (milliseconds === 0) return "";
const secondsFromMilliseconds = (
milliseconds: number,
): { readonly seconds: number; readonly fraction: string } => {
if (milliseconds === 0) return { seconds: 0, fraction: "" };
const [coefficient = "", exponentText] = String(milliseconds)
.toLowerCase()
.split("e");
@@ -166,9 +168,43 @@ const fractionalSecondFromMilliseconds = (milliseconds: number): string => {
: decimalPosition < digits.length
? `${digits.slice(0, decimalPosition)}.${digits.slice(decimalPosition)}`
: `${digits}${"0".repeat(decimalPosition - digits.length)}`;
return (
fraction.includes(".") ? fraction.slice(fraction.indexOf(".") + 1) : ""
).replace(/0+$/u, "");
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;
};
/** Compare instants exactly while treating absent fraction digits as zeroes. */
+105
View File
@@ -1,3 +1,4 @@
import { createHash } from "node:crypto";
import { expect, it } from "vitest";
import {
freezeJsonSnapshot,
@@ -116,3 +117,107 @@ 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);
});
+46 -2
View File
@@ -1,3 +1,5 @@
import { createHash } from "node:crypto";
import { canonicalBase64Schema } from "./stringPatterns.js";
import { z } from "zod";
import {
browserDeadlineIncrease,
@@ -215,7 +217,35 @@ export const webNetworkCaptureBinaryDescriptorSchema = z.discriminatedUnion(
[retainedBinaryDescriptorSchema, redactedBinaryDescriptorSchema],
);
const binarySchema = z.discriminatedUnion("state", [
retainedBinaryDescriptorSchema.extend({ content_base64: z.string() }),
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.",
});
}),
redactedBinaryDescriptorSchema.extend({ content_base64: z.null() }),
]);
const numberSchema = z.strictObject({
@@ -244,7 +274,21 @@ export const webNetworkCaptureRecordSchema = z.strictObject({
}),
]),
reported: jsonValueSchema,
binary_fields: z.array(binarySchema),
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);
}
}),
numeric_literals: z.array(numberSchema),
redactions: z.array(redactionSchema),
limitations: z.array(z.string()),
@@ -923,27 +923,12 @@ it("traces all endpoint carriers and their response consumers through CLI and MC
"response",
]);
const response = carriers.find(({ kind }) => kind === "response");
const request = carriers.find(({ kind }) => kind === "request");
if (response === undefined || request === undefined)
throw new Error("Expected matching request and response");
if (response === undefined) throw new Error("Expected matching response");
const consumer = graph.relations.find(
({ source_node_id, relation }) =>
source_node_id === response.node_id && relation === "consumed-by",
);
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" },
});
expect(consumer).toMatchObject({ resolution: "resolved" });
const client = await connectedMcpClient();
const trace = async (seed: JavaScriptSemanticQueryInput["seed"]) => {
const input = {
@@ -995,13 +980,13 @@ it("traces all endpoint carriers and their response consumers through CLI and MC
"request",
"response",
]);
expect(selected.relations).toEqual([requestLink, consumer]);
expect(selected.relations).toEqual([consumer]);
expect(selected).toMatchObject({
status: "ambiguous",
summary: {
total_seed_matches: 2,
traversed_nodes: 3,
traversed_relations: 2,
traversed_relations: 1,
},
coverage: { status: "partial" },
});