forgedock 1.18.0 → 1.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/bin/engine/outcome-codes.mjs +13 -0
  2. package/bin/engine/phases.mjs +54 -4
  3. package/bin/engine/runlog.mjs +10 -2
  4. package/bin/engine/telemetry-seam.mjs +3 -0
  5. package/bin/engine/telemetry.test.mjs +3 -2
  6. package/bin/engine-cli.mjs +32 -2
  7. package/bin/engine.mjs +139 -7
  8. package/bin/runner.mjs +69 -9
  9. package/bin/stats/aggregate.mjs +7 -3
  10. package/bin/stats/cache.mjs +7 -3
  11. package/bin/stats/github-source.mjs +136 -28
  12. package/bin/stats/index.mjs +46 -9
  13. package/bin/stats/render.mjs +1 -1
  14. package/bin/stats/stats.test.mjs +201 -40
  15. package/commands/review-pr-staging.md +4 -4
  16. package/commands/review-pr.md +25 -15
  17. package/commands/work-on/build/architect.md +53 -0
  18. package/commands/work-on/build/implement.md +19 -0
  19. package/commands/work-on/build/validate.md +320 -28
  20. package/commands/work-on/build.md +66 -9
  21. package/commands/work-on/investigate.md +7 -0
  22. package/commands/work-on/remediate.md +32 -13
  23. package/commands/work-on/review.md +32 -19
  24. package/docs/CONFIG.md +89 -2
  25. package/docs/OUTCOME-CODES.md +5 -1
  26. package/docs/STATS.md +11 -4
  27. package/docs/TELEMETRY.md +5 -2
  28. package/docs/WORK-ON-RUNTIME.md +18 -0
  29. package/package.json +2 -2
  30. package/packages/protocol/src/types.js +1 -1
  31. package/scripts/acceptance-red-green.sh +90 -0
  32. package/scripts/acceptance-red-green.test.sh +74 -0
  33. package/scripts/cap-findings.test.sh +2 -0
  34. package/scripts/check-contract-scope.test.sh +2 -0
  35. package/scripts/check-edge-cases.sh +143 -0
  36. package/scripts/check-edge-cases.test.sh +175 -0
  37. package/scripts/check-spec-markers.sh +2 -0
  38. package/scripts/check-test-tmp-cleanup.sh +56 -0
  39. package/scripts/check-test-tmp-cleanup.test.sh +69 -0
  40. package/scripts/ci-local/adapters/azure-pipelines.mjs +113 -0
  41. package/scripts/ci-local/adapters/bitbucket.mjs +64 -0
  42. package/scripts/ci-local/adapters/buildkite.mjs +58 -0
  43. package/scripts/ci-local/adapters/circleci.mjs +101 -0
  44. package/scripts/ci-local/adapters/common.mjs +115 -0
  45. package/scripts/ci-local/adapters/explicit.mjs +21 -0
  46. package/scripts/ci-local/adapters/github-actions.mjs +349 -0
  47. package/scripts/ci-local/adapters/gitlab-ci.mjs +184 -0
  48. package/scripts/ci-local/adapters/portability.mjs +104 -0
  49. package/scripts/ci-local/adapters/task-runners.mjs +67 -0
  50. package/scripts/ci-local/cli.mjs +50 -0
  51. package/scripts/ci-local/lib.mjs +371 -0
  52. package/scripts/ci-local/plan.mjs +186 -0
  53. package/scripts/ci-local/run.mjs +345 -0
  54. package/scripts/ci-local/scoping.mjs +508 -0
  55. package/scripts/ci-local.sh +97 -0
  56. package/scripts/ci-local.test.sh +919 -0
  57. package/scripts/classify-finding.sh +32 -6
  58. package/scripts/classify-finding.test.sh +23 -0
  59. package/scripts/classify-test-run.sh +69 -0
  60. package/scripts/classify-test-run.test.sh +90 -0
  61. package/scripts/close-parent-c3.test.sh +2 -0
  62. package/scripts/consumer-sweep.sh +569 -0
  63. package/scripts/consumer-sweep.test.sh +357 -0
  64. package/scripts/detect-append-registries.sh +106 -0
  65. package/scripts/detect-append-registries.test.sh +64 -0
  66. package/scripts/fixtures/ci-local/azure/azure-pipelines.yml +11 -0
  67. package/scripts/fixtures/ci-local/bitbucket/bitbucket-pipelines.yml +12 -0
  68. package/scripts/fixtures/ci-local/buildkite/.buildkite/pipeline.yml +10 -0
  69. package/scripts/fixtures/ci-local/circleci/.circleci/config.yml +29 -0
  70. package/scripts/fixtures/ci-local/gha-basic/.github/workflows/ci.yml +59 -0
  71. package/scripts/fixtures/ci-local/gha-basic/.github/workflows/docs.yml +10 -0
  72. package/scripts/fixtures/ci-local/gha-basic/.github/workflows/nightly.yml +10 -0
  73. package/scripts/fixtures/ci-local/gha-basic/docs/index.md +1 -0
  74. package/scripts/fixtures/ci-local/gha-basic/src/greet.sh +2 -0
  75. package/scripts/fixtures/ci-local/gha-basic/tests/greet.test.sh +3 -0
  76. package/scripts/fixtures/ci-local/gha-dorny/.github/workflows/ci.yml +51 -0
  77. package/scripts/fixtures/ci-local/gha-dorny/backend/app.py +1 -0
  78. package/scripts/fixtures/ci-local/gha-dorny/docs/a.md +1 -0
  79. package/scripts/fixtures/ci-local/gha-dorny/frontend/app.js +1 -0
  80. package/scripts/fixtures/ci-local/gha-run/.github/workflows/ci.yml +39 -0
  81. package/scripts/fixtures/ci-local/gha-run/src/x.txt +1 -0
  82. package/scripts/fixtures/ci-local/gitlab/.gitlab-ci.yml +43 -0
  83. package/scripts/fixtures/ci-local/gitlab/docs/d.md +1 -0
  84. package/scripts/fixtures/ci-local/gitlab/src/a.txt +1 -0
  85. package/scripts/fixtures/ci-local/monorepo/.github/workflows/ci.yml +13 -0
  86. package/scripts/fixtures/ci-local/monorepo/package-lock.json +1 -0
  87. package/scripts/fixtures/ci-local/monorepo/packages/a/src/a.sh +2 -0
  88. package/scripts/fixtures/ci-local/monorepo/packages/a/tests/a.test.sh +3 -0
  89. package/scripts/fixtures/ci-local/monorepo/packages/b/src/b.sh +2 -0
  90. package/scripts/fixtures/ci-local/monorepo/packages/b/tests/b.test.sh +3 -0
  91. package/scripts/fixtures/ci-local/no-ci/Makefile +6 -0
  92. package/scripts/fixtures/ci-local/no-ci/package.json +1 -0
  93. package/scripts/fixtures/ci-local/no-ci/src/a.js +1 -0
  94. package/scripts/fixtures/ci-local/no-ci/src/b.js +1 -0
  95. package/scripts/fixtures/ci-local/no-ci/tests/a.test.js +2 -0
  96. package/scripts/fixtures/ci-local/no-ci/tests/b.test.js +2 -0
  97. package/scripts/forge-root.test.sh +1 -1
  98. package/scripts/lib/telemetry.sh +1 -0
  99. package/scripts/remediate-approved-head.test.sh +2 -0
  100. package/scripts/remediate-basesync.test.sh +1 -1
  101. package/scripts/review-acceptance-gate.sh +2 -2
  102. package/scripts/review-acceptance-gate.test.sh +20 -0
  103. package/scripts/review-delta.sh +8 -1
  104. package/scripts/review-delta.test.sh +19 -0
  105. package/scripts/review-finalize.sh +193 -41
  106. package/scripts/review-finalize.test.sh +211 -4
  107. package/scripts/review-pr-delta-wiring.test.sh +13 -0
  108. package/scripts/review-prepare.sh +5 -3
  109. package/scripts/review-prepare.test.sh +18 -2
  110. package/scripts/run-shell-tests.sh +5 -0
  111. package/scripts/telemetry.test.sh +2 -0
  112. package/scripts/trusted-comments.test.sh +2 -1
  113. package/scripts/verify-phase-trail.test.sh +8 -0
  114. package/scripts/wait-ci-green.sh +61 -9
  115. package/scripts/wait-ci-green.test.sh +41 -3
  116. package/scripts/with-run-tmpdir.sh +20 -0
