@holmes-lab/holmes-kit 0.3.4 → 0.3.6

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 CHANGED
@@ -4,6 +4,53 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+ <!-- @implements A-SPEC-209 -->
8
+ ## [0.3.6] - 2026-09-01
9
+
10
+ Traceability hardening: comma-listed anchors stop silently losing ids, the claim gate stops
11
+ false-flagging test fixtures, and boilerplate acceptance criteria can no longer be sealed.
12
+
13
+ ### Fixed
14
+
15
+ - **Comma-listed anchors** (A-SPEC-503.1, field-measured +410 lost rtm edges): all set-semantics
16
+ anchor consumers — coverage (`extractAnchors`), graph (`implementsSpecs`/wrong-kind warning) and
17
+ the claim gate — now parse the full `@implements ID, ID, …` list through ONE shared parser. A
18
+ wrong-kind id in second position is finally visible to the warning; prose mentions after the
19
+ list stay non-anchors; single-id behavior is value-identical.
20
+ - **Claim-gate preprocessing asymmetry** (A-SPEC-504.1): the gate judged raw text while the
21
+ scanner stripped string literals, so anchor-shaped FIXTURE strings were refused as unapproved
22
+ claims (measured twice) — breeding a string-concatenation evasion habit. Both surfaces now share
23
+ `stripStringLiterals`; a string-anchor is a claim to nobody, exactly as it is an anchor to
24
+ nobody (no protection lost — pinned as a test).
25
+
26
+ ### Added
27
+
28
+ - **Acceptance-substance sealing gate** (A-SPEC-505.1, dogfooded: 18 boilerplate-criteria REQs
29
+ sealed in one day): `spec_approve` now refuses a REQ whose Success Criteria are absent or
30
+ entirely boilerplate, with cause and remedy in the refusal. Act-time placement — the 154/428
31
+ legacy approved REQs measured in this repo are untouched. The `spec_slice_init` REQ template no
32
+ longer generates that very boilerplate (empty Success Criteria scaffold: fill before sealing).
33
+
34
+ <!-- @implements A-SPEC-209 -->
35
+ ## [0.3.5] - 2026-09-01
36
+
37
+ Two P0 dogfooding fixes: the self-healer no longer breaks what it heals, and Python evidence
38
+ finally flows on venv projects.
39
+
40
+ ### Fixed
41
+
42
+ - **spec_remediate language-blind `//` injection** (A-SPEC-501.1, dogfooded twice): the anchor is
43
+ now injected in the file's own comment syntax (`#` for py/yml/sh/toml + Makefile/Dockerfile,
44
+ `//` for the C family — byte-identical to before, `/* */` for CSS), and a file whose syntax the
45
+ tool does not know is **refused with guidance** instead of broken. Every injected form is
46
+ round-trip verified against the real anchor scanner, so injector and scanner can no longer
47
+ drift apart.
48
+ - **test_run on Python venv projects** (A-SPEC-502.1): the interpreter resolves from evidence —
49
+ `HOLMES_PYTHON` first, then a `.venv`/`venv` carrying `pyvenv.cfg`, then the old `python3`
50
+ fallback (byte-identical when no venv exists). A `conftest.py` in the scoped file list widens
51
+ to its directory (the fixture's actual blast radius) instead of collection-ERRORing the whole
52
+ run — measured: the same tree passed 1995 cases manually while every scoped run failed.
53
+
7
54
  <!-- @implements A-SPEC-209 -->
8
55
  ## [0.3.4] - 2026-09-01
9
56
 
package/dist/.build-id CHANGED
@@ -1 +1 @@
1
- 68b5982-mthe8y11
1
+ c3e5866-mthi2ccn
@@ -106,12 +106,8 @@ function isVendorDir(dir) {
106
106
  return false;
107
107
  }
108
108
  }
