@reneturcios/greenlight 0.12.0 → 0.14.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.
@@ -0,0 +1,768 @@
1
+ /**
2
+ * CI Pre-flight (`greenlight ci-prep` / MCP `ci_prep`).
3
+ *
4
+ * Answers one question with evidence, BEFORE a PR is pushed: would this
5
+ * repo's own pull_request workflows pass on the current tree?
6
+ *
7
+ * Pipeline:
8
+ * 1. DISCOVER: parse .github/workflows/*.yml for workflows triggered on
9
+ * pull_request / pull_request_target; extract jobs and steps.
10
+ * 2. MAP + RUN (native mode): translate each step into a local check.
11
+ * Steps that map to package scripts or common binaries (test, lint,
12
+ * typecheck, build) are EXECUTED with a per-step timeout. Steps that
13
+ * cannot run locally (deploys, secrets, external services, publishes)
14
+ * are SKIPPED with the reason named. A check is never reported PASS
15
+ * unless it actually ran.
16
+ * 3. CONVENTIONS: changeset requirement, conventional-commit subjects,
17
+ * and DCO sign-off are checked against git history when the repo
18
+ * signals that it requires them.
19
+ * 4. ACT MODE (--act): when `act` and a working docker daemon are both
20
+ * present, the discovered PR workflows run through `act` for real;
21
+ * otherwise the tool says so plainly and falls back to native mode.
22
+ *
23
+ * Honesty contract (same as verify.ts): this would rather say SKIPPED with
24
+ * a reason than report a verdict it cannot defend. Verdict is GO only
25
+ * when zero checks FAIL.
26
+ */
27
+ import { execFileSync, spawnSync } from "node:child_process";
28
+ import { existsSync, mkdirSync, readdirSync, readFileSync } from "node:fs";
29
+ import { tmpdir } from "node:os";
30
+ import { join, resolve } from "node:path";
31
+ function stripComment(line) {
32
+ let inSingle = false;
33
+ let inDouble = false;
34
+ let exprDepth = 0;
35
+ for (let i = 0; i < line.length; i++) {
36
+ const ch = line[i];
37
+ if (ch === "'" && !inDouble && exprDepth === 0)
38
+ inSingle = !inSingle;
39
+ else if (ch === '"' && !inSingle && exprDepth === 0)
40
+ inDouble = !inDouble;
41
+ else if (!inSingle && !inDouble && line.startsWith("${{", i))
42
+ exprDepth++;
43
+ else if (exprDepth > 0 && line.startsWith("}}", i))
44
+ exprDepth--;
45
+ else if (ch === "#" && !inSingle && !inDouble && exprDepth === 0) {
46
+ // A `#` starts a comment only when preceded by whitespace or at start.
47
+ if (i === 0 || /\s/.test(line[i - 1]))
48
+ return line.slice(0, i);
49
+ }
50
+ }
51
+ return line;
52
+ }
53
+ function parseScalar(raw) {
54
+ const t = raw.trim();
55
+ if (t === "" || t === "~" || t === "null" || t === "Null" || t === "NULL")
56
+ return null;
57
+ if ((t.startsWith('"') && t.endsWith('"') && t.length >= 2) || (t.startsWith("'") && t.endsWith("'") && t.length >= 2)) {
58
+ const inner = t.slice(1, -1);
59
+ return t[0] === '"' ? inner.replace(/\\"/g, '"').replace(/\\\\/g, "\\") : inner.replace(/''/g, "'");
60
+ }
61
+ // Flow sequence: [a, b, c] — only top-level commas, good enough for `on:`.
62
+ if (t.startsWith("[") && t.endsWith("]")) {
63
+ const inner = t.slice(1, -1).trim();
64
+ if (!inner)
65
+ return [];
66
+ return splitFlow(inner).map((s) => parseScalar(s));
67
+ }
68
+ if (/^-?\d+$/.test(t))
69
+ return parseInt(t, 10);
70
+ if (/^-?\d+\.\d+$/.test(t))
71
+ return parseFloat(t);
72
+ if (t === "true" || t === "True" || t === "TRUE")
73
+ return true;
74
+ if (t === "false" || t === "False" || t === "FALSE")
75
+ return false;
76
+ return t;
77
+ }
78
+ function splitFlow(s) {
79
+ const parts = [];
80
+ let depth = 0;
81
+ let cur = "";
82
+ let inS = false;
83
+ let inD = false;
84
+ for (let i = 0; i < s.length; i++) {
85
+ const ch = s[i];
86
+ if (ch === "'" && !inD)
87
+ inS = !inS;
88
+ else if (ch === '"' && !inS)
89
+ inD = !inD;
90
+ else if (!inS && !inD && (ch === "[" || ch === "{"))
91
+ depth++;
92
+ else if (!inS && !inD && (ch === "]" || ch === "}"))
93
+ depth--;
94
+ if (ch === "," && depth === 0 && !inS && !inD) {
95
+ parts.push(cur);
96
+ cur = "";
97
+ continue;
98
+ }
99
+ cur += ch;
100
+ }
101
+ parts.push(cur);
102
+ return parts.map((p) => p.trim()).filter((p) => p.length > 0);
103
+ }
104
+ /** Parse a GitHub Actions workflow file into a plain object tree. Throws on structural problems. */
105
+ export function parseWorkflowYaml(text) {
106
+ const rawLines = text.split("\n");
107
+ const lines = [];
108
+ for (const raw of rawLines) {
109
+ if (/^\s*$/.test(raw))
110
+ continue;
111
+ const noComment = stripComment(raw);
112
+ if (/^\s*$/.test(noComment))
113
+ continue;
114
+ const indent = noComment.length - noComment.trimStart().length;
115
+ lines.push({ indent, text: noComment.trim() });
116
+ }
117
+ if (lines.length === 0)
118
+ return {};
119
+ if (lines[0].indent !== 0)
120
+ throw new Error("workflow does not start at indent 0");
121
+ const root = {};
122
+ // Stack of {indent, container} where container is a map or list being filled.
123
+ const stack = [{ indent: -1, node: root }];
124
+ const peek = () => stack[stack.length - 1];
125
+ let i = 0;
126
+ while (i < lines.length) {
127
+ const { indent, text: line } = lines[i];
128
+ // Block scalar: `key: |` or `key: >`
129
+ const blockMatch = line.match(/^([^:#]+):\s*([|>])([+-]?)\s*$/);
130
+ if (blockMatch) {
131
+ const key = blockMatch[1].trim();
132
+ const style = blockMatch[2];
133
+ i++;
134
+ const chunks = [];
135
+ while (i < lines.length && lines[i].indent > indent) {
136
+ chunks.push(lines[i].text);
137
+ i++;
138
+ }
139
+ const value = style === "|" ? chunks.join("\n") : chunks.join(" ");
140
+ while (stack.length > 1 && peek().indent >= indent)
141
+ stack.pop();
142
+ peek().node[key] = value;
143
+ continue;
144
+ }
145
+ // List item
146
+ if (line.startsWith("- ") || line === "-") {
147
+ const itemText = line === "-" ? "" : line.slice(2);
148
+ while (stack.length > 1 && peek().indent >= indent)
149
+ stack.pop();
150
+ const parent = peek().node;
151
+ if (!Array.isArray(parent))
152
+ throw new Error(`list item at indent ${indent} without a list parent`);
153
+ if (itemText === "") {
154
+ const m = {};
155
+ parent.push(m);
156
+ stack.push({ indent, node: m });
157
+ }
158
+ else if (/^[^:#]+:\s*(\S.*)?$/.test(itemText) && !itemText.startsWith("[") && !itemText.startsWith("{") && !itemText.startsWith('"') && !itemText.startsWith("'")) {
159
+ // `- key: value` — a map entry starting the list item.
160
+ const m = {};
161
+ parent.push(m);
162
+ stack.push({ indent, node: m });
163
+ // Re-process this same line as a map entry inside the new map.
164
+ const colonAt = itemText.indexOf(":");
165
+ const key = itemText.slice(0, colonAt).trim();
166
+ const rest = itemText.slice(colonAt + 1).trim();
167
+ m[key] = rest === "" ? null : parseScalar(rest);
168
+ // The map stays on the stack even when rest is non-empty: deeper-
169
+ // indented lines that follow (e.g. a `run: |` block scalar) belong
170
+ // to this map. The list-item branch pops it when the next `- `
171
+ // arrives at the same indent.
172
+ }
173
+ else {
174
+ parent.push(parseScalar(itemText));
175
+ }
176
+ i++;
177
+ continue;
178
+ }
179
+ // NOTE: `- key: value` maps are intentionally left on the stack (not
180
+ // popped): deeper-indented lines that follow (e.g. a `run: |` block
181
+ // scalar) belong to that map. The list-item branch pops them when the
182
+ // next `- ` arrives at the same indent.
183
+ // Map entry: `key: value` or `key:`
184
+ const colonAt = line.indexOf(":");
185
+ if (colonAt > 0) {
186
+ const key = line.slice(0, colonAt).trim();
187
+ const rest = line.slice(colonAt + 1).trim();
188
+ while (stack.length > 1 && peek().indent >= indent)
189
+ stack.pop();
190
+ const parent = peek().node;
191
+ if (rest === "") {
192
+ // Could be a nested map or a list; decide by peeking at the next line.
193
+ const next = lines[i + 1];
194
+ if (next && next.indent > indent && (next.text.startsWith("- ") || next.text === "-")) {
195
+ const arr = [];
196
+ parent[key] = arr;
197
+ stack.push({ indent, node: arr });
198
+ }
199
+ else if (next && next.indent > indent) {
200
+ const m = {};
201
+ parent[key] = m;
202
+ stack.push({ indent, node: m });
203
+ }
204
+ else {
205
+ parent[key] = null;
206
+ }
207
+ }
208
+ else {
209
+ parent[key] = parseScalar(rest);
210
+ }
211
+ i++;
212
+ continue;
213
+ }
214
+ throw new Error(`cannot parse workflow line: ${line.slice(0, 80)}`);
215
+ }
216
+ return root;
217
+ }
218
+ function asRecord(n) {
219
+ return n !== null && typeof n === "object" && !Array.isArray(n) ? n : null;
220
+ }
221
+ /** Does this workflow run on pull_request / pull_request_target? */
222
+ export function triggersOnPullRequest(doc) {
223
+ const on = doc["on"] ?? doc["true"]; // never the YAML-1.1 trap here; belt and suspenders
224
+ if (on === undefined || on === null)
225
+ return false;
226
+ if (typeof on === "string")
227
+ return on === "pull_request" || on === "pull_request_target";
228
+ if (Array.isArray(on))
229
+ return on.some((t) => t === "pull_request" || t === "pull_request_target");
230
+ const rec = asRecord(on);
231
+ if (rec)
232
+ return "pull_request" in rec || "pull_request_target" in rec;
233
+ return false;
234
+ }
235
+ function stepsOf(job) {
236
+ const raw = job["steps"];
237
+ if (!Array.isArray(raw))
238
+ return [];
239
+ const out = [];
240
+ for (const s of raw) {
241
+ const r = asRecord(s);
242
+ if (!r)
243
+ continue;
244
+ out.push({
245
+ name: typeof r["name"] === "string" ? r["name"] : typeof r["uses"] === "string" ? r["uses"] : typeof r["run"] === "string" ? r["run"].split("\n")[0].slice(0, 60) : "step",
246
+ uses: typeof r["uses"] === "string" ? r["uses"] : undefined,
247
+ run: typeof r["run"] === "string" ? r["run"] : undefined,
248
+ if: typeof r["if"] === "string" ? r["if"] : undefined,
249
+ workingDirectory: typeof r["working-directory"] === "string" ? r["working-directory"] : undefined,
250
+ });
251
+ }
252
+ return out;
253
+ }
254
+ export function discoverWorkflows(dir) {
255
+ const wfDir = join(dir, ".github", "workflows");
256
+ if (!existsSync(wfDir))
257
+ return [];
258
+ const files = readdirSync(wfDir).filter((f) => f.endsWith(".yml") || f.endsWith(".yaml")).sort();
259
+ const out = [];
260
+ for (const file of files) {
261
+ let doc;
262
+ try {
263
+ doc = parseWorkflowYaml(readFileSync(join(wfDir, file), "utf8"));
264
+ }
265
+ catch (err) {
266
+ out.push({ file, jobs: [], skipReason: `could not parse ${file} (subset YAML parser): ${err instanceof Error ? err.message : String(err)}` });
267
+ continue;
268
+ }
269
+ if (!triggersOnPullRequest(doc)) {
270
+ out.push({ file, jobs: [], skipReason: `not triggered on pull_request` });
271
+ continue;
272
+ }
273
+ const jobsNode = asRecord(doc["jobs"]);
274
+ const jobs = [];
275
+ if (jobsNode) {
276
+ for (const [id, jn] of Object.entries(jobsNode)) {
277
+ const jr = asRecord(jn);
278
+ if (!jr)
279
+ continue;
280
+ const matrixNode = asRecord(jr["strategy"])?.["matrix"];
281
+ jobs.push({
282
+ id,
283
+ name: typeof jr["name"] === "string" ? jr["name"] : id,
284
+ steps: stepsOf(jr),
285
+ matrix: matrixNode !== undefined ? JSON.stringify(matrixNode).slice(0, 120) : undefined,
286
+ });
287
+ }
288
+ }
289
+ out.push({ file, jobs });
290
+ }
291
+ return out;
292
+ }
293
+ // ---------------------------------------------------------------------------
294
+ // Step mapping: workflow step -> local check
295
+ // ---------------------------------------------------------------------------
296
+ const UNRUNNABLE_PATTERNS = [
297
+ { re: /\$\{\{\s*secrets\./, reason: "needs GitHub secrets" },
298
+ { re: /\bsecrets\.[A-Z_]+/, reason: "needs GitHub secrets" },
299
+ { re: /\b(npm|pnpm|yarn|bun)\s+publish\b/, reason: "publishes a package" },
300
+ { re: /\bpublish\s+--access\b/, reason: "publishes a package" },
301
+ { re: /\btwine\s+upload\b/, reason: "publishes a package" },
302
+ { re: /\bcargo\s+publish\b/, reason: "publishes a package" },
303
+ { re: /\bdeploy\b/i, reason: "deploys somewhere" },
304
+ { re: /\bgh\s+release\b/, reason: "creates a GitHub release" },
305
+ { re: /\bdocker\s+(push|login)\b/, reason: "needs a container registry" },
306
+ { re: /\b(aws|gcloud|az|terraform|kubectl)\s+(apply|destroy|deploy|push|login)/, reason: "needs cloud credentials" },
307
+ { re: /\b(ssh|scp)\s+/, reason: "needs remote access" },
308
+ { re: /\bnpm\s+login\b/, reason: "needs registry credentials" },
309
+ { re: /\bgoreleaser\b/, reason: "release automation" },
310
+ ];
311
+ /** Shell commands we are willing to execute locally. */
312
+ const SAFE_RUN_RE = /^(npm|pnpm|yarn|bun)\s+(run\s+)?(test|tests|lint|typecheck|tsc|build|check|checks|format|format:check|verify|ci)\b/;
313
+ const SAFE_BIN_RE = /^(npx|pnpm dlx|yarn dlx|bunx)\s+(tsc|eslint|prettier|vitest|jest)\b/;
314
+ const SAFE_BARE_RE = /^((tsc|eslint)(\s|$)|prettier\s+--(check|list-different)\b)/;
315
+ function splitCommands(script) {
316
+ return script
317
+ .split("\n")
318
+ .flatMap((l) => l.split("&&"))
319
+ .map((s) => s.trim())
320
+ .filter((s) => s.length > 0 && !s.startsWith("#"));
321
+ }
322
+ /**
323
+ * Decide what to do with a `run:` step. Conservative by design: anything
324
+ * that is not clearly a local test/lint/build invocation, or that touches
325
+ * secrets/publishes/deploys, becomes SKIPPED with a named reason.
326
+ */
327
+ export function mapRunStep(script) {
328
+ for (const { re, reason } of UNRUNNABLE_PATTERNS) {
329
+ if (re.test(script))
330
+ return { kind: "skip", reason };
331
+ }
332
+ const cmds = splitCommands(script);
333
+ if (cmds.length === 0)
334
+ return { kind: "skip", reason: "empty run script" };
335
+ const allSafe = cmds.every((c) => SAFE_RUN_RE.test(c) || SAFE_BIN_RE.test(c) || SAFE_BARE_RE.test(c));
336
+ if (allSafe)
337
+ return { kind: "execute", script };
338
+ return { kind: "skip", reason: "no local mapping for this step (conservative)" };
339
+ }
340
+ const USES_SKIP_REASONS = [
341
+ { re: /^actions\/checkout@/, reason: "checkout: the working tree is the checkout" },
342
+ { re: /^actions\/setup-(node|python|go|java|dotnet|ruby|rust)@/, reason: "toolchain setup: assumed present locally" },
343
+ { re: /^actions\/(cache|upload-artifact|download-artifact|setup-nix)@/, reason: "CI plumbing: not applicable locally" },
344
+ ];
345
+ export function mapUsesStep(uses) {
346
+ for (const { re, reason } of USES_SKIP_REASONS) {
347
+ if (re.test(uses))
348
+ return { kind: "skip", reason };
349
+ }
350
+ return { kind: "skip", reason: `action ${uses} cannot run locally` };
351
+ }
352
+ /** Push-only / main-only `if:` conditions make a step irrelevant to PRs. */
353
+ export function isPushOnlyCondition(ifExpr) {
354
+ return /github\.ref\s*==\s*['"]refs\/heads\/(main|master)['"]/.test(ifExpr) && !/pull_request/i.test(ifExpr);
355
+ }
356
+ // ---------------------------------------------------------------------------
357
+ // Local execution
358
+ // ---------------------------------------------------------------------------
359
+ export function detectPackageManager(dir, override) {
360
+ if (override)
361
+ return override;
362
+ if (existsSync(join(dir, "pnpm-lock.yaml")))
363
+ return "pnpm";
364
+ if (existsSync(join(dir, "yarn.lock")))
365
+ return "yarn";
366
+ if (existsSync(join(dir, "bun.lockb")) || existsSync(join(dir, "bun.lock")))
367
+ return "bun";
368
+ if (existsSync(join(dir, "package-lock.json")))
369
+ return "npm";
370
+ if (existsSync(join(dir, "package.json")))
371
+ return "npm";
372
+ return null;
373
+ }
374
+ function execStep(cwd, script, timeoutMs) {
375
+ const r = spawnSync("sh", ["-c", script], {
376
+ cwd,
377
+ encoding: "utf8",
378
+ timeout: timeoutMs,
379
+ maxBuffer: 16 * 1024 * 1024,
380
+ env: { ...process.env, CI: "true" },
381
+ });
382
+ const output = (r.stdout ?? "") + (r.stderr ?? "");
383
+ if (r.error) {
384
+ const err = r.error;
385
+ if (err.killed)
386
+ return { exitCode: -1, output, timedOut: true };
387
+ return { exitCode: -1, output: output || String(err.message), timedOut: false };
388
+ }
389
+ return { exitCode: r.status ?? -1, output, timedOut: false };
390
+ }
391
+ function tailLines(output, n = 30) {
392
+ const lines = output.split("\n");
393
+ const tail = lines.slice(-n);
394
+ return tail.join("\n").slice(0, 4000);
395
+ }
396
+ function git(dir, args) {
397
+ return execFileSync("git", args, { cwd: dir, encoding: "utf8", timeout: 30000 }).trim();
398
+ }
399
+ function isGitRepo(dir) {
400
+ try {
401
+ git(dir, ["rev-parse", "--git-dir"]);
402
+ return true;
403
+ }
404
+ catch {
405
+ return false;
406
+ }
407
+ }
408
+ // ---------------------------------------------------------------------------
409
+ // Convention checks
410
+ // ---------------------------------------------------------------------------
411
+ function readTextIfExists(path) {
412
+ try {
413
+ return existsSync(path) ? readFileSync(path, "utf8") : null;
414
+ }
415
+ catch {
416
+ return null;
417
+ }
418
+ }
419
+ function workflowText(dir) {
420
+ const wfDir = join(dir, ".github", "workflows");
421
+ if (!existsSync(wfDir))
422
+ return "";
423
+ return readdirSync(wfDir)
424
+ .filter((f) => f.endsWith(".yml") || f.endsWith(".yaml"))
425
+ .map((f) => readTextIfExists(join(wfDir, f)) ?? "")
426
+ .join("\n");
427
+ }
428
+ function changedFiles(dir, base) {
429
+ try {
430
+ const out = git(dir, ["diff", "--name-only", `${base}...HEAD`]);
431
+ // Fall back to two-dot when there is no merge base (e.g. shallow trees).
432
+ const list = out.split("\n").map((s) => s.trim()).filter(Boolean);
433
+ return list;
434
+ }
435
+ catch {
436
+ try {
437
+ return git(dir, ["diff", "--name-only", `${base}..HEAD`])
438
+ .split("\n")
439
+ .map((s) => s.trim())
440
+ .filter(Boolean);
441
+ }
442
+ catch {
443
+ return null;
444
+ }
445
+ }
446
+ }
447
+ const SOURCE_FILE_RE = /^(packages\/[^/]+\/src\/|src\/|lib\/|app\/)/;
448
+ const NON_SOURCE_RE = /(\.md$|\.test\.|__tests__|[/\\]test[s]?[/\\]|\.changeset[/\\])/;
449
+ function checkChangesets(dir, base) {
450
+ const name = "convention: changesets";
451
+ const hasDir = existsSync(join(dir, ".changeset"));
452
+ const signaled = hasDir && (/changeset/i.test(workflowText(dir)) || /changeset/i.test(readTextIfExists(join(dir, "CONTRIBUTING.md")) ?? ""));
453
+ if (!hasDir || !signaled)
454
+ return { name, kind: "convention", status: "SKIPPED", detail: "no changeset convention detected" };
455
+ if (!base || !isGitRepo(dir))
456
+ return { name, kind: "convention", status: "SKIPPED", detail: "no base ref to diff against" };
457
+ const files = changedFiles(dir, base);
458
+ if (files === null)
459
+ return { name, kind: "convention", status: "SKIPPED", detail: "could not list changed files" };
460
+ const sourceChanged = files.some((f) => SOURCE_FILE_RE.test(f) && !NON_SOURCE_RE.test(f));
461
+ const changesetAdded = files.some((f) => f.startsWith(".changeset/") && f.endsWith(".md") && !f.endsWith(".changeset/README.md"));
462
+ if (sourceChanged && !changesetAdded) {
463
+ return {
464
+ name,
465
+ kind: "convention",
466
+ status: "FAIL",
467
+ detail: `source files changed without a .changeset/*.md entry (${files.filter((f) => SOURCE_FILE_RE.test(f) && !NON_SOURCE_RE.test(f)).slice(0, 5).join(", ")})`,
468
+ };
469
+ }
470
+ return {
471
+ name,
472
+ kind: "convention",
473
+ status: "PASS",
474
+ detail: changesetAdded ? "changeset entry present" : "no source changes in range",
475
+ };
476
+ }
477
+ const CONVENTIONAL_RE = /^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\([^)]*\))?(!)?: \S/;
478
+ function commitSubjects(dir, base) {
479
+ try {
480
+ const out = git(dir, ["log", "--format=%s", `${base}..HEAD`]);
481
+ return out.split("\n").map((s) => s.trim()).filter(Boolean);
482
+ }
483
+ catch {
484
+ return null;
485
+ }
486
+ }
487
+ function checkCommitFormat(dir, base) {
488
+ const name = "convention: commit format";
489
+ const wf = workflowText(dir);
490
+ const signaled = existsSync(join(dir, "commitlint.config.js")) ||
491
+ existsSync(join(dir, "commitlint.config.cjs")) ||
492
+ existsSync(join(dir, "commitlint.config.mjs")) ||
493
+ existsSync(join(dir, ".commitlintrc")) ||
494
+ existsSync(join(dir, ".commitlintrc.json")) ||
495
+ /commitlint/i.test(wf) ||
496
+ /conventional.?commits/i.test(wf);
497
+ if (!signaled)
498
+ return { name, kind: "convention", status: "SKIPPED", detail: "no commit-format convention detected" };
499
+ if (!base || !isGitRepo(dir))
500
+ return { name, kind: "convention", status: "SKIPPED", detail: "no base ref to list commits" };
501
+ const subjects = commitSubjects(dir, base);
502
+ if (subjects === null)
503
+ return { name, kind: "convention", status: "SKIPPED", detail: "could not list commits" };
504
+ if (subjects.length === 0)
505
+ return { name, kind: "convention", status: "SKIPPED", detail: "no commits between base and HEAD" };
506
+ const bad = subjects.filter((s) => !CONVENTIONAL_RE.test(s));
507
+ if (bad.length > 0) {
508
+ return { name, kind: "convention", status: "FAIL", detail: `${bad.length} non-conventional subject(s): ${bad.slice(0, 3).join(" | ")}` };
509
+ }
510
+ return { name, kind: "convention", status: "PASS", detail: `${subjects.length} conventional subject(s)` };
511
+ }
512
+ function checkDco(dir, base) {
513
+ const name = "convention: DCO sign-off";
514
+ const wf = workflowText(dir);
515
+ const contributing = readTextIfExists(join(dir, "CONTRIBUTING.md")) ?? "";
516
+ const signaled = /dco/i.test(wf) || /sign-?off/i.test(contributing) || /\bDCO\b/.test(contributing);
517
+ if (!signaled)
518
+ return { name, kind: "convention", status: "SKIPPED", detail: "no DCO requirement detected" };
519
+ if (!base || !isGitRepo(dir))
520
+ return { name, kind: "convention", status: "SKIPPED", detail: "no base ref to list commits" };
521
+ let entries;
522
+ try {
523
+ entries = git(dir, ["log", "--format=%H%x00%B%x1e", `${base}..HEAD`]).split("");
524
+ }
525
+ catch {
526
+ return { name, kind: "convention", status: "SKIPPED", detail: "could not list commits" };
527
+ }
528
+ const commits = entries.map((e) => e.split("")[0]).filter(Boolean);
529
+ if (commits.length === 0) {
530
+ return { name, kind: "convention", status: "SKIPPED", detail: "no commits between base and HEAD" };
531
+ }
532
+ const missing = [];
533
+ for (const e of entries) {
534
+ const [sha, body] = e.split("");
535
+ if (!sha)
536
+ continue;
537
+ if (!/^signed-off-by: /im.test(body ?? ""))
538
+ missing.push(sha.slice(0, 7));
539
+ }
540
+ if (missing.length > 0) {
541
+ return { name, kind: "convention", status: "FAIL", detail: `missing Signed-off-by on: ${missing.slice(0, 5).join(", ")}` };
542
+ }
543
+ return { name, kind: "convention", status: "PASS", detail: "all commits carry Signed-off-by" };
544
+ }
545
+ // ---------------------------------------------------------------------------
546
+ // act mode
547
+ // ---------------------------------------------------------------------------
548
+ export function actAvailable() {
549
+ try {
550
+ execFileSync("sh", ["-c", "command -v act"], { encoding: "utf8", timeout: 10000 });
551
+ }
552
+ catch {
553
+ return false;
554
+ }
555
+ try {
556
+ execFileSync("docker", ["info"], { encoding: "utf8", timeout: 15000, stdio: ["ignore", "pipe", "pipe"] });
557
+ return true;
558
+ }
559
+ catch {
560
+ return false;
561
+ }
562
+ }
563
+ function runAct(dir, timeoutMs) {
564
+ const checks = [];
565
+ const r = spawnSync("act", ["pull_request", "--no-cache-server"], {
566
+ cwd: dir,
567
+ encoding: "utf8",
568
+ timeout: timeoutMs,
569
+ maxBuffer: 32 * 1024 * 1024,
570
+ env: { ...process.env, CI: "true" },
571
+ });
572
+ const output = (r.stdout ?? "") + (r.stderr ?? "");
573
+ if (r.error) {
574
+ checks.push({
575
+ name: "act: pull_request",
576
+ kind: "act",
577
+ status: "SKIPPED",
578
+ detail: `act failed to start: ${r.error.message}`,
579
+ outputTail: tailLines(output),
580
+ });
581
+ return checks;
582
+ }
583
+ // act prints lines like: [Build/test] ✅ Success - Main npm test
584
+ // and a final per-job verdict. Parse job-level success/failure.
585
+ const jobStatus = new Map();
586
+ for (const line of output.split("\n")) {
587
+ const m = line.match(/\[(.+?)\]\s+(?:✅|❌|✔|✖|Success|Failure)\s*-\s*(.+)/);
588
+ if (m) {
589
+ const job = m[1].trim();
590
+ const ok = /✅|✔|Success/.test(line);
591
+ jobStatus.set(job, (jobStatus.get(job) ?? true) && ok);
592
+ }
593
+ }
594
+ if (jobStatus.size === 0) {
595
+ checks.push({
596
+ name: "act: pull_request",
597
+ kind: "act",
598
+ status: "SKIPPED",
599
+ detail: `act ran but no job results could be parsed (exit ${r.status ?? "?"})`,
600
+ outputTail: tailLines(output),
601
+ });
602
+ return checks;
603
+ }
604
+ for (const [job, ok] of jobStatus) {
605
+ checks.push({
606
+ name: `act: ${job}`,
607
+ kind: "act",
608
+ status: ok ? "PASS" : "FAIL",
609
+ detail: ok ? "act job succeeded" : "act job failed",
610
+ outputTail: tailLines(output),
611
+ });
612
+ }
613
+ return checks;
614
+ }
615
+ // ---------------------------------------------------------------------------
616
+ // Orchestration
617
+ // ---------------------------------------------------------------------------
618
+ function defaultBase(dir) {
619
+ if (!isGitRepo(dir))
620
+ return null;
621
+ try {
622
+ const upstream = execFileSync("git", ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], {
623
+ cwd: dir,
624
+ encoding: "utf8",
625
+ timeout: 15000,
626
+ }).trim();
627
+ return execFileSync("git", ["merge-base", "HEAD", upstream], { cwd: dir, encoding: "utf8", timeout: 15000 }).trim();
628
+ }
629
+ catch {
630
+ // fall through
631
+ }
632
+ for (const cand of ["origin/main", "origin/master", "main", "master"]) {
633
+ try {
634
+ execFileSync("git", ["rev-parse", "--verify", cand], { cwd: dir, encoding: "utf8", timeout: 15000 });
635
+ return execFileSync("git", ["merge-base", "HEAD", cand], { cwd: dir, encoding: "utf8", timeout: 15000 }).trim();
636
+ }
637
+ catch {
638
+ // try next
639
+ }
640
+ }
641
+ return null;
642
+ }
643
+ export async function runCiPrep(targetDir, opts = {}) {
644
+ const dir = resolve(targetDir);
645
+ const timeoutMs = (opts.timeoutSecs ?? 300) * 1000;
646
+ const checks = [];
647
+ if (!existsSync(dir)) {
648
+ const r = {
649
+ repoDir: dir,
650
+ base: null,
651
+ verdict: "NO-GO",
652
+ actMode: "not-requested",
653
+ checks: [{ name: "discover", kind: "workflow-step", status: "FAIL", detail: `directory does not exist: ${dir}` }],
654
+ markdown: "## CI pre-flight\n\nDirectory does not exist.\n",
655
+ };
656
+ r.markdown = renderCiPrepMarkdown(r);
657
+ return r;
658
+ }
659
+ const base = opts.base ?? defaultBase(dir);
660
+ let actMode = "not-requested";
661
+ if (opts.useAct) {
662
+ const available = opts.actAvailable ?? actAvailable();
663
+ if (available) {
664
+ actMode = "used";
665
+ checks.push(...runAct(dir, Math.max(timeoutMs, 600_000)));
666
+ }
667
+ else {
668
+ actMode = "unavailable";
669
+ checks.push({
670
+ name: "act",
671
+ kind: "act",
672
+ status: "SKIPPED",
673
+ detail: "act and/or a working docker daemon unavailable; fell back to native mode",
674
+ });
675
+ }
676
+ }
677
+ // Native mode always runs (it is the fallback and the complement).
678
+ const workflows = discoverWorkflows(dir);
679
+ if (workflows.length === 0) {
680
+ checks.push({
681
+ name: "discover",
682
+ kind: "workflow-step",
683
+ status: "SKIPPED",
684
+ detail: "no .github/workflows found: nothing to run",
685
+ });
686
+ }
687
+ for (const wf of workflows) {
688
+ if (wf.skipReason) {
689
+ checks.push({ name: `workflow ${wf.file}`, kind: "workflow-step", status: "SKIPPED", detail: wf.skipReason });
690
+ continue;
691
+ }
692
+ if (wf.jobs.length === 0) {
693
+ checks.push({ name: `workflow ${wf.file}`, kind: "workflow-step", status: "SKIPPED", detail: "PR-triggered but no jobs found" });
694
+ continue;
695
+ }
696
+ for (const job of wf.jobs) {
697
+ for (const step of job.steps) {
698
+ const label = `${step.name} (workflow ${wf.file.replace(/\.ya?ml$/, "")}, job ${job.id}${job.matrix ? `, matrix ${job.matrix}` : ""})`;
699
+ if (step.if && isPushOnlyCondition(step.if)) {
700
+ checks.push({ name: label, kind: "workflow-step", status: "SKIPPED", detail: "push-only condition; not a PR check" });
701
+ continue;
702
+ }
703
+ let mapping;
704
+ if (step.run)
705
+ mapping = mapRunStep(step.run);
706
+ else if (step.uses)
707
+ mapping = mapUsesStep(step.uses);
708
+ else
709
+ mapping = { kind: "skip", reason: "neither run nor uses" };
710
+ if (mapping.kind === "skip") {
711
+ checks.push({ name: label, kind: "workflow-step", status: "SKIPPED", detail: mapping.reason });
712
+ continue;
713
+ }
714
+ const cwd = step.workingDirectory ? join(dir, step.workingDirectory) : dir;
715
+ const started = Date.now();
716
+ const outcome = execStep(cwd, mapping.script, timeoutMs);
717
+ const secs = ((Date.now() - started) / 1000).toFixed(1);
718
+ if (outcome.timedOut) {
719
+ checks.push({ name: label, kind: "workflow-step", status: "FAIL", detail: `timed out after ${timeoutMs / 1000}s`, outputTail: tailLines(outcome.output) });
720
+ }
721
+ else if (outcome.exitCode !== 0) {
722
+ checks.push({ name: label, kind: "workflow-step", status: "FAIL", detail: `exit ${outcome.exitCode} in ${secs}s`, outputTail: tailLines(outcome.output) });
723
+ }
724
+ else {
725
+ checks.push({ name: label, kind: "workflow-step", status: "PASS", detail: `exit 0 in ${secs}s`, outputTail: tailLines(outcome.output) });
726
+ }
727
+ }
728
+ }
729
+ }
730
+ // Convention checks run in both modes; they are cheap and high-value.
731
+ checks.push(checkChangesets(dir, base));
732
+ checks.push(checkCommitFormat(dir, base));
733
+ checks.push(checkDco(dir, base));
734
+ const verdict = checks.some((c) => c.status === "FAIL") ? "NO-GO" : "GO";
735
+ const result = { repoDir: dir, base, verdict, actMode, checks, markdown: "" };
736
+ result.markdown = renderCiPrepMarkdown(result);
737
+ return result;
738
+ }
739
+ export function renderCiPrepMarkdown(r) {
740
+ const lines = [`## CI pre-flight: ${r.repoDir}`, ``];
741
+ lines.push(`Base: ${r.base ?? "unknown"} | act mode: ${r.actMode}`);
742
+ lines.push(``);
743
+ if (r.checks.length === 0)
744
+ lines.push(`No checks discovered.`);
745
+ for (const c of r.checks) {
746
+ lines.push(`- **${c.status}** ${c.name} — ${c.detail}`);
747
+ }
748
+ lines.push(``);
749
+ lines.push(r.verdict === "GO" ? `**Verdict: GO** — no failing checks; safe to push.` : `**Verdict: NO-GO** — fix the FAIL items before opening the PR.`);
750
+ return lines.join("\n");
751
+ }
752
+ /** Human-readable console rendering (the --json flag prints the raw result). */
753
+ export function formatCiPrepHuman(r) {
754
+ const lines = [`CI pre-flight for ${r.repoDir} (base ${r.base ?? "unknown"}, act: ${r.actMode})`];
755
+ for (const c of r.checks) {
756
+ lines.push(` [${c.status}] ${c.name}`);
757
+ lines.push(` ${c.detail}`);
758
+ }
759
+ lines.push(r.verdict === "GO" ? `Verdict: GO` : `Verdict: NO-GO`);
760
+ return lines.join("\n");
761
+ }
762
+ /** Ensure the tmp fixture helper can create scratch dirs outside the repo. */
763
+ export function scratchDir(prefix) {
764
+ const d = join(tmpdir(), `${prefix}-${Date.now()}-${Math.floor(Math.random() * 1e6)}`);
765
+ mkdirSync(d, { recursive: true });
766
+ return d;
767
+ }
768
+ //# sourceMappingURL=ci-prep.js.map