@holmes-lab/holmes-kit 0.20.1 → 0.21.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 +85 -0
- package/dist/.build-id +1 -1
- package/dist/holmes/cli/gitignore-merge.js +10 -0
- package/dist/holmes/governance/autonomy.js +6 -0
- package/dist/holmes/governance/constitution.d.ts +11 -0
- package/dist/holmes/governance/constitution.js +15 -1
- package/dist/holmes/guardrail/impact-gate.d.ts +10 -1
- package/dist/holmes/guardrail/impact-gate.js +19 -0
- package/dist/holmes/guardrail/risk-classifier.d.ts +1 -0
- package/dist/holmes/guardrail/risk-classifier.js +26 -3
- package/dist/holmes/guardrail/scope-judgment.d.ts +12 -1
- package/dist/holmes/guardrail/scope-judgment.js +31 -2
- package/dist/holmes/hooks/stop.d.ts +9 -0
- package/dist/holmes/hooks/stop.js +63 -1
- package/dist/holmes/mcp/handlers/graph-operations.d.ts +1 -0
- package/dist/holmes/mcp/handlers/graph-operations.js +18 -1
- package/dist/holmes/mcp/handlers/maintenance-evidence.d.ts +4 -0
- package/dist/holmes/mcp/handlers/maintenance-evidence.js +16 -1
- package/dist/holmes/mcp/handlers/operator-inspection.d.ts +2 -1
- package/dist/holmes/mcp/handlers/operator-inspection.js +25 -3
- package/dist/holmes/mcp/handlers/spec-approval.d.ts +1 -0
- package/dist/holmes/mcp/handlers/spec-approval.js +29 -1
- package/dist/holmes/mcp/handlers.d.ts +4 -1
- package/dist/holmes/mcp/handlers.js +4 -0
- package/dist/holmes/rtm/anchor-comment.d.ts +2 -0
- package/dist/holmes/rtm/anchor-comment.js +8 -0
- package/dist/holmes/rtm/file-anchors.d.ts +9 -0
- package/dist/holmes/rtm/file-anchors.js +128 -0
- package/dist/holmes/rtm/ftt-fulfilment.d.ts +42 -0
- package/dist/holmes/rtm/ftt-fulfilment.js +195 -0
- package/dist/holmes/rtm/known-defects.d.ts +26 -0
- package/dist/holmes/rtm/known-defects.js +77 -0
- package/dist/holmes/rtm/link-census.d.ts +61 -0
- package/dist/holmes/rtm/link-census.js +90 -0
- package/dist/holmes/rtm/trace-gaps.d.ts +20 -0
- package/dist/holmes/rtm/trace-gaps.js +64 -0
- package/dist/holmes/server/dashboard-launcher.d.ts +20 -0
- package/dist/holmes/server/dashboard-launcher.js +24 -1
- package/dist/holmes/server/dashboard.js +40 -2
- package/package.json +1 -1
- package/playbooks/author-slice/PLAYBOOK.md +11 -0
- package/playbooks/tdd-slice/PLAYBOOK.md +4 -0
|
@@ -46,6 +46,7 @@ export declare function createOperatorInspectionHandlers(context: OperatorInspec
|
|
|
46
46
|
ok: boolean;
|
|
47
47
|
reason: string;
|
|
48
48
|
} | {
|
|
49
|
+
fttFulfilment?: import("../../rtm/ftt-fulfilment").FttFulfilment | undefined;
|
|
49
50
|
graphPreview?: {
|
|
50
51
|
impact?: import("../../rtm/impact-advisory").ImpactAdvisory;
|
|
51
52
|
density?: Array<import("../../rtm/anchor-density").AnchorDensityFinding>;
|
|
@@ -161,7 +162,7 @@ export declare function createOperatorInspectionHandlers(context: OperatorInspec
|
|
|
161
162
|
ok: boolean;
|
|
162
163
|
url: string;
|
|
163
164
|
running: boolean;
|
|
164
|
-
census: import("../../server/dashboard-launcher").
|
|
165
|
+
census: import("../../server/dashboard-launcher").DashboardCensusExtended;
|
|
165
166
|
reason?: undefined;
|
|
166
167
|
}>;
|
|
167
168
|
};
|
|
@@ -190,7 +190,28 @@ function createOperatorInspectionHandlers(context) {
|
|
|
190
190
|
catch {
|
|
191
191
|
graphPreview = undefined;
|
|
192
192
|
}
|
|
193
|
-
|
|
193
|
+
// @implements A-SPEC-656 — the same promised-but-absent finding sealing will report, delivered
|
|
194
|
+
// BEFORE sealing (REQ-572: delivery time was the gap, not the calculation) and without a ledger
|
|
195
|
+
// write (a query must not pollute the observation denominator). Same pure function as the seal.
|
|
196
|
+
let fttFulfilment;
|
|
197
|
+
try {
|
|
198
|
+
if (cur.spec.type === 'A-SPEC' && a.root) {
|
|
199
|
+
const root = a.root;
|
|
200
|
+
const { fttFulfilment: compute, locateByBasename } = require('../../rtm/ftt-fulfilment');
|
|
201
|
+
const exists = (rel) => {
|
|
202
|
+
const abs = path.resolve(root, rel);
|
|
203
|
+
return abs.startsWith(root + path.sep) && fs.existsSync(abs);
|
|
204
|
+
};
|
|
205
|
+
const found = compute(String(cur.spec.sections?.['Files to Touch'] ?? ''), exists, locateByBasename(root));
|
|
206
|
+
if (found)
|
|
207
|
+
fttFulfilment = found;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
fttFulfilment = undefined;
|
|
212
|
+
}
|
|
213
|
+
return { ok: true, ...(0, approval_status_1.describeApproval)(cur.spec, context.resolver(all)), ...(graphPreview ? { graphPreview } : {}),
|
|
214
|
+
...(fttFulfilment ? { fttFulfilment } : {}) };
|
|
194
215
|
},
|
|
195
216
|
/**
|
|
196
217
|
* @implements A-SPEC-538.3
|
|
@@ -270,11 +291,12 @@ function createOperatorInspectionHandlers(context) {
|
|
|
270
291
|
return { ok: false, reason: dest.reason };
|
|
271
292
|
try {
|
|
272
293
|
const { startDashboardServer } = await Promise.resolve().then(() => __importStar(require('../../server/dashboard')));
|
|
273
|
-
|
|
294
|
+
// @implements A-SPEC-655 — the census carries the code-link axis beside pipeline coverage.
|
|
295
|
+
const { ensureDashboard, dashboardCensusExtended } = await Promise.resolve().then(() => __importStar(require('../../server/dashboard-launcher')));
|
|
274
296
|
const launch = await ensureDashboard(dest.root, a.port, (opts) => startDashboardServer(opts));
|
|
275
297
|
const rtm = await context.fetchJson(`${launch.url}/api/rtm`);
|
|
276
298
|
const heatmap = await context.fetchJson(`${launch.url}/api/rtm/heatmap`);
|
|
277
|
-
return { ok: true, url: launch.url, running: launch.running, census:
|
|
299
|
+
return { ok: true, url: launch.url, running: launch.running, census: dashboardCensusExtended(rtm, heatmap) };
|
|
278
300
|
}
|
|
279
301
|
catch (err) {
|
|
280
302
|
return { ok: false, reason: `대시보드 기동 실패: ${err?.message ?? String(err)}` };
|
|
@@ -57,6 +57,7 @@ export declare function createSpecApprovalHandlers(context: SpecApprovalContext)
|
|
|
57
57
|
reason: string;
|
|
58
58
|
conflict: import("../../spec/version-conflict").ConflictDetail;
|
|
59
59
|
} | {
|
|
60
|
+
fttFulfilment?: import("../../rtm/ftt-fulfilment").FttFulfilment | undefined;
|
|
60
61
|
impactAdvisoryUnavailable?: "empty" | "unreadable" | undefined;
|
|
61
62
|
impactGraph?: {
|
|
62
63
|
status: "current" | "stale" | "unverified";
|
|
@@ -415,8 +415,36 @@ function createSpecApprovalHandlers(context) {
|
|
|
415
415
|
impactGraph = undefined;
|
|
416
416
|
impactAdvisoryUnavailable = undefined;
|
|
417
417
|
}
|
|
418
|
+
// @implements A-SPEC-656 — the promised-but-absent side of the declaration, filesystem only,
|
|
419
|
+
// independent of the graph block above (no rtm.sqlite needed). Advisory: the seal is already
|
|
420
|
+
// done. The finding rides the response and lands in its own observation ledger so its
|
|
421
|
+
// false-positive rate can be measured before anyone proposes a gate; a failed read drops the
|
|
422
|
+
// field rather than inventing a finding. Parsing, buckets and the walk live in the pure module.
|
|
423
|
+
let fttFulfilment;
|
|
424
|
+
try {
|
|
425
|
+
if (spec.type === 'A-SPEC' && a.root) {
|
|
426
|
+
const root = a.root;
|
|
427
|
+
const { fttFulfilment: compute, locateByBasename, appendFttFulfilment } = require('../../rtm/ftt-fulfilment');
|
|
428
|
+
const exists = (rel) => {
|
|
429
|
+
const abs = path.resolve(root, rel);
|
|
430
|
+
return abs.startsWith(root + path.sep) && fs.existsSync(abs);
|
|
431
|
+
};
|
|
432
|
+
const found = compute(String(candidate.sections?.['Files to Touch'] ?? ''), exists, locateByBasename(root));
|
|
433
|
+
if (found) {
|
|
434
|
+
fttFulfilment = found;
|
|
435
|
+
appendFttFulfilment(root, {
|
|
436
|
+
aspec: a.id, declared: found.declared, missing: found.missing.map((m) => m.path),
|
|
437
|
+
moved: found.moved.map((m) => m.path), ts: new Date().toISOString(),
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
catch {
|
|
443
|
+
fttFulfilment = undefined;
|
|
444
|
+
}
|
|
418
445
|
return { approved: a.id, digest, ...(impactAdvisory ? { impactAdvisory } : {}), ...(anchorDensity ? { anchorDensity } : {}),
|
|
419
|
-
...(impactGraph ? { impactGraph } : {}), ...(impactAdvisoryUnavailable ? { impactAdvisoryUnavailable } : {})
|
|
446
|
+
...(impactGraph ? { impactGraph } : {}), ...(impactAdvisoryUnavailable ? { impactAdvisoryUnavailable } : {}),
|
|
447
|
+
...(fttFulfilment ? { fttFulfilment } : {}) };
|
|
420
448
|
},
|
|
421
449
|
/**
|
|
422
450
|
* Pin a REQ's citations: compute the content digest of every cited source that resolves inside
|
|
@@ -410,6 +410,7 @@ declare function makeRawHandlers(store: SpecStore, opts?: ElicitOpts): {
|
|
|
410
410
|
reason: "hub" | "depth";
|
|
411
411
|
inDegree?: number;
|
|
412
412
|
}[] | undefined;
|
|
413
|
+
traceGaps?: import("../rtm/trace-gaps").TraceGap[] | undefined;
|
|
413
414
|
summariesOmitted?: number | undefined;
|
|
414
415
|
impacted: string[];
|
|
415
416
|
impactedSummaries: {
|
|
@@ -770,6 +771,7 @@ declare function makeRawHandlers(store: SpecStore, opts?: ElicitOpts): {
|
|
|
770
771
|
ok: boolean;
|
|
771
772
|
reason: string;
|
|
772
773
|
} | {
|
|
774
|
+
fttFulfilment?: import("../rtm/ftt-fulfilment").FttFulfilment | undefined;
|
|
773
775
|
graphPreview?: {
|
|
774
776
|
impact?: import("../rtm/impact-advisory").ImpactAdvisory;
|
|
775
777
|
density?: Array<import("../rtm/anchor-density").AnchorDensityFinding>;
|
|
@@ -822,7 +824,7 @@ declare function makeRawHandlers(store: SpecStore, opts?: ElicitOpts): {
|
|
|
822
824
|
ok: boolean;
|
|
823
825
|
url: string;
|
|
824
826
|
running: boolean;
|
|
825
|
-
census: import("../server/dashboard-launcher").
|
|
827
|
+
census: import("../server/dashboard-launcher").DashboardCensusExtended;
|
|
826
828
|
reason?: undefined;
|
|
827
829
|
}>;
|
|
828
830
|
spec_approve: (a: {
|
|
@@ -844,6 +846,7 @@ declare function makeRawHandlers(store: SpecStore, opts?: ElicitOpts): {
|
|
|
844
846
|
reason: string;
|
|
845
847
|
conflict: import("../spec/version-conflict").ConflictDetail;
|
|
846
848
|
} | {
|
|
849
|
+
fttFulfilment?: import("../rtm/ftt-fulfilment").FttFulfilment | undefined;
|
|
847
850
|
impactAdvisoryUnavailable?: "empty" | "unreadable" | undefined;
|
|
848
851
|
impactGraph?: {
|
|
849
852
|
status: "current" | "stale" | "unverified";
|
|
@@ -919,6 +919,10 @@ function makeRawHandlers(store, opts) {
|
|
|
919
919
|
projectRootOf,
|
|
920
920
|
refusal: reason => new HandlerRefusal(reason),
|
|
921
921
|
fileDigestOf,
|
|
922
|
+
// @implements A-SPEC-658 — the impact gate reads trace gaps from the same store and scan the
|
|
923
|
+
// graph handlers use.
|
|
924
|
+
listSpecs: () => store.list(),
|
|
925
|
+
cachedScan,
|
|
922
926
|
}),
|
|
923
927
|
// @implements A-SPEC-642 — placed in the one slot no public-key-order pin covers (the handler suites pin
|
|
924
928
|
// spec_create..spec_upgrade, spec_retire..spec_unseal, spec_unseal+5, spec_approve+4, spec_next+3,
|
|
@@ -6,4 +6,6 @@
|
|
|
6
6
|
* a syntax this map does not know returns null and the caller must REFUSE to write — an honest
|
|
7
7
|
* refusal beats a broken file, and beats an anchor the gate cannot read.
|
|
8
8
|
*/
|
|
9
|
+
export declare const INJECTABLE_EXTENSIONS: readonly string[];
|
|
10
|
+
export declare const INJECTABLE_BASENAMES: readonly string[];
|
|
9
11
|
export declare function anchorLineFor(filePath: string, aspecId: string): string | null;
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* refusal beats a broken file, and beats an anchor the gate cannot read.
|
|
10
10
|
*/
|
|
11
11
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
exports.INJECTABLE_BASENAMES = exports.INJECTABLE_EXTENSIONS = void 0;
|
|
12
13
|
exports.anchorLineFor = anchorLineFor;
|
|
13
14
|
const SLASH = new Set(['ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs', 'java', 'c', 'h', 'cc', 'cpp',
|
|
14
15
|
'hpp', 'cs', 'go', 'rs', 'swift', 'kt', 'kts', 'scala', 'dart']);
|
|
@@ -16,6 +17,13 @@ const HASH = new Set(['py', 'rb', 'sh', 'bash', 'zsh', 'fish', 'yml', 'yaml', 't
|
|
|
16
17
|
'r', 'jl', 'cmake', 'mk']);
|
|
17
18
|
const CSS = new Set(['css', 'scss', 'less']);
|
|
18
19
|
const HASH_BASENAMES = new Set(['Makefile', 'Dockerfile', 'Rakefile', 'Gemfile']);
|
|
20
|
+
// @implements A-SPEC-655 — the injector's syntax set, exported so the census can read every file
|
|
21
|
+
// the injector may have written. Measured 2026-09-16: this map knew `.sh/.yml/.toml/...` and the
|
|
22
|
+
// indexer's SCANNABLE_EXTENSIONS did not, so `spec_remediate` planted anchors the graph could
|
|
23
|
+
// never see (scripts/ci-local.sh → A-SPEC-530.1). `rtm/file-anchors.ts` reads the difference and
|
|
24
|
+
// link-census.test.ts pins INJECTABLE ⊆ SCANNABLE ∪ FILE_ANCHOR over the whole set.
|
|
25
|
+
exports.INJECTABLE_EXTENSIONS = [...new Set([...SLASH, ...HASH, ...CSS])].sort();
|
|
26
|
+
exports.INJECTABLE_BASENAMES = [...HASH_BASENAMES].sort();
|
|
19
27
|
function anchorLineFor(filePath, aspecId) {
|
|
20
28
|
const basename = filePath.slice(filePath.lastIndexOf('/') + 1);
|
|
21
29
|
if (HASH_BASENAMES.has(basename))
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Extensions the injector writes that the indexer does not parse — the census reads these. */
|
|
2
|
+
export declare const FILE_ANCHOR_EXTENSIONS: readonly string[];
|
|
3
|
+
/** True for a PROJECT-RELATIVE POSIX path the file-anchor scanner reads. */
|
|
4
|
+
export declare function isFileAnchorPath(rel: string): boolean;
|
|
5
|
+
/**
|
|
6
|
+
* Repo-relative POSIX path → A-SPEC ids, for every file `isFileAnchorPath` admits. Same walk and
|
|
7
|
+
* resilience as `scanTestAnchors`: unreadable files are skipped, never reported as anchor-less.
|
|
8
|
+
*/
|
|
9
|
+
export declare function scanFileAnchors(root: string): Record<string, string[]>;
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.FILE_ANCHOR_EXTENSIONS = void 0;
|
|
37
|
+
exports.isFileAnchorPath = isFileAnchorPath;
|
|
38
|
+
exports.scanFileAnchors = scanFileAnchors;
|
|
39
|
+
// @implements A-SPEC-655
|
|
40
|
+
/**
|
|
41
|
+
* Anchors in files the injector can write but the indexer never parses.
|
|
42
|
+
*
|
|
43
|
+
* `anchor-comment.ts` knows the comment syntax of `.sh/.yml/.toml/.rb/...` and `spec_remediate`
|
|
44
|
+
* injects `# @implements` there; `cpg-scanner.ts` ingests 19 AST extensions and none of those.
|
|
45
|
+
* Measured 2026-09-16: `scripts/ci-local.sh` carries a hash-comment anchor line naming
|
|
46
|
+
* A-SPEC-530.1 that no graph edge will ever reflect. (The id is deliberately not written here in
|
|
47
|
+
* anchor form: the scanner reads the marker anywhere in a file, and this module's first attempt
|
|
48
|
+
* quoted the line verbatim — which made THIS file the implementer of A-SPEC-530.1.) This scanner reads exactly the injector's set minus the indexer's set —
|
|
49
|
+
* off-graph, like `scanTestAnchors` — so the census can say "anchored in a file the graph does
|
|
50
|
+
* not parse" instead of "no trace". The set difference is computed, not copied: when either list
|
|
51
|
+
* moves, `FILE_ANCHOR_EXTENSIONS` follows, and link-census.test.ts pins the inclusion invariant
|
|
52
|
+
* INJECTABLE ⊆ SCANNABLE ∪ FILE_ANCHOR over the whole set.
|
|
53
|
+
*
|
|
54
|
+
* NOT a graph input. Test files stay out (isTestFile), `.ax/` stays out (spec documents mention
|
|
55
|
+
* the marker in prose), and nothing here creates a node or an edge.
|
|
56
|
+
*/
|
|
57
|
+
const fs = __importStar(require("node:fs"));
|
|
58
|
+
const path = __importStar(require("node:path"));
|
|
59
|
+
const anchor_comment_1 = require("./anchor-comment");
|
|
60
|
+
const cpg_scanner_1 = require("../cpg/cpg-scanner");
|
|
61
|
+
const test_scope_1 = require("./test-scope");
|
|
62
|
+
const SCANNABLE = new Set(cpg_scanner_1.SCANNABLE_EXTENSIONS.map((e) => e.replace(/^\./, '')));
|
|
63
|
+
/** Extensions the injector writes that the indexer does not parse — the census reads these. */
|
|
64
|
+
exports.FILE_ANCHOR_EXTENSIONS = anchor_comment_1.INJECTABLE_EXTENSIONS.filter((e) => !SCANNABLE.has(e));
|
|
65
|
+
const FILE_ANCHOR = new Set(exports.FILE_ANCHOR_EXTENSIONS);
|
|
66
|
+
const BASENAMES = new Set(anchor_comment_1.INJECTABLE_BASENAMES);
|
|
67
|
+
const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', '.ax']);
|
|
68
|
+
// `isTestFile` is language-scoped on purpose (an unscoped `tests/` rule once swallowed
|
|
69
|
+
// `src/holmes/spec/**`); a shell script or workflow under a test directory has no such hazard and is
|
|
70
|
+
// test scaffolding, so the directory rule applies here in full.
|
|
71
|
+
const TEST_DIRS = new Set(['test', 'tests', '__tests__']);
|
|
72
|
+
/** True for a PROJECT-RELATIVE POSIX path the file-anchor scanner reads. */
|
|
73
|
+
function isFileAnchorPath(rel) {
|
|
74
|
+
const segs = rel.split('/');
|
|
75
|
+
if (rel.startsWith('.ax/') || segs.some((seg) => SKIP_DIRS.has(seg)))
|
|
76
|
+
return false;
|
|
77
|
+
if ((0, cpg_scanner_1.isTestFile)(rel) || segs.slice(0, -1).some((seg) => TEST_DIRS.has(seg)))
|
|
78
|
+
return false;
|
|
79
|
+
const base = rel.slice(rel.lastIndexOf('/') + 1);
|
|
80
|
+
if (BASENAMES.has(base))
|
|
81
|
+
return true;
|
|
82
|
+
const dot = base.lastIndexOf('.');
|
|
83
|
+
if (dot <= 0)
|
|
84
|
+
return false;
|
|
85
|
+
return FILE_ANCHOR.has(base.slice(dot + 1).toLowerCase());
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Repo-relative POSIX path → A-SPEC ids, for every file `isFileAnchorPath` admits. Same walk and
|
|
89
|
+
* resilience as `scanTestAnchors`: unreadable files are skipped, never reported as anchor-less.
|
|
90
|
+
*/
|
|
91
|
+
function scanFileAnchors(root) {
|
|
92
|
+
const out = {};
|
|
93
|
+
const walk = (dir) => {
|
|
94
|
+
let entries;
|
|
95
|
+
try {
|
|
96
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
for (const e of entries) {
|
|
102
|
+
if (SKIP_DIRS.has(e.name))
|
|
103
|
+
continue;
|
|
104
|
+
const abs = path.join(dir, e.name);
|
|
105
|
+
if (e.isDirectory()) {
|
|
106
|
+
walk(abs);
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (!e.isFile())
|
|
110
|
+
continue;
|
|
111
|
+
const rel = path.relative(root, abs).split(path.sep).join('/');
|
|
112
|
+
if (!isFileAnchorPath(rel))
|
|
113
|
+
continue;
|
|
114
|
+
let text;
|
|
115
|
+
try {
|
|
116
|
+
text = fs.readFileSync(abs, 'utf8');
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
const ids = (0, test_scope_1.extractAnchors)(text);
|
|
122
|
+
if (ids.length)
|
|
123
|
+
out[rel] = ids;
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
walk(root);
|
|
127
|
+
return out;
|
|
128
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export interface FttItem {
|
|
2
|
+
path: string;
|
|
3
|
+
line: string;
|
|
4
|
+
isNew: boolean;
|
|
5
|
+
alternative: boolean;
|
|
6
|
+
}
|
|
7
|
+
export interface FttFulfilment {
|
|
8
|
+
/** Items examined — so "no finding" can be told from "nothing to examine". */
|
|
9
|
+
declared: number;
|
|
10
|
+
missing: Array<{
|
|
11
|
+
path: string;
|
|
12
|
+
isNew: boolean;
|
|
13
|
+
}>;
|
|
14
|
+
moved: Array<{
|
|
15
|
+
path: string;
|
|
16
|
+
foundAt: string[];
|
|
17
|
+
}>;
|
|
18
|
+
alternatives: Array<{
|
|
19
|
+
path: string;
|
|
20
|
+
line: string;
|
|
21
|
+
}>;
|
|
22
|
+
}
|
|
23
|
+
export interface FulfilmentRecord {
|
|
24
|
+
aspec: string;
|
|
25
|
+
declared: number;
|
|
26
|
+
missing: string[];
|
|
27
|
+
moved: string[];
|
|
28
|
+
ts: string;
|
|
29
|
+
replica?: string;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* List items only, first word only — the REQ-654 token rule (a slashed path or an item-shaped
|
|
33
|
+
* root filename; prose is nothing). Globs and extension-less paths (directories) are not items:
|
|
34
|
+
* their fulfilment is not a single file's existence.
|
|
35
|
+
*/
|
|
36
|
+
export declare function fttItems(fttText: string): FttItem[];
|
|
37
|
+
export declare function fttFulfilment(fttText: string, exists: (rel: string) => boolean, locate: (basename: string) => string[]): FttFulfilment | null;
|
|
38
|
+
/** One walk, then O(1) lookups. Unreadable directories are skipped, never reported as empty. */
|
|
39
|
+
export declare function locateByBasename(root: string): (basename: string) => string[];
|
|
40
|
+
/** The observation ledger — repo-relative paths, a spec id, integers and a timestamp; nothing else. */
|
|
41
|
+
export declare function appendFttFulfilment(root: string, rec: FulfilmentRecord): boolean;
|
|
42
|
+
export declare function readFttFulfilments(root: string): FulfilmentRecord[];
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.fttItems = fttItems;
|
|
37
|
+
exports.fttFulfilment = fttFulfilment;
|
|
38
|
+
exports.locateByBasename = locateByBasename;
|
|
39
|
+
exports.appendFttFulfilment = appendFttFulfilment;
|
|
40
|
+
exports.readFttFulfilments = readFttFulfilments;
|
|
41
|
+
// @implements A-SPEC-656
|
|
42
|
+
/**
|
|
43
|
+
* Files-to-Touch fulfilment — the paths a sealed A-SPEC promised and never made.
|
|
44
|
+
*
|
|
45
|
+
* `declaredImpactGap` says what the declaration MISSED (from the call graph); this says what the
|
|
46
|
+
* declaration PROMISED and the tree does not hold (from the filesystem). Measured 2026-09-16 on
|
|
47
|
+
* this repository: four promised paths across three approved specs never existed, and nothing
|
|
48
|
+
* named them — every gate reads the other direction (code → declaration). The same measurement
|
|
49
|
+
* found the three false-positive shapes a naive existence check would raise, so each has its own
|
|
50
|
+
* bucket: an item written as an alternative ("또는" / "or"), a basename that lives elsewhere
|
|
51
|
+
* (location drift), and prose that is not an item at all (already not a token under REQ-654).
|
|
52
|
+
*
|
|
53
|
+
* ADVISORY, NEVER VERDICT: nothing here can change ok/refuse/grant. NO GUESSING: a failed read
|
|
54
|
+
* propagates to the caller, which drops the field rather than inventing a finding.
|
|
55
|
+
*/
|
|
56
|
+
const fs = __importStar(require("node:fs"));
|
|
57
|
+
const path = __importStar(require("node:path"));
|
|
58
|
+
const scope_judgment_1 = require("../guardrail/scope-judgment");
|
|
59
|
+
const replica_id_1 = require("../governance/replica-id");
|
|
60
|
+
const ITEM_RE = /^\s*[-*]\s+(.*)$/;
|
|
61
|
+
const NEW_RE = /\((신규|new)\)/i;
|
|
62
|
+
const ALT_RE = /또는|\bor\b/;
|
|
63
|
+
const SLASHED = /^[\w@.-]+(?:\/[\w@.-]+)+$/;
|
|
64
|
+
const HAS_EXT = /\.[A-Za-z][A-Za-z0-9]*$/;
|
|
65
|
+
/**
|
|
66
|
+
* List items only, first word only — the REQ-654 token rule (a slashed path or an item-shaped
|
|
67
|
+
* root filename; prose is nothing). Globs and extension-less paths (directories) are not items:
|
|
68
|
+
* their fulfilment is not a single file's existence.
|
|
69
|
+
*/
|
|
70
|
+
function fttItems(fttText) {
|
|
71
|
+
const out = [];
|
|
72
|
+
for (const raw of String(fttText ?? '').replace(/\\/g, '/').split('\n')) {
|
|
73
|
+
const m = ITEM_RE.exec(raw);
|
|
74
|
+
if (!m)
|
|
75
|
+
continue;
|
|
76
|
+
const line = m[1].trim();
|
|
77
|
+
const word = (line.split(/\s+/)[0] ?? '').replace(/^[`'"]+|[`'"]+$/g, '').replace(/^`?([^`]*?)`?\(/, '$1(');
|
|
78
|
+
const token = word.replace(/\(.*$/, ''); // `src/b.ts`(new) → src/b.ts
|
|
79
|
+
if (!token || token.includes('*') || !HAS_EXT.test(token))
|
|
80
|
+
continue;
|
|
81
|
+
if (!(SLASHED.test(token) || (0, scope_judgment_1.isRootFileToken)(token)))
|
|
82
|
+
continue;
|
|
83
|
+
out.push({ path: token, line, isNew: NEW_RE.test(line), alternative: ALT_RE.test(line) });
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
function fttFulfilment(fttText, exists, locate) {
|
|
88
|
+
const items = fttItems(fttText);
|
|
89
|
+
if (items.length === 0)
|
|
90
|
+
return null;
|
|
91
|
+
const missing = [];
|
|
92
|
+
const moved = [];
|
|
93
|
+
const alternatives = [];
|
|
94
|
+
for (const it of items) {
|
|
95
|
+
if (it.alternative) {
|
|
96
|
+
alternatives.push({ path: it.path, line: it.line });
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (exists(it.path))
|
|
100
|
+
continue;
|
|
101
|
+
const base = it.path.slice(it.path.lastIndexOf('/') + 1);
|
|
102
|
+
const foundAt = [...new Set(locate(base))].filter((p) => p !== it.path).sort();
|
|
103
|
+
if (foundAt.length > 0)
|
|
104
|
+
moved.push({ path: it.path, foundAt });
|
|
105
|
+
else
|
|
106
|
+
missing.push({ path: it.path, isNew: it.isNew });
|
|
107
|
+
}
|
|
108
|
+
if (missing.length === 0 && moved.length === 0 && alternatives.length === 0)
|
|
109
|
+
return null;
|
|
110
|
+
return { declared: items.length, missing, moved, alternatives };
|
|
111
|
+
}
|
|
112
|
+
const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', '.ax']);
|
|
113
|
+
/** One walk, then O(1) lookups. Unreadable directories are skipped, never reported as empty. */
|
|
114
|
+
function locateByBasename(root) {
|
|
115
|
+
const byBase = new Map();
|
|
116
|
+
const walk = (dir) => {
|
|
117
|
+
let entries;
|
|
118
|
+
try {
|
|
119
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
for (const e of entries) {
|
|
125
|
+
if (SKIP_DIRS.has(e.name))
|
|
126
|
+
continue;
|
|
127
|
+
const abs = path.join(dir, e.name);
|
|
128
|
+
if (e.isDirectory()) {
|
|
129
|
+
walk(abs);
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (!e.isFile())
|
|
133
|
+
continue;
|
|
134
|
+
const rel = path.relative(root, abs).split(path.sep).join('/');
|
|
135
|
+
const list = byBase.get(e.name) ?? [];
|
|
136
|
+
list.push(rel);
|
|
137
|
+
byBase.set(e.name, list);
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
walk(root);
|
|
141
|
+
return (basename) => [...(byBase.get(basename) ?? [])].sort();
|
|
142
|
+
}
|
|
143
|
+
const LEDGER_RE = /^ftt-fulfilment\.([^.]+)\.jsonl$/;
|
|
144
|
+
/** The observation ledger — repo-relative paths, a spec id, integers and a timestamp; nothing else. */
|
|
145
|
+
function appendFttFulfilment(root, rec) {
|
|
146
|
+
try {
|
|
147
|
+
if (!fs.existsSync(path.join(root, '.ax')))
|
|
148
|
+
return false;
|
|
149
|
+
let replica = 'local';
|
|
150
|
+
try {
|
|
151
|
+
replica = (0, replica_id_1.resolveReplicaId)(root) || 'local';
|
|
152
|
+
}
|
|
153
|
+
catch { /* keep the fallback */ }
|
|
154
|
+
const file = path.join(root, '.ax', 'ledger', `ftt-fulfilment.${replica}.jsonl`);
|
|
155
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
156
|
+
const line = { aspec: rec.aspec, declared: rec.declared, missing: [...rec.missing], moved: [...rec.moved], ts: rec.ts, replica };
|
|
157
|
+
fs.appendFileSync(file, `${JSON.stringify(line)}\n`);
|
|
158
|
+
return true;
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
function readFttFulfilments(root) {
|
|
165
|
+
const dir = path.join(root, '.ax', 'ledger');
|
|
166
|
+
let names;
|
|
167
|
+
try {
|
|
168
|
+
names = fs.readdirSync(dir).filter((n) => LEDGER_RE.test(n)).sort();
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
return [];
|
|
172
|
+
}
|
|
173
|
+
const out = [];
|
|
174
|
+
for (const name of names) {
|
|
175
|
+
let text;
|
|
176
|
+
try {
|
|
177
|
+
text = fs.readFileSync(path.join(dir, name), 'utf8');
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
for (const raw of text.split('\n')) {
|
|
183
|
+
const s = raw.trim();
|
|
184
|
+
if (!s)
|
|
185
|
+
continue;
|
|
186
|
+
try {
|
|
187
|
+
const r = JSON.parse(s);
|
|
188
|
+
if (r && typeof r.aspec === 'string' && Array.isArray(r.missing) && Array.isArray(r.moved) && typeof r.ts === 'string')
|
|
189
|
+
out.push(r);
|
|
190
|
+
}
|
|
191
|
+
catch { /* a corrupt line never breaks the read */ }
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return out;
|
|
195
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export interface KnownDefect {
|
|
2
|
+
file: string;
|
|
3
|
+
line: number;
|
|
4
|
+
reason: string;
|
|
5
|
+
expires: string;
|
|
6
|
+
}
|
|
7
|
+
export interface MalformedMarker {
|
|
8
|
+
file: string;
|
|
9
|
+
line: number;
|
|
10
|
+
text: string;
|
|
11
|
+
why: 'no-reason' | 'no-expires' | 'bad-date';
|
|
12
|
+
}
|
|
13
|
+
export interface KnownDefectJudgement {
|
|
14
|
+
unexpired: KnownDefect[];
|
|
15
|
+
expired: KnownDefect[];
|
|
16
|
+
malformed: MalformedMarker[];
|
|
17
|
+
}
|
|
18
|
+
export declare function knownDefectsIn(source: string, file: string): {
|
|
19
|
+
markers: KnownDefect[];
|
|
20
|
+
malformed: MalformedMarker[];
|
|
21
|
+
};
|
|
22
|
+
/** Expired when the expiry day's UTC midnight is at or before `now` — the day itself counts as expired. */
|
|
23
|
+
export declare function judgeKnownDefects(found: {
|
|
24
|
+
markers: KnownDefect[];
|
|
25
|
+
malformed: MalformedMarker[];
|
|
26
|
+
}, now: Date): KnownDefectJudgement;
|