@event4u/agent-config 9.18.0 → 9.18.1
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 +54 -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 +6 -2
- package/src/scripts/_lib/release_highlights.ts +279 -0
- package/src/scripts/check_completion_review.ts +191 -9
- package/src/scripts/check_release_highlights.ts +60 -152
- package/src/scripts/dispatch_r2_reviewer.ts +6 -5
- package/src/scripts/release.ts +47 -13
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
},
|
|
7
7
|
"metadata": {
|
|
8
8
|
"description": "Bootstrap shim for the event4u agent-config suite. This plugin ships ONLY the dispatcher hooks plus one install-pointer skill — ALL content (skills, rules, commands) is installed via `npx -y @event4u/agent-config init`, which writes the file projection AND registers the same hooks in ~/.claude/settings.json. DEPRECATED as a content channel; kept so the marketplace listing stays discoverable and existing installs keep working hooks.",
|
|
9
|
-
"version": "9.18.
|
|
9
|
+
"version": "9.18.1",
|
|
10
10
|
"keywords": [
|
|
11
11
|
"agent-config",
|
|
12
12
|
"skills",
|
package/CHANGELOG.md
CHANGED
|
@@ -82,6 +82,29 @@ Entry-shape contract: [`docs/contracts/CHANGELOG-conventions.md`](docs/contracts
|
|
|
82
82
|
|
|
83
83
|
### Fixed
|
|
84
84
|
|
|
85
|
+
- **Every release PR was red on its first run — the generator and the
|
|
86
|
+
highlight gate contradicted each other.** `release.ts` wrote `_none_` into
|
|
87
|
+
all five curated-head fields; `check_release_highlights` (added 2026-08-03)
|
|
88
|
+
fails a `_none_` the release span contradicts. Because every release of this
|
|
89
|
+
package touches `src/rules/` or `src/scripts/schemas/`, "Behaviour changes"
|
|
90
|
+
is *always* substantiated, so the gate reliably red-flagged the generator's
|
|
91
|
+
own default — 9.17.0 (run 30871194277) and 9.18.0 (run 30909511315) both
|
|
92
|
+
failed on that one step, and each release had to be unblocked by hand.
|
|
93
|
+
- The span → category classifier now lives once, in
|
|
94
|
+
`src/scripts/_lib/release_highlights.ts`, and is shared by the generator
|
|
95
|
+
and the gate; the two duplicated label lists are gone with it.
|
|
96
|
+
- `release.ts` **pre-fills** each substantiated label with the deriving
|
|
97
|
+
reason plus its citing SHAs (capped at 6, remainder stated, never silently
|
|
98
|
+
truncated). `_none_` is now a fallback for labels the span does not
|
|
99
|
+
substantiate, not a blanket default — so the tool no longer asserts five
|
|
100
|
+
things it never checked.
|
|
101
|
+
- The gate keeps full teeth for the failure it was actually built for: a
|
|
102
|
+
**human** editing a substantiated line back down to `_none_`, which is what
|
|
103
|
+
produced the false 9.13.0 and 9.14.0 heads. An unrewritten auto-derived
|
|
104
|
+
line is advisory (a prose gap, not a false claim) and never blocks.
|
|
105
|
+
- Derivation is best-effort in the generator: a git failure degrades to the
|
|
106
|
+
`_none_` skeleton with a warning instead of aborting a release.
|
|
107
|
+
|
|
85
108
|
- **`/team delegate` double gate is now enforced in code, not only in prose**
|
|
86
109
|
(team mode stays default-off; closing `road-to-team-mode`). The gate on the
|
|
87
110
|
only write-access wrapper existed as agent-followed instructions in the
|
|
@@ -271,6 +294,37 @@ Entry-shape contract: [`docs/contracts/CHANGELOG-conventions.md`](docs/contracts
|
|
|
271
294
|
> that forces a new era split (`# Era: 9.19.x`, etc.) — see
|
|
272
295
|
> [`docs/contracts/CHANGELOG-conventions.md § Era splits`](docs/contracts/CHANGELOG-conventions.md).
|
|
273
296
|
|
|
297
|
+
## [9.18.1](https://github.com/event4u-app/agent-config/compare/9.18.0...9.18.1) (2026-08-04)
|
|
298
|
+
|
|
299
|
+
### Release highlights
|
|
300
|
+
|
|
301
|
+
<!-- Curated head: fill before merge, keep it under 10 lines, and leave `_none_` where it is genuinely the answer. The generated log below is unchanged. -->
|
|
302
|
+
- **Behaviour changes:** the release generator pre-fills the curated head from the release span instead of writing `_none_` into all five fields (8fe60e9, 2f23d6b) — `_none_` now means "the span substantiates nothing", so a reader can trust it; review-gate scope classification changed, with IaC and extensionless build files counting as code and a findings row required to be exactly six cells (6c5f0f5).
|
|
303
|
+
- **Default changes + migration:** _none_
|
|
304
|
+
- **Security and correctness:** two more fail-open routes in the review-gate fence parser are closed — an unterminated fence no longer swallows the rest of a findings artefact (55dc01d) and a bare fence never delimits a region (9937ad9, the fourth such route); both previously let a truncated artefact read as clean.
|
|
305
|
+
- **Honest nulls:** the honest-null recorded against 6c6fc15a9 in the 9.18.0 notes was **false** — the real binding review is restored (d56c0be), so treat that 9.18.0 line as withdrawn. Rounds 6 to 8 of the R2 review closed 7 findings and deferred 10, with 0 critical or high (7a0f7b3, 39f071f, 70bb89f).
|
|
306
|
+
- **Known limitations:** the head pre-fill matches an `honest-null` literal anywhere in a commit body, so a commit *about* the mechanism gets cited as one — it did here, for 2f23d6b, and a human dropped it. The derived line is a draft, never a claim; the gate blocks only a `_none_` the span contradicts and never judges a filled field.
|
|
307
|
+
|
|
308
|
+
### Bug Fixes
|
|
309
|
+
|
|
310
|
+
* **release:** derive the head in --dry-run too, so the preview matches ([2f23d6b](https://github.com/event4u-app/agent-config/commit/2f23d6bb963577af5741951962b48e556e5a3e97))
|
|
311
|
+
* **release:** stop the generator writing a `_none_` the gate rejects ([8fe60e9](https://github.com/event4u-app/agent-config/commit/8fe60e9aba5192c07f8e33bc6ce1747a77e82922))
|
|
312
|
+
* **gates:** a bare fence never delimits a region — close the fourth fail-open route ([9937ad9](https://github.com/event4u-app/agent-config/commit/9937ad9b75f60bffd4ad42f2a830589bd4be7e5c))
|
|
313
|
+
* **gates:** an unterminated fence must not swallow the rest of a findings artefact ([55dc01d](https://github.com/event4u-app/agent-config/commit/55dc01d1fd3c8dbf44dd04fa7b3f41e3f4bc39bb))
|
|
314
|
+
* **gates:** IaC and extensionless build files are code; a row is exactly six cells ([6c5f0f5](https://github.com/event4u-app/agent-config/commit/6c5f0f5999b8a421cf765269a8da8b780465dc98))
|
|
315
|
+
|
|
316
|
+
### Documentation
|
|
317
|
+
|
|
318
|
+
* **review:** close round 8 — 1 fixed, 4 deferred, 0 critical ([70bb89f](https://github.com/event4u-app/agent-config/commit/70bb89f2820f4c5a5a26cd25f5db2daa53b08e5e))
|
|
319
|
+
* **review:** close round 7 — 2 fixed, 3 deferred, 0 critical/high ([39f071f](https://github.com/event4u-app/agent-config/commit/39f071fad58dc2c5cb9e6ec87e8e37b79085db2f))
|
|
320
|
+
* **gates:** in-place re-binding is the normal fix-pass path (2.1/2.5/2.7) ([1f7d140](https://github.com/event4u-app/agent-config/commit/1f7d1404c4bc63e87d4b46f91d52f81f2ae51f7b))
|
|
321
|
+
* **review:** round-7 R2 findings — 2 medium, 3 low, 0 critical/high ([2bd3eda](https://github.com/event4u-app/agent-config/commit/2bd3edad82ff69700f62c4d16c93f38a484e5e08))
|
|
322
|
+
* **review:** close round 6 — 4 fixed, 3 deferred, 0 critical/high ([7a0f7b3](https://github.com/event4u-app/agent-config/commit/7a0f7b33aed30921f2404ae1955a8e5aa76dde9b))
|
|
323
|
+
* **gates:** two claims corrected to match the code (round-6 lows) ([0f5d43a](https://github.com/event4u-app/agent-config/commit/0f5d43a17be245b034287b0f486839fb156f8b86))
|
|
324
|
+
* **review:** restore the real binding review — 6c6fc15a9's honest-null was false ([d56c0be](https://github.com/event4u-app/agent-config/commit/d56c0be63f6782183737ab50256401ab52a1f38f))
|
|
325
|
+
|
|
326
|
+
Tests: 10963 (+19 since 9.18.0)
|
|
327
|
+
|
|
274
328
|
## [9.18.0](https://github.com/event4u-app/agent-config/compare/9.17.0...9.18.0) (2026-08-04)
|
|
275
329
|
|
|
276
330
|
### Release highlights
|
|
@@ -39,7 +39,7 @@ schema_version: 1
|
|
|
39
39
|
# CI guard: a release bump of `package.json` must update this value
|
|
40
40
|
# in lockstep — see scripts/check_template_pin_drift.ts (road-to-
|
|
41
41
|
# portable-runtime-and-update-check P3.3).
|
|
42
|
-
agent_config_version: "9.18.
|
|
42
|
+
agent_config_version: "9.18.1"
|
|
43
43
|
|
|
44
44
|
# --- Project identity ---
|
|
45
45
|
project:
|
|
@@ -14402,7 +14402,7 @@
|
|
|
14402
14402
|
"reason": "scaffold template for ticket-bundle tickets (ADR-101); carries no runtime frontmatter — emit-tickets fills it in per ticket"
|
|
14403
14403
|
}
|
|
14404
14404
|
],
|
|
14405
|
-
"generated_at": "2026-08-
|
|
14405
|
+
"generated_at": "2026-08-04T15:34:05Z",
|
|
14406
14406
|
"packs": [
|
|
14407
14407
|
{
|
|
14408
14408
|
"artefact_count": 117,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
9221573649a81d2b695576370a341ecdcd2d64ba0246a685d3caaf4582a78ca8 discovery-manifest.json
|
package/dist/mcp/server.json
CHANGED
|
@@ -9,12 +9,12 @@
|
|
|
9
9
|
"transport": {
|
|
10
10
|
"type": "stdio"
|
|
11
11
|
},
|
|
12
|
-
"version": "9.18.
|
|
12
|
+
"version": "9.18.1"
|
|
13
13
|
}
|
|
14
14
|
],
|
|
15
15
|
"repository": {
|
|
16
16
|
"source": "github",
|
|
17
17
|
"url": "https://github.com/event4u-app/agent-config"
|
|
18
18
|
},
|
|
19
|
-
"version": "9.18.
|
|
19
|
+
"version": "9.18.1"
|
|
20
20
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@event4u/agent-config",
|
|
3
3
|
"mcpName": "io.github.event4u-app/agent-config",
|
|
4
|
-
"version": "9.18.
|
|
4
|
+
"version": "9.18.1",
|
|
5
5
|
"description": "Governed skills, rules and commands for AI coding tools (Claude Code, Cursor, Windsurf, Copilot) — every claim machine-checked, including \"zero runtime daemon\".",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"private": false,
|
|
@@ -329,8 +329,12 @@ gates:
|
|
|
329
329
|
Gate R2 (docs/contracts/plan-review-gates.md § 2). HONEST LIMIT, stated
|
|
330
330
|
because a floor that cannot fail is exactly the false green this file
|
|
331
331
|
exists to prevent: scanned = artefacts inspected + 1 for the diff
|
|
332
|
-
evaluation, so
|
|
333
|
-
trip.
|
|
332
|
+
evaluation, so on any run that REACHES the count `min_scanned: 1` cannot
|
|
333
|
+
trip. It is not unreachable in principle — a moved reviews root exits
|
|
334
|
+
before the inventory and reports `scanned: 0`, which the guard does read —
|
|
335
|
+
but that path is the dead-scope violation below, so the floor never adds
|
|
336
|
+
anything the assertion does not already catch.
|
|
337
|
+
NOTHING TRIPS AT ALL DURING THE ADVISORY WINDOW. The argv above
|
|
334
338
|
mirrors the CI invocation, and under `--advisory` every violation kind —
|
|
335
339
|
dead-scan-scope included — is printed as a warning and the exit code is 0.
|
|
336
340
|
So for the duration of the Stage-A window this entry proves only that the
|
|
@@ -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
|
+
}
|
|
@@ -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',
|
|
184
211
|
]);
|
|
185
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',
|
|
229
|
+
]);
|
|
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,91 @@ 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. An
|
|
420
|
+
* unterminated labelled opener skips nothing either and is itself a stray.
|
|
421
|
+
*/
|
|
422
|
+
export function scanFences(lines: readonly string[]): FenceScan {
|
|
423
|
+
const fenced = new Set<number>();
|
|
424
|
+
const strays: number[] = [];
|
|
425
|
+
let open: { at: number; ticks: number } | null = null;
|
|
426
|
+
for (let i = 0; i < lines.length; i++) {
|
|
427
|
+
const parts = fenceParts(lines[i] as string);
|
|
428
|
+
if (!parts) {
|
|
429
|
+
continue;
|
|
430
|
+
}
|
|
431
|
+
if (open !== null) {
|
|
432
|
+
// CommonMark closer: bare, and at least as long as the opener.
|
|
433
|
+
// Anything else is content inside the open region.
|
|
434
|
+
if (parts.info === '' && parts.ticks >= open.ticks) {
|
|
435
|
+
for (let j = open.at + 1; j < i; j++) {
|
|
436
|
+
fenced.add(j);
|
|
437
|
+
}
|
|
438
|
+
open = null;
|
|
439
|
+
}
|
|
440
|
+
continue;
|
|
441
|
+
}
|
|
442
|
+
if (parts.info === '') {
|
|
443
|
+
strays.push(i + 1);
|
|
444
|
+
continue;
|
|
445
|
+
}
|
|
446
|
+
open = { at: i, ticks: parts.ticks };
|
|
447
|
+
}
|
|
448
|
+
if (open !== null) {
|
|
449
|
+
strays.push(open.at + 1);
|
|
450
|
+
}
|
|
451
|
+
strays.sort((a, b) => a - b);
|
|
452
|
+
return { fenced, strays };
|
|
453
|
+
}
|
|
304
454
|
|
|
305
455
|
export function parseArtifact(text: string): ParsedArtifact {
|
|
306
456
|
const out: ParsedArtifact = {
|
|
@@ -311,18 +461,20 @@ export function parseArtifact(text: string): ParsedArtifact {
|
|
|
311
461
|
rows: [],
|
|
312
462
|
malformedLines: [],
|
|
313
463
|
malformedRows: [],
|
|
464
|
+
strayFenceLines: [],
|
|
314
465
|
manifest: parseManifest(text),
|
|
315
466
|
};
|
|
316
467
|
const lines = text.split(/\r?\n/);
|
|
317
|
-
|
|
468
|
+
const fences = scanFences(lines);
|
|
469
|
+
out.strayFenceLines = fences.strays;
|
|
318
470
|
for (let i = 0; i < lines.length; i++) {
|
|
319
471
|
const raw = lines[i] as string;
|
|
320
472
|
const lineno = i + 1;
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
if (
|
|
473
|
+
// A fence marker line carries no grammar of its own, and a line inside a
|
|
474
|
+
// LABELLED closed fence is illustrative content. A stray fence (bare, or
|
|
475
|
+
// an unclosed opener) skips nothing (see scanFences) — the lines around
|
|
476
|
+
// it are parsed, and the artefact is reported.
|
|
477
|
+
if (FENCE_RE.test(raw) || fences.fenced.has(i)) {
|
|
326
478
|
continue;
|
|
327
479
|
}
|
|
328
480
|
const trimmed = raw.trim();
|
|
@@ -371,7 +523,12 @@ export function parseArtifact(text: string): ParsedArtifact {
|
|
|
371
523
|
if (first === '#' || second === 'severity') {
|
|
372
524
|
continue; // header row
|
|
373
525
|
}
|
|
374
|
-
|
|
526
|
+
// EXACTLY six, per contract §2.2 — not `>= 6`. An over-long row is
|
|
527
|
+
// as malformed as a short one and the surplus is usually the tell:
|
|
528
|
+
// an unescaped `|` inside a cell (the shape §2.2 requires escaping)
|
|
529
|
+
// splits one cell into two and shifts Status/Reason-Ref rightwards,
|
|
530
|
+
// so accepting it silently reads the wrong cell as the status.
|
|
531
|
+
if (cells.length === 6) {
|
|
375
532
|
out.rows.push({
|
|
376
533
|
index: cells[0] ?? '',
|
|
377
534
|
severity: (cells[1] ?? '').toLowerCase(),
|
|
@@ -390,7 +547,7 @@ export function parseArtifact(text: string): ParsedArtifact {
|
|
|
390
547
|
// §2.2 template ends in exactly that empty cell. Gate R1 already
|
|
391
548
|
// reports this class — same name, `malformed_row`.
|
|
392
549
|
out.malformedRows.push(
|
|
393
|
-
`line ${lineno}: findings row has ${String(cells.length)} cell(s), expected 6 ` +
|
|
550
|
+
`line ${lineno}: findings row has ${String(cells.length)} cell(s), expected exactly 6 ` +
|
|
394
551
|
'(`| # | Severity | File:Line | Finding | Status | Reason/Ref |` — the trailing ' +
|
|
395
552
|
'Reason/Ref cell is required even when empty)',
|
|
396
553
|
);
|
|
@@ -605,6 +762,24 @@ function evaluate(input: EvalInput): Violation[] {
|
|
|
605
762
|
for (const detail of art.malformedRows) {
|
|
606
763
|
violations.push({ kind: 'malformed-row', file, detail });
|
|
607
764
|
}
|
|
765
|
+
// §2.2: stray ``` fences — a BARE fence (which delimits nothing) or a
|
|
766
|
+
// labelled opener left unclosed. The parser swallows no line around them
|
|
767
|
+
// (so an `open` row still blocks), but an artefact whose fences do not
|
|
768
|
+
// resolve is malformed in its own right — the reader cannot tell
|
|
769
|
+
// illustrative content from live rows. ONE violation naming EVERY stray:
|
|
770
|
+
// a single line number could only ever name the last one.
|
|
771
|
+
if (art.strayFenceLines.length > 0) {
|
|
772
|
+
const at = art.strayFenceLines.map((n) => `line ${String(n)}`).join(', ');
|
|
773
|
+
violations.push({
|
|
774
|
+
kind: 'unbalanced-fence',
|
|
775
|
+
file,
|
|
776
|
+
detail:
|
|
777
|
+
`${at}: stray \`\`\` fence — a bare fence delimits no region, and a labelled fence must ` +
|
|
778
|
+
'be closed. An artefact whose ``` fences do not resolve is malformed (contract §2.2). ' +
|
|
779
|
+
'Label the opener (```markdown) and close it, or inline-escape an illustrative ```; ' +
|
|
780
|
+
'the lines around a stray fence are still parsed, so any finding row there is validated too.',
|
|
781
|
+
});
|
|
782
|
+
}
|
|
608
783
|
}
|
|
609
784
|
|
|
610
785
|
for (const { file, art } of relevant) {
|
|
@@ -757,6 +932,13 @@ function checkFindingsBeforeFixes(repo: string, artifactAbs: string, rows: reado
|
|
|
757
932
|
// Earliest add commit: `git log --diff-filter=A` lists newest-first, so the
|
|
758
933
|
// LAST line is the first commit that ever added the file (backdating via a
|
|
759
934
|
// later amend does not move it).
|
|
935
|
+
//
|
|
936
|
+
// Scope of the guarantee, per contract §2.5: this resolves the earliest add
|
|
937
|
+
// of the PATH, not of the current round's artefact — a re-bound (or renamed
|
|
938
|
+
// and re-added) `<slug>.findings.md` keeps round 1's add. So the check proves
|
|
939
|
+
// "the review file existed before the fixes of its FIRST round", and
|
|
940
|
+
// per-round ordering stays agent-carried convention. Deliberate, and named
|
|
941
|
+
// as a limit in the contract rather than implied away.
|
|
760
942
|
const logRes = gitTry(repo, ['log', '--diff-filter=A', '--format=%H', '--', rel]);
|
|
761
943
|
const addLines = logRes.stdout
|
|
762
944
|
.split('\n')
|
|
@@ -11,8 +11,14 @@
|
|
|
11
11
|
* commit types + rule/schema diffs, honest-null markers, removed public
|
|
12
12
|
* surface) and FAILS when a populated generated category meets a `_none_`
|
|
13
13
|
* curated field. It blocks the contradiction only — a human still writes the
|
|
14
|
-
* prose
|
|
15
|
-
*
|
|
14
|
+
* prose, and a filled field is never judged for quality.
|
|
15
|
+
*
|
|
16
|
+
* The classifier lives in `_lib/release_highlights.ts` and is shared with the
|
|
17
|
+
* generator, which now pre-fills each substantiated label instead of writing
|
|
18
|
+
* `_none_` everywhere. That removes the guaranteed first-run red (9.17.0,
|
|
19
|
+
* 9.18.0) without blunting this gate: a machine-written `_none_` was never the
|
|
20
|
+
* failure it was built for — a HUMAN writing one was (9.13.0, 9.14.0), and that
|
|
21
|
+
* still fails here. An unrewritten derived line warns; it never blocks.
|
|
16
22
|
*
|
|
17
23
|
* Derivation is deliberately conservative (documented per-label below):
|
|
18
24
|
* a false red makes every release annoying; a miss only returns the head to
|
|
@@ -25,96 +31,41 @@
|
|
|
25
31
|
* `--from` defaults to the latest reachable release tag before `--to`
|
|
26
32
|
* (default HEAD). Exit codes: 0 plausible · 1 contradiction · 2 usage error.
|
|
27
33
|
*/
|
|
28
|
-
import { spawnSync } from 'node:child_process';
|
|
29
34
|
import * as fs from 'node:fs';
|
|
30
35
|
import * as path from 'node:path';
|
|
31
36
|
import process from 'node:process';
|
|
32
37
|
import { fileURLToPath } from 'node:url';
|
|
33
38
|
|
|
39
|
+
import {
|
|
40
|
+
HEAD_LABELS,
|
|
41
|
+
HEAD_NONE,
|
|
42
|
+
type SpanCommit,
|
|
43
|
+
collect_span_commits,
|
|
44
|
+
derive_categories,
|
|
45
|
+
parse_git_log,
|
|
46
|
+
previous_release_tag,
|
|
47
|
+
stale_draft_labels,
|
|
48
|
+
} from './_lib/release_highlights.js';
|
|
34
49
|
import { extract_changelog_section } from './_lib/release_material.js';
|
|
35
50
|
|
|
36
51
|
const _HERE = fileURLToPath(import.meta.url);
|
|
37
52
|
const REPO_ROOT = path.resolve(path.dirname(_HERE), '..', '..');
|
|
38
53
|
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
subject: string;
|
|
55
|
-
body: string;
|
|
56
|
-
/** `A` / `M` / `D` / `R…` status per touched path. */
|
|
57
|
-
files: ReadonlyArray<{ status: string; path: string }>;
|
|
58
|
-
breaking: boolean;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/** Public artefact trees whose deletions count as removed public surface. */
|
|
62
|
-
const _PUBLIC_SURFACE_PREFIXES = [
|
|
63
|
-
'src/skills/',
|
|
64
|
-
'src/rules/',
|
|
65
|
-
'src/agent-src/commands/',
|
|
66
|
-
'src/domains/',
|
|
67
|
-
];
|
|
68
|
-
|
|
69
|
-
const _RULE_OR_SCHEMA_PREFIXES = ['src/rules/', 'src/scripts/schemas/'];
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* Derive evidence per curated label from the span. Rules per label:
|
|
73
|
-
*
|
|
74
|
-
* - Security and correctness: conventional scope matching /secur/i, or the
|
|
75
|
-
* whole word "security" in the subject.
|
|
76
|
-
* - Behaviour changes: breaking (`!` / BREAKING CHANGE) commits, diffs
|
|
77
|
-
* touching `src/rules/` or `src/scripts/schemas/`, and deletions under the
|
|
78
|
-
* public artefact trees (removed public surface).
|
|
79
|
-
* - Default changes + migration: subject carrying the whole word "default",
|
|
80
|
-
* "migration", or "migrate".
|
|
81
|
-
* - Honest nulls: subject or body carrying an "honest null" marker.
|
|
82
|
-
* - Known limitations: never derived — pure prose, not gate-checkable.
|
|
83
|
-
*/
|
|
84
|
-
export function derive_categories(commits: readonly SpanCommit[]): Record<string, string[]> {
|
|
85
|
-
const out: Record<string, string[]> = {};
|
|
86
|
-
for (const label of HEAD_LABELS) {
|
|
87
|
-
out[label] = [];
|
|
88
|
-
}
|
|
89
|
-
const evidence = (c: SpanCommit): string => `${c.sha.slice(0, 7)} ${c.subject}`;
|
|
90
|
-
for (const c of commits) {
|
|
91
|
-
const scopeMatch = /^\w+\(([^)]*)\)!?:/u.exec(c.subject);
|
|
92
|
-
const scope = scopeMatch ? scopeMatch[1]! : '';
|
|
93
|
-
if (/secur/iu.test(scope) || /\bsecurity\b/iu.test(c.subject)) {
|
|
94
|
-
out['Security and correctness']!.push(evidence(c));
|
|
95
|
-
}
|
|
96
|
-
const removedPublic = c.files.filter(
|
|
97
|
-
(f) => f.status.startsWith('D') && _PUBLIC_SURFACE_PREFIXES.some((p) => f.path.startsWith(p)),
|
|
98
|
-
);
|
|
99
|
-
const ruleOrSchema = c.files.some((f) =>
|
|
100
|
-
_RULE_OR_SCHEMA_PREFIXES.some((p) => f.path.startsWith(p)),
|
|
101
|
-
);
|
|
102
|
-
if (c.breaking || ruleOrSchema || removedPublic.length > 0) {
|
|
103
|
-
const detail =
|
|
104
|
-
removedPublic.length > 0
|
|
105
|
-
? `${evidence(c)} (removes ${removedPublic.map((f) => f.path).join(', ')})`
|
|
106
|
-
: evidence(c);
|
|
107
|
-
out['Behaviour changes']!.push(detail);
|
|
108
|
-
}
|
|
109
|
-
if (/\b(default|migration|migrate)\b/iu.test(c.subject)) {
|
|
110
|
-
out['Default changes + migration']!.push(evidence(c));
|
|
111
|
-
}
|
|
112
|
-
if (/honest[ -]null/iu.test(`${c.subject}\n${c.body}`)) {
|
|
113
|
-
out['Honest nulls']!.push(evidence(c));
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
return out;
|
|
117
|
-
}
|
|
54
|
+
// Labels and derivation live in `_lib/release_highlights.ts` — ONE definition
|
|
55
|
+
// shared with the generator that pre-fills the head. They used to be duplicated
|
|
56
|
+
// here to keep the checker independent of the release pipeline, and that
|
|
57
|
+
// independence is what produced the defect this gate now guards against from
|
|
58
|
+
// the other side: the generator wrote `_none_` into every field while this
|
|
59
|
+
// checker rejected exactly that, so every release PR was red on its first run.
|
|
60
|
+
// Sharing the classifier does not blunt the gate — it still blocks the failure
|
|
61
|
+
// it was built for, a human editing a substantiated line back to `_none_`.
|
|
62
|
+
export {
|
|
63
|
+
HEAD_LABELS,
|
|
64
|
+
HEAD_NONE,
|
|
65
|
+
derive_categories,
|
|
66
|
+
parse_git_log as _parse_git_log,
|
|
67
|
+
type SpanCommit,
|
|
68
|
+
};
|
|
118
69
|
|
|
119
70
|
/**
|
|
120
71
|
* Parse the curated head from a changelog section body. Returns null when
|
|
@@ -162,74 +113,6 @@ export function highlight_contradictions(
|
|
|
162
113
|
return out;
|
|
163
114
|
}
|
|
164
115
|
|
|
165
|
-
// ─── git span collection (CLI only — the core above is pure) ────────────────
|
|
166
|
-
|
|
167
|
-
const _RECORD = '\u001e';
|
|
168
|
-
const _FIELD = '\u001f';
|
|
169
|
-
|
|
170
|
-
export function _parse_git_log(raw: string): SpanCommit[] {
|
|
171
|
-
const commits: SpanCommit[] = [];
|
|
172
|
-
for (const record of raw.split(_RECORD)) {
|
|
173
|
-
if (!record.trim()) {
|
|
174
|
-
continue;
|
|
175
|
-
}
|
|
176
|
-
const [head, ...restLines] = record.split('\n');
|
|
177
|
-
const parts = (head ?? '').split(_FIELD);
|
|
178
|
-
if (parts.length < 3) {
|
|
179
|
-
continue;
|
|
180
|
-
}
|
|
181
|
-
const sha = parts[0]!.trim();
|
|
182
|
-
const subject = parts[1]!;
|
|
183
|
-
const bodyParts: string[] = [parts.slice(2).join(_FIELD)];
|
|
184
|
-
const files: Array<{ status: string; path: string }> = [];
|
|
185
|
-
for (const line of restLines) {
|
|
186
|
-
const m = /^([A-Z]\d*)\t(.+)$/u.exec(line);
|
|
187
|
-
if (m) {
|
|
188
|
-
// Renames carry two paths; the last tab field is the new path,
|
|
189
|
-
// the first the old — record both so a rename out of a public
|
|
190
|
-
// tree still counts its origin path.
|
|
191
|
-
const segs = m[2]!.split('\t');
|
|
192
|
-
for (const p of segs) {
|
|
193
|
-
files.push({ status: m[1]!, path: p });
|
|
194
|
-
}
|
|
195
|
-
} else {
|
|
196
|
-
// %b is multi-line — everything that is not a name-status
|
|
197
|
-
// line is body continuation (honest-null markers live there).
|
|
198
|
-
bodyParts.push(line);
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
const body = bodyParts.join('\n');
|
|
202
|
-
commits.push({
|
|
203
|
-
sha,
|
|
204
|
-
subject,
|
|
205
|
-
body,
|
|
206
|
-
files,
|
|
207
|
-
breaking: /^\w+(\([^)]*\))?!:/u.test(subject) || /BREAKING CHANGE/u.test(body),
|
|
208
|
-
});
|
|
209
|
-
}
|
|
210
|
-
return commits;
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
function _git(args: string[]): string {
|
|
214
|
-
const r = spawnSync('git', args, { encoding: 'utf-8', cwd: REPO_ROOT, maxBuffer: 64 * 1024 * 1024 });
|
|
215
|
-
if (r.status !== 0) {
|
|
216
|
-
process.stderr.write(`git ${args.join(' ')} failed: ${r.stderr}\n`);
|
|
217
|
-
process.exit(2);
|
|
218
|
-
}
|
|
219
|
-
return r.stdout;
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
function _collect_span(from: string, to: string): SpanCommit[] {
|
|
223
|
-
const raw = _git([
|
|
224
|
-
'log',
|
|
225
|
-
`${from}..${to}`,
|
|
226
|
-
'--no-merges',
|
|
227
|
-
'--name-status',
|
|
228
|
-
'--format=%x1e%H%x1f%s%x1f%b',
|
|
229
|
-
]);
|
|
230
|
-
return _parse_git_log(raw);
|
|
231
|
-
}
|
|
232
|
-
|
|
233
116
|
function main(argv: readonly string[]): number {
|
|
234
117
|
let version: string | null = null;
|
|
235
118
|
let from: string | null = null;
|
|
@@ -251,7 +134,13 @@ function main(argv: readonly string[]): number {
|
|
|
251
134
|
return 2;
|
|
252
135
|
}
|
|
253
136
|
if (!from) {
|
|
254
|
-
from =
|
|
137
|
+
from = previous_release_tag(to, REPO_ROOT);
|
|
138
|
+
if (!from) {
|
|
139
|
+
process.stderr.write(
|
|
140
|
+
`no release tag reachable before ${to} — pass --from explicitly\n`,
|
|
141
|
+
);
|
|
142
|
+
return 2;
|
|
143
|
+
}
|
|
255
144
|
}
|
|
256
145
|
const section = extract_changelog_section(fs.readFileSync(changelogPath, 'utf-8'), version);
|
|
257
146
|
if (!section) {
|
|
@@ -263,7 +152,26 @@ function main(argv: readonly string[]): number {
|
|
|
263
152
|
process.stdout.write(`ℹ️ no curated head in the ${version} section — nothing to check\n`);
|
|
264
153
|
return 0;
|
|
265
154
|
}
|
|
266
|
-
|
|
155
|
+
let span: SpanCommit[];
|
|
156
|
+
try {
|
|
157
|
+
span = collect_span_commits(from, to, REPO_ROOT);
|
|
158
|
+
} catch (err) {
|
|
159
|
+
process.stderr.write(`${(err as Error).message}\n`);
|
|
160
|
+
return 2;
|
|
161
|
+
}
|
|
162
|
+
// A generator-derived line that nobody rewrote is a prose gap, never a
|
|
163
|
+
// contradiction: the line's evidence is true, it is just unpolished. Warn
|
|
164
|
+
// so the omission is visible, and keep the exit code owned solely by the
|
|
165
|
+
// `_none_` check — a warning that reds the build is the guaranteed-red
|
|
166
|
+
// failure mode this whole change exists to remove.
|
|
167
|
+
const drafts = stale_draft_labels(curated);
|
|
168
|
+
if (drafts.length > 0) {
|
|
169
|
+
process.stdout.write(
|
|
170
|
+
`⚠️ auto-derived head line(s) not yet rewritten for ${version}: ` +
|
|
171
|
+
`${drafts.join(', ')} — advisory, not blocking.\n`,
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
const derived = derive_categories(span);
|
|
267
175
|
const contradictions = highlight_contradictions(curated, derived);
|
|
268
176
|
if (contradictions.length === 0) {
|
|
269
177
|
process.stdout.write(`✅ curated head plausible for ${version} (span ${from}..${to})\n`);
|
|
@@ -437,11 +437,12 @@ const CI_BRANCH_ENV_KEYS = ['GITHUB_HEAD_REF', 'GITHUB_REF_NAME'] as const;
|
|
|
437
437
|
* uses it to decide whether a leftover artifact in the reviews directory is
|
|
438
438
|
* THIS branch's (and may therefore produce violations) or a foreign one.
|
|
439
439
|
*
|
|
440
|
-
*
|
|
441
|
-
*
|
|
442
|
-
*
|
|
443
|
-
*
|
|
444
|
-
*
|
|
440
|
+
* Resolution order is **git first, CI environment as the fallback** — see the
|
|
441
|
+
* reason inline below. The env fallback exists because on a `pull_request`
|
|
442
|
+
* checkout `HEAD` is a detached synthetic merge commit: `rev-parse --abbrev-ref
|
|
443
|
+
* HEAD` yields `HEAD`, the slug would degrade to `detached-<sha>`, and no
|
|
444
|
+
* artefact could ever be "own" — inverting contract §2.6 on the layer the
|
|
445
|
+
* contract calls authoritative (an own malformed artefact would report
|
|
445
446
|
* `missing-artifact` instead of the root-cause `bad-marker`, and `stale-review`
|
|
446
447
|
* would never fire in CI). A `HEAD` / `detached-*` env value carries no branch
|
|
447
448
|
* identity either and is ignored.
|
package/src/scripts/release.ts
CHANGED
|
@@ -118,6 +118,12 @@ import {
|
|
|
118
118
|
perform_split,
|
|
119
119
|
plan_split,
|
|
120
120
|
} from './_lib/changelog_eras.js';
|
|
121
|
+
import {
|
|
122
|
+
HEAD_LABELS,
|
|
123
|
+
collect_span_commits,
|
|
124
|
+
derive_category_hits,
|
|
125
|
+
render_derived_head_values,
|
|
126
|
+
} from './_lib/release_highlights.js';
|
|
121
127
|
import {
|
|
122
128
|
extract_changelog_section,
|
|
123
129
|
pr_body_from_section,
|
|
@@ -383,13 +389,7 @@ const SECTIONS: ReadonlyArray<readonly [string, string | null, readonly string[]
|
|
|
383
389
|
* so the package does not end up with two competing definitions of a curated
|
|
384
390
|
* head.
|
|
385
391
|
*/
|
|
386
|
-
const RELEASE_HEAD_SECTIONS: ReadonlyArray<string> =
|
|
387
|
-
'Behaviour changes',
|
|
388
|
-
'Default changes + migration',
|
|
389
|
-
'Security and correctness',
|
|
390
|
-
'Honest nulls',
|
|
391
|
-
'Known limitations',
|
|
392
|
-
];
|
|
392
|
+
const RELEASE_HEAD_SECTIONS: ReadonlyArray<string> = HEAD_LABELS;
|
|
393
393
|
|
|
394
394
|
/**
|
|
395
395
|
* Cap on the head, in rendered lines.
|
|
@@ -402,16 +402,44 @@ const RELEASE_HEAD_SECTIONS: ReadonlyArray<string> = [
|
|
|
402
402
|
export const RELEASE_HEAD_CAP_LINES = 10;
|
|
403
403
|
|
|
404
404
|
/**
|
|
405
|
-
* The
|
|
405
|
+
* The fallback value of a head line the span does not substantiate.
|
|
406
|
+
*
|
|
407
|
+
* `_none_` and not a placeholder token: where nothing was derived it is the
|
|
408
|
+
* **true** answer, and a release that genuinely changed no defaults should say
|
|
409
|
+
* so rather than carry an unfilled marker.
|
|
406
410
|
*
|
|
407
|
-
*
|
|
408
|
-
*
|
|
409
|
-
*
|
|
410
|
-
*
|
|
411
|
-
*
|
|
411
|
+
* It is a fallback and no longer a blanket default. Writing `_none_` into
|
|
412
|
+
* every field made the generator assert five things it had not checked, and
|
|
413
|
+
* `check_release_highlights` rejected exactly that assertion the moment the
|
|
414
|
+
* span contradicted it — which for this package is every release, because every
|
|
415
|
+
* release touches `src/rules/` or `src/scripts/schemas/`. Substantiated labels
|
|
416
|
+
* are now pre-filled from the span (`_derive_head_prefill`), so the tool states
|
|
417
|
+
* only what it can support and `_none_` means what it says.
|
|
412
418
|
*/
|
|
413
419
|
const HEAD_DEFAULT = '_none_';
|
|
414
420
|
|
|
421
|
+
/**
|
|
422
|
+
* Pre-fill values for the curated head from the release span.
|
|
423
|
+
*
|
|
424
|
+
* Best-effort by design, same stance as the test-trend footer: a git failure
|
|
425
|
+
* degrades to the `_none_` skeleton rather than blocking a release. The gate
|
|
426
|
+
* that reads the result runs later, on the branch head, and derives from the
|
|
427
|
+
* same shared classifier — so a degraded run surfaces as the gate's ordinary
|
|
428
|
+
* "fill the head" message instead of a crash mid-release.
|
|
429
|
+
*/
|
|
430
|
+
function _derive_head_prefill(prev: string | null): Record<string, string> {
|
|
431
|
+
try {
|
|
432
|
+
const span = collect_span_commits(prev, 'HEAD', REPO_ROOT);
|
|
433
|
+
return render_derived_head_values(derive_category_hits(span));
|
|
434
|
+
} catch (err) {
|
|
435
|
+
process.stderr.write(
|
|
436
|
+
`warning: could not derive release-head highlights (${(err as Error).message}); ` +
|
|
437
|
+
'the head ships as the `_none_` skeleton\n',
|
|
438
|
+
);
|
|
439
|
+
return {};
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
415
443
|
/**
|
|
416
444
|
* Render the curated head. Emitted by the generator on every release so it
|
|
417
445
|
* cannot be forgotten; edited by the maintainer before merge.
|
|
@@ -2344,8 +2372,14 @@ function main(argv: readonly string[] | null = null): number {
|
|
|
2344
2372
|
// (~15s+), which `--dry-run exits 0 without needing …` contracts (and
|
|
2345
2373
|
// the release test) assume never runs. Real releases compute it.
|
|
2346
2374
|
const test_trend_line = args.dry_run ? null : _render_test_trend_line(prev);
|
|
2375
|
+
// Derived unconditionally, dry-run included: it is one `git log` (~25 ms),
|
|
2376
|
+
// not the trend line's full vitest collection. Skipping it on `--dry-run`
|
|
2377
|
+
// made the preview print the `_none_` skeleton while the real run wrote
|
|
2378
|
+
// pre-filled lines — a preview that contradicts its own output, which is
|
|
2379
|
+
// the exact class of surface disagreement this pre-fill exists to end.
|
|
2347
2380
|
const [full, body] = render_changelog_entry(target, prev, commits, today, {
|
|
2348
2381
|
test_trend_line,
|
|
2382
|
+
head: _derive_head_prefill(prev),
|
|
2349
2383
|
});
|
|
2350
2384
|
|
|
2351
2385
|
// Era-split planning — gate on the POST-release view (2026-07-07 fix).
|