forgedock 1.18.0 → 1.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/bin/engine/outcome-codes.mjs +13 -0
  2. package/bin/engine/phases.mjs +54 -4
  3. package/bin/engine/runlog.mjs +10 -2
  4. package/bin/engine/telemetry-seam.mjs +3 -0
  5. package/bin/engine/telemetry.test.mjs +3 -2
  6. package/bin/engine-cli.mjs +32 -2
  7. package/bin/engine.mjs +139 -7
  8. package/bin/runner.mjs +69 -9
  9. package/bin/stats/aggregate.mjs +7 -3
  10. package/bin/stats/cache.mjs +7 -3
  11. package/bin/stats/github-source.mjs +136 -28
  12. package/bin/stats/index.mjs +46 -9
  13. package/bin/stats/render.mjs +1 -1
  14. package/bin/stats/stats.test.mjs +201 -40
  15. package/commands/review-pr-staging.md +4 -4
  16. package/commands/review-pr.md +25 -15
  17. package/commands/work-on/build/architect.md +53 -0
  18. package/commands/work-on/build/implement.md +19 -0
  19. package/commands/work-on/build/validate.md +320 -28
  20. package/commands/work-on/build.md +66 -9
  21. package/commands/work-on/investigate.md +7 -0
  22. package/commands/work-on/remediate.md +32 -13
  23. package/commands/work-on/review.md +32 -19
  24. package/docs/CONFIG.md +89 -2
  25. package/docs/OUTCOME-CODES.md +5 -1
  26. package/docs/STATS.md +11 -4
  27. package/docs/TELEMETRY.md +5 -2
  28. package/docs/WORK-ON-RUNTIME.md +18 -0
  29. package/package.json +2 -2
  30. package/packages/protocol/src/types.js +1 -1
  31. package/scripts/acceptance-red-green.sh +90 -0
  32. package/scripts/acceptance-red-green.test.sh +74 -0
  33. package/scripts/cap-findings.test.sh +2 -0
  34. package/scripts/check-contract-scope.test.sh +2 -0
  35. package/scripts/check-edge-cases.sh +143 -0
  36. package/scripts/check-edge-cases.test.sh +175 -0
  37. package/scripts/check-spec-markers.sh +2 -0
  38. package/scripts/check-test-tmp-cleanup.sh +56 -0
  39. package/scripts/check-test-tmp-cleanup.test.sh +69 -0
  40. package/scripts/ci-local/adapters/azure-pipelines.mjs +113 -0
  41. package/scripts/ci-local/adapters/bitbucket.mjs +64 -0
  42. package/scripts/ci-local/adapters/buildkite.mjs +58 -0
  43. package/scripts/ci-local/adapters/circleci.mjs +101 -0
  44. package/scripts/ci-local/adapters/common.mjs +115 -0
  45. package/scripts/ci-local/adapters/explicit.mjs +21 -0
  46. package/scripts/ci-local/adapters/github-actions.mjs +349 -0
  47. package/scripts/ci-local/adapters/gitlab-ci.mjs +184 -0
  48. package/scripts/ci-local/adapters/portability.mjs +104 -0
  49. package/scripts/ci-local/adapters/task-runners.mjs +67 -0
  50. package/scripts/ci-local/cli.mjs +50 -0
  51. package/scripts/ci-local/lib.mjs +371 -0
  52. package/scripts/ci-local/plan.mjs +186 -0
  53. package/scripts/ci-local/run.mjs +345 -0
  54. package/scripts/ci-local/scoping.mjs +508 -0
  55. package/scripts/ci-local.sh +97 -0
  56. package/scripts/ci-local.test.sh +919 -0
  57. package/scripts/classify-finding.sh +32 -6
  58. package/scripts/classify-finding.test.sh +23 -0
  59. package/scripts/classify-test-run.sh +69 -0
  60. package/scripts/classify-test-run.test.sh +90 -0
  61. package/scripts/close-parent-c3.test.sh +2 -0
  62. package/scripts/consumer-sweep.sh +569 -0
  63. package/scripts/consumer-sweep.test.sh +357 -0
  64. package/scripts/detect-append-registries.sh +106 -0
  65. package/scripts/detect-append-registries.test.sh +64 -0
  66. package/scripts/fixtures/ci-local/azure/azure-pipelines.yml +11 -0
  67. package/scripts/fixtures/ci-local/bitbucket/bitbucket-pipelines.yml +12 -0
  68. package/scripts/fixtures/ci-local/buildkite/.buildkite/pipeline.yml +10 -0
  69. package/scripts/fixtures/ci-local/circleci/.circleci/config.yml +29 -0
  70. package/scripts/fixtures/ci-local/gha-basic/.github/workflows/ci.yml +59 -0
  71. package/scripts/fixtures/ci-local/gha-basic/.github/workflows/docs.yml +10 -0
  72. package/scripts/fixtures/ci-local/gha-basic/.github/workflows/nightly.yml +10 -0
  73. package/scripts/fixtures/ci-local/gha-basic/docs/index.md +1 -0
  74. package/scripts/fixtures/ci-local/gha-basic/src/greet.sh +2 -0
  75. package/scripts/fixtures/ci-local/gha-basic/tests/greet.test.sh +3 -0
  76. package/scripts/fixtures/ci-local/gha-dorny/.github/workflows/ci.yml +51 -0
  77. package/scripts/fixtures/ci-local/gha-dorny/backend/app.py +1 -0
  78. package/scripts/fixtures/ci-local/gha-dorny/docs/a.md +1 -0
  79. package/scripts/fixtures/ci-local/gha-dorny/frontend/app.js +1 -0
  80. package/scripts/fixtures/ci-local/gha-run/.github/workflows/ci.yml +39 -0
  81. package/scripts/fixtures/ci-local/gha-run/src/x.txt +1 -0
  82. package/scripts/fixtures/ci-local/gitlab/.gitlab-ci.yml +43 -0
  83. package/scripts/fixtures/ci-local/gitlab/docs/d.md +1 -0
  84. package/scripts/fixtures/ci-local/gitlab/src/a.txt +1 -0
  85. package/scripts/fixtures/ci-local/monorepo/.github/workflows/ci.yml +13 -0
  86. package/scripts/fixtures/ci-local/monorepo/package-lock.json +1 -0
  87. package/scripts/fixtures/ci-local/monorepo/packages/a/src/a.sh +2 -0
  88. package/scripts/fixtures/ci-local/monorepo/packages/a/tests/a.test.sh +3 -0
  89. package/scripts/fixtures/ci-local/monorepo/packages/b/src/b.sh +2 -0
  90. package/scripts/fixtures/ci-local/monorepo/packages/b/tests/b.test.sh +3 -0
  91. package/scripts/fixtures/ci-local/no-ci/Makefile +6 -0
  92. package/scripts/fixtures/ci-local/no-ci/package.json +1 -0
  93. package/scripts/fixtures/ci-local/no-ci/src/a.js +1 -0
  94. package/scripts/fixtures/ci-local/no-ci/src/b.js +1 -0
  95. package/scripts/fixtures/ci-local/no-ci/tests/a.test.js +2 -0
  96. package/scripts/fixtures/ci-local/no-ci/tests/b.test.js +2 -0
  97. package/scripts/forge-root.test.sh +1 -1
  98. package/scripts/lib/telemetry.sh +1 -0
  99. package/scripts/remediate-approved-head.test.sh +2 -0
  100. package/scripts/remediate-basesync.test.sh +1 -1
  101. package/scripts/review-acceptance-gate.sh +2 -2
  102. package/scripts/review-acceptance-gate.test.sh +20 -0
  103. package/scripts/review-delta.sh +8 -1
  104. package/scripts/review-delta.test.sh +19 -0
  105. package/scripts/review-finalize.sh +193 -41
  106. package/scripts/review-finalize.test.sh +211 -4
  107. package/scripts/review-pr-delta-wiring.test.sh +13 -0
  108. package/scripts/review-prepare.sh +5 -3
  109. package/scripts/review-prepare.test.sh +18 -2
  110. package/scripts/run-shell-tests.sh +5 -0
  111. package/scripts/telemetry.test.sh +2 -0
  112. package/scripts/trusted-comments.test.sh +2 -1
  113. package/scripts/verify-phase-trail.test.sh +8 -0
  114. package/scripts/wait-ci-green.sh +61 -9
  115. package/scripts/wait-ci-green.test.sh +41 -3
  116. package/scripts/with-run-tmpdir.sh +20 -0
