Compare commits

..
Author SHA1 Message Date
Kaoru0822andClaude Opus 5.5 23ec9931ba fix(native): mark Objective-C declarations partial when the budget cuts their methods
inspect-native-dispatch-metadata returned protocols, classes and categories
with decode status "decoded" and a short or empty method list when the
max_records budget stopped inside that list, which made a truncated list
indistinguishable from a genuinely empty one. Each method-list reader now
reports whether the budget stopped it, and the declaring record reports
partial / method_list_record_budget_exhausted.

Fixes #5543

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 15:00:52 +00:00
7 changed files with 93 additions and 165 deletions

No files matched your search

+1 -5
View File
@@ -322,11 +322,7 @@ does not emit a `changed_from` edge; its pairing and uncertainty remain in the i
One-sided absence is `added` or `removed` only when the opposite input graph has
complete coverage. With partial, unavailable, or truncated input, the same
condition is `unknown`. A one-sided node whose observations are all
`unavailable`, such as an unselected or failed parse scope, records an analysis
gap rather than an application entity; it stays `unknown` with its
`availability` and `coverage` dimensions and the gap's limitations, whatever
the opposite coverage. The comparison retains every classified item and
condition is `unknown`. The comparison retains every classified item and
ambiguity candidate. Coverage carries omitted counts and limits from each input
graph; comparison adds no caller-selected item, candidate, graph node, or edge
budget. Unresolved comparison items are recorded as a residual unknown linked
@@ -220,17 +220,6 @@ const leftOnlyItem = (
dimensions: ["availability"],
limitations: [AMBIGUOUS_MATCH_LIMITATION],
});
const unavailable = unavailableAnalysisLimitations(node);
if (unavailable !== undefined)
return itemWithId({
...common,
status: "unknown",
match: unmatchedMatch(),
dimensions: ["availability", "coverage"],
limitations: unavailable,
left_node_id: node.node_id,
right_node_id: null,
});
const absenceObserved = absenceClaimable(context.rightGraph.coverage);
return itemWithId({
...common,
@@ -271,17 +260,6 @@ const rightOnlyItem = (
dimensions: ["availability"],
limitations: [AMBIGUOUS_MATCH_LIMITATION],
});
const unavailable = unavailableAnalysisLimitations(node);
if (unavailable !== undefined)
return itemWithId({
...common,
status: "unknown",
match: unmatchedMatch(),
dimensions: ["availability", "coverage"],
limitations: unavailable,
left_node_id: null,
right_node_id: node.node_id,
});
const absenceObserved = absenceClaimable(context.leftGraph.coverage);
return itemWithId({
...common,
@@ -296,23 +274,6 @@ const rightOnlyItem = (
});
};
/**
* An unmatched node whose every observation is unavailable is the producer's
* marker for an analysis gap, not a positive application entity, so its
* presence on one side cannot establish an addition or removal.
*/
const unavailableAnalysisLimitations = (
node: ApplicationNode,
): string[] | undefined =>
node.observations.every(({ evidence }) => evidence.state === "unavailable")
? [
...new Set([
UNAVAILABLE_ANALYSIS_LIMITATION,
...node.observations.flatMap(({ evidence }) => evidence.limitations),
]),
]
: undefined;
const matchedMatch = (
pair: ApplicationVersionNodePair,
): Extract<
@@ -367,9 +328,6 @@ const unmatchedMatch = (): Extract<
const AMBIGUOUS_MATCH_LIMITATION =
"Multiple nodes share the same permitted match key; no cross-version pair was selected.";
const UNAVAILABLE_ANALYSIS_LIMITATION =
"This node records unavailable analysis on one side, so its absence from the other side is not an addition or removal.";
const INCOMPLETE_GRAPH_LIMITATION =
"The opposite graph is incomplete, so an unmatched node cannot be classified as added or removed.";
+41
View File
@@ -1193,3 +1193,44 @@ describe("malformed Objective-C protocol method lists", () => {
});
});
});
describe("Objective-C method lists cut by the record budget", () => {
it("never reports a declaration decoded while the budget omitted its methods", () => {
const complete = decodeAppleDispatchMetadata(fixture(), 100, provenance);
const classKey = (item: { name: string; is_meta_class: boolean | null }) =>
`${item.name}:${String(item.is_meta_class)}`;
const completeProtocols = new Map(
complete.objc_protocol_records.map((item) => [item.name, item]),
);
const completeClasses = new Map(
complete.objc_classes.map((item) => [classKey(item), item]),
);
const cutReasons = new Set<string>();
for (let budget = 1; budget < 40; budget++) {
const result = decodeAppleDispatchMetadata(fixture(), budget, provenance);
for (const record of result.objc_protocol_records) {
const full = completeProtocols.get(record.name);
if (record.decode.status === "decoded") {
expect(record.methods).toEqual(full?.methods);
expect(record.optional_methods).toEqual(full?.optional_methods);
} else if (record.decode.reason !== null)
cutReasons.add(`protocol:${record.decode.reason}`);
}
for (const record of result.objc_classes) {
const full = completeClasses.get(classKey(record));
if (record.decode?.status === "decoded")
expect(record.methods).toEqual(full?.methods);
else if (record.decode?.reason != null)
cutReasons.add(`class:${record.decode.reason}`);
}
}
// The fixture has methods on both declaration kinds, so some budget must
// admit each declaration but stop inside its method list.
expect([...cutReasons]).toEqual(
expect.arrayContaining([
"protocol:method_list_record_budget_exhausted",
"class:method_list_record_budget_exhausted",
]),
);
});
});
+23 -16
View File
@@ -31,7 +31,7 @@ export const decodeObjcCategories = (input: {
className: string,
methodType: "instance" | "class",
category: string,
) => string[];
) => { selectors: string[]; budgetExhausted: boolean };
readonly protocols: (list: bigint) => string[];
/** Name of a class defined in this image, from its class_ro_t. */
readonly localClassName: (address: bigint) => string;
@@ -71,6 +71,18 @@ export const decodeObjcCategories = (input: {
);
}
const owner = className ?? `(${name})`;
const instanceMethods = input.methods(
read.pointer(category + 16n),
owner,
"instance",
name,
);
const classMethods = input.methods(
read.pointer(category + 24n),
owner,
"class",
name,
);
result.objc_categories.push({
name,
class_name: className,
@@ -80,18 +92,8 @@ 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,
),
instance_methods: instanceMethods.selectors,
class_methods: classMethods.selectors,
protocols: input.protocols(read.pointer(category + 32n)),
properties: readObjcPropertiesOf(read, read.pointer(category + 40n), {
owner: `category ${name}`,
@@ -101,9 +103,14 @@ export const decodeObjcCategories = (input: {
}),
location: read.location(category),
decode:
className === null
? { status: "partial", reason: "category_class_unresolved" }
: { status: "decoded", reason: null },
instanceMethods.budgetExhausted || classMethods.budgetExhausted
? {
status: "partial",
reason: "method_list_record_budget_exhausted",
}
: className === null
? { status: "partial", reason: "category_class_unresolved" }
: { status: "decoded", reason: null },
evidence: read.evidence(category, "Objective-C category_t record"),
});
} catch (cause: unknown) {
+12 -7
View File
@@ -103,9 +103,9 @@ export const decodeObjcDispatchFacets = (input: {
className: string,
methodType: "instance" | "class",
category?: string,
): string[] => {
): { selectors: string[]; budgetExhausted: boolean } => {
const selectors: string[] = [];
if (list === 0n) return selectors;
if (list === 0n) return { selectors, budgetExhausted: false };
const flags = read.u32(list),
count = read.u32(list + 4n),
relative = (flags & 0x80000000) !== 0;
@@ -113,7 +113,7 @@ 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()) return { selectors, budgetExhausted: true };
const entry = list + 8n + BigInt(index * stride);
const rel = (field: bigint) => {
const displacement = bytes.readInt32LE(read.offset(field, 4));
@@ -196,7 +196,7 @@ export const decodeObjcDispatchFacets = (input: {
),
});
}
return selectors;
return { selectors, budgetExhausted: false };
};
const protocolReader = createObjcProtocolReader({
readers: {
@@ -326,7 +326,11 @@ export const decodeObjcDispatchFacets = (input: {
});
}
}
methods(read.pointer(ro + 32n), name, meta ? "class" : "instance");
const { budgetExhausted } = methods(
read.pointer(ro + 32n),
name,
meta ? "class" : "instance",
);
result.objc_classes.push({
name,
super_class: superclass.name,
@@ -362,8 +366,9 @@ export const decodeObjcDispatchFacets = (input: {
location: read.location(address),
superclass_address: superclass.address,
metaclass_address: meta ? null : hex(read.pointer(address)),
decode:
superclass.name === null && !root
decode: budgetExhausted
? { status: "partial", reason: "method_list_record_budget_exhausted" }
: superclass.name === null && !root
? { status: "partial", reason: "superclass_binding_unresolved" }
: decoded,
evidence: read.evidence(
+16 -7
View File
@@ -39,6 +39,8 @@ export const createObjcProtocolReader = (input: {
instance: boolean,
/** Entries skipped for an empty selector, reported by the declaring protocol. */
emptySelectors: string[],
/** Set when the record budget stops the list before its last entry. */
budget: { exhausted: boolean },
) => {
const output: ObjcSwiftMetadata["objc_protocols"][number]["methods"] = [];
if (address === 0n) return output;
@@ -49,7 +51,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()) {
budget.exhausted = true;
break;
}
const entry = address + 8n + BigInt(index * stride);
let selector = relative
? entry + BigInt(read.i32(entry))
@@ -88,13 +93,14 @@ export const createObjcProtocolReader = (input: {
if (protocolName.length === 0)
throw new TypeError("Objective-C protocol has an empty name");
const skipped: string[] = [];
const budget = { exhausted: false };
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, budget),
...methods(read.pointer(address + 32n), true, false, skipped, budget),
];
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, budget),
...methods(read.pointer(address + 48n), false, false, skipped, budget),
];
for (const message of skipped)
failures.push(
@@ -112,8 +118,11 @@ export const createObjcProtocolReader = (input: {
methods: required,
optional_methods: optional,
location: read.location(address),
decode:
skipped.length === 0
// A budget-cut list is recoverable with a larger budget, so that
// cause is reported first; an empty list is otherwise complete.
decode: budget.exhausted
? { status: "partial", reason: "method_list_record_budget_exhausted" }
: skipped.length === 0
? { status: "decoded", reason: null }
: { status: "partial", reason: "protocol_method_selector_empty" },
evidence: read.evidence(
@@ -347,91 +347,3 @@ cliTest(
).toBe(true);
},
);
cliTest(
"keeps unselected source scopes unknown while genuine additions remain added",
async ({ cli }) => {
const root = await createTestTempDirectory("rea-version-unselected-");
const write = async (name: string, files: Record<string, string>) => {
const directory = join(root, name);
await mkdir(directory);
for (const [path, text] of Object.entries(files))
await writeFile(join(directory, path), text);
return directory;
};
const lib = "export function shared() {\n return 1;\n}\n";
const main = 'export const name = "one";\n';
const full = await write("full", { "lib.js": lib, "main.js": main });
const copy = await write("copy", { "lib.js": lib, "main.js": main });
const mainOnly = await write("main-only", { "main.js": main });
const analyze = async (directory: string, ...selection: string[]) => {
const response = await cli.run({
arguments: [
"analyze-javascript-application",
directory,
...selection,
"--json",
],
environment: { REA_LOG_LEVEL: "silent" },
});
expect(response.exitCode, response.stderr).toBe(0);
return parseEvidence(response.json);
};
const compare = async (name: string, left: unknown, right: unknown) => {
const input = join(root, `${name}.json`);
await writeFile(input, JSON.stringify({ left, right }));
const response = await cli.run({
arguments: ["compare-application-versions", input, "--json"],
environment: { REA_LOG_LEVEL: "silent" },
});
expect(response.exitCode, response.stderr).toBe(0);
return applicationVersionComparisonResultSchema.parse(
parseEvidence(response.json).normalized_result,
);
};
const complete = await analyze(full);
const selected = await analyze(full, "--source-path", "main.js");
const scope = javascriptApplicationAnalysisResultSchema
.parse(selected.normalized_result)
.graph.nodes.find((node) =>
node.observations.some(
({ properties }) =>
properties.path === "lib.js" &&
properties.operation === "select-javascript-sources",
),
);
if (scope === undefined) throw new Error("Missing unselected scope");
for (const [name, left, right, side] of [
["forward", complete, selected, "right_node_id"],
["reverse", selected, complete, "left_node_id"],
] as const) {
const result = await compare(name, left, right);
expect(result.summary).toMatchObject({ added: 0, removed: 0 });
const item = result.items.find((value) => value[side] === scope.node_id);
expect(item).toMatchObject({
status: "unknown",
node_kind: "unknown",
dimensions: ["availability", "coverage"],
});
expect(item?.limitations).toContain(
"Source is outside the caller-selected source_paths; it was inventoried but not analyzed.",
);
}
const control = await compare("control", complete, await analyze(copy));
expect(control.summary).toMatchObject({
added: 0,
removed: 0,
changed: 0,
unknown: 0,
});
const growth = await compare("growth", await analyze(mainOnly), complete);
expect(growth.items).toContainEqual(
expect.objectContaining({
status: "added",
node_kind: "javascript-asset",
}),
);
},
);