Compare commits

...
Author SHA1 Message Date
Kaoru0822andClaude Opus 5.5 0a0d31331e fix(javascript): keep unavailable analysis scopes unknown in version comparison
An unselected or failed parse scope is recorded by the analysis producer as
an unknown-kind node whose observations are all unavailable. Application
version comparison treated that marker as a positive entity, so comparing
complete analysis with source-selected analysis reported it as added or
removed. One-sided nodes whose observations are all unavailable now stay
unknown with availability and coverage dimensions and the producer's
limitations, regardless of opposite coverage.

Fixes #5534

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 15:02:28 +00:00
3 changed files with 135 additions and 1 deletions

No files matched your search

+5 -1
View File
@@ -322,7 +322,11 @@ 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`. The comparison retains every classified item and
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
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,6 +220,17 @@ 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,
@@ -260,6 +271,17 @@ 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,
@@ -274,6 +296,23 @@ 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<
@@ -328,6 +367,9 @@ 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.";
@@ -347,3 +347,91 @@ 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",
}),
);
},
);