@@ -0,0 +1,175 @@
1
+ #!/usr/bin/env bash
2
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
3
+ # SPDX-License-Identifier: AGPL-3.0-or-later
4
+ #
5
+ # Fixtures for scripts/check-edge-cases.sh. Run: bash scripts/check-edge-cases.test.sh (bash 3.2 compatible)
6
+
7
+ set -u
8
+ DIR=$(cd "$(dirname "$0")" && pwd)
9
+ S="$DIR/check-edge-cases.sh"
10
+ T=$(mktemp -d "${TMPDIR:-/tmp}/check-edge-cases-test.XXXXXX")
11
+ trap 'rm -rf "$T"' EXIT
12
+ pass=0; fail=0
13
+ OUT=""; RC=0
14
+
15
+ g() { git -C "$T/r" -c user.name=t -c user.email=t@example.com -c commit.gpgsign=false "$@" >/dev/null 2>&1; }
16
+ run() { OUT=$(bash "$S" --architect "$T/a" --builder "$T/b" --base staging --repo "$T/r" 2>&1); RC=$?; }
17
+ arch() { { echo '## Plan'; echo '<!-- FORGE:EDGE_CASES:BEGIN -->'; cat; echo '<!-- FORGE:EDGE_CASES:END -->'; echo '<!-- FORGE:ARCHITECT:COMPLETE -->'; } > "$T/a"; }
18
+ builder() { { echo '<!-- FORGE:BUILDER -->'; echo '### Edge Case Coverage'; cat; } > "$T/b"; }
19
+ expect() { # expect <want_rc> <desc> [want_substring]
20
+ if [ "$RC" = "$1" ] && { [ -z "${3:-}" ] || case "$OUT" in *"$3"*) true ;; *) false ;; esac; }; then
21
+ pass=$((pass+1))
22
+ else
23
+ fail=$((fail+1)); echo "FAIL: $2 (rc=$RC, want $1)"; printf '%s\n' "$OUT" | sed 's/^/ /'
24
+ fi
25
+ }
26
+ expect_absent() { case "$OUT" in *"$2"*) fail=$((fail+1)); echo "FAIL: $1 (unexpected '$2')"; printf '%s\n' "$OUT" | sed 's/^/ /' ;; *) pass=$((pass+1)) ;; esac; }
27
+ reset_branch() { g checkout -q -f staging; g branch -q -D feat; g checkout -q -b feat; }
28
+
29
+ mkdir "$T/r"; git -C "$T/r" init -q -b staging >/dev/null 2>&1 || { git -C "$T/r" init -q; g checkout -b staging; }
30
+ mkdir -p "$T/r/tests"
31
+ printf 'def test_existing_case():\n assert True\n' > "$T/r/tests/test_a.py"
32
+ g add -A; g commit -m base
33
+ g checkout -q -b feat
34
+
35
+ E1='EDGE: id=e1 kind=edge description=empty input returns nothing'
36
+ E2='EDGE: id=e2 kind=error description=upstream times out with several classes of error'
37
+ add_test() { printf 'def %s():\n assert True\n' "$1" >> "$T/r/tests/test_a.py"; g add -A; g commit -m "add $1"; }
38
+
39
+ # (a) empty block / absent block pass
40
+ arch </dev/null; builder </dev/null; run; expect 0 "empty block passes" "SKIP"
41
+ printf '## Plan\nno block\n' > "$T/a"; : > "$T/b"; run; expect 0 "absent block passes" "SKIP"
42
+
43
+ # (b) unmapped id fails
44
+ arch <<M
45
+ $E1
46
+ M
47
+ builder </dev/null; run; expect 1 "unmapped id fails" "UNMAPPED: e1"
48
+
49
+ # (c) untestable with a reason passes; (d) empty reason fails
50
+ builder <<M
51
+ EDGE_TEST: id=e1 untestable=pure spec change, no runtime to exercise
52
+ M
53
+ run; expect 0 "untestable with reason passes" "OK:"
54
+ builder <<M
55
+ EDGE_TEST: id=e1 untestable=
56
+ M
57
+ run; expect 1 "empty untestable reason fails" "EMPTY_REASON: e1"
58
+ builder <<M
59
+ EDGE_TEST: id=e1 untestable=
60
+ M
61
+ run; expect 1 "whitespace-only untestable reason fails" "EMPTY_REASON: e1"
62
+
63
+ # (e) mapped test absent from the diff fails; present in added lines passes
64
+ builder <<M
65
+ EDGE_TEST: id=e1 test=tests/test_a.py::test_empty_input
66
+ M
67
+ run; expect 1 "mapped test not in diff fails" "TEST_NOT_IN_DIFF: e1"
68
+ add_test test_empty_input
69
+ run; expect 0 "mapped test in committed added lines passes" "MAPPED_TESTS: 1"
70
+
71
+ # (h) a pre-existing test name never satisfies a new edge case
72
+ builder <<M
73
+ EDGE_TEST: id=e1 test=tests/test_a.py::test_existing_case
74
+ M
75
+ run; expect 1 "pre-existing test name fails" "TEST_NOT_IN_DIFF: e1"
76
+
77
+ # the name must be in the mapped file, not just anywhere in the diff
78
+ mkdir -p "$T/r/other"; printf 'def test_elsewhere():\n pass\n' > "$T/r/other/test_b.py"; g add -A; g commit -m other
79
+ builder <<M
80
+ EDGE_TEST: id=e1 test=tests/test_a.py::test_elsewhere
81
+ M
82
+ run; expect 1 "name added in a different file fails" "TEST_NOT_IN_DIFF: e1"
83
+
84
+ # staged-but-uncommitted and untracked tests count (validate runs before the commit)
85
+ printf 'def test_staged_case():\n pass\n' >> "$T/r/tests/test_a.py"; g add -A
86
+ printf 'def test_untracked_case():\n pass\n' > "$T/r/tests/test_new.py"
87
+ arch <<M
88
+ $E1
89
+ $E2
90
+ M
91
+ builder <<M
92
+ EDGE_TEST: id=e1 test=tests/test_a.py::test_staged_case
93
+ EDGE_TEST: id=e2 test=tests/test_new.py::test_untracked_case
94
+ M
95
+ run; expect 0 "staged and untracked tests count" "MAPPED_TESTS: 2"
96
+ reset_branch
97
+
98
+ # (g) duplicate ids fail (architect side and builder side)
99
+ arch <<M
100
+ $E1
101
+ EDGE: id=e1 kind=error description=same id twice
102
+ M
103
+ builder <<M
104
+ EDGE_TEST: id=e1 untestable=reason one
105
+ M
106
+ run; expect 1 "duplicate architect id fails" "DUPLICATE_ID: e1"
107
+ arch <<M
108
+ $E1
109
+ M
110
+ builder <<M
111
+ EDGE_TEST: id=e1 untestable=reason one
112
+ EDGE_TEST: id=e1 untestable=reason two
113
+ M
114
+ run; expect 1 "duplicate EDGE_TEST id fails" "DUPLICATE_ID: e1"
115
+
116
+ # mixed: one covered, one unmapped -> only the unmapped id is named
117
+ arch <<M
118
+ $E1
119
+ $E2
120
+ M
121
+ builder <<M
122
+ - EDGE_TEST: id=e1 untestable=reason given
123
+ M
124
+ run; expect 1 "bulleted EDGE_TEST line parses; other id unmapped" "UNMAPPED: e2"
125
+ expect_absent "covered id is not reported" "UNMAPPED: e1"
126
+
127
+ # prose mentioning EDGE_TEST mid-line is not a record
128
+ builder <<M
129
+ Note: each id needs an EDGE_TEST: line later.
130
+ EDGE_TEST: id=e1 untestable=reason given
131
+ EDGE_TEST: id=e2 untestable=another reason
132
+ M
133
+ run; expect 0 "mid-line EDGE_TEST mention ignored" "OK:"
134
+
135
+ # (f) bad input is rc 2, never a pass
136
+ builder <<M
137
+ EDGE_TEST: id=e1 untestable=r
138
+ EDGE_TEST: id=e2 test=no-separator
139
+ M
140
+ run; expect 2 "test= without ::name is rc 2" "must be <path>::<name>"
141
+ builder <<M
142
+ EDGE_TEST: id=e1 bogus=1
143
+ M
144
+ run; expect 2 "malformed EDGE_TEST is rc 2" "malformed EDGE_TEST"
145
+ builder <<M
146
+ EDGE_TEST: id=e1 test=--evil::x
147
+ M
148
+ run; expect 2 "test path starting with - is rc 2"
149
+ printf '<!-- FORGE:EDGE_CASES:BEGIN -->\nEDGE: id=bad kind=edge description=x\n<!-- FORGE:EDGE_CASES:END -->\n' > "$T/a"; : > "$T/b"
150
+ run; expect 2 "malformed EDGE id is rc 2" "malformed EDGE record"
151
+ printf '<!-- FORGE:EDGE_CASES:BEGIN -->\nEDGE: id=e1 kind=edge description=x\n' > "$T/a"
152
+ run; expect 2 "unbalanced markers are rc 2" "unbalanced"
153
+ arch <<M
154
+ $E1
155
+ M
156
+ builder <<M
157
+ EDGE_TEST: id=e1 untestable=r
158
+ M
159
+ OUT=$(bash "$S" --architect "$T/a" --builder "$T/b" --base nonexistent --repo "$T/r" 2>&1); RC=$?; expect 2 "unresolvable base is rc 2" "cannot resolve base"
160
+ mkdir -p "$T/notgit"
161
+ OUT=$(bash "$S" --architect "$T/a" --builder "$T/b" --base staging --repo "$T/notgit" 2>&1); RC=$?; expect 2 "non-git repo is rc 2"
162
+ OUT=$(bash "$S" --architect "$T/missing" --builder "$T/b" --base staging --repo "$T/r" 2>&1); RC=$?; expect 2 "missing architect file is rc 2"
163
+ OUT=$(bash "$S" --base staging 2>&1); RC=$?; expect 2 "missing args is rc 2"
164
+
165
+ # description with spaces and punctuation is data, never parsed or executed
166
+ arch <<'M'
167
+ EDGE: id=e1 kind=concurrency description=two writers $(touch /tmp/pwned) `x` "quoted" a=b
168
+ M
169
+ builder <<M
170
+ EDGE_TEST: id=e1 untestable=reason given
171
+ M
172
+ run; expect 0 "free-text description is inert" "OK:"
173
+
174
+ echo "check-edge-cases.test.sh: $pass passed, $fail failed"
175
+ [ "$fail" -eq 0 ]
@@ -108,12 +108,14 @@ MARKER_REGISTRY="
108
108
  DECISION_RECORD
