@acme-skunkworks/agent-skills 1.0.0 → 1.1.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.
Files changed (43) hide show
  1. package/README.md +5 -4
  2. package/package.json +2 -6
  3. package/skills/changelog/README.md +59 -0
  4. package/skills/changelog/SKILL.md +187 -0
  5. package/skills/changelog/config.example.json +5 -0
  6. package/skills/changelog/config.json +5 -0
  7. package/skills/changelog/package.json +31 -0
  8. package/skills/changelog/references/changelog-contract.md +121 -0
  9. package/skills/changelog/scripts/add-links.mjs +97 -0
  10. package/skills/changelog/scripts/lib/changelog.mjs +46 -0
  11. package/skills/changelog/scripts/lib/config.mjs +53 -0
  12. package/skills/changelog/scripts/lib/derive-packages.mjs +39 -0
  13. package/skills/changelog/scripts/lib/frontmatter.mjs +369 -0
  14. package/skills/changelog/scripts/preflight-changelog-ci.mjs +152 -0
  15. package/skills/changelog/scripts/set-affected-packages.mjs +99 -0
  16. package/skills/changelog/scripts/validate-changelog.mjs +264 -0
  17. package/skills/linear-sync/README.md +47 -0
  18. package/skills/linear-sync/SKILL.md +115 -0
  19. package/skills/linear-sync/config.example.json +4 -0
  20. package/skills/linear-sync/config.json +4 -0
  21. package/skills/linear-sync/package.json +31 -0
  22. package/skills/preflight/README.md +70 -0
  23. package/skills/preflight/SKILL.md +148 -0
  24. package/skills/preflight/config.example.json +6 -0
  25. package/skills/preflight/package.json +33 -0
  26. package/skills/preflight/scripts/classify-lint.mjs +176 -0
  27. package/skills/preflight/scripts/lib/diff-lines.mjs +83 -0
  28. package/skills/preflight/scripts/lib/paths.mjs +26 -0
  29. package/skills/preflight/scripts/lib/scope.mjs +530 -0
  30. package/skills/preflight/scripts/lint-fix.mjs +78 -0
  31. package/skills/preflight/scripts/preflight.mjs +416 -0
  32. package/skills/send-it/README.md +75 -0
  33. package/skills/send-it/SKILL.md +391 -0
  34. package/skills/send-it/config.example.json +5 -0
  35. package/skills/send-it/config.json +5 -0
  36. package/skills/send-it/package.json +33 -0
  37. package/skills/send-it/scripts/derive-bump.mjs +139 -0
  38. package/skills/triage-pr/README.md +56 -0
  39. package/skills/triage-pr/SKILL.md +291 -0
  40. package/skills/triage-pr/config.json +4 -0
  41. package/skills/triage-pr/package.json +32 -0
  42. package/skills/triage-pr/references/review-discipline.md +73 -0
  43. package/skills/triage-pr/scripts/review-threads.mjs +549 -0
