@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.
- package/README.md +5 -4
- package/package.json +2 -6
- package/skills/changelog/README.md +59 -0
- package/skills/changelog/SKILL.md +187 -0
- package/skills/changelog/config.example.json +5 -0
- package/skills/changelog/config.json +5 -0
- package/skills/changelog/package.json +31 -0
- package/skills/changelog/references/changelog-contract.md +121 -0
- package/skills/changelog/scripts/add-links.mjs +97 -0
- package/skills/changelog/scripts/lib/changelog.mjs +46 -0
- package/skills/changelog/scripts/lib/config.mjs +53 -0
- package/skills/changelog/scripts/lib/derive-packages.mjs +39 -0
- package/skills/changelog/scripts/lib/frontmatter.mjs +369 -0
- package/skills/changelog/scripts/preflight-changelog-ci.mjs +152 -0
- package/skills/changelog/scripts/set-affected-packages.mjs +99 -0
- package/skills/changelog/scripts/validate-changelog.mjs +264 -0
- package/skills/linear-sync/README.md +47 -0
- package/skills/linear-sync/SKILL.md +115 -0
- package/skills/linear-sync/config.example.json +4 -0
- package/skills/linear-sync/config.json +4 -0
- package/skills/linear-sync/package.json +31 -0
- package/skills/preflight/README.md +70 -0
- package/skills/preflight/SKILL.md +148 -0
- package/skills/preflight/config.example.json +6 -0
- package/skills/preflight/package.json +33 -0
- package/skills/preflight/scripts/classify-lint.mjs +176 -0
- package/skills/preflight/scripts/lib/diff-lines.mjs +83 -0
- package/skills/preflight/scripts/lib/paths.mjs +26 -0
- package/skills/preflight/scripts/lib/scope.mjs +530 -0
- package/skills/preflight/scripts/lint-fix.mjs +78 -0
- package/skills/preflight/scripts/preflight.mjs +416 -0
- package/skills/send-it/README.md +75 -0
- package/skills/send-it/SKILL.md +391 -0
- package/skills/send-it/config.example.json +5 -0
- package/skills/send-it/config.json +5 -0
- package/skills/send-it/package.json +33 -0
- package/skills/send-it/scripts/derive-bump.mjs +139 -0
- package/skills/triage-pr/README.md +56 -0
- package/skills/triage-pr/SKILL.md +291 -0
- package/skills/triage-pr/config.json +4 -0
- package/skills/triage-pr/package.json +32 -0
- package/skills/triage-pr/references/review-discipline.md +73 -0
- package/skills/triage-pr/scripts/review-threads.mjs +549 -0
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Branch-scoped file classification for the preflight skill (originally ASW-282).
|
|
4
|
+
* Shared by preflight.mjs, lint-fix.mjs, and classify-lint.mjs.
|
|
5
|
+
*
|
|
6
|
+
* Repo-specific configuration (linted workspaces + base branch) is auto-detected
|
|
7
|
+
* rather than hardcoded (ASW-305, delivered under ASW-344): workspaces come from
|
|
8
|
+
* `pnpm-workspace.yaml` + each package's `lint` script, and the base branch from
|
|
9
|
+
* `origin/HEAD`. A `preflight.config.json` at the repo root overrides both. This
|
|
10
|
+
* keeps the preflight skill portable — a consuming repo edits at most one small
|
|
11
|
+
* file, and usually none.
|
|
12
|
+
*/
|
|
13
|
+
import { spawnSync } from "node:child_process";
|
|
14
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
15
|
+
import { basename, join } from "node:path";
|
|
16
|
+
|
|
17
|
+
const ROOT = process.cwd();
|
|
18
|
+
|
|
19
|
+
const LINTABLE_CODE = /\.(ts|tsx|js|jsx|mjs|cjs)$/i;
|
|
20
|
+
const CODE_CONFIG =
|
|
21
|
+
/(^|\/)(eslint\.config\.[^/]+|tsconfig\.eslint\.json|vite\.config\.[^/]+|package\.json|pnpm-lock\.yaml)$/;
|
|
22
|
+
const ESLINT_RUNNABLE =
|
|
23
|
+
/(^|\/)(eslint\.config\.[^/]+|tsconfig\.eslint\.json|vite\.config\.[^/]+)$/;
|
|
24
|
+
const ROOT_ESLINT_CONFIG = /^eslint\.config\.[^/]+$/;
|
|
25
|
+
const MARKDOWN = /\.(md|mdx)$/i;
|
|
26
|
+
const WORKFLOW = /^\.github\/workflows\/.*\.ya?ml$/i;
|
|
27
|
+
const ACTIONLINT_CONFIG = /^\.github\/actionlint\.yaml$/i;
|
|
28
|
+
// Excludes build output plus installed skill bundles (`.agents/skills/`,
|
|
29
|
+
// `.claude/skills/`): a consumer's vendored skills are third-party content, not
|
|
30
|
+
// the branch's own markdown, so re-linting them would surface noise the author
|
|
31
|
+
// can't fix.
|
|
32
|
+
const MD_IGNORE =
|
|
33
|
+
/(?:^|\/)(?:node_modules|dist|\.astro|\.turbo|\.cache)(?:\/|$)|(?:^|\/)\.agents\/skills\/|(?:^|\/)\.claude\/skills\//;
|
|
34
|
+
|
|
35
|
+
const DEFAULT_BASE_BRANCH = "main";
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Minimal reader for the `packages:` block-sequence in `pnpm-workspace.yaml`.
|
|
39
|
+
* Hand-rolled (no YAML dependency, per ASW-303) so the scripts travel without
|
|
40
|
+
* node_modules. Uses plain string operations rather than a backtracking regex.
|
|
41
|
+
* @param {string} root
|
|
42
|
+
* @returns {string[]}
|
|
43
|
+
*/
|
|
44
|
+
function readWorkspaceGlobs(root) {
|
|
45
|
+
const file = join(root, "pnpm-workspace.yaml");
|
|
46
|
+
if (!existsSync(file)) {
|
|
47
|
+
return [];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const lines = readFileSync(file, "utf8").split("\n");
|
|
51
|
+
const globs = [];
|
|
52
|
+
let inPackages = false;
|
|
53
|
+
|
|
54
|
+
for (const raw of lines) {
|
|
55
|
+
const line = raw.trimEnd();
|
|
56
|
+
const trimmed = line.trim();
|
|
57
|
+
|
|
58
|
+
if (trimmed === "packages:") {
|
|
59
|
+
inPackages = true;
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (!inPackages) {
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// A new top-level key (non-indented, non-list) ends the packages block.
|
|
68
|
+
if (line.length > 0 && !line.startsWith(" ") && !line.startsWith("-")) {
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (trimmed.startsWith("-")) {
|
|
73
|
+
const value = trimmed
|
|
74
|
+
.slice(1)
|
|
75
|
+
.trim()
|
|
76
|
+
.replace(/^['"]|['"]$/g, "");
|
|
77
|
+
if (value) {
|
|
78
|
+
globs.push(value);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return globs;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Whether a directory entry resolves to a directory, following symlinks.
|
|
88
|
+
* `Dirent.isDirectory()` is false for a symlink-to-directory, so a workspace
|
|
89
|
+
* that is a symlink (monorepo linking tools, vendored packages) would otherwise
|
|
90
|
+
* be silently skipped. Broken symlinks resolve to false.
|
|
91
|
+
* @param {string} parentPath
|
|
92
|
+
* @param {import('node:fs').Dirent} entry
|
|
93
|
+
* @returns {boolean}
|
|
94
|
+
*/
|
|
95
|
+
function entryIsDirectory(parentPath, entry) {
|
|
96
|
+
if (entry.isDirectory()) {
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (entry.isSymbolicLink()) {
|
|
101
|
+
try {
|
|
102
|
+
return statSync(join(parentPath, entry.name)).isDirectory();
|
|
103
|
+
} catch {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Expand a workspace glob to existing directories. Supports the common
|
|
113
|
+
* single-level trailing `*` (e.g. `apps/*`) and a literal directory path. Nested
|
|
114
|
+
* `**` globs are not expanded — document a `preflight.config.json` override if a
|
|
115
|
+
* repo needs them.
|
|
116
|
+
* @param {string} root
|
|
117
|
+
* @param {string} glob
|
|
118
|
+
* @returns {string[]}
|
|
119
|
+
*/
|
|
120
|
+
function expandGlob(root, glob) {
|
|
121
|
+
if (glob.endsWith("/*")) {
|
|
122
|
+
const parent = glob.slice(0, -2);
|
|
123
|
+
const parentPath = join(root, parent);
|
|
124
|
+
if (!existsSync(parentPath)) {
|
|
125
|
+
return [];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return readdirSync(parentPath, { withFileTypes: true })
|
|
129
|
+
.filter((entry) => entryIsDirectory(parentPath, entry))
|
|
130
|
+
.map((entry) => `${parent}/${entry.name}`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Skip more complex globs (e.g. nested `**`); only literal dirs fall through.
|
|
134
|
+
if (glob.includes("*")) {
|
|
135
|
+
return [];
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return existsSync(join(root, glob)) ? [glob] : [];
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Auto-detect linted workspaces from `pnpm-workspace.yaml`. A workspace is
|
|
143
|
+
* included only if its `package.json` declares a `lint` script — this naturally
|
|
144
|
+
* excludes intentionally-unlinted workspaces and non-package dirs without a
|
|
145
|
+
* hand-maintained omission list.
|
|
146
|
+
* @param {string} [root]
|
|
147
|
+
* @returns {Record<string, { filter: string; prefix: string }>}
|
|
148
|
+
*/
|
|
149
|
+
export function detectWorkspaces(root = ROOT) {
|
|
150
|
+
const dirs = readWorkspaceGlobs(root).flatMap((glob) =>
|
|
151
|
+
expandGlob(root, glob),
|
|
152
|
+
);
|
|
153
|
+
/** @type {Record<string, { filter: string; prefix: string }>} */
|
|
154
|
+
const workspaces = {};
|
|
155
|
+
|
|
156
|
+
for (const dir of dirs) {
|
|
157
|
+
const pkgPath = join(root, dir, "package.json");
|
|
158
|
+
if (!existsSync(pkgPath)) {
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
let pkg;
|
|
163
|
+
try {
|
|
164
|
+
pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
165
|
+
} catch {
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (!pkg.name || !pkg.scripts || !pkg.scripts.lint) {
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Key by basename for readable summary output. On the rare collision (two
|
|
174
|
+
// workspaces sharing a basename across glob roots, e.g. apps/shared and
|
|
175
|
+
// packages/shared) fall back to the full path so neither is silently dropped.
|
|
176
|
+
let key = basename(dir);
|
|
177
|
+
if (Object.prototype.hasOwnProperty.call(workspaces, key)) {
|
|
178
|
+
console.warn(
|
|
179
|
+
`preflight: workspace basename collision for "${key}" — keying "${dir}" by its full path`,
|
|
180
|
+
);
|
|
181
|
+
key = dir;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
workspaces[key] = { filter: pkg.name, prefix: `${dir}/` };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return workspaces;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Resolve the base branch to diff against. Detects the default branch from
|
|
192
|
+
* `origin/HEAD` (e.g. `main`, `master`, `develop`); falls back to `main` when the
|
|
193
|
+
* symbolic ref is absent (common on fresh clones / shallow CI checkouts).
|
|
194
|
+
* @param {string} [root]
|
|
195
|
+
* @returns {string}
|
|
196
|
+
*/
|
|
197
|
+
export function detectBaseBranch(root = ROOT) {
|
|
198
|
+
const result = spawnSync(
|
|
199
|
+
"git",
|
|
200
|
+
["symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"],
|
|
201
|
+
{ cwd: root, encoding: "utf8" },
|
|
202
|
+
);
|
|
203
|
+
if (result.status === 0 && result.stdout.trim()) {
|
|
204
|
+
return result.stdout.trim().replace(/^refs\/remotes\/origin\//, "");
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return DEFAULT_BASE_BRANCH;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Validate a parsed `preflight.config.json` shape, dropping any malformed key
|
|
212
|
+
* (with a warning) so it falls back to auto-detection rather than surfacing a
|
|
213
|
+
* confusing downstream error. `baseBranch` must be a non-empty string;
|
|
214
|
+
* `workspaces` must be an object of `{ filter, prefix }` string pairs.
|
|
215
|
+
* @param {unknown} raw
|
|
216
|
+
* @returns {{ baseBranch?: string; workspaces?: Record<string, { filter: string; prefix: string }> }}
|
|
217
|
+
*/
|
|
218
|
+
function validateOverride(raw) {
|
|
219
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
220
|
+
console.warn(
|
|
221
|
+
"preflight: ignoring preflight.config.json (expected a JSON object)",
|
|
222
|
+
);
|
|
223
|
+
return {};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** @type {{ baseBranch?: string; workspaces?: Record<string, { filter: string; prefix: string }> }} */
|
|
227
|
+
const override = {};
|
|
228
|
+
|
|
229
|
+
if ("baseBranch" in raw) {
|
|
230
|
+
const baseBranch =
|
|
231
|
+
typeof raw.baseBranch === "string" ? raw.baseBranch.trim() : "";
|
|
232
|
+
if (baseBranch) {
|
|
233
|
+
override.baseBranch = baseBranch;
|
|
234
|
+
} else {
|
|
235
|
+
console.warn(
|
|
236
|
+
"preflight: ignoring preflight.config.json baseBranch (expected a non-empty string)",
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if ("workspaces" in raw) {
|
|
242
|
+
const ws = raw.workspaces;
|
|
243
|
+
if (typeof ws !== "object" || ws === null || Array.isArray(ws)) {
|
|
244
|
+
console.warn(
|
|
245
|
+
"preflight: ignoring preflight.config.json workspaces (expected an object)",
|
|
246
|
+
);
|
|
247
|
+
} else {
|
|
248
|
+
/** @type {Record<string, { filter: string; prefix: string }>} */
|
|
249
|
+
const workspaces = {};
|
|
250
|
+
for (const [key, value] of Object.entries(ws)) {
|
|
251
|
+
const entry = typeof value === "object" && value !== null ? value : {};
|
|
252
|
+
const filter =
|
|
253
|
+
typeof entry.filter === "string" ? entry.filter.trim() : "";
|
|
254
|
+
const prefix =
|
|
255
|
+
typeof entry.prefix === "string" ? entry.prefix.trim() : "";
|
|
256
|
+
|
|
257
|
+
// Reject empty filter/prefix and normalise prefix to a trailing slash:
|
|
258
|
+
// `classifyChangedFiles` uses `file.startsWith(prefix)`, so an empty
|
|
259
|
+
// prefix would bucket every file and a slash-less one would over-match
|
|
260
|
+
// sibling directories (apps/web vs apps/website).
|
|
261
|
+
if (filter && prefix) {
|
|
262
|
+
workspaces[key] = {
|
|
263
|
+
filter,
|
|
264
|
+
prefix: prefix.endsWith("/") ? prefix : `${prefix}/`,
|
|
265
|
+
};
|
|
266
|
+
} else {
|
|
267
|
+
console.warn(
|
|
268
|
+
`preflight: ignoring preflight.config.json workspace "${key}" (expected non-empty { filter, prefix } strings)`,
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Only treat the override as authoritative when at least one entry
|
|
274
|
+
// survived validation. An all-invalid block must NOT win over
|
|
275
|
+
// auto-detection: `resolveConfig` uses `override.workspaces ??
|
|
276
|
+
// detectWorkspaces(root)`, and an empty `{}` is truthy, so it would
|
|
277
|
+
// otherwise silently run ESLint on zero workspaces.
|
|
278
|
+
if (Object.keys(workspaces).length > 0) {
|
|
279
|
+
override.workspaces = workspaces;
|
|
280
|
+
} else {
|
|
281
|
+
console.warn(
|
|
282
|
+
"preflight: ignoring preflight.config.json workspaces (no valid entries; falling back to auto-detection)",
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return override;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Load and validate an optional `preflight.config.json` override from the repo
|
|
293
|
+
* root. Either key may be supplied independently.
|
|
294
|
+
* @param {string} root
|
|
295
|
+
* @returns {{ baseBranch?: string; workspaces?: Record<string, { filter: string; prefix: string }> }}
|
|
296
|
+
*/
|
|
297
|
+
function loadConfigOverride(root) {
|
|
298
|
+
const file = join(root, "preflight.config.json");
|
|
299
|
+
if (!existsSync(file)) {
|
|
300
|
+
return {};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
let parsed;
|
|
304
|
+
try {
|
|
305
|
+
parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
306
|
+
} catch {
|
|
307
|
+
console.warn(
|
|
308
|
+
"preflight: ignoring malformed preflight.config.json (could not parse JSON)",
|
|
309
|
+
);
|
|
310
|
+
return {};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return validateOverride(parsed);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** @type {{ baseBranch: string; workspaces: Record<string, { filter: string; prefix: string }> } | null} */
|
|
317
|
+
let cachedConfig = null;
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Resolve preflight configuration: an explicit `preflight.config.json` wins,
|
|
321
|
+
* otherwise auto-detect. Memoised for the default root.
|
|
322
|
+
* @param {string} [root]
|
|
323
|
+
* @returns {{ baseBranch: string; workspaces: Record<string, { filter: string; prefix: string }> }}
|
|
324
|
+
*/
|
|
325
|
+
export function resolveConfig(root = ROOT) {
|
|
326
|
+
if (root === ROOT && cachedConfig) {
|
|
327
|
+
return cachedConfig;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const override = loadConfigOverride(root);
|
|
331
|
+
const config = {
|
|
332
|
+
baseBranch: override.baseBranch ?? detectBaseBranch(root),
|
|
333
|
+
workspaces: override.workspaces ?? detectWorkspaces(root),
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
if (root === ROOT) {
|
|
337
|
+
cachedConfig = config;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
return config;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* @returns {string}
|
|
345
|
+
*/
|
|
346
|
+
export function gitMergeBase() {
|
|
347
|
+
const { baseBranch } = resolveConfig();
|
|
348
|
+
const result = spawnSync(
|
|
349
|
+
"git",
|
|
350
|
+
["merge-base", "HEAD", `origin/${baseBranch}`],
|
|
351
|
+
{ cwd: ROOT, encoding: "utf8" },
|
|
352
|
+
);
|
|
353
|
+
if (result.status !== 0) {
|
|
354
|
+
throw new Error(
|
|
355
|
+
`preflight: could not find merge base with origin/${baseBranch}. Run: git fetch origin ${baseBranch}`,
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return result.stdout.trim();
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Branch-changed files that still exist at HEAD. `--diff-filter=d` excludes
|
|
364
|
+
* deletions: a file removed on the branch can't be linted and must not reach
|
|
365
|
+
* ESLint/markdownlint (which error on a missing pattern), nor be classified for
|
|
366
|
+
* violations.
|
|
367
|
+
* @param {string} mergeBase
|
|
368
|
+
* @returns {string[]}
|
|
369
|
+
*/
|
|
370
|
+
export function gitChangedFiles(mergeBase) {
|
|
371
|
+
const result = spawnSync(
|
|
372
|
+
"git",
|
|
373
|
+
["diff", "--name-only", "--diff-filter=d", `${mergeBase}...HEAD`],
|
|
374
|
+
{ cwd: ROOT, encoding: "utf8", maxBuffer: 10 * 1024 * 1024 },
|
|
375
|
+
);
|
|
376
|
+
if (result.error || result.status !== 0) {
|
|
377
|
+
const detail =
|
|
378
|
+
result.error?.message || result.stderr?.trim() || "unknown git diff error";
|
|
379
|
+
throw new Error(`preflight: git diff failed: ${detail}`);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
return result.stdout.split("\n").filter(Boolean);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* @param {string} file
|
|
387
|
+
*/
|
|
388
|
+
function isLintableCodePath(file) {
|
|
389
|
+
return LINTABLE_CODE.test(file) || CODE_CONFIG.test(file);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Paths ESLint can actually lint (excludes pnpm-lock.yaml and other non-runnable gates).
|
|
394
|
+
* @param {string} file
|
|
395
|
+
*/
|
|
396
|
+
function isEslintRunnablePath(file) {
|
|
397
|
+
return LINTABLE_CODE.test(file) || ESLINT_RUNNABLE.test(file);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* @param {boolean} workflowsChanged
|
|
402
|
+
* @param {string[]} workflows
|
|
403
|
+
*/
|
|
404
|
+
function resolveActionlintTargets(workflowsChanged, workflows) {
|
|
405
|
+
if (!workflowsChanged) {
|
|
406
|
+
return [];
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
if (workflows.length > 0) {
|
|
410
|
+
return [...workflows];
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// actionlint config-only change: re-validate all tracked workflows (mirrors CI path trigger)
|
|
414
|
+
const ls = spawnSync("git", ["ls-files", ".github/workflows"], {
|
|
415
|
+
cwd: ROOT,
|
|
416
|
+
encoding: "utf8",
|
|
417
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
418
|
+
});
|
|
419
|
+
if (ls.status !== 0) {
|
|
420
|
+
return [];
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
return ls.stdout.split("\n").filter((file) => WORKFLOW.test(file));
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* @param {string[]} changedFiles
|
|
428
|
+
* @param {Record<string, { filter: string; prefix: string }>} [workspaces]
|
|
429
|
+
*/
|
|
430
|
+
export function classifyChangedFiles(
|
|
431
|
+
changedFiles,
|
|
432
|
+
workspaces = resolveConfig().workspaces,
|
|
433
|
+
) {
|
|
434
|
+
let codeChanged = false;
|
|
435
|
+
let markdownChanged = false;
|
|
436
|
+
let workflowsChanged = false;
|
|
437
|
+
|
|
438
|
+
/** @type {Record<string, string[]>} */
|
|
439
|
+
const eslint = {
|
|
440
|
+
scripts: [],
|
|
441
|
+
root: [],
|
|
442
|
+
...Object.fromEntries(Object.keys(workspaces).map((k) => [k, []])),
|
|
443
|
+
};
|
|
444
|
+
const markdown = [];
|
|
445
|
+
const workflows = [];
|
|
446
|
+
|
|
447
|
+
for (const file of changedFiles) {
|
|
448
|
+
if (WORKFLOW.test(file) || ACTIONLINT_CONFIG.test(file)) {
|
|
449
|
+
workflowsChanged = true;
|
|
450
|
+
if (WORKFLOW.test(file)) {
|
|
451
|
+
workflows.push(file);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
if (MARKDOWN.test(file) && !MD_IGNORE.test(file)) {
|
|
456
|
+
markdownChanged = true;
|
|
457
|
+
markdown.push(file);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
if (isLintableCodePath(file)) {
|
|
461
|
+
codeChanged = true;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
if (!isEslintRunnablePath(file)) {
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
if (file.startsWith("scripts/")) {
|
|
469
|
+
eslint.scripts.push(file);
|
|
470
|
+
} else if (
|
|
471
|
+
ROOT_ESLINT_CONFIG.test(file) ||
|
|
472
|
+
file === "tsconfig.eslint.json"
|
|
473
|
+
) {
|
|
474
|
+
eslint.root.push(file);
|
|
475
|
+
} else {
|
|
476
|
+
for (const [key, { prefix }] of Object.entries(workspaces)) {
|
|
477
|
+
if (file.startsWith(prefix)) {
|
|
478
|
+
eslint[key].push(file);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
const actionlintTargets = resolveActionlintTargets(
|
|
485
|
+
workflowsChanged,
|
|
486
|
+
workflows,
|
|
487
|
+
);
|
|
488
|
+
|
|
489
|
+
return {
|
|
490
|
+
codeChanged,
|
|
491
|
+
markdownChanged,
|
|
492
|
+
workflowsChanged,
|
|
493
|
+
eslint,
|
|
494
|
+
markdown,
|
|
495
|
+
workflows,
|
|
496
|
+
actionlintTargets,
|
|
497
|
+
changedFiles,
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Strip a workspace prefix from repo-root-relative paths so they can be handed
|
|
503
|
+
* to a workspace-scoped tool. `pnpm --filter <pkg> exec <tool>` runs with the
|
|
504
|
+
* workspace directory as cwd, so a path like `apps/studio/src/x.ts` must become
|
|
505
|
+
* `src/x.ts` for the tool to resolve it. Paths that don't start with `prefix`
|
|
506
|
+
* (or when `prefix` is empty) are returned unchanged.
|
|
507
|
+
* @param {string[]} files repo-root-relative paths
|
|
508
|
+
* @param {string} prefix workspace prefix with a trailing slash (e.g. "apps/studio/")
|
|
509
|
+
* @returns {string[]} paths relative to the workspace directory
|
|
510
|
+
*/
|
|
511
|
+
export function relativiseToWorkspace(files, prefix) {
|
|
512
|
+
if (!prefix) {
|
|
513
|
+
return [...files];
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
return files.map((file) =>
|
|
517
|
+
file.startsWith(prefix) ? file.slice(prefix.length) : file,
|
|
518
|
+
);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* @returns {{ mergeBase: string; workspaces: Record<string, { filter: string; prefix: string }>; codeChanged: boolean; markdownChanged: boolean; workflowsChanged: boolean; eslint: object; markdown: string[]; workflows: string[]; actionlintTargets: string[]; changedFiles: string[] }}
|
|
523
|
+
*/
|
|
524
|
+
export function getBranchScope() {
|
|
525
|
+
const { workspaces } = resolveConfig();
|
|
526
|
+
const mergeBase = gitMergeBase();
|
|
527
|
+
const changedFiles = gitChangedFiles(mergeBase);
|
|
528
|
+
const classified = classifyChangedFiles(changedFiles, workspaces);
|
|
529
|
+
return { mergeBase, workspaces, ...classified };
|
|
530
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { getBranchScope } from "./lib/scope.mjs";
|
|
3
|
+
/**
|
|
4
|
+
* Scoped auto-fix for branch-changed lintable paths (originally ASW-282).
|
|
5
|
+
*/
|
|
6
|
+
import { spawnSync } from "node:child_process";
|
|
7
|
+
|
|
8
|
+
const ROOT = process.cwd();
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @param {string} cmd
|
|
12
|
+
* @param {string[]} argv
|
|
13
|
+
*/
|
|
14
|
+
function run(cmd, argv) {
|
|
15
|
+
const result = spawnSync(cmd, argv, {
|
|
16
|
+
cwd: ROOT,
|
|
17
|
+
encoding: "utf8",
|
|
18
|
+
stdio: "inherit",
|
|
19
|
+
});
|
|
20
|
+
if (result.status !== 0) {
|
|
21
|
+
process.exit(result.status ?? 1);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function main() {
|
|
26
|
+
const scope = getBranchScope();
|
|
27
|
+
|
|
28
|
+
if (!scope.codeChanged && !scope.markdownChanged) {
|
|
29
|
+
console.log(
|
|
30
|
+
"preflight-lint-fix: nothing to fix (no code or markdown changes on branch)",
|
|
31
|
+
);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (scope.codeChanged) {
|
|
36
|
+
const scriptFiles = [...scope.eslint.scripts, ...scope.eslint.root];
|
|
37
|
+
if (scriptFiles.length > 0) {
|
|
38
|
+
console.log(
|
|
39
|
+
`preflight-lint-fix: eslint --fix on ${scriptFiles.length} root/scripts file(s)`,
|
|
40
|
+
);
|
|
41
|
+
run("pnpm", ["exec", "eslint", "--fix", "--", ...scriptFiles]);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
for (const [key, { filter }] of Object.entries(scope.workspaces)) {
|
|
45
|
+
const files = scope.eslint[key];
|
|
46
|
+
if (files.length === 0) {
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
console.log(
|
|
51
|
+
`preflight-lint-fix: eslint --fix (${filter}) on ${files.length} file(s)`,
|
|
52
|
+
);
|
|
53
|
+
run("pnpm", [
|
|
54
|
+
"--filter",
|
|
55
|
+
filter,
|
|
56
|
+
"exec",
|
|
57
|
+
"eslint",
|
|
58
|
+
"--fix",
|
|
59
|
+
"--",
|
|
60
|
+
...files,
|
|
61
|
+
]);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (scope.markdownChanged && scope.markdown.length > 0) {
|
|
66
|
+
console.log(
|
|
67
|
+
`preflight-lint-fix: markdownlint --fix on ${scope.markdown.length} file(s)`,
|
|
68
|
+
);
|
|
69
|
+
// No explicit --config: markdownlint-cli2 auto-discovers the consumer repo's
|
|
70
|
+
// config (`.markdownlint-cli2.*` / `.markdownlint.*`), matching how the
|
|
71
|
+
// detection side (preflight.mjs) invokes it. Keeps the skill portable.
|
|
72
|
+
run("pnpm", ["exec", "markdownlint-cli2", "--fix", ...scope.markdown]);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
console.log("preflight-lint-fix: done");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
main();
|