109
109
  DECOMPOSED
110
110
  DECOMPOSE_BLOCKED
111
+ DEGRADED_REREVIEW
111
112
  DEFERRED_FROM
112
113
  DESIGN
113
114
  DIFF_SIZE
114
115
  DISPATCH
115
116
  DISPATCHER
116
117
  DOSSIER_UPDATED
118
+ EDGE_CASES
117
119
  ENGINE_FALLBACK
118
120
  FAST_PATH
119
121
  FINDING_SOURCE
@@ -0,0 +1,56 @@
1
+ #!/usr/bin/env bash
2
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
3
+ # SPDX-License-Identifier: AGPL-3.0-or-later
4
+ #
5
+ # check-test-tmp-cleanup.sh — fail when a test suite creates a temp dir with no cleanup.
6
+ #
7
+ # Conservative text check (no parsing):
8
+ # - bin/**/*.test.mjs that mentions mkdtemp/mkdtempSync must import the shared
9
+ # helper bin/tests/_tmp.mjs (tmpDir registers removal at process exit).
10
+ # - scripts/*.test.sh that calls mktemp must register an EXIT trap.
11
+ # The helper itself (bin/tests/_tmp.mjs) is not a *.test.mjs file and is not scanned.
12
+ #
13
+ # Usage: bash scripts/check-test-tmp-cleanup.sh [ROOT]
14
+ # ROOT defaults to the repo root; the test points it at fixture trees.
15
+ # Exit: 0 clean, 1 offenders found (printed one per line), 2 bad ROOT.
16
+ # Bash 3.2 portable: no mapfile, no associative arrays.
17
+
18
+ set -u
19
+
20
+ SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
21
+ ROOT="${1:-$(cd "$SCRIPT_DIR/.." && pwd)}"
22
+ [ -d "$ROOT" ] || { echo "check-test-tmp-cleanup: ROOT is not a directory: $ROOT" >&2; exit 2; }
23
+
24
+ BAD=0
25
+
26
+ if [ -d "$ROOT/bin" ]; then
27
+ NODE_FILES=$(find "$ROOT/bin" -name '*.test.mjs' -not -path '*/node_modules/*' | sort)
28
+ while IFS= read -r f; do
29
+ [ -n "$f" ] || continue
30
+ grep -qE 'mkdtemp(Sync)?[[:space:]]*\(|^[[:space:]]*mkdtemp(Sync)?,?[[:space:]]*$|import[^;]*mkdtemp' "$f" || continue
31
+ if ! grep -qE "^[[:space:]]*import[^;]*from[[:space:]]+['\"][./]+(tests/)?_tmp\.mjs['\"]" "$f"; then
32
+ echo "VIOLATION: ${f#"$ROOT"/}: uses mkdtemp without importing bin/tests/_tmp.mjs (use tmpDir)"
33
+ BAD=1
34
+ fi
35
+ done <<EOF_NODE
36
+ ${NODE_FILES}
37
+ EOF_NODE
38
+ fi
39
+
40
+ if [ -d "$ROOT/scripts" ]; then
41
+ SH_FILES=$(find "$ROOT/scripts" -maxdepth 1 -name '*.test.sh' | sort)
42
+ while IFS= read -r f; do
43
+ [ -n "$f" ] || continue
44
+ # a mktemp call: skip comment lines and quoted mentions inside grep -F patterns
45
+ grep -E '(^|[^[:alnum:]_.-])mktemp([[:space:]]|\)|$)' "$f" | grep -vE '^[[:space:]]*#' | grep -vE "grep[^|]*mktemp" | grep -q . || continue
46
+ if ! grep -qE '^[^#]*\btrap\b[^#]*\bEXIT\b' "$f"; then
47
+ echo "VIOLATION: ${f#"$ROOT"/}: uses mktemp without an EXIT trap"
48
+ BAD=1
49
+ fi
50
+ done <<EOF_SH
51
+ ${SH_FILES}
52
+ EOF_SH
53
+ fi
54
+
55
+ [ "$BAD" -eq 0 ] && echo "check-test-tmp-cleanup: OK"
56
+ exit "$BAD"
@@ -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
+ }