@holmes-lab/holmes-kit 0.2.1 → 0.3.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 +21 -0
- package/dist/.build-id +1 -1
- package/dist/holmes/assoc/assoc-arm.d.ts +94 -0
- package/dist/holmes/assoc/assoc-arm.js +187 -0
- package/dist/holmes/assoc/explore.d.ts +21 -0
- package/dist/holmes/assoc/explore.js +160 -0
- package/dist/holmes/assoc/impact-baseline.d.ts +13 -0
- package/dist/holmes/assoc/impact-baseline.js +48 -0
- package/dist/holmes/assoc/ppr.d.ts +64 -0
- package/dist/holmes/assoc/ppr.js +110 -0
- package/dist/holmes/cli/agents.d.ts +24 -11
- package/dist/holmes/cli/agents.js +93 -21
- package/dist/holmes/cli/doctor.d.ts +56 -0
- package/dist/holmes/cli/doctor.js +311 -5
- package/dist/holmes/cli/index.js +13 -1
- package/dist/holmes/cli/semantic-key.d.ts +19 -0
- package/dist/holmes/cli/semantic-key.js +93 -0
- package/dist/holmes/config/config.d.ts +9 -0
- package/dist/holmes/config/config.js +8 -1
- package/dist/holmes/cpg/language-capability.d.ts +65 -0
- package/dist/holmes/cpg/language-capability.js +145 -0
- package/dist/holmes/cpg/language-parser-walk.js +179 -34
- package/dist/holmes/cpg/language-parser.d.ts +1 -1
- package/dist/holmes/governance/ledger-rechain.d.ts +12 -0
- package/dist/holmes/governance/ledger-rechain.js +17 -2
- package/dist/holmes/governance/provenance-ledger.js +21 -0
- package/dist/holmes/guardrail/blind-spots.js +12 -1
- package/dist/holmes/guardrail/impact-gate.d.ts +77 -0
- package/dist/holmes/guardrail/impact-gate.js +263 -0
- package/dist/holmes/guardrail/write-target.d.ts +38 -1
- package/dist/holmes/guardrail/write-target.js +41 -4
- package/dist/holmes/hooks/adapters/antigravity.js +12 -1
- package/dist/holmes/hooks/corrupt-state-run.d.ts +33 -0
- package/dist/holmes/hooks/corrupt-state-run.js +16 -0
- package/dist/holmes/hooks/pre-tool-use.d.ts +26 -0
- package/dist/holmes/hooks/pre-tool-use.js +203 -11
- package/dist/holmes/hooks/stop.d.ts +65 -0
- package/dist/holmes/hooks/stop.js +200 -2
- package/dist/holmes/mcp/handlers.d.ts +88 -0
- package/dist/holmes/mcp/handlers.js +597 -16
- package/dist/holmes/mcp/maintenance-analyze.d.ts +435 -0
- package/dist/holmes/mcp/maintenance-analyze.js +994 -0
- package/dist/holmes/mcp/maintenance-evidence.d.ts +140 -0
- package/dist/holmes/mcp/maintenance-evidence.js +253 -0
- package/dist/holmes/mcp/tool-schemas.js +71 -0
- package/dist/holmes/project/root.js +3 -1
- package/dist/holmes/review/baseline-arm.d.ts +37 -0
- package/dist/holmes/review/baseline-arm.js +51 -0
- package/dist/holmes/review/captured-stdin-guard.d.ts +8 -0
- package/dist/holmes/review/captured-stdin-guard.js +48 -0
- package/dist/holmes/review/coherence-verify.d.ts +31 -0
- package/dist/holmes/review/coherence-verify.js +144 -0
- package/dist/holmes/review/commit-text.d.ts +50 -0
- package/dist/holmes/review/commit-text.js +76 -0
- package/dist/holmes/review/confidence-calibration.d.ts +39 -0
- package/dist/holmes/review/confidence-calibration.js +39 -0
- package/dist/holmes/review/content-baseline.d.ts +38 -0
- package/dist/holmes/review/content-baseline.js +103 -0
- package/dist/holmes/review/content-verify.d.ts +20 -0
- package/dist/holmes/review/content-verify.js +73 -0
- package/dist/holmes/review/dense-retrieval.d.ts +66 -0
- package/dist/holmes/review/dense-retrieval.js +97 -0
- package/dist/holmes/review/edge-quality.d.ts +44 -0
- package/dist/holmes/review/edge-quality.js +117 -0
- package/dist/holmes/review/evaluation-metrics.d.ts +138 -0
- package/dist/holmes/review/evaluation-metrics.js +175 -0
- package/dist/holmes/review/graph-verifier.d.ts +34 -0
- package/dist/holmes/review/graph-verifier.js +62 -0
- package/dist/holmes/review/hop-ablation.d.ts +100 -0
- package/dist/holmes/review/hop-ablation.js +89 -0
- package/dist/holmes/review/manual-baseline.d.ts +209 -0
- package/dist/holmes/review/manual-baseline.js +2846 -0
- package/dist/holmes/review/oracle-gap.d.ts +32 -0
- package/dist/holmes/review/oracle-gap.js +102 -0
- package/dist/holmes/review/point-in-time-replay.d.ts +41 -0
- package/dist/holmes/review/point-in-time-replay.js +161 -0
- package/dist/holmes/review/rank-diagnosis.d.ts +43 -0
- package/dist/holmes/review/rank-diagnosis.js +163 -0
- package/dist/holmes/review/replay-calibration.d.ts +62 -0
- package/dist/holmes/review/replay-calibration.js +83 -0
- package/dist/holmes/review/replay-corpus.d.ts +135 -0
- package/dist/holmes/review/replay-corpus.js +210 -0
- package/dist/holmes/review/run-replay.d.ts +260 -0
- package/dist/holmes/review/run-replay.js +729 -0
- package/dist/holmes/review/semantic-arm.d.ts +271 -0
- package/dist/holmes/review/semantic-arm.js +717 -0
- package/dist/holmes/review/semantic-retrieval.d.ts +55 -0
- package/dist/holmes/review/semantic-retrieval.js +156 -0
- package/dist/holmes/review/spec-layer-stats.d.ts +38 -0
- package/dist/holmes/review/spec-layer-stats.js +52 -0
- package/dist/holmes/review/temporal-prior.d.ts +33 -0
- package/dist/holmes/review/temporal-prior.js +53 -0
- package/dist/holmes/review/test-runner.d.ts +15 -0
- package/dist/holmes/review/test-runner.js +41 -4
- package/dist/holmes/review/test-selection-breadth.d.ts +75 -0
- package/dist/holmes/review/test-selection-breadth.js +57 -0
- package/dist/holmes/review/traceability-benchmark.d.ts +81 -0
- package/dist/holmes/review/traceability-benchmark.js +135 -0
- package/dist/holmes/review/union-verify.d.ts +12 -0
- package/dist/holmes/review/union-verify.js +70 -0
- package/dist/holmes/rtm/graph-store.d.ts +51 -0
- package/dist/holmes/rtm/graph-store.js +122 -0
- package/dist/holmes/rtm/incremental.d.ts +25 -1
- package/dist/holmes/rtm/incremental.js +18 -1
- package/dist/holmes/rtm/localize.d.ts +28 -0
- package/dist/holmes/rtm/localize.js +272 -14
- package/dist/holmes/rtm/rtm-builder.d.ts +118 -3
- package/dist/holmes/rtm/rtm-builder.js +265 -28
- package/dist/holmes/rtm/rtm-graph.d.ts +117 -2
- package/dist/holmes/rtm/rtm-graph.js +194 -31
- package/dist/holmes/rtm/taint-benchmark.d.ts +97 -0
- package/dist/holmes/rtm/taint-benchmark.js +141 -0
- package/dist/holmes/rtm/test-scope.js +24 -1
- package/dist/holmes/semantic/credentials.d.ts +15 -0
- package/dist/holmes/semantic/credentials.js +134 -0
- package/dist/holmes/semantic/embedder.d.ts +44 -0
- package/dist/holmes/semantic/embedder.js +185 -0
- package/dist/holmes/semantic/hit-rerank.d.ts +4 -0
- package/dist/holmes/semantic/hit-rerank.js +38 -0
- package/dist/holmes/semantic/tier.d.ts +37 -0
- package/dist/holmes/semantic/tier.js +54 -0
- package/dist/holmes/semantic/vector-cache.d.ts +11 -0
- package/dist/holmes/semantic/vector-cache.js +91 -0
- package/dist/holmes/spec/acceptance-quality.d.ts +81 -0
- package/dist/holmes/spec/acceptance-quality.js +169 -0
- package/dist/holmes/spec/validator.js +33 -1
- package/dist/holmes/spec/yaml-scalar.d.ts +1 -0
- package/dist/holmes/spec/yaml-scalar.js +43 -0
- package/package.json +1 -1
|
@@ -35,11 +35,32 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
exports.HandlerRefusal = void 0;
|
|
37
37
|
exports.isHandlerRefusal = isHandlerRefusal;
|
|
38
|
+
exports.unreadableAmong = unreadableAmong;
|
|
38
39
|
exports.makeHandlers = makeHandlers;
|
|
40
|
+
// @implements A-SPEC-293
|
|
41
|
+
// @implements A-SPEC-292
|
|
42
|
+
// @implements A-SPEC-290
|
|
43
|
+
// @implements A-SPEC-283
|
|
44
|
+
// @implements A-SPEC-277
|
|
45
|
+
// @implements A-SPEC-269
|
|
46
|
+
// @implements A-SPEC-267
|
|
39
47
|
const fs = __importStar(require("node:fs"));
|
|
48
|
+
const assoc_arm_1 = require("../assoc/assoc-arm");
|
|
49
|
+
// @implements A-SPEC-478 — the semantic tier runtime, resolved HERE (the async edge) and injected
|
|
50
|
+
// into the sync analysis; the replay harness injects nothing, keeping every pin untouched.
|
|
51
|
+
const tier_1 = require("../semantic/tier");
|
|
52
|
+
const vector_cache_1 = require("../semantic/vector-cache");
|
|
53
|
+
const embedder_1 = require("../semantic/embedder");
|
|
54
|
+
// @implements A-SPEC-496 — policy parity: the direct localization path reranks with the exact
|
|
55
|
+
// A-SPEC-478 math, extracted pure.
|
|
56
|
+
const hit_rerank_1 = require("../semantic/hit-rerank");
|
|
40
57
|
const os = __importStar(require("node:os"));
|
|
41
58
|
const path = __importStar(require("node:path"));
|
|
42
59
|
const crypto = __importStar(require("node:crypto"));
|
|
60
|
+
// @implements A-SPEC-378
|
|
61
|
+
const temporal_prior_1 = require("../review/temporal-prior");
|
|
62
|
+
// @implements A-SPEC-388
|
|
63
|
+
const commit_text_1 = require("../review/commit-text");
|
|
43
64
|
const node_child_process_1 = require("node:child_process");
|
|
44
65
|
const spec_parser_1 = require("../spec/spec-parser");
|
|
45
66
|
const validator_1 = require("../spec/validator");
|
|
@@ -49,6 +70,9 @@ const gap_analyzer_1 = require("../rtm/gap-analyzer");
|
|
|
49
70
|
const test_runner_1 = require("../review/test-runner");
|
|
50
71
|
const test_evidence_1 = require("../review/test-evidence");
|
|
51
72
|
const localize_1 = require("../rtm/localize");
|
|
73
|
+
const maintenance_analyze_1 = require("./maintenance-analyze");
|
|
74
|
+
const maintenance_evidence_1 = require("./maintenance-evidence");
|
|
75
|
+
const impact_gate_1 = require("../guardrail/impact-gate");
|
|
52
76
|
const phase_1 = require("../guardrail/phase");
|
|
53
77
|
const spec_types_1 = require("../spec/spec-types");
|
|
54
78
|
const legacy_format_1 = require("../spec/legacy-format");
|
|
@@ -74,6 +98,10 @@ const cacheDirFor = (root) => {
|
|
|
74
98
|
return path.join(r.root, '.ax', 'cpg_cache');
|
|
75
99
|
return path.join(os.tmpdir(), `holmes-cpg-cache-${crypto.createHash('sha256').update(r.root).digest('hex').slice(0, 16)}`);
|
|
76
100
|
};
|
|
101
|
+
// @implements A-SPEC-283 — bumped whenever the graph's node/edge shape changes, so a store written
|
|
102
|
+
// by an older build is rebuilt rather than read with new assumptions.
|
|
103
|
+
const RTM_GRAPH_SCHEMA = 'rtm-graph/2';
|
|
104
|
+
const RTM_EXTRACTOR_VERSION = 'holmes-rtm/1';
|
|
77
105
|
const cachedScan = (root, repoRoot = root) => new cpg_scanner_1.CpgScanner(undefined, new scan_cache_1.ScanFileCache(cacheDirFor(root))).scan(root, repoRoot);
|
|
78
106
|
// @implements A-SPEC-131
|
|
79
107
|
// Same scan, with the skip report kept: the callers that make honesty claims (cpg_scan's surface,
|
|
@@ -87,6 +115,7 @@ const rtm_builder_1 = require("../rtm/rtm-builder");
|
|
|
87
115
|
const rtm_graph_1 = require("../rtm/rtm-graph");
|
|
88
116
|
const taint_1 = require("../rtm/taint");
|
|
89
117
|
const incremental_1 = require("../rtm/incremental");
|
|
118
|
+
const graph_store_1 = require("../rtm/graph-store");
|
|
90
119
|
const hash_cache_1 = require("../cpg/hash-cache");
|
|
91
120
|
const bundler_1 = require("../context/bundler");
|
|
92
121
|
const scope_1 = require("../review/scope");
|
|
@@ -95,6 +124,7 @@ const coverage_1 = require("../review/coverage");
|
|
|
95
124
|
const basis_1 = require("./basis");
|
|
96
125
|
const write_target_1 = require("../guardrail/write-target");
|
|
97
126
|
const spec_store_1 = require("../spec/spec-store");
|
|
127
|
+
const yaml_scalar_1 = require("../spec/yaml-scalar");
|
|
98
128
|
const findings_1 = require("../review/findings");
|
|
99
129
|
const package_1 = require("../review/package");
|
|
100
130
|
const risk_classifier_1 = require("../guardrail/risk-classifier");
|
|
@@ -235,6 +265,72 @@ function foreignRootReason(store, root) {
|
|
|
235
265
|
return `root를 해석할 수 없습니다: ${String(e.message)}`;
|
|
236
266
|
}
|
|
237
267
|
}
|
|
268
|
+
/**
|
|
269
|
+
* @implements A-SPEC-418
|
|
270
|
+
* The server binds its spec store to a RELATIVE path, so the store follows the process's cwd. Start
|
|
271
|
+
* it inside the project and `maintenance_analyze` reads 1199 specs; start it anywhere else and it
|
|
272
|
+
* reads NONE — and then answers the same request with zero candidates, no warning, a normal success
|
|
273
|
+
* shape, and the root's 378 files still scanned, so it reads as working. Found by installing the
|
|
274
|
+
* tarball and speaking JSON-RPC to the packaged server; no test in this repository saw it.
|
|
275
|
+
*
|
|
276
|
+
* `foreignRootReason` deliberately stays quiet here: it compares two projects, and when the store's
|
|
277
|
+
* own directory has no `.ax` marker there is nothing to compare. That judgement is right, and the
|
|
278
|
+
* silence it leaves is what this fills.
|
|
279
|
+
*
|
|
280
|
+
* Keyed on the BINDING, not on the count. Zero specs because a project's specs are all drafts is a
|
|
281
|
+
* different fact from zero because none were read, and warning on the first would fire on every
|
|
282
|
+
* young project.
|
|
283
|
+
*/
|
|
284
|
+
function specStoreBlindReason(store, root, loaded) {
|
|
285
|
+
if (loaded > 0 || !(store instanceof spec_store_1.LocalMarkdownRepository))
|
|
286
|
+
return null;
|
|
287
|
+
if (!fs.existsSync(path.join(root, '.ax', 'specs')))
|
|
288
|
+
return null; // 스펙이 없는 프로젝트는 무고하다
|
|
289
|
+
// Compared through realpath: on macOS a temp root is `/var/...` while the same directory resolves
|
|
290
|
+
// to `/private/var/...`, and a plain string compare calls the correctly-bound store foreign.
|
|
291
|
+
const real = (p) => { try {
|
|
292
|
+
return fs.realpathSync(p);
|
|
293
|
+
}
|
|
294
|
+
catch {
|
|
295
|
+
return path.resolve(p);
|
|
296
|
+
} };
|
|
297
|
+
const bound = real(store.specsRoot);
|
|
298
|
+
if (bound === real(path.join(root, '.ax', 'specs')))
|
|
299
|
+
return null;
|
|
300
|
+
return `이 서버는 ${bound} 에 바인딩되어 스펙을 하나도 읽지 못했습니다 — 분석 대상 ${root} 에는`
|
|
301
|
+
+ ' 스펙 저장소가 있습니다. 아래 결과는 스펙 층 없이 어휘와 그래프만으로 계산된 것이며,'
|
|
302
|
+
+ ' 스펙 매칭에 의존하는 요청은 조용히 0개를 받습니다. 서버를 프로젝트 루트에서 시작하거나'
|
|
303
|
+
+ ' HOLMES_SPECS 로 스펙 경로를 지정하십시오.';
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* @implements A-SPEC-419
|
|
307
|
+
* `phase_status` refuses when this server's spec directory cannot be resolved; the tools whose whole
|
|
308
|
+
* answer is about specs did not, and answered EMPTY instead. From a cwd outside the project the
|
|
309
|
+
* installed server returned `{"specs":[]}` for a repository holding 1199 of them, empty impacted
|
|
310
|
+
* specs for review, no hits for localization, and rewrote the RTM index with no spec nodes at all —
|
|
311
|
+
* every one a success shape. The cause is one line in `LocalMarkdownRepository.list`: its walk
|
|
312
|
+
* returns quietly when the directory is missing, so an ABSENT store is indistinguishable from an
|
|
313
|
+
* empty one.
|
|
314
|
+
*
|
|
315
|
+
* Keyed on a MISMATCH, never on a count: a project that has not run `init` yet has no specs anywhere
|
|
316
|
+
* and must keep getting its empty answer, or this guard becomes the next defect.
|
|
317
|
+
*/
|
|
318
|
+
function assertSpecStoreReachable(tool, store, root) {
|
|
319
|
+
if (typeof root !== 'string' || root === '')
|
|
320
|
+
return;
|
|
321
|
+
if (!(store instanceof spec_store_1.LocalMarkdownRepository))
|
|
322
|
+
return;
|
|
323
|
+
if (fs.existsSync(store.specsRoot))
|
|
324
|
+
return;
|
|
325
|
+
if (!fs.existsSync(path.join(root, '.ax', 'specs')))
|
|
326
|
+
return; // 어디에도 스펙이 없으면 무고하다
|
|
327
|
+
// The tool name is NOT prefixed here: the raw-handler wrapper already prefixes it, and doing it
|
|
328
|
+
// twice produced `spec_list: spec_list: ...` in the installed server (seen in the probe).
|
|
329
|
+
void tool;
|
|
330
|
+
throw new HandlerRefusal(`이 서버가 바인딩된 스펙 디렉터리 ${store.specsRoot} 가 존재하지 않습니다 —`
|
|
331
|
+
+ ` 요청한 root ${root} 에는 스펙 저장소가 있습니다. 빈 답을 돌려주면 '스펙이 없다'로 읽히므로`
|
|
332
|
+
+ ' 거부합니다. 서버를 프로젝트 루트에서 시작하거나 HOLMES_SPECS 로 스펙 경로를 지정하십시오.');
|
|
333
|
+
}
|
|
238
334
|
/**
|
|
239
335
|
* A root the caller named that this process cannot read is the CALLER'S mistake, not a fault.
|
|
240
336
|
* @implements A-SPEC-189 §10 (round 11)
|
|
@@ -416,6 +512,15 @@ async function deriveChangedContext(store, rootArg, a, toolName) {
|
|
|
416
512
|
* information, and mtime would manufacture divergence on a checkout or a copy.
|
|
417
513
|
*/
|
|
418
514
|
const LOADED_BUILD = (0, basis_1.loadedBuildId)(path.resolve(__dirname, '..', '..', '..'));
|
|
515
|
+
/**
|
|
516
|
+
* @implements A-SPEC-299
|
|
517
|
+
* Ids that were written but cannot be read back. The store drops documents it cannot parse, so
|
|
518
|
+
* absence from `list()` — not an exception — is the signal.
|
|
519
|
+
*/
|
|
520
|
+
function unreadableAmong(listed, created) {
|
|
521
|
+
const present = new Set(listed.map((s) => s.id));
|
|
522
|
+
return created.filter((id) => !present.has(id));
|
|
523
|
+
}
|
|
419
524
|
function makeHandlers(store, opts) {
|
|
420
525
|
const raw = makeRawHandlers(store, opts);
|
|
421
526
|
const wrapped = {};
|
|
@@ -465,6 +570,74 @@ function basisFor(root, withDisk = false) {
|
|
|
465
570
|
}
|
|
466
571
|
return (0, basis_1.collectBasis)(ctx);
|
|
467
572
|
}
|
|
573
|
+
/**
|
|
574
|
+
* @implements A-SPEC-277
|
|
575
|
+
* sha256 of a repo-relative file as it is on disk right now, or null when it cannot be read. Null is
|
|
576
|
+
* a real answer — a deleted or not-yet-created target genuinely cannot be compared against what an
|
|
577
|
+
* analysis recorded, and the gate treats that as unverifiable rather than as unchanged.
|
|
578
|
+
*/
|
|
579
|
+
function fileDigestOf(root, rel) {
|
|
580
|
+
try {
|
|
581
|
+
const resolved = path.resolve(root, rel);
|
|
582
|
+
// Never digest something outside the project: the path arrives from a tool caller.
|
|
583
|
+
if (resolved !== root && !resolved.startsWith(root + path.sep))
|
|
584
|
+
return null;
|
|
585
|
+
if (!fs.statSync(resolved).isFile())
|
|
586
|
+
return null;
|
|
587
|
+
return `sha256:${crypto.createHash('sha256').update(fs.readFileSync(resolved)).digest('hex')}`;
|
|
588
|
+
}
|
|
589
|
+
catch {
|
|
590
|
+
return null;
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
/**
|
|
594
|
+
* @implements A-SPEC-293
|
|
595
|
+
* Read `.ax/decisions/*.md` and find who cites them. Citation is the link, chosen by measurement:
|
|
596
|
+
* `governs` resolves 9 of 65 names to symbols while 37 source files and 71 spec files cite an ADR.
|
|
597
|
+
* Failure here is never fatal — a project with no decisions directory simply has no decisions.
|
|
598
|
+
*/
|
|
599
|
+
function collectDecisions(root, scanned, specs) {
|
|
600
|
+
const dir = path.join(root, '.ax', 'decisions');
|
|
601
|
+
const decisions = [];
|
|
602
|
+
try {
|
|
603
|
+
for (const name of fs.readdirSync(dir).sort()) {
|
|
604
|
+
if (!name.endsWith('.md'))
|
|
605
|
+
continue;
|
|
606
|
+
const text = fs.readFileSync(path.join(dir, name), 'utf8');
|
|
607
|
+
const front = text.split('---')[1] ?? '';
|
|
608
|
+
const id = (front.match(/^id:\s*(\S+)/m) ?? [])[1];
|
|
609
|
+
if (!id)
|
|
610
|
+
continue;
|
|
611
|
+
const supersedes = (front.match(/^supersedes:\s*(.+)$/m) ?? [])[1]?.trim() ?? '';
|
|
612
|
+
decisions.push({
|
|
613
|
+
id,
|
|
614
|
+
title: ((front.match(/^title:\s*(.+)$/m) ?? [])[1] ?? '').trim(),
|
|
615
|
+
status: ((front.match(/^status:\s*(\S+)/m) ?? [])[1] ?? '').trim(),
|
|
616
|
+
supersedes: supersedes && supersedes !== 'null' ? supersedes : null,
|
|
617
|
+
});
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
catch {
|
|
621
|
+
return { decisions: [], citations: [] };
|
|
622
|
+
}
|
|
623
|
+
const ids = new Set(decisions.map((d) => d.id));
|
|
624
|
+
const cite = (text, from, out) => {
|
|
625
|
+
for (const m of text.matchAll(/\bADR-\d{3}\b/g))
|
|
626
|
+
if (ids.has(m[0]))
|
|
627
|
+
out.push({ from, adr: m[0] });
|
|
628
|
+
};
|
|
629
|
+
const citations = [];
|
|
630
|
+
for (const f of scanned) {
|
|
631
|
+
try {
|
|
632
|
+
cite(fs.readFileSync(path.join(root, f.sourcePath), 'utf8'), `FILE:${f.sourcePath}`, citations);
|
|
633
|
+
}
|
|
634
|
+
catch { /* unreadable now — the scan is a snapshot, the tree can move */ }
|
|
635
|
+
}
|
|
636
|
+
for (const spec of specs) {
|
|
637
|
+
cite(`${spec.title} ${Object.values(spec.sections ?? {}).join(' ')}`, `SPEC:${spec.id}`, citations);
|
|
638
|
+
}
|
|
639
|
+
return { decisions, citations };
|
|
640
|
+
}
|
|
468
641
|
function makeRawHandlers(store, opts) {
|
|
469
642
|
const resolver = (specs) => (id) => specs.find((s) => s.id === id) ?? null;
|
|
470
643
|
// @implements A-SPEC-263.1
|
|
@@ -579,6 +752,42 @@ function makeRawHandlers(store, opts) {
|
|
|
579
752
|
};
|
|
580
753
|
}
|
|
581
754
|
}
|
|
755
|
+
// @implements A-SPEC-420
|
|
756
|
+
// The branch above steps aside when the server's spec directory does not exist yet, so that a
|
|
757
|
+
// project can create its first spec. Right intent, but the gap let something else through:
|
|
758
|
+
// measured against the INSTALLED server from a cwd outside any project, `spec_create` with
|
|
759
|
+
// `root` naming an established other project wrote REQ-990 into the SERVER's directory and
|
|
760
|
+
// answered `{"created":"REQ-990"}`. The caller's project gained nothing and was told it had.
|
|
761
|
+
//
|
|
762
|
+
// Layout-independent on purpose: the test is not "<root>/.ax/specs equals my store" — that
|
|
763
|
+
// hardcodes the default layout and, as the note above records, made `--specs-dir docs/specs`
|
|
764
|
+
// deployments refuse their own root. It is "you named a project that already exists, and my
|
|
765
|
+
// store is not inside it", which is true whatever the specs directory is called.
|
|
766
|
+
if (typeof reqRoot === 'string' && reqRoot !== '' && derivedForCreate === null && boundSpecsRoot !== null) {
|
|
767
|
+
const real = (p2) => { try {
|
|
768
|
+
return fs.realpathSync(p2);
|
|
769
|
+
}
|
|
770
|
+
catch {
|
|
771
|
+
return path.resolve(p2);
|
|
772
|
+
} };
|
|
773
|
+
let askedRoot = null;
|
|
774
|
+
try {
|
|
775
|
+
askedRoot = real(projectRootOf(reqRoot));
|
|
776
|
+
}
|
|
777
|
+
catch {
|
|
778
|
+
askedRoot = null;
|
|
779
|
+
}
|
|
780
|
+
if (askedRoot !== null && fs.existsSync(path.join(askedRoot, '.ax'))
|
|
781
|
+
&& !real(boundSpecsRoot).startsWith(askedRoot + path.sep)) {
|
|
782
|
+
return {
|
|
783
|
+
ok: false,
|
|
784
|
+
reason: `이 서버의 스펙 저장소 ${boundSpecsRoot} 는 아직 만들어지지 않았고 ${askedRoot} 안에도`
|
|
785
|
+
+ ' 있지 않습니다 — 요청한 root 는 이미 스펙 저장소를 가진 다른 프로젝트입니다.'
|
|
786
|
+
+ ' 여기서 만들면 파일은 호출자가 지정한 곳이 아니라 이 서버 쪽에 생깁니다.'
|
|
787
|
+
+ ' 서버를 그 프로젝트에서 시작하거나 HOLMES_SPECS 로 그 프로젝트의 스펙 경로를 지정하십시오.',
|
|
788
|
+
};
|
|
789
|
+
}
|
|
790
|
+
}
|
|
582
791
|
// @implements A-SPEC-174
|
|
583
792
|
// Refuse what can never become valid; accept what is merely not filled in yet.
|
|
584
793
|
//
|
|
@@ -1159,6 +1368,7 @@ function makeRawHandlers(store, opts) {
|
|
|
1159
1368
|
return { approved: a.id, digest };
|
|
1160
1369
|
},
|
|
1161
1370
|
async spec_list(a) {
|
|
1371
|
+
assertSpecStoreReachable('spec_list', store, a.root); // @implements A-SPEC-419
|
|
1162
1372
|
const specs = await store.list();
|
|
1163
1373
|
// @implements A-SPEC-186
|
|
1164
1374
|
// The parent travels with the id because a bare one cannot be read. Measured: a reader took
|
|
@@ -1215,6 +1425,15 @@ function makeRawHandlers(store, opts) {
|
|
|
1215
1425
|
return { next: null };
|
|
1216
1426
|
},
|
|
1217
1427
|
async rtm_check(a) {
|
|
1428
|
+
// @implements A-SPEC-461 — a root that names a different project would check THIS project's
|
|
1429
|
+
// specs against THAT project's files and report clean — A-SPEC-191 §15. foreignRootReason
|
|
1430
|
+
// returns null for an absent or matching root, so the spec-only and correct-root paths are
|
|
1431
|
+
// unaffected. Shared guard (citation_pin/maintenance_analyze use it), not a copy (A-SPEC-449).
|
|
1432
|
+
{
|
|
1433
|
+
const foreign = foreignRootReason(store, a?.root);
|
|
1434
|
+
if (foreign)
|
|
1435
|
+
return { ok: false, reason: foreign };
|
|
1436
|
+
}
|
|
1218
1437
|
const specs = await store.list();
|
|
1219
1438
|
// rtm_check runs on the GOVERNED set only (excludes archived legacy +
|
|
1220
1439
|
// JOB specs, which otherwise flood orphan/dangling results; see
|
|
@@ -1403,6 +1622,13 @@ function makeRawHandlers(store, opts) {
|
|
|
1403
1622
|
* never "vulnerable".
|
|
1404
1623
|
*/
|
|
1405
1624
|
async taint_scan(a) {
|
|
1625
|
+
// @implements A-SPEC-461 — same §15 defence as rtm_check: refuse a root that is not this
|
|
1626
|
+
// server's project rather than scanning it against the bound store's specs.
|
|
1627
|
+
{
|
|
1628
|
+
const foreign = foreignRootReason(store, a.root);
|
|
1629
|
+
if (foreign)
|
|
1630
|
+
return { ok: false, reason: foreign };
|
|
1631
|
+
}
|
|
1406
1632
|
const root = projectRootOf(a.root);
|
|
1407
1633
|
const scanned = cachedScan(root, root);
|
|
1408
1634
|
const g = new rtm_graph_1.RtmGraph();
|
|
@@ -1450,7 +1676,7 @@ function makeRawHandlers(store, opts) {
|
|
|
1450
1676
|
const executedByAspec = (0, test_evidence_1.computeExecutedByAspec)(result.executedByFile ?? {}, anchors);
|
|
1451
1677
|
let head = '';
|
|
1452
1678
|
try {
|
|
1453
|
-
head = (0, node_child_process_1.execFileSync)('git', ['-C', root, 'rev-parse', 'HEAD'], { encoding: 'utf8', env: (0, root_1.cleanSubprocessEnv)() }).trim();
|
|
1679
|
+
head = (0, node_child_process_1.execFileSync)('git', ['-C', root, 'rev-parse', 'HEAD'], { encoding: 'utf8', env: (0, root_1.cleanSubprocessEnv)(), stdio: ['ignore', 'pipe', 'pipe'] }).trim();
|
|
1454
1680
|
}
|
|
1455
1681
|
catch { /* non-git */ }
|
|
1456
1682
|
// Record ONLY an actually-executed, GREEN run (review C4/C7): a red suite must not stand as
|
|
@@ -1479,6 +1705,7 @@ function makeRawHandlers(store, opts) {
|
|
|
1479
1705
|
changeSource, ...(scopeFallback ? { scopeFallback } : {}), ...(baseline ? { baselineRecorded: baseline } : {}) };
|
|
1480
1706
|
},
|
|
1481
1707
|
async issue_localize(a) {
|
|
1708
|
+
assertSpecStoreReachable('issue_localize', store, a.root); // @implements A-SPEC-419
|
|
1482
1709
|
// N1: deterministic localization report — CPG lexical match fused with the RTM spec hop.
|
|
1483
1710
|
// @implements A-SPEC-189 §14 (round 13) — `projectRootOf(a.root)` was called as a bare
|
|
1484
1711
|
// statement: its only effect was to throw on a bad path, and the advertised contract ("a
|
|
@@ -1488,9 +1715,270 @@ function makeRawHandlers(store, opts) {
|
|
|
1488
1715
|
const root = projectRootOf(a.root);
|
|
1489
1716
|
const scanned = cachedScan(root, root);
|
|
1490
1717
|
const specs = await store.list();
|
|
1491
|
-
|
|
1718
|
+
const governed = (0, spec_types_1.filterGoverned)(specs);
|
|
1719
|
+
const report = (0, localize_1.localizeIssue)(a.issue, scanned, governed, a.topN ?? 10);
|
|
1720
|
+
// @implements A-SPEC-496 — parity with A-SPEC-478: the SAME uncited gate, the SAME math
|
|
1721
|
+
// (cached vectors only, set fixed, covered hits move, why-line attached). Any missing
|
|
1722
|
+
// signal — no tier, no key, cold cache, embed failure — leaves the report untouched;
|
|
1723
|
+
// localization itself never fails because of the semantic layer.
|
|
1724
|
+
try {
|
|
1725
|
+
if (report.hits.length > 1
|
|
1726
|
+
&& (0, localize_1.citationsIn)(a.issue, new Set(governed.map((s) => s.id))).cited.length === 0) {
|
|
1727
|
+
const rt = (0, embedder_1.makeTierEmbedder)((0, tier_1.resolveSemanticTier)(), new vector_cache_1.VectorCache(root));
|
|
1728
|
+
if (rt !== null) {
|
|
1729
|
+
const qv = await rt.embedQuery(a.issue);
|
|
1730
|
+
const docTextOf = new Map(scanned.map((f) => [f.sourcePath,
|
|
1731
|
+
(f.sourcePath + ' ' + f.symbols.map((sy) => sy.qualifiedName).join(' ')).slice(0, 2000)]));
|
|
1732
|
+
(0, hit_rerank_1.rerankHitsBySemantic)(report.hits, qv, (file) => {
|
|
1733
|
+
const dt = docTextOf.get(file);
|
|
1734
|
+
return dt !== undefined ? rt.cachedDocVector(dt) : null;
|
|
1735
|
+
}, rt.label);
|
|
1736
|
+
}
|
|
1737
|
+
}
|
|
1738
|
+
}
|
|
1739
|
+
catch { /* semantic layer is additive — never block the report */ }
|
|
1740
|
+
return report;
|
|
1741
|
+
},
|
|
1742
|
+
async maintenance_analyze(a) {
|
|
1743
|
+
const foreign = foreignRootReason(store, a.root);
|
|
1744
|
+
if (foreign)
|
|
1745
|
+
throw new HandlerRefusal(foreign);
|
|
1746
|
+
const root = projectRootOf(a.root);
|
|
1747
|
+
// This tool is advertised as read-only. Do not attach ScanFileCache: its normal save/prune
|
|
1748
|
+
// behavior mutates `.ax/cpg_cache` even though the analysis itself is observational.
|
|
1749
|
+
const scanner = new cpg_scanner_1.CpgScanner();
|
|
1750
|
+
const scanned = scanner.scan(root, root);
|
|
1751
|
+
const skipped = scanner.scanSkipped();
|
|
1752
|
+
const specs = (0, spec_types_1.filterGoverned)(await store.list());
|
|
1753
|
+
const anchors = (0, test_scope_1.scanTestAnchors)(root);
|
|
1754
|
+
const detail = basisFor(root, true);
|
|
1755
|
+
let head = '';
|
|
1756
|
+
try {
|
|
1757
|
+
head = (0, node_child_process_1.execFileSync)('git', ['-C', root, 'rev-parse', 'HEAD'], { encoding: 'utf8', env: (0, root_1.cleanSubprocessEnv)(), stdio: ['ignore', 'pipe', 'pipe'] }).trim();
|
|
1758
|
+
}
|
|
1759
|
+
catch { /* non-git */ }
|
|
1760
|
+
const supportedExtensions = new Set(cpg_scanner_1.SCANNABLE_EXTENSIONS);
|
|
1761
|
+
const knownCodeExtensions = new Set(['.rb', '.php', '.kt', '.kts', '.swift', '.scala', '.sh', '.ex', '.exs', '.lua', '.r']);
|
|
1762
|
+
const unsupportedLanguages = new Set();
|
|
1763
|
+
try {
|
|
1764
|
+
const files = (0, node_child_process_1.execFileSync)('git', ['-C', root, 'ls-files', '-z', '--cached', '--others', '--exclude-standard'], { encoding: 'utf8', env: (0, root_1.cleanSubprocessEnv)(), maxBuffer: 8 * 1024 * 1024 }).split('\0');
|
|
1765
|
+
for (const file of files) {
|
|
1766
|
+
const ext = path.extname(file).toLowerCase();
|
|
1767
|
+
if (knownCodeExtensions.has(ext) && !supportedExtensions.has(ext))
|
|
1768
|
+
unsupportedLanguages.add(ext);
|
|
1769
|
+
}
|
|
1770
|
+
}
|
|
1771
|
+
catch {
|
|
1772
|
+
// A non-git tree cannot be enumerated cheaply without reimplementing the scanner's ignore
|
|
1773
|
+
// rules. Surface the missing axis rather than claiming every language was covered.
|
|
1774
|
+
unsupportedLanguages.add('not-enumerated(non-git)');
|
|
1775
|
+
}
|
|
1776
|
+
const graph = new rtm_graph_1.RtmGraph();
|
|
1777
|
+
try {
|
|
1778
|
+
// @implements A-SPEC-292 — the resolution loop's own tally of what it declined to link.
|
|
1779
|
+
const { resolution } = (0, rtm_builder_1.buildRtm)(specs, scanned, graph);
|
|
1780
|
+
// @implements A-SPEC-293 — what constrains this change, not just what it touches.
|
|
1781
|
+
const { decisions, citations } = collectDecisions(root, scanned, specs);
|
|
1782
|
+
(0, rtm_builder_1.addDecisionEdges)(decisions, citations, graph);
|
|
1783
|
+
const common = {
|
|
1784
|
+
request: a.request,
|
|
1785
|
+
scanned,
|
|
1786
|
+
specs,
|
|
1787
|
+
graph,
|
|
1788
|
+
testAnchors: anchors,
|
|
1789
|
+
basis: {
|
|
1790
|
+
head,
|
|
1791
|
+
loadedBuild: detail.loadedBuild,
|
|
1792
|
+
diskBuild: detail.diskBuild ?? 'unknown',
|
|
1793
|
+
specFingerprint: `count:${specs.length}/fp:${detail.specs ?? 'unknown'}`,
|
|
1794
|
+
},
|
|
1795
|
+
changedFiles: a.changedFiles,
|
|
1796
|
+
// @implements A-SPEC-290 — the stored run record. Freshness is judged inside the analysis
|
|
1797
|
+
// against its own basis, so a record from another commit is reported as stale rather than
|
|
1798
|
+
// quietly used.
|
|
1799
|
+
runtimeEvidence: (0, test_evidence_1.readTestEvidence)(root),
|
|
1800
|
+
resolution,
|
|
1801
|
+
coverage: {
|
|
1802
|
+
scannedFiles: scanned.length,
|
|
1803
|
+
skippedFiles: skipped.map((entry) => entry.file),
|
|
1804
|
+
unsupportedLanguages: [...unsupportedLanguages],
|
|
1805
|
+
historyStatus: 'not-requested',
|
|
1806
|
+
},
|
|
1807
|
+
};
|
|
1808
|
+
// @implements A-SPEC-478 — resolve the tier and precompute the query vector at the
|
|
1809
|
+
// async edge; the sync analysis receives only a closure over cached vectors. Any failure
|
|
1810
|
+
// (no tier, no module, no key, embed error) leaves `semantic` undefined = today's behavior.
|
|
1811
|
+
let semantic;
|
|
1812
|
+
try {
|
|
1813
|
+
const rt = (0, embedder_1.makeTierEmbedder)((0, tier_1.resolveSemanticTier)(), new vector_cache_1.VectorCache(root));
|
|
1814
|
+
if (rt !== null) {
|
|
1815
|
+
const qv = await rt.embedQuery(a.request);
|
|
1816
|
+
semantic = { label: rt.label, queryVector: qv, cachedDocVector: rt.cachedDocVector };
|
|
1817
|
+
}
|
|
1818
|
+
}
|
|
1819
|
+
catch { /* the hot path never breaks on the semantic layer */ }
|
|
1820
|
+
const preliminary = (0, maintenance_analyze_1.analyzeMaintenance)({ ...common, history: [], semantic });
|
|
1821
|
+
const candidateFiles = preliminary.candidates.map((candidate) => candidate.file);
|
|
1822
|
+
const limit = Math.max(0, Math.min(100, Math.floor(a.historyLimit ?? 20)));
|
|
1823
|
+
const history = [];
|
|
1824
|
+
let historyStatus = limit === 0 || candidateFiles.length === 0 ? 'not-requested' : 'unavailable';
|
|
1825
|
+
if (limit > 0 && candidateFiles.length > 0) {
|
|
1826
|
+
try {
|
|
1827
|
+
const raw = (0, node_child_process_1.execFileSync)('git', ['-C', root, 'log', `-${limit}`, '--date=short', '--format=%H%x00%ad%x00%s', '--name-only', '--', ...candidateFiles], { encoding: 'utf8', env: (0, root_1.cleanSubprocessEnv)(), maxBuffer: 2 * 1024 * 1024 });
|
|
1828
|
+
let current = null;
|
|
1829
|
+
for (const line of raw.split('\n')) {
|
|
1830
|
+
if (line.includes('\0')) {
|
|
1831
|
+
const [commit, date, subject] = line.split('\0');
|
|
1832
|
+
current = { commit, date, subject, files: [] };
|
|
1833
|
+
history.push(current);
|
|
1834
|
+
}
|
|
1835
|
+
else if (current && line.trim() !== '') {
|
|
1836
|
+
// git C-quotes any non-ASCII pathname here (core.quotepath). Left raw, it never
|
|
1837
|
+
// matched a candidate file, so the rationale came back empty while historyStatus
|
|
1838
|
+
// still reported `available` — a silent false negative with confident provenance.
|
|
1839
|
+
current.files.push((0, maintenance_analyze_1.unquoteGitPath)(line.trim()));
|
|
1840
|
+
}
|
|
1841
|
+
}
|
|
1842
|
+
historyStatus = 'available';
|
|
1843
|
+
}
|
|
1844
|
+
catch { /* history is evidence enrichment, not a prerequisite */ }
|
|
1845
|
+
}
|
|
1846
|
+
// Preserve localization rank for context: `impacts.direct` is deliberately sorted for a
|
|
1847
|
+
// stable evidence contract, but sorting is not relevance. Seeding from it once selected a
|
|
1848
|
+
// lexically earlier vendored symbol while the first ranked hit correctly named live code.
|
|
1849
|
+
const directSeed = preliminary.candidates
|
|
1850
|
+
.flatMap((candidate) => candidate.symbols)
|
|
1851
|
+
.flatMap((qualifiedName) => graph.codeNodeIds(qualifiedName))[0];
|
|
1852
|
+
const specSeed = preliminary.candidateSpecs.find((id) => id.startsWith('A-SPEC-'));
|
|
1853
|
+
const seedId = directSeed ?? (specSeed ? `SPEC:${specSeed}` : undefined);
|
|
1854
|
+
const budget = Math.max(100, Math.min(10_000, Math.floor(a.contextBudget ?? 2_000)));
|
|
1855
|
+
const contextBundle = seedId
|
|
1856
|
+
? new bundler_1.ContextBundler(graph, buildContentSource(specs, scanned)).getContextBundle(seedId, budget)
|
|
1857
|
+
: null;
|
|
1858
|
+
// @implements A-SPEC-378
|
|
1859
|
+
// Change recency over a WIDE window and ALL files — the enrichment history above is
|
|
1860
|
+
// deliberately narrow (candidate files, 20 commits) and cannot serve as a prior: a file the
|
|
1861
|
+
// lexical layer never ranked has no history there, which is precisely the file recency is
|
|
1862
|
+
// supposed to promote. Failure is silent and total: no prior means the previous ranking,
|
|
1863
|
+
// never a partial one, because a prior built from half the history would reorder on evidence
|
|
1864
|
+
// nobody could audit.
|
|
1865
|
+
const changePrior = {};
|
|
1866
|
+
try {
|
|
1867
|
+
const prior = (0, temporal_prior_1.temporalPrior)(root, 'HEAD', 300);
|
|
1868
|
+
for (const [file, age] of prior.recency)
|
|
1869
|
+
changePrior[file] = 1 + Math.pow(0.5, age / 10);
|
|
1870
|
+
}
|
|
1871
|
+
catch { /* no history, no prior — the ranking falls back to lexical evidence alone */ }
|
|
1872
|
+
// @implements A-SPEC-388
|
|
1873
|
+
// What each file's commits have SAID, normalised to 0..1. Measured on history sampled away
|
|
1874
|
+
// from the benchmarker's own recent work (A-SPEC-387): Top-1 0.244 -> 0.333 here and 0.400 ->
|
|
1875
|
+
// 0.500 on a second repository, Top-10 recall 0.393 -> 0.601 there. Precision falls on both,
|
|
1876
|
+
// and that is the trade, not a footnote to it.
|
|
1877
|
+
const commitTextBoost = {};
|
|
1878
|
+
try {
|
|
1879
|
+
const profile = (0, commit_text_1.commitTextProfile)(root, 'HEAD', 400);
|
|
1880
|
+
const ranked = (0, commit_text_1.rankByCommitText)(a.request, profile, 300);
|
|
1881
|
+
const top = ranked[0]?.score ?? 0;
|
|
1882
|
+
if (top > 0)
|
|
1883
|
+
for (const hit of ranked)
|
|
1884
|
+
commitTextBoost[hit.file] = hit.score / top;
|
|
1885
|
+
}
|
|
1886
|
+
catch { /* no history, no boost — the ranking falls back to lexical evidence alone */ }
|
|
1887
|
+
const analysis = (0, maintenance_analyze_1.analyzeMaintenance)({
|
|
1888
|
+
semantic,
|
|
1889
|
+
...common,
|
|
1890
|
+
coverage: { ...common.coverage, historyStatus },
|
|
1891
|
+
history,
|
|
1892
|
+
changePrior,
|
|
1893
|
+
commitTextBoost,
|
|
1894
|
+
contextBundle,
|
|
1895
|
+
groundTruth: a.groundTruth,
|
|
1896
|
+
});
|
|
1897
|
+
// @implements A-SPEC-268 — persistence is OPT-IN. The tool is advertised read-only, and a
|
|
1898
|
+
// regression pins that a cold project gains no `.ax/cpg_cache`; writing evidence by default
|
|
1899
|
+
// would break that contract for every caller who only wanted to look.
|
|
1900
|
+
// One shape either way: `persistedTo` is present-or-undefined rather than a union, so a
|
|
1901
|
+
// caller never has to narrow the result to ask whether anything was written.
|
|
1902
|
+
const result = { ...analysis };
|
|
1903
|
+
// @implements A-SPEC-418 — first line, because everything below it was computed without specs.
|
|
1904
|
+
const blind = specStoreBlindReason(store, root, specs.length);
|
|
1905
|
+
if (blind !== null)
|
|
1906
|
+
result.unknowns = [blind, ...result.unknowns];
|
|
1907
|
+
if (a.persist) {
|
|
1908
|
+
// @implements A-SPEC-277 — record WHAT WAS ANALYSED, not just which paths. HEAD cannot see
|
|
1909
|
+
// an uncommitted edit to the analysed file; a content digest can.
|
|
1910
|
+
const fileDigests = {};
|
|
1911
|
+
for (const file of analysis.impacts.files) {
|
|
1912
|
+
const digest = fileDigestOf(root, file);
|
|
1913
|
+
if (digest)
|
|
1914
|
+
fileDigests[file] = digest;
|
|
1915
|
+
}
|
|
1916
|
+
result.persistedTo = (0, maintenance_evidence_1.writeArtifact)(path.join(root, maintenance_evidence_1.EVIDENCE_DIR), (0, maintenance_evidence_1.artifactFrom)(analysis, new Date().toISOString(), fileDigests));
|
|
1917
|
+
}
|
|
1918
|
+
return result;
|
|
1919
|
+
}
|
|
1920
|
+
finally {
|
|
1921
|
+
graph.close();
|
|
1922
|
+
}
|
|
1923
|
+
},
|
|
1924
|
+
// @implements A-SPEC-268
|
|
1925
|
+
async maintenance_outcome(a) {
|
|
1926
|
+
const foreign = foreignRootReason(store, a.root);
|
|
1927
|
+
if (foreign)
|
|
1928
|
+
throw new HandlerRefusal(foreign);
|
|
1929
|
+
const root = projectRootOf(a.root);
|
|
1930
|
+
return (0, maintenance_evidence_1.recordOutcome)(path.join(root, maintenance_evidence_1.EVIDENCE_DIR), a.digest, {
|
|
1931
|
+
actualFiles: a.actualFiles,
|
|
1932
|
+
actualSymbols: a.actualSymbols,
|
|
1933
|
+
actualTests: a.actualTests,
|
|
1934
|
+
actualClassification: a.actualClassification,
|
|
1935
|
+
}, new Date().toISOString());
|
|
1936
|
+
},
|
|
1937
|
+
// @implements A-SPEC-277
|
|
1938
|
+
async impact_gate_check(a) {
|
|
1939
|
+
const foreign = foreignRootReason(store, a.root);
|
|
1940
|
+
if (foreign)
|
|
1941
|
+
throw new HandlerRefusal(foreign);
|
|
1942
|
+
const root = projectRootOf(a.root);
|
|
1943
|
+
let head = '';
|
|
1944
|
+
try {
|
|
1945
|
+
head = (0, node_child_process_1.execFileSync)('git', ['-C', root, 'rev-parse', 'HEAD'], { encoding: 'utf8', env: (0, root_1.cleanSubprocessEnv)(), stdio: ['ignore', 'pipe', 'pipe'] }).trim();
|
|
1946
|
+
}
|
|
1947
|
+
catch { /* non-git */ }
|
|
1948
|
+
let artifact = null;
|
|
1949
|
+
if (a.digest) {
|
|
1950
|
+
const file = path.join(root, maintenance_evidence_1.EVIDENCE_DIR, (0, maintenance_evidence_1.digestFilename)(a.digest));
|
|
1951
|
+
// A digest naming an artifact this project never stored is NOT the same as no evidence at
|
|
1952
|
+
// all — say which one happened rather than collapsing both into "refused".
|
|
1953
|
+
if (!fs.existsSync(file))
|
|
1954
|
+
throw new HandlerRefusal(`이 프로젝트에 저장된 분석이 아닙니다: ${a.digest}`);
|
|
1955
|
+
artifact = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
1956
|
+
}
|
|
1957
|
+
return (0, impact_gate_1.evaluateEditEvidence)({
|
|
1958
|
+
target: a.target.replace(/\\/g, '/'),
|
|
1959
|
+
artifact,
|
|
1960
|
+
currentHead: head,
|
|
1961
|
+
currentTargetDigest: fileDigestOf(root, a.target),
|
|
1962
|
+
});
|
|
1963
|
+
},
|
|
1964
|
+
// @implements A-SPEC-268
|
|
1965
|
+
async maintenance_calibration(a) {
|
|
1966
|
+
const foreign = foreignRootReason(store, a.root);
|
|
1967
|
+
if (foreign)
|
|
1968
|
+
throw new HandlerRefusal(foreign);
|
|
1969
|
+
const root = projectRootOf(a.root);
|
|
1970
|
+
const read = (0, maintenance_evidence_1.readArtifacts)(path.join(root, maintenance_evidence_1.EVIDENCE_DIR));
|
|
1971
|
+
// Corrupt and off-schema records travel WITH the score. A calibration that quietly dropped
|
|
1972
|
+
// them would report a clean number over an unstated subset of the evidence.
|
|
1973
|
+
return {
|
|
1974
|
+
...(0, maintenance_evidence_1.computeCalibration)(read.artifacts, { minSamples: a.minSamples }),
|
|
1975
|
+
artifacts: read.artifacts.length,
|
|
1976
|
+
unreadable: read.unreadable,
|
|
1977
|
+
skippedSchema: read.skippedSchema,
|
|
1978
|
+
};
|
|
1492
1979
|
},
|
|
1493
1980
|
async rtm_impact(a) {
|
|
1981
|
+
assertSpecStoreReachable('rtm_impact', store, a.root); // @implements A-SPEC-433
|
|
1494
1982
|
// @implements A-SPEC-189 §14 (round 13) — `projectRootOf(a.root)` was called as a bare
|
|
1495
1983
|
// statement: its only effect was to throw on a bad path, and the advertised contract ("a
|
|
1496
1984
|
// subdirectory resolves up to it") was dropped on the floor. `taint_scan` one function away
|
|
@@ -1499,15 +1987,46 @@ function makeRawHandlers(store, opts) {
|
|
|
1499
1987
|
const root = projectRootOf(a.root);
|
|
1500
1988
|
const scanned = cachedScan(root);
|
|
1501
1989
|
const specs = await store.list();
|
|
1502
|
-
|
|
1990
|
+
// @implements A-SPEC-283
|
|
1991
|
+
// Reuse the persisted graph when its basis still holds. Measured: on the warm path the graph
|
|
1992
|
+
// build is ~81% of the cost and reopening is ~0ms. `scanDigest` is the field that makes this
|
|
1993
|
+
// safe — an uncommitted edit never moves HEAD, so a commit-only basis would happily serve a
|
|
1994
|
+
// graph describing content the working tree no longer has.
|
|
1995
|
+
let head = '';
|
|
1503
1996
|
try {
|
|
1504
|
-
(0,
|
|
1997
|
+
head = (0, node_child_process_1.execFileSync)('git', ['-C', root, 'rev-parse', 'HEAD'], { encoding: 'utf8', env: (0, root_1.cleanSubprocessEnv)(), stdio: ['ignore', 'pipe', 'pipe'] }).trim();
|
|
1998
|
+
}
|
|
1999
|
+
catch { /* non-git */ }
|
|
2000
|
+
const opened = (0, graph_store_1.openReusableGraph)(path.join(root, '.ax', 'rtm.sqlite'), {
|
|
2001
|
+
graphSchema: RTM_GRAPH_SCHEMA,
|
|
2002
|
+
extractorVersion: RTM_EXTRACTOR_VERSION,
|
|
2003
|
+
sourceCommit: head,
|
|
2004
|
+
specFingerprint: `n:${specs.length}/${specs.map((s) => `${s.id}:${s.status}`).sort().join(',')}`,
|
|
2005
|
+
scanDigest: (0, graph_store_1.scanDigest)(scanned),
|
|
2006
|
+
});
|
|
2007
|
+
const g = opened.graph;
|
|
2008
|
+
try {
|
|
2009
|
+
if (!opened.reusable) {
|
|
2010
|
+
(0, rtm_builder_1.buildRtm)(specs, scanned, g);
|
|
2011
|
+
opened.commitBasis();
|
|
2012
|
+
}
|
|
1505
2013
|
// explainImpact, not impactedBy: the bounds and the breadth signal must reach the caller.
|
|
1506
2014
|
// An impact set is not just a list — a broad one means "review the contract", and a consumer
|
|
1507
2015
|
// that cannot tell the difference will try to bundle two hundred call sites.
|
|
1508
2016
|
const { specs: impacted, reachedByDepth, stoppedAt, seedIsHub } = (0, rtm_builder_1.explainImpact)(g, a.changed);
|
|
2017
|
+
// @implements A-SPEC-469 — the graded FILE surface beside the spec closure, same code path
|
|
2018
|
+
// as the S-484 measurement (identity, not reimplementation). Seeds are the changed symbols'
|
|
2019
|
+
// nodes; the files that own them are excluded — a prediction naming the change itself is
|
|
2020
|
+
// vacuously true. The closure fields above stay exactly as they were: mass never enters a
|
|
2021
|
+
// gate decision.
|
|
2022
|
+
const riSeeds = new Map(a.changed.flatMap((qn) => g.codeNodeIds(qn)).map((id) => [id, 1]));
|
|
2023
|
+
const riExclude = new Set([...riSeeds.keys()]
|
|
2024
|
+
.map((id) => (id.includes('@') ? id.slice(id.lastIndexOf('@') + 1) : ''))
|
|
2025
|
+
.filter((f) => f !== ''));
|
|
2026
|
+
const rankedImpact = (0, assoc_arm_1.pprImpactRanked)((0, assoc_arm_1.graphViewOf)(g.dumpCanonical()), riSeeds, riExclude, assoc_arm_1.RANKED_IMPACT_K, assoc_arm_1.RANKED_IMPACT_CONFIG);
|
|
1509
2027
|
return {
|
|
1510
2028
|
impacted,
|
|
2029
|
+
rankedImpact,
|
|
1511
2030
|
reachedByDepth,
|
|
1512
2031
|
bounded: stoppedAt.length > 0 ? stoppedAt.slice(0, 20) : undefined,
|
|
1513
2032
|
...(seedIsHub ? {
|
|
@@ -1520,6 +2039,7 @@ function makeRawHandlers(store, opts) {
|
|
|
1520
2039
|
}
|
|
1521
2040
|
},
|
|
1522
2041
|
async rtm_reindex(a) {
|
|
2042
|
+
assertSpecStoreReachable('rtm_reindex', store, a.root); // @implements A-SPEC-419
|
|
1523
2043
|
// Removal/re-derivation of changedFiles/changedSymbols is not needed
|
|
1524
2044
|
// here (rtm_reindex works off changes/specs/scanned/bySourcePath
|
|
1525
2045
|
// directly), but the guard+diff+scan prologue is identical to
|
|
@@ -1566,14 +2086,33 @@ function makeRawHandlers(store, opts) {
|
|
|
1566
2086
|
// of a FUTURE persisted-graph reindex — build the graph once, then
|
|
1567
2087
|
// apply diffs across calls without a full rebuild — that will
|
|
1568
2088
|
// actually depend on this path being correct. Not load-bearing today.
|
|
1569
|
-
|
|
1570
|
-
|
|
2089
|
+
// @implements A-SPEC-280 — the whole scan, so call edges are re-resolved and the reindexed
|
|
2090
|
+
// graph equals what a full rebuild would have produced. Measured: without this, rtm_reindex
|
|
2091
|
+
// returned a graph with no call edges at all.
|
|
2092
|
+
(0, incremental_1.applyIncremental)(g, changesToApply, { repoRoot: root, specs, scanOne, allScanned: () => scanned });
|
|
2093
|
+
// @implements A-SPEC-478 — the semantic cache WARMING lives here, in the explicit heavy
|
|
2094
|
+
// operation, so the ranking hot path only ever LOOKS UP vectors. Idempotent through the
|
|
2095
|
+
// cache; a tier of none (the shipped default) computes nothing.
|
|
2096
|
+
let semanticWarm;
|
|
2097
|
+
try {
|
|
2098
|
+
const tier = (0, tier_1.resolveSemanticTier)();
|
|
2099
|
+
const rt = (0, embedder_1.makeTierEmbedder)(tier, new vector_cache_1.VectorCache(root));
|
|
2100
|
+
if (rt !== null) {
|
|
2101
|
+
const docTexts = scanned.map((f) => (f.sourcePath + ' ' + f.symbols.map((sy) => sy.qualifiedName).join(' ')).slice(0, 2000));
|
|
2102
|
+
const w = await rt.warm(docTexts);
|
|
2103
|
+
semanticWarm = { tier: rt.label, ...w };
|
|
2104
|
+
}
|
|
2105
|
+
}
|
|
2106
|
+
catch { /* warming is best-effort; reindex's own result is unaffected */ }
|
|
2107
|
+
return { changed, nodes: g.nodeCount(), edges: g.edgeCount(), changeSource,
|
|
2108
|
+
...(semanticWarm !== undefined ? { semanticWarm } : {}) };
|
|
1571
2109
|
}
|
|
1572
2110
|
finally {
|
|
1573
2111
|
g.close(); // release native SQLite handle even if build/apply throws
|
|
1574
2112
|
}
|
|
1575
2113
|
},
|
|
1576
2114
|
async context_bundle(a) {
|
|
2115
|
+
assertSpecStoreReachable('context_bundle', store, a.root); // @implements A-SPEC-433
|
|
1577
2116
|
const foreign = foreignRootReason(store, a.root);
|
|
1578
2117
|
if (foreign)
|
|
1579
2118
|
throw new HandlerRefusal(foreign);
|
|
@@ -1591,6 +2130,7 @@ function makeRawHandlers(store, opts) {
|
|
|
1591
2130
|
}
|
|
1592
2131
|
},
|
|
1593
2132
|
async review_scope(a) {
|
|
2133
|
+
assertSpecStoreReachable('review_scope', store, a.root); // @implements A-SPEC-419
|
|
1594
2134
|
// Guard+diff+scan+changedFiles/changedSymbols prologue: see
|
|
1595
2135
|
// deriveChangedContext (shared with review_prepare/rtm_reindex).
|
|
1596
2136
|
const { root, specs, scanned, changedFiles, changedSymbols, changeSource, scopeFallback, anchorImpactedSpecs, changedTestFiles, unresolvedFiles } = await deriveChangedContext(store, a.root, a, 'review_scope');
|
|
@@ -1616,6 +2156,7 @@ function makeRawHandlers(store, opts) {
|
|
|
1616
2156
|
}
|
|
1617
2157
|
},
|
|
1618
2158
|
async review_prepare(a) {
|
|
2159
|
+
assertSpecStoreReachable('review_prepare', store, a.root); // @implements A-SPEC-419
|
|
1619
2160
|
// Guard+diff+scan+changedFiles/changedSymbols prologue: see
|
|
1620
2161
|
// deriveChangedContext (shared with review_scope/rtm_reindex).
|
|
1621
2162
|
const { root, specs, scanned, changedFiles, changedSymbols, changeSource, scopeFallback } = await deriveChangedContext(store, a.root, a, 'review_prepare');
|
|
@@ -2202,6 +2743,12 @@ function makeRawHandlers(store, opts) {
|
|
|
2202
2743
|
}
|
|
2203
2744
|
written.push(d);
|
|
2204
2745
|
}
|
|
2746
|
+
// @implements A-SPEC-299
|
|
2747
|
+
// Deliberately NOT changed. This looked like the same false pass as spec_slice_approve, but an
|
|
2748
|
+
// approved store-integrity test pins `ok: true` for a run that refused every cluster because a
|
|
2749
|
+
// destination held an unreadable file: nothing was written, nothing was damaged, and `ok` here
|
|
2750
|
+
// means the call completed without harm. That is a defensible contract, and the case against
|
|
2751
|
+
// it was analogy rather than an observed failure. Evidence beats symmetry.
|
|
2205
2752
|
return { ok: true, dryRun, parentReqId: req.id, clusters: report.clusters.length, drafted: written, skipped, refused };
|
|
2206
2753
|
},
|
|
2207
2754
|
/**
|
|
@@ -2252,13 +2799,13 @@ function makeRawHandlers(store, opts) {
|
|
|
2252
2799
|
const reqContent = `---
|
|
2253
2800
|
source:
|
|
2254
2801
|
- kind: user-request
|
|
2255
|
-
ref: ${a.title}
|
|
2802
|
+
ref: ${(0, yaml_scalar_1.yamlScalar)(a.title, 4)}
|
|
2256
2803
|
retrieved: ${new Date().toISOString().split('T')[0]}
|
|
2257
|
-
note: ${a.objective}
|
|
2804
|
+
note: ${(0, yaml_scalar_1.yamlScalar)(a.objective, 4)}
|
|
2258
2805
|
created: ${new Date().toISOString()}
|
|
2259
2806
|
id: ${reqId}
|
|
2260
2807
|
type: REQ
|
|
2261
|
-
title: ${a.title}
|
|
2808
|
+
title: ${(0, yaml_scalar_1.yamlScalar)(a.title)}
|
|
2262
2809
|
status: draft
|
|
2263
2810
|
depends_on: []
|
|
2264
2811
|
---
|
|
@@ -2282,7 +2829,7 @@ ${a.objective}
|
|
|
2282
2829
|
created: ${new Date().toISOString()}
|
|
2283
2830
|
id: ${hspecId}
|
|
2284
2831
|
type: H-SPEC
|
|
2285
|
-
title: Functional Specification for ${a.title}
|
|
2832
|
+
title: ${(0, yaml_scalar_1.yamlScalar)(`Functional Specification for ${a.title}`)}
|
|
2286
2833
|
status: draft
|
|
2287
2834
|
req_type: functional
|
|
2288
2835
|
owner: me
|
|
@@ -2319,7 +2866,7 @@ High level design for ${a.title}.
|
|
|
2319
2866
|
created: ${new Date().toISOString()}
|
|
2320
2867
|
id: ${aspecId}
|
|
2321
2868
|
type: A-SPEC
|
|
2322
|
-
title: Architecture Specification for ${a.title}
|
|
2869
|
+
title: ${(0, yaml_scalar_1.yamlScalar)(`Architecture Specification for ${a.title}`)}
|
|
2323
2870
|
status: draft
|
|
2324
2871
|
slice: ${a.sliceName}
|
|
2325
2872
|
priority: P1
|
|
@@ -2360,7 +2907,7 @@ coverage:
|
|
|
2360
2907
|
boundary: true
|
|
2361
2908
|
id: ${tspecId}
|
|
2362
2909
|
type: T-SPEC
|
|
2363
|
-
title: Test Specification for ${a.title}
|
|
2910
|
+
title: ${(0, yaml_scalar_1.yamlScalar)(`Test Specification for ${a.title}`)}
|
|
2364
2911
|
status: draft
|
|
2365
2912
|
depends_on:
|
|
2366
2913
|
- ${aspecId}
|
|
@@ -2394,7 +2941,16 @@ depends_on:
|
|
|
2394
2941
|
fs.writeFileSync(path.join(specsDir, '02_h-spec', 'functional', `${hspecId}.md`), hspecContent);
|
|
2395
2942
|
fs.writeFileSync(path.join(specsDir, '03_a-spec', `${aspecId}.md`), aspecContent);
|
|
2396
2943
|
fs.writeFileSync(path.join(specsDir, '05_t-spec', `${tspecId}.md`), tspecContent);
|
|
2397
|
-
|
|
2944
|
+
// @implements A-SPEC-299
|
|
2945
|
+
// Read back what was just written. S-298 fixed one cause of unparseable frontmatter; this
|
|
2946
|
+
// closes the class. `list()` silently skips documents it cannot parse, so "created" and
|
|
2947
|
+
// "present in the store" can diverge without anything throwing — which is exactly how a
|
|
2948
|
+
// requirement went missing on 2026-08-28 while the tool reported four specs created.
|
|
2949
|
+
const specsCreated = [reqId, hspecId, aspecId, tspecId];
|
|
2950
|
+
const unreadable = unreadableAmong(await new spec_store_1.LocalMarkdownRepository(specsDir).list(), specsCreated);
|
|
2951
|
+
// The files stay on disk on purpose: deleting the evidence turns a diagnosable bug into a
|
|
2952
|
+
// mystery, and a half-written slice is something a person should see.
|
|
2953
|
+
return { ok: unreadable.length === 0, specsCreated, unreadable };
|
|
2398
2954
|
},
|
|
2399
2955
|
async spec_slice_approve(a) {
|
|
2400
2956
|
const root = a.root ? (0, root_2.resolveProjectRoot)(a.root).root : process.cwd();
|
|
@@ -2428,13 +2984,32 @@ depends_on:
|
|
|
2428
2984
|
}
|
|
2429
2985
|
const rawHandlers = makeRawHandlers(rawStore);
|
|
2430
2986
|
const approvedSpecs = [];
|
|
2987
|
+
const refused = [];
|
|
2988
|
+
if (idsToApprove.length === 0) {
|
|
2989
|
+
// Reporting this as a success is how a caller ends up believing an unapproved slice was
|
|
2990
|
+
// approved. Measured 2026-08-28: a broken-frontmatter REQ made the whole slice invisible
|
|
2991
|
+
// here, and the tool answered `ok: true, approvedSpecs: []`.
|
|
2992
|
+
return { ok: false, approvedSpecs, refused: [{ id: a.sliceName, reason: 'no specs matched this slice name' }] };
|
|
2993
|
+
}
|
|
2431
2994
|
for (const id of idsToApprove) {
|
|
2432
2995
|
const res = (await rawHandlers.spec_approve({ root, id }));
|
|
2433
2996
|
if (res.approved || res.ok) {
|
|
2434
2997
|
approvedSpecs.push(id);
|
|
2998
|
+
continue;
|
|
2435
2999
|
}
|
|
2436
|
-
|
|
2437
|
-
|
|
3000
|
+
const findings = Array.isArray(res.findings)
|
|
3001
|
+
? res.findings.map((f) => f.message).filter(Boolean).join('; ')
|
|
3002
|
+
: '';
|
|
3003
|
+
refused.push({ id, reason: findings ? `${res.reason ?? 'refused'}: ${findings}` : (res.reason ?? 'refused') });
|
|
3004
|
+
// The chain is topological: approving a child whose parent was refused would either fail
|
|
3005
|
+
// again or, worse, succeed against an unsealed parent. Stop and say what is left.
|
|
3006
|
+
break;
|
|
3007
|
+
}
|
|
3008
|
+
for (const id of idsToApprove.slice(approvedSpecs.length + refused.length)) {
|
|
3009
|
+
refused.push({ id, reason: 'not attempted — an earlier spec in the chain was refused' });
|
|
3010
|
+
}
|
|
3011
|
+
// `ok` means the slice is approved. Anything less is not a success.
|
|
3012
|
+
return { ok: refused.length === 0 && approvedSpecs.length > 0, approvedSpecs, refused };
|
|
2438
3013
|
},
|
|
2439
3014
|
async spec_remediate(a) {
|
|
2440
3015
|
const root = a.root ? (0, root_2.resolveProjectRoot)(a.root).root : process.cwd();
|
|
@@ -2456,7 +3031,13 @@ depends_on:
|
|
|
2456
3031
|
if (approveRes.approvedSpecs?.length > 0) {
|
|
2457
3032
|
actionsTaken.push(`Approved slice specs: ${approveRes.approvedSpecs.join(', ')}`);
|
|
2458
3033
|
}
|
|
2459
|
-
|
|
3034
|
+
// Same false-PASS as spec_slice_approve had, one layer up: this handler's whole purpose is to
|
|
3035
|
+
// approve, so reporting success when approval was refused sends the caller into code with an
|
|
3036
|
+
// unapproved slice behind it.
|
|
3037
|
+
const refused = approveRes.refused ?? [];
|
|
3038
|
+
for (const r of refused)
|
|
3039
|
+
actionsTaken.push(`Refused ${r.id}: ${r.reason}`);
|
|
3040
|
+
return { ok: refused.length === 0, actionsTaken, refused };
|
|
2460
3041
|
},
|
|
2461
3042
|
};
|
|
2462
3043
|
}
|