@event4u/agent-config 9.18.0 → 9.19.0

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.
@@ -0,0 +1,279 @@
1
+ /**
2
+ * Shared release-highlight classifier — one definition of the curated head's
3
+ * labels and of the span → category derivation.
4
+ *
5
+ * Used by BOTH sides on purpose:
6
+ *
7
+ * - the generator (`release.ts`) pre-fills the curated head with what it can
8
+ * substantiate from the span;
9
+ * - the gate (`check_release_highlights.ts`) blocks a `_none_` the span
10
+ * contradicts.
11
+ *
12
+ * Why shared instead of duplicated. Before this module the generator emitted
13
+ * `_none_` for all five fields while the gate rejected exactly that default
14
+ * the moment the span carried evidence. Every release of this package touches
15
+ * `src/rules/` or `src/scripts/schemas/`, so "Behaviour changes" is always
16
+ * substantiated and every release PR was red on its FIRST run by construction
17
+ * — 9.17.0 (run 30871194277) and 9.18.0 (run 30909511315) both hit it, on the
18
+ * same step, for the same reason.
19
+ *
20
+ * This is not a weaker gate. The generator no longer manufactures a claim it
21
+ * cannot support, and the gate keeps full teeth for the failure it was built
22
+ * for: a human editing a substantiated line back down to `_none_`, which is
23
+ * exactly what produced the false 9.13.0 and 9.14.0 heads. Machine-written
24
+ * `_none_` was never that failure; it was noise standing in front of it.
25
+ */
26
+ import { spawnSync } from 'node:child_process';
27
+
28
+ /** The five curated labels, in the order an operator reads them. */
29
+ export const HEAD_LABELS: readonly string[] = [
30
+ 'Behaviour changes',
31
+ 'Default changes + migration',
32
+ 'Security and correctness',
33
+ 'Honest nulls',
34
+ 'Known limitations',
35
+ ];
36
+
37
+ export const HEAD_NONE = '_none_';
38
+
39
+ /**
40
+ * Prefix marking a head line the generator derived rather than a human wrote.
41
+ *
42
+ * Machine-checkable so the gate can warn when one survives to merge, and
43
+ * readable so the line still says something true to anyone who ships it
44
+ * unedited. It is not a placeholder token: the line it introduces carries real
45
+ * evidence (the reason plus the citing SHAs), so nothing here is
46
+ * wrong-if-shipped — only unpolished-if-unedited.
47
+ */
48
+ export const DERIVED_MARKER = '_auto-derived, rewrite before merge:_';
49
+
50
+ /** Cap on cited SHAs per line, so a wide release cannot unbound the head. */
51
+ export const DERIVED_SHA_CAP = 6;
52
+
53
+ export interface SpanCommit {
54
+ sha: string;
55
+ subject: string;
56
+ body: string;
57
+ /** `A` / `M` / `D` / `R…` status per touched path. */
58
+ files: ReadonlyArray<{ status: string; path: string }>;
59
+ breaking: boolean;
60
+ }
61
+
62
+ /** One derived hit: the commit that substantiates a label. */
63
+ export interface CategoryHit {
64
+ sha: string;
65
+ /** Rendered detail — the subject, plus a removed-surface note when any. */
66
+ text: string;
67
+ }
68
+
69
+ /** Public artefact trees whose deletions count as removed public surface. */
70
+ const _PUBLIC_SURFACE_PREFIXES = [
71
+ 'src/skills/',
72
+ 'src/rules/',
73
+ 'src/agent-src/commands/',
74
+ 'src/domains/',
75
+ ];
76
+
77
+ const _RULE_OR_SCHEMA_PREFIXES = ['src/rules/', 'src/scripts/schemas/'];
78
+
79
+ /**
80
+ * Why a label fired, in generator-facing prose. Keyed by label; a label absent
81
+ * here is never derived (`Known limitations` is pure prose, not checkable).
82
+ */
83
+ const _DERIVED_REASON: Readonly<Record<string, string>> = {
84
+ 'Behaviour changes': 'rule/schema diffs, breaking commits or removed public surface',
85
+ 'Default changes + migration': 'commits naming a default, migration or migrate',
86
+ 'Security and correctness': 'security-scoped commits',
87
+ 'Honest nulls': 'commits carrying an honest-null marker',
88
+ };
89
+
90
+ /**
91
+ * Derive evidence per curated label from the span. Rules per label:
92
+ *
93
+ * - Security and correctness: conventional scope matching /secur/i, or the
94
+ * whole word "security" in the subject.
95
+ * - Behaviour changes: breaking (`!` / BREAKING CHANGE) commits, diffs
96
+ * touching `src/rules/` or `src/scripts/schemas/`, and deletions under the
97
+ * public artefact trees (removed public surface).
98
+ * - Default changes + migration: subject carrying the whole word "default",
99
+ * "migration", or "migrate".
100
+ * - Honest nulls: subject or body carrying an "honest null" marker.
101
+ * - Known limitations: never derived — pure prose, not gate-checkable.
102
+ *
103
+ * Derivation is deliberately conservative: a false red makes every release
104
+ * annoying, a miss only returns the head to the pre-gate state.
105
+ */
106
+ export function derive_category_hits(
107
+ commits: readonly SpanCommit[],
108
+ ): Record<string, CategoryHit[]> {
109
+ const out: Record<string, CategoryHit[]> = {};
110
+ for (const label of HEAD_LABELS) {
111
+ out[label] = [];
112
+ }
113
+ for (const c of commits) {
114
+ const scopeMatch = /^\w+\(([^)]*)\)!?:/u.exec(c.subject);
115
+ const scope = scopeMatch ? scopeMatch[1]! : '';
116
+ if (/secur/iu.test(scope) || /\bsecurity\b/iu.test(c.subject)) {
117
+ out['Security and correctness']!.push({ sha: c.sha, text: c.subject });
118
+ }
119
+ const removedPublic = c.files.filter(
120
+ (f) =>
121
+ f.status.startsWith('D') &&
122
+ _PUBLIC_SURFACE_PREFIXES.some((p) => f.path.startsWith(p)),
123
+ );
124
+ const ruleOrSchema = c.files.some((f) =>
125
+ _RULE_OR_SCHEMA_PREFIXES.some((p) => f.path.startsWith(p)),
126
+ );
127
+ if (c.breaking || ruleOrSchema || removedPublic.length > 0) {
128
+ const text =
129
+ removedPublic.length > 0
130
+ ? `${c.subject} (removes ${removedPublic.map((f) => f.path).join(', ')})`
131
+ : c.subject;
132
+ out['Behaviour changes']!.push({ sha: c.sha, text });
133
+ }
134
+ if (/\b(default|migration|migrate)\b/iu.test(c.subject)) {
135
+ out['Default changes + migration']!.push({ sha: c.sha, text: c.subject });
136
+ }
137
+ if (/honest[ -]null/iu.test(`${c.subject}\n${c.body}`)) {
138
+ out['Honest nulls']!.push({ sha: c.sha, text: c.subject });
139
+ }
140
+ }
141
+ return out;
142
+ }
143
+
144
+ /**
145
+ * String-rendered evidence per label (`<sha7> <detail>`), the shape the gate
146
+ * reports to a human. Derived from the structured hits so the two can never
147
+ * disagree.
148
+ */
149
+ export function derive_categories(commits: readonly SpanCommit[]): Record<string, string[]> {
150
+ const hits = derive_category_hits(commits);
151
+ const out: Record<string, string[]> = {};
152
+ for (const label of HEAD_LABELS) {
153
+ out[label] = (hits[label] ?? []).map((h) => `${h.sha.slice(0, 7)} ${h.text}`);
154
+ }
155
+ return out;
156
+ }
157
+
158
+ /**
159
+ * Pre-fill values for the curated head, one line per SUBSTANTIATED label.
160
+ *
161
+ * A label with no evidence is omitted, so the caller's `_none_` default still
162
+ * applies where `_none_` is the true answer. One line per label keeps the
163
+ * rendered head inside its cap; SHA citations are capped at `DERIVED_SHA_CAP`
164
+ * with an explicit remainder count rather than a silent truncation.
165
+ */
166
+ export function render_derived_head_values(
167
+ hits: Readonly<Record<string, readonly CategoryHit[]>>,
168
+ ): Record<string, string> {
169
+ const out: Record<string, string> = {};
170
+ for (const label of HEAD_LABELS) {
171
+ const reason = _DERIVED_REASON[label];
172
+ const ev = hits[label] ?? [];
173
+ if (!reason || ev.length === 0) {
174
+ continue;
175
+ }
176
+ const shas = ev.map((h) => h.sha.slice(0, 7));
177
+ const shown = shas.slice(0, DERIVED_SHA_CAP);
178
+ const remainder = shas.length - shown.length;
179
+ const more = remainder > 0 ? ` +${remainder} more` : '';
180
+ out[label] = `${DERIVED_MARKER} ${reason} in ${shown.join(', ')}${more}.`;
181
+ }
182
+ return out;
183
+ }
184
+
185
+ /** Labels whose curated value is still the generator's unedited draft. */
186
+ export function stale_draft_labels(
187
+ curated: Readonly<Record<string, string>>,
188
+ ): string[] {
189
+ return HEAD_LABELS.filter((l) => (curated[l] ?? '').includes(DERIVED_MARKER));
190
+ }
191
+
192
+ // ─── git span collection ────────────────────────────────────────────────────
193
+
194
+ const _RECORD = '\u001e';
195
+ const _FIELD = '\u001f';
196
+
197
+ export function parse_git_log(raw: string): SpanCommit[] {
198
+ const commits: SpanCommit[] = [];
199
+ for (const record of raw.split(_RECORD)) {
200
+ if (!record.trim()) {
201
+ continue;
202
+ }
203
+ const [head, ...restLines] = record.split('\n');
204
+ const parts = (head ?? '').split(_FIELD);
205
+ if (parts.length < 3) {
206
+ continue;
207
+ }
208
+ const sha = parts[0]!.trim();
209
+ const subject = parts[1]!;
210
+ const bodyParts: string[] = [parts.slice(2).join(_FIELD)];
211
+ const files: Array<{ status: string; path: string }> = [];
212
+ for (const line of restLines) {
213
+ const m = /^([A-Z]\d*)\t(.+)$/u.exec(line);
214
+ if (m) {
215
+ // Renames carry two paths; the last tab field is the new path,
216
+ // the first the old — record both so a rename out of a public
217
+ // tree still counts its origin path.
218
+ const segs = m[2]!.split('\t');
219
+ for (const p of segs) {
220
+ files.push({ status: m[1]!, path: p });
221
+ }
222
+ } else {
223
+ // %b is multi-line — everything that is not a name-status
224
+ // line is body continuation (honest-null markers live there).
225
+ bodyParts.push(line);
226
+ }
227
+ }
228
+ const body = bodyParts.join('\n');
229
+ commits.push({
230
+ sha,
231
+ subject,
232
+ body,
233
+ files,
234
+ breaking: /^\w+(\([^)]*\))?!:/u.test(subject) || /BREAKING CHANGE/u.test(body),
235
+ });
236
+ }
237
+ return commits;
238
+ }
239
+
240
+ /** `git log` arguments for a span, shared so both callers read one format. */
241
+ export function span_log_args(from: string | null, to: string): string[] {
242
+ return [
243
+ 'log',
244
+ from ? `${from}..${to}` : to,
245
+ '--no-merges',
246
+ '--name-status',
247
+ '--format=%x1e%H%x1f%s%x1f%b',
248
+ ];
249
+ }
250
+
251
+ /**
252
+ * Collect the span. Throws on git failure so a caller can decide whether the
253
+ * derivation is load-bearing (the gate) or best-effort (the generator).
254
+ */
255
+ export function collect_span_commits(from: string | null, to: string, cwd: string): SpanCommit[] {
256
+ const r = spawnSync('git', span_log_args(from, to), {
257
+ encoding: 'utf-8',
258
+ cwd,
259
+ maxBuffer: 64 * 1024 * 1024,
260
+ });
261
+ if (r.status !== 0) {
262
+ throw new Error(`git ${span_log_args(from, to).join(' ')} failed: ${r.stderr}`);
263
+ }
264
+ return parse_git_log(r.stdout);
265
+ }
266
+
267
+ /** Latest reachable release tag before `ref`, or null when there is none. */
268
+ export function previous_release_tag(ref: string, cwd: string): string | null {
269
+ const r = spawnSync(
270
+ 'git',
271
+ ['describe', '--tags', '--abbrev=0', '--match', '[0-9]*.[0-9]*.[0-9]*', `${ref}^`],
272
+ { encoding: 'utf-8', cwd },
273
+ );
274
+ if (r.status !== 0) {
275
+ return null;
276
+ }
277
+ const tag = r.stdout.trim();
278
+ return tag === '' ? null : tag;
279
+ }
@@ -499,6 +499,10 @@ export function main(argv: string[] | null = null): number {
499
499
  process.stderr.write(`❌ ${exc.message}\n`);
500
500
  return 3;
501
501
  }
