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