Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a0d31331e |
No files matched your search
@@ -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",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
Reference in new issue
Block a user