502
+ // Gate-coverage contract (src/config/gate-coverage.yml rule 1): publish the
503
+ // asserted count machine-readably. Written to stderr, not stdout, because
504
+ // this audit's stdout carries the report body consumers read.
505
+ process.stderr.write(`scanned: ${String(skills.length)}\n`);
502
506
  const pairs = find_pairs(skills, args.threshold);
503
507
  const outDir = args.outDir ?? REPORT_DIR;
504
508
  const outJson = path.join(outDir, OUT_JSON_NAME);
@@ -18,10 +18,15 @@
18
18
  * with the required Reason/Ref content;
19
19
  * - every table-shaped row parses into the six §2.2 cells (`malformed-row`) —
20
20
  * a short row is never silently dropped, or an `open` finding would pass;
21
+ * - ``` fences resolve (`unbalanced-fence`) — only a LABELLED, properly-closed
22
+ * fence hides content, so no arrangement of bare fences can swallow a row,
23
+ * for the same reason;
21
24
  * - severity rows are sorted descending (critical > high > medium > low);
22
25
  * - findings-before-fixes ancestry: the commit that FIRST added the
23
26
  * artifact is an ancestor of every referenced fix commit (§2.5 — the
24
- * first-add commit is what counts, so backdating is detected too).
27
+ * first-add commit is what counts, so backdating is detected too; it is the
28
+ * earliest add of the PATH, so the guarantee covers the artefact's FIRST
29
+ * round, and per-round ordering stays convention — §2.5 names that limit).
25
30
  *
26
31
  * The review binds to the review-SCOPE HASH, never to a commit sha. That
27
32
  * definition is owned by `dispatch_r2_reviewer.ts` and imported here so the
@@ -114,6 +119,16 @@ export interface ParsedArtifact {
114
119
  * defect class.
115
120
  */
116
121
  malformedRows: string[];
122
+ /**
123
+ * 1-based lines of every stray ``` fence, ascending (§2.2).
124
+ *
125
+ * A stray is a BARE ``` (which never delimits a region) or a labelled opener
126
+ * that is never closed. All of them are reported as one `unbalanced-fence`
127
+ * violation naming every line — a single number could only ever name the
128
+ * last. Stray fences skip nothing: the lines around them are parsed, because
129
+ * no fence arrangement may swallow an `open` row (see {@link scanFences}).
130
+ */
131
+ strayFenceLines: number[];
117
132
  /** The §5 context manifest, when present and parseable. */
118
133
  manifest: ParsedManifest | null;
119
134
  }
@@ -181,8 +196,46 @@ const CODE_EXTENSIONS = new Set([
181
196
  'erl',
182
197
  'dart',
183
198
  'r',
199
+ // Infrastructure-as-code and build definitions. Production behaviour lives
200
+ // here as much as in application code — this suite ships terraform /
201
+ // terragrunt / aws-infrastructure skills and an engineering-safety-floor
202
+ // that treats these files as prod surfaces, so omitting them let an
203
+ // IaC-only completion claim "no code surface" and take the skip path.
204
+ 'tf',
205
+ 'tfvars',
206
+ 'hcl',
207
+ 'gradle',
208
+ 'cmake',
209
+ 'proto',
210
+ 'bicep',
211
+ ]);
212
+
213
+ /**
214
+ * Extensionless build / infra files. `isCodePath` decides by extension, so a
215
+ * `Dockerfile` or `Makefile` (no dot) fell through to "not code" — the same
216
+ * hole as a missing extension, one step further along.
217
+ */
218
+ const CODE_BASENAMES = new Set([
219
+ 'dockerfile',
220
+ 'containerfile',
221
+ 'makefile',
222
+ 'jenkinsfile',
223
+ 'vagrantfile',
224
+ 'procfile',
225
+ 'brewfile',
226
+ 'rakefile',
227
+ 'gemfile',
228
+ 'justfile',
184
229
  ]);
185
230
 
231
+ /**
232
+ * Tails that turn a {@link CODE_BASENAMES} stem into generated or prose state
233
+ * rather than code — `Gemfile.lock`, `Makefile.md`. Kept narrow on purpose: an
234
+ * unknown tail (`Dockerfile.prod`) stays code, so the classification errs broad
235
+ * exactly as contract §2.4 promises.
236
+ */
237
+ const GENERATED_TAILS = new Set(['lock', 'md', 'txt', 'json', 'yml', 'yaml', 'bak', 'orig']);
238
+
186
239
  /**
187
240
  * Template extensions whose *inner* extension decides. `foo.blade.php` is
188
241
  * already caught by `php`; `foo.html.twig` / `foo.j2` are not, so they are
@@ -216,6 +269,18 @@ export function isCodePath(p: string): boolean {
216
269
  if (CODE_SUFFIXES.some((s) => base.endsWith(s))) {
217
270
  return true;
218
271
  }
272
+ // Extensionless build/infra files (Dockerfile, Makefile, …) plus their
273
+ // environment variants (`Dockerfile.prod`, `Makefile.local`). A generated
274
+ // sibling that merely shares the stem is NOT code — `Gemfile.lock` is
275
+ // dependency state, the same class as the `composer.json` this function
276
+ // already excludes.
277
+ const stem = base.includes('.') ? base.slice(0, base.indexOf('.')) : base;
278
+ if (CODE_BASENAMES.has(stem)) {
279
+ const tail = base.slice(stem.length + 1);
280
+ if (tail === '' || !GENERATED_TAILS.has(tail)) {
281
+ return true;
282
+ }
283
+ }
219
284
  const dot = base.lastIndexOf('.');
220
285
  if (dot <= 0) {
221
286
  return false;
@@ -301,6 +366,112 @@ function splitTableRow(line: string): string[] {
301
366
  }
302
367
 
303
368
  const FENCE_RE = /^\s*```/;
