@holmes-lab/holmes-kit 0.19.6 → 0.20.0
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 +96 -0
- package/README.md +8 -1
- package/dist/.build-id +1 -1
- package/dist/holmes/cli/approve-status.d.ts +24 -0
- package/dist/holmes/cli/approve-status.js +166 -0
- package/dist/holmes/cli/approve-watch.d.ts +6 -0
- package/dist/holmes/cli/approve-watch.js +6 -0
- package/dist/holmes/cli/approve.d.ts +27 -0
- package/dist/holmes/cli/approve.js +68 -7
- package/dist/holmes/cli/cli-execution.d.ts +2 -0
- package/dist/holmes/cli/cli-execution.js +24 -0
- package/dist/holmes/cli/gitignore-merge.d.ts +0 -5
- package/dist/holmes/cli/gitignore-merge.js +6 -2
- package/dist/holmes/cli/index.js +176 -139
- package/dist/holmes/cli/playbook-skills.d.ts +2 -22
- package/dist/holmes/cli/playbook-skills.js +10 -54
- package/dist/holmes/governance/approval-grants.d.ts +22 -0
- package/dist/holmes/governance/approval-grants.js +55 -1
- package/dist/holmes/governance/approval-queue.d.ts +43 -4
- package/dist/holmes/governance/approval-queue.js +86 -12
- package/dist/holmes/governance/display-id-aliases.d.ts +29 -0
- package/dist/holmes/governance/display-id-aliases.js +86 -0
- package/dist/holmes/governance/ledger-store.d.ts +42 -14
- package/dist/holmes/governance/ledger-store.js +49 -3
- package/dist/holmes/governance/ledger-timeline.d.ts +3 -1
- package/dist/holmes/governance/ledger-timeline.js +11 -1
- package/dist/holmes/governance/provenance-chain.d.ts +2 -0
- package/dist/holmes/governance/provenance-chain.js +13 -2
- package/dist/holmes/governance/provenance-ledger.d.ts +28 -0
- package/dist/holmes/governance/provenance-ledger.js +69 -16
- package/dist/holmes/governance/session-context.d.ts +3 -0
- package/dist/holmes/governance/session-context.js +26 -8
- package/dist/holmes/guardrail/risk-classifier.js +2 -2
- package/dist/holmes/guardrail/write-target.js +2 -1
- package/dist/holmes/hooks/pre-tool-use.js +12 -1
- package/dist/holmes/hooks/stop.js +14 -3
- package/dist/holmes/mcp/handlers/adoption.d.ts +118 -0
- package/dist/holmes/mcp/handlers/adoption.js +186 -0
- package/dist/holmes/mcp/handlers/entity-integration.d.ts +100 -0
- package/dist/holmes/mcp/handlers/entity-integration.js +324 -0
- package/dist/holmes/mcp/handlers/entity-renumber.d.ts +137 -0
- package/dist/holmes/mcp/handlers/entity-renumber.js +228 -0
- package/dist/holmes/mcp/handlers/entity-store.d.ts +78 -0
- package/dist/holmes/mcp/handlers/entity-store.js +212 -0
- package/dist/holmes/mcp/handlers/graph-operations.d.ts +90 -0
- package/dist/holmes/mcp/handlers/graph-operations.js +260 -0
- package/dist/holmes/mcp/handlers/maintenance-analysis.d.ts +34 -0
- package/dist/holmes/mcp/handlers/maintenance-analysis.js +288 -0
- package/dist/holmes/mcp/handlers/maintenance-evidence.d.ts +40 -0
- package/dist/holmes/mcp/handlers/maintenance-evidence.js +104 -0
- package/dist/holmes/mcp/handlers/operator-inspection.d.ts +167 -0
- package/dist/holmes/mcp/handlers/operator-inspection.js +284 -0
- package/dist/holmes/mcp/handlers/phase-queries.d.ts +34 -0
- package/dist/holmes/mcp/handlers/phase-queries.js +86 -0
- package/dist/holmes/mcp/handlers/review-evidence.d.ts +47 -0
- package/dist/holmes/mcp/handlers/review-evidence.js +370 -0
- package/dist/holmes/mcp/handlers/review-queries.d.ts +68 -0
- package/dist/holmes/mcp/handlers/review-queries.js +79 -0
- package/dist/holmes/mcp/handlers/risk-assessment.d.ts +25 -0
- package/dist/holmes/mcp/handlers/risk-assessment.js +181 -0
- package/dist/holmes/mcp/handlers/slice-orchestration.d.ts +55 -0
- package/dist/holmes/mcp/handlers/slice-orchestration.js +324 -0
- package/dist/holmes/mcp/handlers/spec-approval.d.ts +105 -0
- package/dist/holmes/mcp/handlers/spec-approval.js +504 -0
- package/dist/holmes/mcp/handlers/spec-authoring.d.ts +57 -0
- package/dist/holmes/mcp/handlers/spec-authoring.js +326 -0
- package/dist/holmes/mcp/handlers/spec-lifecycle.d.ts +122 -0
- package/dist/holmes/mcp/handlers/spec-lifecycle.js +339 -0
- package/dist/holmes/mcp/handlers/spec-queries.d.ts +30 -0
- package/dist/holmes/mcp/handlers/spec-queries.js +74 -0
- package/dist/holmes/mcp/handlers/taint-screening.d.ts +48 -0
- package/dist/holmes/mcp/handlers/taint-screening.js +183 -0
- package/dist/holmes/mcp/handlers/test-execution.d.ts +63 -0
- package/dist/holmes/mcp/handlers/test-execution.js +210 -0
- package/dist/holmes/mcp/handlers/workspace-identity.d.ts +30 -0
- package/dist/holmes/mcp/handlers/workspace-identity.js +68 -0
- package/dist/holmes/mcp/handlers/workspace-queries.d.ts +77 -0
- package/dist/holmes/mcp/handlers/workspace-queries.js +193 -0
- package/dist/holmes/mcp/handlers.d.ts +653 -424
- package/dist/holmes/mcp/handlers.js +267 -3250
- package/dist/holmes/mcp/server.js +2 -1
- package/dist/holmes/mcp/tool-schemas.js +43 -2
- package/dist/holmes/project/execution-context.d.ts +17 -0
- package/dist/holmes/project/execution-context.js +120 -0
- package/dist/holmes/project/installer-markers.d.ts +34 -0
- package/dist/holmes/project/installer-markers.js +65 -0
- package/dist/holmes/project/root.d.ts +3 -1
- package/dist/holmes/project/root.js +4 -3
- package/dist/holmes/project/workspace-identity.d.ts +29 -0
- package/dist/holmes/project/workspace-identity.js +181 -0
- package/dist/holmes/review/test-outcomes.d.ts +6 -1
- package/dist/holmes/review/test-outcomes.js +23 -5
- package/dist/holmes/review/test-runner.d.ts +18 -0
- package/dist/holmes/review/test-runner.js +132 -4
- package/dist/holmes/rtm/graph-store.d.ts +50 -1
- package/dist/holmes/rtm/graph-store.js +244 -3
- package/dist/holmes/rtm/incremental.d.ts +1 -0
- package/dist/holmes/rtm/incremental.js +12 -3
- package/dist/holmes/rtm/localize.js +7 -0
- package/dist/holmes/rtm/rtm-graph.d.ts +9 -0
- package/dist/holmes/rtm/rtm-graph.js +13 -0
- package/dist/holmes/semantic/vector-cache.d.ts +16 -1
- package/dist/holmes/semantic/vector-cache.js +155 -19
- package/dist/holmes/spec/approval-status.d.ts +10 -0
- package/dist/holmes/spec/approval-status.js +7 -3
- package/dist/holmes/spec/entity-git-snapshot.d.ts +50 -0
- package/dist/holmes/spec/entity-git-snapshot.js +276 -0
- package/dist/holmes/spec/entity-integration-discharge.d.ts +73 -0
- package/dist/holmes/spec/entity-integration-discharge.js +233 -0
- package/dist/holmes/spec/entity-integration-transaction.d.ts +179 -0
- package/dist/holmes/spec/entity-integration-transaction.js +1221 -0
- package/dist/holmes/spec/entity-integration.d.ts +207 -0
- package/dist/holmes/spec/entity-integration.js +747 -0
- package/dist/holmes/spec/entity-renumber-transaction.d.ts +82 -0
- package/dist/holmes/spec/entity-renumber-transaction.js +711 -0
- package/dist/holmes/spec/entity-renumber.d.ts +32 -0
- package/dist/holmes/spec/entity-renumber.js +156 -0
- package/dist/holmes/spec/entity-store.d.ts +135 -0
- package/dist/holmes/spec/entity-store.js +1051 -0
- package/dist/holmes/spec/entity-transaction.d.ts +89 -0
- package/dist/holmes/spec/entity-transaction.js +701 -0
- package/dist/holmes/spec/renumber.d.ts +58 -0
- package/dist/holmes/spec/renumber.js +200 -1
- package/dist/holmes/spec/spec-store.d.ts +3 -2
- package/dist/holmes/spec/spec-store.js +23 -2
- package/dist/holmes/spec/transition-policy.d.ts +36 -0
- package/dist/holmes/spec/transition-policy.js +135 -0
- package/package.json +1 -1
|
@@ -40,7 +40,9 @@ exports.appendOutcomes = appendOutcomes;
|
|
|
40
40
|
exports.readOutcomes = readOutcomes;
|
|
41
41
|
exports.buildOutcomeRecords = buildOutcomeRecords;
|
|
42
42
|
exports.groupOutcomesByAspec = groupOutcomesByAspec;
|
|
43
|
-
// @implements A-SPEC-534.3
|
|
43
|
+
// @implements A-SPEC-534.3, A-SPEC-625
|
|
44
|
+
// @implements A-SPEC-645
|
|
45
|
+
const execution_context_1 = require("../project/execution-context");
|
|
44
46
|
const fs = __importStar(require("node:fs"));
|
|
45
47
|
const path = __importStar(require("node:path"));
|
|
46
48
|
const replica_id_1 = require("../governance/replica-id");
|
|
@@ -66,9 +68,16 @@ function isOutcomesFilename(name) {
|
|
|
66
68
|
*/
|
|
67
69
|
function appendOutcomes(root, records) {
|
|
68
70
|
try {
|
|
69
|
-
const
|
|
71
|
+
const prepared = records.map(r => {
|
|
72
|
+
const execution = (0, execution_context_1.observationExecution)(root, r);
|
|
73
|
+
return execution ? { ...r, execution } : r;
|
|
74
|
+
});
|
|
75
|
+
const attributed = prepared.find(r => r.execution)?.execution;
|
|
76
|
+
if (attributed && prepared.some(r => !r.execution || r.execution.replicaId !== attributed.replicaId))
|
|
77
|
+
return false;
|
|
78
|
+
const file = path.join(root, '.ax', 'ledger', outcomesFilename(attributed?.replicaId ?? (0, replica_id_1.resolveReplicaId)(root)));
|
|
70
79
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
71
|
-
fs.appendFileSync(file,
|
|
80
|
+
fs.appendFileSync(file, prepared.map((r) => `${JSON.stringify(r)}\n`).join(''));
|
|
72
81
|
return true;
|
|
73
82
|
}
|
|
74
83
|
catch {
|
|
@@ -110,6 +119,11 @@ function readOutcomes(root) {
|
|
|
110
119
|
const r = JSON.parse(s);
|
|
111
120
|
if (r && typeof r === 'object' && typeof r.aspec === 'string' && typeof r.outcome === 'string'
|
|
112
121
|
&& typeof r.ts === 'string' && typeof r.head === 'string') {
|
|
122
|
+
if ('execution' in r) {
|
|
123
|
+
r.execution = (0, execution_context_1.validateExecution)(r.execution);
|
|
124
|
+
if (name !== outcomesFilename(r.execution.replicaId))
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
113
127
|
out.push({ r: r, i: i++ });
|
|
114
128
|
}
|
|
115
129
|
}
|
|
@@ -136,12 +150,16 @@ function buildOutcomeRecords(outcomeByFile, anchors, head, ts) {
|
|
|
136
150
|
* Group outcomes by A-SPEC, keeping only records stamped with the given baseline `head` — a stale
|
|
137
151
|
* record from another commit cannot vouch for the current work (the isFresh discipline). Pure.
|
|
138
152
|
*/
|
|
139
|
-
function groupOutcomesByAspec(records, head
|
|
153
|
+
function groupOutcomesByAspec(records, head,
|
|
154
|
+
/** @implements A-SPEC-645 — maps a record's display id to the id the entity carries now (from the ledger's renames). */
|
|
155
|
+
canonical) {
|
|
140
156
|
const out = {};
|
|
141
157
|
for (const r of records) {
|
|
142
158
|
if (r.head !== head)
|
|
143
159
|
continue;
|
|
144
|
-
|
|
160
|
+
const execution = 'execution' in r ? (0, execution_context_1.validateExecution)(r.execution) : undefined;
|
|
161
|
+
const key = canonical ? canonical(r.aspec) : r.aspec; // A-SPEC-645 — evidence follows the entity
|
|
162
|
+
(out[key] ??= []).push({ outcome: r.outcome, ts: r.ts, ...(execution ? { execution } : {}) });
|
|
145
163
|
}
|
|
146
164
|
return out;
|
|
147
165
|
}
|
|
@@ -106,10 +106,28 @@ export declare function jestEntry(cwd: string): string | null;
|
|
|
106
106
|
*/
|
|
107
107
|
export declare function runJestOutcomes(files: string[], cwd: string): Record<string, TestOutcome>;
|
|
108
108
|
/** Run the pytest half of a plan, taking execution evidence from pytest's built-in JUnit XML. */
|
|
109
|
+
/**
|
|
110
|
+
* @implements A-SPEC-647
|
|
111
|
+
* Per-file RED-first outcome from the same JUnit XML `parseJUnitXmlCounts` reads. Measured 2026-09-14
|
|
112
|
+
* in an external pytest project: a ranged test_run executed the anchored case and recorded NOTHING,
|
|
113
|
+
* because classification was jest-only — a Python project could never produce the red-assertion→green
|
|
114
|
+
* sequence ART-8 reads. `<error>` (collection/import/setup) is a red-error, `<failure>` (an assertion)
|
|
115
|
+
* a red-assertion, executed cases without either are green; a file with only skipped cases says nothing.
|
|
116
|
+
*/
|
|
117
|
+
export declare function parseJUnitXmlOutcomes(xml: string, ext?: string): Record<string, TestOutcome>;
|
|
118
|
+
/**
|
|
119
|
+
* @implements A-SPEC-647
|
|
120
|
+
* The ecosystems a project's OWN test files belong to, by file-name convention, under a bounded walk
|
|
121
|
+
* that skips vendored trees and virtualenvs. Measured: a full-tier run with no impacted list assumed
|
|
122
|
+
* the jest suite on a pytest-only project, ran nothing and reported passed:false. This repository's
|
|
123
|
+
* Python tests live under `reference/`, so it detects jest only and its runs are unchanged.
|
|
124
|
+
*/
|
|
125
|
+
export declare function detectProjectEcosystems(cwd: string): Ecosystem[];
|
|
109
126
|
export declare function runPytest(files: string[], mode: TestRunPlan['mode'], cwd: string): {
|
|
110
127
|
passed: boolean;
|
|
111
128
|
tail: string;
|
|
112
129
|
executed: Record<string, number>;
|
|
130
|
+
outcomes: Record<string, TestOutcome>;
|
|
113
131
|
};
|
|
114
132
|
/**
|
|
115
133
|
* @implements A-SPEC-137.2
|
|
@@ -42,6 +42,8 @@ exports.classifyJestOutcomes = classifyJestOutcomes;
|
|
|
42
42
|
exports.parseJUnitXmlCounts = parseJUnitXmlCounts;
|
|
43
43
|
exports.jestEntry = jestEntry;
|
|
44
44
|
exports.runJestOutcomes = runJestOutcomes;
|
|
45
|
+
exports.parseJUnitXmlOutcomes = parseJUnitXmlOutcomes;
|
|
46
|
+
exports.detectProjectEcosystems = detectProjectEcosystems;
|
|
45
47
|
exports.runPytest = runPytest;
|
|
46
48
|
exports.parseCargoTest = parseCargoTest;
|
|
47
49
|
exports.runCargo = runCargo;
|
|
@@ -53,6 +55,7 @@ exports.tailOf = tailOf;
|
|
|
53
55
|
exports.failedTestSummary = failedTestSummary;
|
|
54
56
|
exports.summarizeJestJson = summarizeJestJson;
|
|
55
57
|
// @implements A-SPEC-102.1
|
|
58
|
+
// @implements A-SPEC-647
|
|
56
59
|
const node_child_process_1 = require("node:child_process");
|
|
57
60
|
const fs = __importStar(require("node:fs"));
|
|
58
61
|
const os = __importStar(require("node:os"));
|
|
@@ -325,6 +328,105 @@ function runJestOutcomes(files, cwd) {
|
|
|
325
328
|
}
|
|
326
329
|
/** Run the pytest half of a plan, taking execution evidence from pytest's built-in JUnit XML. */
|
|
327
330
|
// @implements A-SPEC-502.1 — exported for the wiring test (a fake venv python capturing argv).
|
|
331
|
+
/**
|
|
332
|
+
* @implements A-SPEC-647
|
|
333
|
+
* Per-file RED-first outcome from the same JUnit XML `parseJUnitXmlCounts` reads. Measured 2026-09-14
|
|
334
|
+
* in an external pytest project: a ranged test_run executed the anchored case and recorded NOTHING,
|
|
335
|
+
* because classification was jest-only — a Python project could never produce the red-assertion→green
|
|
336
|
+
* sequence ART-8 reads. `<error>` (collection/import/setup) is a red-error, `<failure>` (an assertion)
|
|
337
|
+
* a red-assertion, executed cases without either are green; a file with only skipped cases says nothing.
|
|
338
|
+
*/
|
|
339
|
+
function parseJUnitXmlOutcomes(xml, ext = 'py') {
|
|
340
|
+
const flags = new Map();
|
|
341
|
+
for (const m of xml.matchAll(/<testcase\b([^>]*?)(\/>|>([\s\S]*?)<\/testcase>)/g)) {
|
|
342
|
+
const attrs = m[1] ?? '';
|
|
343
|
+
const body = m[3] ?? '';
|
|
344
|
+
let file = /\bfile="([^"]*)"/.exec(attrs)?.[1];
|
|
345
|
+
if (!file) {
|
|
346
|
+
const cls = /\bclassname="([^"]*)"/.exec(attrs)?.[1];
|
|
347
|
+
if (!cls)
|
|
348
|
+
continue;
|
|
349
|
+
const parts = cls.split('.').filter(Boolean);
|
|
350
|
+
if (ext === 'py') {
|
|
351
|
+
while (parts.length > 1 && /^[A-Z]/.test(parts[parts.length - 1]))
|
|
352
|
+
parts.pop();
|
|
353
|
+
}
|
|
354
|
+
if (parts.length === 0)
|
|
355
|
+
continue;
|
|
356
|
+
file = `${parts.join('/')}.${ext}`;
|
|
357
|
+
}
|
|
358
|
+
const key = file.split('\\').join('/');
|
|
359
|
+
const f = flags.get(key) ?? { error: false, failure: false, executed: false };
|
|
360
|
+
if (/<error\b/.test(body))
|
|
361
|
+
f.error = true;
|
|
362
|
+
else if (/<failure\b/.test(body))
|
|
363
|
+
f.failure = true;
|
|
364
|
+
if (!/<skipped\b/.test(body))
|
|
365
|
+
f.executed = true;
|
|
366
|
+
flags.set(key, f);
|
|
367
|
+
}
|
|
368
|
+
const out = {};
|
|
369
|
+
for (const [file, f] of flags) {
|
|
370
|
+
if (f.error)
|
|
371
|
+
out[file] = 'red-error';
|
|
372
|
+
else if (f.failure)
|
|
373
|
+
out[file] = 'red-assertion';
|
|
374
|
+
else if (f.executed)
|
|
375
|
+
out[file] = 'green';
|
|
376
|
+
}
|
|
377
|
+
return out;
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* @implements A-SPEC-647
|
|
381
|
+
* The ecosystems a project's OWN test files belong to, by file-name convention, under a bounded walk
|
|
382
|
+
* that skips vendored trees and virtualenvs. Measured: a full-tier run with no impacted list assumed
|
|
383
|
+
* the jest suite on a pytest-only project, ran nothing and reported passed:false. This repository's
|
|
384
|
+
* Python tests live under `reference/`, so it detects jest only and its runs are unchanged.
|
|
385
|
+
*/
|
|
386
|
+
function detectProjectEcosystems(cwd) {
|
|
387
|
+
const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'dist-tools', 'build', 'vendor', 'third_party', 'reference']);
|
|
388
|
+
const found = new Set();
|
|
389
|
+
const walk = (dir, depth) => {
|
|
390
|
+
if (depth > 6)
|
|
391
|
+
return;
|
|
392
|
+
let entries;
|
|
393
|
+
try {
|
|
394
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
395
|
+
}
|
|
396
|
+
catch {
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
if (entries.some((e) => e.isFile() && e.name === 'pyvenv.cfg'))
|
|
400
|
+
return; // a virtualenv is not the project
|
|
401
|
+
for (const e of entries) {
|
|
402
|
+
const name = e.name;
|
|
403
|
+
if (e.isDirectory()) {
|
|
404
|
+
if (SKIP_DIRS.has(name) || (name.startsWith('.') && name !== '.'))
|
|
405
|
+
continue;
|
|
406
|
+
if (name === '__tests__')
|
|
407
|
+
found.add('jest');
|
|
408
|
+
walk(path.join(dir, name), depth + 1);
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
if (!e.isFile())
|
|
412
|
+
continue;
|
|
413
|
+
if (/\.(test|spec)\.(ts|tsx|js|jsx|mjs|cjs|mts|cts)$/.test(name))
|
|
414
|
+
found.add('jest');
|
|
415
|
+
else if (/^(test_[^/]*|[^/]*_test)\.py$/.test(name) || name === 'conftest.py')
|
|
416
|
+
found.add('pytest');
|
|
417
|
+
else if (/_test\.go$/.test(name))
|
|
418
|
+
found.add('go');
|
|
419
|
+
else if (name === 'Cargo.toml')
|
|
420
|
+
found.add('rust');
|
|
421
|
+
else if (name === 'build.gradle' || name === 'build.gradle.kts')
|
|
422
|
+
found.add('java');
|
|
423
|
+
else if (/\.csproj$/.test(name))
|
|
424
|
+
found.add('dotnet');
|
|
425
|
+
}
|
|
426
|
+
};
|
|
427
|
+
walk(cwd, 0);
|
|
428
|
+
return [...found].sort();
|
|
429
|
+
}
|
|
328
430
|
function runPytest(files, mode, cwd) {
|
|
329
431
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'holmes-pytest-'));
|
|
330
432
|
const report = path.join(dir, 'report.xml');
|
|
@@ -351,14 +453,35 @@ function runPytest(files, mode, cwd) {
|
|
|
351
453
|
return {};
|
|
352
454
|
}
|
|
353
455
|
};
|
|
456
|
+
// @implements A-SPEC-647 — per-file outcomes from the same report; in a scoped run a planned file the
|
|
457
|
+
// failed run never reached (no case at all — a collection error before its cases) is a red-error, so a
|
|
458
|
+
// RED that never ran is not mistaken for evidence of nothing.
|
|
459
|
+
const readOutcomes = (passed) => {
|
|
460
|
+
let outcomes = {};
|
|
461
|
+
try {
|
|
462
|
+
outcomes = parseJUnitXmlOutcomes(fs.readFileSync(report, 'utf8'));
|
|
463
|
+
}
|
|
464
|
+
catch {
|
|
465
|
+
outcomes = {};
|
|
466
|
+
}
|
|
467
|
+
if (mode === 'scoped' && !passed) {
|
|
468
|
+
for (const f of files) {
|
|
469
|
+
const key = f.split('\\').join('/');
|
|
470
|
+
if (path.basename(key) === 'conftest.py' || outcomes[key])
|
|
471
|
+
continue;
|
|
472
|
+
outcomes[key] = 'red-error';
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
return outcomes;
|
|
476
|
+
};
|
|
354
477
|
try {
|
|
355
478
|
const out = (0, node_child_process_1.execFileSync)(python, args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: RUNNER_MAX_BUFFER, env: (0, root_1.cleanTestEnv)() });
|
|
356
|
-
return { passed: true, tail: tailOf(out), executed: read() };
|
|
479
|
+
return { passed: true, tail: tailOf(out), executed: read(), outcomes: readOutcomes(true) };
|
|
357
480
|
}
|
|
358
481
|
catch (e) {
|
|
359
482
|
const err = e;
|
|
360
483
|
// A red pytest run still writes the XML, so execution evidence survives — same property as jest.
|
|
361
|
-
return { passed: false, tail: tailOf(`${err.stdout ?? ''}\n${err.stderr ?? err.message ?? ''}`), executed: read() };
|
|
484
|
+
return { passed: false, tail: tailOf(`${err.stdout ?? ''}\n${err.stderr ?? err.message ?? ''}`), executed: read(), outcomes: readOutcomes(false) };
|
|
362
485
|
}
|
|
363
486
|
finally {
|
|
364
487
|
try {
|
|
@@ -650,8 +773,13 @@ function runTestScope(scope, cwd) {
|
|
|
650
773
|
// to a caller that only sees passed=true.
|
|
651
774
|
let assumedJest = false;
|
|
652
775
|
if (plan.mode === 'full' && groups.size === 0) {
|
|
653
|
-
|
|
654
|
-
|
|
776
|
+
// @implements A-SPEC-647 — run what the project HAS; assume jest only when nothing is detected.
|
|
777
|
+
for (const eco of detectProjectEcosystems(cwd))
|
|
778
|
+
groups.set(eco, []);
|
|
779
|
+
if (groups.size === 0) {
|
|
780
|
+
groups.set('jest', []);
|
|
781
|
+
assumedJest = true;
|
|
782
|
+
}
|
|
655
783
|
}
|
|
656
784
|
if (groups.size > 0) {
|
|
657
785
|
const runners = { jest: runJest, pytest: runPytest, go: runGo, rust: runCargo, java: runGradle, dotnet: runDotnet };
|
|
@@ -42,6 +42,23 @@ export interface OpenedGraph {
|
|
|
42
42
|
reason: string | null;
|
|
43
43
|
/** Record the basis for this graph's CURRENT contents. Call after building. */
|
|
44
44
|
commitBasis(): void;
|
|
45
|
+
/**
|
|
46
|
+
* @implements A-SPEC-631 — finish with the graph. In `publishable` mode a non-reusable graph
|
|
47
|
+
* lives in a private temporary database: `publish()` closes it and replaces the live file by one
|
|
48
|
+
* atomic rename; `discard()` closes it and removes the temporary file. In the default in-place
|
|
49
|
+
* mode both simply close the handle. Exactly one of them is called, exactly once.
|
|
50
|
+
*/
|
|
51
|
+
publish(): void;
|
|
52
|
+
discard(): void;
|
|
53
|
+
}
|
|
54
|
+
export interface ReuseOptions {
|
|
55
|
+
/**
|
|
56
|
+
* @implements A-SPEC-631 — never touch the live file: when the stored graph cannot be reused,
|
|
57
|
+
* hand back an EMPTY graph in a private temporary database beside it instead of clearing the live
|
|
58
|
+
* file in place (which let concurrent readers see zero nodes and a killed rebuild leave an empty,
|
|
59
|
+
* basis-less file). The caller builds into it, `commitBasis()`, then `publish()`.
|
|
60
|
+
*/
|
|
61
|
+
publishable?: boolean;
|
|
45
62
|
}
|
|
46
63
|
/**
|
|
47
64
|
* Open the graph at `dbPath`, reusing its contents only if `expected` still holds.
|
|
@@ -50,4 +67,36 @@ export interface OpenedGraph {
|
|
|
50
67
|
* to fail: the caller always receives a usable graph, empty when the stored one could not be
|
|
51
68
|
* trusted, so "the cache is broken" degrades to "this call is slower" rather than to an outage.
|
|
52
69
|
*/
|
|
53
|
-
export declare function openReusableGraph(dbPath: string, expected: GraphBasis): OpenedGraph;
|
|
70
|
+
export declare function openReusableGraph(dbPath: string, expected: GraphBasis, opts?: ReuseOptions): OpenedGraph;
|
|
71
|
+
/** Age after which a `.tmp-*` / `.bak-*` sibling of the live graph is treated as abandoned by a dead publisher. */
|
|
72
|
+
export declare const GRAPH_STALE_MS: number;
|
|
73
|
+
export type PublishedGraph = {
|
|
74
|
+
status: 'absent' | 'unreadable' | 'empty';
|
|
75
|
+
graph: null;
|
|
76
|
+
graphAsOf?: string;
|
|
77
|
+
} | {
|
|
78
|
+
status: 'current' | 'stale' | 'unverified';
|
|
79
|
+
graph: RtmGraph;
|
|
80
|
+
sourceCommit: string | null;
|
|
81
|
+
head: string | null;
|
|
82
|
+
graphAsOf?: string;
|
|
83
|
+
};
|
|
84
|
+
/**
|
|
85
|
+
* Build a complete graph in a private temporary database, commit `basis`, close it, and replace
|
|
86
|
+
* the live file atomically. The live file is never opened for writing and never cleared; a build
|
|
87
|
+
* that throws leaves it untouched and removes the temporary file. Returns the published counts.
|
|
88
|
+
*/
|
|
89
|
+
export declare function publishGraph(livePath: string, basis: GraphBasis, build: (graph: RtmGraph) => void): {
|
|
90
|
+
nodes: number;
|
|
91
|
+
edges: number;
|
|
92
|
+
};
|
|
93
|
+
/**
|
|
94
|
+
* Open the live graph WITHOUT mutating it and say what it is: absent, unreadable (open or metadata
|
|
95
|
+
* failed — the file is left for the next publication to replace, never deleted here), empty (a
|
|
96
|
+
* schema with no nodes — the state an interrupted in-place rebuild used to leave behind), or a
|
|
97
|
+
* served graph whose basis commit is current / stale against `head`, or unverified when either side
|
|
98
|
+
* is unknown. Served statuses return an OPEN handle the caller must close.
|
|
99
|
+
*/
|
|
100
|
+
export declare function openPublishedGraph(livePath: string, head: string | null): PublishedGraph;
|
|
101
|
+
/** HEAD of the repository at `root`, or null outside Git — the only freshness input a consumer pays for (no scan). */
|
|
102
|
+
export declare function headCommitOf(root: string, env?: NodeJS.ProcessEnv): string | null;
|
|
@@ -33,13 +33,19 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.GRAPH_STALE_MS = void 0;
|
|
36
37
|
exports.specFingerprint = specFingerprint;
|
|
37
38
|
exports.scanDigest = scanDigest;
|
|
38
39
|
exports.openReusableGraph = openReusableGraph;
|
|
40
|
+
exports.publishGraph = publishGraph;
|
|
41
|
+
exports.openPublishedGraph = openPublishedGraph;
|
|
42
|
+
exports.headCommitOf = headCommitOf;
|
|
39
43
|
// @implements A-SPEC-282
|
|
44
|
+
// @implements A-SPEC-631
|
|
40
45
|
const fs = __importStar(require("node:fs"));
|
|
41
46
|
const path = __importStar(require("node:path"));
|
|
42
47
|
const node_crypto_1 = require("node:crypto");
|
|
48
|
+
const node_child_process_1 = require("node:child_process");
|
|
43
49
|
const rtm_graph_1 = require("./rtm-graph");
|
|
44
50
|
const BASIS_FIELDS = [
|
|
45
51
|
'graphSchema', 'extractorVersion', 'sourceCommit', 'specFingerprint', 'scanDigest',
|
|
@@ -88,8 +94,10 @@ function readStoredBasis(graph) {
|
|
|
88
94
|
* to fail: the caller always receives a usable graph, empty when the stored one could not be
|
|
89
95
|
* trusted, so "the cache is broken" degrades to "this call is slower" rather than to an outage.
|
|
90
96
|
*/
|
|
91
|
-
function openReusableGraph(dbPath, expected) {
|
|
97
|
+
function openReusableGraph(dbPath, expected, opts = {}) {
|
|
92
98
|
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
|
|
99
|
+
if (opts.publishable)
|
|
100
|
+
return openPublishable(dbPath, expected);
|
|
93
101
|
let graph;
|
|
94
102
|
let reason = null;
|
|
95
103
|
try {
|
|
@@ -126,11 +134,244 @@ function openReusableGraph(dbPath, expected) {
|
|
|
126
134
|
const commitBasis = () => {
|
|
127
135
|
graph.writeMeta(Object.fromEntries(BASIS_FIELDS.map((f) => [f, expected[f]])));
|
|
128
136
|
};
|
|
137
|
+
const finish = () => graph.close();
|
|
129
138
|
if (reason) {
|
|
130
139
|
// Not reusable means EMPTY. Handing back a partially-stale graph would be worse than no cache:
|
|
131
140
|
// the caller would build on top of facts whose basis it has just been told it cannot trust.
|
|
132
141
|
graph.clear();
|
|
133
|
-
return { graph, reusable: false, reason, commitBasis };
|
|
142
|
+
return { graph, reusable: false, reason, commitBasis, publish: finish, discard: finish };
|
|
143
|
+
}
|
|
144
|
+
return { graph, reusable: true, reason: null, commitBasis, publish: finish, discard: finish };
|
|
145
|
+
}
|
|
146
|
+
// ---------------------------------------------------------------------------------------------
|
|
147
|
+
// @implements A-SPEC-631 — publication and inspection of the LIVE graph.
|
|
148
|
+
//
|
|
149
|
+
// Before this, rtm_impact (and therefore every Stop-hook refresh) opened `.ax/rtm.sqlite` through
|
|
150
|
+
// openReusableGraph, which CLEARS the file in place when the basis moved and rebuilds into it. Any
|
|
151
|
+
// reader in that window — an approval computing its impact advisory — saw zero nodes and reported
|
|
152
|
+
// "no impact"; a refresher killed after the clear left the file empty and basis-less for everyone.
|
|
153
|
+
// Reproduced with the built product on 2026-09-13. The live file therefore changes only by rename
|
|
154
|
+
// of a complete, basis-committed temporary database, and readers classify what they find.
|
|
155
|
+
// ---------------------------------------------------------------------------------------------
|
|
156
|
+
/** Age after which a `.tmp-*` / `.bak-*` sibling of the live graph is treated as abandoned by a dead publisher. */
|
|
157
|
+
exports.GRAPH_STALE_MS = 10 * 60_000;
|
|
158
|
+
const removeQuietly = (p) => { try {
|
|
159
|
+
fs.rmSync(p, { force: true });
|
|
160
|
+
}
|
|
161
|
+
catch { /* best effort */ } };
|
|
162
|
+
const uniqueSibling = (livePath, kind) => `${livePath}.${kind}-${process.pid}-${(0, node_crypto_1.randomBytes)(6).toString('hex')}`;
|
|
163
|
+
/**
|
|
164
|
+
* Replace the live file with the finished temporary database. One rename is atomic on POSIX and
|
|
165
|
+
* replaces a closed file on Windows. When the live file is held open elsewhere on Windows the
|
|
166
|
+
* direct rename is refused (EPERM/EBUSY/EACCES); only then fall back to the rename-aside sequence,
|
|
167
|
+
* where an ENOENT on the aside step means a sibling publisher already replaced the file and is not
|
|
168
|
+
* an error — executing that sequence unconditionally was what made one of four concurrent reindexes
|
|
169
|
+
* fail before.
|
|
170
|
+
*/
|
|
171
|
+
function replaceLive(tempPath, livePath) {
|
|
172
|
+
try {
|
|
173
|
+
fs.renameSync(tempPath, livePath);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
catch (error) {
|
|
177
|
+
const code = error.code;
|
|
178
|
+
if (code !== 'EPERM' && code !== 'EBUSY' && code !== 'EACCES')
|
|
179
|
+
throw error;
|
|
180
|
+
}
|
|
181
|
+
const backup = uniqueSibling(livePath, 'bak');
|
|
182
|
+
let hadBackup = false;
|
|
183
|
+
try {
|
|
184
|
+
fs.renameSync(livePath, backup);
|
|
185
|
+
hadBackup = true;
|
|
186
|
+
}
|
|
187
|
+
catch (error) {
|
|
188
|
+
if (error.code !== 'ENOENT')
|
|
189
|
+
throw error;
|
|
190
|
+
}
|
|
191
|
+
try {
|
|
192
|
+
fs.renameSync(tempPath, livePath);
|
|
193
|
+
}
|
|
194
|
+
catch (error) {
|
|
195
|
+
if (hadBackup && !fs.existsSync(livePath)) {
|
|
196
|
+
try {
|
|
197
|
+
fs.renameSync(backup, livePath);
|
|
198
|
+
}
|
|
199
|
+
catch { /* preserve the publication error */ }
|
|
200
|
+
}
|
|
201
|
+
throw error;
|
|
202
|
+
}
|
|
203
|
+
if (hadBackup)
|
|
204
|
+
removeQuietly(backup);
|
|
205
|
+
}
|
|
206
|
+
/** Remove `.tmp-*` / `.bak-*` siblings (and their SQLite sidecars) older than the stale bound; younger ones may belong to a live publisher. */
|
|
207
|
+
function sweepAbandoned(livePath) {
|
|
208
|
+
const dir = path.dirname(livePath), base = path.basename(livePath);
|
|
209
|
+
let names;
|
|
210
|
+
try {
|
|
211
|
+
names = fs.readdirSync(dir);
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
for (const name of names) {
|
|
217
|
+
if (!name.startsWith(`${base}.tmp-`) && !name.startsWith(`${base}.bak-`))
|
|
218
|
+
continue;
|
|
219
|
+
const p = path.join(dir, name);
|
|
220
|
+
try {
|
|
221
|
+
if (Date.now() - fs.statSync(p).mtimeMs > exports.GRAPH_STALE_MS)
|
|
222
|
+
fs.rmSync(p, { force: true });
|
|
223
|
+
}
|
|
224
|
+
catch { /* raced with its owner or already gone */ }
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Build a complete graph in a private temporary database, commit `basis`, close it, and replace
|
|
229
|
+
* the live file atomically. The live file is never opened for writing and never cleared; a build
|
|
230
|
+
* that throws leaves it untouched and removes the temporary file. Returns the published counts.
|
|
231
|
+
*/
|
|
232
|
+
function publishGraph(livePath, basis, build) {
|
|
233
|
+
fs.mkdirSync(path.dirname(livePath), { recursive: true });
|
|
234
|
+
const tempPath = uniqueSibling(livePath, 'tmp');
|
|
235
|
+
let nodes = 0, edges = 0;
|
|
236
|
+
try {
|
|
237
|
+
const graph = new rtm_graph_1.RtmGraph(tempPath);
|
|
238
|
+
try {
|
|
239
|
+
build(graph);
|
|
240
|
+
graph.writeMeta(Object.fromEntries(BASIS_FIELDS.map((f) => [f, basis[f]])));
|
|
241
|
+
nodes = graph.nodeCount();
|
|
242
|
+
edges = graph.edgeCount();
|
|
243
|
+
}
|
|
244
|
+
finally {
|
|
245
|
+
graph.close(); // checkpoints WAL and drops the temporary database's sidecars
|
|
246
|
+
}
|
|
247
|
+
replaceLive(tempPath, livePath);
|
|
248
|
+
}
|
|
249
|
+
catch (error) {
|
|
250
|
+
for (const suffix of ['', '-wal', '-shm', '-journal'])
|
|
251
|
+
removeQuietly(tempPath + suffix);
|
|
252
|
+
throw error;
|
|
253
|
+
}
|
|
254
|
+
sweepAbandoned(livePath);
|
|
255
|
+
return { nodes, edges };
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Open the live graph WITHOUT mutating it and say what it is: absent, unreadable (open or metadata
|
|
259
|
+
* failed — the file is left for the next publication to replace, never deleted here), empty (a
|
|
260
|
+
* schema with no nodes — the state an interrupted in-place rebuild used to leave behind), or a
|
|
261
|
+
* served graph whose basis commit is current / stale against `head`, or unverified when either side
|
|
262
|
+
* is unknown. Served statuses return an OPEN handle the caller must close.
|
|
263
|
+
*/
|
|
264
|
+
function openPublishedGraph(livePath, head) {
|
|
265
|
+
let graphAsOf;
|
|
266
|
+
try {
|
|
267
|
+
graphAsOf = fs.statSync(livePath).mtime.toISOString();
|
|
268
|
+
}
|
|
269
|
+
catch {
|
|
270
|
+
return { status: 'absent', graph: null };
|
|
271
|
+
}
|
|
272
|
+
let graph;
|
|
273
|
+
try {
|
|
274
|
+
graph = new rtm_graph_1.RtmGraph(livePath);
|
|
275
|
+
}
|
|
276
|
+
catch {
|
|
277
|
+
return { status: 'unreadable', graph: null, graphAsOf };
|
|
278
|
+
}
|
|
279
|
+
let meta, nodes;
|
|
280
|
+
try {
|
|
281
|
+
meta = graph.readMeta();
|
|
282
|
+
nodes = graph.nodeCount();
|
|
283
|
+
}
|
|
284
|
+
catch {
|
|
285
|
+
try {
|
|
286
|
+
graph.close();
|
|
287
|
+
}
|
|
288
|
+
catch { /* preserve the classification */ }
|
|
289
|
+
return { status: 'unreadable', graph: null, graphAsOf };
|
|
290
|
+
}
|
|
291
|
+
if (nodes === 0) {
|
|
292
|
+
graph.close();
|
|
293
|
+
return { status: 'empty', graph: null, graphAsOf };
|
|
294
|
+
}
|
|
295
|
+
const sourceCommit = typeof meta.sourceCommit === 'string' && meta.sourceCommit !== '' ? meta.sourceCommit : null;
|
|
296
|
+
const status = sourceCommit === null || head === null ? 'unverified' : sourceCommit === head ? 'current' : 'stale';
|
|
297
|
+
return { status, graph, sourceCommit, head, graphAsOf };
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* The `publishable` mode of openReusableGraph. Reads the live file without mutating it; when it
|
|
301
|
+
* cannot be reused (absent, unreadable, no basis, a moved field) the returned graph is an empty
|
|
302
|
+
* private temporary database whose `publish()` replaces the live file atomically. The live file is
|
|
303
|
+
* never created, cleared or deleted here — an unreadable one is left for publication to replace.
|
|
304
|
+
*/
|
|
305
|
+
function openPublishable(livePath, expected) {
|
|
306
|
+
const basisMeta = () => Object.fromEntries(BASIS_FIELDS.map((f) => [f, expected[f]]));
|
|
307
|
+
let live = null;
|
|
308
|
+
let reason = null;
|
|
309
|
+
if (!fs.existsSync(livePath))
|
|
310
|
+
reason = 'no stored graph — nothing to reuse';
|
|
311
|
+
else {
|
|
312
|
+
try {
|
|
313
|
+
live = new rtm_graph_1.RtmGraph(livePath);
|
|
314
|
+
}
|
|
315
|
+
catch {
|
|
316
|
+
reason = 'stored graph was unreadable (left for publication to replace)';
|
|
317
|
+
}
|
|
318
|
+
if (live !== null) {
|
|
319
|
+
const stored = readStoredBasis(live);
|
|
320
|
+
if (!stored)
|
|
321
|
+
reason = 'no stored basis — nothing to reuse';
|
|
322
|
+
else {
|
|
323
|
+
const moved = BASIS_FIELDS.find((field) => stored[field] !== expected[field]);
|
|
324
|
+
if (moved)
|
|
325
|
+
reason = `${moved} changed since the stored graph was built`;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
if (reason === null && live !== null) {
|
|
330
|
+
const handle = live;
|
|
331
|
+
const finish = () => handle.close();
|
|
332
|
+
return { graph: handle, reusable: true, reason: null, commitBasis: () => handle.writeMeta(basisMeta()), publish: finish, discard: finish };
|
|
333
|
+
}
|
|
334
|
+
if (live !== null) {
|
|
335
|
+
try {
|
|
336
|
+
live.close();
|
|
337
|
+
}
|
|
338
|
+
catch { /* the handle is not reused */ }
|
|
339
|
+
}
|
|
340
|
+
const tempPath = uniqueSibling(livePath, 'tmp');
|
|
341
|
+
const graph = new rtm_graph_1.RtmGraph(tempPath);
|
|
342
|
+
const cleanup = () => { for (const suffix of ['', '-wal', '-shm', '-journal'])
|
|
343
|
+
removeQuietly(tempPath + suffix); };
|
|
344
|
+
return {
|
|
345
|
+
graph,
|
|
346
|
+
reusable: false,
|
|
347
|
+
reason,
|
|
348
|
+
commitBasis: () => graph.writeMeta(basisMeta()),
|
|
349
|
+
publish: () => {
|
|
350
|
+
try {
|
|
351
|
+
graph.close();
|
|
352
|
+
replaceLive(tempPath, livePath);
|
|
353
|
+
}
|
|
354
|
+
catch (error) {
|
|
355
|
+
cleanup();
|
|
356
|
+
throw error;
|
|
357
|
+
}
|
|
358
|
+
sweepAbandoned(livePath);
|
|
359
|
+
},
|
|
360
|
+
discard: () => { try {
|
|
361
|
+
graph.close();
|
|
362
|
+
}
|
|
363
|
+
finally {
|
|
364
|
+
cleanup();
|
|
365
|
+
} },
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
/** HEAD of the repository at `root`, or null outside Git — the only freshness input a consumer pays for (no scan). */
|
|
369
|
+
function headCommitOf(root, env = process.env) {
|
|
370
|
+
try {
|
|
371
|
+
const out = (0, node_child_process_1.execFileSync)('git', ['-C', root, 'rev-parse', 'HEAD'], { encoding: 'utf8', env, stdio: ['ignore', 'pipe', 'pipe'] }).trim();
|
|
372
|
+
return /^[0-9a-f]{40}$/.test(out) ? out : null;
|
|
373
|
+
}
|
|
374
|
+
catch {
|
|
375
|
+
return null;
|
|
134
376
|
}
|
|
135
|
-
return { graph, reusable: true, reason: null, commitBasis };
|
|
136
377
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.applyIncremental = applyIncremental;
|
|
4
|
+
// @implements A-SPEC-644
|
|
4
5
|
const rtm_builder_1 = require("./rtm-builder");
|
|
5
6
|
/**
|
|
6
7
|
* Reduces every add/modify/delete/rename case to one primitive: replace file
|
|
@@ -55,13 +56,21 @@ function applyIncremental(graph, changes, ctx) {
|
|
|
55
56
|
replaceFile(f);
|
|
56
57
|
}
|
|
57
58
|
if (!ctx.allScanned)
|
|
58
|
-
return { callEdgesResolved: false };
|
|
59
|
+
return { callEdgesResolved: false, importsResolved: false };
|
|
59
60
|
// @implements A-SPEC-573.5 — `calls_ambiguous` is rebuilt by the same pass, so it must be cleared
|
|
60
61
|
// by the same pass. Missing it left a stale candidate edge behind when an ambiguity RESOLVED
|
|
61
62
|
// (the second definer was deleted), and incremental stopped converging with a full rebuild —
|
|
62
63
|
// caught by A-SPEC-280's convergence property, which is exactly what it is for.
|
|
64
|
+
const scanned = ctx.allScanned();
|
|
63
65
|
graph.removeEdgesByRel('calls');
|
|
64
66
|
graph.removeEdgesByRel('calls_ambiguous');
|
|
65
|
-
(0, rtm_builder_1.addCallEdges)(
|
|
66
|
-
|
|
67
|
+
(0, rtm_builder_1.addCallEdges)(scanned, graph, ctx.buildOptions);
|
|
68
|
+
// @implements A-SPEC-644 — the same whole-scan rule for imports. FILE nodes and `imports` edges are
|
|
69
|
+
// written by `addImportEdges` over the whole scan (A-SPEC-289) and never by the per-file subgraph, so
|
|
70
|
+
// measured 2026-09-14 an update that added an import produced no FILE node and no edge, and one that
|
|
71
|
+
// deleted the import target left a stale FILE node. Clear the kind and the relation, then re-resolve.
|
|
72
|
+
graph.removeEdgesByRel('imports');
|
|
73
|
+
graph.removeNodesByKind('FILE');
|
|
74
|
+
(0, rtm_builder_1.addImportEdges)(scanned, graph, ctx.buildOptions);
|
|
75
|
+
return { callEdgesResolved: true, importsResolved: true };
|
|
67
76
|
}
|
|
@@ -163,6 +163,10 @@ function localizeIssue(issueText, scanned, specs, topN = 10) {
|
|
|
163
163
|
// be recovered lexically, however many terms it has.
|
|
164
164
|
const citations = citationsIn(issueText, new Set(specs.map((s) => s.id)));
|
|
165
165
|
const citedSet = new Set(citations.cited);
|
|
166
|
+
// @implements A-SPEC-619 — explicit architecture-contract evidence is a ranking key,
|
|
167
|
+
// not a bonus that unrelated lexical repetition can dilute out of the result window.
|
|
168
|
+
const priorityCitations = new Set(specs.filter(s => s.type === 'A-SPEC' && citedSet.has(s.id)).map(s => s.id));
|
|
169
|
+
const directlyCitedFiles = new Set();
|
|
166
170
|
if (terms.length === 0 && citations.cited.length === 0) {
|
|
167
171
|
return { terms: [], hits: [], matchedSpecs: [], unknownCitations: citations.unknown };
|
|
168
172
|
}
|
|
@@ -314,6 +318,8 @@ function localizeIssue(issueText, scanned, specs, topN = 10) {
|
|
|
314
318
|
why.push(`implements matched spec(s) ${viaSpecs.join(', ')} (${viaSpecs.length}/${anchors.length} anchors)`);
|
|
315
319
|
}
|
|
316
320
|
const citedAnchors = anchors.filter((id) => citedSet.has(id));
|
|
321
|
+
if (anchors.some(id => priorityCitations.has(id)))
|
|
322
|
+
directlyCitedFiles.add(f.sourcePath);
|
|
317
323
|
if (citedAnchors.length > 0) {
|
|
318
324
|
// Normalized by anchor count for the same reason the lexical spec bonus is (A-SPEC-270):
|
|
319
325
|
// otherwise a hub file would collect this bonus for every citation anyone ever writes.
|
|
@@ -361,6 +367,7 @@ function localizeIssue(issueText, scanned, specs, topN = 10) {
|
|
|
361
367
|
// project where the vendored code really is the answer. The score halving stays, so the relative
|
|
362
368
|
// order WITHIN each group is unchanged.
|
|
363
369
|
hits.sort((a, b) => Number(isVendorPath(a.file)) - Number(isVendorPath(b.file)) || // live source before vendored
|
|
370
|
+
Number(directlyCitedFiles.has(b.file)) - Number(directlyCitedFiles.has(a.file)) ||
|
|
364
371
|
b.score - a.score || // then by evidence strength
|
|
365
372
|
a.file.localeCompare(b.file)); // then deterministic path order
|
|
366
373
|
return { terms, hits: hits.slice(0, topN), matchedSpecs: matchedSpecs.sort(), unknownCitations: citations.unknown };
|