Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad3d995604 | ||
|
|
080c0e2b2b | ||
|
|
9eec57b469 | ||
|
|
8d198d6ec7 |
No files matched your search
@@ -94,6 +94,37 @@ it.each([
|
||||
},
|
||||
);
|
||||
|
||||
it.each([false, true])(
|
||||
"traces a directly returned member read back to its literal (candidates: %s)",
|
||||
(candidates) => {
|
||||
const graph = graphFor(`
|
||||
const settings = { index: "products" };
|
||||
function viaLocal() { const value = settings.index; return value; }
|
||||
function viaMember() { return settings.index; }
|
||||
const a = viaLocal();
|
||||
const b = viaMember();
|
||||
`);
|
||||
for (const label of ["a", "b"]) {
|
||||
const binding = graph.nodes.find(
|
||||
(node) => node.kind === "binding" && node.label === label,
|
||||
);
|
||||
if (binding === undefined) throw new Error(`Expected binding ${label}`);
|
||||
const trace = queryJavaScriptSemanticGraph(graph, {
|
||||
seed: { kind: "semantic-node", node_id: binding.node_id },
|
||||
direction: "backward-provenance",
|
||||
include_ambiguous_dynamic_edges: candidates,
|
||||
});
|
||||
expect(trace.status, label).toBe("found");
|
||||
expect(
|
||||
trace.nodes.filter(({ kind }) => kind === "literal"),
|
||||
label,
|
||||
).toEqual([
|
||||
expect.objectContaining({ properties: { value: "products" } }),
|
||||
]);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("projects closure and direct interprocedural flow without execution", () => {
|
||||
const graph = graphFor(`
|
||||
const outer = 2;
|
||||
|
||||
@@ -197,6 +197,14 @@ export const projectSemanticReturnValues = (
|
||||
for (const callable of context.ir.callables)
|
||||
for (const site of callable.returnSites) {
|
||||
const returnNode = context.returnSiteNodes.get(site.returnSiteId);
|
||||
// A non-identifier return evaluated to one exact primitive is defined by
|
||||
// its literal (see projectSemanticValues); references inside it, such as
|
||||
// a member read's receiver, are not candidates for the returned value.
|
||||
if (
|
||||
site.identityReferenceLocation === null &&
|
||||
site.value.status === "literal"
|
||||
)
|
||||
continue;
|
||||
const references = context.referenceNodesWithin(site.location);
|
||||
for (const reference of references)
|
||||
addSemanticGraphRelation(context.state, {
|
||||
|
||||
@@ -34,6 +34,34 @@ export const projectSemanticValues = (
|
||||
path: [],
|
||||
});
|
||||
}
|
||||
// A return through an identifier already reaches its value through the
|
||||
// resolved binding alias. Any other returned expression, such as a member
|
||||
// read, carries its evaluated primitive on the return site itself.
|
||||
for (const callable of context.ir.callables)
|
||||
for (const site of callable.returnSites) {
|
||||
const returnNode = context.returnSiteNodes.get(site.returnSiteId);
|
||||
if (returnNode === undefined || site.identityReferenceLocation !== null)
|
||||
continue;
|
||||
const primitives =
|
||||
site.value.status === "literal"
|
||||
? [site.value.value]
|
||||
: site.value.status === "union"
|
||||
? site.value.values
|
||||
: [];
|
||||
for (const primitive of primitives)
|
||||
addSemanticGraphRelation(context.state, {
|
||||
source: addLiteralNode(context, {
|
||||
key: `return:${site.returnSiteId}`,
|
||||
location: site.location,
|
||||
functionNodeId: returnNode.function_node_id,
|
||||
value: primitive,
|
||||
}),
|
||||
target: returnNode,
|
||||
relation: "defines",
|
||||
resolution:
|
||||
site.value.status === "literal" ? "resolved" : "candidate",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
interface ValueProjectionInput {
|
||||
@@ -50,7 +78,10 @@ const projectValue = (input: ValueProjectionInput): void => {
|
||||
path.length === 0 ? "binding" : `property:${semanticPropertyPointer(path)}`;
|
||||
if (target === null) return;
|
||||
if (value.status === "literal") {
|
||||
const literal = addLiteralNode(context, binding, value.value, role);
|
||||
const literal = addLiteralNode(
|
||||
context,
|
||||
bindingLiteral(context, binding, value.value, role),
|
||||
);
|
||||
addSemanticGraphRelation(context.state, {
|
||||
source: literal,
|
||||
target,
|
||||
@@ -59,7 +90,10 @@ const projectValue = (input: ValueProjectionInput): void => {
|
||||
});
|
||||
} else if (value.status === "union")
|
||||
for (const primitive of value.values) {
|
||||
const literal = addLiteralNode(context, binding, primitive, role);
|
||||
const literal = addLiteralNode(
|
||||
context,
|
||||
bindingLiteral(context, binding, primitive, role),
|
||||
);
|
||||
addSemanticGraphRelation(context.state, {
|
||||
source: literal,
|
||||
target,
|
||||
@@ -134,11 +168,32 @@ const projectValue = (input: ValueProjectionInput): void => {
|
||||
}
|
||||
};
|
||||
|
||||
const addLiteralNode = (
|
||||
interface LiteralOwner {
|
||||
/** Stable owner and role, such as a binding slot or a return site. */
|
||||
readonly key: string;
|
||||
readonly location:
|
||||
| JavaScriptSemanticBinding["definitions"][number]["location"]
|
||||
| null;
|
||||
readonly functionNodeId: string | null;
|
||||
readonly value: string | number | boolean | null;
|
||||
}
|
||||
|
||||
const bindingLiteral = (
|
||||
context: SemanticFlowProjectionContext,
|
||||
binding: JavaScriptSemanticBinding,
|
||||
value: string | number | boolean | null,
|
||||
role: string,
|
||||
): LiteralOwner => ({
|
||||
key: `${binding.bindingId}:${role}`,
|
||||
location: binding.definitions[0]?.location ?? null,
|
||||
functionNodeId:
|
||||
context.bindingNodes.get(binding.bindingId)?.function_node_id ?? null,
|
||||
value,
|
||||
});
|
||||
|
||||
const addLiteralNode = (
|
||||
context: SemanticFlowProjectionContext,
|
||||
{ key, location, functionNodeId, value }: LiteralOwner,
|
||||
) => {
|
||||
const encodedValue = JSON.stringify(value);
|
||||
const valueDigest = `value-sha256:${digestCanonicalValue(value, "Semantic literal")}`;
|
||||
@@ -149,14 +204,13 @@ const addLiteralNode = (
|
||||
kind: "literal",
|
||||
// Identity needs an exact value commitment, not another payload copy.
|
||||
// Keep the complete literal in properties for queries and Evidence export.
|
||||
roleKey: `literal:${binding.bindingId}:${role}:${valueKey}`,
|
||||
location: binding.definitions[0]?.location ?? null,
|
||||
roleKey: `literal:${key}:${valueKey}`,
|
||||
location,
|
||||
label:
|
||||
typeof value === "string" && encodedValue.length > stringLabel.length
|
||||
? stringLabel
|
||||
: encodedValue,
|
||||
functionNodeId:
|
||||
context.bindingNodes.get(binding.bindingId)?.function_node_id ?? null,
|
||||
functionNodeId,
|
||||
properties: { value },
|
||||
});
|
||||
};
|
||||
|
||||
@@ -145,20 +145,28 @@ const matchedItem = (
|
||||
const relationships = compareRelationships(pair, context);
|
||||
const dimensions = changedDimensions(pair, relationships);
|
||||
const complete = graphsComplete(context);
|
||||
const ownEvidenceUncertain =
|
||||
ownPresenceUncertain(pair.left) || ownPresenceUncertain(pair.right);
|
||||
const observedChange =
|
||||
dimensions.includes("content") || dimensions.includes("properties");
|
||||
!ownEvidenceUncertain &&
|
||||
(dimensions.includes("content") || dimensions.includes("properties"));
|
||||
// Coordinates remain comparison evidence; relocation alone is not a
|
||||
// content, property, or relationship change.
|
||||
const completeChange =
|
||||
complete && dimensions.some((dimension) => dimension !== "location");
|
||||
!ownEvidenceUncertain &&
|
||||
complete &&
|
||||
dimensions.some((dimension) => dimension !== "location");
|
||||
const status: ApplicationVersionComparisonItem["status"] =
|
||||
observedChange || completeChange
|
||||
? "changed"
|
||||
: complete && !relationships.unresolved
|
||||
? "unchanged"
|
||||
: "unknown";
|
||||
const finalDimensions =
|
||||
status === "unknown" && !complete
|
||||
ownEvidenceUncertain
|
||||
? "unknown"
|
||||
: observedChange || completeChange
|
||||
? "changed"
|
||||
: complete && !relationships.unresolved
|
||||
? "unchanged"
|
||||
: "unknown";
|
||||
const finalDimensions = ownEvidenceUncertain
|
||||
? []
|
||||
: status === "unknown" && !complete
|
||||
? [...dimensions, "coverage" as const]
|
||||
: dimensions;
|
||||
const semantic: ComparisonItemSemantic = {
|
||||
@@ -170,6 +178,10 @@ const matchedItem = (
|
||||
dimensions: finalDimensions,
|
||||
evidence_links: itemEvidenceLinks(pair.left, pair.right, context),
|
||||
limitations: [
|
||||
...new Set([
|
||||
...ownAvailabilityLimitations(pair.left),
|
||||
...ownAvailabilityLimitations(pair.right),
|
||||
]),
|
||||
...matchLimitations(pair),
|
||||
...(status === "unknown" && !complete
|
||||
? [
|
||||
@@ -218,7 +230,20 @@ const leftOnlyItem = (
|
||||
candidate_right_node_ids: [firstCandidate, ...remainingCandidates],
|
||||
},
|
||||
dimensions: ["availability"],
|
||||
limitations: [AMBIGUOUS_MATCH_LIMITATION],
|
||||
limitations: [
|
||||
...ownAvailabilityLimitations(node),
|
||||
AMBIGUOUS_MATCH_LIMITATION,
|
||||
],
|
||||
});
|
||||
if (ownPresenceUncertain(node))
|
||||
return itemWithId({
|
||||
...common,
|
||||
status: "unknown",
|
||||
left_node_id: node.node_id,
|
||||
right_node_id: null,
|
||||
match: unmatchedMatch(),
|
||||
dimensions: ["availability"],
|
||||
limitations: ownAvailabilityLimitations(node),
|
||||
});
|
||||
const absenceObserved = absenceClaimable(context.rightGraph.coverage);
|
||||
return itemWithId({
|
||||
@@ -230,7 +255,10 @@ const leftOnlyItem = (
|
||||
dimensions: absenceObserved
|
||||
? ["availability"]
|
||||
: ["availability", "coverage"],
|
||||
limitations: absenceObserved ? [] : [INCOMPLETE_GRAPH_LIMITATION],
|
||||
limitations: [
|
||||
...ownAvailabilityLimitations(node),
|
||||
...(absenceObserved ? [] : [INCOMPLETE_GRAPH_LIMITATION]),
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
@@ -258,7 +286,20 @@ const rightOnlyItem = (
|
||||
candidate_right_node_ids: [],
|
||||
},
|
||||
dimensions: ["availability"],
|
||||
limitations: [AMBIGUOUS_MATCH_LIMITATION],
|
||||
limitations: [
|
||||
...ownAvailabilityLimitations(node),
|
||||
AMBIGUOUS_MATCH_LIMITATION,
|
||||
],
|
||||
});
|
||||
if (ownPresenceUncertain(node))
|
||||
return itemWithId({
|
||||
...common,
|
||||
status: "unknown",
|
||||
left_node_id: null,
|
||||
right_node_id: node.node_id,
|
||||
match: unmatchedMatch(),
|
||||
dimensions: ["availability"],
|
||||
limitations: ownAvailabilityLimitations(node),
|
||||
});
|
||||
const absenceObserved = absenceClaimable(context.leftGraph.coverage);
|
||||
return itemWithId({
|
||||
@@ -270,7 +311,10 @@ const rightOnlyItem = (
|
||||
dimensions: absenceObserved
|
||||
? ["availability"]
|
||||
: ["availability", "coverage"],
|
||||
limitations: absenceObserved ? [] : [INCOMPLETE_GRAPH_LIMITATION],
|
||||
limitations: [
|
||||
...ownAvailabilityLimitations(node),
|
||||
...(absenceObserved ? [] : [INCOMPLETE_GRAPH_LIMITATION]),
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
@@ -331,6 +375,21 @@ const AMBIGUOUS_MATCH_LIMITATION =
|
||||
const INCOMPLETE_GRAPH_LIMITATION =
|
||||
"The opposite graph is incomplete, so an unmatched node cannot be classified as added or removed.";
|
||||
|
||||
const ownPresenceUncertain = (node: ApplicationNode): boolean =>
|
||||
node.observations.every(
|
||||
({ evidence }) =>
|
||||
evidence.state === "unknown" || evidence.state === "unavailable",
|
||||
);
|
||||
|
||||
const ownAvailabilityLimitations = (node: ApplicationNode): string[] => {
|
||||
const limitations = new Set<string>();
|
||||
for (const { evidence } of node.observations)
|
||||
if (evidence.state === "unknown" || evidence.state === "unavailable")
|
||||
for (const limitation of evidence.limitations)
|
||||
limitations.add(limitation);
|
||||
return [...limitations].sort(compareUnicodeCodePoints);
|
||||
};
|
||||
|
||||
const changedDimensions = (
|
||||
pair: ApplicationVersionNodePair,
|
||||
relationships: RelationshipComparison,
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
createJavaScriptApplicationGraph,
|
||||
createJavaScriptApplicationNode,
|
||||
} from "./javascriptApplicationGraph.js";
|
||||
import {
|
||||
artifactEvidence,
|
||||
completeCoverage,
|
||||
} from "./javascriptApplicationGraph.fixture.js";
|
||||
import { compareJavaScriptApplicationVersions } from "./javascriptApplicationVersionComparison.js";
|
||||
|
||||
const LEFT_SHA = "a".repeat(64);
|
||||
const RIGHT_SHA = "b".repeat(64);
|
||||
const LIMITATION =
|
||||
"Source is outside the caller-selected source_paths; it was inventoried but not analyzed.";
|
||||
const evidenceId = (character: string) => `ev_${character.repeat(64)}`;
|
||||
const node = (sha256: string, path: string) =>
|
||||
createJavaScriptApplicationNode({
|
||||
kind: "javascript-asset",
|
||||
identity: { strategy: "content-digest", stability: "global-exact", sha256 },
|
||||
observations: [
|
||||
{
|
||||
label: path,
|
||||
properties: { path },
|
||||
evidence: artifactEvidence(sha256, path),
|
||||
},
|
||||
],
|
||||
});
|
||||
const unavailableScope = (
|
||||
artifactSha: string,
|
||||
state: "unknown" | "unavailable" = "unavailable",
|
||||
) =>
|
||||
createJavaScriptApplicationNode({
|
||||
kind: "unknown",
|
||||
identity: {
|
||||
strategy: "artifact-local-key",
|
||||
stability: "artifact-version",
|
||||
artifact_sha256: artifactSha,
|
||||
namespace: "static-parse-scope",
|
||||
key: "select-javascript-sources:lib.js",
|
||||
},
|
||||
observations: [
|
||||
{
|
||||
label: "lib.js parse scope",
|
||||
properties: { path: "lib.js", operation: "select-javascript-sources" },
|
||||
evidence: {
|
||||
...artifactEvidence(artifactSha, "lib.js"),
|
||||
state,
|
||||
confidence: "unknown",
|
||||
limitations: [LIMITATION],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const observedAssetWithUnavailableFact = () => {
|
||||
const digest = "e".repeat(64);
|
||||
return createJavaScriptApplicationNode({
|
||||
kind: "javascript-asset",
|
||||
identity: {
|
||||
strategy: "content-digest",
|
||||
stability: "global-exact",
|
||||
sha256: digest,
|
||||
},
|
||||
observations: [
|
||||
{
|
||||
label: "new.js",
|
||||
properties: { path: "new.js" },
|
||||
evidence: artifactEvidence(digest, "new.js"),
|
||||
},
|
||||
{
|
||||
label: "new.js parse scope",
|
||||
properties: { operation: "parse-javascript" },
|
||||
evidence: {
|
||||
...artifactEvidence(digest, "new.js"),
|
||||
state: "unavailable",
|
||||
confidence: "unknown",
|
||||
limitations: [LIMITATION],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
const graph = (nodes: ReturnType<typeof node>[]) =>
|
||||
createJavaScriptApplicationGraph({
|
||||
schema: "JavaScriptApplicationGraph",
|
||||
root_node_ids: [nodes[0]!.node_id],
|
||||
nodes,
|
||||
edges: [],
|
||||
coverage: completeCoverage,
|
||||
limitations: [],
|
||||
});
|
||||
const compare = (
|
||||
left: ReturnType<typeof node>[],
|
||||
right: ReturnType<typeof node>[],
|
||||
) =>
|
||||
compareJavaScriptApplicationVersions({
|
||||
left: {
|
||||
evidenceId: evidenceId("1"),
|
||||
rootArtifactSha256: LEFT_SHA,
|
||||
graph: graph(left),
|
||||
},
|
||||
right: {
|
||||
evidenceId: evidenceId("2"),
|
||||
rootArtifactSha256: RIGHT_SHA,
|
||||
graph: graph(right),
|
||||
},
|
||||
leftNativeEvidence: [],
|
||||
rightNativeEvidence: [],
|
||||
});
|
||||
|
||||
it("keeps unmatched unavailable scopes unknown in either direction", () => {
|
||||
const leftRoot = node("d".repeat(64), "main.js");
|
||||
const rightRoot = node("d".repeat(64), "main.js");
|
||||
const leftScope = unavailableScope(LEFT_SHA);
|
||||
const rightScope = unavailableScope(RIGHT_SHA);
|
||||
const forward = compare([leftRoot], [rightRoot, rightScope]);
|
||||
const reverse = compare([leftRoot, leftScope], [rightRoot]);
|
||||
|
||||
expect(
|
||||
forward.items.find(
|
||||
({ right_node_id }) => right_node_id === rightScope.node_id,
|
||||
),
|
||||
).toMatchObject({
|
||||
status: "unknown",
|
||||
dimensions: ["availability"],
|
||||
limitations: [LIMITATION],
|
||||
evidence_links: [evidenceId("1"), evidenceId("2")],
|
||||
});
|
||||
expect(
|
||||
reverse.items.find(
|
||||
({ left_node_id }) => left_node_id === leftScope.node_id,
|
||||
),
|
||||
).toMatchObject({
|
||||
status: "unknown",
|
||||
dimensions: ["availability"],
|
||||
limitations: [LIMITATION],
|
||||
});
|
||||
expect(forward.summary).toMatchObject({ added: 0, removed: 0, unknown: 1 });
|
||||
expect(reverse.summary).toMatchObject({ added: 0, removed: 0, unknown: 1 });
|
||||
});
|
||||
|
||||
it("keeps matched unavailable scopes unknown with their source limitation", () => {
|
||||
const leftRoot = node("d".repeat(64), "main.js");
|
||||
const rightRoot = node("d".repeat(64), "main.js");
|
||||
const leftScope = unavailableScope(LEFT_SHA);
|
||||
const rightScope = unavailableScope(LEFT_SHA);
|
||||
const result = compare([leftRoot, leftScope], [rightRoot, rightScope]);
|
||||
|
||||
expect(
|
||||
result.items.find(({ left_node_id }) => left_node_id === leftScope.node_id),
|
||||
).toMatchObject({
|
||||
right_node_id: rightScope.node_id,
|
||||
status: "unknown",
|
||||
dimensions: [],
|
||||
limitations: [LIMITATION],
|
||||
});
|
||||
expect(result.summary).toMatchObject({ unchanged: 1, unknown: 1 });
|
||||
});
|
||||
|
||||
it("retains unknown scope facts and reports genuine entity changes", () => {
|
||||
const leftRoot = node("d".repeat(64), "main.js");
|
||||
const rightRoot = node("d".repeat(64), "main.js");
|
||||
const unknown = unavailableScope(RIGHT_SHA, "unknown");
|
||||
const added = node("e".repeat(64), "new.js");
|
||||
const removed = node("f".repeat(64), "removed.js");
|
||||
const mixedEvidenceAddition = observedAssetWithUnavailableFact();
|
||||
const unknownResult = compare([leftRoot], [rightRoot, unknown]);
|
||||
const addedResult = compare([leftRoot], [rightRoot, added]);
|
||||
const removedResult = compare([leftRoot, removed], [rightRoot]);
|
||||
const mixedResult = compare([leftRoot], [rightRoot, mixedEvidenceAddition]);
|
||||
|
||||
expect(
|
||||
unknownResult.items.find(
|
||||
({ right_node_id }) => right_node_id === unknown.node_id,
|
||||
),
|
||||
).toMatchObject({ status: "unknown", limitations: [LIMITATION] });
|
||||
expect(unknownResult.summary).toMatchObject({ added: 0, unknown: 1 });
|
||||
expect(
|
||||
addedResult.items.find(
|
||||
({ right_node_id }) => right_node_id === added.node_id,
|
||||
),
|
||||
).toMatchObject({ status: "added", dimensions: ["availability"] });
|
||||
expect(addedResult.summary.added).toBe(1);
|
||||
expect(
|
||||
mixedResult.items.find(
|
||||
({ right_node_id }) => right_node_id === mixedEvidenceAddition.node_id,
|
||||
),
|
||||
).toMatchObject({ status: "added", limitations: [LIMITATION] });
|
||||
expect(mixedResult.summary.added).toBe(1);
|
||||
expect(
|
||||
removedResult.items.find(
|
||||
({ left_node_id }) => left_node_id === removed.node_id,
|
||||
),
|
||||
).toMatchObject({ status: "removed", dimensions: ["availability"] });
|
||||
expect(removedResult.summary.removed).toBe(1);
|
||||
expect(
|
||||
compare(
|
||||
[leftRoot, node("c".repeat(64), "same.js")],
|
||||
[rightRoot, node("c".repeat(64), "same.js")],
|
||||
).summary,
|
||||
).toMatchObject({
|
||||
unchanged: 2,
|
||||
added: 0,
|
||||
removed: 0,
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { machoWithPropertyAttributes } from "../../tests/fixtures/objcPropertyMacho.js";
|
||||
import { objcRecordBudgetMacho } from "../../tests/fixtures/objcRecordBudgetMacho.js";
|
||||
import { decodeAppleDispatchMetadata } from "./AppleDispatchMetadata.js";
|
||||
|
||||
const base = 0x100000000n;
|
||||
@@ -119,6 +120,20 @@ const fixture = (
|
||||
};
|
||||
const provenance = { path: "/fixture/app", sha256: "a".repeat(64) };
|
||||
|
||||
const withProtocolListSection = (bytes: Buffer): Buffer => {
|
||||
const pointer = (at: number, offset: number) =>
|
||||
bytes.writeBigUInt64LE(base + BigInt(offset), at);
|
||||
bytes.writeUInt32LE(232, 20);
|
||||
bytes.writeUInt32LE(232, 36);
|
||||
bytes.writeUInt32LE(2, 96);
|
||||
bytes.write("__objc_protolist", 184);
|
||||
bytes.write("__DATA", 200);
|
||||
pointer(216, 0x6c0);
|
||||
bytes.writeBigUInt64LE(8n, 224);
|
||||
pointer(0x6c0, 0x740);
|
||||
return bytes;
|
||||
};
|
||||
|
||||
const partialSiblingsFixture = (
|
||||
options: {
|
||||
readonly emptyMiddleClass?: boolean;
|
||||
@@ -469,6 +484,173 @@ describe("Apple dispatch coverage ownership", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Objective-C declaration-local record-budget coverage", () => {
|
||||
it.each(["protocol", "class"] as const)(
|
||||
"preserves the decoded $0 method at a sufficient budget",
|
||||
(owner) => {
|
||||
const result = decodeAppleDispatchMetadata(
|
||||
objcRecordBudgetMacho(owner),
|
||||
100,
|
||||
provenance,
|
||||
);
|
||||
if (owner === "protocol")
|
||||
expect(result.objc_protocol_records).toMatchObject([
|
||||
{
|
||||
name: "FixtureProtocol",
|
||||
methods: [{ selector: "protocolMethod" }],
|
||||
decode: { status: "decoded", reason: null },
|
||||
},
|
||||
]);
|
||||
else
|
||||
expect(result.objc_classes).toMatchObject([
|
||||
{
|
||||
name: "FixtureClass",
|
||||
methods: [{ selector: "classMethod", method_type: "instance" }],
|
||||
decode: { status: "decoded", reason: null },
|
||||
},
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
it("marks an omitted protocol method list partial while preserving known emptiness", () => {
|
||||
const truncated = decodeAppleDispatchMetadata(
|
||||
withProtocolListSection(fixture()),
|
||||
1,
|
||||
provenance,
|
||||
);
|
||||
expect(truncated.objc_protocol_records).toMatchObject([
|
||||
{
|
||||
name: "FixtureProtocol",
|
||||
methods: [],
|
||||
decode: { status: "partial", reason: "max_records_reached" },
|
||||
},
|
||||
]);
|
||||
|
||||
const emptyBytes = withProtocolListSection(fixture());
|
||||
emptyBytes.writeBigUInt64LE(0n, 0x758);
|
||||
emptyBytes.writeBigUInt64LE(0n, 0x768);
|
||||
const empty = decodeAppleDispatchMetadata(emptyBytes, 1, provenance);
|
||||
expect(empty.objc_protocol_records).toMatchObject([
|
||||
{
|
||||
name: "FixtureProtocol",
|
||||
methods: [],
|
||||
optional_methods: [],
|
||||
decode: { status: "decoded", reason: null },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("marks omitted class methods, ivars, properties, and adopted protocols partial", () => {
|
||||
const methodBytes = fixture();
|
||||
methodBytes.writeUInt32LE(2, 0x300);
|
||||
methodBytes.writeBigUInt64LE(0n, 0x330);
|
||||
methodBytes.writeBigUInt64LE(0n, 0x340);
|
||||
const methodLimited = decodeAppleDispatchMetadata(
|
||||
methodBytes,
|
||||
1,
|
||||
provenance,
|
||||
);
|
||||
expect(methodLimited.objc_classes[0]).toMatchObject({
|
||||
name: "Fixture",
|
||||
methods: [],
|
||||
decode: { status: "partial", reason: "max_records_reached" },
|
||||
});
|
||||
|
||||
const ivarBytes = fixture();
|
||||
ivarBytes.writeBigUInt64LE(0n, 0x320);
|
||||
ivarBytes.writeBigUInt64LE(0n, 0x328);
|
||||
ivarBytes.writeBigUInt64LE(0n, 0x340);
|
||||
ivarBytes.writeUInt32LE(2, 0x300);
|
||||
const ivarsLimited = decodeAppleDispatchMetadata(ivarBytes, 1, provenance);
|
||||
expect(ivarsLimited.objc_classes[0]).toMatchObject({
|
||||
name: "Fixture",
|
||||
ivar_count: 1,
|
||||
decode: { status: "partial", reason: "max_records_reached" },
|
||||
});
|
||||
expect(ivarsLimited.objc_ivars).toEqual([]);
|
||||
|
||||
const propertyBytes = fixture();
|
||||
propertyBytes.writeBigUInt64LE(0n, 0x320);
|
||||
propertyBytes.writeBigUInt64LE(0n, 0x328);
|
||||
propertyBytes.writeBigUInt64LE(0n, 0x330);
|
||||
propertyBytes.writeUInt32LE(2, 0x300);
|
||||
const propertiesLimited = decodeAppleDispatchMetadata(
|
||||
propertyBytes,
|
||||
1,
|
||||
provenance,
|
||||
);
|
||||
expect(propertiesLimited.objc_classes[0]).toMatchObject({
|
||||
name: "Fixture",
|
||||
properties: [],
|
||||
decode: { status: "partial", reason: "max_records_reached" },
|
||||
});
|
||||
|
||||
const adoptedBytes = fixture();
|
||||
adoptedBytes.writeBigUInt64LE(0n, 0x320);
|
||||
adoptedBytes.writeBigUInt64LE(0n, 0x330);
|
||||
adoptedBytes.writeBigUInt64LE(0n, 0x340);
|
||||
adoptedBytes.writeUInt32LE(2, 0x300);
|
||||
const adoptedLimited = decodeAppleDispatchMetadata(
|
||||
adoptedBytes,
|
||||
1,
|
||||
provenance,
|
||||
);
|
||||
expect(adoptedLimited.objc_classes[0]).toMatchObject({
|
||||
name: "Fixture",
|
||||
protocols: [],
|
||||
decode: { status: "partial", reason: "max_records_reached" },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps genuinely empty class method lists decoded at the record limit", () => {
|
||||
const bytes = fixture();
|
||||
bytes.writeBigUInt64LE(0n, 0x320);
|
||||
bytes.writeBigUInt64LE(0n, 0x328);
|
||||
bytes.writeBigUInt64LE(0n, 0x330);
|
||||
bytes.writeBigUInt64LE(0n, 0x340);
|
||||
bytes.writeUInt32LE(2, 0x300);
|
||||
const result = decodeAppleDispatchMetadata(bytes, 1, provenance);
|
||||
expect(result.objc_classes[0]).toMatchObject({
|
||||
name: "Fixture",
|
||||
methods: [],
|
||||
decode: { status: "decoded", reason: null },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Objective-C category-local record-budget coverage", () => {
|
||||
it.each(["instance", "class"] as const)(
|
||||
"marks category %s methods partial only when a declared entry is omitted",
|
||||
(methodType) => {
|
||||
const bytes = fixture(false, "partial-method");
|
||||
const listField = methodType === "instance" ? 0xb50 : 0xb58;
|
||||
const methodList = bytes.readBigUInt64LE(0xb50);
|
||||
bytes.writeBigUInt64LE(0n, 0xb50);
|
||||
bytes.writeBigUInt64LE(methodList, listField);
|
||||
bytes.writeUInt32LE(2, 0x300);
|
||||
bytes.writeBigUInt64LE(0n, 0x320);
|
||||
const truncated = decodeAppleDispatchMetadata(bytes, 11, provenance);
|
||||
expect(truncated.objc_categories).toMatchObject([
|
||||
{
|
||||
name: "FixtureCategory",
|
||||
[`${methodType}_methods`]: [],
|
||||
decode: { status: "partial", reason: "max_records_reached" },
|
||||
},
|
||||
]);
|
||||
|
||||
bytes.writeBigUInt64LE(0n, listField);
|
||||
const empty = decodeAppleDispatchMetadata(bytes, 11, provenance);
|
||||
expect(empty.objc_categories).toMatchObject([
|
||||
{
|
||||
name: "FixtureCategory",
|
||||
[`${methodType}_methods`]: [],
|
||||
decode: { status: "decoded", reason: null },
|
||||
},
|
||||
]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("malformed Objective-C property records", () => {
|
||||
it.each([
|
||||
["an unmapped name", { secondNameOffset: 0x2000 }, "first", "0x100000518"],
|
||||
|
||||
@@ -31,8 +31,9 @@ export const decodeObjcCategories = (input: {
|
||||
className: string,
|
||||
methodType: "instance" | "class",
|
||||
category: string,
|
||||
onTruncated?: () => void,
|
||||
) => string[];
|
||||
readonly protocols: (list: bigint) => string[];
|
||||
readonly protocols: (list: bigint, onTruncated?: () => void) => string[];
|
||||
/** Name of a class defined in this image, from its class_ro_t. */
|
||||
readonly localClassName: (address: bigint) => string;
|
||||
}): number => {
|
||||
@@ -71,6 +72,43 @@ export const decodeObjcCategories = (input: {
|
||||
);
|
||||
}
|
||||
const owner = className ?? `(${name})`;
|
||||
let budgetTruncated = false;
|
||||
const onTruncated = () => {
|
||||
budgetTruncated = true;
|
||||
};
|
||||
const instanceMethods = input.methods(
|
||||
read.pointer(category + 16n),
|
||||
owner,
|
||||
"instance",
|
||||
name,
|
||||
onTruncated,
|
||||
);
|
||||
const classMethods = input.methods(
|
||||
read.pointer(category + 24n),
|
||||
owner,
|
||||
"class",
|
||||
name,
|
||||
onTruncated,
|
||||
);
|
||||
const protocols = input.protocols(
|
||||
read.pointer(category + 32n),
|
||||
onTruncated,
|
||||
);
|
||||
const properties = readObjcPropertiesOf(
|
||||
read,
|
||||
read.pointer(category + 40n),
|
||||
{
|
||||
owner: `category ${name}`,
|
||||
admit: input.admit,
|
||||
issues: input.issues,
|
||||
location: `0x${read.pointer(category + 40n).toString(16)}`,
|
||||
onTruncated,
|
||||
},
|
||||
);
|
||||
const decodeReasons = [
|
||||
...(className === null ? ["category_class_unresolved"] : []),
|
||||
...(budgetTruncated ? ["max_records_reached"] : []),
|
||||
];
|
||||
result.objc_categories.push({
|
||||
name,
|
||||
class_name: className,
|
||||
@@ -80,30 +118,15 @@ export const decodeObjcCategories = (input: {
|
||||
: className === null
|
||||
? "unresolved"
|
||||
: "local",
|
||||
instance_methods: input.methods(
|
||||
read.pointer(category + 16n),
|
||||
owner,
|
||||
"instance",
|
||||
name,
|
||||
),
|
||||
class_methods: input.methods(
|
||||
read.pointer(category + 24n),
|
||||
owner,
|
||||
"class",
|
||||
name,
|
||||
),
|
||||
protocols: input.protocols(read.pointer(category + 32n)),
|
||||
properties: readObjcPropertiesOf(read, read.pointer(category + 40n), {
|
||||
owner: `category ${name}`,
|
||||
admit: input.admit,
|
||||
issues: input.issues,
|
||||
location: `0x${read.pointer(category + 40n).toString(16)}`,
|
||||
}),
|
||||
instance_methods: instanceMethods,
|
||||
class_methods: classMethods,
|
||||
protocols,
|
||||
properties,
|
||||
location: read.location(category),
|
||||
decode:
|
||||
className === null
|
||||
? { status: "partial", reason: "category_class_unresolved" }
|
||||
: { status: "decoded", reason: null },
|
||||
decodeReasons.length === 0
|
||||
? { status: "decoded", reason: null }
|
||||
: { status: "partial", reason: decodeReasons.join("; ") },
|
||||
evidence: read.evidence(category, "Objective-C category_t record"),
|
||||
});
|
||||
} catch (cause: unknown) {
|
||||
|
||||
@@ -103,6 +103,7 @@ export const decodeObjcDispatchFacets = (input: {
|
||||
className: string,
|
||||
methodType: "instance" | "class",
|
||||
category?: string,
|
||||
onTruncated?: () => void,
|
||||
): string[] => {
|
||||
const selectors: string[] = [];
|
||||
if (list === 0n) return selectors;
|
||||
@@ -113,7 +114,10 @@ export const decodeObjcDispatchFacets = (input: {
|
||||
if (stride !== (relative ? 12 : 24))
|
||||
throw new TypeError(`Unsupported method stride/flags at ${hex(list)}`);
|
||||
for (let index = 0; index < count; index++) {
|
||||
if (!budget.admit()) break;
|
||||
if (!budget.admit()) {
|
||||
onTruncated?.();
|
||||
break;
|
||||
}
|
||||
const entry = list + 8n + BigInt(index * stride);
|
||||
const rel = (field: bigint) => {
|
||||
const displacement = bytes.readInt32LE(read.offset(field, 4));
|
||||
@@ -279,6 +283,10 @@ export const decodeObjcDispatchFacets = (input: {
|
||||
);
|
||||
const ivarList = read.pointer(ro + 48n);
|
||||
let ivarCount: number | null = meta ? null : 0;
|
||||
let classListTruncated = false;
|
||||
const onTruncated = () => {
|
||||
classListTruncated = true;
|
||||
};
|
||||
if (ivarList !== 0n && !meta) {
|
||||
const stride = read.u32(ivarList),
|
||||
count = read.u32(ivarList + 4n);
|
||||
@@ -286,7 +294,10 @@ export const decodeObjcDispatchFacets = (input: {
|
||||
throw new TypeError("Unsupported Objective-C ivar entry size");
|
||||
ivarCount = count;
|
||||
for (let index = 0; index < count; index++) {
|
||||
if (!budget.admit()) break;
|
||||
if (!budget.admit()) {
|
||||
classListTruncated = true;
|
||||
break;
|
||||
}
|
||||
const entry = ivarList + 8n + BigInt(index * stride);
|
||||
const encodedOffset = read.pointer(entry);
|
||||
let value: number | null = null;
|
||||
@@ -326,7 +337,27 @@ export const decodeObjcDispatchFacets = (input: {
|
||||
});
|
||||
}
|
||||
}
|
||||
methods(read.pointer(ro + 32n), name, meta ? "class" : "instance");
|
||||
methods(
|
||||
read.pointer(ro + 32n),
|
||||
name,
|
||||
meta ? "class" : "instance",
|
||||
undefined,
|
||||
onTruncated,
|
||||
);
|
||||
const properties = readObjcPropertiesOf(read, read.pointer(ro + 64n), {
|
||||
owner: name,
|
||||
admit: () => budget.admit(),
|
||||
issues: categoryIssues,
|
||||
location: hex(read.pointer(ro + 64n)),
|
||||
onTruncated,
|
||||
});
|
||||
const protocols = protocolReader.list(read.pointer(ro + 40n), onTruncated);
|
||||
const classDecodeReasons = [
|
||||
...(superclass.name === null && !root
|
||||
? ["superclass_binding_unresolved"]
|
||||
: []),
|
||||
...(classListTruncated ? ["max_records_reached"] : []),
|
||||
];
|
||||
result.objc_classes.push({
|
||||
name,
|
||||
super_class: superclass.name,
|
||||
@@ -350,22 +381,17 @@ export const decodeObjcDispatchFacets = (input: {
|
||||
is_required: null,
|
||||
is_optional: null,
|
||||
})),
|
||||
properties: readObjcPropertiesOf(read, read.pointer(ro + 64n), {
|
||||
owner: name,
|
||||
admit: () => budget.admit(),
|
||||
issues: categoryIssues,
|
||||
location: hex(read.pointer(ro + 64n)),
|
||||
}),
|
||||
protocols: protocolReader.list(read.pointer(ro + 40n)),
|
||||
properties,
|
||||
protocols,
|
||||
ivar_count: ivarCount,
|
||||
instance_size: read.u32(ro + 8n),
|
||||
location: read.location(address),
|
||||
superclass_address: superclass.address,
|
||||
metaclass_address: meta ? null : hex(read.pointer(address)),
|
||||
decode:
|
||||
superclass.name === null && !root
|
||||
? { status: "partial", reason: "superclass_binding_unresolved" }
|
||||
: decoded,
|
||||
classDecodeReasons.length === 0
|
||||
? decoded
|
||||
: { status: "partial", reason: classDecodeReasons.join("; ") },
|
||||
evidence: read.evidence(
|
||||
address,
|
||||
"Objective-C class and class_ro_t records",
|
||||
@@ -422,7 +448,7 @@ export const decodeObjcDispatchFacets = (input: {
|
||||
issues: categoryIssues,
|
||||
admit: () => budget.admit(),
|
||||
methods,
|
||||
protocols: (list) => protocolReader.list(list),
|
||||
protocols: (list, onTruncated) => protocolReader.list(list, onTruncated),
|
||||
localClassName: (address) => readLocalClass(read, address).name,
|
||||
});
|
||||
return {
|
||||
|
||||
@@ -107,6 +107,7 @@ export const readObjcPropertyList = (
|
||||
read: ObjcPropertyReaders,
|
||||
list: bigint,
|
||||
admit: () => boolean,
|
||||
onTruncated?: () => void,
|
||||
): {
|
||||
readonly properties: ObjcProperty[];
|
||||
readonly issues: PropertyListIssue[];
|
||||
@@ -121,7 +122,10 @@ export const readObjcPropertyList = (
|
||||
`Unsupported Objective-C property entry size ${entrySize}`,
|
||||
);
|
||||
for (let index = 0; index < count; index++) {
|
||||
if (!admit()) break;
|
||||
if (!admit()) {
|
||||
onTruncated?.();
|
||||
break;
|
||||
}
|
||||
const entry = list + 8n + BigInt(index * entrySize);
|
||||
const location = `0x${entry.toString(16)}`;
|
||||
try {
|
||||
@@ -158,6 +162,7 @@ export const readObjcPropertiesOf = (
|
||||
readonly admit: () => boolean;
|
||||
readonly issues: DecodeIssue[];
|
||||
readonly location?: string;
|
||||
readonly onTruncated?: () => void;
|
||||
},
|
||||
): ObjcProperty[] => {
|
||||
try {
|
||||
@@ -165,6 +170,7 @@ export const readObjcPropertiesOf = (
|
||||
read,
|
||||
list,
|
||||
context.admit,
|
||||
context.onTruncated,
|
||||
);
|
||||
for (const item of issues)
|
||||
context.issues.push(
|
||||
|
||||
@@ -18,7 +18,7 @@ export const createObjcProtocolReader = (input: {
|
||||
const { readers: read, result, failures } = input;
|
||||
const visited = new Set<bigint>();
|
||||
const name = (address: bigint) => read.string(read.pointer(address + 8n));
|
||||
const list = (address: bigint): string[] => {
|
||||
const list = (address: bigint, onTruncated?: () => void): string[] => {
|
||||
if (address === 0n) return [];
|
||||
// `count` is plain uintptr_t data, not a pointer fixup.
|
||||
const count = read.u64(address);
|
||||
@@ -26,7 +26,10 @@ export const createObjcProtocolReader = (input: {
|
||||
throw new RangeError("Protocol list exceeds 20000 entries");
|
||||
const names: string[] = [];
|
||||
for (let index = 0n; index < count; index++) {
|
||||
if (!read.admit()) break;
|
||||
if (!read.admit()) {
|
||||
onTruncated?.();
|
||||
break;
|
||||
}
|
||||
const protocol = read.pointer(address + 8n + index * 8n);
|
||||
names.push(name(protocol));
|
||||
record(protocol);
|
||||
@@ -39,6 +42,7 @@ export const createObjcProtocolReader = (input: {
|
||||
instance: boolean,
|
||||
/** Entries skipped for an empty selector, reported by the declaring protocol. */
|
||||
emptySelectors: string[],
|
||||
onTruncated?: () => void,
|
||||
) => {
|
||||
const output: ObjcSwiftMetadata["objc_protocols"][number]["methods"] = [];
|
||||
if (address === 0n) return output;
|
||||
@@ -49,7 +53,10 @@ export const createObjcProtocolReader = (input: {
|
||||
if (stride !== (relative ? 12 : 24))
|
||||
throw new TypeError("Unsupported Objective-C protocol method entry size");
|
||||
for (let index = 0; index < count; index++) {
|
||||
if (!read.admit()) break;
|
||||
if (!read.admit()) {
|
||||
onTruncated?.();
|
||||
break;
|
||||
}
|
||||
const entry = address + 8n + BigInt(index * stride);
|
||||
let selector = relative
|
||||
? entry + BigInt(read.i32(entry))
|
||||
@@ -84,22 +91,54 @@ export const createObjcProtocolReader = (input: {
|
||||
const protocolName = name(address);
|
||||
// Adopted protocols are independent declarations; decode them before
|
||||
// rejecting a parent whose own name is empty.
|
||||
const adopted = list(read.pointer(address + 16n));
|
||||
let budgetTruncated = false;
|
||||
const onTruncated = () => {
|
||||
budgetTruncated = true;
|
||||
};
|
||||
const adopted = list(read.pointer(address + 16n), onTruncated);
|
||||
if (protocolName.length === 0)
|
||||
throw new TypeError("Objective-C protocol has an empty name");
|
||||
const skipped: string[] = [];
|
||||
const required = [
|
||||
...methods(read.pointer(address + 24n), true, true, skipped),
|
||||
...methods(read.pointer(address + 32n), true, false, skipped),
|
||||
...methods(
|
||||
read.pointer(address + 24n),
|
||||
true,
|
||||
true,
|
||||
skipped,
|
||||
onTruncated,
|
||||
),
|
||||
...methods(
|
||||
read.pointer(address + 32n),
|
||||
true,
|
||||
false,
|
||||
skipped,
|
||||
onTruncated,
|
||||
),
|
||||
];
|
||||
const optional = [
|
||||
...methods(read.pointer(address + 40n), false, true, skipped),
|
||||
...methods(read.pointer(address + 48n), false, false, skipped),
|
||||
...methods(
|
||||
read.pointer(address + 40n),
|
||||
false,
|
||||
true,
|
||||
skipped,
|
||||
onTruncated,
|
||||
),
|
||||
...methods(
|
||||
read.pointer(address + 48n),
|
||||
false,
|
||||
false,
|
||||
skipped,
|
||||
onTruncated,
|
||||
),
|
||||
];
|
||||
for (const message of skipped)
|
||||
failures.push(
|
||||
`Protocol 0x${address.toString(16)}: Objective-C protocol ${protocolName} ${message}`,
|
||||
);
|
||||
const decodeReasons = [
|
||||
...(skipped.length === 0 ? [] : ["protocol_method_selector_empty"]),
|
||||
...(budgetTruncated ? ["max_records_reached"] : []),
|
||||
];
|
||||
result.objc_protocols.push({
|
||||
name: protocolName,
|
||||
methods: required,
|
||||
@@ -113,9 +152,9 @@ export const createObjcProtocolReader = (input: {
|
||||
optional_methods: optional,
|
||||
location: read.location(address),
|
||||
decode:
|
||||
skipped.length === 0
|
||||
decodeReasons.length === 0
|
||||
? { status: "decoded", reason: null }
|
||||
: { status: "partial", reason: "protocol_method_selector_empty" },
|
||||
: { status: "partial", reason: decodeReasons.join("; ") },
|
||||
evidence: read.evidence(
|
||||
address,
|
||||
"Objective-C protocol declaration and encoded method lists; IMP fields are not handler targets",
|
||||
|
||||
@@ -9,6 +9,113 @@ import { applicationVersionComparisonResultSchema } from "../../../src/domain/ja
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { cliTest } from "../../support/cli/cliFixture.js";
|
||||
|
||||
cliTest(
|
||||
"keeps unavailable source-selection scopes unknown in version comparisons",
|
||||
async ({ cli }) => {
|
||||
const root = await createTestTempDirectory("rea-version-scope-");
|
||||
const application = join(root, "application");
|
||||
const copy = join(root, "copy");
|
||||
await Promise.all([mkdir(application), mkdir(copy)]);
|
||||
const files = [
|
||||
["lib.js", "export function shared() { return 1; }\n"],
|
||||
["main.js", 'export const name = "one";\n'],
|
||||
] as const;
|
||||
for (const [name, contents] of files) {
|
||||
await Promise.all([
|
||||
writeFile(join(application, name), contents),
|
||||
writeFile(join(copy, name), contents),
|
||||
]);
|
||||
}
|
||||
|
||||
const analyze = async (input: string, selected: boolean) => {
|
||||
const response = await cli.run({
|
||||
arguments: [
|
||||
"analyze-javascript-application",
|
||||
input,
|
||||
...(selected ? ["--source-path", "main.js"] : []),
|
||||
"--json",
|
||||
],
|
||||
environment: { REA_LOG_LEVEL: "silent" },
|
||||
});
|
||||
expect(response.exitCode, response.stderr).toBe(0);
|
||||
return parseEvidence(response.json);
|
||||
};
|
||||
const full = await analyze(application, false);
|
||||
const selected = await analyze(application, true);
|
||||
const fullCopy = await analyze(copy, false);
|
||||
const selectedAnalysis = javascriptApplicationAnalysisResultSchema.parse(
|
||||
selected.normalized_result,
|
||||
);
|
||||
const parseScope = selectedAnalysis.graph.nodes.find(
|
||||
({ kind, observations }) =>
|
||||
kind === "unknown" &&
|
||||
observations.some(
|
||||
({ properties }) =>
|
||||
properties.path === "lib.js" &&
|
||||
properties.operation === "select-javascript-sources",
|
||||
),
|
||||
);
|
||||
expect(parseScope?.observations[0]?.evidence).toMatchObject({
|
||||
state: "unavailable",
|
||||
limitations: [
|
||||
"Source is outside the caller-selected source_paths; it was inventoried but not analyzed.",
|
||||
],
|
||||
});
|
||||
|
||||
const compare = async (
|
||||
left: ReturnType<typeof parseEvidence>,
|
||||
right: ReturnType<typeof parseEvidence>,
|
||||
name: string,
|
||||
) => {
|
||||
const inputPath = join(root, `${name}.json`);
|
||||
await writeFile(inputPath, JSON.stringify({ left, right }));
|
||||
const response = await cli.run({
|
||||
arguments: ["compare-application-versions", inputPath, "--json"],
|
||||
environment: { REA_LOG_LEVEL: "silent" },
|
||||
});
|
||||
expect(response.exitCode, response.stderr).toBe(0);
|
||||
return applicationVersionComparisonResultSchema.parse(
|
||||
parseEvidence(response.json).normalized_result,
|
||||
);
|
||||
};
|
||||
const forward = await compare(full, selected, "forward");
|
||||
const reverse = await compare(selected, full, "reverse");
|
||||
const unchangedCopy = await compare(full, fullCopy, "full-copy");
|
||||
|
||||
expect(
|
||||
forward.items.find(
|
||||
({ right_node_id }) => right_node_id === parseScope?.node_id,
|
||||
),
|
||||
).toMatchObject({
|
||||
status: "unknown",
|
||||
dimensions: ["availability"],
|
||||
limitations: [
|
||||
"Source is outside the caller-selected source_paths; it was inventoried but not analyzed.",
|
||||
],
|
||||
evidence_links: expect.arrayContaining([
|
||||
full.evidence_id,
|
||||
selected.evidence_id,
|
||||
]),
|
||||
});
|
||||
expect(
|
||||
reverse.items.find(
|
||||
({ left_node_id }) => left_node_id === parseScope?.node_id,
|
||||
),
|
||||
).toMatchObject({
|
||||
status: "unknown",
|
||||
dimensions: ["availability"],
|
||||
limitations: [
|
||||
"Source is outside the caller-selected source_paths; it was inventoried but not analyzed.",
|
||||
],
|
||||
});
|
||||
expect(forward.summary.added).toBe(0);
|
||||
expect(forward.summary.removed).toBe(0);
|
||||
expect(reverse.summary.added).toBe(0);
|
||||
expect(reverse.summary.removed).toBe(0);
|
||||
expect(unchangedCopy.summary).toMatchObject({ added: 0, removed: 0 });
|
||||
},
|
||||
);
|
||||
|
||||
cliTest(
|
||||
"uses syntax-aware bundle fingerprints for changed syntax and renamed bindings",
|
||||
async ({ cli }) => {
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
javascriptApplicationAnalysisResultSchema,
|
||||
type JavaScriptApplicationAnalysisResult,
|
||||
} from "../../../src/domain/javascript/javascriptApplicationAnalysis.js";
|
||||
import { applicationVersionComparisonResultSchema } from "../../../src/domain/javascript/javascriptApplicationVersionComparisonSchemas.js";
|
||||
import { parseJavaScriptApplicationGraph } from "../../../src/domain/javascript/javascriptApplicationGraph.js";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -186,23 +187,31 @@ const expectSelfImportEvidence = (
|
||||
const analyzeThroughStdioMcp = async (
|
||||
inputPath: string,
|
||||
): Promise<JavaScriptApplicationAnalysisResult> => {
|
||||
const evidence = await analyzeEvidenceThroughStdioMcp(inputPath);
|
||||
const result = javascriptApplicationAnalysisResultSchema.parse(
|
||||
evidence.normalized_result,
|
||||
);
|
||||
expect(result).toMatchObject({ input_path: inputPath, format: "directory" });
|
||||
return result;
|
||||
};
|
||||
|
||||
const analyzeEvidenceThroughStdioMcp = async (
|
||||
inputPath: string,
|
||||
sourcePaths?: readonly string[],
|
||||
) => {
|
||||
const client = await connectedMcpClient();
|
||||
const response = await client.callTool({
|
||||
name: "analyze_javascript_application",
|
||||
arguments: { input_path: inputPath },
|
||||
arguments: {
|
||||
input_path: inputPath,
|
||||
...(sourcePaths === undefined ? {} : { source_paths: [...sourcePaths] }),
|
||||
},
|
||||
});
|
||||
expect(response.isError).not.toBe(true);
|
||||
expect(response.content).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ type: "text" })]),
|
||||
);
|
||||
const result = z
|
||||
.object({ normalized_result: javascriptApplicationAnalysisResultSchema })
|
||||
.parse(response.structuredContent).normalized_result;
|
||||
expect(result).toMatchObject({
|
||||
input_path: inputPath,
|
||||
format: "directory",
|
||||
});
|
||||
return result;
|
||||
return parseEvidence(response.structuredContent);
|
||||
};
|
||||
|
||||
const expectCliAnalysisParity = async (
|
||||
@@ -1220,6 +1229,73 @@ describe("application workflow CLI input", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("application version source-scope MCP comparison", () => {
|
||||
it("keeps unselected parse scopes unknown through stdio MCP", async () => {
|
||||
const root = await createTestTempDirectory("rea-version-scope-mcp-");
|
||||
await Promise.all([
|
||||
writeFile(
|
||||
join(root, "lib.js"),
|
||||
"export function shared() { return 1; }\n",
|
||||
),
|
||||
writeFile(join(root, "main.js"), 'export const name = "one";\n'),
|
||||
]);
|
||||
const full = await analyzeEvidenceThroughStdioMcp(root);
|
||||
const selected = await analyzeEvidenceThroughStdioMcp(root, ["main.js"]);
|
||||
const selectedAnalysis = javascriptApplicationAnalysisResultSchema.parse(
|
||||
selected.normalized_result,
|
||||
);
|
||||
const scope = selectedAnalysis.graph.nodes.find(
|
||||
({ kind, observations }) =>
|
||||
kind === "unknown" &&
|
||||
observations.some(
|
||||
({ properties }) =>
|
||||
properties.path === "lib.js" &&
|
||||
properties.operation === "select-javascript-sources",
|
||||
),
|
||||
);
|
||||
expect(scope?.observations[0]?.evidence).toMatchObject({
|
||||
state: "unavailable",
|
||||
limitations: [
|
||||
"Source is outside the caller-selected source_paths; it was inventoried but not analyzed.",
|
||||
],
|
||||
});
|
||||
const client = await connectedMcpClient();
|
||||
const compare = async (left: unknown, right: unknown) => {
|
||||
const response = await client.callTool({
|
||||
name: "compare_application_versions",
|
||||
arguments: { left, right },
|
||||
});
|
||||
expect(response.isError).not.toBe(true);
|
||||
return applicationVersionComparisonResultSchema.parse(
|
||||
parseEvidence(response.structuredContent).normalized_result,
|
||||
);
|
||||
};
|
||||
const forward = await compare(full, selected);
|
||||
const reverse = await compare(selected, full);
|
||||
|
||||
expect(
|
||||
forward.items.find(
|
||||
({ right_node_id }) => right_node_id === scope?.node_id,
|
||||
),
|
||||
).toMatchObject({
|
||||
status: "unknown",
|
||||
dimensions: ["availability"],
|
||||
limitations: [
|
||||
"Source is outside the caller-selected source_paths; it was inventoried but not analyzed.",
|
||||
],
|
||||
evidence_links: expect.arrayContaining([
|
||||
full.evidence_id,
|
||||
selected.evidence_id,
|
||||
]),
|
||||
});
|
||||
expect(
|
||||
reverse.items.find(({ left_node_id }) => left_node_id === scope?.node_id),
|
||||
).toMatchObject({ status: "unknown", dimensions: ["availability"] });
|
||||
expect(forward.summary).toMatchObject({ added: 0, removed: 0 });
|
||||
expect(reverse.summary).toMatchObject({ added: 0, removed: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("application workflow CLI copy boundaries", () => {
|
||||
it("compares source values excluded from escaped rest and spread copies", async () => {
|
||||
const root = await createTestTempDirectory("rea-copy-boundary-cli-");
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect } from "vitest";
|
||||
import { parseEvidence } from "../../../src/domain/evidence.js";
|
||||
import { nativeDispatchMetadataResultSchema } from "../../../src/domain/native/objcSwiftMetadata.js";
|
||||
|
||||
import { objcRecordBudgetMacho } from "../../fixtures/objcRecordBudgetMacho.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { cliTest } from "../../support/cli/cliFixture.js";
|
||||
|
||||
describe.skipIf(process.platform !== "darwin")(
|
||||
"native dispatch declaration-local record budget",
|
||||
() => {
|
||||
cliTest.for([["protocol"], ["class"]] as const)(
|
||||
"marks an omitted $0 method list partial in public CLI output",
|
||||
async ([owner], { cli }) => {
|
||||
const directory = await createTestTempDirectory("rea-objc-budget-");
|
||||
const path = join(directory, "fixture.macho");
|
||||
await writeFile(path, objcRecordBudgetMacho(owner));
|
||||
const output = await cli.run({
|
||||
arguments: [
|
||||
"inspect-native-dispatch-metadata",
|
||||
path,
|
||||
"--max-records",
|
||||
"1",
|
||||
"--json",
|
||||
],
|
||||
environment: { REA_LOG_LEVEL: "silent" },
|
||||
});
|
||||
expect(output.exitCode, `${output.stdout}\n${output.stderr}`).toBe(0);
|
||||
const result = nativeDispatchMetadataResultSchema.parse(
|
||||
parseEvidence(output.json).normalized_result,
|
||||
).result;
|
||||
if (owner === "protocol") {
|
||||
expect(result?.objc_protocol_records).toMatchObject([
|
||||
{
|
||||
name: "FixtureProtocol",
|
||||
methods: [],
|
||||
decode: { status: "partial", reason: "max_records_reached" },
|
||||
},
|
||||
]);
|
||||
} else {
|
||||
expect(result?.objc_classes).toMatchObject([
|
||||
{
|
||||
name: "FixtureClass",
|
||||
methods: [],
|
||||
decode: { status: "partial", reason: "max_records_reached" },
|
||||
},
|
||||
]);
|
||||
}
|
||||
expect(result?.coverage).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
facet: "objc_class_method_ivar_metadata",
|
||||
status: "partial",
|
||||
reason: expect.stringContaining("max_records_reached"),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
const base = 0x100000000n;
|
||||
|
||||
/** Minimal inert Mach-O with one class instance method and protocol method. */
|
||||
export const objcRecordBudgetMacho = (owner: "class" | "protocol"): Buffer => {
|
||||
const bytes = Buffer.alloc(4096);
|
||||
const u32 = (offset: number, value: number) =>
|
||||
bytes.writeUInt32LE(value, offset);
|
||||
const pointer = (offset: number, target: number) =>
|
||||
bytes.writeBigUInt64LE(base + BigInt(target), offset);
|
||||
const nullPointer = (offset: number) => bytes.writeBigUInt64LE(0n, offset);
|
||||
|
||||
u32(0, 0xfeedfacf);
|
||||
u32(4, 0x0100000c);
|
||||
u32(12, 2);
|
||||
u32(16, 1);
|
||||
u32(20, owner === "protocol" ? 232 : 152);
|
||||
u32(32, 0x19);
|
||||
u32(36, owner === "protocol" ? 232 : 152);
|
||||
bytes.write("__DATA", 40);
|
||||
pointer(56, 0);
|
||||
bytes.writeBigUInt64LE(4096n, 64);
|
||||
bytes.writeBigUInt64LE(4096n, 80);
|
||||
u32(88, 7);
|
||||
u32(92, 5);
|
||||
u32(96, owner === "protocol" ? 2 : 1);
|
||||
bytes.write("__objc_classlist", 104);
|
||||
bytes.write("__DATA", 120);
|
||||
pointer(136, 0x180);
|
||||
bytes.writeBigUInt64LE(8n, 144);
|
||||
u32(152, 0x180);
|
||||
if (owner === "protocol") {
|
||||
bytes.write("__objc_protolist", 184);
|
||||
bytes.write("__DATA", 200);
|
||||
pointer(216, 0x6c0);
|
||||
bytes.writeBigUInt64LE(8n, 224);
|
||||
u32(232, 0x6c0);
|
||||
pointer(0x6c0, 0x740);
|
||||
}
|
||||
|
||||
pointer(0x180, 0x200);
|
||||
// class_t: zero isa/superclass; class_ro_t is root, named and has one method.
|
||||
nullPointer(0x200);
|
||||
nullPointer(0x208);
|
||||
pointer(0x220, 0x300);
|
||||
u32(0x300, 2);
|
||||
u32(0x308, 8);
|
||||
pointer(0x318, 0x900);
|
||||
pointer(0x320, 0x500);
|
||||
nullPointer(0x328);
|
||||
nullPointer(0x330);
|
||||
nullPointer(0x340);
|
||||
bytes.write("FixtureClass\0", 0x900);
|
||||
u32(0x500, 24);
|
||||
u32(0x504, 1);
|
||||
pointer(0x508, 0x920);
|
||||
pointer(0x510, 0x940);
|
||||
pointer(0x518, 0x700);
|
||||
bytes.write("classMethod\0", 0x920);
|
||||
bytes.write("v@:\0", 0x940);
|
||||
|
||||
// Protocol method lists use the same declaration boundary and budget.
|
||||
pointer(0x6c0, 0x740);
|
||||
pointer(0x748, 0x980);
|
||||
pointer(0x758, 0x800);
|
||||
u32(0x780, 72);
|
||||
u32(0x800, 24);
|
||||
u32(0x804, 1);
|
||||
pointer(0x808, 0x9a0);
|
||||
bytes.write("FixtureProtocol\0", 0x980);
|
||||
bytes.write("protocolMethod\0", 0x9a0);
|
||||
return bytes;
|
||||
};
|
||||
Reference in new issue
Block a user