@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 +7 -7
- package/README.md +13 -0
- package/package.json +1 -1
- package/sbom.json +6 -6
- package/templates/.claude/skills/feature-adr/modules/06-implementation-plan.md +7 -0
- package/templates/.claude/skills/feature-adr/modules/08-qe.md +26 -12
- package/templates/.claude/skills/feature-adr/scripts/check-plan-completeness.mjs +146 -9
package/.dz-manifest.json
CHANGED
|
@@ -9,11 +9,11 @@
|
|
|
9
9
|
},
|
|
10
10
|
{
|
|
11
11
|
"path": "CHANGELOG.md",
|
|
12
|
-
"sha256": "
|
|
12
|
+
"sha256": "eea09507de22a829358a02a818a62767d36addeb585ec42a29d3ada8be2e3f7f"
|
|
13
13
|
},
|
|
14
14
|
{
|
|
15
15
|
"path": "README.md",
|
|
16
|
-
"sha256": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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.
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
|
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 =
|
|
111
|
-
|
|
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
|
-
|
|
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
|