109
- const IMPL = /@implements\s+(A-SPEC-\d{3,}(?:\.\d+)?)/g;
110
- // Well-formed governed-but-non-A-SPEC ids used (wrongly) as an @implements
111
- // anchor. Deliberately requires the full "KIND-NNN" shape (kind + '-' +
112
- // 3+ digits) so prose/comments like `@implements A-SPEC refs` (no id number)
113
- // or a bare word never match — only a genuine wrong-kind spec-id does.
114
- const WRONG_KIND_IMPL = /@implements\s+((?:REQ|H-SPEC|C-SPEC|T-SPEC)-\d{3,}(?:\.\d+)?)/g;
109
+ // Anchor parsing (incl. wrong-kind detection) moved to the shared comma-list parser in
110
+ // rtm/anchor-ids (A-SPEC-503.1) the local single-capture regexes dropped every id after a comma.
115
111
  /** Single source of truth for which file extensions CpgScanner ingests (REQ-124 gate 2). */
116
112
  exports.SCANNABLE_EXTENSIONS = ['.ts', '.mts', '.cts', '.tsx', '.jsx', '.js', '.mjs', '.cjs', '.py', '.cs', '.java', '.go', '.rs', '.cpp', '.cc', '.cxx', '.hpp', '.hh', '.h'];
117
113
  const SCANNABLE_RE = /\.(ts|mts|cts|tsx|jsx|js|mjs|cjs|py|cs|java|go|rs|cpp|cc|cxx|hpp|hh|h)$/;
@@ -122,6 +118,7 @@ var test_files_1 = require("./test-files");
122
118
  Object.defineProperty(exports, "TEST_FILE_PATTERNS", { enumerable: true, get: function () { return test_files_1.TEST_FILE_PATTERNS; } });
123
119
  Object.defineProperty(exports, "isTestFile", { enumerable: true, get: function () { return test_files_1.isTestFile; } });
124
120
  const test_files_2 = require("./test-files");
121
+ const anchor_ids_1 = require("../rtm/anchor-ids");
125
122
  // .ts/.mts/.cts can contain TS type-assertion syntax (`<T>x`, arrow-generics)
126
123
  // that the 'tsx' grammar misreads as JSX, so they must stay on 'typescript'.
127
124
  // Everything else in SCANNABLE_EXTENSIONS (.tsx/.jsx/.js/.mjs/.cjs) never uses
