@dzhechkov/keysarium 1.5.37 → 1.5.39

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/.dz-manifest.json CHANGED
@@ -9,11 +9,11 @@
9
9
  },
10
10
  {
11
11
  "path": "CHANGELOG.md",
12
- "sha256": "d7ebdc513689fb05fd652691b7446335fedbdc137b4420de3ab9c016f8d3aa15"
12
+ "sha256": "eea09507de22a829358a02a818a62767d36addeb585ec42a29d3ada8be2e3f7f"
13
13
  },
14
14
  {
15
15
  "path": "README.md",
16
- "sha256": "06b7590edbe2b63cd5bfd4c50b25ced9b13f8e6ba7d511f060e9c6caf8ba27e5"
16
+ "sha256": "546283f9b70fe63fbe404fc80bd78e66c898522dfcbf10efb684f13f4a89a77d"
17
17
  },
18
18
  {
19
19
  "path": "README.ru.md",
@@ -29,7 +29,7 @@
29
29
  },
30
30
  {
31
31
  "path": "package.json",
32
- "sha256": "3123e3492edd83e8fe521f4246d182b15803689ff83e658f683d96002ef303e3"
32
+ "sha256": "6e7bd27dbde362b7a252abca1ce6fb6412798af0160a88ed4cbd0ddf106e58c8"
33
33
  },
34
34
  {
35
35
  "path": "scripts/sync-templates.js",
@@ -401,7 +401,7 @@
401
401
  },
402
402
  {
403
403
  "path": "templates/.claude/skills/feature-adr/modules/06-implementation-plan.md",
404
- "sha256": "c8fdd1877d8d9515ab29f52c3db49ddd9612b5797bc071e6208def860c02e6f2"
404
+ "sha256": "3903e2694ad47782914b35869bbf19c5f0803b6dafdcfb629df343a5ff00d1b9"
405
405
  },
406
406
  {
407
407
  "path": "templates/.claude/skills/feature-adr/modules/07-code.md",
@@ -409,7 +409,7 @@
409
409
  },
410
410
  {
411
411
  "path": "templates/.claude/skills/feature-adr/modules/08-qe.md",
412
- "sha256": "97e9a7bc54f196b381c4ef71258cddcb2fb48f216df1ba015927c176bad57084"
412
+ "sha256": "bd5b5809142c12fa1378de446b68027a0c342a7a91ad17e4fb3395ca8f6a8508"
413
413
  },
414
414
  {
415
415
  "path": "templates/.claude/skills/feature-adr/modules/09-fleet-qe.md",
@@ -501,7 +501,7 @@
501
501
  },
502
502
  {
503
503
  "path": "templates/.claude/skills/feature-adr/scripts/check-plan-completeness.mjs",
504
- "sha256": "41d85b012a9d6cc172eaa5d7777294f2c01fb54b752ed61c7cbe2d2cd7b79b67"
504
+ "sha256": "2e1afeb8cbc08ce872fc8280a0809e7712ee76879d08b48caa5a0d4082cb8647"
505
505
  },
506
506
  {
507
507
  "path": "templates/.claude/skills/frontend-design/LICENSE.txt",
@@ -1005,5 +1005,5 @@
1005
1005
  }
1006
1006
  ]
1007
1007
  },
1008
- "signature": "pKq4VEm9ivZe6zNp7kRKyB1yqBN5g0W+L2uED0qz38Uowj0I/STFIkSXuytmLmxaHqJEAU9G3+EBtOdqz0hmAw=="
1008
+ "signature": "XoglxxyxKFLcXDz2N8wZzIPbdDffl6Mo5OQwgXt1cIZT1dmM/xHe/s70VsMxlFUiYTgQwYyxjHctP33tG/P+DA=="
1009
1009
  }
package/README.md CHANGED
@@ -416,3 +416,16 @@ structure chosen before understanding, and section-per-edit raises tool-call ove
416
416
 
417
417
  Steps whose deliverable is a returned verdict rather than a document (the complexity router) are
418
418
  deliberately excluded — "skeleton first" is nonsense there.
