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,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
+ }