@@ -0,0 +1,53 @@
1
+ // Load the bundle's config.json (issue-ID prefixes, Linear workspace slug, base
2
+ // branch). Resolved relative to THIS module — `config.json` sits at the bundle
3
+ // root, two levels up from scripts/lib/ — not relative to cwd, which is the
4
+ // consumer repo root where the `changelog/` directory lives.
5
+ //
6
+ // Zero-deps: a plain JSON read with sensible ACME defaults if the file is
7
+ // missing or unreadable, so a partially-configured bundle still runs.
8
+
9
+ import { readFileSync } from "node:fs";
10
+
11
+ const CONFIG_URL = new URL("../../config.json", import.meta.url);
12
+
13
+ const DEFAULTS = {
14
+ issueKeys: ["ASW", "AKW", "SKW"],
15
+ linearWorkspaceSlug: "goose-and-hobbes",
16
+ baseBranch: "main",
17
+ };
18
+
19
+ let cached;
20
+
21
+ /**
22
+ * @returns {{ issueKeys: string[], linearWorkspaceSlug: string, baseBranch: string }}
23
+ */
24
+ export function loadConfig() {
25
+ if (cached) {
26
+ return cached;
27
+ }
28
+
29
+ let raw;
30
+ try {
31
+ raw = readFileSync(CONFIG_URL, "utf8");
32
+ } catch (err) {
33
+ // A missing file is fine — fall back to defaults so a partially-configured
34
+ // bundle still runs. Any other read error (e.g. EACCES) is real: surface it.
35
+ if (err.code === "ENOENT") {
36
+ cached = { ...DEFAULTS };
37
+ return cached;
38
+ }
39
+ throw err;
40
+ }
41
+
42
+ // A present-but-malformed config is a mistake the author needs to see, not
43
+ // something to mask by silently reverting to ACME defaults.
44
+ let parsed;
45
+ try {
46
+ parsed = JSON.parse(raw);
47
+ } catch (err) {
48
+ console.error(`Invalid JSON in ${CONFIG_URL.pathname}: ${err.message}`);
49
+ throw err;
50
+ }
51
+ cached = { ...DEFAULTS, ...parsed };
52
+ return cached;
53
+ }
@@ -0,0 +1,39 @@
1
+ // Map a set of changed repo-relative paths to the workspace packages they touch.
2
+ //
3
+ // Used by the merge-time path (set-affected-packages.mjs, computed from the
4
+ // branch diff). Kept as one implementation so the `affected_packages` value
5
+ // can't drift if a post-merge counterpart reuses the same rule.
6
+ //
7
+ // The rule (mirrors a conventional monorepo path→package mapping):
8
+ // apps/<x>/... -> <x>
9
+ // packages/<x>/... -> <x>
10
+ // services/<x>/... -> <x>
11
+ // everything else -> infrastructure
12
+ // The changelog directory itself is skipped — it's touched by every entry and
13
+ // would otherwise pin `infrastructure` onto every package list.
14
+
15
+ /**
16
+ * @param {string[]} paths repo-relative changed paths
17
+ * @returns {string[]} sorted, de-duplicated package names
18
+ */
19
+ export function derivePackagesFromPaths(paths) {
20
+ const out = new Set();
21
+ for (const changedPath of paths) {
22
+ const path = changedPath.trim();
23
+ if (!path) {
24
+ continue;
25
+ }
26
+
27
+ if (path.startsWith("changelog/")) {
28
+ continue;
29
+ }
30
+
31
+ const m =
32
+ /^apps\/([^/]+)\//.exec(path) ??
33
+ /^packages\/([^/]+)\//.exec(path) ??
34
+ /^services\/([^/]+)\//.exec(path);
35
+ out.add(m ? m[1] : "infrastructure");
36
+ }
37
+
38
+ return [...out].toSorted();
39
+ }
@@ -0,0 +1,369 @@
1
+ // Zero-deps YAML-frontmatter parser/serialiser for the changelog corpus.
2
+ //
3
+ // Carries no third-party npm import so it can be lifted wholesale into this
4
+ // skill bundle. It is NOT a general YAML implementation — it handles exactly the
5
+ // subset the changelog frontmatter uses:
6
+ //
7
+ // - plain / single- / double-quoted string scalars
8
+ // - integers, booleans, the `null` literal, and bare `key:` (-> null)
9
+ // - one folded/literal block scalar (`>-` `>` `|` `|-`) for `release_note`
10
+ // - inline arrays (`[]`, `["a", b]`) and block arrays (`- item`)
11
+ // - one level of nested mapping (`stats:`)
12
+ //
13
+ // API mirrors gray-matter's so call sites change minimally:
14
+ // parseFrontmatter(raw) -> { data, content }
15
+ // stringifyFrontmatter(content, d) -> "---\n<yaml>\n---\n<content>"
16
+
17
+ const FENCE = "---";
18
+
19
+ // --- parsing ---------------------------------------------------------------
20
+
21
+ function indentOf(line) {
22
+ return line.length - line.trimStart().length;
23
+ }
24
+
25
+ // Parse a scalar token (the text after `key:` or an array item).
26
+ function parseScalar(token) {
27
+ const t = token.trim();
28
+ if (t === "" || t === "null" || t === "~") {
29
+ return null;
30
+ }
31
+
32
+ if (t === "true") {
33
+ return true;
34
+ }
35
+
36
+ if (t === "false") {
37
+ return false;
38
+ }
39
+
40
+ if (/^-?\d+$/.test(t)) {
41
+ return Number.parseInt(t, 10);
42
+ }
43
+
44
+ if (t.startsWith("'") && t.endsWith("'") && t.length >= 2) {
45
+ return t.slice(1, -1).replaceAll("''", "'");
46
+ }
47
+
48
+ if (t.startsWith('"') && t.endsWith('"') && t.length >= 2) {
49
+ // Unescape in a single left-to-right pass so an escaped backslash (`\\`)
50
+ // can't have its trailing char re-consumed by a later rule (e.g. `\\n`
51
+ // must become `\n`, not a newline).
52
+ return t.slice(1, -1).replace(/\\(.)/g, (_, c) => (c === "n" ? "\n" : c));
53
+ }
54
+
55
+ return t;
56
+ }
57
+
58
+ // Split an inline-array body on top-level commas only — commas inside single-
59
+ // or double-quoted strings are preserved (e.g. `"Smith, Jr. <a@b>"` stays one
60
+ // item). Mirrors parseScalar's quoting: `''` is an escaped quote inside single
61
+ // quotes, `\` escapes inside double quotes.
62
+ function splitInlineItems(inner) {
63
+ const items = [];
64
+ let current = "";
65
+ /** @type {"'"|'"'|null} */
66
+ let quote = null;
67
+ for (let i = 0; i < inner.length; i++) {
68
+ const ch = inner[i];
69
+ if (quote === '"') {
70
+ current += ch;
71
+ if (ch === "\\" && i + 1 < inner.length) {
72
+ current += inner[++i];
73
+ } else if (ch === '"') {
74
+ quote = null;
75
+ }
76
+ } else if (quote === "'") {
77
+ current += ch;
78
+ if (ch === "'") {
79
+ if (inner[i + 1] === "'") {
80
+ current += inner[++i];
81
+ } else {
82
+ quote = null;
83
+ }
84
+ }
85
+ } else if (ch === '"' || ch === "'") {
86
+ quote = ch;
87
+ current += ch;
88
+ } else if (ch === ",") {
89
+ items.push(current);
90
+ current = "";
91
+ } else {
92
+ current += ch;
93
+ }
94
+ }
95
+ items.push(current);
96
+ return items;
97
+ }
98
+
99
+ // Parse an inline array body (the text between the surrounding brackets).
100
+ function parseInlineArray(body) {
101
+ const inner = body.trim();
102
+ if (inner === "") {
103
+ return [];
104
+ }
105
+
106
+ return splitInlineItems(inner).map((item) => parseScalar(item));
107
+ }
108
+
109
+ // Collect an indented block following a `key:` / block-scalar header, returning
110
+ // the consumed lines (those more indented than `parentIndent`) and the next index.
111
+ function collectBlock(lines, start, parentIndent) {
112
+ const block = [];
113
+ let i = start;
114
+ while (i < lines.length) {
115
+ const line = lines[i];
116
+ if (line.trim() === "") {
117
+ block.push(line);
118
+ i++;
119
+ continue;
120
+ }
121
+
122
+ if (indentOf(line) <= parentIndent) {
123
+ break;
124
+ }
125
+
126
+ block.push(line);
127
+ i++;
128
+ }
129
+
130
+ // Drop trailing blank lines that belong to the gap before the next key.
131
+ while (block.length > 0 && block.at(-1).trim() === "") {
132
+ block.pop();
133
+ }
134
+
135
+ return { block, next: i };
136
+ }
137
+
138
+ // Fold/keep a block scalar per its indicator (`>` folds newlines to spaces,
139
+ // `|` keeps them; a trailing `-` strips the final newline, which we always do
140
+ // here since the corpus only ever uses `>-`).
141
+ function parseBlockScalar(indicator, block) {
142
+ if (block.length === 0) {
143
+ return "";
144
+ }
145
+
146
+ const minIndent = Math.min(
147
+ ...block.filter((l) => l.trim() !== "").map((l) => indentOf(l)),
148
+ );
149
+ const dedented = block.map((l) => l.slice(minIndent));
150
+ const folded = indicator.startsWith(">");
151
+ return folded
152
+ ? dedented.join(" ").replace(/\s+/g, " ").trim()
153
+ : dedented.join("\n");
154
+ }
155
+
156
+ function parseMapping(lines, startIndent) {
157
+ const data = {};
158
+ let i = 0;
159
+ while (i < lines.length) {
160
+ const line = lines[i];
161
+ if (line.trim() === "") {
162
+ i++;
163
+ continue;
164
+ }
165
+
166
+ const colon = line.indexOf(":");
167
+ if (colon === -1) {
168
+ // No `:` means malformed input (or a mis-routed block-array item). Fail
169
+ // loudly: silently slicing on colon === -1 mangles the key/value and
170
+ // produces a confusing downstream validation error instead.
171
+ throw new Error(`Invalid frontmatter line (expected "key: value"): ${line}`);
172
+ }
173
+ const key = line.slice(indentOf(line), colon).trim();
174
+ const rest = line.slice(colon + 1).trim();
175
+ i++;
176
+
177
+ if (
178
+ rest === "" ||
179
+ rest === ">" ||
180
+ rest === ">-" ||
181
+ rest === "|" ||
182
+ rest === "|-"
183
+ ) {
184
+ const { block, next } = collectBlock(lines, i, startIndent);
185
+ i = next;
186
+ if (rest === "") {
187
+ // Could be a block array, a nested mapping, or a bare null.
188
+ if (block.length === 0) {
189
+ data[key] = null;
190
+ } else if (
191
+ block[0].trimStart().startsWith("- ") ||
192
+ block[0].trim() === "-"
193
+ ) {
194
+ data[key] = block.map((l) =>
195
+ parseScalar(l.trimStart().replace(/^-\s?/, "")),
196
+ );
197
+ } else {
198
+ const childIndent = indentOf(block[0]);
199
+ data[key] = parseMapping(block, childIndent);
200
+ }
201
+ } else {
202
+ data[key] = parseBlockScalar(rest, block);
203
+ }
204
+
205
+ continue;
206
+ }
207
+
208
+ if (rest.startsWith("[") && rest.endsWith("]")) {
209
+ data[key] = parseInlineArray(rest.slice(1, -1));
210
+ continue;
211
+ }
212
+
213
+ data[key] = parseScalar(rest);
214
+ }
215
+
216
+ return data;
217
+ }
218
+
219
+ // Matches a leading `---` fence, the frontmatter body (group 1, non-greedy up to
220
+ // the first closing fence on its own line), the closing fence, and its trailing
221
+ // newline. `content` is the exact remainder, so the markdown body is preserved
222
+ // byte-for-byte and round-trips are idempotent — only the frontmatter is rewritten.
223
+ const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---[ \t]*\r?\n?/;
224
+
225
+ export function parseFrontmatter(raw) {
226
+ const text = raw.startsWith("") ? raw.slice(1) : raw;
227
+ const match = FRONTMATTER_RE.exec(text);
228
+ if (!match) {
229
+ return { data: {}, content: text };
230
+ }
231
+
232
+ const fmLines = match[1].split("\n");
233
+ const content = text.slice(match[0].length);
234
+ return { data: parseMapping(fmLines, 0), content };
235
+ }
236
+
237
+ // --- serialising -----------------------------------------------------------
238
+
239
+ const INDICATORS = new Set([
240
+ "!",
241
+ '"',
242
+ "#",
243
+ "%",
244
+ "&",
245
+ "'",
246
+ "*",
247
+ ",",
248
+ "-",
249
+ ":",
250
+ ">",
251
+ "?",
252
+ "@",
253
+ "[",
254
+ "]",
255
+ "`",
256
+ "{",
257
+ "|",
258
+ "}",
259
+ ]);
260
+
261
+ function reparsesAsNonString(str) {
262
+ // True when an unquoted emit of this string would parse back as something
263
+ // other than a string (bool/int/null) or as a date-shaped token worth quoting.
264
+ if (
265
+ str === "" ||
266
+ str === "null" ||
267
+ str === "~" ||
268
+ str === "true" ||
269
+ str === "false"
270
+ ) {
271
+ return true;
272
+ }
273
+
274
+ if (/^-?\d+$/.test(str)) {
275
+ return true;
276
+ }
277
+
278
+ return /^\d{4}-\d{2}-\d{2}/.test(str);
279
+ }
280
+
281
+ function needsQuoting(str) {
282
+ if (str.length === 0) {
283
+ return true;
284
+ }
285
+
286
+ if (str !== str.trim()) {
287
+ return true;
288
+ }
289
+
290
+ if (INDICATORS.has(str[0]) || str.startsWith("- ")) {
291
+ return true;
292
+ }
293
+
294
+ if (str.includes(": ") || str.includes(" #") || str.includes("\n")) {
295
+ return true;
296
+ }
297
+
298
+ return reparsesAsNonString(str);
299
+ }
300
+
301
+ function serialiseString(str) {
302
+ if (!needsQuoting(str)) {
303
+ return str;
304
+ }
305
+
306
+ if (str.includes("\n")) {
307
+ const escaped = str
308
+ .replaceAll("\\", "\\\\")
309
+ .replaceAll('"', '\\"')
310
+ .replaceAll("\n", "\\n");
311
+ return `"${escaped}"`;
312
+ }
313
+
314
+ return `'${str.replaceAll("'", "''")}'`;
315
+ }
316
+
317
+ function serialiseScalar(value) {
318
+ if (value === null || value === undefined) {
319
+ return "";
320
+ }
321
+
322
+ if (typeof value === "boolean") {
323
+ return value ? "true" : "false";
324
+ }
325
+
326
+ if (typeof value === "number") {
327
+ return String(value);
328
+ }
329
+
330
+ return serialiseString(String(value));
331
+ }
332
+
333
+ function serialiseValue(key, value, lines) {
334
+ if (Array.isArray(value)) {
335
+ if (value.length === 0) {
336
+ lines.push(`${key}: []`);
337
+ return;
338
+ }
339
+
340
+ lines.push(`${key}:`);
341
+ for (const item of value) {
342
+ lines.push(` - ${serialiseScalar(item)}`);
343
+ }
344
+
345
+ return;
346
+ }
347
+
348
+ if (value !== null && typeof value === "object") {
349
+ lines.push(`${key}:`);
350
+ for (const [k, v] of Object.entries(value)) {
351
+ const child = serialiseScalar(v);
352
+ lines.push(child === "" ? ` ${k}:` : ` ${k}: ${child}`);
353
+ }
354
+
355
+ return;
356
+ }
357
+
358
+ const emitted = serialiseScalar(value);
359
+ lines.push(emitted === "" ? `${key}:` : `${key}: ${emitted}`);
360
+ }
361
+
362
+ export function stringifyFrontmatter(content, data) {
363
+ const lines = [];
364
+ for (const [key, value] of Object.entries(data)) {
365
+ serialiseValue(key, value, lines);
366
+ }
367
+
368
+ return `${FENCE}\n${lines.join("\n")}\n${FENCE}\n${content}`;
369
+ }
@@ -0,0 +1,152 @@
1
+ #!/usr/bin/env node
2
+ // Optional CI-parity preflight: confirm the active Node satisfies the consumer
3
+ // repo's engines/.nvmrc policy, then run `pnpm install --frozen-lockfile` so the
4
+ // lockfile is honoured before validating. Assumes the consumer repo uses pnpm
5
+ // with a committed lockfile; skip this step if yours does not.
6
+ import { spawnSync } from "node:child_process";
7
+ import { readFileSync } from "node:fs";
8
+ import { join } from "node:path";
9
+
10
+ const ROOT = process.cwd();
11
+
12
+ function parseVersion(raw) {
13
+ // Accept full and partial versions: a bare `22` or `22.5` (common in
14
+ // `.nvmrc`, which is what `nvm use` writes) pads the missing parts with 0.
15
+ const m = String(raw)
16
+ .trim()
17
+ .replace(/^v/, "")
18
+ .match(/^(\d+)(?:\.(\d+))?(?:\.(\d+))?/);
19
+ if (!m) {
20
+ return null;
21
+ }
22
+
23
+ return [Number(m[1]), Number(m[2] ?? 0), Number(m[3] ?? 0)];
24
+ }
25
+
26
+ // Extract the minimum concrete version from any common `engines.node` range
27
+ // without a semver dependency: `>=22`, `>=22.1.0`, `^22.0.0`, `~22.1`, `22.x`,
28
+ // `>=22 <23`, or a bare `22`. We take the first version-like token (the lower
29
+ // bound for the ranges we emit) and treat `x`/`*`/missing parts as 0.
30
+ function coerceMinVersion(spec) {
31
+ const m = String(spec).match(/(\d+)(?:\.(\d+|[xX*]))?(?:\.(\d+|[xX*]))?/);
32
+ if (!m) {
33
+ return null;
34
+ }
35
+
36
+ const part = (s) => (s === undefined || /[xX*]/.test(s) ? 0 : Number(s));
37
+ return [Number(m[1]), part(m[2]), part(m[3])];
38
+ }
39
+
40
+ function compareVersions(a, b) {
41
+ for (let i = 0; i < 3; i++) {
42
+ if (a[i] > b[i]) {
43
+ return 1;
44
+ }
45
+
46
+ if (a[i] < b[i]) {
47
+ return -1;
48
+ }
49
+ }
50
+
51
+ return 0;
52
+ }
53
+
54
+ function satisfiesGte(versionParts, minParts) {
55
+ return compareVersions(versionParts, minParts) >= 0;
56
+ }
57
+
58
+ function readEnginesNode() {
59
+ const pkg = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8"));
60
+ const spec = pkg.engines?.node;
61
+ if (!spec || typeof spec !== "string") {
62
+ console.error(
63
+ "preflight-changelog-ci: package.json engines.node is missing",
64
+ );
65
+ process.exit(1);
66
+ }
67
+
68
+ const min = coerceMinVersion(spec);
69
+ if (!min) {
70
+ console.error(
71
+ `preflight-changelog-ci: could not parse a minimum version from engines.node "${spec}"`,
72
+ );
73
+ process.exit(1);
74
+ }
75
+
76
+ return min;
77
+ }
78
+
79
+ function readNvmrc() {
80
+ let raw;
81
+ try {
82
+ raw = readFileSync(join(ROOT, ".nvmrc"), "utf8").trim();
83
+ } catch (error) {
84
+ if (
85
+ error &&
86
+ typeof error === "object" &&
87
+ "code" in error &&
88
+ error.code === "ENOENT"
89
+ ) {
90
+ console.error("preflight-changelog-ci: .nvmrc is missing");
91
+ process.exit(1);
92
+ }
93
+
94
+ throw error;
95
+ }
96
+
97
+ const v = parseVersion(raw);
98
+ if (!v) {
99
+ console.error(
100
+ `preflight-changelog-ci: could not parse .nvmrc version "${raw}"`,
101
+ );
102
+ process.exit(1);
103
+ }
104
+
105
+ return v;
106
+ }
107
+
108
+ const active = parseVersion(process.version);
109
+ if (!active) {
110
+ console.error(
111
+ `preflight-changelog-ci: could not parse active Node version "${process.version}"`,
112
+ );
113
+ process.exit(1);
114
+ }
115
+
116
+ const enginesMin = readEnginesNode();
117
+ const nvmrc = readNvmrc();
118
+
119
+ if (!satisfiesGte(active, enginesMin)) {
120
+ const required = enginesMin.join(".");
121
+ console.error(
122
+ `Active Node is ${process.version}; this repo requires >=${required} (see package.json engines and .nvmrc).`,
123
+ );
124
+ console.error("Switch Node (e.g. nvm use, fnm use) and re-run.");
125
+ process.exit(1);
126
+ }
127
+
128
+ if (!satisfiesGte(active, nvmrc)) {
129
+ const recommended = nvmrc.join(".");
130
+ console.error(
131
+ `Active Node is ${process.version}; .nvmrc recommends ${recommended}.`,
132
+ );
133
+ console.error("Switch Node (e.g. nvm use, fnm use) and re-run.");
134
+ process.exit(1);
135
+ }
136
+
137
+ const install = spawnSync("pnpm", ["install", "--frozen-lockfile"], {
138
+ cwd: ROOT,
139
+ stdio: "inherit",
140
+ shell: process.platform === "win32",
141
+ });
142
+
143
+ if (install.error) {
144
+ console.error(
145
+ `preflight-changelog-ci: could not run pnpm — ${install.error.message}`,
146
+ );
147
+ process.exit(1);
148
+ }
149
+
150
+ if (install.status !== 0) {
151
+ process.exit(install.status ?? 1);
152
+ }
@@ -0,0 +1,99 @@
1
+ #!/usr/bin/env node
2
+ // Merge-time half of changelog enrichment.
3
+ //
4
+ // `affected_packages` is knowable before merge — it's just the set of workspace
5
+ // packages the branch diff touches — so the changelog step computes it at write
6
+ // time rather than waiting for a privileged post-merge step. This script reads
7
+ // the branch diff against the base, maps it through the shared
8
+ // `derivePackagesFromPaths` rule, and writes the result into the changelog entry
9
+ // for the current branch.
10
+ //
11
+ // The post-merge-only fields (`merged_at`, `commit`, `merge_strategy`, and
12
+ // authoritative `stats`) are deliberately NOT touched here — they're owned by
13
+ // the release-orchestrator and stay blank until it fills them.
14
+ //
15
+ // Env overrides (both optional):
16
+ // BASE_REF — base to diff against (default: origin/<baseBranch> from config.json)
17
+ // BRANCH_NAME — entry lookup key (default: current branch via git)
18
+
19
+ import { findEntryByBranch } from "./lib/changelog.mjs";
20
+ import { derivePackagesFromPaths } from "./lib/derive-packages.mjs";
21
+ import { parseFrontmatter, stringifyFrontmatter } from "./lib/frontmatter.mjs";
22
+ import { loadConfig } from "./lib/config.mjs";
23
+ import { execFileSync } from "node:child_process";
24
+ import { readFileSync, writeFileSync } from "node:fs";
25
+
26
+ const BASE_REF =
27
+ process.env.BASE_REF?.trim() || `origin/${loadConfig().baseBranch}`;
28
+
29
+ function git(args) {
30
+ let out;
31
+ try {
32
+ out = execFileSync("git", args, { encoding: "utf8" });
33
+ } catch (error) {
34
+ // Most likely cause when run standalone: BASE_REF isn't fetched. In a ship
35
+ // flow the base is fetched before this runs.
36
+ console.error(`git ${args.join(" ")} failed: ${error.message}`);
37
+ process.exit(1);
38
+ }
39
+
40
+ return out.trim();
41
+ }
42
+
43
+ function currentBranch() {
44
+ const fromEnv = process.env.BRANCH_NAME?.trim();
45
+ if (fromEnv) {
46
+ return fromEnv;
47
+ }
48
+
49
+ return git(["rev-parse", "--abbrev-ref", "HEAD"]);
50
+ }
51
+
52
+ function changedPaths(base) {
53
+ // Three-dot: files changed on the branch since it diverged from base, so
54
+ // unrelated churn that landed on base meanwhile doesn't leak in.
55
+ const out = git(["diff", "--name-only", `${base}...HEAD`]);
56
+ return out ? out.split("\n") : [];
57
+ }
58
+
59
+ const branch = currentBranch();
60
+ const file = findEntryByBranch(branch);
61
+ if (!file) {
62
+ console.log(
63
+ `No changelog entry found for branch '${branch}'. Nothing to set.`,
64
+ );
65
+ process.exit(0);
66
+ }
67
+
68
+ const packages = derivePackagesFromPaths(changedPaths(BASE_REF));
69
+
70
+ const raw = readFileSync(file, "utf8");
71
+ const parsed = parseFrontmatter(raw);
72
+ // Always overwrite (not fill-only like the post-merge fields): re-running must
73
+ // re-derive affected_packages from the latest branch diff as commits are added.
74
+ //
75
+ // Rebuild in canonical field order — `affected_packages` immediately before
76
+ // `stats` — instead of spreading it on the end. `stringifyFrontmatter` emits in
77
+ // insertion order, so a source entry that lacked the `affected_packages: []`
78
+ // placeholder would otherwise drift the field after `stats` permanently.
79
+ const fm = {};
80
+ for (const [key, value] of Object.entries(parsed.data)) {
81
+ if (key === "affected_packages") {
82
+ continue; // re-inserted in its canonical slot below
83
+ }
84
+ if (key === "stats") {
85
+ fm.affected_packages = packages;
86
+ }
87
+ fm[key] = value;
88
+ }
89
+ if (!("affected_packages" in fm)) {
90
+ // No `stats` key to anchor against; append (a missing `stats` is itself a
91
+ // contract violation the validator will flag).
92
+ fm.affected_packages = packages;
93
+ }
94
+
95
+ writeFileSync(file, stringifyFrontmatter(parsed.content, fm));
96
+
97
+ console.log(`Set affected_packages on ${file}`);
98
+ console.log(` branch=${branch} base=${BASE_REF}`);
99
+ console.log(` affected_packages=${JSON.stringify(packages)}`);