369
+ /** A ```-prefixed line split into its backtick run and its info string. */
370
+ const FENCE_PARTS_RE = /^\s*(`{3,})(.*)$/;
371
+
372
+ export interface FenceScan {
373
+ /** 0-based indices of lines inside a LABELLED, properly-closed fence — the only lines to skip. */
374
+ fenced: Set<number>;
375
+ /**
376
+ * 1-based line numbers of every stray ``` fence, ascending.
377
+ *
378
+ * A stray is a bare ``` that opens nothing (no region is ever delimited by
379
+ * one) or a labelled opener that is never closed. Empty = fences are sound.
380
+ */
381
+ strays: number[];
382
+ }
383
+
384
+ /** Backtick-run length and info string of a fence line, or null if not a fence. */
385
+ function fenceParts(line: string): { ticks: number; info: string } | null {
386
+ const m = FENCE_PARTS_RE.exec(line);
387
+ return m ? { ticks: (m[1] as string).length, info: (m[2] as string).trim() } : null;
388
+ }
389
+
390
+ /**
391
+ * Which lines are fenced — and which ``` fences are stray?
392
+ *
393
+ * Fenced regions exist for ONE reason: an *illustrative* template row (the §2.2
394
+ * `| # | Severity | …` example a doc-ish artefact quotes) must not be read as a
395
+ * live finding. Nothing else justifies making a line invisible, so the
396
+ * delimiter has to be something no author produces by accident.
397
+ *
398
+ * Two earlier shapes both failed OPEN, and they are the same defect:
399
+ * - a single `inFence` toggle made every line after an ODD ```-count invisible;
400
+ * - POSITIONAL pairing then detected parity but never MIS-pairing — two
401
+ * unpaired inner openers (two `~~~`-wrapped illustrations, each holding one
402
+ * stray ``` — exactly what `markdown-safe-codeblocks` produces, since the
403
+ * outer `~~~` is not a fence to this grammar) paired with EACH OTHER and
404
+ * swallowed every line between them, `unterminatedAt` staying `null`.
405
+ * In both, one earlier well-formed row kept `rows.length > 0`, so the
406
+ * neither-table-nor-honest-null fallback also stayed quiet and an unreviewed
407
+ * `open` row PASSED — the same fail-open the §2.2 `malformed-row` rule closes by
408
+ * a different route. Patching parity, then pairing, then the next arrangement is
409
+ * a losing game, so the discriminator is fixed instead of the arithmetic:
410
+ *
411
+ * A region is skipped ONLY when it is a properly-closed pair whose OPENING
412
+ * fence carries an info string (```` ```markdown ````). A BARE ``` never
413
+ * delimits a region: it is recorded as a stray, the lines around it are parsed
414
+ * as ordinary content, and the artefact is reported `unbalanced-fence`.
415
+ *
416
+ * So no arrangement of bare fences can hide a row — a swallowed `open` finding
417
+ * needs a deliberate label, which is the authoring act that means "illustration".
418
+ * Closing follows CommonMark (bare, and at least as many backticks as the
419
+ * opener), which also makes a ```` ````-wrapped ``` block nest correctly. A
420
+ * labelled opener with NO bare fence after it anywhere is a stray and skips
421
+ * nothing.
422
+ *
423
+ * KNOWN HOLE, still open — this function does NOT deliver the property the two
424
+ * paragraphs above imply (blind-pass findings 1-3, 2026-08-04). A labelled
425
+ * opener is closed by the first later bare fence ANYWHERE in the artefact, even
426
+ * one the author never meant as its closer: the lines between them are skipped,
427
+ * a live `open` row among them vanishes from `rows`, and because the opener was
428
+ * consumed `strays` stays empty so no `unbalanced-fence` fires. Reproduced; an
429
+ * unreviewed `open` finding passes. The remediation string this file prints for
430
+ * `unbalanced-fence` leads an author into exactly that arrangement.
431
+ *
432
+ * Not patched here on purpose: the obvious guard — a findings-shaped row inside
433
+ * a fenced region is a violation — fires on every artefact that quotes the
434
+ * template, including the skeleton `dispatch_r2_reviewer` writes, which carries
435
+ * an `open` row. The fix is a §2.2 grammar decision (declare illustrative
436
+ * regions explicitly, or move the safety check off fence pairing), recorded in
437
+ * the contract as open rather than replaced with a guard that reds every future
438
+ * artefact.
439
+ */
440
+ export function scanFences(lines: readonly string[]): FenceScan {
441
+ const fenced = new Set<number>();
442
+ const strays: number[] = [];
443
+ let open: { at: number; ticks: number } | null = null;
444
+ for (let i = 0; i < lines.length; i++) {
445
+ const parts = fenceParts(lines[i] as string);
446
+ if (!parts) {
447
+ continue;
448
+ }
449
+ if (open !== null) {
450
+ // CommonMark closer: bare, and at least as long as the opener.
451
+ // Anything else is content inside the open region.
452
+ if (parts.info === '' && parts.ticks >= open.ticks) {
453
+ for (let j = open.at + 1; j < i; j++) {
454
+ fenced.add(j);
455
+ }
456
+ open = null;
457
+ }
458
+ continue;
459
+ }
460
+ if (parts.info === '') {
461
+ strays.push(i + 1);
462
+ continue;
463
+ }
464
+ open = { at: i, ticks: parts.ticks };
465
+ }
466
+ if (open !== null) {
467
+ strays.push(open.at + 1);
468
+ }
469
+ // No sort: bare strays are only pushed while no opener is open, so they land
470
+ // in ascending line order, and the unterminated-opener push above is always
471
+ // the largest index. A sort here was dead code (blind-pass finding 5) —
472
+ // keeping it would imply an ordering hazard that cannot occur.
473
+ return { fenced, strays };
474
+ }
304
475
 
305
476
  export function parseArtifact(text: string): ParsedArtifact {
306
477
  const out: ParsedArtifact = {
@@ -311,18 +482,20 @@ export function parseArtifact(text: string): ParsedArtifact {
311
482
  rows: [],
312
483
  malformedLines: [],
313
484
  malformedRows: [],
485
+ strayFenceLines: [],
314
486
  manifest: parseManifest(text),
315
487
  };
316
488
  const lines = text.split(/\r?\n/);
317
- let inFence = false;
489
+ const fences = scanFences(lines);
490
+ out.strayFenceLines = fences.strays;
318
491
  for (let i = 0; i < lines.length; i++) {
319
492
  const raw = lines[i] as string;
320
493
  const lineno = i + 1;
321
- if (FENCE_RE.test(raw)) {
322
- inFence = !inFence;
323
- continue;
324
- }
325
- if (inFence) {
494
+ // A fence marker line carries no grammar of its own, and a line inside a
495
+ // LABELLED closed fence is illustrative content. A stray fence (bare, or
496
+ // an unclosed opener) skips nothing (see scanFences) — the lines around
497
+ // it are parsed, and the artefact is reported.
498
+ if (FENCE_RE.test(raw) || fences.fenced.has(i)) {
326
499
  continue;
327
500
  }
328
501
  const trimmed = raw.trim();
@@ -371,7 +544,12 @@ export function parseArtifact(text: string): ParsedArtifact {
371
544
  if (first === '#' || second === 'severity') {
372
545
  continue; // header row
373
546
  }
374
- if (cells.length >= 6) {
547
+ // EXACTLY six, per contract §2.2 — not `>= 6`. An over-long row is
548
+ // as malformed as a short one and the surplus is usually the tell:
549
+ // an unescaped `|` inside a cell (the shape §2.2 requires escaping)
550
+ // splits one cell into two and shifts Status/Reason-Ref rightwards,
551
+ // so accepting it silently reads the wrong cell as the status.
552
+ if (cells.length === 6) {
375
553
  out.rows.push({
376
554
  index: cells[0] ?? '',
377
555
  severity: (cells[1] ?? '').toLowerCase(),
@@ -390,7 +568,7 @@ export function parseArtifact(text: string): ParsedArtifact {
390
568
  // §2.2 template ends in exactly that empty cell. Gate R1 already
391
569
  // reports this class — same name, `malformed_row`.
392
570
  out.malformedRows.push(
393
- `line ${lineno}: findings row has ${String(cells.length)} cell(s), expected 6 ` +
571
+ `line ${lineno}: findings row has ${String(cells.length)} cell(s), expected exactly 6 ` +
394
572
  '(`| # | Severity | File:Line | Finding | Status | Reason/Ref |` — the trailing ' +
395
573
  'Reason/Ref cell is required even when empty)',
396
574
  );
