@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
|
@@ -277,6 +277,10 @@ export function main(argv?: readonly string[]): number {
|
|
|
277
277
|
process.stderr.write(`❌ ${exc instanceof Error ? exc.message : String(exc)}\n`);
|
|
278
278
|
return 2;
|
|
279
279
|
}
|
|
280
|
+
// Gate-coverage contract (src/config/gate-coverage.yml rule 1): publish the
|
|
281
|
+
// asserted count machine-readably. Written to stderr because `--format json`
|
|
282
|
+
// owns stdout here — a count on stdout would corrupt the JSON payload.
|
|
283
|
+
process.stderr.write(`scanned: ${String(targets.length)}\n`);
|
|
280
284
|
|
|
281
285
|
const all_violations: Violation[] = [];
|
|
282
286
|
for (const p of targets) {
|
|
@@ -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.
|
|
@@ -359,6 +359,10 @@ function main(argv?: readonly string[]): number {
|
|
|
359
359
|
process.stderr.write(`❌ ${exc.message}\n`);
|
|
360
360
|
return 2;
|
|
361
361
|
}
|
|
362
|
+
// Gate-coverage contract (src/config/gate-coverage.yml rule 1): publish the
|
|
363
|
+
// asserted count machine-readably, before the verdict branches, so a run
|
|
364
|
+
// that finds errors still reports the corpus it read.
|
|
365
|
+
process.stdout.write(`scanned: ${String(artefacts.length)}\n`);
|
|
362
366
|
const allErrs: string[] = [];
|
|
363
367
|
for (const p of artefacts) {
|
|
364
368
|
allErrs.push(..._check_one(p, wsIds, packIds, quarantine));
|
|
@@ -362,7 +362,13 @@ export function validate_handoff_artifact(text: string): string[] {
|
|
|
362
362
|
|
|
363
363
|
export function main(argv?: readonly string[]): number {
|
|
364
364
|
let skills_dir = SKILLS_DIR;
|
|
365
|
-
|
|
365
|
+
// Positional-only: flags are NOT paths. Before this filter the CI invocation
|
|
366
|
+
// (`lint_handoffs --quiet`, injected by Taskfile's QUIET_FLAG) resolved
|
|
367
|
+
// `--quiet` as the skills root, scanned 0 files and exited 2 — the gate was
|
|
368
|
+
// red under the exact argv CI runs while green when probed bare. That is the
|
|
369
|
+
// inverse of this suite's dead-scope defect and the reason gate-coverage
|
|
370
|
+
// rule 2 pins CI-identical argv.
|
|
371
|
+
const args = (argv ?? process.argv.slice(2)).filter((a) => !String(a).startsWith('-'));
|
|
366
372
|
if (args.length > 0 && String(args[0]).endsWith('HANDOFF.md')) {
|
|
367
373
|
// artifact mode — validate the workflow-resume file's required fields
|
|
368
374
|
const text = fs.readFileSync(_resolve(args[0] as string), 'utf-8');
|
|
@@ -399,6 +405,12 @@ export function main(argv?: readonly string[]): number {
|
|
|
399
405
|
process.stderr.write(`❌ ${exc.message}\n`);
|
|
400
406
|
return 2;
|
|
401
407
|
}
|
|
408
|
+
// Gate-coverage contract (src/config/gate-coverage.yml rule 1): publish the
|
|
409
|
+
// count the assertion above just validated. Emitted before the verdict
|
|
410
|
+
// branches so a run WITH violations still reports its corpus — coverage and
|
|
411
|
+
// verdict are different questions — and outside the QUIET guard, because CI
|
|
412
|
+
// passes --quiet and a count only visible without it is not a count.
|
|
413
|
+
process.stdout.write(`scanned: ${String(scanned)}\n`);
|
|
402
414
|
|
|
403
415
|
const violations = lint(skills_dir);
|
|
404
416
|
if (violations.length === 0) {
|
|
@@ -347,6 +347,11 @@ function main(argv?: readonly string[]): number {
|
|
|
347
347
|
process.stderr.write(`❌ ${exc.message}\n`);
|
|
348
348
|
return 2;
|
|
349
349
|
}
|
|
350
|
+
// Gate-coverage contract (src/config/gate-coverage.yml rule 1): publish the
|
|
351
|
+
// asserted count machine-readably. The human line below is quiet-suppressed
|
|
352
|
+
// and only prints on the clean path; this one prints on both, because a gate
|
|
353
|
+
// that found issues still scanned its corpus.
|
|
354
|
+
process.stdout.write(`scanned: ${String(checked)}\n`);
|
|
350
355
|
if (issues) {
|
|
351
356
|
process.stderr.write(`BASELINE: ${issues} issue(s) across ${checked} name(s)\n`);
|
|
352
357
|
return 1;
|
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).
|
|
@@ -372,7 +372,7 @@ export function analyze(src: string, repoRoot: string): Analysis {
|
|
|
372
372
|
const confirmed: Finding[] = [];
|
|
373
373
|
const unproven: Finding[] = [];
|
|
374
374
|
const roots = new Map<string, string>();
|
|
375
|
-
const missing = (rel: string): boolean => !
|
|
375
|
+
const missing = (rel: string): boolean => !rootExists(repoRoot, rel);
|
|
376
376
|
|
|
377
377
|
for (const [rel, e] of found) {
|
|
378
378
|
roots.set(rel, [...e.names].join(',') || '(inline)');
|
|
@@ -549,12 +549,46 @@ export function censusOnlyRoots(src: string, repoRoot: string): Map<string, stri
|
|
|
549
549
|
if (rel === '' || rel.startsWith('..')) continue;
|
|
550
550
|
// `node_modules/.bin/tsx` is a tool path a gate SPAWNS, not a corpus it reads.
|
|
551
551
|
if (rel === 'node_modules' || rel.startsWith('node_modules/')) continue;
|
|
552
|
-
if (!
|
|
552
|
+
if (!rootExists(repoRoot, rel)) continue; // the safety filter — see the doc comment
|
|
553
553
|
if (!out.has(rel)) out.set(rel, '(census-only)');
|
|
554
554
|
}
|
|
555
555
|
return out;
|
|
556
556
|
}
|
|
557
557
|
|
|
558
|
+
/**
|
|
559
|
+
* Does a declared root exist, independent of checkout shape?
|
|
560
|
+
*
|
|
561
|
+
* A plain `existsSync` answers differently in a linked git worktree, where
|
|
562
|
+
* `.git` is a FILE containing `gitdir: …` rather than a directory — so a root
|
|
563
|
+
* like `.git/HEAD` resolves in a clone and vanishes in a worktree. The census
|
|
564
|
+
* has to "match a fresh run" to be worth committing, and a report whose rows
|
|
565
|
+
* depend on which checkout generated it cannot. Resolving `.git/*` through the
|
|
566
|
+
* real git directory makes the two agree.
|
|
567
|
+
*/
|
|
568
|
+
export function resolveRoot(repoRoot: string, rel: string): string {
|
|
569
|
+
if (rel === '.git' || rel.startsWith('.git/')) {
|
|
570
|
+
const gitPath = path.join(repoRoot, '.git');
|
|
571
|
+
try {
|
|
572
|
+
if (fs.statSync(gitPath).isFile()) {
|
|
573
|
+
// `gitdir: <abs-or-relative path>` — the worktree's real git dir.
|
|
574
|
+
const m = /^gitdir:\s*(.+)$/m.exec(fs.readFileSync(gitPath, 'utf8'));
|
|
575
|
+
const target = m?.[1]?.trim();
|
|
576
|
+
if (target !== undefined && target !== '') {
|
|
577
|
+
const gitDir = path.isAbsolute(target) ? target : path.join(repoRoot, target);
|
|
578
|
+
return rel === '.git' ? gitDir : path.join(gitDir, rel.slice('.git/'.length));
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
} catch {
|
|
582
|
+
/* fall through to the plain join — a missing .git is genuinely absent */
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
return path.join(repoRoot, rel);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
export function rootExists(repoRoot: string, rel: string): boolean {
|
|
589
|
+
return fs.existsSync(resolveRoot(repoRoot, rel));
|
|
590
|
+
}
|
|
591
|
+
|
|
558
592
|
/** Directories never worth walking for a unit count. */
|
|
559
593
|
const COUNT_SKIP: ReadonlySet<string> = new Set(['node_modules', '.git', '.venv', '__pycache__']);
|
|
560
594
|
|
|
@@ -578,7 +612,7 @@ export interface RootCount {
|
|
|
578
612
|
* a container migration shows up as a diff instead of as silence.
|
|
579
613
|
*/
|
|
580
614
|
export function countUnits(repoRoot: string, rel: string): RootCount {
|
|
581
|
-
const abs =
|
|
615
|
+
const abs = resolveRoot(repoRoot, rel);
|
|
582
616
|
let st: fs.Stats;
|
|
583
617
|
try {
|
|
584
618
|
st = fs.statSync(abs);
|