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,69 @@
1
+ #!/usr/bin/env bash
2
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
3
+ # SPDX-License-Identifier: AGPL-3.0-or-later
4
+ #
5
+ # Tests for scripts/check-test-tmp-cleanup.sh (passing and failing fixtures, plus the real tree).
6
+ # Run: bash scripts/check-test-tmp-cleanup.test.sh (bash 3.2 compatible)
7
+
8
+ set -u
9
+ DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
10
+ LINT="$DIR/check-test-tmp-cleanup.sh"
11
+ T=$(mktemp -d "${TMPDIR:-/tmp}/check-test-tmp-cleanup-test.XXXXXX")
12
+ trap 'rm -rf "$T"' EXIT
13
+
14
+ PASS=0; FAIL=0
15
+ ok() { PASS=$((PASS + 1)); echo " ok - $1"; }
16
+ bad() { FAIL=$((FAIL + 1)); echo " FAIL - $1"; }
17
+
18
+ # run_case NAME EXPECT_RC EXPECT_OUT_SUBSTR (fixture tree = $T/$NAME)
19
+ run_case() {
20
+ local name="$1" want_rc="$2" want_out="$3" out rc
21
+ out=$(bash "$LINT" "$T/$name" 2>&1); rc=$?
22
+ [ "$rc" -eq "$want_rc" ] && ok "$name: exit $want_rc" || bad "$name: exit $rc, wanted $want_rc ($out)"
23
+ case "$out" in *"$want_out"*) ok "$name: output mentions '$want_out'" ;; *) bad "$name: output lacks '$want_out' ($out)" ;; esac
24
+ }
25
+
26
+ mk() { mkdir -p "$T/$1/bin/tests" "$T/$1/scripts"; }
27
+
28
+ mk pass_node
29
+ printf 'import { tmpDir } from "./_tmp.mjs";\nconst d = tmpDir("fd-x-");\n' > "$T/pass_node/bin/tests/a.test.mjs"
30
+ run_case pass_node 0 "OK"
31
+
32
+ mk pass_node_nested
33
+ mkdir -p "$T/pass_node_nested/bin/stats"
34
+ printf 'import { tmpDir } from "../tests/_tmp.mjs";\nconst d = tmpDir("fd-x-");\n' > "$T/pass_node_nested/bin/stats/a.test.mjs"
35
+ run_case pass_node_nested 0 "OK"
36
+
37
+ mk fail_node
38
+ printf 'import { mkdtempSync } from "node:fs";\nconst d = mkdtempSync("/tmp/fd-x-");\n' > "$T/fail_node/bin/tests/b.test.mjs"
39
+ run_case fail_node 1 "b.test.mjs"
40
+
41
+ mk fail_node_multiline_import
42
+ printf 'import {\n mkdtempSync,\n rmSync,\n} from "node:fs";\n' > "$T/fail_node_multiline_import/bin/tests/c.test.mjs"
43
+ run_case fail_node_multiline_import 1 "c.test.mjs"
44
+
45
+ mk pass_sh
46
+ printf '#!/usr/bin/env bash\nT=$(mktemp -d)\ntrap '"'"'rm -rf "$T"'"'"' EXIT\n' > "$T/pass_sh/scripts/a.test.sh"
47
+ run_case pass_sh 0 "OK"
48
+
49
+ mk fail_sh
50
+ printf '#!/usr/bin/env bash\nT=$(mktemp -d)\necho hi\n' > "$T/fail_sh/scripts/b.test.sh"
51
+ run_case fail_sh 1 "b.test.sh"
52
+
53
+ mk fail_sh_trap_not_exit
54
+ printf '#!/usr/bin/env bash\nT=$(mktemp)\ntrap '"'"'echo x'"'"' INT\n' > "$T/fail_sh_trap_not_exit/scripts/c.test.sh"
55
+ run_case fail_sh_trap_not_exit 1 "c.test.sh"
56
+
57
+ mk pass_no_temp
58
+ printf 'const x = 1;\n' > "$T/pass_no_temp/bin/tests/d.test.mjs"
59
+ printf '#!/usr/bin/env bash\n# mktemp is only mentioned in a comment\n' > "$T/pass_no_temp/scripts/d.test.sh"
60
+ run_case pass_no_temp 0 "OK"
61
+
62
+ out=$(bash "$LINT" "$T/does-not-exist" 2>&1); rc=$?
63
+ [ "$rc" -eq 2 ] && ok "bad ROOT exits 2" || bad "bad ROOT exit $rc ($out)"
64
+
65
+ out=$(bash "$LINT" 2>&1); rc=$?
66
+ [ "$rc" -eq 0 ] && ok "lint is clean on the real repo tree" || bad "real tree has offenders: $out"
67
+
68
+ echo "passed=$PASS failed=$FAIL"
69
+ [ "$FAIL" -eq 0 ]
@@ -0,0 +1,113 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ //
4
+ // azure-pipelines.mjs - azure-pipelines.yml -> normalised jobs. pr: triggers, stages/jobs/steps, script/bash steps,
5
+ // a small set of known no-op tasks, $(Var) macros from declared and predefined variables. Templates, deployment jobs,
6
+ // variable groups, container jobs and unknown tasks are deferred-to-ci.
7
+
8
+ import os from 'node:os';
9
+ import { Unsupported, branchMatches, matchesFilter, jobId, deferred, notApplicable, hostClass, DEPLOY_NAME } from '../lib.mjs';
10
+ import { stepsFromScript, loadDoc, parseSafe, baseJob } from './common.mjs';
11
+
12
+ export const id = 'azure-pipelines';
13
+ export const DEF_RE = /^azure-pipelines\.ya?ml$/;
14
+ const NOOP_TASKS = /^(NodeTool|UsePythonVersion|GoTool|JavaToolInstaller|UseDotNet|Cache|PublishBuildArtifacts|PublishTestResults|PublishCodeCoverageResults|PublishPipelineArtifact)@/;
15
+
16
+ function varMap(v) {
17
+ const m = {}; if (!v) return m;
18
+ if (Array.isArray(v)) { for (const x of v) { if (x.group) throw new Unsupported('variable group'); if (x.template) throw new Unsupported('variable template'); if (x.name) m[x.name] = String(x.value ?? ''); } } else for (const [k, x] of Object.entries(v)) m[k] = String(x && typeof x === 'object' ? x.value ?? '' : x);
19
+ return m;
20
+ }
21
+
22
+ function expandMacros(text, vars) {
23
+ if (/\$\{\{/.test(text)) throw new Unsupported('compile-time ${{ }} expression');
24
+ return String(text).replace(/\$\(([A-Za-z0-9_.]+)\)/g, (_, n) => { if (n in vars) return vars[n]; throw new Unsupported(`variable $(${n})`); });
25
+ }
26
+
27
+ function prOk(doc, a) {
28
+ const pr = doc.pr;
29
+ if (pr === 'none' || pr === false) return 'pr: none';
30
+ if (pr === undefined || pr === null) return '';
31
+ const br = Array.isArray(pr) ? { include: pr } : (pr.branches || {});
32
+ if (br.include && !branchMatches(a.baseBranch, [].concat(br.include))) return `pr branches exclude base ${a.baseBranch}`;
33
+ if (br.exclude && branchMatches(a.baseBranch, [].concat(br.exclude))) return `pr branches exclude base ${a.baseBranch}`;
34
+ if (pr.paths && a.changed.length) {
35
+ const inc = pr.paths.include || []; const exc = pr.paths.exclude || [];
36
+ if (!a.changed.some((f) => (!inc.length || matchesFilter(f, inc)) && !matchesFilter(f, exc.length ? exc : ['\0']))) return 'pr paths select none of the changed files';
37
+ }
38
+ return '';
39
+ }
40
+
41
+ function condOk(cond) {
42
+ if (cond === undefined) return true;
43
+ const c = String(cond).replace(/\s+/g, '');
44
+ if (/^(succeeded\(\)|succeeded\('[^']*'\)|always\(\)|succeededOrFailed\(\))$/.test(c)) return true;
45
+ if (/^eq\(variables\['Build\.Reason'\],'PullRequest'\)$/.test(c)) return true;
46
+ if (/^ne\(variables\['Build\.Reason'\],'(Manual|Schedule|IndividualCI|BatchedCI)'\)$/.test(c)) return true;
47
+ if (/^(eq|ne)\(variables\['Build\.Reason'\],/.test(c)) return false;
48
+ throw new Unsupported('condition ' + String(cond).slice(0, 60));
49
+ }
50
+
51
+ function osOf(pool) { const img = String((pool && (pool.vmImage || pool.name)) || '').toLowerCase(); if (/windows/.test(img)) return 'win32'; if (/mac/.test(img)) return 'darwin'; if (/ubuntu|linux/.test(img)) return 'linux'; return ''; }
52
+ const runnable = (o) => (o === 'linux' ? hostClass() !== 'win32' : o === hostClass());
53
+
54
+ export function plan(a) {
55
+ const out = []; const warnings = []; const used = [];
56
+ for (const file of a.defFiles(DEF_RE)) {
57
+ const doc = loadDoc(a, file, parseSafe);
58
+ if (doc === undefined) { out.push(deferred(jobId('azure', 'pipeline'), 'azure-pipelines', file, 'pipeline YAML unreadable')); continue; }
59
+ if (!doc || typeof doc !== 'object') continue;
60
+ used.push(file);
61
+ const why = prOk(doc, a);
62
+ if (why) { out.push(notApplicable(jobId('azure', 'pipeline'), 'azure-pipelines', file, why)); continue; }
63
+ let gvars;
64
+ try { gvars = varMap(doc.variables); } catch (e) { out.push(deferred(jobId('azure', 'pipeline'), 'azure-pipelines', file, e.message)); continue; }
65
+ const jobs = [];
66
+ if (Array.isArray(doc.stages)) for (const st of doc.stages) for (const j of st.jobs || []) jobs.push({ j, pool: doc.pool, stage: st.stage });
67
+ else if (Array.isArray(doc.jobs)) for (const j of doc.jobs) jobs.push({ j, pool: doc.pool });
68
+ else if (Array.isArray(doc.steps)) jobs.push({ j: { job: 'build', steps: doc.steps }, pool: doc.pool });
69
+ for (const { j, pool, stage } of jobs) {
70
+ const key = j.job || j.deployment || j.template || 'job';
71
+ const jid = jobId('azure', stage || '', key); const name = j.displayName || key;
72
+ const def = (r) => out.push(deferred(jid, 'azure-pipelines', name, r));
73
+ try {
74
+ if (j.template) { def('job template'); continue; }
75
+ if (j.deployment) { def('deployment job'); continue; }
76
+ if (j.container || j.services) { def('container/service job'); continue; }
77
+ if (DEPLOY_NAME.test(key)) { def('deploy/publish job'); continue; }
78
+ if (!condOk(j.condition)) { out.push(notApplicable(jid, 'azure-pipelines', name, 'condition is false for pull requests')); continue; }
79
+ const jvars = { ...gvars, ...varMap(j.variables) };
80
+ const p = j.pool || pool; const o = osOf(p);
81
+ if (!o) { def('unknown agent pool'); continue; }
82
+ if (!runnable(o)) { def(`${o} agent (host is ${hostClass()})`); continue; }
83
+ const pre = { 'Build.SourcesDirectory': a.repo, 'Build.Repository.LocalPath': a.repo, 'Build.SourceVersion': a.headSha, 'Build.SourceBranch': `refs/heads/${a.headBranch || ''}`, 'Build.SourceBranchName': a.headBranch || '', 'System.PullRequest.TargetBranch': a.baseBranch, 'System.PullRequest.SourceBranch': a.headBranch || '', 'Build.Reason': 'PullRequest', 'Agent.OS': { linux: 'Linux', darwin: 'Darwin', win32: 'Windows_NT' }[hostClass()], 'Agent.TempDirectory': os.tmpdir(), 'Pipeline.Workspace': a.repo, 'Build.ArtifactStagingDirectory': os.tmpdir() };
84
+ const vars = { ...pre, ...jvars };
85
+ let combos = [{}]; let tag; let variants = 0;
86
+ const mx = j.strategy && j.strategy.matrix;
87
+ if (mx && typeof mx === 'object') { const ks = Object.keys(mx); combos = ks.map((k) => ({ __n: k, ...mx[k] })); variants = combos.length - 1; tag = ks[0]; }
88
+ const env = {}; for (const [k, v] of Object.entries(combos[0])) if (k !== '__n') { env[k] = String(v); vars[k] = String(v); }
89
+ const steps = []; let defer = '';
90
+ for (const s of j.steps || []) {
91
+ if (s.condition !== undefined && !condOk(s.condition)) continue;
92
+ if (s.template || s.download || s.getPackage) { defer = defer || 'step template/download'; break; }
93
+ if (s.checkout !== undefined) continue;
94
+ if (s.task) {
95
+ if (NOOP_TASKS.test(s.task)) continue;
96
+ if (/^Npm@1$/.test(s.task) && s.inputs && ['ci', 'install'].includes(s.inputs.command || 'install')) { steps.push({ name: s.displayName || 'npm ' + (s.inputs.command || 'install'), command: 'npm ' + (s.inputs.command || 'install'), cwd: '.', env: {}, kind: 'install' }); continue; }
97
+ defer = defer || `unknown task ${s.task}`; break;
98
+ }
99
+ const body = s.script ?? s.bash;
100
+ if (body === undefined) { defer = defer || (s.pwsh || s.powershell ? 'PowerShell step' : 'unsupported step'); break; }
101
+ const sh = s.bash !== undefined ? 'bash' : undefined;
102
+ const r = stepsFromScript(expandMacros(body, vars), { name: s.displayName || 'script', cwd: s.workingDirectory ? expandMacros(s.workingDirectory, vars) : '.', env: Object.fromEntries(Object.entries(s.env || {}).map(([k, v]) => [k, expandMacros(v, vars)])), shell: sh, repo: a.repo, continue_on_error: s.continueOnError === true });
103
+ if (r.defer) { defer = defer || r.defer; break; }
104
+ steps.push(...r.steps);
105
+ }
106
+ if (defer) { def(defer); continue; }
107
+ out.push(baseJob({ id: jid, source: 'azure-pipelines', name, env: { ...env, BUILD_SOURCESDIRECTORY: a.repo }, steps, needs: [].concat(j.dependsOn || []).map((d) => jobId('azure', stage || '', d)), matrix: tag, timeout_s: j.timeoutInMinutes ? Number(j.timeoutInMinutes) * 60 : undefined }));
108
+ if (variants) out.push(deferred(jobId(jid, 'variants'), 'azure-pipelines', name, `${variants} matrix variant(s) not run locally`));
109
+ } catch (e) { if (e instanceof Unsupported) { def(`unsupported construct: ${e.message}`); continue; } throw e; }
110
+ }
111
+ }
112
+ return { jobs: out, warnings, used };
113
+ }
@@ -0,0 +1,64 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ //
4
+ // bitbucket.mjs - bitbucket-pipelines.yml -> normalised jobs. Pull-request (else default) pipeline, step/parallel/stage
5
+ // items, condition.changesets path filters, definition services on host networking. Pipes, deployments, manual
6
+ // triggers, OIDC and credential-like variables are deferred-to-ci.
7
+
8
+ import { Unsupported, branchMatches, matchesFilter, jobId, deferred, notApplicable, secretLikeVar, hostClass, DEPLOY_NAME } from '../lib.mjs';
9
+ import { stepsFromScript, loadDoc, parseSafe, baseJob, globList } from './common.mjs';
10
+
11
+ export const id = 'bitbucket';
12
+ export const DEF_RE = /^bitbucket-pipelines\.ya?ml$/;
13
+
14
+ function flatten(items, parallel = false, out = []) {
15
+ for (const it of items || []) {
16
+ if (it.step) out.push({ step: it.step, parallel });
17
+ else if (it.parallel) flatten(Array.isArray(it.parallel) ? it.parallel : it.parallel.steps, true, out);
18
+ else if (it.stage) flatten(it.stage.steps, parallel, out);
19
+ }
20
+ return out;
21
+ }
22
+
23
+ export function plan(a) {
24
+ const out = []; const warnings = []; const used = [];
25
+ for (const file of a.defFiles(DEF_RE)) {
26
+ const doc = loadDoc(a, file, parseSafe);
27
+ if (doc === undefined) { out.push(deferred(jobId('bitbucket', 'pipeline'), 'bitbucket', file, 'pipeline YAML unreadable')); continue; }
28
+ if (!doc || !doc.pipelines) continue;
29
+ used.push(file);
30
+ let items = null; let which = 'default';
31
+ const pr = doc.pipelines['pull-requests'];
32
+ if (pr) for (const [pat, v] of Object.entries(pr)) if (branchMatches(a.headBranch || '', [pat])) { items = v; which = `pull-requests:${pat}`; break; }
33
+ if (!items && Array.isArray(doc.pipelines.default)) items = doc.pipelines.default;
34
+ if (!items) { out.push(notApplicable(jobId('bitbucket', 'pipeline'), 'bitbucket', file, 'no pull-request or default pipeline matches this branch')); continue; }
35
+ const steps = flatten(items); let prev = []; let prevIds = [];
36
+ steps.forEach((entry, idx) => {
37
+ const s = entry.step; const name = s.name || `step-${idx + 1}`; const jid = jobId('bitbucket', which, String(idx + 1), name);
38
+ const def = (r) => out.push(deferred(jid, 'bitbucket', name, r));
39
+ const needs = entry.parallel ? prev : prevIds;
40
+ const next = () => { if (!entry.parallel) { prevIds = [jid]; prev = [jid]; } else prev = prev.concat([jid]); };
41
+ try {
42
+ if (s.trigger === 'manual') { def('manual trigger'); next(); return; }
43
+ if (s.deployment || DEPLOY_NAME.test(name)) { def('deployment step'); next(); return; }
44
+ if (s.oidc) { def('OIDC token'); next(); return; }
45
+ if (s['runs-on']) { def('self-hosted runner'); next(); return; }
46
+ const inc = s.condition && s.condition.changesets && globList(s.condition.changesets.includePaths);
47
+ if (inc && inc.length && a.changed.length && !a.changed.some((f) => matchesFilter(f, inc))) { out.push(notApplicable(jid, 'bitbucket', name, 'condition.changesets selects none of the changed files')); next(); return; }
48
+ const script = s.script || [];
49
+ if (script.some((l) => typeof l !== 'string')) { def('script uses a pipe'); next(); return; }
50
+ const svcNames = (s.services || []).filter((x) => x !== 'docker');
51
+ const services = svcNames.map((n) => { const d = doc.definitions && doc.definitions.services && doc.definitions.services[n]; if (!d || !d.image) throw new Unsupported(`service ${n}`); return { name: n, image: typeof d.image === 'string' ? d.image : d.image.name, env: d.variables || {}, ports: [], options: '', host_network: true }; });
52
+ if (services.length && hostClass() !== 'linux') { def('service containers need host networking (Linux only)'); next(); return; }
53
+ const sv = secretLikeVar(script.join('\n'));
54
+ if (sv) { def(`references credential-like variable $${sv}`); next(); return; }
55
+ const r = stepsFromScript(script.join('\n'), { name, cwd: '.', env: {}, repo: a.repo });
56
+ if (r.defer) { def(r.defer); next(); return; }
57
+ const env = { BITBUCKET_COMMIT: a.headSha, BITBUCKET_BRANCH: a.headBranch || '', BITBUCKET_PR_DESTINATION_BRANCH: a.baseBranch, BITBUCKET_CLONE_DIR: a.repo };
58
+ out.push(baseJob({ id: jid, source: 'bitbucket', name, env, steps: r.steps, services, needs, timeout_s: s['max-time'] ? Number(s['max-time']) * 60 : undefined, warnings: s.image ? [`image ${typeof s.image === 'string' ? s.image : s.image.name} ignored; running with the host toolchain`] : [] }));
59
+ next();
60
+ } catch (e) { if (e instanceof Unsupported) { def(`unsupported construct: ${e.message}`); next(); return; } throw e; }
61
+ });
62
+ }
63
+ return { jobs: out, warnings, used };
64
+ }
@@ -0,0 +1,58 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ //
4
+ // buildkite.mjs - .buildkite/pipeline.yml -> normalised jobs. Command steps, wait barriers, groups, branch filters and
5
+ // simple `if:` conditions. Plugins, block/input/trigger steps, buildkite-agent calls, parallelism/matrix and
6
+ // credential-like variables are deferred-to-ci.
7
+
8
+ import { Unsupported, evalExpr, branchMatches, jobId, deferred, notApplicable, secretLikeVar, hostClass, DEPLOY_NAME } from '../lib.mjs';
9
+ import { stepsFromScript, loadDoc, parseSafe, baseJob } from './common.mjs';
10
+
11
+ export const id = 'buildkite';
12
+ export const DEF_RE = /^(\.buildkite\/pipeline\.ya?ml|buildkite\.ya?ml)$/;
13
+
14
+ function flat(steps, out = []) { for (const s of steps || []) { if (s && s.group) flat(s.steps, out); else out.push(s); } return out; }
15
+
16
+ export function plan(a) {
17
+ const out = []; const warnings = []; const used = [];
18
+ for (const file of a.defFiles(DEF_RE)) {
19
+ const doc = loadDoc(a, file, parseSafe);
20
+ if (doc === undefined) { out.push(deferred(jobId('buildkite', 'pipeline'), 'buildkite', file, 'pipeline YAML unreadable')); continue; }
21
+ if (!doc || !Array.isArray(doc.steps)) continue;
22
+ used.push(file);
23
+ const roots = { build: { branch: a.headBranch || '', source: 'webhook', pull_request: { id: '1', base_branch: a.baseBranch }, tag: '', message: '', commit: a.headSha }, pipeline: { slug: 'local' } };
24
+ const keys = {}; let barrier = []; const emitted = [];
25
+ const items = flat(doc.steps);
26
+ items.forEach((s, idx) => {
27
+ if (typeof s === 'string' || (s && (s.wait !== undefined || s.waiter !== undefined))) { barrier = emitted.slice(); return; }
28
+ if (!s) return;
29
+ const label = String(s.label || s.name || (typeof s.block === 'string' ? s.block : '') || (typeof s.input === 'string' ? s.input : '') || s.key || `step-${idx + 1}`); const jid = jobId('buildkite', s.key || String(idx + 1), label);
30
+ if (s.key) keys[s.key] = jid;
31
+ const def = (r) => { out.push(deferred(jid, 'buildkite', label, r)); emitted.push(jid); };
32
+ try {
33
+ if (s.block || s.input || s.trigger) { def('block/input/trigger step'); return; }
34
+ if (s.command === undefined && s.commands === undefined) return;
35
+ if (s.skip) { out.push(notApplicable(jid, 'buildkite', label, 'skip is set')); return; }
36
+ if (s.branches && !branchMatches(a.headBranch || '', String(s.branches).split(/\s+/).filter(Boolean))) { out.push(notApplicable(jid, 'buildkite', label, 'branches filter excludes this branch')); return; }
37
+ if (s.if !== undefined && !Boolean(evalExpr(String(s.if).replace(/"([^"]*)"/g, "'$1'"), { __roots: roots }))) { out.push(notApplicable(jid, 'buildkite', label, 'if: is false')); return; }
38
+ if (s.plugins && (Array.isArray(s.plugins) ? s.plugins.length : Object.keys(s.plugins).length)) { def('uses plugins (docker/containers)'); return; }
39
+ const ag = s.agents || {}; const agOs = String(Array.isArray(ag) ? '' : ag.os || '').toLowerCase();
40
+ if (agOs && agOs !== hostClass() && !(agOs === 'linux' && hostClass() === 'darwin')) { def(`agent os=${agOs}`); return; }
41
+ if (s.parallelism || s.matrix) { def('parallelism/matrix step'); return; }
42
+ if (DEPLOY_NAME.test(label)) { def('deploy/publish step'); return; }
43
+ const cmds = [].concat(s.commands || s.command).map(String);
44
+ if (cmds.some((c) => /buildkite-agent/.test(c))) { def('uses buildkite-agent'); return; }
45
+ const env = Object.fromEntries(Object.entries(s.env || {}).map(([k, v]) => [k, String(v)]));
46
+ const sv = secretLikeVar(cmds.join('\n'), new Set(Object.keys(env)));
47
+ if (sv) { def(`references credential-like variable $${sv}`); return; }
48
+ const r = stepsFromScript(cmds.join('\n'), { name: label, cwd: '.', env: {}, repo: a.repo, continue_on_error: s.soft_fail === true, timeout_s: s.timeout_in_minutes ? Number(s.timeout_in_minutes) * 60 : undefined });
49
+ if (r.defer) { def(r.defer); return; }
50
+ const needs = (s.depends_on !== undefined ? [].concat(s.depends_on).map((k) => keys[typeof k === 'string' ? k : k && k.step]).filter(Boolean) : barrier);
51
+ Object.assign(env, { BUILDKITE_BRANCH: a.headBranch || '', BUILDKITE_COMMIT: a.headSha, BUILDKITE_BUILD_CHECKOUT_PATH: a.repo, BUILDKITE_PULL_REQUEST_BASE_BRANCH: a.baseBranch });
52
+ out.push(baseJob({ id: jid, source: 'buildkite', name: label, env, steps: r.steps, needs, timeout_s: s.timeout_in_minutes ? Number(s.timeout_in_minutes) * 60 : undefined }));
53
+ emitted.push(jid);
54
+ } catch (e) { if (e instanceof Unsupported) { def(`unsupported construct: ${e.message}`); return; } throw e; }
55
+ });
56
+ }
57
+ return { jobs: out, warnings, used };
58
+ }
@@ -0,0 +1,101 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ //
4
+ // circleci.mjs - .circleci/config.yml -> normalised jobs. Workflow job lists, branch filters, requires ordering,
5
+ // commands/executors with simple parameters, docker service containers (host network). Contexts (secrets), orbs,
6
+ // approvals, macOS executors off macOS, add_ssh_keys and deploy-named jobs are deferred-to-ci.
7
+
8
+ import { Unsupported, branchMatches, jobId, deferred, notApplicable, secretLikeVar, DEPLOY_NAME, hostClass, parseDuration } from '../lib.mjs';
9
+ import { stepsFromScript, loadDoc, parseSafe, baseJob } from './common.mjs';
10
+
11
+ export const id = 'circleci';
12
+ export const DEF_RE = /^\.circleci\/config\.ya?ml$/;
13
+ const NOOP = new Set(['checkout', 'restore_cache', 'save_cache', 'persist_to_workspace', 'attach_workspace', 'store_artifacts', 'store_test_results', 'setup_remote_docker']);
14
+
15
+ function subst(text, params) {
16
+ return String(text).replace(/<<\s*parameters\.([A-Za-z0-9_-]+)\s*>>/g, (_, k) => { if (!(k in params)) throw new Unsupported(`parameter ${k}`); return String(params[k]); })
17
+ .replace(/<<\s*(pipeline|[^>\s]+)[^>]*>>/g, (m) => { throw new Unsupported(`template ${m.slice(0, 40)}`); });
18
+ }
19
+
20
+ function expand(steps, doc, params, depth, out) {
21
+ if (depth > 4) throw new Unsupported('command nesting');
22
+ for (const st of steps || []) {
23
+ if (typeof st === 'string') { if (NOOP.has(st)) continue; if (doc.commands && doc.commands[st]) { expand(doc.commands[st].steps, doc, defaultsOf(doc.commands[st], {}), depth + 1, out); continue; } throw new Unsupported(`step ${st}`); }
24
+ const [k] = Object.keys(st); const v = st[k];
25
+ if (NOOP.has(k)) continue;
26
+ if (k === 'run') {
27
+ const o = typeof v === 'string' ? { command: v } : v;
28
+ if (o.when === 'on_fail' || o.when === 'never') continue;
29
+ out.push({ name: o.name || 'run', command: subst(o.command, params), env: Object.fromEntries(Object.entries(o.environment || {}).map(([a, b]) => [a, subst(b, params)])), wd: o.working_directory ? subst(o.working_directory, params) : '.' });
30
+ continue;
31
+ }
32
+ if (doc.commands && doc.commands[k]) { expand(doc.commands[k].steps, doc, defaultsOf(doc.commands[k], v || {}), depth + 1, out); continue; }
33
+ throw new Unsupported(`step ${k}`);
34
+ }
35
+ }
36
+ function defaultsOf(def, args) { const p = {}; for (const [k, d] of Object.entries(def.parameters || {})) p[k] = k in args ? args[k] : d && d.default; for (const [k, v] of Object.entries(args)) p[k] = v; return p; }
37
+
38
+ const DEFAULT_PORTS = { postgres: 5432, mysql: 3306, mariadb: 3306, redis: 6379, mongo: 27017, rabbitmq: 5672, memcached: 11211, elasticsearch: 9200 };
39
+ export function declaredPorts(d) {
40
+ if (Array.isArray(d.ports) && d.ports.length) return d.ports.map((p) => (/^\d+$/.test(String(p)) ? `${p}:${p}` : String(p)));
41
+ const base = String(d.image || '').split('/').pop().split(':')[0].replace(/^cimg-/, '');
42
+ const hit = DEFAULT_PORTS[base] || DEFAULT_PORTS[String(d.image || '').split('/').pop().split(':')[0]];
43
+ return hit ? [`${hit}:${hit}`] : [];
44
+ }
45
+
46
+ export function plan(a) {
47
+ const out = []; const warnings = []; const used = [];
48
+ for (const file of a.defFiles(DEF_RE)) {
49
+ const doc = loadDoc(a, file, parseSafe);
50
+ if (doc === undefined) { out.push(deferred(jobId('circleci', 'config'), 'circleci', file, 'config YAML unreadable')); continue; }
51
+ if (!doc || !doc.jobs) continue;
52
+ used.push(file);
53
+ if (doc.orbs) warnings.push(`${file}: orbs declared; jobs/commands from orbs are deferred-to-ci`);
54
+ const entries = [];
55
+ const wfs = Object.entries(doc.workflows || {}).filter(([k]) => k !== 'version');
56
+ if (!wfs.length && doc.jobs.build) entries.push({ job: 'build', name: 'build', cfg: {} });
57
+ for (const [wn, wf] of wfs) {
58
+ if (wf.when !== undefined || wf.unless !== undefined) { out.push(deferred(jobId('circleci', wn), 'circleci', wn, 'workflow when/unless conditions are not evaluated')); continue; }
59
+ for (const item of wf.jobs || []) {
60
+ const [jn, cfg] = typeof item === 'string' ? [item, {}] : [Object.keys(item)[0], Object.values(item)[0] || {}];
61
+ entries.push({ job: jn, name: cfg.name || jn, cfg, wf: wn });
62
+ }
63
+ }
64
+ const nameToId = {}; for (const e of entries) nameToId[e.name] = jobId('circleci', e.wf || '', e.name);
65
+ for (const e of entries) {
66
+ const jid = nameToId[e.name]; const jobDef = doc.jobs[e.job];
67
+ const def = (r) => out.push(deferred(jid, 'circleci', e.name, r));
68
+ try {
69
+ if (!jobDef) { def(`job ${e.job} defined by an orb or missing`); continue; }
70
+ const f = e.cfg.filters && e.cfg.filters.branches;
71
+ if (f && ((f.only && !branchMatches(a.headBranch || '', [].concat(f.only))) || (f.ignore && branchMatches(a.headBranch || '', [].concat(f.ignore))))) { out.push(notApplicable(jid, 'circleci', e.name, 'branch filter excludes this branch')); continue; }
72
+ if (e.cfg.context) { def('uses a context (secrets)'); continue; }
73
+ if (e.cfg.type === 'approval') { def('approval gate'); continue; }
74
+ if (e.cfg.matrix) { def('matrix job'); continue; }
75
+ if (DEPLOY_NAME.test(e.job)) { def('deploy/publish job'); continue; }
76
+ let exec = jobDef; if (jobDef.executor) { const ex = typeof jobDef.executor === 'string' ? jobDef.executor : jobDef.executor.name; if (!doc.executors || !doc.executors[ex]) { def(`executor ${ex} is not defined in this file`); continue; } exec = { ...doc.executors[ex], ...jobDef }; }
77
+ if (exec.macos && hostClass() !== 'darwin') { def('macOS executor'); continue; }
78
+ if (exec.machine && exec.machine.image && /windows/i.test(exec.machine.image)) { def('Windows executor'); continue; }
79
+ const params = defaultsOf(jobDef, Object.fromEntries(Object.entries(e.cfg).filter(([k]) => !['requires', 'filters', 'context', 'name', 'type', 'matrix'].includes(k))));
80
+ const steps = []; expand(jobDef.steps, doc, params, 0, steps);
81
+ if (jobDef.steps.some((s) => s && typeof s === 'object' && Object.keys(s)[0] === 'add_ssh_keys')) { def('add_ssh_keys'); continue; }
82
+ const services = [];
83
+ // forge#4040 (SEC-6): secondary containers never use host networking. Each runs on a per-job docker network and only
84
+ // its declared ports (a `ports:` list, else the image's well-known default) are published on 127.0.0.1.
85
+ if (Array.isArray(exec.docker) && exec.docker.length > 1) for (const d of exec.docker.slice(1)) services.push({ name: String(d.name || d.image).replace(/[^A-Za-z0-9_.-]/g, '-'), image: d.image, env: d.environment || {}, ports: declaredPorts(d), options: '' });
86
+ { const blind = services.filter((sv) => !sv.ports.length); if (blind.length) { def(`unknown service ports: ${blind.map((sv) => sv.image).join(', ')} (no ports: declared and no well-known default; unreachable without host networking)`); continue; } }
87
+ const env = Object.fromEntries(Object.entries({ ...(exec.environment || {}), ...(exec.docker && exec.docker[0] ? exec.docker[0].environment || {} : {}) }).map(([k, v]) => [k, subst(v, params)]));
88
+ const defined = new Set(Object.keys(env));
89
+ const all = steps.map((s) => s.command).join('\n');
90
+ const sv = secretLikeVar(all, defined);
91
+ if (sv) { def(`references credential-like variable $${sv}`); continue; }
92
+ Object.assign(env, { CIRCLE_BRANCH: a.headBranch || '', CIRCLE_SHA1: a.headSha, CIRCLE_WORKING_DIRECTORY: a.repo, CIRCLE_JOB: e.job });
93
+ const built = []; let defer = '';
94
+ for (const s of steps) { const r = stepsFromScript(s.command, { name: s.name, cwd: s.wd, env: s.env, repo: a.repo }); if (r.defer) defer = defer || r.defer; built.push(...r.steps); }
95
+ if (defer) { def(defer); continue; }
96
+ out.push(baseJob({ id: jid, source: 'circleci', name: e.name, env, steps: built, services, needs: (e.cfg.requires || []).map((r) => nameToId[r]).filter(Boolean), timeout_s: parseDuration(jobDef.no_output_timeout) }));
97
+ } catch (err) { if (err instanceof Unsupported) { def(`unsupported construct: ${err.message}`); continue; } throw err; }
98
+ }
99
+ }
100
+ return { jobs: out, warnings, used };
101
+ }
@@ -0,0 +1,115 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ //
4
+ // common.mjs - what every source adapter shares: turning a script into classified steps, and the rule that a step
5
+ // which installs system packages, deploys, or talks to a remote service is never run locally.
6
+
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import { commandExists, shellSplit, parseYaml, Degraded } from '../lib.mjs';
10
+ import { splitBlock } from '../scoping.mjs';
11
+
12
+ const SYSTEM = /^(sudo|apt|apt-get|aptitude|yum|dnf|apk|brew|choco|snap|zypper|pacman|port|winget|scoop|systemctl|service)(\s|$)/;
13
+ const REMOTE = /^(docker\s+(push|login)|podman\s+push|kubectl|helm|terraform\s+(apply|destroy)|pulumi|aws|gcloud|az|gsutil|doctl|flyctl|fly\s+deploy|vercel|netlify|wrangler|heroku|firebase|npm\s+(publish|login|whoami|token)|yarn\s+npm\s+publish|pnpm\s+publish|twine\s+upload|cargo\s+(publish|login)|gem\s+push|git\s+push|git\s+remote\s+add|gh(\s|$)|curl\s+.*\|\s*(ba)?sh|ssh(\s|$)|scp(\s|$)|rsync\s+.*:|sentry-cli|codecov|slack)/;
14
+ const INSTALL = /^(npm\s+(ci|install|i)|pnpm\s+(install|i)|yarn(\s+install)?|bun\s+install|pip3?\s+install|python3?\s+-m\s+pip\s+install|poetry\s+install|uv\s+(sync|pip\s+install)|pipenv\s+install|bundle\s+install|composer\s+install|go\s+mod\s+(download|tidy)|cargo\s+fetch|mvn\s+.*dependency:|gradle(w)?\s+dependencies|dotnet\s+restore)(\s|$)/;
15
+ const SHELL_BUILTIN = new Set(['cd', 'echo', 'test', '[', '[[', 'export', 'set', 'true', 'false', 'exit', 'printf', 'source', '.', 'read', 'eval', 'exec', 'trap', 'unset', 'command', 'type', ':', 'if', 'for', 'while', 'case', 'until', 'function', 'local', 'declare', 'shift', 'wait', 'return', 'mkdir', 'rm', 'cp', 'mv', 'cat', 'ls', 'pwd', 'sleep', 'grep', 'sed', 'awk', 'find', 'sort', 'head', 'tail', 'wc', 'tr', 'cut', 'xargs', 'diff', 'touch', 'chmod', 'env', 'bash', 'sh', 'git', 'tee', 'basename', 'dirname', 'date', 'uname', 'which', 'tar', 'unzip', 'jq', 'yq', 'make', 'node', 'npm', 'npx']);
16
+
17
+ // Classify one command line. Returns { kind, defer?, noop?, note? }.
18
+ // Every command segment of a line: split on `&&`, `||`, `;`, `|`, `&`, newlines, `$(`, backticks and grouping, then drop
19
+ // leading env assignments and control words, so `cd x && aws s3 sync` is judged on `aws`, not on `cd`. A quoted separator
20
+ // over-splits, which only ever defers more (the safe direction).
21
+ const LEAD_WORD = /^(?:(?:then|do|else|elif|if|while|until|time|exec|command|env|nohup|!)\s+)+/;
22
+ function segments(cmd) {
23
+ return cmd.split(/&&|\|\||[;|&\n`(){}]|\$\(/).map((p) => p.trim().replace(/^(?:[A-Za-z_][A-Za-z0-9_]*=\S*\s+)+/, '').replace(LEAD_WORD, '').replace(/^(?:[A-Za-z_][A-Za-z0-9_]*=\S*\s+)+/, '')).filter(Boolean);
24
+ }
25
+
26
+ export function classifyCommand(cmd, repo, cwdRel) {
27
+ const first = cmd.trim().replace(/^(?:[A-Za-z_][A-Za-z0-9_]*=\S*\s+)+/, '');
28
+ const head3 = (t) => t.split(/\s+/).slice(0, 3).join(' ');
29
+ for (const seg of segments(cmd)) {
30
+ if (SYSTEM.test(seg)) return { kind: 'system', defer: `system-level command: ${head3(seg)}` };
31
+ if (REMOTE.test(seg)) return { kind: 'remote', defer: `remote-mutating or service command: ${head3(seg)}` };
32
+ }
33
+ // `curl ... | sh` spans a pipe, so it is matched on the whole line as well as per segment.
34
+ if (/\b(curl|wget)\b[^\n]*\|\s*(ba)?sh\b/.test(cmd)) return { kind: 'remote', defer: `remote-mutating or service command: ${head3(first)}` };
35
+ if (INSTALL.test(first)) {
36
+ if (/^(npm|pnpm|yarn|bun)\b/.test(first)) {
37
+ const nm = path.join(repo, cwdRel || '.', 'node_modules');
38
+ return fs.existsSync(nm) ? { kind: 'install', noop: true, note: 'node_modules present' } : { kind: 'install', note: 'dependency install (offline failure defers the job)' };
39
+ }
40
+ if (/^(go|cargo|mvn|gradle|dotnet)/.test(first)) return { kind: 'install', noop: true, note: 'the build tool fetches dependencies on demand' };
41
+ return { kind: 'install', noop: true, note: 'python/ruby/php dependency install is not run on the host; assumes a provisioned environment' };
42
+ }
43
+ return { kind: 'check' };
44
+ }
45
+
46
+ // Missing local tool for a simple command: the job is deferred, never failed for an absent binary.
47
+ export function missingTool(cmd) {
48
+ const toks = shellSplit(cmd);
49
+ if (!toks) return '';
50
+ const i = toks.findIndex((t) => !/^[A-Za-z_][A-Za-z0-9_]*=/.test(t));
51
+ const t = i >= 0 ? toks[i] : '';
52
+ if (!t || t.includes('/') || SHELL_BUILTIN.has(t) || t.startsWith('.')) return '';
53
+ return commandExists(t) ? '' : t;
54
+ }
55
+
56
+ // Build normalised step objects for one `run` script. Lines are split only when provably independent.
57
+ export function stepsFromScript(script, o) {
58
+ const parts = splitBlock(script);
59
+ const out = []; let defer = '';
60
+ parts.forEach((cmd, idx) => {
61
+ const c = classifyCommand(cmd, o.repo, o.cwd);
62
+ if (c.defer && !defer) defer = c.defer;
63
+ // A multi-line block is also scanned line by line for deferrable commands.
64
+ if (parts.length === 1 && cmd.includes('\n')) for (const l of cmd.split('\n')) { const lc = classifyCommand(l, o.repo, o.cwd); if (lc.defer && !defer) defer = lc.defer; }
65
+ if (c.noop) { out.push({ name: `${o.name || 'run'}`, command: cmd, cwd: o.cwd, env: o.env || {}, shell: o.shell, kind: 'install', noop: true, note: c.note }); return; }
66
+ out.push({
67
+ name: parts.length > 1 ? `${o.name || 'run'} [${idx + 1}/${parts.length}]` : (o.name || 'run'),
68
+ group: parts.length > 1 ? (o.name || 'run') : undefined,
69
+ command: cmd, cwd: o.cwd, env: o.env || {}, shell: o.shell, kind: c.kind, note: c.note,
70
+ continue_on_error: !!o.continue_on_error, timeout_s: o.timeout_s,
71
+ });
72
+ });
73
+ return { steps: out, defer };
74
+ }
75
+
76
+ // Resolve the shell invocation (argv minus the script file) for a CI `shell:` value; null when it cannot run here.
77
+ export function shellArgv(shell) {
78
+ const s = (shell || '').trim();
79
+ if (!s) return ['bash', '-e'];
80
+ if (s === 'bash') return ['bash', '--noprofile', '--norc', '-eo', 'pipefail'];
81
+ if (s === 'sh') return ['sh', '-e'];
82
+ if (/^bash\b/.test(s)) return s.replace(/\s*\{0\}\s*$/, '').split(/\s+/);
83
+ if (/^sh\b/.test(s)) return s.replace(/\s*\{0\}\s*$/, '').split(/\s+/);
84
+ if (s === 'python') return ['python3'];
85
+ return null;
86
+ }
87
+
88
+ export function deepMerge(a, b) {
89
+ if (b === undefined) return a;
90
+ if (a && b && typeof a === 'object' && typeof b === 'object' && !Array.isArray(a) && !Array.isArray(b)) {
91
+ const out = { ...a }; for (const [k, v] of Object.entries(b)) out[k] = k in a ? deepMerge(a[k], v) : v; return out;
92
+ }
93
+ return b;
94
+ }
95
+
96
+ // Trusted base definition merged with the PR's own edits (PR wins per key; a PR cannot delete a base job).
97
+ export function loadDoc(a, file, parse) {
98
+ const { base, head } = a.readDefs(file);
99
+ const b = base != null ? parse(base) : null; const h = head != null ? parse(head) : null;
100
+ if (b === undefined || h === undefined) return undefined;
101
+ if (!b) return h; if (!h) return b;
102
+ return deepMerge(b, h);
103
+ }
104
+
105
+ export function parseSafe(text) {
106
+ // parseYaml throws Degraded when yq is absent (propagate) and Error on malformed YAML (undefined = unreadable).
107
+ try { return parseYaml(text); } catch (e) { if (e instanceof Degraded) throw e; return undefined; }
108
+ }
109
+
110
+ // Env and checkout conventions for jobs derived from non-GitHub CI systems.
111
+ export function baseJob(o) {
112
+ return { id: o.id, source: o.source, name: o.name, cwd: '.', env: o.env || {}, services: o.services || [], steps: o.steps || [], needs: o.needs || [], timeout_s: o.timeout_s, deferred_reason: null, warnings: o.warnings || [], matrix: o.matrix };
113
+ }
114
+
115
+ export function globList(v) { return [].concat(v || []).filter((x) => typeof x === 'string'); }
@@ -0,0 +1,21 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ //
4
+ // explicit.mjs - commands the operator declared in forge.yaml -> verification.ci_local.extra_commands.
5
+ // Always merged into the plan. verification.commands is NOT repeated here: validate V2 already runs it as the fast pre-step.
6
+
7
+ import { jobId } from '../lib.mjs';
8
+ import { baseJob, stepsFromScript } from './common.mjs';
9
+
10
+ export const id = 'explicit';
11
+
12
+ export function plan(a) {
13
+ const jobs = [];
14
+ (a.config.extra_commands || []).forEach((c, i) => {
15
+ const cmd = typeof c === 'string' ? c : c && c.command; if (!cmd) return;
16
+ const name = typeof c === 'object' && c.name ? String(c.name) : cmd.split('\n')[0].slice(0, 60);
17
+ const r = stepsFromScript(String(cmd), { name, cwd: '.', env: {}, repo: a.repo });
18
+ jobs.push(baseJob({ id: jobId('explicit', String(i + 1)), source: 'explicit', name, steps: r.steps }));
19
+ });
20
+ return { jobs, warnings: [], used: [] };
21
+ }