@dzhechkov/harness-core 0.8.11 → 0.8.21
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 +399 -139
- package/LICENSE +13 -0
- package/README.md +420 -6
- package/dist/agents-policy.d.ts +15 -1
- package/dist/agents-policy.d.ts.map +1 -1
- package/dist/agents-policy.js +27 -1
- package/dist/agents-policy.js.map +1 -1
- package/dist/amendment-trace.d.ts +72 -0
- package/dist/amendment-trace.d.ts.map +1 -1
- package/dist/amendment-trace.js +385 -17
- package/dist/amendment-trace.js.map +1 -1
- package/dist/backlog-public.d.ts +153 -0
- package/dist/backlog-public.d.ts.map +1 -0
- package/dist/backlog-public.js +415 -0
- package/dist/backlog-public.js.map +1 -0
- package/dist/backlog-transitions.d.ts +48 -0
- package/dist/backlog-transitions.d.ts.map +1 -0
- package/dist/backlog-transitions.js +64 -0
- package/dist/backlog-transitions.js.map +1 -0
- package/dist/backlog.d.ts.map +1 -1
- package/dist/backlog.js +13 -0
- package/dist/backlog.js.map +1 -1
- package/dist/claim-check.d.ts.map +1 -1
- package/dist/claim-check.js +24 -2
- package/dist/claim-check.js.map +1 -1
- package/dist/claude-hooks-assets.d.ts +93 -0
- package/dist/claude-hooks-assets.d.ts.map +1 -0
- package/dist/claude-hooks-assets.js +208 -0
- package/dist/claude-hooks-assets.js.map +1 -0
- package/dist/clean-room-smoke.d.ts +83 -0
- package/dist/clean-room-smoke.d.ts.map +1 -0
- package/dist/clean-room-smoke.js +138 -0
- package/dist/clean-room-smoke.js.map +1 -0
- package/dist/cmd-usage.d.ts.map +1 -1
- package/dist/cmd-usage.js +36 -6
- package/dist/cmd-usage.js.map +1 -1
- package/dist/codex-hooks-assets.d.ts +25 -7
- package/dist/codex-hooks-assets.d.ts.map +1 -1
- package/dist/codex-hooks-assets.js +138 -30
- package/dist/codex-hooks-assets.js.map +1 -1
- package/dist/codex-hooks.d.ts +21 -1
- package/dist/codex-hooks.d.ts.map +1 -1
- package/dist/codex-hooks.js +21 -1
- package/dist/codex-hooks.js.map +1 -1
- package/dist/course-staleness.d.ts +19 -0
- package/dist/course-staleness.d.ts.map +1 -0
- package/dist/course-staleness.js +95 -0
- package/dist/course-staleness.js.map +1 -0
- package/dist/destructive-guard-hook.d.ts +40 -0
- package/dist/destructive-guard-hook.d.ts.map +1 -0
- package/dist/destructive-guard-hook.js +109 -0
- package/dist/destructive-guard-hook.js.map +1 -0
- package/dist/destructive-guard.d.ts +27 -0
- package/dist/destructive-guard.d.ts.map +1 -0
- package/dist/destructive-guard.js +2808 -0
- package/dist/destructive-guard.js.map +1 -0
- package/dist/discrimination-gate.d.ts +28 -3
- package/dist/discrimination-gate.d.ts.map +1 -1
- package/dist/discrimination-gate.js +76 -16
- package/dist/discrimination-gate.js.map +1 -1
- package/dist/feature-adr-routing.d.ts +95 -1
- package/dist/feature-adr-routing.d.ts.map +1 -1
- package/dist/feature-adr-routing.js +193 -27
- package/dist/feature-adr-routing.js.map +1 -1
- package/dist/guard.d.ts +90 -0
- package/dist/guard.d.ts.map +1 -1
- package/dist/guard.js +271 -0
- package/dist/guard.js.map +1 -1
- package/dist/harness-core-location.d.ts +18 -0
- package/dist/harness-core-location.d.ts.map +1 -0
- package/dist/harness-core-location.js +42 -0
- package/dist/harness-core-location.js.map +1 -0
- package/dist/index.d.ts +24 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +24 -3
- package/dist/index.js.map +1 -1
- package/dist/lead-shift-gate.d.ts +95 -0
- package/dist/lead-shift-gate.d.ts.map +1 -0
- package/dist/lead-shift-gate.js +100 -0
- package/dist/lead-shift-gate.js.map +1 -0
- package/dist/ledger-backfill.d.ts +11 -1
- package/dist/ledger-backfill.d.ts.map +1 -1
- package/dist/ledger-backfill.js +19 -0
- package/dist/ledger-backfill.js.map +1 -1
- package/dist/loop-blobs.generated.d.ts +1 -1
- package/dist/loop-blobs.generated.d.ts.map +1 -1
- package/dist/loop-blobs.generated.js +12 -3
- package/dist/loop-blobs.generated.js.map +1 -1
- package/dist/loop-lint.d.ts.map +1 -1
- package/dist/loop-lint.js +56 -7
- package/dist/loop-lint.js.map +1 -1
- package/dist/loop-plan-graph.d.ts +1 -3
- package/dist/loop-plan-graph.d.ts.map +1 -1
- package/dist/loop-plan-graph.js +70 -1
- package/dist/loop-plan-graph.js.map +1 -1
- package/dist/loop-trace.d.ts.map +1 -1
- package/dist/loop-trace.js +16 -2
- package/dist/loop-trace.js.map +1 -1
- package/dist/managed-hooks.d.ts +5 -6
- package/dist/managed-hooks.d.ts.map +1 -1
- package/dist/managed-hooks.js +2 -2
- package/dist/managed-hooks.js.map +1 -1
- package/dist/model-recommender.d.ts.map +1 -1
- package/dist/model-recommender.js +14 -3
- package/dist/model-recommender.js.map +1 -1
- package/dist/operations.d.ts.map +1 -1
- package/dist/operations.js +82 -0
- package/dist/operations.js.map +1 -1
- package/dist/patterns.d.ts +23 -0
- package/dist/patterns.d.ts.map +1 -1
- package/dist/patterns.js +10 -0
- package/dist/patterns.js.map +1 -1
- package/dist/publish.d.ts +11 -0
- package/dist/publish.d.ts.map +1 -1
- package/dist/publish.js +16 -2
- package/dist/publish.js.map +1 -1
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +3 -2
- package/dist/registry.js.map +1 -1
- package/dist/score.d.ts.map +1 -1
- package/dist/score.js +15 -4
- package/dist/score.js.map +1 -1
- package/dist/session-retro.d.ts +121 -2
- package/dist/session-retro.d.ts.map +1 -1
- package/dist/session-retro.js +454 -11
- package/dist/session-retro.js.map +1 -1
- package/dist/setup.d.ts +28 -0
- package/dist/setup.d.ts.map +1 -1
- package/dist/setup.js +223 -6
- package/dist/setup.js.map +1 -1
- package/dist/sign.d.ts.map +1 -1
- package/dist/sign.js +18 -1
- package/dist/sign.js.map +1 -1
- package/dist/skills-verify.d.ts +34 -1
- package/dist/skills-verify.d.ts.map +1 -1
- package/dist/skills-verify.js +82 -0
- package/dist/skills-verify.js.map +1 -1
- package/dist/stage-line.d.ts +68 -0
- package/dist/stage-line.d.ts.map +1 -0
- package/dist/stage-line.js +129 -0
- package/dist/stage-line.js.map +1 -0
- package/dist/statusline.d.ts +99 -0
- package/dist/statusline.d.ts.map +1 -1
- package/dist/statusline.js +310 -44
- package/dist/statusline.js.map +1 -1
- package/dist/store-counts.d.ts +26 -0
- package/dist/store-counts.d.ts.map +1 -0
- package/dist/store-counts.js +125 -0
- package/dist/store-counts.js.map +1 -0
- package/dist/store-guard.d.ts +106 -0
- package/dist/store-guard.d.ts.map +1 -0
- package/dist/store-guard.js +294 -0
- package/dist/store-guard.js.map +1 -0
- package/dist/swarm-brief.d.ts +95 -0
- package/dist/swarm-brief.d.ts.map +1 -0
- package/dist/swarm-brief.js +660 -0
- package/dist/swarm-brief.js.map +1 -0
- package/dist/trace-bundle.d.ts +8 -0
- package/dist/trace-bundle.d.ts.map +1 -1
- package/dist/trace-bundle.js +11 -0
- package/dist/trace-bundle.js.map +1 -1
- package/package.json +12 -11
- package/sbom.json +804 -154
- package/src/agents-policy.ts +46 -2
- package/src/amendment-trace.ts +441 -19
- package/src/backlog-public.ts +503 -0
- package/src/backlog-transitions.ts +77 -0
- package/src/backlog.ts +12 -0
- package/src/claim-check.ts +25 -2
- package/src/claude-hooks-assets.ts +227 -0
- package/src/clean-room-smoke.ts +195 -0
- package/src/cmd-usage.ts +29 -5
- package/src/codex-hooks-assets.ts +140 -30
- package/src/codex-hooks.ts +21 -1
- package/src/course-staleness.ts +125 -0
- package/src/destructive-guard-hook.ts +151 -0
- package/src/destructive-guard.ts +3027 -0
- package/src/discrimination-gate.ts +98 -19
- package/src/feature-adr-routing.ts +220 -22
- package/src/guard.ts +318 -0
- package/src/harness-core-location.ts +44 -0
- package/src/index.ts +111 -3
- package/src/lead-shift-gate.ts +145 -0
- package/src/ledger-backfill.ts +20 -1
- package/src/loop-blobs.generated.ts +12 -3
- package/src/loop-lint.ts +52 -7
- package/src/loop-plan-graph.ts +66 -1
- package/src/loop-trace.ts +13 -1
- package/src/managed-hooks.ts +5 -6
- package/src/model-recommender.ts +14 -3
- package/src/operations.ts +75 -0
- package/src/patterns.ts +33 -0
- package/src/publish.ts +27 -2
- package/src/registry.ts +3 -2
- package/src/score.ts +16 -4
- package/src/session-retro.ts +466 -11
- package/src/setup.ts +246 -9
- package/src/sign.ts +18 -1
- package/src/skills-verify.ts +99 -1
- package/src/stage-line.ts +151 -0
- package/src/statusline.ts +396 -47
- package/src/store-counts.ts +154 -0
- package/src/store-guard.ts +388 -0
- package/src/swarm-brief.ts +661 -0
- package/src/trace-bundle.ts +10 -0
package/src/agents-policy.ts
CHANGED
|
@@ -40,7 +40,16 @@ export const POLICY_SOURCES: readonly PolicySource[] = [
|
|
|
40
40
|
file: 'CLAUDE.md',
|
|
41
41
|
heading: 'Test execution: never watch',
|
|
42
42
|
why: 'A watch-mode test run hangs an unattended coding session.',
|
|
43
|
-
|
|
43
|
+
// Была `npm test -- --run` — эта команда в корне НЕ СУЩЕСТВУЕТ (Missing script: test), и
|
|
44
|
+
// 2026-09-03 канон исправлен. Несущей стала команда, которая есть.
|
|
45
|
+
operativeClause: 'npm run test:all',
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
id: 'backlog-coverage',
|
|
49
|
+
file: 'CLAUDE.md',
|
|
50
|
+
heading: 'Backlog coverage',
|
|
51
|
+
why: 'Работа, не закрытая в этом же ходу, иначе не оставляет следа нигде, кроме переписки, которая прокручивается.',
|
|
52
|
+
operativeClause: 'ALWAYS say out loud which records you filed',
|
|
44
53
|
},
|
|
45
54
|
{
|
|
46
55
|
id: 'data-protection',
|
|
@@ -101,7 +110,26 @@ export interface ExtractPolicyBlocksResult {
|
|
|
101
110
|
readonly missing: readonly string[];
|
|
102
111
|
}
|
|
103
112
|
|
|
104
|
-
export type PolicyDriftStatus =
|
|
113
|
+
export type PolicyDriftStatus =
|
|
114
|
+
| 'ok'
|
|
115
|
+
| 'stale'
|
|
116
|
+
| 'missing-stamp'
|
|
117
|
+
| 'missing-anchor'
|
|
118
|
+
| 'orphan-stamp'
|
|
119
|
+
/**
|
|
120
|
+
* Секция `<!-- dz:policy id=X -->` найдена в ФАЙЛЕ-ИСТОЧНИКЕ, но не объявлена в `POLICY_SOURCES`.
|
|
121
|
+
*
|
|
122
|
+
* ЗАЧЕМ ОТДЕЛЬНЫЙ СТАТУС. `orphan-stamp` смотрит в другую сторону — штамп в проекции без
|
|
123
|
+
* источника. Обратный случай не покрывался ничем, и это давало ЛОЖНОЕ ЗЕЛЁНОЕ: реестр
|
|
124
|
+
* `POLICY_SOURCES` ведётся руками, поэтому секция, дописанная в CLAUDE.md и не вписанная в него,
|
|
125
|
+
* молча не попадала в AGENTS.md, а `dz agents-sync --check` отвечал «in sync».
|
|
126
|
+
*
|
|
127
|
+
* ИЗМЕРЕНО 2026-09-03 на живом случае: добавил в канон секцию `backlog-coverage`, прогнал
|
|
128
|
+
* `agents-sync` — «in sync — 9 policy section(s), 9250 bytes», ровно те же число секций и байт,
|
|
129
|
+
* что до правки, ни на единицу не изменившиеся. Правило, ради которого всё делалось, до агентов
|
|
130
|
+
* не доехало, а прибор доложил успех.
|
|
131
|
+
*/
|
|
132
|
+
| 'unregistered-section';
|
|
105
133
|
|
|
106
134
|
export interface PolicyDriftFinding {
|
|
107
135
|
readonly id: string;
|
|
@@ -320,6 +348,22 @@ export function detectPolicyDrift(
|
|
|
320
348
|
findings.push({ id: stamp.id, file: stamp.file, status: 'orphan-stamp', expectedSha: null, actualSha: stamp.sha });
|
|
321
349
|
}
|
|
322
350
|
|
|
351
|
+
// Секции канона, о которых реестр не знает. Ищем во ВСЕХ переданных файлах-источниках, а не
|
|
352
|
+
// только в тех, что уже объявлены: файл может целиком отсутствовать в реестре.
|
|
353
|
+
{
|
|
354
|
+
const declared = new Set(sources.map((source) => source.id));
|
|
355
|
+
const seen = new Set<string>();
|
|
356
|
+
for (const [file, text] of sourceFiles) {
|
|
357
|
+
if (typeof text !== 'string') continue;
|
|
358
|
+
for (const m of text.matchAll(/<!-- dz:policy id=([a-z0-9-]+) -->/g)) {
|
|
359
|
+
const id = m[1]!;
|
|
360
|
+
if (declared.has(id) || seen.has(id)) continue;
|
|
361
|
+
seen.add(id);
|
|
362
|
+
findings.push({ id, file, status: 'unregistered-section', expectedSha: null, actualSha: null });
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
323
367
|
if (typeof agentsMdText === 'string') {
|
|
324
368
|
const beginCount = markerCount(agentsMdText, POLICY_BLOCK_BEGIN);
|
|
325
369
|
const endCount = markerCount(agentsMdText, POLICY_BLOCK_END);
|
package/src/amendment-trace.ts
CHANGED
|
@@ -20,7 +20,38 @@ export interface AmendmentRow {
|
|
|
20
20
|
readonly id: string;
|
|
21
21
|
readonly testIds: readonly string[];
|
|
22
22
|
readonly file: string | null;
|
|
23
|
+
/** The row's literal text, fenced examples included — what the author wrote. */
|
|
23
24
|
readonly raw: string;
|
|
25
|
+
/**
|
|
26
|
+
* The same slice with non-rendered regions (fenced blocks, HTML comments) blanked out, same
|
|
27
|
+
* length, so an index means the same byte in both. EVERY semantic read goes through this one —
|
|
28
|
+
* pointers, retraction, subject — because an EXAMPLE inside a row's block is not that row's
|
|
29
|
+
* answer.
|
|
30
|
+
*
|
|
31
|
+
* OPTIONAL, and that is a compatibility promise rather than sloppiness: round 4 made it REQUIRED
|
|
32
|
+
* and thereby broke every downstream constructor of a row the previous release accepted — a
|
|
33
|
+
* TypeScript consumer stopped compiling, and a legacy or deserialised row reaching
|
|
34
|
+
* `amendmentsMissingFromPlan` threw `TypeError: Cannot read properties of undefined (reading
|
|
35
|
+
* 'split')` (MEASURED 2026-09-06, Codex cross-family review round 5, P1). Every read goes through
|
|
36
|
+
* `rowScan()`, which falls back to `raw` — the honest degradation for a row that was built
|
|
37
|
+
* without a mask and therefore has no masked text to offer.
|
|
38
|
+
*
|
|
39
|
+
* MEASURED 2026-09-06 (Codex cross-family review round 4, P2): row starts were found in the mask
|
|
40
|
+
* while the text was sliced from the original, so a testless `AM-1` followed by a fenced
|
|
41
|
+
* `AM-2 … → test \`x\` in \`x.ts\`` example borrowed the example's pointer, resolved, and the
|
|
42
|
+
* gate answered `pass` with exit 0. Finding in the mask and reading past it is not masking.
|
|
43
|
+
*/
|
|
44
|
+
readonly scan?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The text a row's SEMANTICS are read from: its masked slice when it has one, its raw text when it
|
|
49
|
+
* does not. A row built by hand or revived from JSON has no mask to speak of, so `raw` is the only
|
|
50
|
+
* honest answer for it — and a fallback that returns text is the difference between a legacy row
|
|
51
|
+
* being read slightly too generously and the whole call throwing.
|
|
52
|
+
*/
|
|
53
|
+
function rowScan(row: AmendmentRow): string {
|
|
54
|
+
return row.scan ?? row.raw;
|
|
24
55
|
}
|
|
25
56
|
|
|
26
57
|
export type AmendmentVerdict =
|
|
@@ -109,39 +140,364 @@ export function normalizeTestId(s: string): string {
|
|
|
109
140
|
// bullet drops 104 of the corpus's 347 real amendment rows out of the check entirely, and a
|
|
110
141
|
// bullet-less row made this tool return `not-established` — which the pipeline's own gate text calls
|
|
111
142
|
// "NEVER a pass". C6 has always treated the bullet as optional; this is that half of the contract.
|
|
112
|
-
|
|
143
|
+
//
|
|
144
|
+
// The `CP-` prefix is CAPTURED, because it is IDENTITY: `AM-CP-N` is a row like any other AND a
|
|
145
|
+
// DIFFERENT id from `AM-N`. It was a non-capturing group until 2026-09-05 and the id was rebuilt
|
|
146
|
+
// from the number alone, so a challenge-panel row collided with the ideation's `AM-1` and the
|
|
147
|
+
// subject guard failed an honest plan as `subject-changed`. MEASURED TWICE that day (backlog
|
|
148
|
+
// a7d0aece023774a0): `run-registry-liveness` re-numbered 7 panel rows to AM-18…AM-31 to escape it,
|
|
149
|
+
// `core-boundary-guard` refused the `AM-CP-N` form outright. K2 (check-plan-completeness.mjs:361,
|
|
150
|
+
// `(AM-(?:CP-)?\d+)`) has always kept the whole token — this ends that disagreement, and the
|
|
151
|
+
// agreement is pinned by test/amendment-grammar-agreement.test.ts, not by this comment.
|
|
152
|
+
const ROW_START = /^(?:[-*|]\s*)?\*{0,2}AM-(CP-)?(\d+)/gm;
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The same token, read out of ORDINARY PROSE rather than at the start of a row — `dz score` scans a
|
|
156
|
+
* whole plan and a whole QE report for the amendments it must account for, and a scan is not a row
|
|
157
|
+
* grammar (a row is anchored to the line start; a mention is not).
|
|
158
|
+
*
|
|
159
|
+
* It lives HERE, next to `ROW_START`, because the defect it closes is the same class the row
|
|
160
|
+
* grammar just closed (doc-26: two tools, one text, two contracts) — measured one reader later.
|
|
161
|
+
* MEASURED 2026-09-06: `score.ts` scanned with its own `/AM-\d+/g`, which does not match `AM-CP-1`
|
|
162
|
+
* at all (`node -e "console.log('AM-CP-1'.match(/AM-\d+/g))"` → `null`), so a plan whose amendments
|
|
163
|
+
* were all challenge-panel rows produced an EMPTY planned set and the amendment-confirmation
|
|
164
|
+
* discipline was skipped with no trace — a check that silently checked nothing, the very class this
|
|
165
|
+
* module exists to remove. There were never two readers of `AM-*`; there were three.
|
|
166
|
+
*
|
|
167
|
+
* The trailing `\b` is identity too: without it a planned `AM-1` is "covered" by a report that only
|
|
168
|
+
* ever mentions `AM-10` (MEASURED — `'AM-10'.includes('AM-1')` → `true`). A substring is not an id.
|
|
169
|
+
*/
|
|
170
|
+
const AMENDMENT_ID_TOKEN = /\bAM-(?:CP-)?\d+\b/g;
|
|
171
|
+
|
|
172
|
+
/** Every distinct amendment id mentioned in `text`, in first-seen order. */
|
|
173
|
+
export function amendmentIdsIn(text: string): string[] {
|
|
174
|
+
return [...new Set(text.match(AMENDMENT_ID_TOKEN) ?? [])];
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Does `text` mention `id` AS AN ID? `AM-10` is not a mention of `AM-1`, and `AM-CP-1` is not a
|
|
179
|
+
* mention of `AM-1` — both are what a `String.includes` check answers wrongly.
|
|
180
|
+
*/
|
|
181
|
+
export function mentionsAmendmentId(text: string, id: string): boolean {
|
|
182
|
+
return amendmentIdsIn(text).includes(id);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* The `## Amendments` heading line — ONE definition, because two readers of the same line is the
|
|
187
|
+
* defect class this module keeps closing. Group 1 is the heading SUFFIX: everything after the word
|
|
188
|
+
* `Amendments` on that line, which is where the inline declaration `## Amendments: None` lives.
|
|
189
|
+
* The final newline is optional so a document that ENDS on its heading is still a section that
|
|
190
|
+
* exists (an empty one) rather than no section at all.
|
|
191
|
+
*/
|
|
192
|
+
const AMENDMENT_HEADING = /^ {0,3}(#{2,4})\s+Amendments\b([^\n]*)(?:\n|$)/m;
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The CLOSED set of explicit-none forms, matched against a WHOLE trimmed heading suffix or a WHOLE
|
|
196
|
+
* trimmed first paragraph. The word alone, optionally with a full stop — nothing else.
|
|
197
|
+
*
|
|
198
|
+
* The previous trailing-guard form only checked the character AFTER the word, so ordinary prose such
|
|
199
|
+
* as `None of the required rows has been written yet.` passed as a DECLARATION because the next
|
|
200
|
+
* character was a space, and with zero parsed rows the gate answered skip/exit 0 (MEASURED
|
|
201
|
+
* 2026-09-06, Codex cross-family review round 7, P1). A declaration is a whole utterance, not a
|
|
202
|
+
* prefix: "none" is an answer, "none of X yet" is a description of work outstanding.
|
|
203
|
+
*/
|
|
204
|
+
const NONE_DECLARATION_EXACT = /^(?:none|n\/a|нет)\.?$/iu;
|
|
205
|
+
|
|
206
|
+
/** Anything that LOOKS like amendment content in rendered text: a row id, a table cell, a child section. */
|
|
207
|
+
const AMENDMENT_LIKE = /\bAM-(?:CP-)?\d+\b|^\s*\|\s*\*{0,2}AM-|^ {0,3}#{3,6}\s+Amendments\b/;
|
|
208
|
+
|
|
209
|
+
/** A separator that turns a heading suffix into a DECLARATION rather than a qualifier. */
|
|
210
|
+
const HEADING_DECLARATION_SEPARATOR = /^\s*[:\u2014\u2013-]\s*/;
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* The document with every FENCED block blanked out — same length, same line count, so every index
|
|
214
|
+
* computed on the mask addresses the identical byte of the original.
|
|
215
|
+
*
|
|
216
|
+
* A plan is allowed to SHOW the form it may write, and this module must not read the example as the
|
|
217
|
+
* document's own answer. MEASURED 2026-09-06 (Codex round 3, P2): a fenced `## Amendments: None`
|
|
218
|
+
* placed above the real section won the raw heading search, the fence's own body parsed as zero
|
|
219
|
+
* rows, and the explicit-none branch exited 0 WITHOUT EVER LOOKING at the real section, which
|
|
220
|
+
* carried an unresolvable `AM-1`. That is the new false pass NFR-3 forbids. K2 has refused fenced
|
|
221
|
+
* examples from the start (`check-plan-completeness.mjs`, its fence-blindness fix); this is that
|
|
222
|
+
* half of the contract, arriving here.
|
|
223
|
+
*
|
|
224
|
+
* An UNCLOSED fence is reverted rather than trusted — masking may only lose EXAMPLES, never hide a
|
|
225
|
+
* real section, because a hidden section is a skip and a skip is exit 0. The safe direction is
|
|
226
|
+
* chosen explicitly and pinned by a test.
|
|
227
|
+
*/
|
|
228
|
+
function blankLine(line: string): string {
|
|
229
|
+
return ' '.repeat(line.length);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* The document with every NON-RENDERED block blanked out — same length, same line count, so every
|
|
234
|
+
* index computed on the mask addresses the identical byte of the original.
|
|
235
|
+
*
|
|
236
|
+
* ONE BLOCK SCAN, not a stack of regexes. Five rounds of cross-family review found the same class
|
|
237
|
+
* five times — a fenced heading, a fenced row, a fenced pointer, a fence closed by a shorter run,
|
|
238
|
+
* an HTML-commented template — and round 6 found the sixth: `<!--` and `-->` written inside INLINE
|
|
239
|
+
* CODE SPANS in ordinary prose, which a `<!--[\s\S]*?-->` mask happily read as a comment spanning
|
|
240
|
+
* the real section (MEASURED 2026-09-06: `section: NULL`, `skip`, exit 0, the whole section
|
|
241
|
+
* swallowed). Each mask was born to close the previous one's hole, which is the signature of the
|
|
242
|
+
* wrong level: markdown decides what a document CONTAINS at BLOCK level, so that is where this
|
|
243
|
+
* reads. An inline construct cannot open a block, so the code-span case is closed BY CONSTRUCTION
|
|
244
|
+
* rather than by a longer pattern.
|
|
245
|
+
*
|
|
246
|
+
* WHAT THIS IMPLEMENTS, named so the gap is auditable:
|
|
247
|
+
* • CommonMark §4.5 fenced code blocks — opener ``` or ~~~ (three or more, indented ≤3), closed
|
|
248
|
+
* only by a run of the SAME character at least as long, with nothing but whitespace after it.
|
|
249
|
+
* • CommonMark §4.6 HTML blocks, TYPE 2 ONLY (`<!-- … -->`) — start condition `<!--` at the start
|
|
250
|
+
* of a line after at most three spaces; end condition `-->` anywhere on a line, the start line
|
|
251
|
+
* included. This is the rule that makes a code-spanned delimiter a non-event.
|
|
252
|
+
* • CommonMark §4.2 ATX headings are then matched only in what survives (the heading pattern
|
|
253
|
+
* itself is unchanged and allows at most three spaces of indent).
|
|
254
|
+
* • CommonMark §6.1 code spans are deliberately NOT parsed: no inline construct can open a block,
|
|
255
|
+
* so there is nothing left for them to forge.
|
|
256
|
+
*
|
|
257
|
+
* WHAT IT DOES NOT IMPLEMENT (honest, and the reason a fixture outside this list is not covered):
|
|
258
|
+
* indented code blocks (§4.4) — a four-space-indented row still parses as a row, though a
|
|
259
|
+
* four-space-indented heading is not a heading; HTML block types 1 and 3–7 (`<script>`, `<?`,
|
|
260
|
+
* `<!DOCTYPE`, `<![CDATA[`, block tags, generic tags); block quotes and list containers, so a fence
|
|
261
|
+
* nested inside them is matched by its own indentation rather than relative to its container; tabs
|
|
262
|
+
* as indentation; link reference definitions.
|
|
263
|
+
*
|
|
264
|
+
* DELIBERATE DEVIATION: CommonMark runs an unclosed fence (or HTML block) to the end of the
|
|
265
|
+
* document. Here an unclosed block is REVERTED — its lines come back. The asymmetry is the point:
|
|
266
|
+
* masking may lose an EXAMPLE, but hiding a real section turns the gate into `skip`, and a skip is
|
|
267
|
+
* exit 0. Pinned by its own tests, for both fences and comments.
|
|
268
|
+
*/
|
|
269
|
+
function maskNonRendered(md: string): string {
|
|
270
|
+
const lines = md.split('\n');
|
|
271
|
+
const out = lines.slice();
|
|
272
|
+
let state: 'text' | 'fence' | 'html' = 'text';
|
|
273
|
+
let marker = '';
|
|
274
|
+
let markerLength = 0;
|
|
275
|
+
let openedAt = -1;
|
|
276
|
+
for (let i = 0; i < lines.length; i++) {
|
|
277
|
+
const line = lines[i] as string;
|
|
278
|
+
if (state === 'text') {
|
|
279
|
+
const fence = /^ {0,3}(`{3,}|~{3,})([^\n]*)$/.exec(line);
|
|
280
|
+
// CommonMark §4.5: the info string of a BACKTICK fence may not contain a backtick (a tilde
|
|
281
|
+
// fence may). An invalid opener is not an opener — accepting one let a later run close a
|
|
282
|
+
// fence that markdown never opened, exposing a `## Amendments: None` that is really inside an
|
|
283
|
+
// unclosed fence and hiding a real section above it (Codex round 7, P2).
|
|
284
|
+
if (fence && (fence[1] as string)[0] === '`' && (fence[2] as string).includes('`')) {
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
if (fence) {
|
|
288
|
+
const run = fence[1] as string;
|
|
289
|
+
state = 'fence';
|
|
290
|
+
marker = run[0] as string;
|
|
291
|
+
markerLength = run.length;
|
|
292
|
+
openedAt = i;
|
|
293
|
+
out[i] = blankLine(line);
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
// §4.6 type 2 START CONDITION: line start, at most three spaces. NOT anywhere in the line —
|
|
297
|
+
// that spelling is what let a code-spanned `<!--` in prose open a comment (round 6, P1).
|
|
298
|
+
const htmlStart = /^ {0,3}<!--/.exec(line);
|
|
299
|
+
if (htmlStart) {
|
|
300
|
+
state = 'html';
|
|
301
|
+
openedAt = i;
|
|
302
|
+
out[i] = blankLine(line);
|
|
303
|
+
// The end condition may be satisfied on the start line itself.
|
|
304
|
+
if (/-->/.test(line)) {
|
|
305
|
+
state = 'text';
|
|
306
|
+
openedAt = -1;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
out[i] = blankLine(line);
|
|
312
|
+
if (state === 'fence') {
|
|
313
|
+
const close = /^ {0,3}(`{3,}|~{3,})\s*$/.exec(line);
|
|
314
|
+
if (close) {
|
|
315
|
+
const run = close[1] as string;
|
|
316
|
+
if ((run[0] as string) === marker && run.length >= markerLength) {
|
|
317
|
+
state = 'text';
|
|
318
|
+
openedAt = -1;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
} else if (/-->/.test(line)) {
|
|
322
|
+
state = 'text';
|
|
323
|
+
openedAt = -1;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
// An unclosed block is not trusted: put its lines back (see DELIBERATE DEVIATION above).
|
|
327
|
+
if (openedAt >= 0) for (let i = openedAt; i < lines.length; i++) out[i] = lines[i] as string;
|
|
328
|
+
return out.join('\n');
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* How many RENDERED `## Amendments` sections the document opens. More than one is a document that
|
|
333
|
+
* contradicts itself, and until 2026-09-06 the FIRST one answered for all of them: a stale
|
|
334
|
+
* `## Amendments: None` left above the real section made the explicit-none branch exit 0 while the
|
|
335
|
+
* real section's unresolved rows were never parsed (Codex round 6, P2 — MEASURED `skip`, exit 0).
|
|
336
|
+
*/
|
|
337
|
+
/** Where the document contradicts its own "no amendments" declaration. */
|
|
338
|
+
export interface AmendmentAmbiguity {
|
|
339
|
+
/** 1-based line in the ORIGINAL document. */
|
|
340
|
+
readonly line: number;
|
|
341
|
+
/** the offending line, trimmed and capped — evidence, not a hint. */
|
|
342
|
+
readonly text: string;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* The FAIL-CLOSED half of the explicit-none contract (Codex cross-family review round 7).
|
|
347
|
+
*
|
|
348
|
+
* Seven rounds produced one outcome again and again: an ambiguous document answering `skip`/exit 0.
|
|
349
|
+
* Each round closed the parser hole that round's fixture used — and the next fixture used the next
|
|
350
|
+
* hole. This function stops paying that toll by inverting the burden: a declaration of "no
|
|
351
|
+
* amendments" is honoured ONLY IF the rendered document carries no amendment-shaped content below
|
|
352
|
+
* it. Anything AM-like with zero parsed rows means the PARSER and the DOCUMENT disagree, and the
|
|
353
|
+
* honest verdict for a disagreement is NOT-ESTABLISHED, never a pass and never a skip.
|
|
354
|
+
*
|
|
355
|
+
* Scanned in the block-scanned text, so fenced examples and HTML comments are already out; scanned
|
|
356
|
+
* from the section heading DOWN, which is deliberately more than "below the declaration" — the
|
|
357
|
+
* conservative direction here is to see more, because every miss is an exit 0.
|
|
358
|
+
*/
|
|
359
|
+
export function amendmentDeclarationAmbiguity(md: string): AmendmentAmbiguity | null {
|
|
360
|
+
const mask = maskNonRendered(md);
|
|
361
|
+
const m = AMENDMENT_HEADING.exec(mask);
|
|
362
|
+
if (m === null) return null;
|
|
363
|
+
const maskLines = mask.split('\n');
|
|
364
|
+
const rawLines = md.split('\n');
|
|
365
|
+
const headingIndex = mask.slice(0, m.index).split('\n').length - 1;
|
|
366
|
+
for (let i = headingIndex + 1; i < maskLines.length; i++) {
|
|
367
|
+
const line = maskLines[i] as string;
|
|
368
|
+
if (AMENDMENT_LIKE.test(line)) {
|
|
369
|
+
return { line: i + 1, text: (rawLines[i] ?? '').trim().slice(0, 160) };
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
return null;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
export function amendmentSectionCount(md: string): number {
|
|
376
|
+
const mask = maskNonRendered(md);
|
|
377
|
+
// DEPTH MATTERS, and the corpus is why (MEASURED 2026-09-06 on the 363-feature census while this
|
|
378
|
+
// refusal was being added): `features/p16-non-js-portability/06_implementation_plan.md` carries
|
|
379
|
+
// `## Amendments` at line 780 and `### Amendments folded in from the challenge panel …` at 845.
|
|
380
|
+
// The second is a SUBSECTION of the first, not a rival for it, and counting it turned a feature
|
|
381
|
+
// whose rows are genuinely broken from `fail` into `not-established` — a gate that answers "I
|
|
382
|
+
// cannot tell" where it used to name six bad rows is a REGRESSION dressed as caution. So a
|
|
383
|
+
// duplicate is a later heading at the SAME depth or shallower; anything deeper is a child.
|
|
384
|
+
const re = /^ {0,3}(#{2,4})\s+Amendments\b/gm;
|
|
385
|
+
let first: number | null = null;
|
|
386
|
+
let count = 0;
|
|
387
|
+
for (let m = re.exec(mask); m !== null; m = re.exec(mask)) {
|
|
388
|
+
const depth = (m[1] as string).length;
|
|
389
|
+
if (first === null) {
|
|
390
|
+
first = depth;
|
|
391
|
+
count = 1;
|
|
392
|
+
continue;
|
|
393
|
+
}
|
|
394
|
+
if (depth <= first) count++;
|
|
395
|
+
}
|
|
396
|
+
return count;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Does the heading itself declare "none"? `## Amendments: None` is the form the pipeline's own
|
|
401
|
+
* Step-8 module documents (`.claude/skills/feature-adr/modules/08-qe.md:166`), and until 2026-09-06
|
|
402
|
+
* `amendmentSection` ate `: None` as part of the heading and returned an EMPTY body, so the
|
|
403
|
+
* explicit-none branch never ran and the gate answered NOT-ESTABLISHED (exit 3) on a plan that had
|
|
404
|
+
* said its piece (Codex cross-family review round 2, P2).
|
|
405
|
+
*
|
|
406
|
+
* A DECLARATION, not a qualifier: a separator (`:` or a dash) must precede the word, and the word
|
|
407
|
+
* must end there. That rule was chosen from the corpus rather than invented — the 363 features
|
|
408
|
+
* carry `## Amendments (carried verbatim into the plan)` (8×), `## Amendments — conditions before
|
|
409
|
+
* plan/code`, `## Amendments applied before Step 6` and `## Amendments and confirmation
|
|
410
|
+
* obligations`, and none of them may read as "none". Whole-utterance matching refuses
|
|
411
|
+
* `none-blocking`; an optional whitespace-separated closing `#` sequence is heading furniture.
|
|
412
|
+
*/
|
|
413
|
+
function headingSaysNone(md: string): boolean {
|
|
414
|
+
const m = AMENDMENT_HEADING.exec(maskNonRendered(md));
|
|
415
|
+
if (!m) return false;
|
|
416
|
+
const suffix = m[1 + 1] ?? '';
|
|
417
|
+
const sep = HEADING_DECLARATION_SEPARATOR.exec(suffix);
|
|
418
|
+
if (!sep) return false;
|
|
419
|
+
// CommonMark §4.2 permits an optional closing `#` sequence when whitespace separates it from
|
|
420
|
+
// the heading text. `None##` deliberately stays text because it has no separating whitespace.
|
|
421
|
+
const declaration = suffix.slice((sep[0] as string).length).replace(/[ \t]+#+[ \t]*$/, '').trim();
|
|
422
|
+
// The WHOLE remainder must be the declaration (round 7, P1): `## Amendments: None` is an answer,
|
|
423
|
+
// `## Amendments: None of the panel rows landed` is a sentence about outstanding work.
|
|
424
|
+
return NONE_DECLARATION_EXACT.test(declaration);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* The `## Amendments` section body AND the same body with fenced examples blanked out. Both are
|
|
429
|
+
* returned because they answer different questions: the RAW body is what a row's text is, and the
|
|
430
|
+
* MASKED body is what counts as a row or as a heading at all. Two callers, one traversal, no way
|
|
431
|
+
* for them to disagree about where the section is.
|
|
432
|
+
*/
|
|
433
|
+
function amendmentSectionPair(md: string): { raw: string; masked: string } | null {
|
|
434
|
+
const mask = maskNonRendered(md);
|
|
435
|
+
const m = AMENDMENT_HEADING.exec(mask);
|
|
436
|
+
if (!m) return null;
|
|
437
|
+
const start = m.index + (m[0] as string).length;
|
|
438
|
+
const depth = (m[1] as string).length;
|
|
439
|
+
// The boundary is searched in the MASK for the same reason the heading is: a `## Something`
|
|
440
|
+
// inside a fenced example does not end the section, it illustrates one.
|
|
441
|
+
//
|
|
442
|
+
// And it ends only at a heading of the SAME depth or SHALLOWER. A `### Amendments folded in from
|
|
443
|
+
// the challenge panel` under a `## Amendments` is a CHILD, and round 6 made it a non-duplicate
|
|
444
|
+
// for the ambiguity count while parsing still stopped dead at it — so `## Amendments: None` plus
|
|
445
|
+
// a deeper subsection full of rows read as zero rows and answered skip/exit 0 (MEASURED
|
|
446
|
+
// 2026-09-06, Codex round 7, P1). One rule for both questions: deeper is inside.
|
|
447
|
+
const rest = mask.slice(start);
|
|
448
|
+
const boundary = /^ {0,3}(#{1,6})\s+\S/gm;
|
|
449
|
+
let end = md.length;
|
|
450
|
+
for (let b = boundary.exec(rest); b !== null; b = boundary.exec(rest)) {
|
|
451
|
+
if ((b[1] as string).length <= depth) {
|
|
452
|
+
end = start + b.index;
|
|
453
|
+
break;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
return { raw: md.slice(start, end), masked: mask.slice(start, end) };
|
|
457
|
+
}
|
|
113
458
|
|
|
114
459
|
/** The `## Amendments` section body, or null when the document has none (acid case A5). */
|
|
115
460
|
export function amendmentSection(md: string): string | null {
|
|
116
|
-
|
|
117
|
-
if (!m) return null;
|
|
118
|
-
const start = m.index + m[0].length;
|
|
119
|
-
const rest = md.slice(start);
|
|
120
|
-
const next = /^ {0,3}#{2,4}\s+\S/m.exec(rest);
|
|
121
|
-
return next ? rest.slice(0, next.index) : rest;
|
|
461
|
+
return amendmentSectionPair(md)?.raw ?? null;
|
|
122
462
|
}
|
|
123
463
|
|
|
124
464
|
/** `## Amendments` present but recording nothing to check — distinct from the section being absent. */
|
|
125
465
|
export function planSaysNoAmendments(planMd: string): boolean {
|
|
126
466
|
const sec = amendmentSection(planMd);
|
|
127
467
|
if (sec === null) return false;
|
|
128
|
-
|
|
468
|
+
// BOTH homes of the same declaration: the section body, and the heading suffix. A reader that
|
|
469
|
+
// knows only one of them tells a plan that answered honestly that its grammar matched nothing.
|
|
470
|
+
// The body form is the FIRST PARAGRAPH, whole: `None.` is a declaration, `None of the rows has
|
|
471
|
+
// been written yet.` is prose about outstanding work and used to pass as one (round 7, P1).
|
|
472
|
+
const firstParagraph = (sec.trim().split(/\n\s*\n/)[0] ?? '').trim();
|
|
473
|
+
return headingSaysNone(planMd) || NONE_DECLARATION_EXACT.test(firstParagraph);
|
|
129
474
|
}
|
|
130
475
|
|
|
131
476
|
export function parseAmendments(md: string): AmendmentRow[] {
|
|
132
|
-
const
|
|
133
|
-
if (
|
|
134
|
-
const
|
|
477
|
+
const pair = amendmentSectionPair(md);
|
|
478
|
+
if (pair === null) return [];
|
|
479
|
+
const section = pair.raw;
|
|
480
|
+
// Rows are FOUND in the masked body (a fenced `- AM-9 …` is an example of the form, not an
|
|
481
|
+
// amendment) and SLICED from the raw one, which is the text the row actually carries. The mask is
|
|
482
|
+
// length-preserving, so an index means the same byte in both.
|
|
483
|
+
const scan = pair.masked;
|
|
484
|
+
const starts: { index: number; prefix: string; num: string }[] = [];
|
|
135
485
|
ROW_START.lastIndex = 0;
|
|
136
|
-
for (let m = ROW_START.exec(
|
|
137
|
-
|
|
486
|
+
for (let m = ROW_START.exec(scan); m !== null; m = ROW_START.exec(scan)) {
|
|
487
|
+
// group 1 is the optional `CP-` prefix, group 2 the number. The prefix is kept because it is
|
|
488
|
+
// part of the id (backlog a7d0aece) — dropping it merges two provenances into one identity.
|
|
489
|
+
starts.push({ index: m.index, prefix: m[1] ?? '', num: m[2] as string });
|
|
138
490
|
}
|
|
139
491
|
const rows: AmendmentRow[] = [];
|
|
140
492
|
for (let i = 0; i < starts.length; i++) {
|
|
141
|
-
const s = starts[i] as { index: number; num: string };
|
|
493
|
+
const s = starts[i] as { index: number; prefix: string; num: string };
|
|
142
494
|
const end = i + 1 < starts.length ? (starts[i + 1] as { index: number }).index : section.length;
|
|
143
495
|
const raw = section.slice(s.index, end);
|
|
144
|
-
|
|
496
|
+
// Pointers are read from the MASKED slice: a fenced example inside this row's block illustrates
|
|
497
|
+
// the form, it does not answer for the row (Codex round 4, P2 — it used to, and that resolved a
|
|
498
|
+
// testless amendment).
|
|
499
|
+
const rowScan = scan.slice(s.index, end);
|
|
500
|
+
rows.push({ id: `AM-${s.prefix}${s.num}`, testIds: extractTestIds(rowScan), file: extractFile(rowScan), raw, scan: rowScan });
|
|
145
501
|
}
|
|
146
502
|
return rows;
|
|
147
503
|
}
|
|
@@ -180,7 +536,9 @@ export function resolveAmendments(
|
|
|
180
536
|
for (const row of rows) {
|
|
181
537
|
// A retraction is checked BEFORE the missing-pointer branch: a row that says it was superseded
|
|
182
538
|
// is not a row that forgot its test.
|
|
183
|
-
|
|
539
|
+
// The masked slice, for the same reason the pointer is: a retraction shown INSIDE a fenced
|
|
540
|
+
// example is an illustration of the form, not this row's retraction.
|
|
541
|
+
if (SUPERSEDED.test(rowScan(row))) {
|
|
184
542
|
out.push({ id: row.id, testId: null, file: row.file, verdict: 'superseded', detail: 'the plan retracted this amendment and named its successor' });
|
|
185
543
|
continue;
|
|
186
544
|
}
|
|
@@ -260,6 +618,18 @@ export function decideAmendmentOutcome(input: {
|
|
|
260
618
|
readError?: string | null;
|
|
261
619
|
/** Ideation amendments the plan fails to carry — absent, or reworded under the same id. */
|
|
262
620
|
missingFromPlan?: readonly { id: string; kind: 'dropped' | 'subject-changed' }[];
|
|
621
|
+
/**
|
|
622
|
+
* How many RENDERED `## Amendments` sections the inputs open (`amendmentSectionCount`). Optional
|
|
623
|
+
* for backward compatibility — absent means "not measured", never "exactly one".
|
|
624
|
+
*/
|
|
625
|
+
sectionCount?: number;
|
|
626
|
+
/**
|
|
627
|
+
* Amendment-shaped content found below the section heading in RENDERED text
|
|
628
|
+
* (`amendmentDeclarationAmbiguity`). Optional for backward compatibility — absent means "not
|
|
629
|
+
* measured". Present WITH zero parsed rows is the fail-closed trigger: parser and document
|
|
630
|
+
* disagree, so the run is inconclusive rather than skipped.
|
|
631
|
+
*/
|
|
632
|
+
ambiguity?: { readonly line: number; readonly text: string } | null;
|
|
263
633
|
}): AmendmentDecision {
|
|
264
634
|
const counts: Record<AmendmentVerdict, number> = { ...ZERO_COUNTS };
|
|
265
635
|
for (const r of input.resolutions) counts[r.verdict]++;
|
|
@@ -278,6 +648,55 @@ export function decideAmendmentOutcome(input: {
|
|
|
278
648
|
counts,
|
|
279
649
|
};
|
|
280
650
|
}
|
|
651
|
+
// A document that opens the section TWICE contradicts itself, and the first heading used to answer
|
|
652
|
+
// for both — a stale `## Amendments: None` above the real section bought a skip while the real
|
|
653
|
+
// section's rows were never parsed (Codex round 6, P2; MEASURED skip/exit 0). Refused BEFORE the
|
|
654
|
+
// explicit-none branch, and refused as INCONCLUSIVE rather than as a failure: which section is
|
|
655
|
+
// authoritative is not something this checker can decide, and guessing would be the same class of
|
|
656
|
+
// lie in a new place.
|
|
657
|
+
if ((input.sectionCount ?? 0) > 1) {
|
|
658
|
+
return {
|
|
659
|
+
outcome: 'not-established',
|
|
660
|
+
exit: 3,
|
|
661
|
+
reasons: [
|
|
662
|
+
`the document opens more than one rendered \`## Amendments\` section (${input.sectionCount}) — which one is authoritative is not decidable here, and the first one must never answer for the rest`,
|
|
663
|
+
],
|
|
664
|
+
counts,
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
// An explicit "None" is an ANSWER; zero rows from a section that says nothing is a BLIND SPOT.
|
|
668
|
+
// Until 2026-09-06 the blind-spot branch below ran first, so a plan that honestly declared it had
|
|
669
|
+
// no amendments got NOT-ESTABLISHED (exit 3) — an inconclusive verdict on a document that was
|
|
670
|
+
// complete. MEASURED on this feature's own plan (backlog ce2da797e17a7a7f):
|
|
671
|
+
// `dz amendment-check --feature-dir features/amendment-trace-cp-prefix --json` → exit 3 with
|
|
672
|
+
// "the grammar matched nothing". The two facts are opposites and must not share a verdict.
|
|
673
|
+
// GUARDED: only when nothing is OWED. A gap carried in from the ideation side is owed, so an
|
|
674
|
+
// explicit "None" may never be the reason a dropped amendment exits 0 (that case falls through
|
|
675
|
+
// to the blind-spot branch, which is inconclusive — never a pass).
|
|
676
|
+
if (input.rows.length === 0 && input.planSaysNone && (input.missingFromPlan ?? []).length === 0) {
|
|
677
|
+
// FAIL-CLOSED (round 7): a declaration only answers for a document that carries nothing
|
|
678
|
+
// amendment-shaped below it. If it does and NOTHING parsed, the parser and the document
|
|
679
|
+
// disagree — which is exactly the shape every one of the seven review rounds arrived in — and a
|
|
680
|
+
// disagreement is NOT-ESTABLISHED. Absence of a receipt is not success.
|
|
681
|
+
if (input.ambiguity) {
|
|
682
|
+
return {
|
|
683
|
+
outcome: 'not-established',
|
|
684
|
+
exit: 3,
|
|
685
|
+
reasons: [
|
|
686
|
+
`the \`## Amendments\` section declares no amendments but AM-like content exists at line ${input.ambiguity.line} ("${input.ambiguity.text}") while ZERO rows parsed — the declaration and the document disagree, and a disagreement is never a pass`,
|
|
687
|
+
],
|
|
688
|
+
counts,
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
return {
|
|
692
|
+
outcome: 'skip',
|
|
693
|
+
exit: 0,
|
|
694
|
+
reasons: [
|
|
695
|
+
'the `## Amendments` section explicitly declares no amendments ("None"/"нет"/"n/a") and zero rows parsed — an explicit absence, which is an answer and not a grammar failure (an absence is a skip, never a pass)',
|
|
696
|
+
],
|
|
697
|
+
counts,
|
|
698
|
+
};
|
|
699
|
+
}
|
|
281
700
|
// The whole class this feature removes: a check that silently checked nothing (AM-1, acid case A7).
|
|
282
701
|
if (input.rows.length === 0) {
|
|
283
702
|
return {
|
|
@@ -355,7 +774,10 @@ export function amendmentSubject(raw: string): string {
|
|
|
355
774
|
// a colon. An earlier version consumed up to 80 characters after the id, which ate the SUBJECT
|
|
356
775
|
// itself whenever a row carried no `(source):` tag — the checker then compared two truncations
|
|
357
776
|
// and called honest rows a mismatch.
|
|
358
|
-
|
|
777
|
+
// `AM-CP-N` is furniture too. Until 2026-09-05 this pattern demanded a digit straight after
|
|
778
|
+
// `AM-`, so a panel row's own id stayed inside its "subject" and every CP row read as a different
|
|
779
|
+
// change from every other one (backlog a7d0aece).
|
|
780
|
+
const stripped = withoutPointer.replace(/^[\s|*\-]*\**AM-(?:CP-)?\d+\**\s*(?:\([^)]{0,80}\))?\s*:?\s*/, '');
|
|
359
781
|
return normalizeTestId(stripped);
|
|
360
782
|
}
|
|
361
783
|
|
|
@@ -382,8 +804,8 @@ export function amendmentsMissingFromPlan(
|
|
|
382
804
|
gaps.push({ id: row.id, kind: 'dropped' });
|
|
383
805
|
continue;
|
|
384
806
|
}
|
|
385
|
-
const want = amendmentSubject(row
|
|
386
|
-
const got = amendmentSubject(planRow
|
|
807
|
+
const want = amendmentSubject(rowScan(row));
|
|
808
|
+
const got = amendmentSubject(rowScan(planRow));
|
|
387
809
|
// Containment either way: a plan may append a note ("closes HIGH-2"), and ideation may be the
|
|
388
810
|
// longer prose. What it may not do is describe a different change.
|
|
389
811
|
if (want.length >= MIN_MATCHABLE_ID_LENGTH && !got.includes(want) && !want.includes(got)) {
|