@@ -212,8 +209,11 @@ class CpgScanner {
212
209
  // symbol walk, and disambiguating a bare `.h` between C and C++
213
210
  // is out of scope for this slice.
214
211
  const lang = /\.py$/.test(e.name) ? 'python' : (/\.cs$/.test(e.name) ? 'csharp' : (/\.java$/.test(e.name) ? 'java' : (/\.go$/.test(e.name) ? 'go' : (/\.rs$/.test(e.name) ? 'rust' : (/\.(cpp|cc|cxx|hpp|hh|h)$/.test(e.name) ? 'cpp' : (TSX_GRAMMAR_RE.test(e.name) ? 'tsx' : 'typescript'))))));
215
- const implementsSpecs = [...code.matchAll(IMPL)].map(m => m[1]);
216
- const unanchoredImplements = [...code.matchAll(WRONG_KIND_IMPL)].map(m => m[1]);
212
+ // @implements A-SPEC-503.1 comma-listed anchors: the old single-capture regexes
213
+ // dropped every id after the first (C12), and a wrong-kind id in second position was
214
+ // invisible to the warning. Both consumers now ride the shared list parser.
215
+ const implementsSpecs = (0, anchor_ids_1.anchorSpecIds)(code);
216
+ const unanchoredImplements = (0, anchor_ids_1.wrongKindAnchorIds)(code);
217
217
  const sourcePath = (0, source_path_1.toSourcePath)(repoRoot, path.resolve(p));
218
218
  // @implements A-SPEC-140.1
219
219
  // The third parse happens only when asked for. `undefined` from the parser (a language
@@ -15,9 +15,19 @@ exports.newlyClaimed = newlyClaimed;
15
15
  * keeps LLM judgement out of governance. What a change CLAIMS, though, is right there in the text —
16
16
  * that is the decidable subset, and this is it.
17
17
  */
18
- const ANCHOR_RE = /@implements\s+([A-Za-z]+-SPEC-[\w.]+)/g;
18
+ // @implements A-SPEC-503.1 — comma-list support with the gate's OWN lenient token grammar kept
19
+ // (any `*-SPEC-*` shape claims; the shared parser's strict kinds must not narrow a gate). The old
20
+ // single capture let the second id of a listed claim slip past `newlyClaimed` unjudged (C12).
21
+ const anchor_ids_1 = require("../rtm/anchor-ids");
22
+ const ANCHOR_TOKEN = String.raw `[A-Za-z]+-SPEC-[\w.]+`;
23
+ const ANCHOR_RE = new RegExp(String.raw `@implements\s+(${ANCHOR_TOKEN}(?:[ \t]*,[ \t]*${ANCHOR_TOKEN})*)`, 'g');
19
24
  function claimedAnchors(content) {
20
- return [...new Set([...content.matchAll(ANCHOR_RE)].map((m) => m[1]))];
25
+ // @implements A-SPEC-504.1 judge on the SAME literal-stripped text the coverage scanner uses:
26
+ // a string-anchor is an anchor to nobody, so treating it as a claim produced only false-positive
27
+ // refusals (measured twice on legitimate fixtures) and bred the '@'+'implements' evasion habit.
28
+ // Losing it costs no protection — there is no "claimed but unjudged" state to protect against.
29
+ const ids = [...(0, anchor_ids_1.stripStringLiterals)(content).matchAll(ANCHOR_RE)].flatMap((m) => m[1].split(/[ \t]*,[ \t]*/));
30
+ return [...new Set(ids)];
21
31
  }
22
32
  /**
23
33
  * The anchors this write ADDS, relative to what the file already had.
@@ -130,6 +130,7 @@ const package_1 = require("../review/package");
130
130
  const risk_classifier_1 = require("../guardrail/risk-classifier");
131
131
  const risk_gate_1 = require("../guardrail/risk-gate");
132
132
  const elicit_approval_1 = require("./elicit-approval");
133
+ const anchor_comment_1 = require("../rtm/anchor-comment");
133
134
  const approval_queue_1 = require("../governance/approval-queue");
134
135
  const approval_grants_1 = require("../governance/approval-grants");
135
136
  const spec_digest_1 = require("../spec/spec-digest");
@@ -1294,6 +1295,16 @@ function makeRawHandlers(store, opts) {
1294
1295
  if (stubs.length > 0) {
1295
1296
  return { ok: false, reason: (0, approval_blockers_1.placeholderMessage)(stubs) };
1296
1297
  }
1298
+ // @implements A-SPEC-505.1 — acceptance substance, judged at the ACT on the same candidate
1299
+ // the seal would freeze, like the placeholder gate above (154/428 approved REQs here are
1300
+ // non-stated legacy; a validateSpec predicate would brick them all). Judged HERE and not
1301
+ // before the approval channels: an earlier extra read shifts the optimistic-concurrency
1302
+ // window and lets a mid-approval edit get sealed (store-integrity contract: edits win,
1303
+ // approvals lose). The post-grant refusal it costs is the standing property of every
1304
+ // act-time blocker in this block (breaking_change, placeholder) — one ordering, one truth.
1305
+ const unactionable = (0, approval_blockers_1.unactionableCriteriaBlocker)(candidate);
1306
+ if (unactionable)
1307
+ return { ok: false, reason: unactionable };
1297
1308
  // Parents-first: a sealed child snapshotting an unsealed parent would pin nothing. This loop
1298
1309
  // exists to COLLECT the digests; the refusal inside it is now a backstop, because
1299
1310
  // `parentBlockers` above already returns for the same condition with the same sentence. Kept
@@ -2840,7 +2851,6 @@ ${a.objective}
2840
2851
  - Standard project governance rules.
2841
2852
 
2842
2853
  ## Success Criteria
2843
- - Implementation completed and verified by tests.
2844
2854
 
2845
2855
  ## Out of Scope
2846
2856
  - Unrelated feature changes.
@@ -3037,9 +3047,17 @@ depends_on:
3037
3047
  if (a.targetFile && a.aspecId) {
3038
3048
  const fullPath = path.isAbsolute(a.targetFile) ? a.targetFile : path.join(root, a.targetFile);
3039
3049
  if (fs.existsSync(fullPath)) {
3050
+ // @implements A-SPEC-501.1 — BUG-4 (dogfooded twice): the old unconditional
3051
+ // `// @implements` broke Python/YAML targets outright AND produced anchors the scanner
3052
+ // could not read. The syntax comes from the one map the scanner is aligned with, an
3053
+ // unknown syntax is an honest refusal, and the containment check is form-agnostic so a
3054
+ // `#` anchor is not double-injected.
3055
+ const anchorTag = (0, anchor_comment_1.anchorLineFor)(fullPath, a.aspecId);
3040
3056
  const content = fs.readFileSync(fullPath, 'utf8');
3041
- const anchorTag = `// @implements ${a.aspecId}`;
3042
- if (!content.includes(anchorTag)) {
3057
+ if (anchorTag === null) {
3058
+ actionsTaken.push(`앵커 주입 거부: ${path.basename(fullPath)} — 이 확장자의 주석 문법을 모릅니다. 파일 관례에 맞는 주석으로 1행에 '@implements ${a.aspecId}'를 직접 추가하십시오`);
3059
+ }
3060
+ else if (!content.includes(`@implements ${a.aspecId}`)) {
3043
3061
  fs.writeFileSync(fullPath, `${anchorTag}\n${content}`);
3044
3062
  actionsTaken.push(`Injected ${anchorTag} on line 1 of ${a.targetFile}`);
3045
3063
  }
@@ -0,0 +1,7 @@
1
+ export interface PythonEnvDeps {
2
+ env: NodeJS.ProcessEnv;
3
+ exists: (p: string) => boolean;
4
+ platform: NodeJS.Platform;
5
+ }
6
+ export declare function pythonFor(cwd: string, deps: PythonEnvDeps): string;
7
+ export declare function pytestTargets(files: string[]): string[];
@@ -0,0 +1,76 @@
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.pythonFor = pythonFor;
37
+ exports.pytestTargets = pytestTargets;
38
+ // @implements A-SPEC-502.1
39
+ /**
40
+ * The two pure transforms that stand between planTestRun and runPytest's execFileSync — BUG-2,
41
+ * dogfooded on a real .venv project, where both halves of the evidence system idled:
42
+ *
43
+ * WHICH python: PATH's python3 was the system interpreter (no pytest), so last-green was never
44
+ * recorded and ART-4 debt piled 12 entries deep. The interpreter now resolves from EVIDENCE —
45
+ * pyvenv.cfg, the same proof containsPyvenvCfg uses in the scanner — never from a directory name,
46
+ * with HOLMES_PYTHON as the explicit operator escape hatch above all detection.
47
+ *
48
+ * WHAT arguments: conftest.py is a legitimate TEST FILE for anchor/coverage purposes, but not a
49
+ * runnable target — passed verbatim it collection-ERRORs every scoped run (measured: the same
50
+ * tree's manual `pytest tests/` was green, 1995 cases). A conftest argument WIDENS to its
51
+ * directory — the fixture's actual blast radius — because dropping it would verify less and
52
+ * keeping it verbatim verifies nothing.
53
+ */
54
+ const path = __importStar(require("node:path"));
55
+ function pythonFor(cwd, deps) {
56
+ const explicit = deps.env.HOLMES_PYTHON;
57
+ if (typeof explicit === 'string' && explicit.trim() !== '')
58
+ return explicit;
59
+ for (const dir of ['.venv', 'venv']) {
60
+ if (deps.exists(path.join(cwd, dir, 'pyvenv.cfg'))) {
61
+ return deps.platform === 'win32'
62
+ ? path.join(cwd, dir, 'Scripts', 'python.exe')
63
+ : path.join(cwd, dir, 'bin', 'python');
64
+ }
65
+ }
66
+ return 'python3';
67
+ }
68
+ function pytestTargets(files) {
69
+ const out = [];
70
+ for (const f of files) {
71
+ const target = path.basename(f) === 'conftest.py' ? path.dirname(f) : f;
72
+ if (!out.includes(target))
73
+ out.push(target);
74
+ }
75
+ return out;
76
+ }
@@ -75,6 +75,12 @@ export declare function parseJUnitXmlCounts(xml: string, ext?: string): Record<s
75
75
  * there), and a repo with no local jest could never yield meaningful suite evidence anyway.