419
+
420
+ ## Status
421
+
422
+ `1.5.38` — a fix to the bundled `feature-adr` gate script in `templates/`, no pipeline change.
423
+
424
+ The bundled K2 plan-completeness gate now tells an **ABSENT** `00_complexity_assessment.md` from a
425
+ deliberate skip: a missing file is a WARNING that names the missing artifact, while a present file
426
+ carrying no acid table is an honest SKIP. Both used to report as a clean skip, so the acid check
427
+ silently switched itself off whenever Step 0 wrote nothing — MEASURED 2026-08-21, 66 of 199 features
428
+ carried that file and the last four in a row did not.
429
+
430
+ `1.5.39` — the bundled `feature-adr` Step-8 module now describes the amendment gate as the command
431
+ `dz amendment-check` instead of a judgement the reviewer is asked to make. Text only; no pipeline change.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dzhechkov/keysarium",
3
- "version": "1.5.37",
3
+ "version": "1.5.39",
4
4
  "description": "AI Case Research Toolkit for Claude Code — full 7-phase pipeline with feature-adr, dream cycles, background workers, multi-platform support, reward learning, and Cloud.ru AI Factory mapping (--ai-factory flag)",
5
5
  "main": "src/cli.js",
6
6
  "bin": {
package/sbom.json CHANGED
@@ -25,7 +25,7 @@
25
25
  "hashes": [
26
26
  {
27
27
  "alg": "SHA-256",
28
- "content": "d7ebdc513689fb05fd652691b7446335fedbdc137b4420de3ab9c016f8d3aa15"
28
+ "content": "eea09507de22a829358a02a818a62767d36addeb585ec42a29d3ada8be2e3f7f"
29
29
  }
30
30
  ]
31
31
  },
@@ -35,7 +35,7 @@
35
35
  "hashes": [
36
36
  {
37
37
  "alg": "SHA-256",
38
- "content": "06b7590edbe2b63cd5bfd4c50b25ced9b13f8e6ba7d511f060e9c6caf8ba27e5"
38
+ "content": "546283f9b70fe63fbe404fc80bd78e66c898522dfcbf10efb684f13f4a89a77d"
39
39
  }
40
40
  ]
41
41
  },
@@ -75,7 +75,7 @@
75
75
  "hashes": [
76
76
  {
77
77
  "alg": "SHA-256",
78
- "content": "3123e3492edd83e8fe521f4246d182b15803689ff83e658f683d96002ef303e3"
78
+ "content": "6e7bd27dbde362b7a252abca1ce6fb6412798af0160a88ed4cbd0ddf106e58c8"
79
79
  }
80
80
  ]
81
81
  },
@@ -1005,7 +1005,7 @@
1005
1005
  "hashes": [
1006
1006
  {
1007
1007
  "alg": "SHA-256",
1008
- "content": "c8fdd1877d8d9515ab29f52c3db49ddd9612b5797bc071e6208def860c02e6f2"
1008
+ "content": "3903e2694ad47782914b35869bbf19c5f0803b6dafdcfb629df343a5ff00d1b9"
1009
1009
  }
1010
1010
  ]
1011
1011
  },
@@ -1025,7 +1025,7 @@
1025
1025
  "hashes": [
1026
1026
  {
1027
1027
  "alg": "SHA-256",
1028
- "content": "97e9a7bc54f196b381c4ef71258cddcb2fb48f216df1ba015927c176bad57084"
1028
+ "content": "bd5b5809142c12fa1378de446b68027a0c342a7a91ad17e4fb3395ca8f6a8508"
1029
1029
  }
1030
1030
  ]
1031
1031
  },
@@ -1255,7 +1255,7 @@
1255
1255
  "hashes": [
1256
1256
  {
1257
1257
  "alg": "SHA-256",
1258
- "content": "41d85b012a9d6cc172eaa5d7777294f2c01fb54b752ed61c7cbe2d2cd7b79b67"
1258
+ "content": "2e1afeb8cbc08ce872fc8280a0809e7712ee76879d08b48caa5a0d4082cb8647"
1259
1259
  }
1260
1260
  ]
1261
1261
  },
@@ -198,6 +198,13 @@ After the plan is written, run the gate. It is a script, not a judgement call:
198
198
  node .claude/skills/feature-adr/scripts/check-plan-completeness.mjs features/<slug>
