@event4u/agent-config 14.15.0 → 14.17.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/.augment-plugin/marketplace.json +3 -3
- package/.augment-plugin/plugin.json +1 -1
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +84 -153
- package/README.md +1 -1
- package/dist/agent-src/commands/analyze/inbox.md +172 -6
- package/dist/agent-src/commands/fix/commit-messages.md +336 -0
- package/dist/agent-src/commands/fix.md +5 -3
- package/dist/agent-src/rules/commit-conventions.md +1 -1
- package/dist/agent-src/rules/security-sensitive-stop.md +7 -5
- package/dist/agent-src/skills/accessibility-auditor/SKILL.md +20 -2
- package/dist/agent-src/skills/aws-infrastructure/SKILL.md +41 -0
- package/dist/agent-src/skills/conventional-commits-writing/SKILL.md +234 -4
- package/dist/agent-src/skills/design-intelligence/data/ux-guidelines.csv +99 -99
- package/dist/agent-src/skills/fe-design/SKILL.md +6 -3
- package/dist/agent-src/skills/fe-design/references/design-patterns.md +28 -0
- package/dist/agent-src/skills/iconography/SKILL.md +37 -6
- package/dist/agent-src/skills/react-shadcn-ui/SKILL.md +1 -1
- package/dist/agent-src/skills/terraform/SKILL.md +29 -0
- package/dist/agent-src/skills/threat-modeling/data/reasoning.csv +1 -0
- package/dist/agent-src/skills/threat-modeling/data/surfaces.csv +1 -0
- package/dist/agent-src/skills/threat-modeling/data/threats.csv +5 -0
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/cli-delegate/cmd_conformance.js +158 -33
- package/dist/cli-delegate/cmd_explain.js +24 -112
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +47 -21
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +3 -3
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +6 -5
- package/dist/discovery/trust-report.md +2 -2
- package/dist/discovery/workspaces.json +5 -4
- package/dist/hooks/dispatch.js +169 -41
- package/dist/mcp/registry-manifest.json +3 -3
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +160 -35
- package/dist/router.json +1 -1
- package/docs/guidelines/design-antipatterns.md +10 -2
- package/package.json +1 -1
- package/src/config/gate-violation-baselines.json +2 -2
- package/src/config/web-launch-readiness.json +1 -1
- package/src/scripts/_cli/explain_last/index.ts +1 -1
- package/src/scripts/_cli/explain_last/memory.ts +8 -104
- package/src/scripts/check_artefact_count_messaging.ts +9 -0
- package/src/scripts/check_release_highlights.ts +77 -4
- package/src/scripts/check_web_launch_readiness.ts +295 -15
- package/src/scripts/design_slop_rules.ts +19 -9
- package/src/scripts/git_authorization_hook.ts +257 -35
- package/src/scripts/hooks/block_unauthorized_git.ts +16 -4
- package/src/scripts/hooks/merge_impact.ts +68 -0
- package/src/scripts/measure_release_mix.ts +384 -0
- package/src/scripts/release.ts +4 -17
- package/src/scripts/release_drill.ts +154 -1
- package/src/scripts/release_mix_taxonomy.json +172 -0
- package/src/scripts/release_publication.ts +91 -0
- package/src/scripts/attest_artifact.ts +0 -193
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
/**
|
|
3
|
+
* Release governance-vs-product mix — measured from changed paths, published as
|
|
4
|
+
* two views, with no threshold.
|
|
5
|
+
*
|
|
6
|
+
* Why this exists, and what it deliberately is not.
|
|
7
|
+
*
|
|
8
|
+
* An external reviewer asked twice for a per-PR rule: "no meta/governance
|
|
9
|
+
* feature without a user-facing artifact in the same PR". The AI council
|
|
10
|
+
* DECLINED that proposal (2026-09-04, anthropic/claude-sonnet-4-5 +
|
|
11
|
+
* openai/codex-default, 2 rounds, quorum 2/2, $0.00 — both seats
|
|
12
|
+
* subscription-authed) on two grounds: a same-PR gate measures packaging rather
|
|
13
|
+
* than progress, since one token consumer-file edit legitimises an otherwise
|
|
14
|
+
* governance-only pull request; and it rejects legitimate work by construction,
|
|
15
|
+
* since a CI fix, a dependency bump and an analysis round producing only
|
|
16
|
+
* roadmaps are all real work with no consumer surface. The decline is recorded
|
|
17
|
+
* in `docs/decisions/ADR-253-per-pr-user-artifact-gate-declined.md`.
|
|
18
|
+
*
|
|
19
|
+
* This script is the replacement both seats specified: classify at RELEASE
|
|
20
|
+
* level, publish two views, attach a response obligation, and refuse to pick a
|
|
21
|
+
* threshold until at least two readings exist. It therefore **enforces
|
|
22
|
+
* nothing about the ratio**. It measures and it publishes. The one enforced
|
|
23
|
+
* consequence lives in `check_release_highlights.ts` and is about the
|
|
24
|
+
* completeness of the written response, never about the number.
|
|
25
|
+
*
|
|
26
|
+
* The two views.
|
|
27
|
+
*
|
|
28
|
+
* - **Commit view** — exclusive counts per category, plus `mixed`, plus
|
|
29
|
+
* `unclassified`. A commit is classified from the files it touches.
|
|
30
|
+
* - **Diff view** — added/deleted lines per category over the whole range.
|
|
31
|
+
*
|
|
32
|
+
* Generated projections and lockfiles (`dist/`, `.augment/`, `.claude/`,
|
|
33
|
+
* `package-lock.json`, …) are removed from BOTH views by name. In this
|
|
34
|
+
* repository `dist/` is a byte-exact projection of `src/`, so leaving it in
|
|
35
|
+
* would let a mechanical regeneration dominate every reading. A commit left
|
|
36
|
+
* with no path after that exclusion lands in the `generated_only` diagnostic
|
|
37
|
+
* rather than in maintenance — otherwise the ratio would depend on whether the
|
|
38
|
+
* regeneration was committed separately from the source edit that caused it.
|
|
39
|
+
*
|
|
40
|
+
* Classification reads paths, never subjects.
|
|
41
|
+
*
|
|
42
|
+
* Commit subjects are mutable, inconsistently formatted, and can contradict the
|
|
43
|
+
* files a commit touches. Nothing in this file reads `%s`, `%b` or any other
|
|
44
|
+
* message field; the only git output consumed is a name list and a numstat.
|
|
45
|
+
* `tests/scripts/measure_release_mix.test.ts` pins that by rewriting a
|
|
46
|
+
* subject with `git commit --amend` and asserting the reading is byte-identical.
|
|
47
|
+
*
|
|
48
|
+
* `mixed` is never collapsed into a category.
|
|
49
|
+
*
|
|
50
|
+
* Collapsing `mixed` into product would recreate the exact gaming the declined
|
|
51
|
+
* rule permits. A commit touching two classified categories is `mixed` and is
|
|
52
|
+
* reported as its own bucket in both views.
|
|
53
|
+
*
|
|
54
|
+
* `unclassified` is separate from `mixed` and does not cause it. A commit whose
|
|
55
|
+
* paths are all unmatched is `unclassified`; a commit with one classified
|
|
56
|
+
* category plus unmatched paths keeps that category and carries
|
|
57
|
+
* `has_unclassified`. Conflating taxonomy uncertainty with genuine
|
|
58
|
+
* cross-category work would hide both.
|
|
59
|
+
*
|
|
60
|
+
* Usage.
|
|
61
|
+
*
|
|
62
|
+
* measure_release_mix --from <ref> --to <ref> [--label <tag>] [--json <path>] [--quiet]
|
|
63
|
+
* measure_release_mix --audit # every top-level tracked path resolves
|
|
64
|
+
*
|
|
65
|
+
* Exit codes: 0 clean · 1 an unresolved path in `--audit` · 2 usage or git error.
|
|
66
|
+
*/
|
|
67
|
+
import { spawnSync } from 'node:child_process';
|
|
68
|
+
import * as fs from 'node:fs';
|
|
69
|
+
import * as path from 'node:path';
|
|
70
|
+
import process from 'node:process';
|
|
71
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
72
|
+
|
|
73
|
+
const _HERE = path.resolve(fileURLToPath(import.meta.url));
|
|
74
|
+
export const REPO_ROOT = path.resolve(path.dirname(_HERE), '..', '..');
|
|
75
|
+
const TAXONOMY_PATH = path.join(_HERE, '..', 'release_mix_taxonomy.json');
|
|
76
|
+
|
|
77
|
+
export type Category = 'consumer' | 'governance' | 'maintenance' | 'unclassified';
|
|
78
|
+
export const CLASSIFIED: readonly Category[] = ['consumer', 'governance', 'maintenance'];
|
|
79
|
+
|
|
80
|
+
export interface Taxonomy {
|
|
81
|
+
taxonomy_version: string;
|
|
82
|
+
excluded_generated: string[];
|
|
83
|
+
rules: { prefix: string; category: Category }[];
|
|
84
|
+
governance_contracts: string[];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function loadTaxonomy(file = TAXONOMY_PATH): Taxonomy {
|
|
88
|
+
const raw = JSON.parse(fs.readFileSync(file, 'utf8')) as Taxonomy;
|
|
89
|
+
if (!raw.taxonomy_version) throw new Error('taxonomy carries no taxonomy_version');
|
|
90
|
+
return raw;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** True when the path is a generated projection or a lockfile — dropped from both views. */
|
|
94
|
+
export function isExcluded(p: string, tax: Taxonomy): boolean {
|
|
95
|
+
return tax.excluded_generated.some((pre) => (pre.endsWith('/') ? p.startsWith(pre) : p === pre));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Longest-prefix-wins. A path matching no rule is `unclassified` — a reported
|
|
100
|
+
* state, never a silent default into some category.
|
|
101
|
+
*/
|
|
102
|
+
export function classifyPath(p: string, tax: Taxonomy): Category {
|
|
103
|
+
return classifyPathDetailed(p, tax).category;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The same decision, with the rule that made it. `matched: false` is the
|
|
108
|
+
* failure `--audit` looks for: a path that fell through every rule, which is
|
|
109
|
+
* not the same thing as a path the taxonomy deliberately calls unclassified.
|
|
110
|
+
*/
|
|
111
|
+
export function classifyPathDetailed(
|
|
112
|
+
p: string,
|
|
113
|
+
tax: Taxonomy,
|
|
114
|
+
): { category: Category; matched: boolean; rule: string } {
|
|
115
|
+
if (tax.governance_contracts.includes(p)) {
|
|
116
|
+
return { category: 'governance', matched: true, rule: `governance_contracts:${p}` };
|
|
117
|
+
}
|
|
118
|
+
let best: { len: number; category: Category; rule: string } | null = null;
|
|
119
|
+
for (const rule of tax.rules) {
|
|
120
|
+
if (!p.startsWith(rule.prefix)) continue;
|
|
121
|
+
if (best === null || rule.prefix.length > best.len) {
|
|
122
|
+
best = { len: rule.prefix.length, category: rule.category, rule: rule.prefix };
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return best === null
|
|
126
|
+
? { category: 'unclassified', matched: false, rule: '' }
|
|
127
|
+
: { category: best.category, matched: true, rule: best.rule };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export interface CommitVerdict {
|
|
131
|
+
/** The bucket the commit lands in. `generated_only` is a diagnostic, not a view bucket. */
|
|
132
|
+
bucket: Category | 'mixed' | 'generated_only';
|
|
133
|
+
categories: Category[];
|
|
134
|
+
has_unclassified: boolean;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Classify ONE commit from its changed paths. Nothing here reads the subject. */
|
|
138
|
+
export function classifyCommit(paths: readonly string[], tax: Taxonomy): CommitVerdict {
|
|
139
|
+
const kept = paths.filter((p) => !isExcluded(p, tax));
|
|
140
|
+
if (kept.length === 0) {
|
|
141
|
+
return { bucket: 'generated_only', categories: [], has_unclassified: false };
|
|
142
|
+
}
|
|
143
|
+
const seen = new Set<Category>();
|
|
144
|
+
for (const p of kept) seen.add(classifyPath(p, tax));
|
|
145
|
+
const has_unclassified = seen.has('unclassified');
|
|
146
|
+
const classified = [...seen].filter((c) => c !== 'unclassified').sort();
|
|
147
|
+
if (classified.length === 0) return { bucket: 'unclassified', categories: [], has_unclassified: true };
|
|
148
|
+
if (classified.length === 1) return { bucket: classified[0]!, categories: classified, has_unclassified };
|
|
149
|
+
return { bucket: 'mixed', categories: classified, has_unclassified };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function git(args: string[], cwd = REPO_ROOT): string {
|
|
153
|
+
const r = spawnSync('git', args, { cwd, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
|
|
154
|
+
if (r.status !== 0) throw new Error(`git ${args.join(' ')} failed: ${(r.stderr || '').trim()}`);
|
|
155
|
+
return r.stdout;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export interface Reading {
|
|
159
|
+
schema_version: 1;
|
|
160
|
+
taxonomy_version: string;
|
|
161
|
+
classifier_version: string;
|
|
162
|
+
label: string;
|
|
163
|
+
range: { from: string; to: string; from_sha: string; to_sha: string };
|
|
164
|
+
merge_handling: 'no-merges';
|
|
165
|
+
rename_handling: 'git-default';
|
|
166
|
+
commit_view: Record<string, number>;
|
|
167
|
+
diff_view: Record<string, { added: number; deleted: number }>;
|
|
168
|
+
diagnostics: {
|
|
169
|
+
generated_only_commits: number;
|
|
170
|
+
commits_with_unclassified: number;
|
|
171
|
+
unclassified_paths: string[];
|
|
172
|
+
mixed_combinations: Record<string, number>;
|
|
173
|
+
largest_contributors: Record<string, [string, number][]>;
|
|
174
|
+
};
|
|
175
|
+
response_obligation: { triggered: boolean; rule: string; governance_only: number; consumer_only: number };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** The classifier's own version, bumped independently of the taxonomy's. */
|
|
179
|
+
export const CLASSIFIER_VERSION = '1.0.0';
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* The response obligation's trigger, stated once so the gate and the report
|
|
183
|
+
* cannot drift: governance-only commits STRICTLY outnumber consumer-only
|
|
184
|
+
* commits over the span. Strict inequality is the literal reading of the
|
|
185
|
+
* roadmap's own wording and both council seats named it explicitly.
|
|
186
|
+
*/
|
|
187
|
+
export const OBLIGATION_RULE = 'governance-only commits > consumer-only commits';
|
|
188
|
+
|
|
189
|
+
export function measureRange(from: string, to: string, tax: Taxonomy, label = '', cwd = REPO_ROOT): Reading {
|
|
190
|
+
const shas = git(['log', '--no-merges', '--format=%H', `${from}..${to}`], cwd)
|
|
191
|
+
.split('\n')
|
|
192
|
+
.map((s) => s.trim())
|
|
193
|
+
.filter(Boolean);
|
|
194
|
+
|
|
195
|
+
const commit_view: Record<string, number> = {
|
|
196
|
+
consumer: 0, governance: 0, maintenance: 0, unclassified: 0, mixed: 0,
|
|
197
|
+
};
|
|
198
|
+
const mixed_combinations: Record<string, number> = {};
|
|
199
|
+
const unclassified_paths = new Set<string>();
|
|
200
|
+
let generated_only_commits = 0;
|
|
201
|
+
let commits_with_unclassified = 0;
|
|
202
|
+
|
|
203
|
+
for (const sha of shas) {
|
|
204
|
+
const paths = git(['show', '--name-only', '--format=', sha], cwd)
|
|
205
|
+
.split('\n').map((s) => s.trim()).filter(Boolean);
|
|
206
|
+
const v = classifyCommit(paths, tax);
|
|
207
|
+
if (v.bucket === 'generated_only') { generated_only_commits += 1; continue; }
|
|
208
|
+
commit_view[v.bucket] = (commit_view[v.bucket] ?? 0) + 1;
|
|
209
|
+
if (v.has_unclassified) commits_with_unclassified += 1;
|
|
210
|
+
if (v.bucket === 'mixed') {
|
|
211
|
+
const key = v.categories.join('+');
|
|
212
|
+
mixed_combinations[key] = (mixed_combinations[key] ?? 0) + 1;
|
|
213
|
+
}
|
|
214
|
+
for (const p of paths) {
|
|
215
|
+
if (!isExcluded(p, tax) && classifyPath(p, tax) === 'unclassified') unclassified_paths.add(p);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const diff_view: Record<string, { added: number; deleted: number }> = {
|
|
220
|
+
consumer: { added: 0, deleted: 0 },
|
|
221
|
+
governance: { added: 0, deleted: 0 },
|
|
222
|
+
maintenance: { added: 0, deleted: 0 },
|
|
223
|
+
unclassified: { added: 0, deleted: 0 },
|
|
224
|
+
};
|
|
225
|
+
const touchCount: Record<string, Record<string, number>> = {
|
|
226
|
+
consumer: {}, governance: {}, maintenance: {}, unclassified: {},
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
for (const line of git(['diff', '--numstat', `${from}..${to}`], cwd).split('\n')) {
|
|
230
|
+
const parts = line.split('\t');
|
|
231
|
+
if (parts.length < 3) continue;
|
|
232
|
+
const [addRaw, delRaw, pathRaw] = parts as [string, string, string];
|
|
233
|
+
// `git diff --numstat` renders a rename as `old => new`; take the new path.
|
|
234
|
+
const p = pathRaw.includes('=>') ? pathRaw.replace(/^.*=>\s*/, '').replace(/[{}]/g, '') : pathRaw;
|
|
235
|
+
if (isExcluded(p, tax)) continue;
|
|
236
|
+
const cat = classifyPath(p, tax);
|
|
237
|
+
// A binary file renders as `-`; it contributes a touch, not a line count.
|
|
238
|
+
diff_view[cat]!.added += addRaw === '-' ? 0 : Number(addRaw);
|
|
239
|
+
diff_view[cat]!.deleted += delRaw === '-' ? 0 : Number(delRaw);
|
|
240
|
+
const top = p.split('/').slice(0, 2).join('/');
|
|
241
|
+
touchCount[cat]![top] = (touchCount[cat]![top] ?? 0) + 1;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const largest_contributors: Record<string, [string, number][]> = {};
|
|
245
|
+
for (const cat of Object.keys(touchCount)) {
|
|
246
|
+
largest_contributors[cat] = Object.entries(touchCount[cat]!)
|
|
247
|
+
.sort((a, b) => b[1] - a[1])
|
|
248
|
+
.slice(0, 5) as [string, number][];
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const governance_only = commit_view.governance ?? 0;
|
|
252
|
+
const consumer_only = commit_view.consumer ?? 0;
|
|
253
|
+
|
|
254
|
+
return {
|
|
255
|
+
schema_version: 1,
|
|
256
|
+
taxonomy_version: tax.taxonomy_version,
|
|
257
|
+
classifier_version: CLASSIFIER_VERSION,
|
|
258
|
+
label: label || `${from}..${to}`,
|
|
259
|
+
range: {
|
|
260
|
+
from, to,
|
|
261
|
+
from_sha: git(['rev-parse', from], cwd).trim(),
|
|
262
|
+
to_sha: git(['rev-parse', to], cwd).trim(),
|
|
263
|
+
},
|
|
264
|
+
merge_handling: 'no-merges',
|
|
265
|
+
rename_handling: 'git-default',
|
|
266
|
+
commit_view,
|
|
267
|
+
diff_view,
|
|
268
|
+
diagnostics: {
|
|
269
|
+
generated_only_commits,
|
|
270
|
+
commits_with_unclassified,
|
|
271
|
+
unclassified_paths: [...unclassified_paths].sort().slice(0, 50),
|
|
272
|
+
mixed_combinations,
|
|
273
|
+
largest_contributors,
|
|
274
|
+
},
|
|
275
|
+
response_obligation: {
|
|
276
|
+
triggered: governance_only > consumer_only,
|
|
277
|
+
rule: OBLIGATION_RULE,
|
|
278
|
+
governance_only,
|
|
279
|
+
consumer_only,
|
|
280
|
+
},
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export function renderReading(r: Reading): string {
|
|
285
|
+
const cv = r.commit_view;
|
|
286
|
+
const lines: string[] = [];
|
|
287
|
+
lines.push(`release-mix · ${r.label} · taxonomy ${r.taxonomy_version} · classifier ${r.classifier_version}`);
|
|
288
|
+
lines.push(` range ${r.range.from_sha.slice(0, 9)}..${r.range.to_sha.slice(0, 9)} (${r.merge_handling})`);
|
|
289
|
+
lines.push(` commit view consumer ${cv.consumer} · governance ${cv.governance} · maintenance ${cv.maintenance} · mixed ${cv.mixed} · unclassified ${cv.unclassified}`);
|
|
290
|
+
for (const cat of ['consumer', 'governance', 'maintenance', 'unclassified'] as const) {
|
|
291
|
+
const d = r.diff_view[cat]!;
|
|
292
|
+
lines.push(` diff view ${cat.padEnd(13)} +${d.added} / -${d.deleted}`);
|
|
293
|
+
}
|
|
294
|
+
lines.push(` diagnostics generated-only commits ${r.diagnostics.generated_only_commits} · commits carrying unclassified paths ${r.diagnostics.commits_with_unclassified}`);
|
|
295
|
+
lines.push(` response owed ${r.response_obligation.triggered ? 'YES' : 'no'} (${r.response_obligation.rule}: ${r.response_obligation.governance_only} vs ${r.response_obligation.consumer_only})`);
|
|
296
|
+
lines.push(' NOTE a level, not a verdict — no threshold is committed to this repository.');
|
|
297
|
+
return lines.join('\n');
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* AC-2 — every tracked path resolves to exactly one category, or to a rule that
|
|
302
|
+
* says `unclassified` on purpose. A path that matches NO rule is the failure:
|
|
303
|
+
* it would otherwise be counted as taxonomy uncertainty when it is really a
|
|
304
|
+
* taxonomy hole. Reported grouped by the level at which the taxonomy actually
|
|
305
|
+
* decides, so `src/` shows its subdirectories rather than one aggregate row.
|
|
306
|
+
*/
|
|
307
|
+
export function auditTree(tax: Taxonomy, cwd = REPO_ROOT): {
|
|
308
|
+
rows: { unit: string; category: Category; files: number }[];
|
|
309
|
+
unmatched: string[];
|
|
310
|
+
} {
|
|
311
|
+
const byUnit = new Map<string, { category: Category; files: number }>();
|
|
312
|
+
const unmatched: string[] = [];
|
|
313
|
+
for (const f of git(['ls-files'], cwd).split('\n')) {
|
|
314
|
+
const file = f.trim();
|
|
315
|
+
if (!file || isExcluded(file, tax)) continue;
|
|
316
|
+
const d = classifyPathDetailed(file, tax);
|
|
317
|
+
if (!d.matched) {
|
|
318
|
+
unmatched.push(file);
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
const unit = d.rule.startsWith('governance_contracts:') ? 'docs/contracts/ (exception list)' : d.rule;
|
|
322
|
+
const cur = byUnit.get(unit);
|
|
323
|
+
if (cur) cur.files += 1;
|
|
324
|
+
else byUnit.set(unit, { category: d.category, files: 1 });
|
|
325
|
+
}
|
|
326
|
+
const rows = [...byUnit.entries()]
|
|
327
|
+
.map(([unit, v]) => ({ unit, category: v.category, files: v.files }))
|
|
328
|
+
.sort((a, b) => a.unit.localeCompare(b.unit));
|
|
329
|
+
return { rows, unmatched };
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function usage(msg: string): never {
|
|
333
|
+
process.stderr.write(`measure_release_mix: ${msg}\n`);
|
|
334
|
+
process.stderr.write('usage: measure_release_mix --from <ref> --to <ref> [--label <tag>] [--json <path>] [--quiet]\n');
|
|
335
|
+
process.stderr.write(' measure_release_mix --audit\n');
|
|
336
|
+
process.exit(2);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
export function main(argv: readonly string[]): number {
|
|
340
|
+
const args = [...argv];
|
|
341
|
+
const flag = (name: string): string | undefined => {
|
|
342
|
+
const i = args.indexOf(name);
|
|
343
|
+
return i === -1 ? undefined : args[i + 1];
|
|
344
|
+
};
|
|
345
|
+
const tax = loadTaxonomy();
|
|
346
|
+
|
|
347
|
+
if (args.includes('--audit')) {
|
|
348
|
+
const { rows, unmatched } = auditTree(tax);
|
|
349
|
+
for (const r of rows) {
|
|
350
|
+
process.stdout.write(` ${r.category.padEnd(13)} ${String(r.files).padStart(5)} ${r.unit}\n`);
|
|
351
|
+
}
|
|
352
|
+
if (unmatched.length > 0) {
|
|
353
|
+
process.stderr.write(`\nmeasure_release_mix: ${unmatched.length} tracked path(s) match no rule:\n`);
|
|
354
|
+
for (const u of unmatched.slice(0, 20)) process.stderr.write(` ${u}\n`);
|
|
355
|
+
return 1;
|
|
356
|
+
}
|
|
357
|
+
const files = rows.reduce((n, r) => n + r.files, 0);
|
|
358
|
+
process.stdout.write(
|
|
359
|
+
`\nOK — ${files} tracked paths over ${rows.length} classification units, all resolved (taxonomy ${tax.taxonomy_version}).\n`,
|
|
360
|
+
);
|
|
361
|
+
return 0;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const from = flag('--from');
|
|
365
|
+
const to = flag('--to');
|
|
366
|
+
if (!from || !to) usage('--from and --to are required');
|
|
367
|
+
const reading = measureRange(from, to, tax, flag('--label') ?? '');
|
|
368
|
+
const jsonPath = flag('--json');
|
|
369
|
+
if (jsonPath) {
|
|
370
|
+
fs.mkdirSync(path.dirname(path.resolve(jsonPath)), { recursive: true });
|
|
371
|
+
fs.writeFileSync(path.resolve(jsonPath), `${JSON.stringify(reading, null, 2)}\n`, 'utf8');
|
|
372
|
+
}
|
|
373
|
+
if (!args.includes('--quiet')) process.stdout.write(`${renderReading(reading)}\n`);
|
|
374
|
+
return 0;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
|
|
378
|
+
try {
|
|
379
|
+
process.exit(main(process.argv.slice(2)));
|
|
380
|
+
} catch (e) {
|
|
381
|
+
process.stderr.write(`measure_release_mix: ${(e as Error).message}\n`);
|
|
382
|
+
process.exit(2);
|
|
383
|
+
}
|
|
384
|
+
}
|
package/src/scripts/release.ts
CHANGED
|
@@ -194,6 +194,7 @@ import {
|
|
|
194
194
|
_target_from_branch,
|
|
195
195
|
create_and_push_annotated_tag,
|
|
196
196
|
guard_publication,
|
|
197
|
+
checkout_release_branch,
|
|
197
198
|
guard_release_curation,
|
|
198
199
|
die,
|
|
199
200
|
gh,
|
|
@@ -1177,23 +1178,9 @@ function execute(
|
|
|
1177
1178
|
const pr_merged = pr_state === 'MERGED';
|
|
1178
1179
|
|
|
1179
1180
|
// ─── 1. branch ──────────────────────────────────────────────────────────
|
|
1180
|
-
|
|
1181
|
-
_step(1, total,
|
|
1182
|
-
|
|
1183
|
-
run(['git', 'checkout', MAIN_BRANCH]);
|
|
1184
|
-
}
|
|
1185
|
-
run(['git', 'pull', '--ff-only', REMOTE, MAIN_BRANCH]);
|
|
1186
|
-
} else if (resume && _branch_exists_local(branch)) {
|
|
1187
|
-
_step(1, total, `Branch ${branch} exists locally — checkout`);
|
|
1188
|
-
run(['git', 'checkout', branch]);
|
|
1189
|
-
} else if (resume && _branch_exists_remote(branch)) {
|
|
1190
|
-
_step(1, total, `Branch ${branch} exists on ${REMOTE} — fetch + checkout`);
|
|
1191
|
-
run(['git', 'fetch', REMOTE, branch]);
|
|
1192
|
-
run(['git', 'checkout', '-b', branch, `${REMOTE}/${branch}`]);
|
|
1193
|
-
} else {
|
|
1194
|
-
_step(1, total, `Create branch ${branch}`);
|
|
1195
|
-
run(['git', 'checkout', '-b', branch]);
|
|
1196
|
-
}
|
|
1181
|
+
checkout_release_branch(branch, pr_merged, (m) => {
|
|
1182
|
+
_step(1, total, m);
|
|
1183
|
+
});
|
|
1197
1184
|
|
|
1198
1185
|
// ─── 1b. era split (optional, separate commit) ─────────────────────────
|
|
1199
1186
|
// Lands as `chore(changelog): split era ...` BEFORE the release commit
|
|
@@ -38,6 +38,58 @@ import {
|
|
|
38
38
|
SystemExitError,
|
|
39
39
|
} from './release.js';
|
|
40
40
|
import { DERIVED_MARKER } from './_lib/release_highlights.js';
|
|
41
|
+
import {
|
|
42
|
+
AUGMENT_MARKETPLACE_JSON,
|
|
43
|
+
AUGMENT_PLUGIN_JSON,
|
|
44
|
+
CHANGELOG,
|
|
45
|
+
MARKETPLACE_JSON,
|
|
46
|
+
PACKAGE_JSON,
|
|
47
|
+
PACKAGE_LOCK_JSON,
|
|
48
|
+
PROJECT_TEMPLATE,
|
|
49
|
+
} from './release_env.js';
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The files step 2 mutates on disk, snapshotted around every scenario.
|
|
53
|
+
*
|
|
54
|
+
* The FakeWorld intercepts COMMANDS, not `fs` writes, and step 2's bump is
|
|
55
|
+
* seven direct writes. Every scenario before 2026-09-03 passed `--resume` with
|
|
56
|
+
* `package.json` already at the target, so step 2 short-circuited and nothing
|
|
57
|
+
* was ever written — the drill looked side-effect-free because no scenario had
|
|
58
|
+
* reached the writing arm. The first `resume: false` scenario appended twelve
|
|
59
|
+
* `## 14.15.0` headings to the REAL `CHANGELOG.md`.
|
|
60
|
+
*
|
|
61
|
+
* Snapshot-and-restore rather than a writer seam: a seam means threading an
|
|
62
|
+
* injection point through seven call sites in `release.ts`, which sits past the
|
|
63
|
+
* 1500-line cap where every line is charged. This is contained to the runner
|
|
64
|
+
* and covers any future scenario without further thought.
|
|
65
|
+
*/
|
|
66
|
+
const _MUTATED_BY_STEP_2: readonly string[] = [
|
|
67
|
+
CHANGELOG,
|
|
68
|
+
PACKAGE_JSON,
|
|
69
|
+
PACKAGE_LOCK_JSON,
|
|
70
|
+
MARKETPLACE_JSON,
|
|
71
|
+
AUGMENT_PLUGIN_JSON,
|
|
72
|
+
AUGMENT_MARKETPLACE_JSON,
|
|
73
|
+
PROJECT_TEMPLATE,
|
|
74
|
+
];
|
|
75
|
+
|
|
76
|
+
function _snapshot_step_2_files(): Map<string, string> {
|
|
77
|
+
const snap = new Map<string, string>();
|
|
78
|
+
for (const f of _MUTATED_BY_STEP_2) {
|
|
79
|
+
if (fs.existsSync(f)) {
|
|
80
|
+
snap.set(f, fs.readFileSync(f, 'utf-8'));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return snap;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function _restore_step_2_files(snap: ReadonlyMap<string, string>): void {
|
|
87
|
+
for (const [f, content] of snap) {
|
|
88
|
+
if (fs.readFileSync(f, 'utf-8') !== content) {
|
|
89
|
+
fs.writeFileSync(f, content);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
41
93
|
|
|
42
94
|
const _HERE = fileURLToPath(import.meta.url);
|
|
43
95
|
const REPO_ROOT = path.resolve(path.dirname(_HERE), '..', '..');
|
|
@@ -64,6 +116,27 @@ interface WorldConfig {
|
|
|
64
116
|
push_rejections?: number;
|
|
65
117
|
/** mergeStateStatus reports BEHIND this many probes in a row (9.16.0). */
|
|
66
118
|
behind_probes?: number;
|
|
119
|
+
/**
|
|
120
|
+
* Commits the release branch is behind `origin/main` at step 1.
|
|
121
|
+
*
|
|
122
|
+
* Distinct from `behind_probes`, which fakes the PR's own
|
|
123
|
+
* `mergeStateStatus` at step 6. This one fakes `git rev-list --count
|
|
124
|
+
* HEAD..origin/main`, the probe `integrate_main_if_behind` uses — the
|
|
125
|
+
* defect it covers surfaced at the PUSH, six steps before that PR ever
|
|
126
|
+
* existed.
|
|
127
|
+
*/
|
|
128
|
+
branch_behind_main?: number;
|
|
129
|
+
/**
|
|
130
|
+
* Whether `execute()` is invoked with `--resume`. Defaults to TRUE so every
|
|
131
|
+
* pre-existing scenario keeps the shape it was written against.
|
|
132
|
+
*
|
|
133
|
+
* Configurable since 2026-09-03 because the drill could not otherwise
|
|
134
|
+
* reach the defect: branch reuse used to be gated on `resume`, so a plain
|
|
135
|
+
* `task release` over an existing branch died at `git checkout -b` with
|
|
136
|
+
* exit 128 — and every scenario here passed `resume: true`, which is
|
|
137
|
+
* exactly the arm that worked.
|
|
138
|
+
*/
|
|
139
|
+
resume?: boolean;
|
|
67
140
|
/** `gh pr merge` fails once with the not-up-to-date error (the race). */
|
|
68
141
|
merge_races_once?: boolean;
|
|
69
142
|
/** `gh pr merge` fails hard with an unrelated error (must surface). */
|
|
@@ -199,6 +272,7 @@ class FakeWorld {
|
|
|
199
272
|
|
|
200
273
|
private push_rejections: number;
|
|
201
274
|
private behind_probes: number;
|
|
275
|
+
private branch_behind_main: number;
|
|
202
276
|
private merge_races_once: boolean;
|
|
203
277
|
private merge_fails_hard: boolean;
|
|
204
278
|
private checks_fail: boolean;
|
|
@@ -217,6 +291,7 @@ class FakeWorld {
|
|
|
217
291
|
this.tag_local = (cfg.tag_created_unpushed ?? false) || (cfg.tag_already_remote ?? false);
|
|
218
292
|
this.tag_remote = cfg.tag_already_remote ?? false;
|
|
219
293
|
this.behind_probes = cfg.behind_probes ?? 0;
|
|
294
|
+
this.branch_behind_main = cfg.branch_behind_main ?? 0;
|
|
220
295
|
this.merge_races_once = cfg.merge_races_once ?? false;
|
|
221
296
|
this.merge_fails_hard = cfg.merge_fails_hard ?? false;
|
|
222
297
|
this.checks_fail = cfg.checks_fail ?? false;
|
|
@@ -235,6 +310,15 @@ class FakeWorld {
|
|
|
235
310
|
this.head = args[2] === '-b' ? (args[3] as string) : (args[2] as string);
|
|
236
311
|
return OK;
|
|
237
312
|
}
|
|
313
|
+
if (cmd === 'git rev-list --count HEAD..origin/main') {
|
|
314
|
+
return { ...OK, stdout: `${String(this.branch_behind_main)}\n` };
|
|
315
|
+
}
|
|
316
|
+
if (cmd === 'git merge origin/main --no-edit') {
|
|
317
|
+
// A real merge closes the gap, so a second probe reads 0. Without
|
|
318
|
+
// this the scenario could not tell one merge from a loop.
|
|
319
|
+
this.branch_behind_main = 0;
|
|
320
|
+
return OK;
|
|
321
|
+
}
|
|
238
322
|
if (cmd === `git rev-parse --verify --quiet refs/heads/${this.branch}`) {
|
|
239
323
|
return this.pr_merged ? { ...OK, status: 1 } : OK;
|
|
240
324
|
}
|
|
@@ -345,6 +429,15 @@ class FakeWorld {
|
|
|
345
429
|
// live body before deciding whether to edit.
|
|
346
430
|
return { ...OK, stdout: this.pr_body };
|
|
347
431
|
}
|
|
432
|
+
if (args[0] === 'gh' && args[1] === 'pr' && args[2] === 'create') {
|
|
433
|
+
// Only reachable on the NON-resume path: a resumed run refreshes an
|
|
434
|
+
// existing PR body instead (step 5's other arm), which is why every
|
|
435
|
+
// scenario before 2026-09-03 could leave this unscripted.
|
|
436
|
+
const bodyIdx = args.indexOf('--body');
|
|
437
|
+
this.pr_body = bodyIdx >= 0 ? String(args[bodyIdx + 1] ?? '') : '';
|
|
438
|
+
this.branch_exists_remote = true;
|
|
439
|
+
return { ...OK, stdout: `https://github.com/event4u-app/agent-config/pull/999\n` };
|
|
440
|
+
}
|
|
348
441
|
if (args[0] === 'gh' && args[1] === 'pr' && args[2] === 'edit' && args[3] === this.branch) {
|
|
349
442
|
const bodyIdx = args.indexOf('--body');
|
|
350
443
|
this.pr_body = bodyIdx >= 0 ? String(args[bodyIdx + 1] ?? '') : this.pr_body;
|
|
@@ -390,6 +483,64 @@ function _count(world: FakeWorld, needle: string): number {
|
|
|
390
483
|
}
|
|
391
484
|
|
|
392
485
|
const SCENARIOS: Record<string, Scenario> = {
|
|
486
|
+
'plain-run-reuses-an-existing-branch': {
|
|
487
|
+
summary:
|
|
488
|
+
'step 1: a PLAIN run (no --resume) over an existing release branch checks it out instead of dying at `git checkout -b`',
|
|
489
|
+
// The defect, measured on 14.15.0: branch reuse read
|
|
490
|
+
// `resume && _branch_exists_local(branch)`, so without --resume the
|
|
491
|
+
// run fell through to the create arm and died with exit 128. That is
|
|
492
|
+
// precisely the state `guard_release_curation` leaves behind — it stops
|
|
493
|
+
// BEFORE the commit, on a branch step 1 already created — while its own
|
|
494
|
+
// message says to re-run `task release`. The code refused the remedy its
|
|
495
|
+
// own message prescribed.
|
|
496
|
+
config: { resume: false },
|
|
497
|
+
expect_success: true,
|
|
498
|
+
verify: (w) => {
|
|
499
|
+
const f: string[] = [];
|
|
500
|
+
_expect(
|
|
501
|
+
w.calls.includes(`git checkout ${w.branch}`),
|
|
502
|
+
'the existing branch was not checked out',
|
|
503
|
+
f,
|
|
504
|
+
);
|
|
505
|
+
_expect(
|
|
506
|
+
!w.calls.includes(`git checkout -b ${w.branch}`),
|
|
507
|
+
'took the create arm over an existing branch — this is the exit-128 path',
|
|
508
|
+
f,
|
|
509
|
+
);
|
|
510
|
+
return f;
|
|
511
|
+
},
|
|
512
|
+
},
|
|
513
|
+
'stale-branch-merges-main-at-step-1': {
|
|
514
|
+
summary:
|
|
515
|
+
'step 1: a branch behind origin/main merges it in there — NOT at the push, where the preflight used to be the first to notice',
|
|
516
|
+
// Second half of the same defect. On 14.15.0 the branch had been cut
|
|
517
|
+
// from an older main during an earlier aborted run; steps 2 and 3
|
|
518
|
+
// bumped and committed, and only the pre-push preflight said `branch is
|
|
519
|
+
// BEHIND origin/main`. The cheapest moment to integrate main is the
|
|
520
|
+
// moment the branch is checked out.
|
|
521
|
+
config: { resume: false, branch_behind_main: 3 },
|
|
522
|
+
expect_success: true,
|
|
523
|
+
verify: (w) => {
|
|
524
|
+
const f: string[] = [];
|
|
525
|
+
const merge = w.calls.indexOf('git merge origin/main --no-edit');
|
|
526
|
+
const push = w.calls.indexOf(`git push -u origin ${w.branch}`);
|
|
527
|
+
_expect(merge >= 0, 'main was never merged into the stale branch', f);
|
|
528
|
+
_expect(push >= 0, 'the branch was never pushed', f);
|
|
529
|
+
// Ordering is the whole point: a merge AFTER the push would mean the
|
|
530
|
+
// preflight had already refused.
|
|
531
|
+
_expect(
|
|
532
|
+
merge >= 0 && push >= 0 && merge < push,
|
|
533
|
+
'main was merged after the push, not before it',
|
|
534
|
+
f,
|
|
535
|
+
);
|
|
536
|
+
_expect(
|
|
537
|
+
_count(w, 'git merge origin/main --no-edit') === 1,
|
|
538
|
+
'merged main more than once — the probe is not being re-read after the merge',
|
|
539
|
+
f,
|
|
540
|
+
);
|
|
541
|
+
return f;
|
|
542
|
+
},
|
|
543
|
+
},
|
|
393
544
|
'marker-refuses-before-commit': {
|
|
394
545
|
summary:
|
|
395
546
|
'step 3: a draft marker refuses BEFORE the release commit — no local release state, and therefore no remote state either',
|
|
@@ -685,12 +836,13 @@ function run_scenario(name: string): ScenarioOutcome {
|
|
|
685
836
|
captured.push(String(s));
|
|
686
837
|
return true;
|
|
687
838
|
};
|
|
839
|
+
const tree_snapshot = _snapshot_step_2_files();
|
|
688
840
|
_set_exec_override((args) => world.exec(args));
|
|
689
841
|
// The working-tree read, faked for the same reason the command layer is.
|
|
690
842
|
// Without this the drill's step 8 reads the repository's real CHANGELOG.md.
|
|
691
843
|
_set_changelog_reader(() => world.changelog_file);
|
|
692
844
|
try {
|
|
693
|
-
execute(plan, { wait_for_checks: true, dry_run: false, resume: true });
|
|
845
|
+
execute(plan, { wait_for_checks: true, dry_run: false, resume: scenario.config.resume ?? true });
|
|
694
846
|
} catch (err) {
|
|
695
847
|
if (err instanceof SystemExitError) {
|
|
696
848
|
error = `SystemExit(${err.code}): ${captured.join('')}`;
|
|
@@ -702,6 +854,7 @@ function run_scenario(name: string): ScenarioOutcome {
|
|
|
702
854
|
_set_changelog_reader(null);
|
|
703
855
|
process.stdout.write = orig_out;
|
|
704
856
|
process.stderr.write = orig_err;
|
|
857
|
+
_restore_step_2_files(tree_snapshot);
|
|
705
858
|
}
|
|
706
859
|
|
|
707
860
|
const failures: string[] = [];
|