76
76
  */
77
77
  export declare function jestEntry(cwd: string): string | null;
78
+ /** Run the pytest half of a plan, taking execution evidence from pytest's built-in JUnit XML. */
79
+ export declare function runPytest(files: string[], mode: TestRunPlan['mode'], cwd: string): {
80
+ passed: boolean;
81
+ tail: string;
82
+ executed: Record<string, number>;
83
+ };
78
84
  /**
79
85
  * @implements A-SPEC-137.2
80
86
  * Executed-test count from `cargo test` text output. cargo prints one `test <name> ... ok|FAILED|
@@ -39,6 +39,7 @@ exports.parseGoTestJson = parseGoTestJson;
39
39
  exports.parseExecutedCounts = parseExecutedCounts;
40
40
  exports.parseJUnitXmlCounts = parseJUnitXmlCounts;
41
41
  exports.jestEntry = jestEntry;
42
+ exports.runPytest = runPytest;
42
43
  exports.parseCargoTest = parseCargoTest;
43
44
  exports.runCargo = runCargo;
44
45
  exports.runGradle = runGradle;
@@ -50,6 +51,7 @@ const node_child_process_1 = require("node:child_process");
50
51
  const fs = __importStar(require("node:fs"));
51
52
  const os = __importStar(require("node:os"));
52
53
  const path = __importStar(require("node:path"));
54
+ const python_env_1 = require("./python-env");
53
55
  function planTestRun(scope) {
54
56
  if (scope.tier === 'full') {
55
57
  return { mode: 'full', testFiles: [], reason: 'full regression — run the entire suite' };
@@ -241,6 +243,7 @@ function runJest(files, mode, cwd) {
241
243
  }
242
244
  }
243
245
  /** Run the pytest half of a plan, taking execution evidence from pytest's built-in JUnit XML. */