@@ -0,0 +1,371 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ //
4
+ // lib.mjs - shared helpers for scripts/ci-local (process, git, yaml, glob, expression, shell tokenizer).
5
+ // No dependencies beyond Node and the `yq` / `git` binaries. Nothing here ever talks to GitHub.
6
+
7
+ import { spawnSync } from 'node:child_process';
8
+ import { createHash } from 'node:crypto';
9
+ import fs from 'node:fs';
10
+ import os from 'node:os';
11
+ import path from 'node:path';
12
+
13
+ export class Unsupported extends Error {}
14
+ export class Degraded extends Error {}
15
+
16
+ export const sha256 = (s) => createHash('sha256').update(s).digest('hex');
17
+
18
+ export function run(cmd, args, opts = {}) {
19
+ const t0 = process.env.FORGE_CI_LOCAL_DEBUG ? Date.now() : 0;
20
+ const r = spawnSync(cmd, args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, timeout: opts.timeout || 60000, cwd: opts.cwd, input: opts.input, env: opts.env || process.env });
21
+ if (t0 && Date.now() - t0 > 40) process.stderr.write(`[ci-local debug] ${Date.now() - t0}ms ${cmd} ${args.slice(0, 6).join(' ').slice(0, 100)}\n`);
22
+ return { code: r.status === null ? 124 : r.status, out: r.stdout || '', err: r.stderr || '' };
23
+ }
24
+
25
+ export const git = (repo, args, opts = {}) => run('git', ['-C', repo, ...args], opts);
26
+
27
+ export function commandExists(name) {
28
+ const r = run('sh', ['-c', 'command -v "$1" >/dev/null 2>&1', 'sh', name], { timeout: 5000 });
29
+ return r.code === 0;
30
+ }
31
+
32
+ export function toolVersion(name) {
33
+ if (!/^[A-Za-z0-9._+-]+$/.test(name) || !commandExists(name)) return '';
34
+ const r = run(name, ['--version'], { timeout: 5000 });
35
+ return (r.out || r.err).split('\n')[0].slice(0, 120);
36
+ }
37
+
38
+ export function hostClass() {
39
+ return { linux: 'linux', darwin: 'darwin', win32: 'win32' }[process.platform] || process.platform;
40
+ }
41
+
42
+ // ---- YAML via yq (mikefarah v4 first, kislyuk-style fallback) ----
43
+ let yqMode = null;
44
+ export function yqAvailable() {
45
+ if (yqMode !== null) return yqMode !== '';
46
+ if (process.env.FORGE_CI_LOCAL_NO_YQ === '1') { yqMode = ''; return false; }
47
+ if (!commandExists('yq')) { yqMode = ''; return false; }
48
+ const ok = run('yq', ['-o=json', '.', '-'], { input: 'a: 1\n', timeout: 10000 });
49
+ if (ok.code === 0 && /"a":\s*1/.test(ok.out)) { yqMode = 'mikefarah'; return true; }
50
+ const alt = run('yq', ['.', '-'], { input: 'a: 1\n', timeout: 10000 });
51
+ if (alt.code === 0 && /"a":\s*1/.test(alt.out)) { yqMode = 'kislyuk'; return true; }
52
+ yqMode = ''; return false;
53
+ }
54
+
55
+ export function parseYaml(text) {
56
+ if (!yqAvailable()) throw new Degraded('yq not found (mikefarah yq v4 is required to read CI definitions)');
57
+ const args = yqMode === 'mikefarah' ? ['-o=json', '-I=0', '.', '-'] : ['.', '-'];
58
+ const r = run('yq', args, { input: text, timeout: 30000 });
59
+ if (r.code !== 0) throw new Error('yaml parse error: ' + r.err.split('\n')[0]);
60
+ // Multi-document streams print one JSON value per document; keep the first document.
61
+ const first = r.out.trim();
62
+ if (first === '') return null;
63
+ try { return JSON.parse(first); } catch (e) {
64
+ const docs = []; let depth = 0, start = 0, inStr = false, esc = false;
65
+ for (let i = 0; i < first.length; i++) {
66
+ const c = first[i];
67
+ if (inStr) { if (esc) esc = false; else if (c === '\\') esc = true; else if (c === '"') inStr = false; continue; }
68
+ if (c === '"') inStr = true; else if (c === '{' || c === '[') { if (depth === 0) start = i; depth++; } else if (c === '}' || c === ']') { depth--; if (depth === 0) docs.push(first.slice(start, i + 1)); }
69
+ }
70
+ if (!docs.length) throw new Error('yaml parse error: unreadable output');
71
+ return JSON.parse(docs[0]);
72
+ }
73
+ }
74
+
75
+ export const safeParseYaml = (text) => { try { return parseYaml(text); } catch (e) { if (e instanceof Degraded) throw e; return undefined; } };
76
+
77
+ // ---- repo state ----
78
+ export function resolveRef(repo, ref) {
79
+ const r = git(repo, ['rev-parse', '--verify', '--quiet', ref + '^{commit}']);
80
+ return r.code === 0 ? r.out.trim() : '';
81
+ }
82
+
83
+ export function readAtRef(repo, ref, file) {
84
+ const r = git(repo, ['show', `${ref}:${file}`]);
85
+ return r.code === 0 ? r.out : null;
86
+ }
87
+
88
+ export function listAtRef(repo, ref) {
89
+ const r = git(repo, ['ls-tree', '-r', '--name-only', ref]);
90
+ return r.code === 0 ? r.out.split('\n').filter(Boolean) : [];
91
+ }
92
+
93
+ export function trackedFiles(repo) {
94
+ const r = git(repo, ['ls-files', '-z', '--cached', '--others', '--exclude-standard']);
95
+ return r.out.split('\0').filter(Boolean);
96
+ }
97
+
98
+ // Files this change touches: working tree (staged + unstaged) vs the merge base, plus untracked.
99
+ export function changedFiles(repo, baseRef) {
100
+ const set = new Set();
101
+ const mb = baseRef ? git(repo, ['merge-base', baseRef, 'HEAD']) : { code: 1, out: '' };
102
+ const from = mb.code === 0 ? mb.out.trim() : (baseRef && resolveRef(repo, baseRef)) || 'HEAD';
103
+ for (const args of [['diff', '--name-only', '--no-renames', from], ['ls-files', '--others', '--exclude-standard']]) {
104
+ for (const f of git(repo, args).out.split('\n')) if (f) set.add(f);
105
+ }
106
+ return { files: [...set].sort(), from };
107
+ }
108
+
109
+ export function headSha(repo) { return git(repo, ['rev-parse', 'HEAD']).out.trim(); }
110
+
111
+ // A fingerprint of the exact tree state (index + unstaged diff + untracked contents).
112
+ export function treeFingerprint(repo) {
113
+ const h = createHash('sha256');
114
+ h.update(git(repo, ['ls-files', '-s']).out);
115
+ h.update(git(repo, ['diff', '--no-ext-diff', '--binary']).out);
116
+ for (const f of git(repo, ['ls-files', '--others', '--exclude-standard', '-z']).out.split('\0').filter(Boolean)) {
117
+ h.update(f); try { h.update(fs.readFileSync(path.join(repo, f))); } catch { /* vanished */ }
118
+ }
119
+ return h.digest('hex');
120
+ }
121
+
122
+ export function fileHash(repo, f) {
123
+ try { return sha256(fs.readFileSync(path.join(repo, f))); } catch { return 'missing'; }
124
+ }
125
+
126
+ // ---- globs (GitHub path-filter semantics: * stops at /, ** crosses it) ----
127
+ export function globToRegex(glob) {
128
+ let re = '';
129
+ for (let i = 0; i < glob.length; i++) {
130
+ const c = glob[i];
131
+ if (c === '*') {
132
+ if (glob[i + 1] === '*') {
133
+ i++;
134
+ if (glob[i + 1] === '/') { i++; re += '(?:.*/)?'; } else re += '.*';
135
+ } else re += '[^/]*';
136
+ } else if (c === '?') re += '[^/]';
137
+ else if (c === '[') {
138
+ const j = glob.indexOf(']', i + 1);
139
+ if (j > i + 1) { re += '[' + glob.slice(i + 1, j).replace(/^!/, '^').replace(/\\/g, '\\\\') + ']'; i = j; } else re += '\\[';
140
+ } else re += c.replace(/[.+^${}()|\\]/g, '\\$&');
141
+ }
142
+ return new RegExp('^' + re + '$');
143
+ }
144
+
145
+ // Ordered include/exclude list ('!' prefix excludes): the last matching pattern decides.
146
+ export function matchesFilter(file, patterns) {
147
+ let hit = false;
148
+ for (const p of patterns) {
149
+ if (typeof p !== 'string') continue;
150
+ const neg = p.startsWith('!');
151
+ const re = globToRegex((neg ? p.slice(1) : p).replace(/^\.?\//, ''));
152
+ if (re.test(file)) hit = !neg;
153
+ }
154
+ return hit;
155
+ }
156
+
157
+ export function branchMatches(branch, patterns) {
158
+ let hit = false;
159
+ for (const p of patterns) {
160
+ if (typeof p !== 'string') continue;
161
+ const neg = p.startsWith('!');
162
+ if (globToRegex(neg ? p.slice(1) : p).test(branch)) hit = !neg;
163
+ }
164
+ return hit;
165
+ }
166
+
167
+ // ---- GitHub-style expressions ----
168
+ function truthy(v) { return !(v === false || v === 0 || v === '' || v === null || v === undefined || Number.isNaN(v)); }
169
+ function num(v) { if (v === null || v === undefined) return 0; if (typeof v === 'boolean') return v ? 1 : 0; if (typeof v === 'number') return v; if (v === '') return 0; const n = Number(v); return n; }
170
+ function looseEq(a, b) {
171
+ if (typeof a === 'string' && typeof b === 'string') return a.toLowerCase() === b.toLowerCase();
172
+ if ((a === null || a === undefined) && (b === null || b === undefined)) return true;
173
+ if (typeof a === typeof b) return a === b;
174
+ return num(a) === num(b);
175
+ }
176
+ export const str = (v) => (v === null || v === undefined ? '' : typeof v === 'object' ? JSON.stringify(v) : String(v));
177
+
178
+ function tokenize(src) {
179
+ const toks = []; let i = 0;
180
+ while (i < src.length) {
181
+ const c = src[i];
182
+ if (/\s/.test(c)) { i++; continue; }
183
+ if (c === "'") { let j = i + 1, s = ''; while (j < src.length) { if (src[j] === "'") { if (src[j + 1] === "'") { s += "'"; j += 2; continue; } break; } s += src[j++]; } if (j >= src.length) throw new Unsupported('unterminated string'); toks.push({ t: 'str', v: s }); i = j + 1; continue; }
184
+ if (/[0-9]/.test(c)) { const m = /^-?[0-9]+(\.[0-9]+)?/.exec(src.slice(i)); toks.push({ t: 'num', v: Number(m[0]) }); i += m[0].length; continue; }
185
+ if (/[A-Za-z_]/.test(c)) { const m = /^[A-Za-z_][A-Za-z0-9_-]*/.exec(src.slice(i)); toks.push({ t: 'id', v: m[0] }); i += m[0].length; continue; }
186
+ const two = src.slice(i, i + 2);
187
+ if (['==', '!=', '<=', '>=', '&&', '||'].includes(two)) { toks.push({ t: 'op', v: two }); i += 2; continue; }
188
+ if ('!<>().,[]*'.includes(c)) { toks.push({ t: 'op', v: c }); i++; continue; }
189
+ throw new Unsupported('unexpected character ' + c);
190
+ }
191
+ return toks;
192
+ }
193
+
194
+ const FUNCS = {
195
+ contains: (a, b) => (Array.isArray(a) ? a.some((x) => looseEq(x, b)) : str(a).toLowerCase().includes(str(b).toLowerCase())),
196
+ startsWith: (a, b) => str(a).toLowerCase().startsWith(str(b).toLowerCase()),
197
+ endsWith: (a, b) => str(a).toLowerCase().endsWith(str(b).toLowerCase()),
198
+ format: (f, ...a) => str(f).replace(/\{(\d+)\}/g, (_, n) => str(a[Number(n)])).replace(/\{\{/g, '{').replace(/\}\}/g, '}'),
199
+ join: (a, sep = ',') => (Array.isArray(a) ? a.map(str).join(str(sep)) : str(a)),
200
+ toJSON: (v) => JSON.stringify(v),
201
+ success: () => true, always: () => true, failure: () => false, cancelled: () => false,
202
+ };
203
+
204
+ // Safe built-in names the evaluator may read from a context; anything else is Unsupported (never guessed).
205
+ const GITHUB_KEYS = new Set(['event_name', 'base_ref', 'head_ref', 'ref', 'ref_name', 'ref_type', 'sha', 'repository', 'workspace', 'event', 'server_url', 'repository_owner']);
206
+
207
+ const EVENT_PATHS = new Set(['pull_request', 'pull_request.base', 'pull_request.base.sha', 'pull_request.base.ref', 'pull_request.head', 'pull_request.head.sha', 'pull_request.head.ref', 'pull_request.draft', 'pull_request.labels', 'pull_request.labels.*.name', 'pull_request.head.repo', 'pull_request.head.repo.fork', 'pull_request.head.repo.full_name', 'pull_request.base.repo', 'pull_request.base.repo.full_name', 'before']);
208
+
209
+ export function evalExpr(src, ctx) {
210
+ const toks = tokenize(src); let p = 0;
211
+ const peek = () => toks[p]; const eat = (v) => { const t = toks[p]; if (t && t.t === 'op' && t.v === v) { p++; return true; } return false; };
212
+ function primary() {
213
+ const t = toks[p++];
214
+ if (!t) throw new Unsupported('unexpected end');
215
+ if (t.t === 'str' || t.t === 'num') return t.v;
216
+ if (t.t === 'op' && t.v === '(') { const v = or(); if (!eat(')')) throw new Unsupported('missing )'); return v; }
217
+ if (t.t === 'op' && t.v === '!') return !truthy(primary());
218
+ if (t.t === 'id') {
219
+ if (t.v === 'true') return true; if (t.v === 'false') return false; if (t.v === 'null') return null;
220
+ if (eat('(')) {
221
+ const args = []; if (!eat(')')) { do { args.push(or()); } while (eat(',')); if (!eat(')')) throw new Unsupported('missing )'); }
222
+ const fn = FUNCS[t.v];
223
+ if (!fn) throw new Unsupported('function ' + t.v + '()');
224
+ return fn(...args);
225
+ }
226
+ const segs = [t.v];
227
+ for (;;) {
228
+ if (eat('.')) { const n = toks[p++]; if (!n || n.t === 'op' && n.v !== '*') throw new Unsupported('bad member access'); if (n.t === 'op') { segs.push('*'); continue; } segs.push(String(n.v)); }
229
+ else if (peek() && peek().t === 'op' && peek().v === '[') { p++; const k = or(); if (!eat(']')) throw new Unsupported('missing ]'); segs.push(str(k)); }
230
+ else break;
231
+ }
232
+ return lookup(segs, ctx);
233
+ }
234
+ throw new Unsupported('unexpected token');
235
+ }
236
+ function cmp() { let l = primary(); while (peek() && peek().t === 'op' && ['<', '>', '<=', '>='].includes(peek().v)) { const o = toks[p++].v; const r = primary(); const a = num(l), b = num(r); l = o === '<' ? a < b : o === '>' ? a > b : o === '<=' ? a <= b : a >= b; } return l; }
237
+ function eq() { let l = cmp(); while (peek() && peek().t === 'op' && (peek().v === '==' || peek().v === '!=')) { const o = toks[p++].v; const r = cmp(); l = o === '==' ? looseEq(l, r) : !looseEq(l, r); } return l; }
238
+ function and() { let l = eq(); while (peek() && peek().t === 'op' && peek().v === '&&') { p++; const r = eq(); l = truthy(l) ? r : l; } return l; }
239
+ function or() { let l = and(); while (peek() && peek().t === 'op' && peek().v === '||') { p++; const r = and(); l = truthy(l) ? l : r; } return l; }
240
+ const v = or();
241
+ if (p < toks.length) throw new Unsupported('trailing tokens');
242
+ return v;
243
+ }
244
+
245
+ function lookup(segs, ctx) {
246
+ const root = segs[0];
247
+ if (ctx.__roots && root in ctx.__roots) {
248
+ let cur = ctx.__roots[root];
249
+ for (let i = 1; i < segs.length; i++) { if (cur === null || typeof cur !== 'object' || !(segs[i] in cur)) throw new Unsupported(segs.slice(0, i + 1).join('.')); cur = cur[segs[i]]; }
250
+ return cur === undefined ? null : cur;
251
+ }
252
+ if (root === 'github') {
253
+ if (!GITHUB_KEYS.has(segs[1])) throw new Unsupported('github.' + segs[1]);
254
+ if (segs[1] === 'event' && !EVENT_PATHS.has(segs.slice(2).join('.'))) throw new Unsupported('github.event.' + segs.slice(2).join('.'));
255
+ if (segs[1] === 'repository' && !ctx.github.repository) throw new Unsupported('github.repository');
256
+ } else if (root === 'runner') {
257
+ if (!['os', 'arch', 'temp'].includes(segs[1])) throw new Unsupported('runner.' + segs[1]);
258
+ } else if (root === 'matrix' || root === 'env' || root === 'needs' || root === 'steps') {
259
+ if (ctx[root] === undefined) throw new Unsupported(root + ' context');
260
+ } else throw new Unsupported(root + ' context');
261
+ let cur = ctx[root];
262
+ for (let i = 1; i < segs.length; i++) {
263
+ if (segs[i] === '*') {
264
+ if (cur === null || typeof cur !== 'object') return [];
265
+ const rest = segs.slice(i + 1); const items = Array.isArray(cur) ? cur : Object.values(cur);
266
+ return items.map((el) => { let c = el; for (const k of rest) { if (c === null || typeof c !== 'object') return null; c = c[k]; } return c === undefined ? null : c; });
267
+ }
268
+ if (cur === null || cur === undefined || typeof cur !== 'object') return null;
269
+ if (root === 'steps' && i === 1 && !(segs[i] in cur)) throw new Unsupported('steps.' + segs[i] + ' (not resolved locally)');
270
+ if (root === 'steps' && i === 2 && segs[2] === 'outputs' && !('outputs' in cur)) throw new Unsupported('step outputs');
271
+ if (root === 'needs' && i === 1 && !(segs[i] in cur)) throw new Unsupported('needs.' + segs[i]);
272
+ if (root === 'needs' && i === 2 && !['outputs', 'result'].includes(segs[2])) throw new Unsupported('needs.' + segs[1] + '.' + segs[2]);
273
+ if (root === 'needs' && i === 3 && !(segs[3] in cur)) throw new Unsupported('needs output ' + segs[3]);
274
+ if (root === 'steps' && i === 3 && !(segs[3] in cur)) throw new Unsupported('steps output ' + segs[3]);
275
+ cur = cur[segs[i]];
276
+ }
277
+ return cur === undefined ? null : cur;
278
+ }
279
+
280
+ // Replace every ${{ expr }} in a string. A string that is exactly one expression keeps the raw value type.
281
+ export function interpolate(text, ctx) {
282
+ if (typeof text !== 'string' || !text.includes('${{')) return text;
283
+ const only = /^\s*\$\{\{([\s\S]*?)\}\}\s*$/.exec(text);
284
+ if (only && !only[1].includes('}}')) return evalExpr(only[1], ctx);
285
+ return text.replace(/\$\{\{([\s\S]*?)\}\}/g, (_, e) => str(evalExpr(e, ctx)));
286
+ }
287
+
288
+ export function condTrue(cond, ctx) {
289
+ if (cond === undefined || cond === null || cond === true) return true;
290
+ if (cond === false) return false;
291
+ let c = String(cond).trim();
292
+ const m = /^\$\{\{([\s\S]*)\}\}$/.exec(c);
293
+ if (m) c = m[1].trim();
294
+ return truthy(evalExpr(c, ctx));
295
+ }
296
+
297
+ // ---- shell tokenizer: returns tokens for a single simple command, or null when the line is not simple ----
298
+ export function shellSplit(cmd) {
299
+ const toks = []; let cur = null, i = 0;
300
+ const push = () => { if (cur !== null) { toks.push(cur); cur = null; } };
301
+ while (i < cmd.length) {
302
+ const c = cmd[i];
303
+ if (c === '\\') { if (i + 1 >= cmd.length) return null; if (cmd[i + 1] === '\n') { i += 2; continue; } cur = (cur || '') + cmd[i + 1]; i += 2; continue; }
304
+ if (c === "'") { const j = cmd.indexOf("'", i + 1); if (j < 0) return null; cur = (cur || '') + cmd.slice(i + 1, j); i = j + 1; continue; }
305
+ if (c === '"') { let j = i + 1, s = ''; while (j < cmd.length && cmd[j] !== '"') { if (cmd[j] === '\\' && '"\\$`'.includes(cmd[j + 1] || '')) { s += cmd[j + 1]; j += 2; continue; } if (cmd[j] === '$' || cmd[j] === '`') return null; s += cmd[j++]; } if (j >= cmd.length) return null; cur = (cur || '') + s; i = j + 1; continue; }
306
+ if (/\s/.test(c)) { push(); i++; continue; }
307
+ if ('|&;<>(){}`$'.includes(c) || (c === '#' && cur === null)) return null;
308
+ cur = (cur || '') + c; i++;
309
+ }
310
+ push();
311
+ return toks;
312
+ }
313
+
314
+ export function shellQuote(s) {
315
+ return /^[A-Za-z0-9_@%+=:,./-]+$/.test(s) ? s : "'" + s.replace(/'/g, "'\\''") + "'";
316
+ }
317
+
318
+ // ---- environment hygiene: CI jobs run in a runner-like environment ----
319
+ // An allowlist, not a blacklist: remote CI starts from a clean environment, so ambient operator state (agent
320
+ // credentials, install-root overrides, git hook variables such as GIT_DIR/GIT_INDEX_FILE, FORGE_*/CLAUDE_* settings)
321
+ // must not reach the jobs. Toolchain variables are passed through; credential-looking names are always dropped.
322
+ const ENV_EXACT = new Set(['PATH', 'HOME', 'USER', 'LOGNAME', 'SHELL', 'LANG', 'LANGUAGE', 'TERM', 'TMPDIR', 'TMP', 'TEMP', 'TZ', 'COLORTERM', 'NO_COLOR', 'SSL_CERT_FILE', 'SSL_CERT_DIR', 'NODE_EXTRA_CA_CERTS', 'REQUESTS_CA_BUNDLE', 'CURL_CA_BUNDLE', 'HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY', 'http_proxy', 'https_proxy', 'no_proxy', 'DOCKER_HOST', 'JAVA_HOME', 'GOPATH', 'GOROOT', 'GOFLAGS', 'GOCACHE', 'GOMODCACHE', 'GOPROXY', 'GOTOOLCHAIN', 'VIRTUAL_ENV', 'PYTHONPATH', 'PYTHONDONTWRITEBYTECODE', 'RUSTFLAGS', 'RUSTUP_HOME', 'SDKMAN_DIR', 'PNPM_HOME', 'BUN_INSTALL', 'NVM_DIR', 'NVM_BIN', 'NVM_INC', 'RBENV_ROOT', 'PYENV_ROOT', 'ASDF_DIR', 'ASDF_DATA_DIR', 'MISE_DATA_DIR', 'HOMEBREW_PREFIX', 'ComSpec', 'SystemRoot', 'USERPROFILE', 'APPDATA', 'LOCALAPPDATA', 'PROGRAMFILES', 'MSYSTEM']);
323
+ const ENV_PREFIX = ['LC_', 'XDG_', 'CARGO_', 'MAVEN_', 'M2_', 'GRADLE_', 'DOTNET_', 'npm_config_', 'NPM_CONFIG_', 'YARN_', 'UV_', 'POETRY_', 'PIP_CACHE', 'FORGE_CI_LOCAL_'];
324
+ // Substring tokens are unambiguous; short ones (PASS, PAT, KEY, IDENT, CRED, SESSION, COOKIE) match only as whole
325
+ // name segments (or a trailing _KEY) so benign names such as FORGE_CI_LOCAL_PASSTHROUGH or PATH are kept.
326
+ export const SECRET_NAME = /(TOKEN|SECRET|PASSWORD|PASSWD|AUTH|CREDENTIAL|PRIVATE|API_?KEY|ACCESS_?KEY|SESSION|COOKIE)|(^|[^A-Za-z0-9])(PASS|PAT|IDENT|CRED|CREDS|KEY)([^A-Za-z0-9]|$)|_KEY$/i;
327
+ // URL-embedded credentials (scheme://user:pass@host) in proxy/index/registry variables: the userinfo is stripped, the
328
+ // rest of the value is kept so the job still reaches the same host.
329
+ const URL_USERINFO = /([A-Za-z][A-Za-z0-9+.-]*:\/\/)[^\/?#\s@]*@/g;
330
+ export function stripUserinfo(v) { return typeof v === 'string' ? v.replace(URL_USERINFO, '$1') : v; }
331
+ export function scrubbedEnv(extra = {}) {
332
+ const pass = new Set((process.env.FORGE_CI_LOCAL_PASSTHROUGH || '').split(',').map((x) => x.trim()).filter(Boolean));
333
+ const env = {};
334
+ for (const [k, v] of Object.entries(process.env)) {
335
+ if (SECRET_NAME.test(k)) continue;
336
+ if (ENV_EXACT.has(k) || pass.has(k) || ENV_PREFIX.some((p) => k.startsWith(p))) env[k] = stripUserinfo(v);
337
+ }
338
+ env.CI = 'true'; env.FORGE_CI_LOCAL = '1'; env.FORGE_TELEMETRY = 'off';
339
+ return Object.assign(env, extra);
340
+ }
341
+
342
+ export function homeForgeDir() {
343
+ return process.env.FORGE_CI_LOCAL_HOME || path.join(process.env.HOME || os.homedir(), '.forge');
344
+ }
345
+
346
+ export function majorOf(v) { const m = /(\d+)/.exec(String(v)); return m ? Number(m[1]) : null; }
347
+
348
+ export function dedupe(a) { return [...new Set(a)]; }
349
+
350
+ export function jobId(...parts) { return parts.filter((x) => x !== '' && x !== undefined && x !== null).join(':').replace(/[^A-Za-z0-9_.:@/-]+/g, '-'); }
351
+
352
+ // "Deferred" and "skipped" shapes shared by every adapter.
353
+ export function deferred(id, source, name, reason, extra = {}) { return { id, source, name, deferred_reason: reason, steps: [], services: [], env: {}, cwd: '.', ...extra }; }
354
+ export function notApplicable(id, source, name, reason) { return { id, source, name, not_applicable: reason, steps: [], services: [], env: {}, cwd: '.' }; }
355
+
356
+ export const SECRET_REF = /(secrets\.|\$\{?(?:\{\{ *)?secrets|CI_JOB_TOKEN|\bsecrets:)/;
357
+ export const DEPLOY_NAME = /(^|[^a-z])(deploy|deployment|publish|release|promote|rollout)([^a-z]|$)/i;
358
+
359
+ // A $VAR / ${VAR} reference whose name looks like a credential and is not defined in the CI file itself.
360
+ export function secretLikeVar(text, defined = new Set()) {
361
+ const re = /\$\{?([A-Za-z][A-Za-z0-9_]*(?:TOKEN|PASSWORD|PASSWD|SECRET|API_?KEY|PRIVATE_?KEY|CREDENTIALS?))\}?/g;
362
+ let m; while ((m = re.exec(text))) if (!defined.has(m[1])) return m[1];
363
+ return '';
364
+ }
365
+
366
+ export function parseDuration(v) {
367
+ if (typeof v === 'number') return v * 60;
368
+ const m = /^(?:(\d+)\s*h(?:ours?)?)?\s*(?:(\d+)\s*m(?:in(?:utes?)?)?)?\s*(?:(\d+)\s*s(?:ec(?:onds?)?)?)?$/i.exec(String(v || '').trim());
369
+ if (!m || (!m[1] && !m[2] && !m[3])) return undefined;
370
+ return (Number(m[1] || 0) * 3600) + (Number(m[2] || 0) * 60) + Number(m[3] || 0);
371
+ }
@@ -0,0 +1,186 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ //
4
+ // plan.mjs - derive the local check set for a change. Reads CI definitions from the trusted base ref plus the PR's own
5
+ // edits, runs every source adapter, applies exact scoping, and caches the plan keyed by the hash of the CI definitions
6
+ // plus everything the plan is derived from. Never talks to GitHub; never triggers a remote run.
7
+
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ import {
11
+ Degraded, git, run, resolveRef, fileHash, readAtRef, listAtRef, changedFiles, headSha, sha256, hostClass, homeForgeDir, parseYaml, globToRegex,
12
+ } from './lib.mjs';
13
+ import { makeScopeCtx, scopeCommand } from './scoping.mjs';
14
+ import { missingTool } from './adapters/common.mjs';
15
+ import * as gha from './adapters/github-actions.mjs';
16
+ import * as gitlab from './adapters/gitlab-ci.mjs';
17
+ import * as circle from './adapters/circleci.mjs';
18
+ import * as azure from './adapters/azure-pipelines.mjs';
19
+ import * as bitbucket from './adapters/bitbucket.mjs';
20
+ import * as buildkite from './adapters/buildkite.mjs';
21
+ import * as tasks from './adapters/task-runners.mjs';
22
+ import * as explicit from './adapters/explicit.mjs';
23
+ import * as portability from './adapters/portability.mjs';
24
+
25
+ export const PLAN_VERSION = 1;
26
+ const CI_ADAPTERS = [gha, gitlab, circle, azure, bitbucket, buildkite];
27
+
28
+ // forge#4040 (SEC-2): the gate config (exclude_jobs, include_jobs, extra_commands, mode, ...) is read from a TRUSTED copy
29
+ // only, never from the PR's own tree: a PR that could edit it could exclude every job and pass with jobs_run=0. Trusted
30
+ // copies, in order: the main checkout's forge.yaml (a worktree's git common dir points at it); for a plain checkout, the
31
+ // base ref's tracked copy, or an untracked/ignored working-tree copy (not part of the PR). A tracked forge.yaml in the
32
+ // worktree is never read.
33
+ function loadConfig(repo, baseRef, baseSha) {
34
+ let docText = ''; let file = '';
35
+ const gc = git(repo, ['rev-parse', '--git-common-dir']).out.trim();
36
+ const top = fs.realpathSync(path.resolve(git(repo, ['rev-parse', '--show-toplevel']).out.trim() || repo));
37
+ let main = '';
38
+ if (gc) { const abs = path.resolve(repo, gc); if (path.basename(abs) === '.git') main = fs.realpathSync(path.dirname(abs)); }
39
+ if (main && main !== top && fs.existsSync(path.join(main, 'forge.yaml'))) { file = path.join(main, 'forge.yaml'); docText = fs.readFileSync(file, 'utf8'); }
40
+ else if (!main || main === top) {
41
+ const tracked = git(repo, ['ls-files', '--error-unmatch', '--', 'forge.yaml']).code === 0;
42
+ if (tracked) { const t = baseSha ? readAtRef(repo, baseRef, 'forge.yaml') : null; if (t) { file = `${baseRef}:forge.yaml`; docText = t; } }
43
+ else if (fs.existsSync(path.join(repo, 'forge.yaml'))) { file = path.join(repo, 'forge.yaml'); docText = fs.readFileSync(file, 'utf8'); }
44
+ }
45
+ if (!docText) return { file: '', exclude_jobs: [], include_jobs: [], extra_commands: [], services: 'auto', mode: 'advisory' };
46
+ let doc; try { doc = parseYaml(docText); } catch (e) { if (e instanceof Degraded) throw e; doc = null; }
47
+ const c = (doc && doc.verification && doc.verification.ci_local) || {};
48
+ return { file, exclude_jobs: [].concat(c.exclude_jobs || []), include_jobs: [].concat(c.include_jobs || []), extra_commands: [].concat(c.extra_commands || []), services: c.services === 'off' ? 'off' : 'auto', mode: c.mode === 'blocking' ? 'blocking' : 'advisory', budget_minutes: Number(c.budget_minutes) > 0 ? Number(c.budget_minutes) : undefined, max_parallel: Number(c.max_parallel) > 0 ? Number(c.max_parallel) : undefined };
49
+ }
50
+
51
+ function repoSlug(repo) {
52
+ if (process.env.GITHUB_REPOSITORY) return process.env.GITHUB_REPOSITORY;
53
+ const u = git(repo, ['remote', 'get-url', 'origin']).out.trim();
54
+ const m = /[:/]([^/:]+\/[^/]+?)(?:\.git)?$/.exec(u);
55
+ return m ? m[1] : '';
56
+ }
57
+
58
+ export function buildPlan(opts) {
59
+ const repo = path.resolve(opts.repo || '.');
60
+ const top = git(repo, ['rev-parse', '--show-toplevel']);
61
+ if (top.code !== 0) throw new Error(`not a git repository: ${repo}`);
62
+ const warnings = [];
63
+ const baseRef = opts.base || '';
64
+ let baseSha = baseRef ? resolveRef(repo, baseRef) : '';
65
+ if (baseRef && !baseSha) warnings.push(`base ref ${baseRef} did not resolve; CI definitions are read from the working tree (not the trusted base) and only uncommitted changes count as changed`);
66
+ const baseBranch = (opts.baseBranch || baseRef.replace(/^(refs\/(remotes\/)?)?origin\//, '').replace(/^refs\/heads\//, '')) || 'main';
67
+ const headBranch = opts.headBranch || git(repo, ['branch', '--show-current']).out.trim();
68
+ let changed; let from;
69
+ if (opts.files) { changed = opts.files.slice().sort(); from = baseSha || 'HEAD'; } else { const c = changedFiles(repo, baseSha ? baseRef : ''); changed = c.files; from = c.from; }
70
+ const config = loadConfig(repo, baseRef, baseSha);
71
+ const head = headSha(repo);
72
+ const tracked = git(repo, ['ls-files', '-z', '--cached', '--others', '--exclude-standard']).out.split('\0').filter(Boolean);
73
+ const baseFiles = baseSha ? listAtRef(repo, baseRef) : [];
74
+
75
+ const a = {
76
+ repo, baseRef, baseSha, baseBranch, headBranch, headSha: head, changed, from, tracked, config, repository: repoSlug(repo),
77
+ defFiles(re) { return [...new Set([...baseFiles, ...tracked].filter((f) => re.test(f)))].sort(); },
78
+ readDefs(f) {
79
+ const baseText = baseSha ? readAtRef(repo, baseRef, f) : null;
80
+ let headText = null; try { headText = fs.readFileSync(path.join(repo, f), 'utf8'); } catch { /* absent in the working tree */ }
81
+ return { base: baseText, head: headText };
82
+ },
83
+ };
84
+
85
+ // ---- cache key: CI definitions + everything the plan derives from ----
86
+ const defRe = new RegExp([gha, gitlab, circle, azure, bitbucket, buildkite].map((m) => m.DEF_RE.source).join('|'));
87
+ const defContents = a.defFiles(defRe).map((f) => { const d = a.readDefs(f); return `${f}\n${d.base}\n${d.head}`; }).join('\n--\n');
88
+ const changeContents = changed.map((f) => `${f}:${fileHash(repo, f)}`);
89
+ const key = sha256(JSON.stringify([PLAN_VERSION, repo, defContents, changeContents, baseSha, baseBranch, headBranch, hostClass(), config, process.version, process.env.FORGE_CI_LOCAL_SERVICES || '', fs.existsSync(path.join(repo, 'package.json')) ? sha256(fs.readFileSync(path.join(repo, 'package.json'))) : '']));
90
+ const cacheDir = path.join(opts.cacheDir || path.join(homeForgeDir(), 'ci-local', 'plans'));
91
+ const cacheFile = path.join(cacheDir, key + '.json');
92
+ if (!opts.noCache && fs.existsSync(cacheFile)) {
93
+ try { const cached = JSON.parse(fs.readFileSync(cacheFile, 'utf8')); cached.plan_cache = 'hit'; return cached; } catch { /* rebuild */ }
94
+ }
95
+
96
+ // ---- adapters ----
97
+ let jobs = [];
98
+ for (const ad of CI_ADAPTERS) { const r = ad.plan(a); jobs.push(...r.jobs); warnings.push(...(r.warnings || [])); }
99
+ // forge#4040: task-runner checks are a fallback ONLY for a repo with no CI definitions. When CI exists and its path
100
+ // filters leave no job applicable, nothing runs (a docs-only change must not trigger a full suite).
101
+ const ciExists = jobs.length > 0;
102
+ const ciApplicable = jobs.some((j) => !j.not_applicable);
103
+ const sources = [...new Set(jobs.map((j) => j.source))];
104
+ if (!ciExists) { const r = tasks.plan(a); jobs.push(...r.jobs); warnings.push(...r.warnings); if (r.jobs.length) sources.push('task-runners'); }
105
+ // forge#4040 (REG-3): the built-in bash-3.2/GNU portability job only makes sense when the repo's CI targets macOS or
106
+ // Windows; a Linux-only repo would be hard-blocked on constructs its own CI never rejects.
107
+ const portTarget = ciTargetsNonLinux(a, defRe);
108
+ const portJobs = portability.plan(a).jobs;
109
+ if (portTarget) jobs.push(...portJobs, ...explicit.plan(a).jobs);
110
+ else {
111
+ jobs.push(...explicit.plan(a).jobs);
112
+ for (const j of portJobs) jobs.push({ ...j, not_applicable: 'CI definitions target Linux only (no macOS/Windows runs-on or matrix); portability checks skipped', steps: [], services: [] });
113
+ }
114
+
115
+
116
+ // ---- config filters ----
117
+ const matches = (j, pats) => pats.some((p) => globToRegex(String(p)).test(j.id) || globToRegex(String(p)).test(String(j.name)) || String(p) === j.id);
118
+ if (config.exclude_jobs.length) jobs = jobs.map((j) => (!j.not_applicable && matches(j, config.exclude_jobs) ? { ...j, not_applicable: 'excluded by verification.ci_local.exclude_jobs', steps: [], services: [] } : j));
119
+ if (config.include_jobs.length) jobs = jobs.map((j) => (!j.not_applicable && !['explicit', 'portability'].includes(j.source) && !matches(j, config.include_jobs) ? { ...j, not_applicable: 'not in verification.ci_local.include_jobs', steps: [], services: [] } : j));
120
+
121
+ // ---- exact scoping + tool availability ----
122
+ const sctx = makeScopeCtx({ repo, baseRef, from, changed });
123
+ for (const j of jobs) {
124
+ if (j.not_applicable || j.deferred_reason) continue;
125
+ for (const s of j.steps) {
126
+ if (s.noop || s.builtin || s.kind === 'install') continue;
127
+ s.original_command = s.command;
128
+ if (s.prescoped) { s.scope = { mode: 'full', selector: 'explicit', widen_reason: '' }; continue; }
129
+ const sc = scopeCommand(s.command, s.cwd && s.cwd !== '.' ? s.cwd : '', sctx);
130
+ s.scope = { mode: sc.mode, selected: sc.selected, total: sc.total, selector: sc.selector || '', widen_reason: sc.widen_reason || '', files: sc.files };
131
+ if (sc.action === 'skip') { s.skip = sc.reason; continue; }
132
+ s.command = sc.command;
133
+ }
134
+ const live = j.steps.filter((s) => !s.noop && !s.skip && !s.builtin);
135
+ for (const s of live) { const m = missingTool(s.command); if (m) { j.deferred_reason = `tool not installed locally: ${m}`; break; } }
136
+ }
137
+
138
+ // ---- services policy ----
139
+ const servicesOff = config.services === 'off' || process.env.FORGE_CI_LOCAL_SERVICES === 'off';
140
+ for (const j of jobs) {
141
+ if (!j.not_applicable && !j.deferred_reason && j.services && j.services.length && servicesOff) j.deferred_reason = 'service containers disabled (verification.ci_local.services: off)';
142
+ }
143
+
144
+ const noApplicable = ciExists && !ciApplicable && !jobs.some((j) => !j.not_applicable && !j.deferred_reason && ['explicit'].includes(j.source));
145
+ const plan = {
146
+ version: PLAN_VERSION, repo, base_ref: baseRef, base_sha: baseSha, base_branch: baseBranch, head_branch: headBranch, head_sha: head, merge_base: from,
147
+ host: hostClass(), changed_files: changed, widen_reason: sctx.widen, sources, warnings: [...new Set(warnings)],
148
+ config: { budget_minutes: config.budget_minutes, max_parallel: config.max_parallel, services: config.services, mode: config.mode, file: config.file },
149
+ no_applicable_jobs: noApplicable, plan_key: key, plan_cache: 'miss', jobs,
150
+ };
151
+ try { fs.mkdirSync(cacheDir, { recursive: true }); fs.writeFileSync(cacheFile, JSON.stringify(plan)); pruneOld(cacheDir); } catch { /* cache is best effort */ }
152
+ return plan;
153
+ }
154
+
155
+ // Does any CI definition (trusted base or the PR's edit) run on macOS or Windows?
156
+ export function ciTargetsNonLinux(a, defRe) {
157
+ const re = /\b(macos(?:-[\w.]+)?|mac-?os|osx|darwin|windows(?:-[\w.]+)?|win(?:dows)?server[\w.-]*)\b/i;
158
+ for (const f of a.defFiles(defRe)) {
159
+ const d = a.readDefs(f);
160
+ for (const text of [d.base, d.head]) {
161
+ if (!text) continue;
162
+ for (const line of text.split('\n')) if (!/^\s*#/.test(line) && re.test(line)) return true;
163
+ }
164
+ }
165
+ return false;
166
+ }
167
+
168
+ function pruneOld(dir) {
169
+ const now = Date.now(); let n = 0;
170
+ for (const f of fs.readdirSync(dir)) { if (++n > 400) break; try { const p = path.join(dir, f); if (now - fs.statSync(p).mtimeMs > 30 * 86400 * 1000) fs.unlinkSync(p); } catch { /* ignore */ } }
171
+ }
172
+
173
+ export function summarizePlan(plan) {
174
+ const lines = [];
175
+ if (plan.no_applicable_jobs) lines.push('CI_LOCAL_NOTE: no_applicable_jobs (CI definitions exist but no job applies to this change; task-runner fallback not used)');
176
+ lines.push(`CI_LOCAL_PLAN: base=${plan.base_ref || '-'} host=${plan.host} changed=${plan.changed_files.length} sources=${plan.sources.join(',') || 'none'} cache=${plan.plan_cache}${plan.widen_reason ? ` widened=${plan.widen_reason}` : ''}`);
177
+ for (const w of plan.warnings) lines.push(`CI_LOCAL_WARNING: ${w}`);
178
+ for (const j of plan.jobs) {
179
+ if (j.not_applicable) { lines.push(`CI_LOCAL_JOB: not-applicable ${j.id} | ${j.name} | ${j.not_applicable}`); continue; }
180
+ if (j.deferred_reason) { lines.push(`CI_LOCAL_JOB: deferred-to-ci ${j.id} | ${j.name} | ${j.deferred_reason}`); continue; }
181
+ const live = j.steps.filter((s) => !s.noop);
182
+ const skipped = live.filter((s) => s.skip).length;
183
+ lines.push(`CI_LOCAL_JOB: runnable ${j.id} | ${j.name} | steps=${live.length} skipped-by-scope=${skipped}${j.services && j.services.length ? ` services=${j.services.map((s) => s.name).join(',')}` : ''}`);
184
+ }
185
+ return lines;
186
+ }