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 One-sided absence is `added` or `removed` only when the opposite input graph has
complete coverage. With partial, unavailable, or truncated input, the same 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 ambiguity candidate. Coverage carries omitted counts and limits from each input
graph; comparison adds no caller-selected item, candidate, graph node, or edge graph; comparison adds no caller-selected item, candidate, graph node, or edge
budget. Unresolved comparison items are recorded as a residual unknown linked budget. Unresolved comparison items are recorded as a residual unknown linked
@@ -220,6 +220,17 @@ const leftOnlyItem = (
dimensions: ["availability"], dimensions: ["availability"],
limitations: [AMBIGUOUS_MATCH_LIMITATION], 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); const absenceObserved = absenceClaimable(context.rightGraph.coverage);
return itemWithId({ return itemWithId({
...common, ...common,
@@ -260,6 +271,17 @@ const rightOnlyItem = (
dimensions: ["availability"], dimensions: ["availability"],
limitations: [AMBIGUOUS_MATCH_LIMITATION], 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); const absenceObserved = absenceClaimable(context.leftGraph.coverage);
return itemWithId({ return itemWithId({
...common, ...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 = ( const matchedMatch = (
pair: ApplicationVersionNodePair, pair: ApplicationVersionNodePair,
): Extract< ): Extract<
@@ -328,6 +367,9 @@ const unmatchedMatch = (): Extract<
const AMBIGUOUS_MATCH_LIMITATION = const AMBIGUOUS_MATCH_LIMITATION =
"Multiple nodes share the same permitted match key; no cross-version pair was selected."; "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 = const INCOMPLETE_GRAPH_LIMITATION =
"The opposite graph is incomplete, so an unmatched node cannot be classified as added or removed."; "The opposite graph is incomplete, so an unmatched node cannot be classified as added or removed.";
@@ -347,3 +347,91 @@ cliTest(
).toBe(true); ).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",
}),
);
},
);