199
199
  ```
200
200
 
201
+ The path above is relative to the workspace the skill is INSTALLED in. Working inside a target repo
202
+ that has no feature-adr install of its own? Pass the absolute path to the script instead — the
203
+ ultracode workflow does this for you (it tries `args.gateScript`, then the workspace copy, then the
204
+ target-repo copy, and refuses with `tooling-missing` if none exists). The gate is language-neutral:
205
+ C2 recognises JS/TS, pytest, Go, Rust, JVM and .NET test paths, extensible per project with
206
+ `testPathRules` in `architecture/project-skills.json`.
207
+
201
208
  | Exit | Verdict | What you do |
202
209
  |------|---------|-------------|
203
210
  | `0` | PASS | proceed to Step 7 |
@@ -160,18 +160,32 @@ The Confirmation-to-test link is load-bearing. If absent, grade no better than C
160
160
 
161
161
  ### 3b. Amendment Gate (P2 — amendments inherit the Confirmation discipline)
162
162
 
163
- Read the `## Amendments` sections of `03.5_ideation_report.md` and `06_implementation_plan.md` (skip
164
- silently if absent). For EVERY `AM-N` row verify:
165
- 1. the row contains a `→ test ` token naming a real test (the fixed shape);
166
- 2. the named test **exists** in the suite;
167
- 3. it is **non-vacuous** — include the amendment test files in the SAME
168
- `dz discrimination-check --test <f1,f2,...> --base HEAD --json` run as the ADR property test. An
169
- amendment test that stays green at pre-feature base proves nothing about its amendment;
170
- 4. a **safeguard** amendment's test proves the safeguard FIRES on a real input (a structurally-dead
171
- safeguard passes an existence test and never fires in production).
172
-
173
- A missing, unnamed, or vacuous amendment test is a HIGH gap. This closes the distance between "we caught
174
- the design flaw" (challenge-panel/QCSD) and "we proved the fix works".
163
+ **Do not judge this yourself — RUN the check.** Resolution of every `AM-N` row to a real test is a
164
+ deterministic question, and a deterministic question must not live in prose (cost-of-detection ladder).
165
+ It used to live here, and `features/qe-scoped-review` shipped with five named amendment test ids of
166
+ which none existed, while its plan recorded `## Amendments: None`.
167
+
168
+ ```bash
169
+ dz amendment-check --slug <feature-slug> --json # or --feature-dir <abs path>
170
+ ```
171
+
172
+ | Outcome | Exit | What it means for the gate |
173
+ |---|---|---|
174
+ | `pass` | 0 | every row resolves to a test found inside the file the row names |
175
+ | `skip` | 0 | no `## Amendments` section — an absence, stated, never a silent pass |
176
+ | `fail` | 1 | a HIGH gap: quote every reason verbatim into the QE report |
177
+ | `not-established` | 3 | the grammar matched nothing, or the inputs were unreadable — **never a pass** |
178
+
179
+ Empty stdout, a crash, or a missing `dz` is `not-established`, not a clean gate.
180
+
181
+ **What the command does NOT prove:** that a resolved test discriminates. Vacuity stays yours, in the
182
+ SAME `dz discrimination-check --test <f1,f2,...> --base HEAD --json` run as the ADR property test — an
183
+ amendment test that stays green at pre-feature base proves nothing about its amendment. A **safeguard**
184
+ amendment additionally needs its test to prove the safeguard FIRES on a real input: a structurally-dead
185
+ safeguard passes an existence check and never fires in production.
186
+
187
+ This closes the distance between "we caught the design flaw" (challenge-panel/QCSD) and "we proved the
188
+ fix works".
175
189
 
176
190
  ### 3c. I/O-on-pure-path + fixture-swap hunt (P5)
177
191
 
