@dzhechkov/keysarium 1.5.36 → 1.5.38
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 +60 -20
- package/README.md +51 -4
- package/package.json +1 -1
- package/sbom.json +119 -19
- package/templates/.claude/skills/feature-adr/SKILL.md +48 -7
- package/templates/.claude/skills/feature-adr/modules/01-requirements.md +26 -0
- package/templates/.claude/skills/feature-adr/modules/02-research.md +26 -0
- package/templates/.claude/skills/feature-adr/modules/03-adr.md +26 -0
- package/templates/.claude/skills/feature-adr/modules/03.5-ideation-swarm.md +25 -0
- package/templates/.claude/skills/feature-adr/modules/04-ddd.md +25 -0
- package/templates/.claude/skills/feature-adr/modules/05-architecture.md +26 -0
- package/templates/.claude/skills/feature-adr/modules/06-implementation-plan.md +81 -4
- package/templates/.claude/skills/feature-adr/modules/08-qe.md +14 -1
- package/templates/.claude/skills/feature-adr/scripts/check-plan-completeness.mjs +303 -0
- package/templates/.claude/skills/goap-research-ed25519/SKILL.md +340 -47
- package/templates/.claude/skills/goap-research-ed25519/scripts/check_report_evidence.py +359 -3
- package/templates/.claude/skills/goap-research-ed25519/scripts/ed25519_verifier.py +386 -13
- package/templates/.claude/skills/goap-research-ed25519/scripts/fixture_legacy_v2_fact.json +23 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/fixtures_field_cases.json +133 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/goap_planner.py +314 -44
- package/templates/.claude/skills/goap-research-ed25519/scripts/learning_bridge.py +890 -303
- package/templates/.claude/skills/goap-research-ed25519/scripts/population_match.py +591 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/risk_statement.py +289 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/test_ed25519_verifier.py +57 -2
- package/templates/.claude/skills/goap-research-ed25519/scripts/test_evidence_provenance.py +969 -344
- package/templates/.claude/skills/goap-research-ed25519/scripts/test_goap_planner.py +420 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/test_population_match.py +544 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/test_risk_absolute.py +239 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/test_signature_v3.py +554 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/test_suite_completeness.py +90 -0
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// K2 — machine plan-completeness gate for /feature-adr, run BETWEEN Step 6 (plan) and Step 7 (code).
|
|
3
|
+
// Generalized from features/wave1-instrument-repair/check-plan-completeness.mjs (that copy is the
|
|
4
|
+
// historical artifact of its run and stays untouched); this one is parameterized by feature dir.
|
|
5
|
+
//
|
|
6
|
+
// USAGE: node .claude/skills/feature-adr/scripts/check-plan-completeness.mjs [<feature-dir>] [--tier=M] [--acid=A1,A2]
|
|
7
|
+
// <feature-dir> defaults to the current working directory.
|
|
8
|
+
// --tier=S|M|L|XL closes the ADR-less dodge (see S-TIER HONESTY); omitting it keeps the
|
|
9
|
+
// heuristic, and the skip note then names the dodge out loud.
|
|
10
|
+
//
|
|
11
|
+
// VERDICT CONTRACT (unchanged from the proven copy — never a silent pass):
|
|
12
|
+
// PASS exit 0 last line: `K2 plan-completeness: PASS (...)`
|
|
13
|
+
// FAIL exit 1 last line: `K2 plan-completeness: FAIL (...)`
|
|
14
|
+
// NOT-ESTABLISHED exit 3 last line: `K2 plan-completeness: NOT-ESTABLISHED — <reason>`
|
|
15
|
+
// (the wave1 copy printed the exit-3 line as `K2: NOT-ESTABLISHED — …`; the prefix is unified here so
|
|
16
|
+
// ONE regex parses all three verdicts — the exit codes and their meanings are identical.)
|
|
17
|
+
// Set difference over IDENTIFIERS, not text similarity.
|
|
18
|
+
//
|
|
19
|
+
// KNOWN LIMITATION (measured on the discrimination twin, 2026-08-19): C1 is a grep — a PROSE
|
|
20
|
+
// mention of "ADR-002" satisfies it exactly like a task reference. The real N14 (backlog
|
|
21
|
+
// 3dbd2851-adjacent) must parse task structure. Kept honest here: C1 catches "forgot entirely",
|
|
22
|
+
// not "mentioned but not tasked".
|
|
23
|
+
//
|
|
24
|
+
// Checks:
|
|
25
|
+
// C1 every ADR file in 03_adr/ has >=1 task line in 06_implementation_plan.md citing it (ADR-00N)
|
|
26
|
+
// C2 every Confirmation-numbered check in each ADR is named in the plan (by its test-file path)
|
|
27
|
+
// C3 the plan carries an EXPECTED_CODE_TARGETS: block, non-empty, and EVERY line parses to a
|
|
28
|
+
// plausible repo-relative path (no spaces unless quoted, no traversal, no markdown residue)
|
|
29
|
+
// — SFDIPOT condition: line-level validation, reject-with-reason, not just block presence
|
|
30
|
+
// C4 the plan names the feature's OWN acid corpus (see "acid corpus" below)
|
|
31
|
+
// C5 the plan has an 'Inputs read:' line naming 03_adr, 05_architecture (wave-2 seam, cheap here)
|
|
32
|
+
//
|
|
33
|
+
// S-TIER HONESTY (no 03_adr/): an S-tier run legitimately has no ADR files, and forcing it to fail a
|
|
34
|
+
// plan gate it can never satisfy would make the gate a nuisance to route around. So:
|
|
35
|
+
// - no ADR files BUT the plan cites `ADR-<digits>` → NOT-ESTABLISHED (exit 3): the plan claims
|
|
36
|
+
// decisions the gate cannot see, so its completeness is unknown — that is never a pass.
|
|
37
|
+
// - no ADR files AND --tier is M/L/XL → FAIL: an M+ feature owes ADRs, and skipping C1/C2 for it
|
|
38
|
+
// would let a whole tier dodge the check by simply not writing 03_adr/ (G-F3).
|
|
39
|
+
// - no ADR files AND (--tier=S or no --tier) AND the plan claims no ADR work
|
|
40
|
+
// → C1 + C2 are SKIPPED-with-note (printed as SKIP lines; the run can still PASS on C3-C5).
|
|
41
|
+
// Without --tier the skip note NAMES the dodge, because the gate cannot tell an honest S-run
|
|
42
|
+
// from an M+ run that deleted its ADR directory.
|
|
43
|
+
//
|
|
44
|
+
// ECHO SAFETY (G-F1, reproduced by execution): this script echoes plan-controlled content (rejected
|
|
45
|
+
// target lines, acid tokens, the feature path). A plan line carrying the literal verdict marker was
|
|
46
|
+
// echoed verbatim and a first-match parser upstream read the PLANT instead of the real verdict. Every
|
|
47
|
+
// echoed value now goes through `safe()`, which defangs the marker and the exit trailer and truncates,
|
|
48
|
+
// so the only spellable verdict line in this stream is the one this script writes last.
|
|
49
|
+
//
|
|
50
|
+
// ACID CORPUS (C4): the wave1 copy hard-coded ITS run's tokens. Here the corpus is DISCOVERED from
|
|
51
|
+
// the feature's own `00_complexity_assessment.md` acid-case table (rows shaped `| A<N> | … |`), or
|
|
52
|
+
// supplied explicitly with `--acid=T1,T2,…`. If neither establishes a corpus, C4 is SKIPPED-with-note
|
|
53
|
+
// (a feature that declared no acid cases cannot be failed for not naming them).
|
|
54
|
+
import { readFileSync, readdirSync, existsSync } from 'node:fs';
|
|
55
|
+
import { isAbsolute, join, resolve } from 'node:path';
|
|
56
|
+
|
|
57
|
+
const argv = process.argv.slice(2);
|
|
58
|
+
const acidArg = argv.find((a) => a.startsWith('--acid='));
|
|
59
|
+
const tierArg = argv.find((a) => a.startsWith('--tier='));
|
|
60
|
+
const TIER = tierArg ? tierArg.slice('--tier='.length).trim().toUpperCase() : null;
|
|
61
|
+
const TIER_REQUIRES_ADR = TIER === 'M' || TIER === 'L' || TIER === 'XL';
|
|
62
|
+
const dirArg = argv.find((a) => !a.startsWith('--'));
|
|
63
|
+
const FDIR = resolve(dirArg && dirArg !== '' ? (isAbsolute(dirArg) ? dirArg : join(process.cwd(), dirArg)) : process.cwd());
|
|
64
|
+
const planPath = join(FDIR, '06_implementation_plan.md');
|
|
65
|
+
const adrDir = join(FDIR, '03_adr');
|
|
66
|
+
const complexityPath = join(FDIR, '00_complexity_assessment.md');
|
|
67
|
+
|
|
68
|
+
// Defang any value this script echoes back: a plan (or a path, or an ADR) must never be able to spell
|
|
69
|
+
// the verdict marker or the exit trailer into this stream. Truncated so one hostile line cannot bury
|
|
70
|
+
// the verdict either. The offender stays READABLE — defanged, not dropped.
|
|
71
|
+
const safe = (v) => String(v)
|
|
72
|
+
.replace(/K2 plan-completeness:/g, 'K2 plan-completeness[echoed]:')
|
|
73
|
+
.replace(/K2_EXIT=/g, 'K2_EXIT[echoed]=')
|
|
74
|
+
.replace(/[\r\n]+/g, ' ')
|
|
75
|
+
.slice(0, 300);
|
|
76
|
+
const out = (s) => console.log(s);
|
|
77
|
+
const notEstablished = (why) => { out(`K2 plan-completeness: NOT-ESTABLISHED — ${safe(why)}`); process.exit(3); };
|
|
78
|
+
let failures = [], warnings = [], skips = [];
|
|
79
|
+
|
|
80
|
+
if (!existsSync(FDIR)) notEstablished(`feature dir absent: ${FDIR}`);
|
|
81
|
+
if (!existsSync(planPath)) notEstablished('06_implementation_plan.md absent');
|
|
82
|
+
const plan = readFileSync(planPath, 'utf-8');
|
|
83
|
+
if (plan.trim().length < 200) notEstablished('plan suspiciously small (<200 chars)');
|
|
84
|
+
const adrFiles = existsSync(adrDir) ? readdirSync(adrDir).filter(f => f.endsWith('.md')).sort() : [];
|
|
85
|
+
const planClaimsAdrWork = /\bADR-\d+/.test(plan);
|
|
86
|
+
if (adrFiles.length === 0 && planClaimsAdrWork) notEstablished('no ADR files under 03_adr/, yet the plan cites ADR-<n> — completeness cannot be established');
|
|
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
|
+
|
|
161
|
+
if (adrFiles.length === 0 && TIER_REQUIRES_ADR) {
|
|
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`);
|
|
163
|
+
failures.push(`C2: tier ${TIER} has no 03_adr/ — no Confirmation test can be checked`);
|
|
164
|
+
} else if (adrFiles.length === 0) {
|
|
165
|
+
skips.push(`C1: no 03_adr/ and the plan claims no ADR work — ADR-coverage check SKIPPED${TIER === 'S' ? ' (--tier=S, the legitimate S-tier shape)' : ' (NO --tier supplied: an M/L/XL run that simply never wrote 03_adr/ would dodge C1/C2 here — pass --tier to close it)'}`);
|
|
166
|
+
skips.push('C2: no 03_adr/ — Confirmation-test coverage check SKIPPED');
|
|
167
|
+
} else {
|
|
168
|
+
// C1 — ADR ids referenced by plan tasks
|
|
169
|
+
for (const f of adrFiles) {
|
|
170
|
+
const m = f.match(/^(\d{3})-/); if (!m) { warnings.push(`C1: unparseable ADR filename ${safe(f)}`); continue; }
|
|
171
|
+
const id = `ADR-${m[1]}`;
|
|
172
|
+
const re = new RegExp(`ADR-0*${Number(m[1])}\\b`);
|
|
173
|
+
if (!re.test(plan)) failures.push(`C1: ${id} (${safe(f)}) has NO task in the plan referencing it`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// C2 — every Confirmation-listed test file path appears in the plan
|
|
177
|
+
const testPathRules = loadTestPathRules();
|
|
178
|
+
const isTestPath = (p) => testPathRules.some((r) => r.re.test(p));
|
|
179
|
+
for (const f of adrFiles) {
|
|
180
|
+
const adr = readFileSync(join(adrDir, f), 'utf-8');
|
|
181
|
+
const confIdx = adr.search(/^##+\s*Confirmation/mi);
|
|
182
|
+
if (confIdx < 0) { failures.push(`C2: ${safe(f)} has no Confirmation section`); continue; }
|
|
183
|
+
const conf = adr.slice(confIdx);
|
|
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; }
|
|
189
|
+
for (const p of paths) if (!plan.includes(p)) failures.push(`C2: ${safe(f)} Confirmation test ${safe(p)} NOT named in the plan`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
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
|
+
|
|
249
|
+
// C3 — EXPECTED_CODE_TARGETS block, line-level validation
|
|
250
|
+
const blockM = plan.match(/EXPECTED_CODE_TARGETS:\s*\n((?:\s*[-*]\s*.+\n?)+)/);
|
|
251
|
+
if (!blockM) failures.push('C3: no EXPECTED_CODE_TARGETS: block in the plan');
|
|
252
|
+
else {
|
|
253
|
+
const lines = blockM[1].split('\n').map(s => s.trim()).filter(Boolean);
|
|
254
|
+
if (lines.length === 0) failures.push('C3: EXPECTED_CODE_TARGETS block is empty');
|
|
255
|
+
for (const ln of lines) {
|
|
256
|
+
const path = ln.replace(/^[-*]\s*/, '').replace(/`/g, '').trim();
|
|
257
|
+
const reasons = classifyTargetPath(path);
|
|
258
|
+
if (reasons.length) failures.push(`C3: target line rejected: "${safe(ln)}" — ${reasons.join(', ')}`);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// C4 — the feature's OWN acid corpus is named in the plan (discovered, or --acid=…)
|
|
263
|
+
// G-F5: a corpus that was DECLARED but declared BADLY (lowercase `| a1 |`, a row with trailing junk,
|
|
264
|
+
// or an acid-case table header with no parsable rows) is a FAILURE, not a skip. Silently skipping it
|
|
265
|
+
// let a malformed declaration buy the same green as an honest "no acid cases here". The strict row
|
|
266
|
+
// shape is `| A<digits> |`; anything that LOOKS like an acid row under the loose shape but is not
|
|
267
|
+
// strict is reported by name. (Honest scope: rows that look nothing like an acid row — a defect table
|
|
268
|
+
// `| D1 |`, say — are not acid candidates and are left alone.)
|
|
269
|
+
let acidTokens = [];
|
|
270
|
+
let acidSource = null;
|
|
271
|
+
if (acidArg) {
|
|
272
|
+
acidTokens = acidArg.slice('--acid='.length).split(',').map(s => s.trim()).filter(Boolean);
|
|
273
|
+
acidSource = '--acid';
|
|
274
|
+
if (acidTokens.length === 0) failures.push('C4-malformed: --acid was passed but declares no tokens');
|
|
275
|
+
} else if (existsSync(complexityPath)) {
|
|
276
|
+
const complexity = readFileSync(complexityPath, 'utf-8');
|
|
277
|
+
acidTokens = [...new Set((complexity.match(/^\|\s*(A\d+)\s*\|/gm) ?? []).map(r => r.replace(/[|\s]/g, '')))];
|
|
278
|
+
if (acidTokens.length) acidSource = '00_complexity_assessment.md acid-case table';
|
|
279
|
+
const loose = [...new Set((complexity.match(/^\|\s*([Aa]\d+[A-Za-z]?)\s*\|/gm) ?? []).map(r => r.replace(/[|\s]/g, '')))];
|
|
280
|
+
for (const cand of loose) if (!acidTokens.includes(cand)) failures.push(`C4-malformed: acid row "${safe(cand)}" does not match the required \`| A<n> |\` shape (case-sensitive, digits only) — it is declared but uncheckable`);
|
|
281
|
+
const declaresTable = /\|\s*Acid case\s*\|/i.test(complexity);
|
|
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');
|
|
283
|
+
}
|
|
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');
|
|
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`);
|
|
294
|
+
|
|
295
|
+
// C5 — Inputs read line
|
|
296
|
+
if (!/Inputs read:/i.test(plan)) warnings.push('C5: no "Inputs read:" line (wave-2 seam, WARN only)');
|
|
297
|
+
else for (const need of ['03_adr','05_architecture']) if (!plan.includes(need)) warnings.push(`C5: Inputs read line missing ${need}`);
|
|
298
|
+
|
|
299
|
+
for (const s of skips) out('SKIP ' + s);
|
|
300
|
+
for (const w of warnings) out('WARN ' + w);
|
|
301
|
+
for (const f of failures) out('FAIL ' + f);
|
|
302
|
+
out(`K2 plan-completeness: ${failures.length === 0 ? 'PASS' : 'FAIL'} (${failures.length} failure(s), ${warnings.length} warning(s), ${skips.length} skip(s)) over ${adrFiles.length} ADRs, tier ${TIER === null ? '(unspecified)' : safe(TIER)}, in ${safe(FDIR)}`);
|
|
303
|
+
process.exit(failures.length === 0 ? 0 : 1);
|