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.
- package/README.md +2 -0
- package/bin/engine/outcome-codes.mjs +13 -0
- package/bin/engine/phases.mjs +54 -4
- package/bin/engine/runlog.mjs +10 -2
- package/bin/engine/telemetry-seam.mjs +3 -0
- package/bin/engine/telemetry.test.mjs +3 -2
- package/bin/engine-cli.mjs +32 -2
- package/bin/engine.mjs +187 -10
- package/bin/runner.mjs +89 -13
- package/bin/stats/aggregate.mjs +7 -3
- package/bin/stats/cache.mjs +7 -3
- package/bin/stats/github-source.mjs +136 -28
- package/bin/stats/index.mjs +46 -9
- package/bin/stats/render.mjs +1 -1
- package/bin/stats/stats.test.mjs +201 -40
- package/commands/orchestrate/phase-4-execution.md +1 -1
- package/commands/review-pr-agents/protocols.md +19 -0
- package/commands/review-pr-staging.md +4 -4
- package/commands/review-pr.md +31 -20
- package/commands/work-on/build/architect.md +54 -1
- package/commands/work-on/build/implement.md +28 -1
- package/commands/work-on/build/validate.md +321 -29
- package/commands/work-on/build.md +184 -17
- package/commands/work-on/close.md +9 -4
- package/commands/work-on/investigate.md +7 -0
- package/commands/work-on/remediate.md +43 -17
- package/commands/work-on/review.md +43 -22
- package/commands/work-on.md +4 -4
- package/docs/CONFIG.md +93 -2
- package/docs/OUTCOME-CODES.md +5 -1
- package/docs/RELEASE-NOTES.md +15 -0
- package/docs/STATS.md +11 -4
- package/docs/TELEMETRY.md +5 -2
- package/docs/WORK-ON-RUNTIME.md +29 -3
- package/package.json +2 -2
- package/packages/protocol/src/types.js +1 -1
- package/scripts/acceptance-red-green.sh +90 -0
- package/scripts/acceptance-red-green.test.sh +74 -0
- package/scripts/architect-resume-match.test.sh +12 -3
- package/scripts/build-knowledge-index.mjs +8 -5
- package/scripts/builder-resume-match.test.sh +12 -3
- package/scripts/calibration.mjs +5 -2
- package/scripts/cap-findings.test.sh +2 -0
- package/scripts/check-contract-scope.test.sh +2 -0
- package/scripts/check-edge-cases.sh +143 -0
- package/scripts/check-edge-cases.test.sh +175 -0
- package/scripts/check-spec-markers.sh +4 -0
- package/scripts/check-test-tmp-cleanup.sh +56 -0
- package/scripts/check-test-tmp-cleanup.test.sh +69 -0
- package/scripts/ci-local/adapters/azure-pipelines.mjs +113 -0
- package/scripts/ci-local/adapters/bitbucket.mjs +64 -0
- package/scripts/ci-local/adapters/buildkite.mjs +58 -0
- package/scripts/ci-local/adapters/circleci.mjs +101 -0
- package/scripts/ci-local/adapters/common.mjs +115 -0
- package/scripts/ci-local/adapters/explicit.mjs +21 -0
- package/scripts/ci-local/adapters/github-actions.mjs +349 -0
- package/scripts/ci-local/adapters/gitlab-ci.mjs +184 -0
- package/scripts/ci-local/adapters/portability.mjs +104 -0
- package/scripts/ci-local/adapters/task-runners.mjs +67 -0
- package/scripts/ci-local/cli.mjs +50 -0
- package/scripts/ci-local/lib.mjs +379 -0
- package/scripts/ci-local/plan.mjs +186 -0
- package/scripts/ci-local/run.mjs +451 -0
- package/scripts/ci-local/scoping.mjs +531 -0
- package/scripts/ci-local.sh +98 -0
- package/scripts/ci-local.test.sh +1108 -0
- package/scripts/classify-finding.sh +33 -7
- package/scripts/classify-finding.test.sh +23 -0
- package/scripts/classify-test-run.sh +69 -0
- package/scripts/classify-test-run.test.sh +90 -0
- package/scripts/close-parent-c3.test.sh +2 -0
- package/scripts/consumer-sweep.sh +569 -0
- package/scripts/consumer-sweep.test.sh +357 -0
- package/scripts/detect-append-registries.sh +106 -0
- package/scripts/detect-append-registries.test.sh +64 -0
- package/scripts/file-deferred-followups.sh +91 -30
- package/scripts/file-deferred-followups.test.sh +101 -8
- package/scripts/fixtures/ci-local/azure/azure-pipelines.yml +11 -0
- package/scripts/fixtures/ci-local/bitbucket/bitbucket-pipelines.yml +12 -0
- package/scripts/fixtures/ci-local/buildkite/.buildkite/pipeline.yml +10 -0
- package/scripts/fixtures/ci-local/circleci/.circleci/config.yml +29 -0
- package/scripts/fixtures/ci-local/gha-basic/.github/workflows/ci.yml +59 -0
- package/scripts/fixtures/ci-local/gha-basic/.github/workflows/docs.yml +10 -0
- package/scripts/fixtures/ci-local/gha-basic/.github/workflows/nightly.yml +10 -0
- package/scripts/fixtures/ci-local/gha-basic/docs/index.md +1 -0
- package/scripts/fixtures/ci-local/gha-basic/src/greet.sh +2 -0
- package/scripts/fixtures/ci-local/gha-basic/tests/greet.test.sh +3 -0
- package/scripts/fixtures/ci-local/gha-dorny/.github/workflows/ci.yml +51 -0
- package/scripts/fixtures/ci-local/gha-dorny/backend/app.py +1 -0
- package/scripts/fixtures/ci-local/gha-dorny/docs/a.md +1 -0
- package/scripts/fixtures/ci-local/gha-dorny/frontend/app.js +1 -0
- package/scripts/fixtures/ci-local/gha-run/.github/workflows/ci.yml +39 -0
- package/scripts/fixtures/ci-local/gha-run/src/x.txt +1 -0
- package/scripts/fixtures/ci-local/gitlab/.gitlab-ci.yml +43 -0
- package/scripts/fixtures/ci-local/gitlab/docs/d.md +1 -0
- package/scripts/fixtures/ci-local/gitlab/src/a.txt +1 -0
- package/scripts/fixtures/ci-local/monorepo/.github/workflows/ci.yml +13 -0
- package/scripts/fixtures/ci-local/monorepo/package-lock.json +1 -0
- package/scripts/fixtures/ci-local/monorepo/packages/a/src/a.sh +2 -0
- package/scripts/fixtures/ci-local/monorepo/packages/a/tests/a.test.sh +3 -0
- package/scripts/fixtures/ci-local/monorepo/packages/b/src/b.sh +2 -0
- package/scripts/fixtures/ci-local/monorepo/packages/b/tests/b.test.sh +3 -0
- package/scripts/fixtures/ci-local/no-ci/Makefile +6 -0
- package/scripts/fixtures/ci-local/no-ci/package.json +1 -0
- package/scripts/fixtures/ci-local/no-ci/src/a.js +1 -0
- package/scripts/fixtures/ci-local/no-ci/src/b.js +1 -0
- package/scripts/fixtures/ci-local/no-ci/tests/a.test.js +2 -0
- package/scripts/fixtures/ci-local/no-ci/tests/b.test.js +2 -0
- package/scripts/forge-root.test.sh +1 -1
- package/scripts/lib/telemetry.sh +3 -0
- package/scripts/remediate-approved-head.test.sh +2 -0
- package/scripts/remediate-basesync.test.sh +1 -1
- package/scripts/review-acceptance-gate.sh +2 -2
- package/scripts/review-acceptance-gate.test.sh +20 -0
- package/scripts/review-delta.sh +8 -1
- package/scripts/review-delta.test.sh +19 -0
- package/scripts/review-finalize.sh +294 -53
- package/scripts/review-finalize.test.sh +347 -9
- package/scripts/review-pr-delta-wiring.test.sh +13 -0
- package/scripts/review-prepare.sh +5 -3
- package/scripts/review-prepare.test.sh +18 -2
- package/scripts/review-roster.sh +18 -5
- package/scripts/review-roster.test.sh +57 -1
- package/scripts/run-shell-tests.sh +5 -0
- package/scripts/selfreview-plan.sh +86 -0
- package/scripts/selfreview-plan.test.sh +90 -0
- package/scripts/spawn-depth-check.sh +5 -4
- package/scripts/spawn-depth-check.test.sh +3 -0
- package/scripts/telemetry.test.sh +10 -0
- package/scripts/transition-label.sh +26 -0
- package/scripts/trusted-comments.test.sh +2 -1
- package/scripts/verify-phase-trail.test.sh +14 -0
- package/scripts/wait-ci-green.sh +61 -9
- package/scripts/wait-ci-green.test.sh +41 -3
- package/scripts/with-run-tmpdir.sh +20 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
3
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
4
|
+
#
|
|
5
|
+
# check-edge-cases.sh — Enforce that every FORGE:ARCHITECT edge case maps to a test in the diff or an untestable reason.
|
|
6
|
+
#
|
|
7
|
+
# Usage: check-edge-cases.sh --architect <file> --builder <file> --base <ref> [--repo <path>]
|
|
8
|
+
#
|
|
9
|
+
# <architect> is a FORGE:ARCHITECT comment body (or the extracted block); only the text between
|
|
10
|
+
# <!-- FORGE:EDGE_CASES:BEGIN --> and <!-- FORGE:EDGE_CASES:END --> is parsed. One record per line:
|
|
11
|
+
# EDGE: id=e1 kind=error|edge|concurrency description=<free text>
|
|
12
|
+
# Only id= and kind= are parsed; the description is free text and is never interpreted. ids match ^e[0-9]+$.
|
|
13
|
+
# <builder> is a FORGE:BUILDER comment body (may be empty or absent content). Lines parsed:
|
|
14
|
+
# EDGE_TEST: id=e1 test=<path>::<name>
|
|
15
|
+
# EDGE_TEST: id=e1 untestable=<non-empty reason, spaces allowed>
|
|
16
|
+
#
|
|
17
|
+
# An id is covered when it has a non-empty untestable= reason, or a test=<path>::<name> where <name> (fixed-string)
|
|
18
|
+
# appears in a line ADDED to <path> by the change (merge-base(base,HEAD) to the working tree, plus untracked files).
|
|
19
|
+
# Pre-existing tests never satisfy a new edge case.
|
|
20
|
+
#
|
|
21
|
+
# Exit codes:
|
|
22
|
+
# 0 covered, or no edge-case block / empty block (prints "SKIP: ..."); on success prints "MAPPED_TESTS: <n>"
|
|
23
|
+
# 1 UNMAPPED: <id> | TEST_NOT_IN_DIFF: <id> <path>::<name> | EMPTY_REASON: <id> | DUPLICATE_ID: <id>
|
|
24
|
+
# 2 malformed block or EDGE_TEST line, bad arguments, unreadable input, unresolvable base, git failure —
|
|
25
|
+
# NEVER a pass; callers report DEGRADED
|
|
26
|
+
#
|
|
27
|
+
# Offline, values are data (never eval'd). Portable to bash 3.2: no mapfile, no associative arrays.
|
|
28
|
+
|
|
29
|
+
set -uo pipefail
|
|
30
|
+
|
|
31
|
+
ARCH=""; BUILDER=""; BASE=""; REPO="."
|
|
32
|
+
USAGE="usage: check-edge-cases.sh --architect <file> --builder <file> --base <ref> [--repo <path>]"
|
|
33
|
+
die2() { echo "check-edge-cases: $*" >&2; exit 2; }
|
|
34
|
+
|
|
35
|
+
while [ "$#" -gt 0 ]; do
|
|
36
|
+
case "$1" in
|
|
37
|
+
--architect) [ "$#" -ge 2 ] || die2 "--architect needs a value"; ARCH="$2"; shift 2 ;;
|
|
38
|
+
--builder) [ "$#" -ge 2 ] || die2 "--builder needs a value"; BUILDER="$2"; shift 2 ;;
|
|
39
|
+
--base) [ "$#" -ge 2 ] || die2 "--base needs a value"; BASE="$2"; shift 2 ;;
|
|
40
|
+
--repo) [ "$#" -ge 2 ] || die2 "--repo needs a value"; REPO="$2"; shift 2 ;;
|
|
41
|
+
*) die2 "$USAGE" ;;
|
|
42
|
+
esac
|
|
43
|
+
done
|
|
44
|
+
[ -n "$ARCH" ] && [ -n "$BUILDER" ] && [ -n "$BASE" ] || die2 "$USAGE"
|
|
45
|
+
[ -f "$ARCH" ] || die2 "architect file not found: $ARCH"
|
|
46
|
+
[ -f "$BUILDER" ] || die2 "builder file not found: $BUILDER"
|
|
47
|
+
|
|
48
|
+
TMP=$(mktemp -d "${TMPDIR:-/tmp}/check-edge-cases.XXXXXX") || die2 "mktemp failed"
|
|
49
|
+
trap 'rm -rf "$TMP"' EXIT
|
|
50
|
+
|
|
51
|
+
# ---- extract BEGIN..END regions of the architect body --------------------------------------------------
|
|
52
|
+
awk -v out="$TMP/region" '
|
|
53
|
+
index($0, "<!-- FORGE:EDGE_CASES:BEGIN -->") { if (inb) { bad = 1 } inb = 1; seen = 1; next }
|
|
54
|
+
index($0, "<!-- FORGE:EDGE_CASES:END -->") { if (!inb) { bad = 1 } inb = 0; next }
|
|
55
|
+
inb { print > out }
|
|
56
|
+
END { if (inb || bad) exit 3; if (!seen) exit 4 }
|
|
57
|
+
' "$ARCH"
|
|
58
|
+
case $? in
|
|
59
|
+
0) ;;
|
|
60
|
+
4) echo "SKIP: no edge-case block"; exit 0 ;;
|
|
61
|
+
*) die2 "unbalanced FORGE:EDGE_CASES:BEGIN/END markers" ;;
|
|
62
|
+
esac
|
|
63
|
+
|
|
64
|
+
# ---- parse EDGE records ---------------------------------------------------------------------------------
|
|
65
|
+
: > "$TMP/ids"; : > "$TMP/dups"
|
|
66
|
+
EDGE_RE='^EDGE: id=(e[0-9]+) kind=(error|edge|concurrency)([[:space:]]|$)'
|
|
67
|
+
LN=0
|
|
68
|
+
if [ -f "$TMP/region" ]; then
|
|
69
|
+
while IFS= read -r line || [ -n "$line" ]; do
|
|
70
|
+
LN=$((LN+1)); line="${line%$'\r'}"
|
|
71
|
+
trimmed="${line#"${line%%[![:space:]]*}"}"
|
|
72
|
+
case "$trimmed" in ""|'```'*) continue ;; esac
|
|
73
|
+
[[ "$trimmed" =~ $EDGE_RE ]] || die2 "line $LN: malformed EDGE record: $trimmed"
|
|
74
|
+
id="${BASH_REMATCH[1]}"
|
|
75
|
+
if grep -Fxq -- "$id" "$TMP/ids"; then echo "$id" >> "$TMP/dups"; else echo "$id" >> "$TMP/ids"; fi
|
|
76
|
+
done < "$TMP/region"
|
|
77
|
+
fi
|
|
78
|
+
if [ ! -s "$TMP/ids" ]; then echo "SKIP: empty edge-case block"; exit 0; fi
|
|
79
|
+
|
|
80
|
+
# ---- parse EDGE_TEST lines of the builder body ----------------------------------------------------------
|
|
81
|
+
: > "$TMP/maps" # id<TAB>test|untestable<TAB>value
|
|
82
|
+
: > "$TMP/mapdups"
|
|
83
|
+
PRE='^(-[[:space:]]+)?EDGE_TEST:[[:space:]]+id=(e[0-9]+)[[:space:]]+(test|untestable)=(.*)$'
|
|
84
|
+
while IFS= read -r line || [ -n "$line" ]; do
|
|
85
|
+
line="${line%$'\r'}"
|
|
86
|
+
trimmed="${line#"${line%%[![:space:]]*}"}"
|
|
87
|
+
case "$trimmed" in *EDGE_TEST:*) ;; *) continue ;; esac
|
|
88
|
+
case "$trimmed" in EDGE_TEST:*|"- EDGE_TEST:"*) ;; *) continue ;; esac # only line-leading records, not prose mentions
|
|
89
|
+
[[ "$trimmed" =~ $PRE ]] || die2 "malformed EDGE_TEST line: $trimmed"
|
|
90
|
+
mid="${BASH_REMATCH[2]}"; mkind="${BASH_REMATCH[3]}"; mval="${BASH_REMATCH[4]}"
|
|
91
|
+
mval="${mval#"${mval%%[![:space:]]*}"}"; mval="${mval%"${mval##*[![:space:]]}"}"
|
|
92
|
+
if awk -F'\t' -v s="$mid" '$1==s {ok=1} END{exit ok?0:1}' "$TMP/maps"; then echo "$mid" >> "$TMP/mapdups"; continue; fi
|
|
93
|
+
printf '%s\t%s\t%s\n' "$mid" "$mkind" "$mval" >> "$TMP/maps"
|
|
94
|
+
done < "$BUILDER"
|
|
95
|
+
|
|
96
|
+
# ---- repo, base, merge-base -----------------------------------------------------------------------------
|
|
97
|
+
case "$BASE" in ""|-*) die2 "invalid --base: '$BASE'" ;; esac
|
|
98
|
+
git -C "$REPO" rev-parse --is-inside-work-tree >/dev/null 2>&1 || die2 "not a git work tree: $REPO"
|
|
99
|
+
git -C "$REPO" rev-parse --verify --quiet "${BASE}^{commit}" >/dev/null 2>&1 || die2 "cannot resolve base ref '$BASE'"
|
|
100
|
+
MB=$(git -C "$REPO" merge-base "$BASE" HEAD 2>/dev/null) || die2 "no merge base between '$BASE' and HEAD"
|
|
101
|
+
|
|
102
|
+
# added_has <path> <name>: 0 when <name> (fixed string) occurs in a line added to <path>
|
|
103
|
+
added_has() {
|
|
104
|
+
local p="$1" n="$2" rc
|
|
105
|
+
if git -C "$REPO" ls-files --error-unmatch -- "$p" >/dev/null 2>&1; then
|
|
106
|
+
git -C "$REPO" diff -U0 --no-color "$MB" -- "$p" > "$TMP/d" 2>/dev/null || die2 "git diff failed for $p"
|
|
107
|
+
awk -v n="$n" '/^\+\+\+/ {next} /^\+/ { if (index($0, n) > 0) ok=1 } END{exit ok?0:1}' "$TMP/d"; return $?
|
|
108
|
+
fi
|
|
109
|
+
# untracked (new, not yet staged) file: every line is added
|
|
110
|
+
git -C "$REPO" ls-files --others --exclude-standard -- "$p" > "$TMP/u" 2>/dev/null || die2 "git ls-files failed"
|
|
111
|
+
[ -s "$TMP/u" ] || return 1
|
|
112
|
+
[ -f "$REPO/$p" ] || return 1
|
|
113
|
+
awk -v n="$n" 'index($0, n) > 0 {ok=1} END{exit ok?0:1}' "$REPO/$p"; rc=$?
|
|
114
|
+
return "$rc"
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
# ---- check ----------------------------------------------------------------------------------------------
|
|
118
|
+
RC=0; MAPPED=0
|
|
119
|
+
while IFS= read -r d; do [ -n "$d" ] && { echo "DUPLICATE_ID: $d"; RC=1; }; done < "$TMP/dups"
|
|
120
|
+
while IFS= read -r d; do [ -n "$d" ] && { echo "DUPLICATE_ID: $d (EDGE_TEST)"; RC=1; }; done < "$TMP/mapdups"
|
|
121
|
+
PATH_RE='^[A-Za-z0-9_./@+=:,-]+$'
|
|
122
|
+
NAME_RE='^[^[:space:]]{1,200}$'
|
|
123
|
+
while IFS= read -r id; do
|
|
124
|
+
row=$(awk -F'\t' -v s="$id" '$1==s {print; exit}' "$TMP/maps")
|
|
125
|
+
if [ -z "$row" ]; then echo "UNMAPPED: $id"; RC=1; continue; fi
|
|
126
|
+
mkind=$(printf '%s' "$row" | cut -f2); mval=$(printf '%s' "$row" | cut -f3-)
|
|
127
|
+
if [ "$mkind" = untestable ]; then
|
|
128
|
+
[ -n "$mval" ] || { echo "EMPTY_REASON: $id"; RC=1; }
|
|
129
|
+
continue
|
|
130
|
+
fi
|
|
131
|
+
case "$mval" in *::*) ;; *) die2 "$id: test= must be <path>::<name>: $mval" ;; esac
|
|
132
|
+
tpath="${mval%%::*}"; tname="${mval##*::}"
|
|
133
|
+
case "$tpath" in -*) die2 "$id: test path must not start with '-'" ;; esac
|
|
134
|
+
[[ "$tpath" =~ $PATH_RE ]] || die2 "$id: invalid test path: $tpath"
|
|
135
|
+
[[ "$tname" =~ $NAME_RE ]] || die2 "$id: invalid test name"
|
|
136
|
+
if added_has "$tpath" "$tname"; then MAPPED=$((MAPPED+1)); else echo "TEST_NOT_IN_DIFF: $id $tpath::$tname"; RC=1; fi
|
|
137
|
+
done < "$TMP/ids"
|
|
138
|
+
|
|
139
|
+
if [ "$RC" -eq 0 ]; then
|
|
140
|
+
echo "OK: edge cases covered ($(wc -l < "$TMP/ids" | tr -d ' ') id(s))"
|
|
141
|
+
echo "MAPPED_TESTS: $MAPPED"
|
|
142
|
+
fi
|
|
143
|
+
exit "$RC"
|
|
@@ -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
|
|
@@ -175,6 +177,8 @@ MARKER_REGISTRY="
|
|
|
175
177
|
REVIEW_TIER
|
|
176
178
|
REVIEW_STARTED
|
|
177
179
|
SECURITY_AUDIT
|
|
180
|
+
SELFREVIEW
|
|
181
|
+
SELFREVIEW_REPAIR
|
|
178
182
|
SIGNAL_RESOLVED
|
|
179
183
|
SIGNAL_UNRESOLVED
|
|
180
184
|
SIZE_OVERRIDE
|
|
@@ -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
|
+
}
|