@@ -85,6 +85,79 @@ const adrFiles = existsSync(adrDir) ? readdirSync(adrDir).filter(f => f.endsWith
85
85
  const planClaimsAdrWork = /\bADR-\d+/.test(plan);
86
86
  if (adrFiles.length === 0 && planClaimsAdrWork) notEstablished('no ADR files under 03_adr/, yet the plan cites ADR-<n> — completeness cannot be established');
87
87
 
88
+ // ── C2 test-path predicate (P16/D1) — language-neutral, two stages ────────────────────────────
89
+ // The shipped rule demanded a `.test.(ts|mjs|js)` SUFFIX, so C2 could never be satisfied outside
90
+ // JS/TS: MEASURED 2026-08-20 over a 15-path corpus, 4/4 JS forms matched and 0/11 non-JS forms did
91
+ // (`tests/test_x.py`, `x_test.go`, `tests/x.rs`, `XTest.java`, `XTests.cs`, …) — and zero matches is
92
+ // the C2 FAIL branch, so the gate blocked every non-JS repo. The fix is a PREDICATE, not a wider
93
+ // regex: "contains the word test" would admit `docs/testing.md` and `src/latest.rs`, and a gate that
94
+ // passes prose is worse than one that fails Python.
95
+ //
96
+ // STAGE 1 (extractCandidatePaths) — pull candidate PATHS out of prose. The left edge is ANCHORED on
97
+ // a boundary character so a longer token can never donate a substring (`xcontests/foo.test.ts` must
98
+ // not yield `foo.test.ts`), and a candidate MUST carry at least one directory segment and an
99
+ // extension — a bare `test_staleness.py` written inside a sentence is a name, not a path.
100
+ // STAGE 2 (isTestPath) — ONE `$`-anchored rule per ecosystem. The anchoring IS the safety argument:
101
+ // every acid item is refused by construction rather than by a blocklist.
102
+ // DELIBERATELY NOT DONE: no `existsSync` decides whether a Confirmation path is valid — C2 runs
103
+ // BEFORE Step 7 writes the test, so a file-existence probe there would fail every honest plan.
104
+ const CANDIDATE_PATH_RE = /(?:^|[\s`'"(|])((?:[.\w@][\w@.-]*\/)+[\w@.-]+\.[A-Za-z0-9]+)/g;
105
+ const extractCandidatePaths = (text) => {
106
+ const found = [];
107
+ CANDIDATE_PATH_RE.lastIndex = 0;
108
+ let m;
109
+ while ((m = CANDIDATE_PATH_RE.exec(String(text))) !== null) found.push(m[1]);
110
+ return [...new Set(found)];
111
+ };
112
+
113
+ // One rule per ecosystem, each anchored at the END of the path. Adding an ecosystem is a data edit.
114
+ const BUILTIN_TEST_PATH_RULES = [
115
+ { ecosystem: 'js', re: /\.(?:test|spec)\.(?:ts|tsx|mts|cts|mjs|cjs|js|jsx)$/ },
116
+ { ecosystem: 'pytest', re: /(?:^|\/)test_[^/]+\.py$/ },
117
+ { ecosystem: 'pytest', re: /[^/]+_test\.py$/ },
118
+ { ecosystem: 'go', re: /[^/]+_test\.go$/ },
119
+ { ecosystem: 'rust', re: /(?:^|\/)tests\/[^/]+\.rs$/ },
120
+ { ecosystem: 'rust', re: /(?:^|\/)tests\.rs$/ },
121
+ { ecosystem: 'jvm', re: /[^/]+(?:Test|Tests|IT|Spec)\.(?:java|kt|kts|scala|groovy)$/ },
122
+ { ecosystem: 'dotnet', re: /[^/]+(?:Test|Tests)\.(?:cs|fs|vb)$/ },
123
+ ];
124
+ const KNOWN_ECOSYSTEMS = 'JS/TS *.test|spec.[tj]s, pytest test_*.py + *_test.py, Go *_test.go, Rust tests/*.rs + tests.rs, JVM *Test|Tests|IT|Spec.java|kt|kts|scala|groovy, .NET *Test|Tests.cs|fs|vb';
125
+ // The override channel lives in the manifest the pipeline ALREADY probes at Step 0 — a second
126
+ // dotfile would be a second thing to keep in sync. Resolved against the TARGET REPO cwd (the gate
127
+ // command cd's into the repo first); never shelled out to `dz project-skills`, because a gate that
128
+ // needs a working `dz` to decide whether a path is a test inherits every one of dz's own failures.
129
+ const MANIFEST_REL = 'architecture/project-skills.json';
130
+ function loadTestPathRules() {
131
+ const rules = BUILTIN_TEST_PATH_RULES.slice();
132
+ const manifestPath = join(process.cwd(), MANIFEST_REL);
133
+ if (!existsSync(manifestPath)) return rules;
134
+ let text = null;
135
+ try { text = readFileSync(manifestPath, 'utf-8'); } catch { notEstablished(`testPathRules unreadable: ${MANIFEST_REL} could not be read`); }
136
+ let manifest = null;
137
+ try { manifest = JSON.parse(text); } catch { notEstablished(`testPathRules unreadable: ${MANIFEST_REL} is not valid JSON`); }
138
+ const extra = (manifest !== null && typeof manifest === 'object' && !Array.isArray(manifest)) ? manifest.testPathRules : undefined;
139
+ // ABSENT is not an error — the built-ins stand. Only a PRESENT-but-broken override is fatal: a
140
+ // silent fall-back to built-ins would tell a project its rules are live when they are not.
141
+ if (extra === undefined || extra === null) return rules;
142
+ if (!Array.isArray(extra)) notEstablished('testPathRules unreadable: testPathRules is present but is not an array');
143
+ for (const entry of extra) {
144
+ if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) notEstablished('testPathRules unreadable: an entry is not a {ecosystem, pattern} object');
145
+ const eco = typeof entry.ecosystem === 'string' ? entry.ecosystem.trim() : '';
146
+ if (eco === '') notEstablished('testPathRules unreadable: an entry has no non-empty "ecosystem"');
147
+ if (typeof entry.pattern !== 'string' || entry.pattern === '') notEstablished(`testPathRules unreadable: entry "${eco}" has no non-empty "pattern"`);
148
+ if (entry.pattern.length > 200) notEstablished(`testPathRules unreadable: entry "${eco}" pattern exceeds 200 characters`);
149
+ // The ONE catastrophic-backtracking shape a length cap does not bound. Refused by SHAPE, because
150
+ // this sandboxless script cannot enforce a match timeout.
151
+ if (/\([^)]*[+*][^)]*\)[+*]/.test(entry.pattern)) notEstablished(`testPathRules unreadable: entry "${eco}" pattern nests a quantifier over a quantified group (catastrophic backtracking)`);
152
+ let re = null;
153
+ // WRAP, never append a bare `$`: `foo|bar` + `$` anchors only the LAST branch, so `src/foolish.py`
154
+ // would match `foo` unanchored and an arbitrary source file would count as a Confirmation test.
155
+ try { re = new RegExp('(?:' + entry.pattern + ')$'); } catch { notEstablished(`testPathRules unreadable: entry "${eco}" pattern is not a valid regular expression`); }
156
+ rules.push({ ecosystem: eco, re: re });
157
+ }
158
+ return rules;
159
+ }
160
+
88
161
  if (adrFiles.length === 0 && TIER_REQUIRES_ADR) {
89
162
  failures.push(`C1: tier ${TIER} has NO ADR files under 03_adr/ — an M/L/XL feature owes at least one ADR; the plan cannot be complete against decisions that were never written`);
90
163
  failures.push(`C2: tier ${TIER} has no 03_adr/ — no Confirmation test can be checked`);
@@ -101,18 +174,78 @@ if (adrFiles.length === 0 && TIER_REQUIRES_ADR) {
101
174
  }
102
175
 
103
176
  // C2 — every Confirmation-listed test file path appears in the plan
104
- const testPathRe = /(?:packages\/[\w@/.-]+\/test\/[\w./-]+\.test\.(?:ts|mjs|js)|tests?\/[\w./-]+\.test\.(?:ts|mjs|js))/g;
177
+ const testPathRules = loadTestPathRules();
178
+ const isTestPath = (p) => testPathRules.some((r) => r.re.test(p));
105
179
  for (const f of adrFiles) {
106
180
  const adr = readFileSync(join(adrDir, f), 'utf-8');
107
181
  const confIdx = adr.search(/^##+\s*Confirmation/mi);
108
182
  if (confIdx < 0) { failures.push(`C2: ${safe(f)} has no Confirmation section`); continue; }
109
183
  const conf = adr.slice(confIdx);
110
- const paths = [...new Set((conf.match(testPathRe) ?? []))];
111
- if (paths.length === 0) { failures.push(`C2: ${safe(f)} Confirmation names no test file paths`); continue; }
184
+ const paths = extractCandidatePaths(conf).filter(isTestPath);
185
+ // UNKNOWN ECOSYSTEM STAYS A FAILURE, never a WARN: a gate that downgrades itself on the one
186
+ // repo it cannot read is a gate that is off exactly where it is needed. The remedy is named
187
+ // in the message instead.
188
+ if (paths.length === 0) { failures.push(`C2: ${safe(f)} Confirmation names no test file paths (recognised: ${KNOWN_ECOSYSTEMS}; extend the vocabulary with a "testPathRules" array in ${MANIFEST_REL})`); continue; }
112
189
  for (const p of paths) if (!plan.includes(p)) failures.push(`C2: ${safe(f)} Confirmation test ${safe(p)} NOT named in the plan`);
113
190
  }
114
191
  }
115
192
 
193
+ // ── C3 target-path admissibility (P16/D3) — named rules, not one character class ───────────────
194
+ // A LEADING DOT is legitimate: `.claude/`, `.github/workflows/ci.yml`, `.gitignore`, `.env.example`
195
+ // are ordinary targets in every ecosystem, and until the 2026-08-20 bootstrap fix the `^[\w@]…`
196
+ // class rejected every one of them — so feature-adr could not name its OWN files and no feature
197
+ // could touch CI config. Found by a run that stopped at this very gate while trying to fix the gate.
198
+ //
199
+ // This is the HARDENING of that minimal fix, and it answers the three holes it left:
200
+ // (a) the traversal rule was a SUBSTRING test, so an ordinary filename `foo..bar.ts` collected the
201
+ // label `path traversal` (MEASURED). It is replaced by SEGMENT rules: a segment exactly `..` is
202
+ // traversal; a segment of nothing but dots is a DEGENERATE NAME — a different defect that must
203
+ // not borrow the traversal label. Coverage is not weakened: `..`, `../`, `../etc/passwd` and
204
+ // `.a/../b` all carry a `..` SEGMENT and are still refused.
205
+ // (b) a directory-shaped target (`.claude/`) was ACCEPTED and gives Step-6/7 plan-vs-diff matching
206
+ // nothing concrete to verify against — rejected now, by its own name.
207
+ // (c) every refusal names WHAT is wrong. The old catch-all `not a plain repo-relative path` fired
208
+ // for an illegal character, a bad first character and a stray dot alike.
209
+ // A named reason that LIES is worse than a generic one: the leading-stem message is emitted ONLY for
210
+ // a path that actually starts with a dot, never for `-foo.ts` or `+x.ts`.
211
+ const MARKDOWN_RESIDUE_RE = /[*#\[\]()]/;
212
+ const LEGAL_TARGET_CHAR_RE = /[A-Za-z0-9_@./-]/;
213
+ function classifyTargetPath(path) {
214
+ const reasons = [];
215
+ if (/\s/.test(path)) reasons.push('contains whitespace');
216
+ if (MARKDOWN_RESIDUE_RE.test(path)) reasons.push('markdown residue');
217
+ if (path.startsWith('/')) reasons.push('absolute path — targets are repo-relative');
218
+ if (path.startsWith('~')) reasons.push('home-relative path — targets are repo-relative');
219
+ const anchoredHere = path === '.' || path.startsWith('./');
220
+ if (anchoredHere) reasons.push('not repo-relative (anchored at the current directory)');
221
+ const segs = path.split('/');
222
+ let dotDot = false, degenerate = false, emptySeg = false, trailingDotSeg = false;
223
+ for (let i = 0; i < segs.length; i++) {
224
+ const s = segs[i];
225
+ if (s === '..') { dotDot = true; continue; }
226
+ if (/^\.+$/.test(s)) { if (s.length >= 3 || (i > 0 && !anchoredHere)) degenerate = true; continue; }
227
+ if (s === '') { if (i > 0 && i < segs.length - 1) emptySeg = true; continue; }
228
+ if (s.endsWith('.')) trailingDotSeg = true;
229
+ }
230
+ if (dotDot) reasons.push("path traversal ('..' segment)");
231
+ if (degenerate) reasons.push('degenerate path segment');
232
+ if (emptySeg) reasons.push('empty path segment');
233
+ if (path.endsWith('/')) reasons.push('trailing slash — names a directory, not a file');
234
+ if (trailingDotSeg) reasons.push("path segment ends with '.'");
235
+ // Residual character check — LAST, and it names the first offender instead of shrugging. Characters
236
+ // already explained by the whitespace/markdown rules are skipped so one defect gets one reason.
237
+ const scanned = path.startsWith('~') ? path.slice(1) : path;
238
+ const illegal = [...scanned].find((c) => !LEGAL_TARGET_CHAR_RE.test(c) && !/\s/.test(c) && !MARKDOWN_RESIDUE_RE.test(c));
239
+ if (illegal !== undefined) reasons.push(`illegal character '${illegal}'`);
240
+ // The leading stem: a dot must be followed by a REAL name (`.-` has an empty stem), and any other
241
+ // path must start with a word character, '@' or '.'. Suppressed where a structural rule above
242
+ // already explains the first character, so the operator is never handed a reason that is false.
243
+ else if (!/^\.?[\w@]/.test(path) && !path.startsWith('/') && !path.startsWith('~') && !anchoredHere && !dotDot && !degenerate) {
244
+ reasons.push(path.startsWith('.') ? 'empty stem after the leading dot' : "path does not start with a letter, digit, '_', '@' or '.'");
245
+ }
246
+ return reasons;
247
+ }
248
+
116
249
  // C3 — EXPECTED_CODE_TARGETS block, line-level validation
117
250
  const blockM = plan.match(/EXPECTED_CODE_TARGETS:\s*\n((?:\s*[-*]\s*.+\n?)+)/);
118
251
  if (!blockM) failures.push('C3: no EXPECTED_CODE_TARGETS: block in the plan');
@@ -121,11 +254,7 @@ else {
121
254
  if (lines.length === 0) failures.push('C3: EXPECTED_CODE_TARGETS block is empty');
122
255
  for (const ln of lines) {
123
256
  const path = ln.replace(/^[-*]\s*/, '').replace(/`/g, '').trim();
124
- const reasons = [];
125
- if (/\s/.test(path)) reasons.push('contains whitespace');
126
- if (/\.\./.test(path)) reasons.push('path traversal');
127
- if (/[*#\[\]()]/.test(path)) reasons.push('markdown residue');
128
- if (!/^[\w@][\w@/.-]*$/.test(path)) reasons.push('not a plain repo-relative path');
257
+ const reasons = classifyTargetPath(path);
129
258
  if (reasons.length) failures.push(`C3: target line rejected: "${safe(ln)}" — ${reasons.join(', ')}`);
130
259
  }
131
260
  }
@@ -152,7 +281,15 @@ if (acidArg) {
152
281
  const declaresTable = /\|\s*Acid case\s*\|/i.test(complexity);
153
282
  if (declaresTable && acidTokens.length === 0) failures.push('C4-malformed: 00_complexity_assessment.md declares an acid-case table but NO `| A<n> |` row parsed from it');
154
283
  }
155
- if (acidTokens.length === 0) skips.push('C4: no acid corpus declared (no --acid, no `| A<n> |` table in 00_complexity_assessment.md) — acid-naming check SKIPPED');
284
+ // ABSENT INPUT is not a deliberate skip. Until 2026-08-21 both read the same: `SKIP C4`. The owner
285
+ // asked why the tier is recorded nowhere, and the answer was that Step 0 never writes
286
+ // 00_complexity_assessment.md at all (MEASURED: 66 of 199 features have it; the last four in a row
287
+ // did not) — so the acid check quietly switched itself off for every one of them, including the
288
+ // features that INTRODUCED it. A check that could not read its input must not look like a check that
289
+ // read its input and found nothing to do.
290
+ if (acidTokens.length === 0 && !acidArg && !existsSync(complexityPath)) {
291
+ warnings.push('C4: 00_complexity_assessment.md is ABSENT — the acid-naming check had no input to read (a missing Step-0 artifact, not a clean skip)');
292
+ } else if (acidTokens.length === 0) skips.push('C4: no acid corpus declared (no --acid, no `| A<n> |` table in 00_complexity_assessment.md) — acid-naming check SKIPPED');
156
293
  else for (const t of acidTokens) if (!new RegExp(`\\b${t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'i').test(plan)) failures.push(`C4: acid token "${safe(t)}" (from ${acidSource}) not named in the plan`);
157
294
 
158
295
  // C5 — Inputs read line