@@ -605,6 +783,24 @@ function evaluate(input: EvalInput): Violation[] {
605
783
  for (const detail of art.malformedRows) {
606
784
  violations.push({ kind: 'malformed-row', file, detail });
607
785
  }
786
+ // §2.2: stray ``` fences — a BARE fence (which delimits nothing) or a
787
+ // labelled opener left unclosed. The parser swallows no line around them
788
+ // (so an `open` row still blocks), but an artefact whose fences do not
789
+ // resolve is malformed in its own right — the reader cannot tell
790
+ // illustrative content from live rows. ONE violation naming EVERY stray:
791
+ // a single line number could only ever name the last one.
792
+ if (art.strayFenceLines.length > 0) {
793
+ const at = art.strayFenceLines.map((n) => `line ${String(n)}`).join(', ');
794
+ violations.push({
795
+ kind: 'unbalanced-fence',
796
+ file,
797
+ detail:
798
+ `${at}: stray \`\`\` fence — a bare fence delimits no region, and a labelled fence must ` +
799
+ 'be closed. An artefact whose ``` fences do not resolve is malformed (contract §2.2). ' +
800
+ 'Label the opener (```markdown) and close it, or inline-escape an illustrative ```; ' +
801
+ 'the lines around a stray fence are still parsed, so any finding row there is validated too.',
802
+ });
803
+ }
608
804
  }
