@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +89 -0
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +1 -1
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +1 -1
- package/dist/discovery/trust-report.md +1 -1
- package/dist/discovery/workspaces.json +1 -1
- package/dist/mcp/registry-manifest.json +1 -1
- package/dist/mcp/server.json +2 -2
- package/package.json +1 -1
- package/src/config/gate-coverage.yml +99 -16
- package/src/config/gate-violation-baselines.json +5 -0
- package/src/scripts/_lib/release_highlights.ts +279 -0
- package/src/scripts/audit_skill_overlap.ts +4 -0
- package/src/scripts/check_completion_review.ts +212 -9
- package/src/scripts/check_gate_coverage.ts +63 -0
- package/src/scripts/check_iron_law_prominence.ts +4 -0
- package/src/scripts/check_release_highlights.ts +60 -152
- package/src/scripts/dispatch_r2_reviewer.ts +6 -5
- package/src/scripts/lint_artefact_frontmatter.ts +4 -0
- package/src/scripts/lint_handoffs.ts +13 -1
- package/src/scripts/lint_namespace.ts +5 -0
- package/src/scripts/release.ts +47 -13
- package/src/scripts/sweep_dead_scan_roots.ts +37 -3
|
@@ -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
|
-
|
|
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
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
if (
|
|
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
|
-
|
|
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
|
}
|