forgedock 1.17.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 (143) hide show
  1. package/bin/engine/outcome-codes.mjs +37 -2
  2. package/bin/engine/phases.mjs +207 -106
  3. package/bin/engine/runlog.mjs +17 -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 +37 -2
  7. package/bin/engine.mjs +221 -15
  8. package/bin/forgedock.mjs +1 -1
  9. package/bin/hooks/pre-tool-use.mjs +65 -32
  10. package/bin/runner.mjs +125 -6
  11. package/bin/stats/aggregate.mjs +7 -3
  12. package/bin/stats/cache.mjs +7 -3
  13. package/bin/stats/github-source.mjs +203 -24
  14. package/bin/stats/index.mjs +46 -9
  15. package/bin/stats/render.mjs +1 -1
  16. package/bin/stats/stats.test.mjs +264 -15
  17. package/commands/cleanup.md +10 -3
  18. package/commands/deploy-pr.md +64 -0
  19. package/commands/fix-ci.md +20 -6
  20. package/commands/milestone.md +34 -17
  21. package/commands/orchestrate/phase-4-execution.md +11 -4
  22. package/commands/review-pr-staging.md +145 -522
  23. package/commands/review-pr.md +496 -3124
  24. package/commands/test-gate.md +25 -10
  25. package/commands/work-on/build/architect.md +66 -1
  26. package/commands/work-on/build/implement.md +21 -1
  27. package/commands/work-on/build/validate.md +322 -28
  28. package/commands/work-on/build.md +173 -95
  29. package/commands/work-on/close.md +50 -15
  30. package/commands/work-on/decompose.md +72 -34
  31. package/commands/work-on/investigate.md +16 -12
  32. package/commands/work-on/remediate.md +58 -25
  33. package/commands/work-on/review.md +36 -21
  34. package/commands/work-on.md +1 -2
  35. package/docs/CONFIG.md +132 -26
  36. package/docs/OUTCOME-CODES.md +12 -3
  37. package/docs/RELEASE-NOTES.md +12 -0
  38. package/docs/STATS.md +13 -5
  39. package/docs/TELEMETRY.md +7 -4
  40. package/docs/WORK-ON-RUNTIME.md +24 -0
  41. package/docs/spec/forge-protocol-v1.md +2 -2
  42. package/package.json +2 -2
  43. package/packages/protocol/src/phases.js +5 -5
  44. package/packages/protocol/src/types.js +1 -1
  45. package/scripts/acceptance-red-green.sh +90 -0
  46. package/scripts/acceptance-red-green.test.sh +74 -0
  47. package/scripts/cap-findings.sh +1 -1
  48. package/scripts/cap-findings.test.sh +3 -1
  49. package/scripts/check-contract-scope.test.sh +2 -0
  50. package/scripts/check-edge-cases.sh +143 -0
  51. package/scripts/check-edge-cases.test.sh +175 -0
  52. package/scripts/check-spec-bash.sh +5 -1
  53. package/scripts/check-spec-bash.test.sh +20 -0
  54. package/scripts/check-spec-markers.sh +2 -0
  55. package/scripts/check-test-tmp-cleanup.sh +56 -0
  56. package/scripts/check-test-tmp-cleanup.test.sh +69 -0
  57. package/scripts/ci-local/adapters/azure-pipelines.mjs +113 -0
  58. package/scripts/ci-local/adapters/bitbucket.mjs +64 -0
  59. package/scripts/ci-local/adapters/buildkite.mjs +58 -0
  60. package/scripts/ci-local/adapters/circleci.mjs +101 -0
  61. package/scripts/ci-local/adapters/common.mjs +115 -0
  62. package/scripts/ci-local/adapters/explicit.mjs +21 -0
  63. package/scripts/ci-local/adapters/github-actions.mjs +349 -0
  64. package/scripts/ci-local/adapters/gitlab-ci.mjs +184 -0
  65. package/scripts/ci-local/adapters/portability.mjs +104 -0
  66. package/scripts/ci-local/adapters/task-runners.mjs +67 -0
  67. package/scripts/ci-local/cli.mjs +50 -0
  68. package/scripts/ci-local/lib.mjs +371 -0
  69. package/scripts/ci-local/plan.mjs +186 -0
  70. package/scripts/ci-local/run.mjs +345 -0
  71. package/scripts/ci-local/scoping.mjs +508 -0
  72. package/scripts/ci-local.sh +97 -0
  73. package/scripts/ci-local.test.sh +919 -0
  74. package/scripts/classify-finding.sh +32 -6
  75. package/scripts/classify-finding.test.sh +23 -0
  76. package/scripts/classify-test-run.sh +69 -0
  77. package/scripts/classify-test-run.test.sh +90 -0
  78. package/scripts/close-parent-c3.test.sh +71 -0
  79. package/scripts/consumer-sweep.sh +569 -0
  80. package/scripts/consumer-sweep.test.sh +357 -0
  81. package/scripts/detect-append-registries.sh +106 -0
  82. package/scripts/detect-append-registries.test.sh +64 -0
  83. package/scripts/diff-size.sh +93 -4
  84. package/scripts/diff-size.test.sh +59 -2
  85. package/scripts/fixtures/ci-local/azure/azure-pipelines.yml +11 -0
  86. package/scripts/fixtures/ci-local/bitbucket/bitbucket-pipelines.yml +12 -0
  87. package/scripts/fixtures/ci-local/buildkite/.buildkite/pipeline.yml +10 -0
  88. package/scripts/fixtures/ci-local/circleci/.circleci/config.yml +29 -0
  89. package/scripts/fixtures/ci-local/gha-basic/.github/workflows/ci.yml +59 -0
  90. package/scripts/fixtures/ci-local/gha-basic/.github/workflows/docs.yml +10 -0
  91. package/scripts/fixtures/ci-local/gha-basic/.github/workflows/nightly.yml +10 -0
  92. package/scripts/fixtures/ci-local/gha-basic/docs/index.md +1 -0
  93. package/scripts/fixtures/ci-local/gha-basic/src/greet.sh +2 -0
  94. package/scripts/fixtures/ci-local/gha-basic/tests/greet.test.sh +3 -0
  95. package/scripts/fixtures/ci-local/gha-dorny/.github/workflows/ci.yml +51 -0
  96. package/scripts/fixtures/ci-local/gha-dorny/backend/app.py +1 -0
  97. package/scripts/fixtures/ci-local/gha-dorny/docs/a.md +1 -0
  98. package/scripts/fixtures/ci-local/gha-dorny/frontend/app.js +1 -0
  99. package/scripts/fixtures/ci-local/gha-run/.github/workflows/ci.yml +39 -0
  100. package/scripts/fixtures/ci-local/gha-run/src/x.txt +1 -0
  101. package/scripts/fixtures/ci-local/gitlab/.gitlab-ci.yml +43 -0
  102. package/scripts/fixtures/ci-local/gitlab/docs/d.md +1 -0
  103. package/scripts/fixtures/ci-local/gitlab/src/a.txt +1 -0
  104. package/scripts/fixtures/ci-local/monorepo/.github/workflows/ci.yml +13 -0
  105. package/scripts/fixtures/ci-local/monorepo/package-lock.json +1 -0
  106. package/scripts/fixtures/ci-local/monorepo/packages/a/src/a.sh +2 -0
  107. package/scripts/fixtures/ci-local/monorepo/packages/a/tests/a.test.sh +3 -0
  108. package/scripts/fixtures/ci-local/monorepo/packages/b/src/b.sh +2 -0
  109. package/scripts/fixtures/ci-local/monorepo/packages/b/tests/b.test.sh +3 -0
  110. package/scripts/fixtures/ci-local/no-ci/Makefile +6 -0
  111. package/scripts/fixtures/ci-local/no-ci/package.json +1 -0
  112. package/scripts/fixtures/ci-local/no-ci/src/a.js +1 -0
  113. package/scripts/fixtures/ci-local/no-ci/src/b.js +1 -0
  114. package/scripts/fixtures/ci-local/no-ci/tests/a.test.js +2 -0
  115. package/scripts/fixtures/ci-local/no-ci/tests/b.test.js +2 -0
  116. package/scripts/forge-root.test.sh +14 -12
  117. package/scripts/lib/telemetry.sh +1 -0
  118. package/scripts/publish-staging-sync.test.sh +48 -0
  119. package/scripts/remediate-approved-head.test.sh +2 -0
  120. package/scripts/remediate-basesync.test.sh +1 -1
  121. package/scripts/resolve-deploy-gate.sh +32 -6
  122. package/scripts/resolve-deploy-gate.test.sh +42 -1
  123. package/scripts/review-acceptance-gate.sh +2 -2
  124. package/scripts/review-acceptance-gate.test.sh +20 -0
  125. package/scripts/review-delta.sh +8 -1
  126. package/scripts/review-delta.test.sh +19 -0
  127. package/scripts/review-finalize.sh +1579 -0
  128. package/scripts/review-finalize.test.sh +1040 -0
  129. package/scripts/review-pr-delta-wiring.test.sh +32 -72
  130. package/scripts/review-pr-domain-map.test.sh +18 -13
  131. package/scripts/review-pr-gate2.test.sh +9 -65
  132. package/scripts/review-pr-staging-scope.test.sh +14 -98
  133. package/scripts/review-prepare.sh +53 -15
  134. package/scripts/review-prepare.test.sh +37 -3
  135. package/scripts/review-roster.sh +16 -2
  136. package/scripts/review-roster.test.sh +22 -0
  137. package/scripts/run-shell-tests.sh +5 -0
  138. package/scripts/telemetry.test.sh +2 -0
  139. package/scripts/trusted-comments.test.sh +11 -3
  140. package/scripts/verify-phase-trail.test.sh +8 -0
  141. package/scripts/wait-ci-green.sh +61 -9
  142. package/scripts/wait-ci-green.test.sh +44 -6
  143. package/scripts/with-run-tmpdir.sh +20 -0