609
805
 
610
806
  for (const { file, art } of relevant) {
@@ -757,6 +953,13 @@ function checkFindingsBeforeFixes(repo: string, artifactAbs: string, rows: reado
757
953
  // Earliest add commit: `git log --diff-filter=A` lists newest-first, so the
758
954
  // LAST line is the first commit that ever added the file (backdating via a
759
955
  // later amend does not move it).
956
+ //
957
+ // Scope of the guarantee, per contract §2.5: this resolves the earliest add
958
+ // of the PATH, not of the current round's artefact — a re-bound (or renamed
959
+ // and re-added) `<slug>.findings.md` keeps round 1's add. So the check proves
960
+ // "the review file existed before the fixes of its FIRST round", and
961
+ // per-round ordering stays agent-carried convention. Deliberate, and named
962
+ // as a limit in the contract rather than implied away.
760
963
  const logRes = gitTry(repo, ['log', '--diff-filter=A', '--format=%H', '--', rel]);
761
964
  const addLines = logRes.stdout
762
965
  .split('\n')
@@ -49,6 +49,7 @@ import process from 'node:process';
49
49
  import { fileURLToPath, pathToFileURL } from 'node:url';
50
50
 
51
51
  import * as yaml from 'js-yaml';
52
+ import { checkRatchet } from './_lib/gate_baseline.js';
52
53
  import { runCountedProbe } from './_lib/counted_probe.js';
53
54
 
54
55
  const _HERE = fileURLToPath(import.meta.url);
@@ -318,14 +319,76 @@ export function main(argv: readonly string[]): number {
318
319
  `⚠️ ${String(pending.length)} gate(s) are listed but NOT enforced — this guard's coverage is partial by declaration.\n`,
319
320
  );
320
321
  }
322
+ const hardening = report_hardening_ratchet();
321
323
  if (failed.length > 0) {
322
324
  process.stdout.write(`❌ ${String(failed.length)} gate(s) failed the coverage floor.\n`);
323
325
  return 1;
324
326
  }
327
+ if (hardening !== 0) {
328
+ return hardening;
329
+ }
325
330
  process.stdout.write('✅ every enforced gate cleared its coverage floor.\n');
326
331
  return 0;
327
332
  }
