forgedock 1.18.0 → 1.20.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 (135) hide show
  1. package/README.md +2 -0
  2. package/bin/engine/outcome-codes.mjs +13 -0
  3. package/bin/engine/phases.mjs +54 -4
  4. package/bin/engine/runlog.mjs +10 -2
  5. package/bin/engine/telemetry-seam.mjs +3 -0
  6. package/bin/engine/telemetry.test.mjs +3 -2
  7. package/bin/engine-cli.mjs +32 -2
  8. package/bin/engine.mjs +187 -10
  9. package/bin/runner.mjs +89 -13
  10. package/bin/stats/aggregate.mjs +7 -3
  11. package/bin/stats/cache.mjs +7 -3
  12. package/bin/stats/github-source.mjs +136 -28
  13. package/bin/stats/index.mjs +46 -9
  14. package/bin/stats/render.mjs +1 -1
  15. package/bin/stats/stats.test.mjs +201 -40
  16. package/commands/orchestrate/phase-4-execution.md +1 -1
  17. package/commands/review-pr-agents/protocols.md +19 -0
  18. package/commands/review-pr-staging.md +4 -4
  19. package/commands/review-pr.md +31 -20
  20. package/commands/work-on/build/architect.md +54 -1
  21. package/commands/work-on/build/implement.md +28 -1
  22. package/commands/work-on/build/validate.md +321 -29
  23. package/commands/work-on/build.md +184 -17
  24. package/commands/work-on/close.md +9 -4
  25. package/commands/work-on/investigate.md +7 -0
  26. package/commands/work-on/remediate.md +43 -17
  27. package/commands/work-on/review.md +43 -22
  28. package/commands/work-on.md +4 -4
  29. package/docs/CONFIG.md +93 -2
  30. package/docs/OUTCOME-CODES.md +5 -1
  31. package/docs/RELEASE-NOTES.md +15 -0
  32. package/docs/STATS.md +11 -4
  33. package/docs/TELEMETRY.md +5 -2
  34. package/docs/WORK-ON-RUNTIME.md +29 -3
  35. package/package.json +2 -2
  36. package/packages/protocol/src/types.js +1 -1
  37. package/scripts/acceptance-red-green.sh +90 -0
  38. package/scripts/acceptance-red-green.test.sh +74 -0
  39. package/scripts/architect-resume-match.test.sh +12 -3
  40. package/scripts/build-knowledge-index.mjs +8 -5
  41. package/scripts/builder-resume-match.test.sh +12 -3
  42. package/scripts/calibration.mjs +5 -2
  43. package/scripts/cap-findings.test.sh +2 -0
  44. package/scripts/check-contract-scope.test.sh +2 -0
  45. package/scripts/check-edge-cases.sh +143 -0
  46. package/scripts/check-edge-cases.test.sh +175 -0
  47. package/scripts/check-spec-markers.sh +4 -0
  48. package/scripts/check-test-tmp-cleanup.sh +56 -0
  49. package/scripts/check-test-tmp-cleanup.test.sh +69 -0
  50. package/scripts/ci-local/adapters/azure-pipelines.mjs +113 -0
  51. package/scripts/ci-local/adapters/bitbucket.mjs +64 -0
  52. package/scripts/ci-local/adapters/buildkite.mjs +58 -0
  53. package/scripts/ci-local/adapters/circleci.mjs +101 -0
  54. package/scripts/ci-local/adapters/common.mjs +115 -0
  55. package/scripts/ci-local/adapters/explicit.mjs +21 -0
  56. package/scripts/ci-local/adapters/github-actions.mjs +349 -0
  57. package/scripts/ci-local/adapters/gitlab-ci.mjs +184 -0
  58. package/scripts/ci-local/adapters/portability.mjs +104 -0
  59. package/scripts/ci-local/adapters/task-runners.mjs +67 -0
  60. package/scripts/ci-local/cli.mjs +50 -0
  61. package/scripts/ci-local/lib.mjs +379 -0
  62. package/scripts/ci-local/plan.mjs +186 -0
  63. package/scripts/ci-local/run.mjs +451 -0
  64. package/scripts/ci-local/scoping.mjs +531 -0
  65. package/scripts/ci-local.sh +98 -0
  66. package/scripts/ci-local.test.sh +1108 -0
  67. package/scripts/classify-finding.sh +33 -7
  68. package/scripts/classify-finding.test.sh +23 -0
  69. package/scripts/classify-test-run.sh +69 -0
  70. package/scripts/classify-test-run.test.sh +90 -0
  71. package/scripts/close-parent-c3.test.sh +2 -0
  72. package/scripts/consumer-sweep.sh +569 -0
  73. package/scripts/consumer-sweep.test.sh +357 -0
  74. package/scripts/detect-append-registries.sh +106 -0
  75. package/scripts/detect-append-registries.test.sh +64 -0
  76. package/scripts/file-deferred-followups.sh +91 -30
  77. package/scripts/file-deferred-followups.test.sh +101 -8
  78. package/scripts/fixtures/ci-local/azure/azure-pipelines.yml +11 -0
  79. package/scripts/fixtures/ci-local/bitbucket/bitbucket-pipelines.yml +12 -0
  80. package/scripts/fixtures/ci-local/buildkite/.buildkite/pipeline.yml +10 -0
  81. package/scripts/fixtures/ci-local/circleci/.circleci/config.yml +29 -0
  82. package/scripts/fixtures/ci-local/gha-basic/.github/workflows/ci.yml +59 -0
  83. package/scripts/fixtures/ci-local/gha-basic/.github/workflows/docs.yml +10 -0
  84. package/scripts/fixtures/ci-local/gha-basic/.github/workflows/nightly.yml +10 -0
  85. package/scripts/fixtures/ci-local/gha-basic/docs/index.md +1 -0
  86. package/scripts/fixtures/ci-local/gha-basic/src/greet.sh +2 -0
  87. package/scripts/fixtures/ci-local/gha-basic/tests/greet.test.sh +3 -0
  88. package/scripts/fixtures/ci-local/gha-dorny/.github/workflows/ci.yml +51 -0
  89. package/scripts/fixtures/ci-local/gha-dorny/backend/app.py +1 -0
  90. package/scripts/fixtures/ci-local/gha-dorny/docs/a.md +1 -0
  91. package/scripts/fixtures/ci-local/gha-dorny/frontend/app.js +1 -0
  92. package/scripts/fixtures/ci-local/gha-run/.github/workflows/ci.yml +39 -0
  93. package/scripts/fixtures/ci-local/gha-run/src/x.txt +1 -0
  94. package/scripts/fixtures/ci-local/gitlab/.gitlab-ci.yml +43 -0
  95. package/scripts/fixtures/ci-local/gitlab/docs/d.md +1 -0
  96. package/scripts/fixtures/ci-local/gitlab/src/a.txt +1 -0
  97. package/scripts/fixtures/ci-local/monorepo/.github/workflows/ci.yml +13 -0
  98. package/scripts/fixtures/ci-local/monorepo/package-lock.json +1 -0
  99. package/scripts/fixtures/ci-local/monorepo/packages/a/src/a.sh +2 -0
  100. package/scripts/fixtures/ci-local/monorepo/packages/a/tests/a.test.sh +3 -0
  101. package/scripts/fixtures/ci-local/monorepo/packages/b/src/b.sh +2 -0
  102. package/scripts/fixtures/ci-local/monorepo/packages/b/tests/b.test.sh +3 -0
  103. package/scripts/fixtures/ci-local/no-ci/Makefile +6 -0
  104. package/scripts/fixtures/ci-local/no-ci/package.json +1 -0
  105. package/scripts/fixtures/ci-local/no-ci/src/a.js +1 -0
  106. package/scripts/fixtures/ci-local/no-ci/src/b.js +1 -0
  107. package/scripts/fixtures/ci-local/no-ci/tests/a.test.js +2 -0
  108. package/scripts/fixtures/ci-local/no-ci/tests/b.test.js +2 -0
  109. package/scripts/forge-root.test.sh +1 -1
  110. package/scripts/lib/telemetry.sh +3 -0
  111. package/scripts/remediate-approved-head.test.sh +2 -0
  112. package/scripts/remediate-basesync.test.sh +1 -1
  113. package/scripts/review-acceptance-gate.sh +2 -2
  114. package/scripts/review-acceptance-gate.test.sh +20 -0
  115. package/scripts/review-delta.sh +8 -1
  116. package/scripts/review-delta.test.sh +19 -0
  117. package/scripts/review-finalize.sh +294 -53
  118. package/scripts/review-finalize.test.sh +347 -9
  119. package/scripts/review-pr-delta-wiring.test.sh +13 -0
  120. package/scripts/review-prepare.sh +5 -3
  121. package/scripts/review-prepare.test.sh +18 -2
  122. package/scripts/review-roster.sh +18 -5
  123. package/scripts/review-roster.test.sh +57 -1
  124. package/scripts/run-shell-tests.sh +5 -0
  125. package/scripts/selfreview-plan.sh +86 -0
  126. package/scripts/selfreview-plan.test.sh +90 -0
  127. package/scripts/spawn-depth-check.sh +5 -4
  128. package/scripts/spawn-depth-check.test.sh +3 -0
  129. package/scripts/telemetry.test.sh +10 -0
  130. package/scripts/transition-label.sh +26 -0
  131. package/scripts/trusted-comments.test.sh +2 -1
  132. package/scripts/verify-phase-trail.test.sh +14 -0
  133. package/scripts/wait-ci-green.sh +61 -9
  134. package/scripts/wait-ci-green.test.sh +41 -3
  135. 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,451 @@
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
+ let ticketSeq = 0;
33
+ // Is this slot dir free to take (absent, or held by a dead pid / for over 3 h)? A stale one is reaped.
34
+ function slotFree(slot) {
35
+ if (!fs.existsSync(slot)) return true;
36
+ let pid = 0; let ts = 0;
37
+ try { const [p, t] = fs.readFileSync(path.join(slot, 'owner'), 'utf8').split(' '); pid = Number(p); ts = Number(t); } catch { /* owner file not written yet */ }
38
+ const stale = pid ? !alive(pid) : (Date.now() - (fs.statSync(slot, { throwIfNoEntry: false })?.mtimeMs || 0) > 30000);
39
+ if (stale || (ts && Date.now() - ts > 3 * 3600 * 1000)) { try { fs.rmSync(slot, { recursive: true, force: true }); } catch { /* raced */ } return !fs.existsSync(slot); }
40
+ return false;
41
+ }
42
+
43
+ // forge#4057: FIFO-fair slot acquisition. Every waiter files a ticket (`<ms>-<seq>-<pid>-<rand>`) under ci-local.lock/queue and
44
+ // only a ticket among the first N (N = number of free slots) may claim one, so an invocation can never be starved by polling
45
+ // races. A ticket whose pid is dead is reaped; the ticket is removed on acquire, on a deadline miss and on every exit path.
46
+ export async function acquireSlot(owner, deadline) {
47
+ const dir = path.join(homeForgeDir(), 'ci-local.lock');
48
+ const qdir = path.join(dir, 'queue');
49
+ fs.mkdirSync(qdir, { recursive: true });
50
+ const n = slotCount();
51
+ const ticket = `${String(Date.now()).padStart(14, '0')}-${String(++ticketSeq).padStart(6, '0')}-${process.pid}-${Math.random().toString(36).slice(2, 8)}`;
52
+ const tpath = path.join(qdir, ticket);
53
+ fs.writeFileSync(tpath, `${process.pid} ${owner}\n`, { flag: 'wx' });
54
+ const dropTicket = () => { try { fs.rmSync(tpath, { force: true }); } catch { /* gone */ } cleanups.delete(dropTicket); };
55
+ cleanups.add(dropTicket);
56
+ try {
57
+ for (;;) {
58
+ const free = [];
59
+ for (let i = 0; i < n; i++) if (slotFree(path.join(dir, `slot-${i}`))) free.push(i);
60
+ const live = [];
61
+ for (const name of fs.readdirSync(qdir).sort()) {
62
+ if (name !== ticket && !alive(Number(name.split('-')[2]))) { try { fs.rmSync(path.join(qdir, name), { force: true }); } catch { /* raced */ } continue; }
63
+ live.push(name);
64
+ }
65
+ const pos = live.indexOf(ticket);
66
+ if (pos === -1) { try { fs.writeFileSync(tpath, `${process.pid} ${owner}\n`, { flag: 'wx' }); } catch { /* re-filed by a racing reaper */ } }
67
+ else if (pos < free.length) {
68
+ for (const i of free) {
69
+ const slot = path.join(dir, `slot-${i}`);
70
+ try {
71
+ fs.mkdirSync(slot);
72
+ fs.writeFileSync(path.join(slot, 'owner'), `${process.pid} ${Date.now()} ${owner}\n`);
73
+ const release = () => { try { fs.rmSync(slot, { recursive: true, force: true }); } catch { /* gone */ } cleanups.delete(release); };
74
+ cleanups.add(release);
75
+ return release;
76
+ } catch (e) { if (e.code !== 'EEXIST') throw e; }
77
+ }
78
+ }
79
+ if (Date.now() > deadline) return null;
80
+ await sleep(250);
81
+ }
82
+ } finally { dropTicket(); }
83
+ }
84
+
85
+ // ---- process execution ----
86
+ function exec(argv, { cwd, env, timeoutMs, onData }) {
87
+ return new Promise((resolve) => {
88
+ let child; let killed = false; let timer;
89
+ 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; }
90
+ const kill = () => { try { process.kill(-child.pid, 'SIGTERM'); } catch { /* gone */ } setTimeout(() => { try { process.kill(-child.pid, 'SIGKILL'); } catch { /* gone */ } }, 3000).unref(); };
91
+ const unreg = () => { cleanups.delete(hard); };
92
+ const hard = () => { try { process.kill(-child.pid, 'SIGKILL'); } catch { /* gone */ } };
93
+ cleanups.add(hard);
94
+ timer = setTimeout(() => { killed = true; kill(); }, timeoutMs);
95
+ child.stdout.on('data', onData); child.stderr.on('data', onData);
96
+ child.on('error', (e) => { clearTimeout(timer); unreg(); resolve({ code: 127, signal: null, error: e.message, timedOut: false }); });
97
+ child.on('close', (code, signal) => { clearTimeout(timer); unreg(); resolve({ code: code === null ? 1 : code, signal, timedOut: killed }); });
98
+ });
99
+ }
100
+
101
+ // forge#4040 (SEC-4): a path-SEGMENT containment check; a string prefix would accept `<repo>-sibling`.
102
+ export function insideRepo(repo, target) {
103
+ const root = path.resolve(repo); const t = path.resolve(target);
104
+ const rel = path.relative(root, t);
105
+ if (!(rel === '' || (rel !== '..' && !rel.startsWith('..' + path.sep) && !path.isAbsolute(rel)))) return false;
106
+ 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 */ }
107
+ return true;
108
+ }
109
+
110
+ class Tail {
111
+ constructor(max = 120) { this.lines = []; this.max = max; this.partial = ''; }
112
+ 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(); } }
113
+ text(n = 40) { const all = this.partial ? [...this.lines, this.partial] : this.lines; return all.slice(-n); }
114
+ }
115
+
116
+ // ---- services ----
117
+ 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))); }); }
118
+ const HEALTH_FLAGS = new Set(['--health-cmd', '--health-interval', '--health-timeout', '--health-retries', '--health-start-period', '--shm-size']);
119
+
120
+ async function startServices(job, log) {
121
+ const started = [];
122
+ const stop = () => { for (const n of started) runSync('docker', ['rm', '-f', n], { timeout: 30000 }); started.length = 0; };
123
+ cleanups.add(stop);
124
+ let net = '';
125
+ const dropNet = () => { if (net) { runSync('docker', ['network', 'rm', net], { timeout: 30000 }); net = ''; } };
126
+ const finish = () => { stop(); dropNet(); cleanups.delete(stop); cleanups.delete(dropNet); };
127
+ cleanups.add(dropNet);
128
+ if (!commandExists('docker') || runSync('docker', ['info'], { timeout: 20000 }).code !== 0) return { error: 'docker is not available', finish };
129
+ // forge#4040 (SEC-6): one docker network per job; nothing runs with --network host.
130
+ net = `forge-ci-net-${process.pid}-${job.id.replace(/[^A-Za-z0-9_.-]/g, '-')}`.slice(0, 120);
131
+ const nr = runSync('docker', ['network', 'create', net], { timeout: 30000 });
132
+ if (nr.code !== 0) { net = ''; return { error: `docker network create failed: ${(nr.err || nr.out).split('\n')[0]}`, finish }; }
133
+ for (const svc of job.services) {
134
+ const name = `forge-ci-${process.pid}-${job.id.replace(/[^A-Za-z0-9_.-]/g, '-')}-${svc.name}`.slice(0, 120);
135
+ const args = ['run', '-d', '--rm', '--name', name, '--network', net, '--network-alias', svc.name];
136
+ const hostPorts = [];
137
+ for (const p of svc.ports || []) {
138
+ const m = /^(?:(\d+):)?(\d+)(?:\/(tcp|udp))?$/.exec(String(p).trim());
139
+ if (!m) return { error: `unsupported port mapping ${p}`, finish };
140
+ 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]}`);
141
+ }
142
+ for (const [k, v] of Object.entries(svc.env || {})) args.push('-e', `${k}=${v}`);
143
+ const opts = shellSplit(svc.options || '') || [];
144
+ let health = false;
145
+ for (let i = 0; i < opts.length; i++) {
146
+ const [flag, inline] = opts[i].split(/=(.*)/s);
147
+ 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; }
148
+ if (flag === '--health-cmd') health = true;
149
+ args.push(inline !== undefined ? `${flag}=${inline}` : flag); if (inline === undefined && opts[i + 1] !== undefined) args.push(opts[++i]);
150
+ }
151
+ args.push(svc.image);
152
+ const r = runSync('docker', args, { timeout: 300000 });
153
+ if (r.code !== 0) return { error: `docker run ${svc.image} failed: ${(r.err || r.out).split('\n')[0]}`, finish };
154
+ started.push(name);
155
+ const until = Date.now() + 90000;
156
+ for (;;) {
157
+ const st = runSync('docker', ['inspect', '-f', '{{.State.Running}}|{{if .State.Health}}{{.State.Health.Status}}{{end}}', name], { timeout: 15000 }).out.trim();
158
+ const [running, h] = st.split('|');
159
+ if (running !== 'true') return { error: `service ${svc.name} exited: ${runSync('docker', ['logs', '--tail', '5', name], { timeout: 15000 }).err.trim().split('\n').pop()}`, finish };
160
+ if (health) { if (h === 'healthy') break; if (h === 'unhealthy') return { error: `service ${svc.name} is unhealthy`, finish }; }
161
+ 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; }
162
+ else { await sleep(2000); break; }
163
+ if (Date.now() > until) return { error: `service ${svc.name} did not become ready in 90s`, finish };
164
+ await sleep(1000);
165
+ }
166
+ }
167
+ return { finish };
168
+ }
169
+
170
+ // ---- result cache ----
171
+ function cacheDir(o) { return o.resultCacheDir || path.join(homeForgeDir(), 'ci-local', 'results'); }
172
+ function configFilesHash(ctx) {
173
+ if (!ctx.configHash) {
174
+ const re = /(^|\/)(\.shellcheckrc|\.eslintrc[^/]*|eslint\.config\.[^/]*|\.prettierrc[^/]*|\.flake8|ruff\.toml|\.ruff\.toml|pyproject\.toml|setup\.cfg|\.markdownlint[^/]*|\.yamllint[^/]*|\.editorconfig|tox\.ini)$/;
175
+ 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') : '');
176
+ }
177
+ return ctx.configHash;
178
+ }
179
+ function changeHash(ctx) {
180
+ if (!ctx.changeHash) ctx.changeHash = sha256(JSON.stringify([ctx.plan.merge_base, ctx.plan.changed_files.map((f) => `${f}:${fileHash(ctx.repo, f)}`)]));
181
+ return ctx.changeHash;
182
+ }
183
+ // forge#4060: service containers are an execution-environment input; they must enter every key derived from stepKey
184
+ // (single-flight, persisted result cache, cost probe). Order-insensitive; absent, undefined and [] fingerprint identically.
185
+ function servicesFp(job) {
186
+ const svcs = Array.isArray(job.services) ? job.services : [];
187
+ return JSON.stringify(svcs.map((s) => {
188
+ const v = s || {};
189
+ const env = v.env || {};
190
+ return [v.name, v.image, Object.keys(env).sort().map((k) => `${k}=${env[k]}`), (v.ports || []).map(String), v.options || ''];
191
+ }).sort((a, b) => JSON.stringify(a) < JSON.stringify(b) ? -1 : 1));
192
+ }
193
+ function stepKey(step, job, ctx) {
194
+ const sel = step.scope || {};
195
+ const first = (shellSplit(step.command) || [])[0] || '';
196
+ const perFile = /:per-file$/.test(sel.selector || '') && sel.files;
197
+ const inputs = perFile
198
+ ? { files: sel.files.map((f) => `${f}:${fileHash(ctx.repo, f)}`), config: configFilesHash(ctx) }
199
+ : { change: changeHash(ctx), selected: sel.files || sel.selector || '' };
200
+ const env = { ...job.env, ...step.env };
201
+ 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, servicesFp(job)]));
202
+ }
203
+
204
+ function durKey(step) { return sha256(`${step.command}\0${step.cwd || ''}`).slice(0, 40); }
205
+
206
+ // forge#4057: cost hint for ordering. wide = some step runs unscoped or widened; est = expected execution ms (a cached step
207
+ // costs 0, otherwise the last recorded duration of that command, else a default). Cheap and narrow jobs go first so a budget
208
+ // hit leaves the most coverage proven.
209
+ function jobCost(job, ctx) {
210
+ let wide = 0; let est = 0;
211
+ for (const step of job.steps) {
212
+ if (step.noop || step.skip) continue;
213
+ if (step.builtin) continue;
214
+ if (!step.scope || step.scope.mode === 'full' || step.scope.widen_reason) wide = 1;
215
+ try {
216
+ if (step.kind !== 'install' && !ctx.noResultCache && fs.existsSync(path.join(cacheDir(ctx.o), stepKey(step, job, ctx) + '.json'))) continue;
217
+ } catch { /* unknown cost */ }
218
+ let d = 60000;
219
+ try { const v = Number(fs.readFileSync(path.join(cacheDir(ctx.o), 'dur', durKey(step)), 'utf8')); if (v >= 0) d = v; } catch { /* no history */ }
220
+ est += d;
221
+ }
222
+ return [wide, est];
223
+ }
224
+
225
+ // ---- one job ----
226
+ function jobEnv(job, step, ctx, ghFiles) {
227
+ const extra = { ...job.env, ...step.env };
228
+ 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 });
229
+ return scrubbedEnv(extra);
230
+ }
231
+ function absorbGhFiles(ghFiles, env) {
232
+ try {
233
+ const lines = fs.readFileSync(ghFiles.env, 'utf8').split('\n'); fs.writeFileSync(ghFiles.env, '');
234
+ for (let i = 0; i < lines.length; i++) {
235
+ const m = /^([A-Za-z_][A-Za-z0-9_]*)<<(.+)$/.exec(lines[i]);
236
+ if (m) { const buf = []; i++; while (i < lines.length && lines[i] !== m[2]) buf.push(lines[i++]); env[m[1]] = buf.join('\n'); continue; }
237
+ const kv = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(lines[i]); if (kv) env[kv[1]] = kv[2];
238
+ }
239
+ const paths = fs.readFileSync(ghFiles.path, 'utf8').split('\n').filter(Boolean); fs.writeFileSync(ghFiles.path, '');
240
+ if (paths.length) env.PATH = paths.join(path.delimiter) + path.delimiter + (env.PATH || process.env.PATH);
241
+ } catch { /* files are best effort */ }
242
+ }
243
+
244
+ async function runJob(job, ctx) {
245
+ const t0 = Date.now(); const res = { id: job.id, name: job.name, source: job.source, status: 'pass', steps: [], reason: '', duration_ms: 0, wait_ms: 0 };
246
+ const jobTimeout = (job.timeout_s || ctx.jobTimeoutS) * 1000;
247
+ const log = (m) => { res.notes = (res.notes || []).concat(m); };
248
+ const runnableSteps = job.steps.filter((s) => !s.noop);
249
+ if (!runnableSteps.length) { res.status = 'pass'; res.reason = 'nothing to run'; res.duration_ms = Date.now() - t0; return res; }
250
+ let svc = null; let release = null;
251
+ // forge#4040 (REG-5): the job timeout clock starts when the job starts executing, never while it waits for a slot.
252
+ // forge#4057: the slot is taken lazily, right before the first step that really executes, so a job made only of skipped,
253
+ // cached or deduplicated steps never queues for one. Time spent here is wait_ms (not duration_ms) and shifts tExec.
254
+ let tExec = Date.now();
255
+ const takeSlot = async () => {
256
+ const tw = Date.now();
257
+ try {
258
+ release = await acquireSlot(job.id, ctx.deadline);
259
+ res.wait_ms += Date.now() - tw;
260
+ if (!release) { res.status = 'deferred'; res.budget = true; res.reason = 'budget exhausted waiting for a machine slot'; return false; }
261
+ if (job.services && job.services.length) {
262
+ svc = await startServices(job, log);
263
+ if (svc.error) { res.status = 'deferred'; res.reason = `service containers: ${svc.error}`; return false; }
264
+ }
265
+ return true;
266
+ } finally { tExec += Date.now() - tw; }
267
+ };
268
+ try {
269
+ 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`) };
270
+ for (const f of Object.values(ghFiles)) fs.writeFileSync(f, '');
271
+ const carried = {}; let failedGroup = null;
272
+ for (const [i, step] of job.steps.entries()) {
273
+ if (step.noop) continue;
274
+ 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' };
275
+ res.steps.push(sr);
276
+ if (step.skip) { sr.status = 'skipped'; sr.reason = step.skip; continue; }
277
+ if (failedGroup && step.group !== failedGroup) break;
278
+ if (Date.now() > ctx.deadline) { res.status = 'deferred'; res.budget = true; res.reason = 'budget exhausted'; return res; }
279
+ let s0 = Date.now();
280
+ if (step.builtin === 'portability') {
281
+ const f = checkPortability({ repo: ctx.repo, from: ctx.plan.merge_base, changed: ctx.plan.changed_files, tracked: ctx.tracked });
282
+ sr.duration_ms = Date.now() - s0;
283
+ 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; }
284
+ continue;
285
+ }
286
+ let key = ''; let fkey = '';
287
+ const cacheable = step.kind !== 'install' && !ctx.noResultCache;
288
+ if (step.kind !== 'install') {
289
+ const base = stepKey(step, job, ctx);
290
+ // forge#4057: identical checks (command, cwd, shell, env, input hash, carried env) run once per invocation. A step that
291
+ // reads or writes the GitHub Actions side files has effects beyond its exit code, so it is never shared.
292
+ if (!(job.source === 'github-actions' && /GITHUB_(ENV|PATH|OUTPUT|STEP_SUMMARY)/.test(step.command))) fkey = sha256(base + JSON.stringify(carried));
293
+ if (cacheable) key = base;
294
+ }
295
+ if (cacheable) {
296
+ const f = path.join(cacheDir(ctx.o), key + '.json');
297
+ if (fs.existsSync(f)) { sr.cache = 'hit'; sr.duration_ms = 0; try { fs.utimesSync(f, new Date(), new Date()); } catch { /* ignore */ } continue; }
298
+ sr.cache = 'miss';
299
+ }
300
+ const argv = shellArgv(step.shell); if (!argv) { res.status = 'deferred'; res.reason = `shell ${step.shell} is not available locally`; return res; }
301
+ const scriptFile = path.join(ctx.tmp, `${job.id.replace(/[^A-Za-z0-9]/g, '_')}-${i}.sh`);
302
+ fs.writeFileSync(scriptFile, step.command + '\n');
303
+ const env = jobEnv(job, step, ctx, ghFiles); Object.assign(env, carried);
304
+ const cwd = path.resolve(ctx.repo, step.cwd || '.');
305
+ if (!insideRepo(ctx.repo, cwd)) { res.status = 'deferred'; res.reason = 'step working-directory escapes the repository'; return res; }
306
+ let prior = fkey ? ctx.flight.get(fkey) : null;
307
+ if (!prior && !release) {
308
+ if (!(await takeSlot())) return res;
309
+ s0 = Date.now();
310
+ prior = fkey ? ctx.flight.get(fkey) : null; // another job may have started the same check while this one queued
311
+ }
312
+ let r; let text; let left;
313
+ if (prior) {
314
+ // single-flight: wait for the first run of this exact check and reuse its result (a failure is shared, never re-run)
315
+ ({ r, text, left } = await prior);
316
+ sr.cache = 'dedup'; sr.duration_ms = 0;
317
+ } else {
318
+ const tail = new Tail();
319
+ left = Math.max(1000, Math.min((step.timeout_s || ctx.jobTimeoutS) * 1000, jobTimeout - (Date.now() - tExec), ctx.deadline - Date.now()));
320
+ const l0 = left;
321
+ const flight = (async () => { const r0 = await exec([...argv, scriptFile], { cwd, env, timeoutMs: l0, onData: (b) => tail.push(b) }); if (r0.error) tail.push(Buffer.from(`${r0.error}\n`)); return { r: r0, text: tail.text(60), left: l0 }; })();
322
+ if (fkey) ctx.flight.set(fkey, flight);
323
+ ({ r, text } = await flight);
324
+ sr.duration_ms = Date.now() - s0;
325
+ 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]; }
326
+ if (r.code === 0) {
327
+ try { // duration_ms in the entry and a per-command duration hint feed the scheduler's cost ordering
328
+ fs.mkdirSync(path.join(cacheDir(ctx.o), 'dur'), { recursive: true });
329
+ if (key) fs.writeFileSync(path.join(cacheDir(ctx.o), key + '.json'), JSON.stringify({ cmd: step.command.slice(0, 300), ts: Date.now(), duration_ms: sr.duration_ms }));
330
+ fs.writeFileSync(path.join(cacheDir(ctx.o), 'dur', durKey(step)), String(sr.duration_ms));
331
+ } catch { /* cache is best effort */ }
332
+ }
333
+ }
334
+ if (r.code === 0) continue;
335
+ 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; }
336
+ if (step.kind === 'install') { res.status = 'deferred'; res.reason = `dependency install failed (offline?): ${step.command.slice(0, 60)}`; return res; }
337
+ if (r.code === 127 && text.some((l) => /command not found|not found$|No such file or directory/.test(l)) && !step.continue_on_error) {
338
+ const tool = (text.find((l) => /command not found/.test(l)) || '').replace(/^.*?([A-Za-z0-9._-]+): command not found.*$/, '$1');
339
+ if (/command not found/.test(text.join('\n'))) { res.status = 'deferred'; res.reason = `tool not installed locally: ${tool || 'unknown'}`; return res; }
340
+ }
341
+ if (step.continue_on_error) { sr.status = 'allowed-failure'; continue; }
342
+ // forge#4040 (REG-4): a failure under a tool-version drift (setup-node 18 vs host 26) cannot be attributed to the change.
343
+ 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; }
344
+ 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;
345
+ failedGroup = step.group || null; if (!failedGroup) break;
346
+ }
347
+ return res;
348
+ } finally {
349
+ if (svc && svc.finish) svc.finish();
350
+ if (release) release();
351
+ res.duration_ms = Math.max(0, Date.now() - t0 - res.wait_ms);
352
+ }
353
+ }
354
+
355
+ // ---- scheduler ----
356
+ export async function runPlan(plan, o = {}) {
357
+ const t0 = Date.now();
358
+ const repo = path.resolve(plan.repo || o.repo || '.');
359
+ 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;
360
+ // forge#4040 (REG-6): one invocation never exceeds the Bash tool cap (10 min); beyond the cap it ends as a partial run
361
+ // (DEGRADED) and a re-run resumes from the result cache.
362
+ const maxInvS = Number(process.env.FORGE_CI_LOCAL_MAX_INVOCATION_SECONDS) || 540;
363
+ const budgetS = Math.min(requestedS, maxInvS); const capped = requestedS > maxInvS;
364
+ const maxParallel = o.maxParallel || plan.config.max_parallel || Number(process.env.FORGE_CI_LOCAL_MAX_PARALLEL) || 4;
365
+ const tmpRoot = fs.mkdtempSync(path.join(process.env.TMPDIR || os.tmpdir(), 'forge-ci-local.'));
366
+ cleanups.add(() => { try { fs.rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* ignore */ } });
367
+ const ctx = {
368
+ plan, repo, o, tmp: tmpRoot, deadline: t0 + budgetS * 1000, jobTimeoutS: Number(process.env.FORGE_CI_LOCAL_JOB_TIMEOUT) || Math.min(900, budgetS),
369
+ tracked: runSync('git', ['-C', repo, 'ls-files', '--cached', '--others', '--exclude-standard', '-z']).out.split('\0').filter(Boolean),
370
+ noResultCache: process.env.FORGE_CI_LOCAL_NO_CACHE === '1',
371
+ flight: new Map(),
372
+ };
373
+ const runnable = plan.jobs.filter((j) => !j.not_applicable && !j.deferred_reason);
374
+ const ids = new Set(runnable.map((j) => j.id));
375
+ const results = new Map(); const running = new Map();
376
+ const pending = new Set(runnable.map((j) => j.id));
377
+ const byId = new Map(runnable.map((j) => [j.id, j]));
378
+ const costs = new Map();
379
+ const cost = (id) => { if (!costs.has(id)) { let c = [1, 0]; try { c = jobCost(byId.get(id), ctx); } catch { /* ordering is a hint */ } costs.set(id, c); } return costs.get(id); };
380
+ const order = new Map([...pending].map((id, i) => [id, i]));
381
+ while (pending.size || running.size) {
382
+ const ready = [];
383
+ for (const id of [...pending]) {
384
+ const j = byId.get(id);
385
+ const needs = (j.needs || []).filter((n) => ids.has(n));
386
+ const blocked = needs.find((n) => results.has(n) && results.get(n).status !== 'pass');
387
+ 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, wait_ms: 0 }; results.set(id, r); printJob(r); continue; }
388
+ if (needs.some((n) => !results.has(n))) continue;
389
+ ready.push(id);
390
+ }
391
+ // narrow/scoped jobs first, then the cheapest; the widest or longest last (ties keep plan order). `needs` is honoured above.
392
+ ready.sort((x, y) => cost(x)[0] - cost(y)[0] || cost(x)[1] - cost(y)[1] || order.get(x) - order.get(y));
393
+ for (const id of ready) {
394
+ if (running.size >= maxParallel) break;
395
+ const j = byId.get(id);
396
+ 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, wait_ms: 0 }); continue; }
397
+ pending.delete(id);
398
+ 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, wait_ms: 0 })).then((r) => { results.set(id, r); running.delete(id); printJob(r); });
399
+ running.set(id, p);
400
+ }
401
+ if (running.size) await Promise.race(running.values()); else if (pending.size) await sleep(10);
402
+ }
403
+ // report
404
+ const all = [];
405
+ for (const j of plan.jobs) {
406
+ if (j.not_applicable) continue;
407
+ if (j.deferred_reason) { all.push({ id: j.id, name: j.name, status: 'deferred', reason: j.deferred_reason, steps: [], duration_ms: 0 }); continue; }
408
+ const r = results.get(j.id); if (r) all.push(r);
409
+ }
410
+ const run_ = all.filter((r) => r.status === 'pass' || r.status === 'fail' || r.status === 'degraded');
411
+ const failed = all.filter((r) => r.status === 'fail'); const defd = all.filter((r) => r.status === 'deferred'); const skipped = all.filter((r) => r.status === 'skipped');
412
+ for (const r of defd) console.log(`CI_LOCAL_DEFERRED: ${r.id} | ${r.name} | ${r.reason}`);
413
+ let checks = 0; let hits = 0; let narrowed = 0; let widened = 0; let selTotal = 0; let selSel = 0;
414
+ for (const r of all) for (const [n, s] of r.steps.entries()) {
415
+ if (!s.scope) continue;
416
+ if (s.cache === 'hit') hits++;
417
+ if (s.scope.mode === 'scoped' || s.scope.mode === 'skip') narrowed++;
418
+ if (s.scope.widen_reason) widened++;
419
+ if (typeof s.scope.selected === 'number' && typeof s.scope.total === 'number') { selSel += s.scope.selected; selTotal += s.scope.total; }
420
+ 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'} wait_ms=${r.wait_ms || 0}`);
421
+ }
422
+ const degraded = all.filter((r) => r.status === 'degraded');
423
+ const budgetHit = all.some((r) => r.budget) || (all.some((r) => r.status === 'deferred' && /budget exhausted/.test(r.reason || '')));
424
+ for (const r of degraded) console.log(`CI_LOCAL_DEGRADED: ${r.id} | ${r.name} | ${r.reason}`);
425
+ // forge#4040: FAIL only for a runnable job that really failed. A timeout or an exhausted budget leaves code unproven:
426
+ // DEGRADED (exit 2), never a pass and never a fail.
427
+ const degReason = degraded.length ? 'job_timeout' : budgetHit ? (capped ? 'budget_capped_at_invocation_limit' : 'budget_exhausted') : '';
428
+ const state = failed.length ? 'FAIL' : degReason ? 'DEGRADED' : 'PASS';
429
+ const dur = Date.now() - t0;
430
+ const waitTotal = all.reduce((a, r) => a + (r.wait_ms || 0), 0);
431
+ const dedups = all.reduce((a, r) => a + r.steps.filter((x) => x.cache === 'dedup').length, 0);
432
+ 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)');
433
+ 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} wait_ms=${waitTotal} dedup=${dedups}${Date.now() > ctx.deadline ? ' budget_exhausted=true' : ''}${plan.no_applicable_jobs && !all.length ? ' no_applicable_jobs=true' : ''}${degReason && state === 'DEGRADED' ? ` reason=${degReason}` : ''}`);
434
+ 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, wait_ms: waitTotal }, null, 2)); } catch { /* ignore */ } }
435
+ try { fs.rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* ignore */ }
436
+ return failed.length ? 1 : state === 'DEGRADED' ? 2 : 0;
437
+ }
438
+
439
+ function printJob(r) {
440
+ const secs = (r.duration_ms / 1000).toFixed(1);
441
+ const waitS = ((r.wait_ms || 0) / 1000).toFixed(1);
442
+ const dedup = r.steps.filter((s) => s.cache === 'dedup').length;
443
+ const stepsRun = r.steps.filter((s) => s.status !== 'skipped').length;
444
+ const hit = r.steps.filter((s) => s.cache === 'hit').length;
445
+ 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}` : ''}${dedup ? ` dedup=${dedup}` : ''} wait=${waitS}s${r.reason ? ` | ${r.reason}` : ''}`);
446
+ if (r.status === 'fail' || r.status === 'degraded') {
447
+ console.log(`${r.status === 'fail' ? 'CI_LOCAL_FAIL' : 'CI_LOCAL_DEGRADED_STEP'}: ${r.id} | step: ${r.failed_step || '?'}${r.exit !== undefined ? ` | exit ${r.exit}` : ''}`);
448
+ if (r.failed_command) console.log(`CI_LOCAL_FAIL_COMMAND: ${r.failed_command.split('\n')[0].slice(0, 300)}`);
449
+ for (const l of r.tail || []) console.log(` | ${l}`);
450
+ }
451
+ }