@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.
Files changed (42) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/dist/.build-id +1 -1
  3. package/dist/holmes/cli/gitignore-merge.js +10 -0
  4. package/dist/holmes/governance/autonomy.js +6 -0
  5. package/dist/holmes/governance/constitution.d.ts +11 -0
  6. package/dist/holmes/governance/constitution.js +15 -1
  7. package/dist/holmes/guardrail/impact-gate.d.ts +10 -1
  8. package/dist/holmes/guardrail/impact-gate.js +19 -0
  9. package/dist/holmes/guardrail/risk-classifier.d.ts +1 -0
  10. package/dist/holmes/guardrail/risk-classifier.js +26 -3
  11. package/dist/holmes/guardrail/scope-judgment.d.ts +12 -1
  12. package/dist/holmes/guardrail/scope-judgment.js +31 -2
  13. package/dist/holmes/hooks/stop.d.ts +9 -0
  14. package/dist/holmes/hooks/stop.js +63 -1
  15. package/dist/holmes/mcp/handlers/graph-operations.d.ts +1 -0
  16. package/dist/holmes/mcp/handlers/graph-operations.js +18 -1
  17. package/dist/holmes/mcp/handlers/maintenance-evidence.d.ts +4 -0
  18. package/dist/holmes/mcp/handlers/maintenance-evidence.js +16 -1
  19. package/dist/holmes/mcp/handlers/operator-inspection.d.ts +2 -1
  20. package/dist/holmes/mcp/handlers/operator-inspection.js +25 -3
  21. package/dist/holmes/mcp/handlers/spec-approval.d.ts +1 -0
  22. package/dist/holmes/mcp/handlers/spec-approval.js +29 -1
  23. package/dist/holmes/mcp/handlers.d.ts +4 -1
  24. package/dist/holmes/mcp/handlers.js +4 -0
  25. package/dist/holmes/rtm/anchor-comment.d.ts +2 -0
  26. package/dist/holmes/rtm/anchor-comment.js +8 -0
  27. package/dist/holmes/rtm/file-anchors.d.ts +9 -0
  28. package/dist/holmes/rtm/file-anchors.js +128 -0
  29. package/dist/holmes/rtm/ftt-fulfilment.d.ts +42 -0
  30. package/dist/holmes/rtm/ftt-fulfilment.js +195 -0
  31. package/dist/holmes/rtm/known-defects.d.ts +26 -0
  32. package/dist/holmes/rtm/known-defects.js +77 -0
  33. package/dist/holmes/rtm/link-census.d.ts +61 -0
  34. package/dist/holmes/rtm/link-census.js +90 -0
  35. package/dist/holmes/rtm/trace-gaps.d.ts +20 -0
  36. package/dist/holmes/rtm/trace-gaps.js +64 -0
  37. package/dist/holmes/server/dashboard-launcher.d.ts +20 -0
  38. package/dist/holmes/server/dashboard-launcher.js +24 -1
  39. package/dist/holmes/server/dashboard.js +40 -2
  40. package/package.json +1 -1
  41. package/playbooks/author-slice/PLAYBOOK.md +11 -0
  42. 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").DashboardCensus;
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
- return { ok: true, ...(0, approval_status_1.describeApproval)(cur.spec, context.resolver(all)), ...(graphPreview ? { graphPreview } : {}) };
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
- const { ensureDashboard, dashboardCensus } = await Promise.resolve().then(() => __importStar(require('../../server/dashboard-launcher')));
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: dashboardCensus(rtm, heatmap) };
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").DashboardCensus;
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;