328
333
 
334
+ /**
335
+ * Vulnerability ratchet — the count that must not RISE.
336
+ *
337
+ * This deliberately measures **defect exposure**, not adoption reach. A
338
+ * coverage-percentage ratchet ("hardened gates must increase") would be
339
+ * threshold-lowering in a ratchet's clothes: it tracks how far the fix has
340
+ * spread and can never regress, so it grades the solution instead of the
341
+ * problem. The number below is the opposite — how many gates could still exit 0
342
+ * over a moved root without saying so. It CAN regress (every new gate written
343
+ * without a scope assertion raises it) and the target stays 0.
344
+ *
345
+ * Recorded via the existing violation ratchet (`_lib/gate_baseline.ts`), so the
346
+ * 56-day non-stagnation clause applies unchanged: a count that never drops
347
+ * fails, because a frozen baseline is suppression with extra steps.
348
+ */
349
+ export function report_hardening_ratchet(): number {
350
+ const unhardened = list_unhardened_gates();
351
+ const verdict = checkRatchet({
352
+ gate: 'gate-hardening:unhardened-scan-scope',
353
+ actual: unhardened.length,
354
+ repoRoot: REPO_ROOT,
355
+ });
356
+ process.stdout.write(` ${verdict.ok ? '✅' : '❌'} ${verdict.message}\n`);
357
+ return verdict.ok ? 0 : 1;
358
+ }
359
+
360
+ /**
361
+ * Gate scripts that neither assert their scan scope nor publish a count — the
362
+ * population still able to report success over an empty corpus.
363
+ *
364
+ * A gate counts as hardened when it routes through `_lib/scan_scope` (the
365
+ * assertion that turns a zero scan into a red exit) OR emits the machine-
366
+ * readable `scanned:` line the coverage guard reads. Either one makes a dead
367
+ * root visible; neither is satisfiable by a gate that read nothing.
368
+ */
369
+ export function list_unhardened_gates(dir = path.join(REPO_ROOT, 'src/scripts')): string[] {
370
+ let entries: string[];
371
+ try {
372
+ entries = fs.readdirSync(dir);
373
+ } catch {
374
+ return [];
375
+ }
376
+ const gates = entries.filter((f) => /^(lint|check|audit)_.*\.ts$/.test(f) && !f.endsWith('.d.ts'));
377
+ const out: string[] = [];
378
+ for (const f of gates) {
379
+ let src: string;
380
+ try {
381
+ src = fs.readFileSync(path.join(dir, f), 'utf8');
382
+ } catch {
383
+ continue;
384
+ }
385
+ const asserts = /assertScanned\(|assertWatchlistResolves\(/.test(src);
386
+ const emits = /(?:process\.(?:stdout|stderr)\.write|lines\.push)\(\s*`scanned: /.test(src);
387
+ if (!asserts && !emits) out.push(f.replace(/\.ts$/, ''));
388
+ }
389
+ return out.sort();
390
+ }
391
+
329
392
  function spec_is_pending(s: GateSpec): boolean {
330
393
  return s.status === 'pending';
331
394
  }