@dailephd/my-dev-kit-lab 0.6.0 → 0.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +32 -0
- package/README.md +7 -4
- package/benchmarks/contracts/incremental-change-staleness-scenarios.json +360 -0
- package/dist/src/commands/runExperimentRunCommand.js +4 -3
- package/dist/src/evaluation/affectedNeighborhood.js +712 -0
- package/dist/src/evaluation/affectedNeighborhoodMetrics.js +51 -0
- package/dist/src/evaluation/indexSnapshot.js +1 -1
- package/dist/src/experiments/defaultRegistry.js +2 -0
- package/dist/src/experiments/plugins/incrementalChangeStaleness/comparison.js +101 -0
- package/dist/src/experiments/plugins/incrementalChangeStaleness/config.js +66 -0
- package/dist/src/experiments/plugins/incrementalChangeStaleness/disposableTarget.js +107 -0
- package/dist/src/experiments/plugins/incrementalChangeStaleness/execution.js +404 -0
- package/dist/src/experiments/plugins/incrementalChangeStaleness/executionArtifact.js +160 -0
- package/dist/src/experiments/plugins/incrementalChangeStaleness/index.js +13 -0
- package/dist/src/experiments/plugins/incrementalChangeStaleness/lifecycle.js +276 -0
- package/dist/src/experiments/plugins/incrementalChangeStaleness/lifecyclePolicy.js +152 -0
- package/dist/src/experiments/plugins/incrementalChangeStaleness/mutationExecution.js +144 -0
- package/dist/src/experiments/plugins/incrementalChangeStaleness/plugin.js +274 -0
- package/dist/src/experiments/plugins/incrementalChangeStaleness/scenarioCatalog.js +42 -0
- package/dist/src/experiments/plugins/incrementalChangeStaleness/scenarioMutation.js +116 -0
- package/dist/src/experiments/plugins/incrementalChangeStaleness/scenarioSelection.js +79 -0
- package/dist/src/experiments/plugins/incrementalChangeStaleness/scenarioTypes.js +37 -0
- package/dist/src/experiments/plugins/incrementalChangeStaleness/scenarioValidation.js +168 -0
- package/dist/src/experiments/plugins/incrementalChangeStaleness/sourceState.js +145 -0
- package/dist/src/experiments/plugins/incrementalChangeStaleness/treatmentSession.js +6 -0
- package/dist/src/experiments/plugins/index.js +1 -0
- package/dist/src/experiments/plugins/warmIndexReuse/execution.js +21 -0
- package/dist/src/experiments/plugins/warmIndexReuse/executionArtifact.js +1 -0
- package/dist/src/experiments/plugins/warmIndexReuse/metrics.js +68 -0
- package/dist/src/experiments/plugins/warmIndexReuse/warmIndexSession.js +9 -1
- package/dist/src/report/experiments/buildIncrementalChangeStalenessReport.js +112 -0
- package/dist/src/report/experiments/buildPluginExperimentReport.js +17 -2
- package/dist/src/report/experiments/buildWarmIndexReuseReport.js +80 -3
- package/dist/src/report/experiments/incrementalChangeStalenessReportModel.js +4 -0
- package/dist/src/report/experiments/index.js +3 -0
- package/dist/src/report/experiments/renderIncrementalChangeStalenessHtml.js +239 -0
- package/dist/src/report/experiments/renderPluginExperimentReportHtml.js +3 -0
- package/dist/src/report/experiments/renderPluginExperimentReportText.js +222 -0
- package/dist/src/report/experiments/renderWarmIndexReuseHtml.js +98 -0
- package/dist/src/report/experiments/warmIndexReuseReportModel.js +5 -0
- package/docs/METRICS.md +79 -3
- package/examples/tutorial-browser/prepare.mjs +13 -13
- package/examples/tutorial-browser/server.mjs +13 -13
- package/package.json +1 -1
|
@@ -0,0 +1,712 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { resolveWithinRoot } from "../core/pathSafety.js";
|
|
3
|
+
import { compareCodeUnits, interpretIndexManifest, readJson } from "./indexSnapshot.js";
|
|
4
|
+
export const AFFECTED_NEIGHBORHOOD_GRAPH_EVIDENCE_SCHEMA_VERSION = "my-dev-kit-lab-affected-neighborhood-graph-evidence-v1";
|
|
5
|
+
export const AFFECTED_NEIGHBORHOOD_SEED_MAPPING_SCHEMA_VERSION = "my-dev-kit-lab-affected-neighborhood-seed-mapping-v1";
|
|
6
|
+
const MANIFEST_FILE = "manifest.json";
|
|
7
|
+
const SUPPORTED_SYMBOL_INDEX_SCHEMA_VERSION = "2";
|
|
8
|
+
const SUPPORTED_CODE_GRAPH_ARTIFACT_KIND = "code-graph";
|
|
9
|
+
const SUPPORTED_CODE_GRAPH_VERSION_PATTERN = /^1\./;
|
|
10
|
+
// Same bounds as the v0.6.0 freshness/snapshot evidence lists.
|
|
11
|
+
const MAX_WARNINGS = 50;
|
|
12
|
+
const MAX_UNRESOLVED = 50;
|
|
13
|
+
const MAX_REPORTED_TEXT_LENGTH = 260;
|
|
14
|
+
function isRecord(value) {
|
|
15
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
16
|
+
}
|
|
17
|
+
function boundedText(value) {
|
|
18
|
+
return value.length > MAX_REPORTED_TEXT_LENGTH ? `${value.slice(0, MAX_REPORTED_TEXT_LENGTH)}...` : value;
|
|
19
|
+
}
|
|
20
|
+
function nonEmptyString(value) {
|
|
21
|
+
return typeof value === "string" && value.length > 0;
|
|
22
|
+
}
|
|
23
|
+
function normalizePath(value) {
|
|
24
|
+
return value.replace(/\\/g, "/");
|
|
25
|
+
}
|
|
26
|
+
/** The upstream stable file-node identity. */
|
|
27
|
+
export function fileNodeId(filePath) {
|
|
28
|
+
return `file:${filePath}`;
|
|
29
|
+
}
|
|
30
|
+
/** The upstream stable symbol-node identity: file path + symbol name. */
|
|
31
|
+
export function symbolNodeId(filePath, symbolName) {
|
|
32
|
+
return `symbol:${filePath}#${symbolName}`;
|
|
33
|
+
}
|
|
34
|
+
function emptyEvidence(indexRoot) {
|
|
35
|
+
return {
|
|
36
|
+
schemaVersion: AFFECTED_NEIGHBORHOOD_GRAPH_EVIDENCE_SCHEMA_VERSION,
|
|
37
|
+
status: "unavailable",
|
|
38
|
+
unavailable: null,
|
|
39
|
+
indexRoot,
|
|
40
|
+
manifest: null,
|
|
41
|
+
symbolIndex: null,
|
|
42
|
+
codeGraph: null,
|
|
43
|
+
indexedFilePaths: [],
|
|
44
|
+
symbolFiles: new Map(),
|
|
45
|
+
nodes: new Map(),
|
|
46
|
+
edges: [],
|
|
47
|
+
duplicateEdgeRecordCount: 0,
|
|
48
|
+
warningCount: 0,
|
|
49
|
+
warnings: [],
|
|
50
|
+
warningsTruncated: false,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
function unavailableEvidence(indexRoot, code, message, manifest = null) {
|
|
54
|
+
return { ...emptyEvidence(indexRoot), unavailable: { code, message: boundedText(message) }, manifest };
|
|
55
|
+
}
|
|
56
|
+
function sortWarnings(warnings) {
|
|
57
|
+
return [...warnings].sort((left, right) => compareCodeUnits(left.code, right.code) || compareCodeUnits(left.subject ?? "", right.subject ?? "") || compareCodeUnits(left.message, right.message));
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Policy: reads indexed-file membership and baseline symbols from a symbol-index artifact. Only
|
|
61
|
+
* schema version 2 is understood. A file entry whose symbol list is malformed keeps its
|
|
62
|
+
* membership but yields no symbol record; a malformed symbol entry is dropped with a warning.
|
|
63
|
+
* Pure.
|
|
64
|
+
*/
|
|
65
|
+
export function interpretSymbolIndexForNeighborhood(symbolIndex) {
|
|
66
|
+
if (!isRecord(symbolIndex)) {
|
|
67
|
+
return { ok: false, message: "Symbol index is not a JSON object." };
|
|
68
|
+
}
|
|
69
|
+
if (symbolIndex.schemaVersion !== SUPPORTED_SYMBOL_INDEX_SCHEMA_VERSION) {
|
|
70
|
+
return { ok: false, message: `Symbol index schemaVersion is not the supported ${SUPPORTED_SYMBOL_INDEX_SCHEMA_VERSION}.` };
|
|
71
|
+
}
|
|
72
|
+
if (!Array.isArray(symbolIndex.files)) {
|
|
73
|
+
return { ok: false, message: "Symbol index does not list indexed files." };
|
|
74
|
+
}
|
|
75
|
+
const warnings = [];
|
|
76
|
+
const records = new Map();
|
|
77
|
+
const paths = new Set();
|
|
78
|
+
for (const entry of symbolIndex.files) {
|
|
79
|
+
if (!isRecord(entry) || !nonEmptyString(entry.path)) {
|
|
80
|
+
return { ok: false, message: "Symbol index contains a file entry without a path." };
|
|
81
|
+
}
|
|
82
|
+
const filePath = normalizePath(entry.path);
|
|
83
|
+
if (paths.has(filePath)) {
|
|
84
|
+
return { ok: false, message: "Symbol index lists the same file path more than once." };
|
|
85
|
+
}
|
|
86
|
+
paths.add(filePath);
|
|
87
|
+
if (!Array.isArray(entry.symbols)) {
|
|
88
|
+
warnings.push({
|
|
89
|
+
code: "invalid-symbol-file-entry",
|
|
90
|
+
subject: boundedText(filePath),
|
|
91
|
+
message: "Symbol index file entry has no symbol list; no baseline symbols are recorded for it.",
|
|
92
|
+
});
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
const seen = new Set();
|
|
96
|
+
const duplicates = new Set();
|
|
97
|
+
for (const symbol of entry.symbols) {
|
|
98
|
+
if (!isRecord(symbol) || !nonEmptyString(symbol.name)) {
|
|
99
|
+
warnings.push({ code: "invalid-symbol-entry", subject: boundedText(filePath), message: "Symbol index entry lacks a symbol name and was ignored." });
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (seen.has(symbol.name)) {
|
|
103
|
+
duplicates.add(symbol.name);
|
|
104
|
+
}
|
|
105
|
+
seen.add(symbol.name);
|
|
106
|
+
}
|
|
107
|
+
records.set(filePath, {
|
|
108
|
+
symbolNames: [...seen].sort(compareCodeUnits),
|
|
109
|
+
duplicateSymbolNames: [...duplicates].sort(compareCodeUnits),
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
const indexedFilePaths = [...paths].sort(compareCodeUnits);
|
|
113
|
+
const symbolFiles = new Map();
|
|
114
|
+
for (const key of [...records.keys()].sort(compareCodeUnits)) {
|
|
115
|
+
symbolFiles.set(key, records.get(key));
|
|
116
|
+
}
|
|
117
|
+
return { ok: true, value: { indexedFilePaths, symbolFiles, warnings } };
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Policy: validates the structure of a code-graph artifact and preserves its nodes and edges by
|
|
121
|
+
* their actual IDs and kinds. Edge kinds are not filtered. Structurally invalid entries are dropped
|
|
122
|
+
* with a warning; identical repeated edge records collapse silently into a count. Pure.
|
|
123
|
+
*/
|
|
124
|
+
export function interpretCodeGraphForNeighborhood(codeGraph) {
|
|
125
|
+
if (!isRecord(codeGraph)) {
|
|
126
|
+
return { ok: false, message: "Code graph is not a JSON object." };
|
|
127
|
+
}
|
|
128
|
+
if (codeGraph.artifactKind !== SUPPORTED_CODE_GRAPH_ARTIFACT_KIND) {
|
|
129
|
+
return { ok: false, message: `Code graph artifactKind is not ${SUPPORTED_CODE_GRAPH_ARTIFACT_KIND}.` };
|
|
130
|
+
}
|
|
131
|
+
if (typeof codeGraph.schemaVersion !== "string" || !SUPPORTED_CODE_GRAPH_VERSION_PATTERN.test(codeGraph.schemaVersion)) {
|
|
132
|
+
return { ok: false, message: "Code graph schemaVersion is missing or not a supported 1.x version." };
|
|
133
|
+
}
|
|
134
|
+
if (!Array.isArray(codeGraph.nodes) || !Array.isArray(codeGraph.edges)) {
|
|
135
|
+
return { ok: false, message: "Code graph does not list nodes and edges." };
|
|
136
|
+
}
|
|
137
|
+
const warnings = [];
|
|
138
|
+
const unsorted = new Map();
|
|
139
|
+
for (const entry of codeGraph.nodes) {
|
|
140
|
+
if (!isRecord(entry) || !nonEmptyString(entry.id) || !nonEmptyString(entry.kind)) {
|
|
141
|
+
warnings.push({ code: "invalid-node-entry", subject: null, message: "Code graph node lacks an id or kind and was ignored." });
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
const node = {
|
|
145
|
+
id: entry.id,
|
|
146
|
+
kind: entry.kind,
|
|
147
|
+
path: typeof entry.path === "string" ? entry.path : null,
|
|
148
|
+
label: typeof entry.label === "string" ? entry.label : null,
|
|
149
|
+
};
|
|
150
|
+
const existing = unsorted.get(node.id);
|
|
151
|
+
if (existing) {
|
|
152
|
+
if (existing.kind !== node.kind || existing.path !== node.path) {
|
|
153
|
+
warnings.push({ code: "duplicate-node-id", subject: boundedText(node.id), message: "Code graph repeats a node ID with different content; the first record was kept." });
|
|
154
|
+
}
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
unsorted.set(node.id, node);
|
|
158
|
+
}
|
|
159
|
+
const nodes = new Map();
|
|
160
|
+
for (const key of [...unsorted.keys()].sort(compareCodeUnits)) {
|
|
161
|
+
nodes.set(key, unsorted.get(key));
|
|
162
|
+
}
|
|
163
|
+
const edgesById = new Map();
|
|
164
|
+
let duplicateEdgeRecordCount = 0;
|
|
165
|
+
for (const entry of codeGraph.edges) {
|
|
166
|
+
if (!isRecord(entry) || !nonEmptyString(entry.id) || !nonEmptyString(entry.source) || !nonEmptyString(entry.target) || !nonEmptyString(entry.kind)) {
|
|
167
|
+
warnings.push({ code: "invalid-edge-entry", subject: null, message: "Code graph edge lacks an id, source, target, or kind and was ignored." });
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
const edge = { id: entry.id, source: entry.source, target: entry.target, kind: entry.kind };
|
|
171
|
+
const existing = edgesById.get(edge.id);
|
|
172
|
+
if (existing) {
|
|
173
|
+
if (existing.source === edge.source && existing.target === edge.target && existing.kind === edge.kind) {
|
|
174
|
+
duplicateEdgeRecordCount += 1;
|
|
175
|
+
}
|
|
176
|
+
else {
|
|
177
|
+
warnings.push({ code: "conflicting-duplicate-edge-id", subject: boundedText(edge.id), message: "Code graph repeats an edge ID with different content; the first record was kept." });
|
|
178
|
+
}
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
if (!nodes.has(edge.source) || !nodes.has(edge.target)) {
|
|
182
|
+
warnings.push({ code: "dangling-edge", subject: boundedText(edge.id), message: "Code graph edge references a node that is not in the graph and was ignored." });
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
edgesById.set(edge.id, edge);
|
|
186
|
+
}
|
|
187
|
+
const edges = [...edgesById.values()].sort((left, right) => compareCodeUnits(left.id, right.id));
|
|
188
|
+
return {
|
|
189
|
+
ok: true,
|
|
190
|
+
value: { artifactKind: codeGraph.artifactKind, schemaVersion: codeGraph.schemaVersion, nodes, edges, duplicateEdgeRecordCount, warnings },
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Loads the baseline graph evidence of one prepared index once. Reads only manifest-referenced
|
|
195
|
+
* artifacts inside `indexDir`; never enumerates the index, reads `cache-metadata.json`, walks
|
|
196
|
+
* source roots, runs my-dev-kit, or throws. `indexSnapshot` is the session's already captured v0.6.0
|
|
197
|
+
* snapshot, which proves the manifest was accepted for this target; an unavailable snapshot yields
|
|
198
|
+
* unavailable evidence rather than a re-derived one.
|
|
199
|
+
*/
|
|
200
|
+
export async function loadAffectedNeighborhoodGraphEvidence(options) {
|
|
201
|
+
const { indexDir, sourceRoots, indexSnapshot } = options;
|
|
202
|
+
const indexRoot = path.resolve(indexDir);
|
|
203
|
+
try {
|
|
204
|
+
if (indexSnapshot.status === "unavailable" || !indexSnapshot.manifest) {
|
|
205
|
+
return unavailableEvidence(indexRoot, "index-snapshot-unavailable", indexSnapshot.unavailable?.message ?? "The index snapshot is unavailable, so the index contract was not accepted.");
|
|
206
|
+
}
|
|
207
|
+
const manifestRead = await readJson(path.join(indexDir, MANIFEST_FILE));
|
|
208
|
+
if (!manifestRead.ok) {
|
|
209
|
+
return unavailableEvidence(indexRoot, manifestRead.missing ? "manifest-missing" : "manifest-unreadable", `Index manifest ${manifestRead.message}.`);
|
|
210
|
+
}
|
|
211
|
+
const manifest = interpretIndexManifest(manifestRead.value, { sourceRoots });
|
|
212
|
+
if (!manifest.ok) {
|
|
213
|
+
return unavailableEvidence(indexRoot, "manifest-unsupported", manifest.message);
|
|
214
|
+
}
|
|
215
|
+
const manifestSummary = {
|
|
216
|
+
path: MANIFEST_FILE,
|
|
217
|
+
artifactKind: manifest.contract.manifestArtifactKind,
|
|
218
|
+
schemaVersion: manifest.contract.manifestVersion,
|
|
219
|
+
};
|
|
220
|
+
let symbolIndexFile;
|
|
221
|
+
try {
|
|
222
|
+
symbolIndexFile = resolveWithinRoot(indexDir, manifest.contract.symbolIndexRelativePath);
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
return unavailableEvidence(indexRoot, "artifact-path-escapes-index", "Index manifest symbolIndex path escapes the index directory.", manifestSummary);
|
|
226
|
+
}
|
|
227
|
+
const symbolIndexRead = await readJson(symbolIndexFile);
|
|
228
|
+
if (!symbolIndexRead.ok) {
|
|
229
|
+
return unavailableEvidence(indexRoot, symbolIndexRead.missing ? "symbol-index-missing" : "symbol-index-unreadable", `Symbol index ${symbolIndexRead.message}.`, manifestSummary);
|
|
230
|
+
}
|
|
231
|
+
const symbols = interpretSymbolIndexForNeighborhood(symbolIndexRead.value);
|
|
232
|
+
if (!symbols.ok) {
|
|
233
|
+
return unavailableEvidence(indexRoot, "symbol-index-unsupported", symbols.message, manifestSummary);
|
|
234
|
+
}
|
|
235
|
+
const symbolIndexSummary = {
|
|
236
|
+
path: normalizePath(path.relative(path.resolve(indexDir), symbolIndexFile)),
|
|
237
|
+
schemaVersion: SUPPORTED_SYMBOL_INDEX_SCHEMA_VERSION,
|
|
238
|
+
};
|
|
239
|
+
const warnings = [...symbols.value.warnings];
|
|
240
|
+
let codeGraphSummary = null;
|
|
241
|
+
let graph = null;
|
|
242
|
+
const rawCodeGraph = isRecord(manifestRead.value) && isRecord(manifestRead.value.artifacts) ? manifestRead.value.artifacts.codeGraph : undefined;
|
|
243
|
+
if (!nonEmptyString(rawCodeGraph)) {
|
|
244
|
+
warnings.push({ code: "code-graph-not-referenced", subject: null, message: "Index manifest does not name a codeGraph artifact." });
|
|
245
|
+
}
|
|
246
|
+
else {
|
|
247
|
+
let codeGraphFile = null;
|
|
248
|
+
try {
|
|
249
|
+
codeGraphFile = resolveWithinRoot(indexDir, rawCodeGraph);
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
warnings.push({ code: "code-graph-path-escapes-index", subject: null, message: "Index manifest codeGraph path escapes the index directory." });
|
|
253
|
+
}
|
|
254
|
+
if (codeGraphFile !== null) {
|
|
255
|
+
const codeGraphRead = await readJson(codeGraphFile);
|
|
256
|
+
if (!codeGraphRead.ok) {
|
|
257
|
+
warnings.push({
|
|
258
|
+
code: codeGraphRead.missing ? "code-graph-missing" : "code-graph-unreadable",
|
|
259
|
+
subject: null,
|
|
260
|
+
message: boundedText(`Code graph ${codeGraphRead.message}.`),
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
else {
|
|
264
|
+
const interpreted = interpretCodeGraphForNeighborhood(codeGraphRead.value);
|
|
265
|
+
if (!interpreted.ok) {
|
|
266
|
+
warnings.push({ code: "code-graph-unsupported", subject: null, message: boundedText(interpreted.message) });
|
|
267
|
+
}
|
|
268
|
+
else {
|
|
269
|
+
graph = interpreted.value;
|
|
270
|
+
warnings.push(...graph.warnings);
|
|
271
|
+
codeGraphSummary = {
|
|
272
|
+
path: normalizePath(path.relative(path.resolve(indexDir), codeGraphFile)),
|
|
273
|
+
artifactKind: graph.artifactKind,
|
|
274
|
+
schemaVersion: graph.schemaVersion,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
const sorted = sortWarnings(warnings);
|
|
281
|
+
return {
|
|
282
|
+
schemaVersion: AFFECTED_NEIGHBORHOOD_GRAPH_EVIDENCE_SCHEMA_VERSION,
|
|
283
|
+
status: sorted.length === 0 ? "complete" : "partial",
|
|
284
|
+
unavailable: null,
|
|
285
|
+
indexRoot,
|
|
286
|
+
manifest: manifestSummary,
|
|
287
|
+
symbolIndex: symbolIndexSummary,
|
|
288
|
+
codeGraph: codeGraphSummary,
|
|
289
|
+
indexedFilePaths: symbols.value.indexedFilePaths,
|
|
290
|
+
symbolFiles: symbols.value.symbolFiles,
|
|
291
|
+
nodes: graph?.nodes ?? new Map(),
|
|
292
|
+
edges: graph?.edges ?? [],
|
|
293
|
+
duplicateEdgeRecordCount: graph?.duplicateEdgeRecordCount ?? 0,
|
|
294
|
+
warningCount: sorted.length,
|
|
295
|
+
warnings: sorted.slice(0, MAX_WARNINGS),
|
|
296
|
+
warningsTruncated: sorted.length > MAX_WARNINGS,
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
catch (error) {
|
|
300
|
+
return unavailableEvidence(indexRoot, "graph-evidence-load-failed", `Affected-neighborhood graph evidence load failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
function lookupNode(evidence, expectedNodeId, kind) {
|
|
304
|
+
const node = evidence.nodes.get(expectedNodeId);
|
|
305
|
+
if (!node)
|
|
306
|
+
return { ok: false, expectedNodeId, reason: "graph-node-missing" };
|
|
307
|
+
return node.kind === kind ? { ok: true, nodeId: node.id } : { ok: false, expectedNodeId, reason: "graph-node-kind-mismatch" };
|
|
308
|
+
}
|
|
309
|
+
/** Exact file-path lookup of the baseline file node. Never falls back to another path. */
|
|
310
|
+
export function lookupFileNode(evidence, filePath) {
|
|
311
|
+
return lookupNode(evidence, fileNodeId(filePath), "file");
|
|
312
|
+
}
|
|
313
|
+
/** Same-file symbol lookup. Never searches for a same-named symbol in another file. */
|
|
314
|
+
export function lookupSymbolNode(evidence, filePath, symbolName) {
|
|
315
|
+
return lookupNode(evidence, symbolNodeId(filePath, symbolName), "symbol");
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* Maps confirmed changed indexed files, and the baseline symbols belonging to them, onto baseline
|
|
319
|
+
* graph nodes. Pure and deterministic: consumes only the v0.6.0 snapshot and freshness assessment
|
|
320
|
+
* plus the prepared index's graph evidence; never touches the filesystem, current source, or
|
|
321
|
+
* my-dev-kit, and never falls back to fuzzy or cross-file matching.
|
|
322
|
+
*/
|
|
323
|
+
export function mapAffectedNeighborhoodSeeds(input) {
|
|
324
|
+
const { indexSnapshot, freshness, graph } = input;
|
|
325
|
+
const warnings = [];
|
|
326
|
+
const unresolved = [];
|
|
327
|
+
const warn = (code, message, warnPath = null) => {
|
|
328
|
+
warnings.push({ code, path: warnPath === null ? null : boundedText(warnPath), message });
|
|
329
|
+
};
|
|
330
|
+
const snapshotPaths = new Set(indexSnapshot.files.map((file) => normalizePath(file.path)));
|
|
331
|
+
const changesByPath = new Map();
|
|
332
|
+
for (const change of freshness.changes) {
|
|
333
|
+
const changePath = normalizePath(change.path);
|
|
334
|
+
if (!snapshotPaths.has(changePath)) {
|
|
335
|
+
unresolved.push({ subject: "changed-file", path: boundedText(changePath), name: null, reason: "not-in-index-snapshot" });
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
// Duplicate change records collapse deterministically: a missing file outranks a modified one.
|
|
339
|
+
const existing = changesByPath.get(changePath);
|
|
340
|
+
if (existing === undefined || (existing === "modified" && change.changeType === "missing")) {
|
|
341
|
+
changesByPath.set(changePath, change.changeType);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
const changedPaths = [...changesByPath.keys()].sort(compareCodeUnits);
|
|
345
|
+
const totalConfirmed = freshness.changedFileCount + freshness.missingFileCount;
|
|
346
|
+
const changesTruncated = freshness.changesTruncated;
|
|
347
|
+
const freshnessGivesBasis = freshness.status !== "unknown" || changedPaths.length > 0;
|
|
348
|
+
const changedFileCount = !freshnessGivesBasis ? null : changesTruncated ? Math.max(totalConfirmed, changedPaths.length) : changedPaths.length;
|
|
349
|
+
const codeGraphLoaded = graph.codeGraph !== null && graph.status !== "unavailable";
|
|
350
|
+
const symbolIndexAvailable = graph.status !== "unavailable";
|
|
351
|
+
if (freshness.status === "unknown")
|
|
352
|
+
warn("freshness-unknown", "Freshness is unknown; no changed-file basis exists.");
|
|
353
|
+
if (freshness.status === "partially-stale")
|
|
354
|
+
warn("freshness-partially-stale", "Freshness comparison is incomplete; confirmed changes are preserved but the mapping stays partial.");
|
|
355
|
+
if (changesTruncated)
|
|
356
|
+
warn("freshness-changes-truncated", `Freshness listed ${changedPaths.length} of ${totalConfirmed} confirmed changes; the remainder is not mapped.`);
|
|
357
|
+
if (graph.status === "unavailable")
|
|
358
|
+
warn("graph-evidence-unavailable", graph.unavailable?.message ?? "Graph evidence is unavailable.");
|
|
359
|
+
else if (graph.status === "partial")
|
|
360
|
+
warn("graph-evidence-incomplete", `Graph evidence is partial (${graph.warningCount} warning(s)).`);
|
|
361
|
+
if (symbolIndexAvailable && !codeGraphLoaded)
|
|
362
|
+
warn("code-graph-not-loaded", "The code graph could not be loaded, so no node mapping is possible.");
|
|
363
|
+
const changedFiles = [];
|
|
364
|
+
const changedSymbols = [];
|
|
365
|
+
const seeds = new Set();
|
|
366
|
+
let mappedFileCount = 0;
|
|
367
|
+
let mappedSymbolCount = 0;
|
|
368
|
+
let symbolIdentityCount = 0;
|
|
369
|
+
for (const filePath of changedPaths) {
|
|
370
|
+
const changeType = changesByPath.get(filePath);
|
|
371
|
+
const expectedNodeId = fileNodeId(filePath);
|
|
372
|
+
let resolvedNodeId = null;
|
|
373
|
+
let reason = null;
|
|
374
|
+
if (!codeGraphLoaded) {
|
|
375
|
+
reason = "graph-unavailable";
|
|
376
|
+
}
|
|
377
|
+
else {
|
|
378
|
+
const lookup = lookupFileNode(graph, filePath);
|
|
379
|
+
if (lookup.ok)
|
|
380
|
+
resolvedNodeId = lookup.nodeId;
|
|
381
|
+
else
|
|
382
|
+
reason = lookup.reason;
|
|
383
|
+
}
|
|
384
|
+
if (resolvedNodeId !== null) {
|
|
385
|
+
mappedFileCount += 1;
|
|
386
|
+
seeds.add(resolvedNodeId);
|
|
387
|
+
}
|
|
388
|
+
else {
|
|
389
|
+
unresolved.push({ subject: "changed-file", path: boundedText(filePath), name: null, reason: reason });
|
|
390
|
+
}
|
|
391
|
+
changedFiles.push({
|
|
392
|
+
path: filePath,
|
|
393
|
+
changeType,
|
|
394
|
+
expectedNodeId,
|
|
395
|
+
resolvedNodeId,
|
|
396
|
+
status: resolvedNodeId === null ? "unresolved" : "mapped",
|
|
397
|
+
unresolvedReason: reason,
|
|
398
|
+
});
|
|
399
|
+
if (!symbolIndexAvailable)
|
|
400
|
+
continue;
|
|
401
|
+
const record = graph.symbolFiles.get(filePath);
|
|
402
|
+
if (!record) {
|
|
403
|
+
unresolved.push({ subject: "symbol-index-record", path: boundedText(filePath), name: null, reason: "symbol-index-record-missing" });
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
if (record.duplicateSymbolNames.length > 0) {
|
|
407
|
+
warn("duplicate-symbol-records", `Baseline symbol index lists ${record.duplicateSymbolNames.length} symbol name(s) more than once; each counts as one identity.`, filePath);
|
|
408
|
+
}
|
|
409
|
+
for (const name of record.symbolNames) {
|
|
410
|
+
symbolIdentityCount += 1;
|
|
411
|
+
const symbolExpectedId = symbolNodeId(filePath, name);
|
|
412
|
+
let symbolResolvedId = null;
|
|
413
|
+
let symbolReason = null;
|
|
414
|
+
if (!codeGraphLoaded) {
|
|
415
|
+
symbolReason = "graph-unavailable";
|
|
416
|
+
}
|
|
417
|
+
else {
|
|
418
|
+
const lookup = lookupSymbolNode(graph, filePath, name);
|
|
419
|
+
if (lookup.ok)
|
|
420
|
+
symbolResolvedId = lookup.nodeId;
|
|
421
|
+
else
|
|
422
|
+
symbolReason = lookup.reason;
|
|
423
|
+
}
|
|
424
|
+
if (symbolResolvedId !== null) {
|
|
425
|
+
mappedSymbolCount += 1;
|
|
426
|
+
seeds.add(symbolResolvedId);
|
|
427
|
+
}
|
|
428
|
+
else {
|
|
429
|
+
unresolved.push({ subject: "changed-symbol", path: boundedText(filePath), name: boundedText(name), reason: symbolReason });
|
|
430
|
+
}
|
|
431
|
+
changedSymbols.push({
|
|
432
|
+
path: filePath,
|
|
433
|
+
name,
|
|
434
|
+
expectedNodeId: symbolExpectedId,
|
|
435
|
+
resolvedNodeId: symbolResolvedId,
|
|
436
|
+
status: symbolResolvedId === null ? "unresolved" : "mapped",
|
|
437
|
+
unresolvedReason: symbolReason,
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
const symbolCountEstablished = symbolIndexAvailable && freshnessGivesBasis;
|
|
442
|
+
const changedSymbolCount = symbolCountEstablished ? symbolIdentityCount : null;
|
|
443
|
+
if (unresolved.length > 0)
|
|
444
|
+
warn("mapping-unresolved", `${unresolved.length} changed-file or changed-symbol mapping(s) could not be resolved.`);
|
|
445
|
+
unresolved.sort((left, right) => compareCodeUnits(left.path, right.path) ||
|
|
446
|
+
compareCodeUnits(left.name ?? "", right.name ?? "") ||
|
|
447
|
+
compareCodeUnits(left.subject, right.subject) ||
|
|
448
|
+
compareCodeUnits(left.reason, right.reason));
|
|
449
|
+
warnings.sort((left, right) => compareCodeUnits(left.code, right.code) || compareCodeUnits(left.path ?? "", right.path ?? "") || compareCodeUnits(left.message, right.message));
|
|
450
|
+
let status;
|
|
451
|
+
if (!freshnessGivesBasis || !codeGraphLoaded) {
|
|
452
|
+
status = "unavailable";
|
|
453
|
+
}
|
|
454
|
+
else if ((freshness.status === "fresh" || freshness.status === "stale") &&
|
|
455
|
+
graph.status === "complete" &&
|
|
456
|
+
!changesTruncated &&
|
|
457
|
+
unresolved.length === 0) {
|
|
458
|
+
status = "complete";
|
|
459
|
+
}
|
|
460
|
+
else {
|
|
461
|
+
status = "partial";
|
|
462
|
+
}
|
|
463
|
+
const seedNodeIds = [...seeds].sort(compareCodeUnits);
|
|
464
|
+
return {
|
|
465
|
+
schemaVersion: AFFECTED_NEIGHBORHOOD_SEED_MAPPING_SCHEMA_VERSION,
|
|
466
|
+
status,
|
|
467
|
+
freshnessStatus: freshness.status,
|
|
468
|
+
graphEvidenceStatus: graph.status,
|
|
469
|
+
changedFileCount,
|
|
470
|
+
changedSymbolCount,
|
|
471
|
+
mappedChangedFileNodeCount: mappedFileCount,
|
|
472
|
+
mappedChangedSymbolNodeCount: mappedSymbolCount,
|
|
473
|
+
seedNodeCount: seedNodeIds.length,
|
|
474
|
+
changesTruncated,
|
|
475
|
+
changedFiles,
|
|
476
|
+
changedSymbols,
|
|
477
|
+
seedNodeIds,
|
|
478
|
+
unresolvedCount: unresolved.length,
|
|
479
|
+
unresolved: unresolved.slice(0, MAX_UNRESOLVED),
|
|
480
|
+
unresolvedTruncated: unresolved.length > MAX_UNRESOLVED,
|
|
481
|
+
warningCount: warnings.length,
|
|
482
|
+
warnings: warnings.slice(0, MAX_WARNINGS),
|
|
483
|
+
warningsTruncated: warnings.length > MAX_WARNINGS,
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
export const AFFECTED_NEIGHBORHOOD_ASSESSMENT_SCHEMA_VERSION = "my-dev-kit-lab-affected-neighborhood-assessment-v1";
|
|
487
|
+
function uniqueSorted(values) {
|
|
488
|
+
return [...new Set(values)].sort(compareCodeUnits);
|
|
489
|
+
}
|
|
490
|
+
function normalizeExpectedPath(value) {
|
|
491
|
+
return normalizePath(value).replace(/^(\.\/)+/, "");
|
|
492
|
+
}
|
|
493
|
+
/**
|
|
494
|
+
* Policy: maps a task's expected files and symbols to baseline graph nodes. Files resolve only to
|
|
495
|
+
* their exact `file:` node. A symbol resolves only within the task's own expected files and only
|
|
496
|
+
* when exactly one same-name node exists there; zero matches is unresolved and several is
|
|
497
|
+
* ambiguous (no winner is picked, no node is added). Never searches the rest of the graph. Pure.
|
|
498
|
+
*/
|
|
499
|
+
export function mapAffectedNeighborhoodTask(input) {
|
|
500
|
+
const { graph } = input;
|
|
501
|
+
const unresolved = [];
|
|
502
|
+
const ambiguous = [];
|
|
503
|
+
const empty = () => ({
|
|
504
|
+
status: "unavailable",
|
|
505
|
+
expectedFiles: [],
|
|
506
|
+
expectedSymbols: [],
|
|
507
|
+
resolvedFiles: [],
|
|
508
|
+
resolvedSymbols: [],
|
|
509
|
+
unresolvedCount: 0,
|
|
510
|
+
unresolved: [],
|
|
511
|
+
unresolvedTruncated: false,
|
|
512
|
+
ambiguousCount: 0,
|
|
513
|
+
ambiguousSymbols: [],
|
|
514
|
+
ambiguousTruncated: false,
|
|
515
|
+
duplicateEntryCount: 0,
|
|
516
|
+
resolvedTaskNodeIds: [],
|
|
517
|
+
resolvableTaskNodeCount: 0,
|
|
518
|
+
});
|
|
519
|
+
if (!Array.isArray(input.expectedFiles) || !Array.isArray(input.expectedSymbols)) {
|
|
520
|
+
return empty();
|
|
521
|
+
}
|
|
522
|
+
let duplicateEntryCount = 0;
|
|
523
|
+
const collect = (entries, subject) => {
|
|
524
|
+
const seen = new Set();
|
|
525
|
+
for (const entry of entries) {
|
|
526
|
+
const value = typeof entry === "string" ? (subject === "expected-file" ? normalizeExpectedPath(entry) : entry) : "";
|
|
527
|
+
if (value.length === 0) {
|
|
528
|
+
unresolved.push({ subject, name: typeof entry === "string" ? "" : boundedText(String(entry)), reason: "invalid-entry" });
|
|
529
|
+
continue;
|
|
530
|
+
}
|
|
531
|
+
if (seen.has(value))
|
|
532
|
+
duplicateEntryCount += 1;
|
|
533
|
+
seen.add(value);
|
|
534
|
+
}
|
|
535
|
+
return [...seen].sort(compareCodeUnits);
|
|
536
|
+
};
|
|
537
|
+
const expectedFiles = collect(input.expectedFiles, "expected-file");
|
|
538
|
+
const expectedSymbols = collect(input.expectedSymbols, "expected-symbol");
|
|
539
|
+
const graphUsable = graph.status !== "unavailable" && graph.codeGraph !== null;
|
|
540
|
+
if (!graphUsable || (expectedFiles.length === 0 && expectedSymbols.length === 0)) {
|
|
541
|
+
// No truthful mapping exists, but invalid metadata entries stay visible as diagnostics.
|
|
542
|
+
unresolved.sort((left, right) => compareCodeUnits(left.subject, right.subject) || compareCodeUnits(left.name, right.name));
|
|
543
|
+
return {
|
|
544
|
+
...empty(),
|
|
545
|
+
expectedFiles,
|
|
546
|
+
expectedSymbols,
|
|
547
|
+
duplicateEntryCount,
|
|
548
|
+
unresolvedCount: unresolved.length,
|
|
549
|
+
unresolved: unresolved.slice(0, MAX_UNRESOLVED),
|
|
550
|
+
unresolvedTruncated: unresolved.length > MAX_UNRESOLVED,
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
const resolvedFiles = [];
|
|
554
|
+
for (const filePath of expectedFiles) {
|
|
555
|
+
const lookup = lookupFileNode(graph, filePath);
|
|
556
|
+
if (lookup.ok)
|
|
557
|
+
resolvedFiles.push({ path: filePath, nodeId: lookup.nodeId });
|
|
558
|
+
else
|
|
559
|
+
unresolved.push({ subject: "expected-file", name: boundedText(filePath), reason: lookup.reason });
|
|
560
|
+
}
|
|
561
|
+
const resolvedSymbols = [];
|
|
562
|
+
for (const name of expectedSymbols) {
|
|
563
|
+
// Scope is the task's expected files, whether or not their own file node resolved.
|
|
564
|
+
const candidates = uniqueSorted(expectedFiles.flatMap((filePath) => {
|
|
565
|
+
const lookup = lookupSymbolNode(graph, filePath, name);
|
|
566
|
+
return lookup.ok ? [lookup.nodeId] : [];
|
|
567
|
+
}));
|
|
568
|
+
if (candidates.length === 1) {
|
|
569
|
+
resolvedSymbols.push({ name, nodeId: candidates[0] });
|
|
570
|
+
}
|
|
571
|
+
else if (candidates.length === 0) {
|
|
572
|
+
unresolved.push({ subject: "expected-symbol", name: boundedText(name), reason: "graph-node-missing" });
|
|
573
|
+
}
|
|
574
|
+
else {
|
|
575
|
+
ambiguous.push({ name: boundedText(name), candidateNodeIds: candidates });
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
const resolvedTaskNodeIds = uniqueSorted([...resolvedFiles.map((entry) => entry.nodeId), ...resolvedSymbols.map((entry) => entry.nodeId)]);
|
|
579
|
+
unresolved.sort((left, right) => compareCodeUnits(left.subject, right.subject) || compareCodeUnits(left.name, right.name) || compareCodeUnits(left.reason, right.reason));
|
|
580
|
+
ambiguous.sort((left, right) => compareCodeUnits(left.name, right.name));
|
|
581
|
+
const status = resolvedTaskNodeIds.length === 0
|
|
582
|
+
? "unavailable"
|
|
583
|
+
: graph.status === "complete" && unresolved.length === 0 && ambiguous.length === 0
|
|
584
|
+
? "complete"
|
|
585
|
+
: "partial";
|
|
586
|
+
return {
|
|
587
|
+
status,
|
|
588
|
+
expectedFiles,
|
|
589
|
+
expectedSymbols,
|
|
590
|
+
resolvedFiles,
|
|
591
|
+
resolvedSymbols,
|
|
592
|
+
unresolvedCount: unresolved.length,
|
|
593
|
+
unresolved: unresolved.slice(0, MAX_UNRESOLVED),
|
|
594
|
+
unresolvedTruncated: unresolved.length > MAX_UNRESOLVED,
|
|
595
|
+
ambiguousCount: ambiguous.length,
|
|
596
|
+
ambiguousSymbols: ambiguous.slice(0, MAX_UNRESOLVED),
|
|
597
|
+
ambiguousTruncated: ambiguous.length > MAX_UNRESOLVED,
|
|
598
|
+
duplicateEntryCount,
|
|
599
|
+
resolvedTaskNodeIds,
|
|
600
|
+
resolvableTaskNodeCount: resolvedTaskNodeIds.length,
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
/**
|
|
604
|
+
* Policy: exactly one graph hop, bidirectional, over every retained structurally valid edge
|
|
605
|
+
* regardless of kind. Affected nodes are the resolved seeds plus the opposite endpoint of every
|
|
606
|
+
* edge incident to a seed. Affected edges are only those incident to a seed: an edge between two
|
|
607
|
+
* non-seed neighbors is not part of the frontier. A single scan of the sorted edge list, O(E); the
|
|
608
|
+
* graph evidence is not mutated.
|
|
609
|
+
*/
|
|
610
|
+
export function traverseAffectedNeighborhood(graph, seedNodeIds) {
|
|
611
|
+
const seeds = new Set(seedNodeIds.filter((id) => graph.nodes.has(id)));
|
|
612
|
+
const affected = new Set(seeds);
|
|
613
|
+
const edgeIds = new Set();
|
|
614
|
+
for (const edge of graph.edges) {
|
|
615
|
+
if (!seeds.has(edge.source) && !seeds.has(edge.target))
|
|
616
|
+
continue;
|
|
617
|
+
edgeIds.add(edge.id);
|
|
618
|
+
affected.add(edge.source);
|
|
619
|
+
affected.add(edge.target);
|
|
620
|
+
}
|
|
621
|
+
return { affectedNodeIds: [...affected].sort(compareCodeUnits), participatingEdgeIds: [...edgeIds].sort(compareCodeUnits) };
|
|
622
|
+
}
|
|
623
|
+
/**
|
|
624
|
+
* Assesses one task against the affected neighborhood of the confirmed changed seeds. Pure and
|
|
625
|
+
* deterministic; uses only the baseline graph evidence, the Batch 1 seed mapping, and the task's
|
|
626
|
+
* expected metadata. It never reads files, runs my-dev-kit, or classifies from task locality labels.
|
|
627
|
+
*
|
|
628
|
+
* Evidence rules: positive overlap is `related` (and `recommended`) even under partial evidence;
|
|
629
|
+
* zero overlap is `unrelated` (and `not-indicated`) only when seed mapping, neighborhood, and task
|
|
630
|
+
* mapping are all complete. Zero overlap under any incomplete evidence is `unknown`, never
|
|
631
|
+
* `unrelated`. "Not indicated" means only that this experiment's bounded evidence does not point
|
|
632
|
+
* to reindexing for this task.
|
|
633
|
+
*/
|
|
634
|
+
export function assessAffectedNeighborhood(input) {
|
|
635
|
+
const { graph, seedMapping, task } = input;
|
|
636
|
+
const warnings = [];
|
|
637
|
+
const warn = (code, message) => {
|
|
638
|
+
warnings.push({ code, message });
|
|
639
|
+
};
|
|
640
|
+
const graphUsable = graph.status !== "unavailable" && graph.codeGraph !== null;
|
|
641
|
+
const neighborhoodUsable = graphUsable && seedMapping.status !== "unavailable";
|
|
642
|
+
let neighborhoodStatus = "unavailable";
|
|
643
|
+
let affectedNodeIds = [];
|
|
644
|
+
let participatingEdgeIds = [];
|
|
645
|
+
if (neighborhoodUsable) {
|
|
646
|
+
({ affectedNodeIds, participatingEdgeIds } = traverseAffectedNeighborhood(graph, seedMapping.seedNodeIds));
|
|
647
|
+
neighborhoodStatus = seedMapping.status === "complete" && graph.status === "complete" ? "complete" : "partial";
|
|
648
|
+
}
|
|
649
|
+
const taskMapping = mapAffectedNeighborhoodTask({ graph, expectedFiles: task.expectedFiles, expectedSymbols: task.expectedSymbols });
|
|
650
|
+
const overlapEvaluable = neighborhoodStatus !== "unavailable" && taskMapping.status !== "unavailable";
|
|
651
|
+
let taskOverlapNodeIds = [];
|
|
652
|
+
let taskOverlapCount = null;
|
|
653
|
+
let taskOverlapPercent = null;
|
|
654
|
+
if (overlapEvaluable) {
|
|
655
|
+
const affected = new Set(affectedNodeIds);
|
|
656
|
+
taskOverlapNodeIds = taskMapping.resolvedTaskNodeIds.filter((id) => affected.has(id));
|
|
657
|
+
taskOverlapCount = taskOverlapNodeIds.length;
|
|
658
|
+
taskOverlapPercent = taskMapping.resolvableTaskNodeCount > 0 ? (taskOverlapCount / taskMapping.resolvableTaskNodeCount) * 100 : null;
|
|
659
|
+
}
|
|
660
|
+
const allComplete = seedMapping.status === "complete" && neighborhoodStatus === "complete" && taskMapping.status === "complete";
|
|
661
|
+
let relationship;
|
|
662
|
+
if (taskOverlapCount !== null && taskOverlapCount > 0) {
|
|
663
|
+
relationship = "related";
|
|
664
|
+
}
|
|
665
|
+
else if (taskOverlapCount === 0 && allComplete) {
|
|
666
|
+
relationship = "unrelated";
|
|
667
|
+
}
|
|
668
|
+
else {
|
|
669
|
+
relationship = "unknown";
|
|
670
|
+
}
|
|
671
|
+
const reindexRecommendation = relationship === "related" ? "recommended" : relationship === "unrelated" ? "not-indicated" : "unknown";
|
|
672
|
+
if (seedMapping.status === "unavailable")
|
|
673
|
+
warn("seed-mapping-unavailable", "Seed mapping is unavailable; no neighborhood was established.");
|
|
674
|
+
else if (seedMapping.status === "partial")
|
|
675
|
+
warn("seed-mapping-partial", "Seed mapping is partial; only resolved seeds were traversed.");
|
|
676
|
+
if (!graphUsable)
|
|
677
|
+
warn("graph-evidence-unavailable", "Baseline graph evidence is not usable for traversal.");
|
|
678
|
+
else if (graph.status === "partial")
|
|
679
|
+
warn("graph-evidence-partial", "Baseline graph evidence is partial; only validated nodes and edges were used.");
|
|
680
|
+
if (taskMapping.status === "unavailable")
|
|
681
|
+
warn("task-mapping-unavailable", "No task node could be resolved from the expected files and symbols.");
|
|
682
|
+
else if (taskMapping.status === "partial")
|
|
683
|
+
warn("task-mapping-partial", "Some expected files or symbols are unresolved or ambiguous; overlap covers resolvable task nodes only.");
|
|
684
|
+
if (relationship === "unknown" && taskOverlapCount === 0) {
|
|
685
|
+
warn("zero-overlap-under-incomplete-evidence", "No overlap was observed, but incomplete evidence cannot prove the task unrelated.");
|
|
686
|
+
}
|
|
687
|
+
warnings.sort((left, right) => compareCodeUnits(left.code, right.code));
|
|
688
|
+
return {
|
|
689
|
+
schemaVersion: AFFECTED_NEIGHBORHOOD_ASSESSMENT_SCHEMA_VERSION,
|
|
690
|
+
status: allComplete ? "complete" : overlapEvaluable ? "partial" : "unavailable",
|
|
691
|
+
freshnessStatus: seedMapping.freshnessStatus,
|
|
692
|
+
seedMappingStatus: seedMapping.status,
|
|
693
|
+
graphEvidenceStatus: graph.status,
|
|
694
|
+
neighborhoodStatus,
|
|
695
|
+
changedFileCount: seedMapping.changedFileCount,
|
|
696
|
+
changedSymbolCount: seedMapping.changedSymbolCount,
|
|
697
|
+
seedNodeCount: seedMapping.seedNodeCount,
|
|
698
|
+
affectedNodeCount: neighborhoodUsable ? affectedNodeIds.length : null,
|
|
699
|
+
affectedEdgeCount: neighborhoodUsable ? participatingEdgeIds.length : null,
|
|
700
|
+
affectedNodeIds,
|
|
701
|
+
participatingEdgeIds,
|
|
702
|
+
taskMapping,
|
|
703
|
+
taskOverlapCount,
|
|
704
|
+
taskOverlapNodeIds,
|
|
705
|
+
taskOverlapPercent,
|
|
706
|
+
relationship,
|
|
707
|
+
reindexRecommendation,
|
|
708
|
+
warningCount: warnings.length,
|
|
709
|
+
warnings: warnings.slice(0, MAX_WARNINGS),
|
|
710
|
+
warningsTruncated: warnings.length > MAX_WARNINGS,
|
|
711
|
+
};
|
|
712
|
+
}
|