246
+ // @implements A-SPEC-502.1 — exported for the wiring test (a fake venv python capturing argv).
244
247
  function runPytest(files, mode, cwd) {
245
248
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'holmes-pytest-'));
246
249
  const report = path.join(dir, 'report.xml');
@@ -253,8 +256,12 @@ function runPytest(files, mode, cwd) {
253
256
  // attribute per testcase. pytest's default since v6 is xunit2, which carries only `classname` —
254
257
  // measured: a green 4-passed/1-skipped run yielded ZERO per-file evidence under the default, so
255
258
  // the tier gate would have had nothing to verify against.
259
+ // @implements A-SPEC-502.1 — BUG-2, both halves: the interpreter resolves from pyvenv.cfg
260
+ // evidence (HOLMES_PYTHON above all, python3 fallback byte-identical to the old path), and a
261
+ // conftest.py argument widens to its directory instead of collection-ERRORing the whole run.
256
262
  const args = ['-m', 'pytest', '-q', '-o', 'junit_family=xunit1', `--junit-xml=${report}`,
257
- ...(mode === 'scoped' ? ['--', ...files] : [])];
263
+ ...(mode === 'scoped' ? ['--', ...(0, python_env_1.pytestTargets)(files)] : [])];
264
+ const python = (0, python_env_1.pythonFor)(cwd, { env: process.env, exists: fs.existsSync, platform: process.platform });
258
265
  const read = () => {
259
266
  try {
260
267
  return parseJUnitXmlCounts(fs.readFileSync(report, 'utf8'));
@@ -264,7 +271,7 @@ function runPytest(files, mode, cwd) {
264
271
  }
265
272
  };
266
273
  try {
267
- const out = (0, node_child_process_1.execFileSync)('python3', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
274
+ const out = (0, node_child_process_1.execFileSync)(python, args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
268
275
  return { passed: true, tail: tailOf(out), executed: read() };
269
276
  }
270
277
  catch (e) {
@@ -0,0 +1,9 @@
1
+ /**
2
+ * The one comment-syntax truth the anchor INJECTOR shares with the anchor SCANNER. BUG-4
3
+ * (dogfooded twice): spec_remediate wrote `// @implements` into every file type — a YAML workflow
4
+ * became a parse error, a Python golden file a SyntaxError, and the anchor was not even legible to
5
+ * extractAnchors. Every form emitted here matches the scanner's comment prefixes (`//`, `/*`, `#`);
6
+ * a syntax this map does not know returns null and the caller must REFUSE to write — an honest
7
+ * refusal beats a broken file, and beats an anchor the gate cannot read.
8
+ */
9
+ export declare function anchorLineFor(filePath: string, aspecId: string): string | null;
@@ -0,0 +1,34 @@
1
+ "use strict";
2
+ // @implements A-SPEC-501.1
3
+ /**
4
+ * The one comment-syntax truth the anchor INJECTOR shares with the anchor SCANNER. BUG-4
5
+ * (dogfooded twice): spec_remediate wrote `// @implements` into every file type — a YAML workflow
6
+ * became a parse error, a Python golden file a SyntaxError, and the anchor was not even legible to
7
+ * extractAnchors. Every form emitted here matches the scanner's comment prefixes (`//`, `/*`, `#`);
8
+ * a syntax this map does not know returns null and the caller must REFUSE to write — an honest
9
+ * refusal beats a broken file, and beats an anchor the gate cannot read.
10
+ */
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.anchorLineFor = anchorLineFor;
13
+ const SLASH = new Set(['ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs', 'java', 'c', 'h', 'cc', 'cpp',
14
+ 'hpp', 'cs', 'go', 'rs', 'swift', 'kt', 'kts', 'scala', 'dart']);
15
+ const HASH = new Set(['py', 'rb', 'sh', 'bash', 'zsh', 'fish', 'yml', 'yaml', 'toml', 'pl', 'pm',
16
+ 'r', 'jl', 'cmake', 'mk']);
17
+ const CSS = new Set(['css', 'scss', 'less']);
18
+ const HASH_BASENAMES = new Set(['Makefile', 'Dockerfile', 'Rakefile', 'Gemfile']);
19
+ function anchorLineFor(filePath, aspecId) {
20
+ const basename = filePath.slice(filePath.lastIndexOf('/') + 1);
21
+ if (HASH_BASENAMES.has(basename))
22
+ return `# @implements ${aspecId}`;
23
+ const dot = basename.lastIndexOf('.');
24
+ if (dot <= 0)
25
+ return null;
26
+ const ext = basename.slice(dot + 1).toLowerCase();
27
+ if (SLASH.has(ext))
28
+ return `// @implements ${aspecId}`;
29
+ if (HASH.has(ext))
30
+ return `# @implements ${aspecId}`;
31
+ if (CSS.has(ext))
32
+ return `/* @implements ${aspecId} */`;
33
+ return null;
34
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * The ONE comma-list grammar for anchor lines — C12, dogfooded: four parsers each captured only
3
+ * the first id of a comma-listed anchor (`… <marker> A-SPEC-…110, …219`), so every id after the
4
+ * comma silently vanished from coverage, graph edges (+410 recovered in the field by
5
+ * hand-normalizing 14 files) and claim gating. The list ends at the comma chain: a prose mention
6
+ * after the ids (`… (see another id)`) is NOT a claim, which is what keeps this widening from
7
+ * turning commentary into governance.
8
+ */
9
+ export declare function stripStringLiterals(text: string): string;
10
+ /** Every anchor-marker occurrence's comma-list, as an array of ids per occurrence. */
11
+ export declare function implementsIdLists(text: string): string[][];
12
+ /** Flattened A-SPEC ids across all lists — the graph/coverage consumers' shape. */
13
+ export declare function anchorSpecIds(text: string): string[];
14
+ /** Flattened wrong-kind ids (a governed id anchored where only A-SPEC belongs) — anywhere in a list. */
15
+ export declare function wrongKindAnchorIds(text: string): string[];
@@ -0,0 +1,39 @@
1
+ "use strict";
2
+ // @implements A-SPEC-503.1
3
+ /**
4
+ * The ONE comma-list grammar for anchor lines — C12, dogfooded: four parsers each captured only
5
+ * the first id of a comma-listed anchor (`… <marker> A-SPEC-…110, …219`), so every id after the
6
+ * comma silently vanished from coverage, graph edges (+410 recovered in the field by
7
+ * hand-normalizing 14 files) and claim gating. The list ends at the comma chain: a prose mention
8
+ * after the ids (`… (see another id)`) is NOT a claim, which is what keeps this widening from
9
+ * turning commentary into governance.
10
+ */
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.stripStringLiterals = stripStringLiterals;
13
+ exports.implementsIdLists = implementsIdLists;
14
+ exports.anchorSpecIds = anchorSpecIds;
15
+ exports.wrongKindAnchorIds = wrongKindAnchorIds;
16
+ // @implements A-SPEC-504.1
17
+ // The ONE string-literal preprocessing every anchor-judging surface shares. Measured 2026-09-01:
18
+ // the coverage scanner stripped literals while the claim gate judged raw text, so a legitimate
19
+ // fixture string was refused twice as an "unapproved claim" — and that friction bred the
20
+ // '@'+'implements' evasion habit. A string-anchor is an anchor to nobody and must be a claim to
21
+ // nobody; one definition here keeps the two judgments from drifting apart again.
22
+ const STRINGS_RE = /'(?:\\.|[^'\\\n])*'|"(?:\\.|[^"\\\n])*"|`(?:\\.|[^`\\])*`/g;
23
+ function stripStringLiterals(text) {
24
+ return text.replace(STRINGS_RE, '""');
25
+ }
26
+ const ID = String.raw `(?:REQ|A-SPEC|H-SPEC|C-SPEC|T-SPEC)-\d{3,}(?:\.\d+)?`;
27
+ const LIST_RE = new RegExp(String.raw `@implements[ \t]+(${ID}(?:[ \t]*,[ \t]*${ID})*)`, 'g');
28
+ /** Every anchor-marker occurrence's comma-list, as an array of ids per occurrence. */
29
+ function implementsIdLists(text) {
30
+ return [...text.matchAll(LIST_RE)].map((m) => m[1].split(/[ \t]*,[ \t]*/));
31
+ }
32
+ /** Flattened A-SPEC ids across all lists — the graph/coverage consumers' shape. */
33
+ function anchorSpecIds(text) {
34
+ return [...new Set(implementsIdLists(text).flat().filter((id) => id.startsWith('A-SPEC-')))];
35
+ }
36
+ /** Flattened wrong-kind ids (a governed id anchored where only A-SPEC belongs) — anywhere in a list. */
37
+ function wrongKindAnchorIds(text) {
38
+ return [...new Set(implementsIdLists(text).flat().filter((id) => !id.startsWith('A-SPEC-')))];
39
+ }
@@ -41,6 +41,7 @@ exports.extractAnchors = extractAnchors;
41
41
  exports.scanTestAnchors = scanTestAnchors;
42
42
  exports.computeTestScope = computeTestScope;
43
43
  // @implements A-SPEC-121.2
44
+ const anchor_ids_1 = require("./anchor-ids");
44
45
  const fs = __importStar(require("node:fs"));
45
46
  const path = __importStar(require("node:path"));
46
47
  const spec_types_1 = require("../spec/spec-types");
@@ -256,12 +257,22 @@ function countTestCases(source, filePath) {
256
257
  // every id merely MENTIONED in it — a live run credited 50 cases to the nonexistent A-SPEC-999 from
257
258
  // a fixture string, and injecting one comment into any test file re-credited it to an arbitrary
258
259
  // spec). Both the fixture-string and the mid-code-line injection vectors die here.
259
- const IMPL_LINE_RE = /^[ \t]*(?:\/\/|\/\*|\*|#)[^\n]*?@implements\s+(A-SPEC-\d{3,}(?:\.\d+)?)/gm;
260
- const STRINGS_RE = /'(?:\\.|[^'\\\n])*'|"(?:\\.|[^"\\\n])*"|`(?:\\.|[^`\\])*`/g;
260
+ // @implements A-SPEC-503.1 — the line must still BE a comment line, but the ids on it come from
261
+ // the shared comma-list parser: the old single-capture regex silently dropped every id after the
262
+ // first comma (C12 — +410 rtm edges recovered in the field only by hand-normalizing 14 files).
263
+ const IMPL_LINE_RE = /^[ \t]*(?:\/\/|\/\*|\*|#)[^\n]*?@implements[ \t]/gm;
261
264
  /** A-SPEC ids anchored by a file, counting only standalone comment-line markers outside literals. */
262
265
  function extractAnchors(source) {
263
- const noStrings = source.replace(STRINGS_RE, '""');
264
- return [...new Set([...noStrings.matchAll(IMPL_LINE_RE)].map((m) => m[1]))];
266
+ // @implements A-SPEC-504.1 — the literal-stripping moved to the shared definition the claim
267
+ // gate now uses too; a private copy here is how the two judgments drifted apart in the first place.
268
+ const noStrings = (0, anchor_ids_1.stripStringLiterals)(source);
269
+ const ids = [];
270
+ for (const line of noStrings.split('\n')) {
271
+ IMPL_LINE_RE.lastIndex = 0;
272
+ if (IMPL_LINE_RE.test(line))
273
+ ids.push(...(0, anchor_ids_1.anchorSpecIds)(line));
274
+ }
275
+ return [...new Set(ids)];
265
276
  }
266
277
  /**
267
278
  * @implements A-SPEC-269
@@ -76,3 +76,13 @@ export declare const blockerClause: (label: string, blockers: string[]) => strin
76
76
  * Never throws: a refusal that says less is recoverable, a hook that dies is not.
77
77
  */
78
78
  export declare function blockerSummary(spec: Spec | undefined, resolve: (id: string) => Spec | null, specId?: string): string | null;
79
+ /**
80
+ * The acceptance-substance gate, standing at the SEALING ACT like the placeholder gate above and
81
+ * for that gate's measured reason (A-SPEC-182): a static predicate in validateSpec would turn the
82
+ * already-approved corpus into violations — measured 2026-09-01, 154/428 approved REQs here are
83
+ * non-stated legacy — and brick the harness. Dogfooded origin: 18 REQs sealed in one day whose
84
+ * entire Success Criteria read "implementation completed and verified by tests", while the
85
+ * detector (acceptanceQuality) sat wired only into the post-hoc analyzer. The detector is
86
+ * CONSUMED, not copied — one judgment, two surfaces.
87
+ */
88
+ export declare function unactionableCriteriaBlocker(spec: Spec): string | null;
@@ -5,6 +5,9 @@ exports.placeholderSections = placeholderSections;
5
5
  exports.parentBlockers = parentBlockers;
6
6
  exports.approvalBlockers = approvalBlockers;
7
7
  exports.blockerSummary = blockerSummary;
8
+ exports.unactionableCriteriaBlocker = unactionableCriteriaBlocker;
9
+ // @implements A-SPEC-182
10
+ const acceptance_quality_1 = require("./acceptance-quality");
8
11
  const validator_1 = require("./validator");
9
12
  const breaking_change_1 = require("./breaking-change");
10
13
  const spec_digest_1 = require("./spec-digest");
@@ -245,3 +248,23 @@ function blockerSummary(spec, resolve, specId) {
245
248
  return null;
246
249
  }
247
250
  }
251
+ // @implements A-SPEC-505.1
252
+ /**
253
+ * The acceptance-substance gate, standing at the SEALING ACT like the placeholder gate above and
254
+ * for that gate's measured reason (A-SPEC-182): a static predicate in validateSpec would turn the
255
+ * already-approved corpus into violations — measured 2026-09-01, 154/428 approved REQs here are
256
+ * non-stated legacy — and brick the harness. Dogfooded origin: 18 REQs sealed in one day whose
257
+ * entire Success Criteria read "implementation completed and verified by tests", while the
258
+ * detector (acceptanceQuality) sat wired only into the post-hoc analyzer. The detector is
259
+ * CONSUMED, not copied — one judgment, two surfaces.
260
+ */
261
+ function unactionableCriteriaBlocker(spec) {
262
+ if (spec.type !== 'REQ')
263
+ return null;
264
+ const quality = (0, acceptance_quality_1.acceptanceQuality)(spec);
265
+ if (quality === 'stated')
266
+ return null;
267
+ return quality === 'absent'
268
+ ? `REQ ${spec.id}의 Success Criteria가 비어 있습니다 — 관측 가능한 기준을 채운 뒤 승인하십시오`
269
+ : `REQ ${spec.id}의 Success Criteria가 전량 보일러플레이트입니다('구현·테스트 완료'는 충족 정의가 아닙니다) — 각 항목이 검증 동사·측정 대상을 갖게 고친 뒤 승인하십시오`;
270
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "//": "@implements A-SPEC-209",
3
3
  "name": "@holmes-lab/holmes-kit",
4
- "version": "0.3.4",
4
+ "version": "0.3.6",
5
5
  "description": "Holmes-Kit — deterministic Agentic Software Engineering (ASE) harness with causal traceability (spec chain + D-CPG + RTM + phase guardrail)",
6
6
  "main": "dist/holmes/mcp/server.js",
7
7
  "types": "dist/holmes/mcp/server.d.ts",