Compare commits

...
Author SHA1 Message Date
Kaoru0822andClaude Opus 5.5 ad3d995604 fix(javascript): carry a directly returned value's literal to its return site
Return sites evaluated their returned expression but only projected
identifier references as aliases. A function that returned a member read
such as `settings.index` linked only the receiver `settings` as a candidate
alias, so backward provenance from the call result lost the known literal
and reported ambiguity. Project a non-identifier return's evaluated
primitive onto its return site, as binding values already are, and do not
offer the expression's inner references as candidates when that value is
one exact literal.

Fixes #5585

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 15:27:17 +00:00
morluto 080c0e2b2b Merge remote-tracking branch 'origin/main' into codex/audit-continuation-20261020-refresh20 2026-10-20 23:15:43 +08:00
morluto 9eec57b469 fix(native): report record-local Objective-C budget omissions
Have method, property, ivar, and adopted-protocol list owners flag real budget omissions on returned class, protocol, and category records. Preserve known empty lists and existing decode reasons.

Validated 59 source tests, two built CLI controls, five static inspections of an Apple-clang fixture, and typecheck/lint. The target was never executed. Fixes #5543.
2026-10-20 23:15:05 +08:00
morluto 8d198d6ec7 fix(javascript): preserve unavailable scope uncertainty in comparisons
Classify wholly unknown or unavailable node observations at the version-item owner and retain their limitations across matched and unmatched comparisons. Preserve positive mixed observations and genuine additions/removals.

Validated source regressions and public CLI/stdout MCP workflows in the 103-test focused gate; typecheck and lint pass. Fixes #5534.
2026-10-20 23:14:47 +08:00
14 changed files with 1031 additions and 77 deletions

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,
});
});
+182
View File
@@ -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"],
+46 -23
View File
@@ -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) {
+40 -14
View File
@@ -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 {
+7 -1
View File
@@ -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(
+49 -10
View File
@@ -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
View File
@@ -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;
};