@@ -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
+ }
@@ -0,0 +1,345 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ //
4
+ // run.mjs - execute a plan in the build worktree. Independent jobs run in parallel, bounded per run (max_parallel) and
5
+ // machine-wide (a slot semaphore under ~/.forge/ci-local.lock), each with a timeout, inside an overall budget.
6
+ // Service containers (docker) are named per run and always torn down. Passing checks are cached by input hash.
7
+ // Nothing here talks to GitHub or triggers a remote workflow.
8
+
9
+ import { spawn } from 'node:child_process';
10
+ import fs from 'node:fs';
11
+ import net from 'node:net';
12
+ import os from 'node:os';
13
+ import path from 'node:path';
14
+ import { run as runSync, commandExists, scrubbedEnv, homeForgeDir, sha256, fileHash, toolVersion, shellSplit } from './lib.mjs';
15
+ import { shellArgv } from './adapters/common.mjs';
16
+ import { checkPortability } from './adapters/portability.mjs';
17
+
18
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
19
+ const cleanups = new Set();
20
+ let cleaning = false;
21
+ function cleanupAll() { if (cleaning) return; cleaning = true; for (const f of [...cleanups]) { try { f(); } catch { /* best effort */ } } }
22
+ process.on('exit', cleanupAll);
23
+ for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.on(sig, () => { cleanupAll(); process.exit(130); });
24
+
25
+ // ---- machine-wide slot semaphore ----
26
+ function slotCount() {
27
+ const n = Number(process.env.FORGE_CI_LOCAL_SLOTS);
28
+ return n > 0 ? n : Math.max(2, Math.min(8, Math.floor(os.cpus().length / 2)));
29
+ }
30
+ function alive(pid) { try { process.kill(pid, 0); return true; } catch (e) { return e.code === 'EPERM'; } }
31
+
32
+ async function acquireSlot(owner, deadline) {
33
+ const dir = path.join(homeForgeDir(), 'ci-local.lock');
34
+ fs.mkdirSync(dir, { recursive: true });
35
+ const n = slotCount();
36
+ for (;;) {
37
+ for (let i = 0; i < n; i++) {
38
+ const slot = path.join(dir, `slot-${i}`);
39
+ try {
40
+ fs.mkdirSync(slot);
41
+ fs.writeFileSync(path.join(slot, 'owner'), `${process.pid} ${Date.now()} ${owner}\n`);
42
+ const release = () => { try { fs.rmSync(slot, { recursive: true, force: true }); } catch { /* gone */ } cleanups.delete(release); };
43
+ cleanups.add(release);
44
+ return release;
45
+ } catch (e) {
46
+ if (e.code !== 'EEXIST') throw e;
47
+ let pid = 0; let ts = 0;
48
+ try { const [p, t] = fs.readFileSync(path.join(slot, 'owner'), 'utf8').split(' '); pid = Number(p); ts = Number(t); } catch { /* owner file not written yet */ }
49
+ const stale = pid ? !alive(pid) : (Date.now() - (fs.statSync(slot, { throwIfNoEntry: false })?.mtimeMs || 0) > 30000);
50
+ if (stale || (ts && Date.now() - ts > 3 * 3600 * 1000)) { try { fs.rmSync(slot, { recursive: true, force: true }); } catch { /* raced */ } i--; continue; }
51
+ }
52
+ }
53
+ if (Date.now() > deadline) return null;
54
+ await sleep(250);
55
+ }
56
+ }
57
+
58
+ // ---- process execution ----
59
+ function exec(argv, { cwd, env, timeoutMs, onData }) {
60
+ return new Promise((resolve) => {
61
+ let child; let killed = false; let timer;
62
+ try { child = spawn(argv[0], argv.slice(1), { cwd, env, detached: true, stdio: ['ignore', 'pipe', 'pipe'] }); } catch (e) { resolve({ code: 127, signal: null, error: e.message, timedOut: false }); return; }
63
+ const kill = () => { try { process.kill(-child.pid, 'SIGTERM'); } catch { /* gone */ } setTimeout(() => { try { process.kill(-child.pid, 'SIGKILL'); } catch { /* gone */ } }, 3000).unref(); };
64
+ const unreg = () => { cleanups.delete(hard); };
65
+ const hard = () => { try { process.kill(-child.pid, 'SIGKILL'); } catch { /* gone */ } };
66
+ cleanups.add(hard);
67
+ timer = setTimeout(() => { killed = true; kill(); }, timeoutMs);
68
+ child.stdout.on('data', onData); child.stderr.on('data', onData);
69
+ child.on('error', (e) => { clearTimeout(timer); unreg(); resolve({ code: 127, signal: null, error: e.message, timedOut: false }); });
70
+ child.on('close', (code, signal) => { clearTimeout(timer); unreg(); resolve({ code: code === null ? 1 : code, signal, timedOut: killed }); });
71
+ });
72
+ }
73
+
74
+ // forge#4040 (SEC-4): a path-SEGMENT containment check; a string prefix would accept `<repo>-sibling`.
75
+ export function insideRepo(repo, target) {
76
+ const root = path.resolve(repo); const t = path.resolve(target);
77
+ const rel = path.relative(root, t);
78
+ if (!(rel === '' || (rel !== '..' && !rel.startsWith('..' + path.sep) && !path.isAbsolute(rel)))) return false;
79
+ try { const rr = path.relative(fs.realpathSync(root), fs.realpathSync(t)); if (rr === '..' || rr.startsWith('..' + path.sep) || path.isAbsolute(rr)) return false; } catch { /* target not created yet: the lexical check stands */ }
80
+ return true;
81
+ }
82
+
83
+ class Tail {
84
+ constructor(max = 120) { this.lines = []; this.max = max; this.partial = ''; }
85
+ push(buf) { const t = this.partial + buf.toString('utf8'); const parts = t.split('\n'); this.partial = parts.pop(); for (const l of parts) { this.lines.push(l); if (this.lines.length > this.max) this.lines.shift(); } }
86
+ text(n = 40) { const all = this.partial ? [...this.lines, this.partial] : this.lines; return all.slice(-n); }
87
+ }
88
+
89
+ // ---- services ----
90
+ async function portFree(port) { return new Promise((res) => { const s = net.createServer(); s.once('error', () => res(false)); s.listen(port, '127.0.0.1', () => s.close(() => res(true))); }); }
91
+ const HEALTH_FLAGS = new Set(['--health-cmd', '--health-interval', '--health-timeout', '--health-retries', '--health-start-period', '--shm-size']);
92
+
93
+ async function startServices(job, log) {
94
+ const started = [];
95
+ const stop = () => { for (const n of started) runSync('docker', ['rm', '-f', n], { timeout: 30000 }); started.length = 0; };
96
+ cleanups.add(stop);
97
+ let net = '';
98
+ const dropNet = () => { if (net) { runSync('docker', ['network', 'rm', net], { timeout: 30000 }); net = ''; } };
99
+ const finish = () => { stop(); dropNet(); cleanups.delete(stop); cleanups.delete(dropNet); };
100
+ cleanups.add(dropNet);
101
+ if (!commandExists('docker') || runSync('docker', ['info'], { timeout: 20000 }).code !== 0) return { error: 'docker is not available', finish };
102
+ // forge#4040 (SEC-6): one docker network per job; nothing runs with --network host.
103
+ net = `forge-ci-net-${process.pid}-${job.id.replace(/[^A-Za-z0-9_.-]/g, '-')}`.slice(0, 120);
104
+ const nr = runSync('docker', ['network', 'create', net], { timeout: 30000 });
105
+ if (nr.code !== 0) { net = ''; return { error: `docker network create failed: ${(nr.err || nr.out).split('\n')[0]}`, finish }; }
106
+ for (const svc of job.services) {
107
+ const name = `forge-ci-${process.pid}-${job.id.replace(/[^A-Za-z0-9_.-]/g, '-')}-${svc.name}`.slice(0, 120);
108
+ const args = ['run', '-d', '--rm', '--name', name, '--network', net, '--network-alias', svc.name];
109
+ const hostPorts = [];
110
+ for (const p of svc.ports || []) {
111
+ const m = /^(?:(\d+):)?(\d+)(?:\/(tcp|udp))?$/.exec(String(p).trim());
112
+ if (!m) return { error: `unsupported port mapping ${p}`, finish };
113
+ if (m[1]) { if (!(await portFree(Number(m[1])))) return { error: `host port ${m[1]} is already in use`, finish }; hostPorts.push(Number(m[1])); args.push('-p', `127.0.0.1:${m[1]}:${m[2]}${m[3] ? '/' + m[3] : ''}`); } else args.push('-p', `127.0.0.1::${m[2]}`);
114
+ }
115
+ for (const [k, v] of Object.entries(svc.env || {})) args.push('-e', `${k}=${v}`);
116
+ const opts = shellSplit(svc.options || '') || [];
117
+ let health = false;
118
+ for (let i = 0; i < opts.length; i++) {
119
+ const [flag, inline] = opts[i].split(/=(.*)/s);
120
+ if (!HEALTH_FLAGS.has(flag)) { log(`service ${svc.name}: option ${flag} ignored (not on the safe list)`); if (inline === undefined && opts[i + 1] && !opts[i + 1].startsWith('-')) i++; continue; }
121
+ if (flag === '--health-cmd') health = true;
122
+ args.push(inline !== undefined ? `${flag}=${inline}` : flag); if (inline === undefined && opts[i + 1] !== undefined) args.push(opts[++i]);
123
+ }
124
+ args.push(svc.image);
125
+ const r = runSync('docker', args, { timeout: 300000 });
126
+ if (r.code !== 0) return { error: `docker run ${svc.image} failed: ${(r.err || r.out).split('\n')[0]}`, finish };
127
+ started.push(name);
128
+ const until = Date.now() + 90000;
129
+ for (;;) {
130
+ const st = runSync('docker', ['inspect', '-f', '{{.State.Running}}|{{if .State.Health}}{{.State.Health.Status}}{{end}}', name], { timeout: 15000 }).out.trim();
131
+ const [running, h] = st.split('|');
132
+ if (running !== 'true') return { error: `service ${svc.name} exited: ${runSync('docker', ['logs', '--tail', '5', name], { timeout: 15000 }).err.trim().split('\n').pop()}`, finish };
133
+ if (health) { if (h === 'healthy') break; if (h === 'unhealthy') return { error: `service ${svc.name} is unhealthy`, finish }; }
134
+ else if (hostPorts.length) { const ok = await new Promise((res) => { const s = net.connect(hostPorts[0], '127.0.0.1'); s.once('connect', () => { s.destroy(); res(true); }); s.once('error', () => res(false)); }); if (ok) break; }
135
+ else { await sleep(2000); break; }
136
+ if (Date.now() > until) return { error: `service ${svc.name} did not become ready in 90s`, finish };
137
+ await sleep(1000);
138
+ }
139
+ }
140
+ return { finish };
141
+ }
142
+
143
+ // ---- result cache ----
144
+ function cacheDir(o) { return o.resultCacheDir || path.join(homeForgeDir(), 'ci-local', 'results'); }
145
+ function configFilesHash(ctx) {
146
+ if (!ctx.configHash) {
147
+ const re = /(^|\/)(\.shellcheckrc|\.eslintrc[^/]*|eslint\.config\.[^/]*|\.prettierrc[^/]*|\.flake8|ruff\.toml|\.ruff\.toml|pyproject\.toml|setup\.cfg|\.markdownlint[^/]*|\.yamllint[^/]*|\.editorconfig|tox\.ini)$/;
148
+ ctx.configHash = sha256(ctx.plan.changed_files.length ? ctx.tracked.filter((f) => re.test(f)).sort().map((f) => `${f}:${fileHash(ctx.repo, f)}`).join('\n') : '');
149
+ }
150
+ return ctx.configHash;
151
+ }
152
+ function changeHash(ctx) {
153
+ if (!ctx.changeHash) ctx.changeHash = sha256(JSON.stringify([ctx.plan.merge_base, ctx.plan.changed_files.map((f) => `${f}:${fileHash(ctx.repo, f)}`)]));
154
+ return ctx.changeHash;
155
+ }
156
+ function stepKey(step, job, ctx) {
157
+ const sel = step.scope || {};
158
+ const first = (shellSplit(step.command) || [])[0] || '';
159
+ const perFile = /:per-file$/.test(sel.selector || '') && sel.files;
160
+ const inputs = perFile
161
+ ? { files: sel.files.map((f) => `${f}:${fileHash(ctx.repo, f)}`), config: configFilesHash(ctx) }
162
+ : { change: changeHash(ctx), selected: sel.files || sel.selector || '' };
163
+ const env = { ...job.env, ...step.env };
164
+ return sha256(JSON.stringify([step.command, step.cwd, step.shell, Object.keys(env).sort().map((k) => `${k}=${env[k]}`), toolVersion(path.basename(first)), process.version, inputs]));
165
+ }
166
+
167
+ // ---- one job ----
168
+ function jobEnv(job, step, ctx, ghFiles) {
169
+ const extra = { ...job.env, ...step.env };
170
+ if (job.source === 'github-actions') Object.assign(extra, { GITHUB_WORKSPACE: ctx.repo, GITHUB_SHA: ctx.plan.head_sha, GITHUB_REF_NAME: ctx.plan.head_branch, GITHUB_BASE_REF: ctx.plan.base_branch, GITHUB_HEAD_REF: ctx.plan.head_branch, GITHUB_EVENT_NAME: 'pull_request', RUNNER_OS: { linux: 'Linux', darwin: 'macOS', win32: 'Windows' }[process.platform], RUNNER_TEMP: ctx.tmp, GITHUB_ENV: ghFiles.env, GITHUB_PATH: ghFiles.path, GITHUB_OUTPUT: ghFiles.output, GITHUB_STEP_SUMMARY: ghFiles.summary });
171
+ return scrubbedEnv(extra);
172
+ }
173
+ function absorbGhFiles(ghFiles, env) {
174
+ try {
175
+ const lines = fs.readFileSync(ghFiles.env, 'utf8').split('\n'); fs.writeFileSync(ghFiles.env, '');
176
+ for (let i = 0; i < lines.length; i++) {
177
+ const m = /^([A-Za-z_][A-Za-z0-9_]*)<<(.+)$/.exec(lines[i]);
178
+ if (m) { const buf = []; i++; while (i < lines.length && lines[i] !== m[2]) buf.push(lines[i++]); env[m[1]] = buf.join('\n'); continue; }
179
+ const kv = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(lines[i]); if (kv) env[kv[1]] = kv[2];
180
+ }
181
+ const paths = fs.readFileSync(ghFiles.path, 'utf8').split('\n').filter(Boolean); fs.writeFileSync(ghFiles.path, '');
182
+ if (paths.length) env.PATH = paths.join(path.delimiter) + path.delimiter + (env.PATH || process.env.PATH);
183
+ } catch { /* files are best effort */ }
184
+ }
185
+
186
+ async function runJob(job, ctx) {
187
+ const t0 = Date.now(); const res = { id: job.id, name: job.name, source: job.source, status: 'pass', steps: [], reason: '', duration_ms: 0 };
188
+ const jobTimeout = (job.timeout_s || ctx.jobTimeoutS) * 1000;
189
+ const log = (m) => { res.notes = (res.notes || []).concat(m); };
190
+ const runnableSteps = job.steps.filter((s) => !s.noop);
191
+ if (!runnableSteps.length) { res.status = 'pass'; res.reason = 'nothing to run'; res.duration_ms = Date.now() - t0; return res; }
192
+ const needExec = runnableSteps.some((s) => !s.skip);
193
+ let svc = null; let release = null;
194
+ try {
195
+ if (needExec) {
196
+ release = await acquireSlot(job.id, ctx.deadline);
197
+ if (!release) { res.status = 'deferred'; res.budget = true; res.reason = 'budget exhausted waiting for a machine slot'; return res; }
198
+ if (job.services && job.services.length) {
199
+ svc = await startServices(job, log);
200
+ if (svc.error) { res.status = 'deferred'; res.reason = `service containers: ${svc.error}`; return res; }
201
+ }
202
+ }
203
+ // forge#4040 (REG-5): the job timeout clock starts when the job starts executing, never while it waits for a slot.
204
+ const tExec = Date.now();
205
+ const ghFiles = { env: path.join(ctx.tmp, `${job.id.replace(/[^A-Za-z0-9]/g, '_')}.env`), path: path.join(ctx.tmp, `${job.id.replace(/[^A-Za-z0-9]/g, '_')}.path`), output: path.join(ctx.tmp, `${job.id.replace(/[^A-Za-z0-9]/g, '_')}.out`), summary: path.join(ctx.tmp, `${job.id.replace(/[^A-Za-z0-9]/g, '_')}.sum`) };
206
+ for (const f of Object.values(ghFiles)) fs.writeFileSync(f, '');
207
+ const carried = {}; let failedGroup = null;
208
+ for (const [i, step] of job.steps.entries()) {
209
+ if (step.noop) continue;
210
+ const sr = { name: step.name, command: step.command.length > 200 ? step.command.slice(0, 200) + '...' : step.command, scope: step.scope ? { mode: step.scope.mode, selector: step.scope.selector, selected: step.scope.selected, total: step.scope.total, widen_reason: step.scope.widen_reason } : undefined, status: 'pass', duration_ms: 0, cache: 'na' };
211
+ res.steps.push(sr);
212
+ if (step.skip) { sr.status = 'skipped'; sr.reason = step.skip; continue; }
213
+ if (failedGroup && step.group !== failedGroup) break;
214
+ if (Date.now() > ctx.deadline) { res.status = 'deferred'; res.budget = true; res.reason = 'budget exhausted'; return res; }
215
+ const s0 = Date.now();
216
+ if (step.builtin === 'portability') {
217
+ const f = checkPortability({ repo: ctx.repo, from: ctx.plan.merge_base, changed: ctx.plan.changed_files, tracked: ctx.tracked });
218
+ sr.duration_ms = Date.now() - s0;
219
+ if (f.length) { sr.status = 'fail'; res.tail = f.slice(0, 40); res.status = 'fail'; res.failed_step = step.name; failedGroup = step.group || null; if (!failedGroup) break; }
220
+ continue;
221
+ }
222
+ let key = '';
223
+ const cacheable = step.kind !== 'install' && !ctx.noResultCache;
224
+ if (cacheable) {
225
+ key = stepKey(step, job, ctx);
226
+ const f = path.join(cacheDir(ctx.o), key + '.json');
227
+ if (fs.existsSync(f)) { sr.cache = 'hit'; sr.duration_ms = 0; try { fs.utimesSync(f, new Date(), new Date()); } catch { /* ignore */ } continue; }
228
+ sr.cache = 'miss';
229
+ }
230
+ const argv = shellArgv(step.shell); if (!argv) { res.status = 'deferred'; res.reason = `shell ${step.shell} is not available locally`; return res; }
231
+ const scriptFile = path.join(ctx.tmp, `${job.id.replace(/[^A-Za-z0-9]/g, '_')}-${i}.sh`);
232
+ fs.writeFileSync(scriptFile, step.command + '\n');
233
+ const env = jobEnv(job, step, ctx, ghFiles); Object.assign(env, carried);
234
+ const cwd = path.resolve(ctx.repo, step.cwd || '.');
235
+ if (!insideRepo(ctx.repo, cwd)) { res.status = 'deferred'; res.reason = 'step working-directory escapes the repository'; return res; }
236
+ const tail = new Tail();
237
+ const left = Math.max(1000, Math.min((step.timeout_s || ctx.jobTimeoutS) * 1000, jobTimeout - (Date.now() - tExec), ctx.deadline - Date.now()));
238
+ const r = await exec([...argv, scriptFile], { cwd, env, timeoutMs: left, onData: (b) => tail.push(b) });
239
+ sr.duration_ms = Date.now() - s0;
240
+ if (job.source === 'github-actions') { const e2 = { ...env }; absorbGhFiles(ghFiles, e2); for (const k of Object.keys(e2)) if (e2[k] !== env[k]) carried[k] = e2[k]; }
241
+ if (r.code === 0) { if (key) { try { fs.mkdirSync(cacheDir(ctx.o), { recursive: true }); fs.writeFileSync(path.join(cacheDir(ctx.o), key + '.json'), JSON.stringify({ cmd: step.command.slice(0, 300), ts: Date.now() })); } catch { /* cache is best effort */ } } continue; }
242
+ if (r.error) tail.push(Buffer.from(`${r.error}\n`));
243
+ const text = tail.text(60);
244
+ if (r.timedOut) { if (Date.now() > ctx.deadline) { res.status = 'deferred'; res.budget = true; res.reason = 'budget exhausted (job killed)'; return res; } sr.status = 'timeout'; res.status = 'degraded'; res.reason = `timed out after ${Math.round(left / 1000)}s (a timeout proves nothing about the code)`; res.tail = text; res.failed_step = step.name; return res; }
245
+ if (step.kind === 'install') { res.status = 'deferred'; res.reason = `dependency install failed (offline?): ${step.command.slice(0, 60)}`; return res; }
246
+ if (r.code === 127 && text.some((l) => /command not found|not found$|No such file or directory/.test(l)) && !step.continue_on_error) {
247
+ const tool = (text.find((l) => /command not found/.test(l)) || '').replace(/^.*?([A-Za-z0-9._-]+): command not found.*$/, '$1');
248
+ if (/command not found/.test(text.join('\n'))) { res.status = 'deferred'; res.reason = `tool not installed locally: ${tool || 'unknown'}`; return res; }
249
+ }
250
+ if (step.continue_on_error) { sr.status = 'allowed-failure'; continue; }
251
+ // forge#4040 (REG-4): a failure under a tool-version drift (setup-node 18 vs host 26) cannot be attributed to the change.
252
+ if (job.version_drift && job.version_drift.length) { res.status = 'deferred'; res.reason = `version drift (${job.version_drift[0]}); step "${step.name}" failed locally (exit ${r.code}), CI proves it on the pinned toolchain`; res.tail = text; return res; }
253
+ sr.status = 'fail'; sr.exit = r.code; res.status = 'fail'; res.failed_step = step.name; res.exit = r.code; res.failed_command = step.command; res.tail = text;
254
+ failedGroup = step.group || null; if (!failedGroup) break;
255
+ }
256
+ return res;
257
+ } finally {
258
+ if (svc && svc.finish) svc.finish();
259
+ if (release) release();
260
+ res.duration_ms = Date.now() - t0;
261
+ }
262
+ }
263
+
264
+ // ---- scheduler ----
265
+ export async function runPlan(plan, o = {}) {
266
+ const t0 = Date.now();
267
+ const repo = path.resolve(plan.repo || o.repo || '.');
268
+ const requestedS = Number(process.env.FORGE_CI_LOCAL_BUDGET_SECONDS) || (o.budgetMinutes || plan.config.budget_minutes || Number(process.env.FORGE_CI_LOCAL_BUDGET_MINUTES) || 15) * 60;
269
+ // forge#4040 (REG-6): one invocation never exceeds the Bash tool cap (10 min); beyond the cap it ends as a partial run
270
+ // (DEGRADED) and a re-run resumes from the result cache.
271
+ const maxInvS = Number(process.env.FORGE_CI_LOCAL_MAX_INVOCATION_SECONDS) || 540;
272
+ const budgetS = Math.min(requestedS, maxInvS); const capped = requestedS > maxInvS;
273
+ const maxParallel = o.maxParallel || plan.config.max_parallel || Number(process.env.FORGE_CI_LOCAL_MAX_PARALLEL) || 4;
274
+ const tmpRoot = fs.mkdtempSync(path.join(process.env.TMPDIR || os.tmpdir(), 'forge-ci-local.'));
275
+ cleanups.add(() => { try { fs.rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* ignore */ } });
276
+ const ctx = {
277
+ plan, repo, o, tmp: tmpRoot, deadline: t0 + budgetS * 1000, jobTimeoutS: Number(process.env.FORGE_CI_LOCAL_JOB_TIMEOUT) || Math.min(900, budgetS),
278
+ tracked: runSync('git', ['-C', repo, 'ls-files', '--cached', '--others', '--exclude-standard', '-z']).out.split('\0').filter(Boolean),
279
+ noResultCache: process.env.FORGE_CI_LOCAL_NO_CACHE === '1',
280
+ };
281
+ const runnable = plan.jobs.filter((j) => !j.not_applicable && !j.deferred_reason);
282
+ const ids = new Set(runnable.map((j) => j.id));
283
+ const results = new Map(); const running = new Map();
284
+ const pending = new Set(runnable.map((j) => j.id));
285
+ const byId = new Map(runnable.map((j) => [j.id, j]));
286
+ while (pending.size || running.size) {
287
+ for (const id of [...pending]) {
288
+ const j = byId.get(id);
289
+ const needs = (j.needs || []).filter((n) => ids.has(n));
290
+ const blocked = needs.find((n) => results.has(n) && results.get(n).status !== 'pass');
291
+ if (blocked) { pending.delete(id); const b = results.get(blocked); const r = { id, name: j.name, source: j.source, status: b.status === 'fail' ? 'skipped' : 'deferred', reason: b.status === 'fail' ? `needs ${blocked}, which failed` : `needs ${blocked}, which is not proven locally`, steps: [], duration_ms: 0 }; results.set(id, r); printJob(r); continue; }
292
+ if (needs.some((n) => !results.has(n))) continue;
293
+ if (running.size >= maxParallel) break;
294
+ if (Date.now() > ctx.deadline) { pending.delete(id); results.set(id, { id, name: j.name, source: j.source, status: 'deferred', budget: true, reason: 'budget exhausted before the job started', steps: [], duration_ms: 0 }); continue; }
295
+ pending.delete(id);
296
+ const p = runJob(j, ctx).catch((e) => ({ id, name: j.name, source: j.source, status: 'deferred', reason: `runner error: ${e.message}`, steps: [], duration_ms: 0 })).then((r) => { results.set(id, r); running.delete(id); printJob(r); });
297
+ running.set(id, p);
298
+ }
299
+ if (running.size) await Promise.race(running.values()); else if (pending.size) await sleep(10);
300
+ }
301
+ // report
302
+ const all = [];
303
+ for (const j of plan.jobs) {
304
+ if (j.not_applicable) continue;
305
+ if (j.deferred_reason) { all.push({ id: j.id, name: j.name, status: 'deferred', reason: j.deferred_reason, steps: [], duration_ms: 0 }); continue; }
306
+ const r = results.get(j.id); if (r) all.push(r);
307
+ }
308
+ const run_ = all.filter((r) => r.status === 'pass' || r.status === 'fail' || r.status === 'degraded');
309
+ const failed = all.filter((r) => r.status === 'fail'); const defd = all.filter((r) => r.status === 'deferred'); const skipped = all.filter((r) => r.status === 'skipped');
310
+ for (const r of defd) console.log(`CI_LOCAL_DEFERRED: ${r.id} | ${r.name} | ${r.reason}`);
311
+ let checks = 0; let hits = 0; let narrowed = 0; let widened = 0; let selTotal = 0; let selSel = 0;
312
+ for (const r of all) for (const [n, s] of r.steps.entries()) {
313
+ if (!s.scope) continue;
314
+ if (s.cache === 'hit') hits++;
315
+ if (s.scope.mode === 'scoped' || s.scope.mode === 'skip') narrowed++;
316
+ if (s.scope.widen_reason) widened++;
317
+ if (typeof s.scope.selected === 'number' && typeof s.scope.total === 'number') { selSel += s.scope.selected; selTotal += s.scope.total; }
318
+ if (checks++ < 40) console.log(`CI_LOCAL_CHECK: job=${r.id} step=${n + 1} selector=${s.scope.selector || 'none'} selected=${s.scope.selected ?? ''} total=${s.scope.total ?? ''} cache=${s.cache} duration_ms=${s.duration_ms} widen=${(s.scope.widen_reason || '').replace(/\s+/g, '_') || 'none'}`);
319
+ }
320
+ const degraded = all.filter((r) => r.status === 'degraded');
321
+ const budgetHit = all.some((r) => r.budget) || (all.some((r) => r.status === 'deferred' && /budget exhausted/.test(r.reason || '')));
322
+ for (const r of degraded) console.log(`CI_LOCAL_DEGRADED: ${r.id} | ${r.name} | ${r.reason}`);
323
+ // forge#4040: FAIL only for a runnable job that really failed. A timeout or an exhausted budget leaves code unproven:
324
+ // DEGRADED (exit 2), never a pass and never a fail.
325
+ const degReason = degraded.length ? 'job_timeout' : budgetHit ? (capped ? 'budget_capped_at_invocation_limit' : 'budget_exhausted') : '';
326
+ const state = failed.length ? 'FAIL' : degReason ? 'DEGRADED' : 'PASS';
327
+ const dur = Date.now() - t0;
328
+ if (plan.no_applicable_jobs && !all.length) console.log('CI_LOCAL_NOTE: no_applicable_jobs (CI definitions exist but no job applies to this change)');
329
+ console.log(`CI_LOCAL_RESULT: ${state} jobs_run=${run_.length} jobs_deferred=${defd.length} jobs_failed=${failed.length} jobs_degraded=${degraded.length} jobs_skipped=${skipped.length} checks=${checks} cache_hits=${hits} narrowed=${narrowed} widened=${widened} selected=${selSel} total=${selTotal} duration_ms=${dur}${Date.now() > ctx.deadline ? ' budget_exhausted=true' : ''}${plan.no_applicable_jobs && !all.length ? ' no_applicable_jobs=true' : ''}${degReason && state === 'DEGRADED' ? ` reason=${degReason}` : ''}`);
330
+ if (o.resultFile) { try { fs.writeFileSync(o.resultFile, JSON.stringify({ state, mode: plan.config && plan.config.mode, jobs: all, plan_key: plan.plan_key, duration_ms: dur }, null, 2)); } catch { /* ignore */ } }
331
+ try { fs.rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* ignore */ }
332
+ return failed.length ? 1 : state === 'DEGRADED' ? 2 : 0;
333
+ }
334
+
335
+ function printJob(r) {
336
+ const secs = (r.duration_ms / 1000).toFixed(1);
337
+ const stepsRun = r.steps.filter((s) => s.status !== 'skipped').length;
338
+ const hit = r.steps.filter((s) => s.cache === 'hit').length;
339
+ console.log(`CI_LOCAL_JOB: ${r.status === 'pass' ? 'pass' : r.status} ${r.id} | ${r.name} | ${secs}s | steps=${stepsRun}/${r.steps.length}${hit ? ` cached=${hit}` : ''}${r.reason ? ` | ${r.reason}` : ''}`);
340
+ if (r.status === 'fail' || r.status === 'degraded') {
341
+ console.log(`${r.status === 'fail' ? 'CI_LOCAL_FAIL' : 'CI_LOCAL_DEGRADED_STEP'}: ${r.id} | step: ${r.failed_step || '?'}${r.exit !== undefined ? ` | exit ${r.exit}` : ''}`);
342
+ if (r.failed_command) console.log(`CI_LOCAL_FAIL_COMMAND: ${r.failed_command.split('\n')[0].slice(0, 300)}`);
343
+ for (const l of r.tail || []) console.log(` | ${l}`);
344
+ }
345
+ }