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.
- 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 +139 -7
- package/bin/runner.mjs +69 -9
- 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/review-pr-staging.md +4 -4
- package/commands/review-pr.md +25 -15
- package/commands/work-on/build/architect.md +53 -0
- package/commands/work-on/build/implement.md +19 -0
- package/commands/work-on/build/validate.md +320 -28
- package/commands/work-on/build.md +66 -9
- package/commands/work-on/investigate.md +7 -0
- package/commands/work-on/remediate.md +32 -13
- package/commands/work-on/review.md +32 -19
- package/docs/CONFIG.md +89 -2
- package/docs/OUTCOME-CODES.md +5 -1
- package/docs/STATS.md +11 -4
- package/docs/TELEMETRY.md +5 -2
- package/docs/WORK-ON-RUNTIME.md +18 -0
- 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/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 +2 -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 +371 -0
- package/scripts/ci-local/plan.mjs +186 -0
- package/scripts/ci-local/run.mjs +345 -0
- package/scripts/ci-local/scoping.mjs +508 -0
- package/scripts/ci-local.sh +97 -0
- package/scripts/ci-local.test.sh +919 -0
- package/scripts/classify-finding.sh +32 -6
- 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/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 +1 -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 +193 -41
- package/scripts/review-finalize.test.sh +211 -4
- 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/run-shell-tests.sh +5 -0
- package/scripts/telemetry.test.sh +2 -0
- package/scripts/trusted-comments.test.sh +2 -1
- package/scripts/verify-phase-trail.test.sh +8 -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,357 @@
|
|
|
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/consumer-sweep.sh. Run: bash scripts/consumer-sweep.test.sh (bash 3.2 compatible)
|
|
6
|
+
# Fixture repos are multi-ecosystem (py, ts, go, yaml, sh) and codebase-agnostic.
|
|
7
|
+
|
|
8
|
+
set -u
|
|
9
|
+
DIR=$(cd "$(dirname "$0")" && pwd)
|
|
10
|
+
S="$DIR/consumer-sweep.sh"
|
|
11
|
+
T=$(mktemp -d "${TMPDIR:-/tmp}/consumer-sweep-test.XXXXXX")
|
|
12
|
+
trap 'rm -rf "$T"' EXIT
|
|
13
|
+
pass=0; fail=0
|
|
14
|
+
OUT=""; RC=0
|
|
15
|
+
|
|
16
|
+
g() { git -C "$T/r" -c user.name=t -c user.email=t@example.com -c commit.gpgsign=false "$@" >/dev/null 2>&1; }
|
|
17
|
+
run() { OUT=$(bash "$S" --repo "$T/r" "$@" 2>"$T/err"); RC=$?; }
|
|
18
|
+
has() { # has <desc> <substring>
|
|
19
|
+
case "$OUT" in *"$2"*) pass=$((pass+1)) ;; *) fail=$((fail+1)); echo "FAIL: $1 (missing '$2')"; printf '%s\n' "$OUT" | sed 's/^/ /' ;; esac
|
|
20
|
+
}
|
|
21
|
+
lacks() { # lacks <desc> <substring>
|
|
22
|
+
case "$OUT" in *"$2"*) fail=$((fail+1)); echo "FAIL: $1 (unexpected '$2')"; printf '%s\n' "$OUT" | sed 's/^/ /' ;; *) pass=$((pass+1)) ;; esac
|
|
23
|
+
}
|
|
24
|
+
rc() { if [ "$RC" = "$1" ]; then pass=$((pass+1)); else fail=$((fail+1)); echo "FAIL: $2 (rc=$RC, want $1)"; printf '%s\n' "$OUT" | sed 's/^/ /'; fi; }
|
|
25
|
+
reset() { g checkout -q -f base; g branch -q -D feat; g checkout -q -b feat; }
|
|
26
|
+
|
|
27
|
+
mkdir "$T/r"; git -C "$T/r" init -q -b base >/dev/null 2>&1 || { git -C "$T/r" init -q; g checkout -b base; }
|
|
28
|
+
mkdir -p "$T/r/svc" "$T/r/web" "$T/r/cmd" "$T/r/ci" "$T/r/.github/workflows" "$T/r/vendor" "$T/r/scripts"
|
|
29
|
+
cat > "$T/r/svc/core.py" <<'P'
|
|
30
|
+
def compute_total(items):
|
|
31
|
+
return sum(items)
|
|
32
|
+
TIMEOUT_SECONDS = 30
|
|
33
|
+
P
|
|
34
|
+
echo 'from svc.core import compute_total
|
|
35
|
+
print(compute_total([1]))' > "$T/r/svc/user_a.py"
|
|
36
|
+
echo 'import { compute_total } from "./bridge"
|
|
37
|
+
export const t = compute_total' > "$T/r/web/user_b.ts"
|
|
38
|
+
echo 'package cmd
|
|
39
|
+
// compute_total mirrors the python helper
|
|
40
|
+
func Run() {}' > "$T/r/cmd/main.go"
|
|
41
|
+
cat > "$T/r/ci/settings.yaml" <<'P'
|
|
42
|
+
retry_budget: 3
|
|
43
|
+
pinned_hash: "9f8e7d6c5b4a"
|
|
44
|
+
P
|
|
45
|
+
cat > "$T/r/.github/workflows/verify.yml" <<'P'
|
|
46
|
+
steps:
|
|
47
|
+
- run: test "$(sha256sum policy.txt)" = "9f8e7d6c5b4a"
|
|
48
|
+
P
|
|
49
|
+
cat > "$T/r/scripts/run.sh" <<'P'
|
|
50
|
+
#!/bin/sh
|
|
51
|
+
exec tool --output-dir "$OUT_DIRECTORY" --retry_budget
|
|
52
|
+
read_cfg() { echo retry_budget; }
|
|
53
|
+
parse() { case "$1" in
|
|
54
|
+
--output-dir) shift ;;
|
|
55
|
+
esac; }
|
|
56
|
+
P
|
|
57
|
+
echo 'compute_total vendored copy' > "$T/r/vendor/lib.txt"
|
|
58
|
+
echo 'x = TIMEOUT_SECONDS' > "$T/r/svc/user_c.py"
|
|
59
|
+
printf 'import os\nOUT = os.environ.get("OUT_DIRECTORY")\n' > "$T/r/svc/cfg.py"
|
|
60
|
+
mkdir -p "$T/r/db"
|
|
61
|
+
printf -- '-- see "obsolete_table_ref": dropped\nSELECT 1;\n' > "$T/r/db/schema.sql"
|
|
62
|
+
echo 'report reads obsolete_table_ref nightly' > "$T/r/db/report.txt"
|
|
63
|
+
echo 'docs: pass --output-dir and set OUT_DIRECTORY' > "$T/r/docs.txt"
|
|
64
|
+
g add -A; g commit -m base
|
|
65
|
+
g checkout -q -b feat
|
|
66
|
+
|
|
67
|
+
# (a) renamed function: the OLD name is swept across py, ts, go; the changed file is not a site
|
|
68
|
+
sed -i.bak 's/compute_total/compute_sum/' "$T/r/svc/core.py"; rm -f "$T/r/svc/core.py.bak"
|
|
69
|
+
g add -A; g commit -m rename
|
|
70
|
+
run --range base..feat
|
|
71
|
+
rc 0 "sweep exits 0"
|
|
72
|
+
has "renamed function: py consumer" "compute_total function svc/user_a.py:1"
|
|
73
|
+
has "renamed function: ts consumer" "compute_total function web/user_b.ts:1"
|
|
74
|
+
has "renamed function: go consumer" "compute_total function cmd/main.go:2"
|
|
75
|
+
lacks "changed file is not a site" "svc/core.py"
|
|
76
|
+
lacks "vendored path excluded by default" "vendor/lib.txt"
|
|
77
|
+
|
|
78
|
+
# (b) re-typed function: same name, changed signature line -> still swept
|
|
79
|
+
reset
|
|
80
|
+
printf 'def compute_total(items, scale):\n return sum(items) * scale\nTIMEOUT_SECONDS = 30\n' > "$T/r/svc/core.py"
|
|
81
|
+
g add -A; g commit -m retype
|
|
82
|
+
run --range base..feat
|
|
83
|
+
has "re-typed function swept by name" "compute_total function svc/user_a.py:1"
|
|
84
|
+
|
|
85
|
+
# (c) renamed config key + renamed constant
|
|
86
|
+
reset
|
|
87
|
+
printf 'retry_budget: 3\npinned_hash: "9f8e7d6c5b4a"\n' | sed 's/retry_budget/retry_limit/' > "$T/r/ci/settings.yaml"
|
|
88
|
+
sed -i.bak 's/TIMEOUT_SECONDS = 30/TIMEOUT_LIMIT = 30/' "$T/r/svc/core.py"; rm -f "$T/r/svc/core.py.bak"
|
|
89
|
+
git -C "$T/r" add -A; g commit -m key
|
|
90
|
+
run --range base..feat
|
|
91
|
+
has "changed config key swept (sh consumer)" "retry_budget key scripts/run.sh:"
|
|
92
|
+
has "renamed constant swept" "TIMEOUT_SECONDS const svc/user_c.py:1"
|
|
93
|
+
|
|
94
|
+
# (d) changed literal hash is found in a different workflow
|
|
95
|
+
reset
|
|
96
|
+
sed -i.bak 's/9f8e7d6c5b4a/1a2b3c4d5e6f/' "$T/r/ci/settings.yaml"; rm -f "$T/r/ci/settings.yaml.bak"
|
|
97
|
+
g add -A; g commit -m hash
|
|
98
|
+
run --range base..feat
|
|
99
|
+
has "old literal hash swept" "9f8e7d6c5b4a literal .github/workflows/verify.yml:2"
|
|
100
|
+
lacks "new hash is not swept (added side)" "1a2b3c4d5e6f"
|
|
101
|
+
|
|
102
|
+
# (d2) removed line that starts with "-- " (diff line "--- ...") is content, not a file header
|
|
103
|
+
reset
|
|
104
|
+
printf 'SELECT 1;\n' > "$T/r/db/schema.sql"
|
|
105
|
+
g add -A; g commit -m dropcomment
|
|
106
|
+
run --range base..feat
|
|
107
|
+
has "removed '-- ' line identifier swept" "obsolete_table_ref key db/report.txt:1"
|
|
108
|
+
|
|
109
|
+
# (e) CLI flag and env var on the removed side
|
|
110
|
+
reset
|
|
111
|
+
sed -i.bak -e 's/--output-dir "\$OUT_DIRECTORY"/--out "$OUT_DIR"/' -e 's/^ --output-dir)/ --out)/' "$T/r/scripts/run.sh"; rm -f "$T/r/scripts/run.sh.bak"
|
|
112
|
+
sed -i.bak 's/OUT_DIRECTORY/OUT_DIR/' "$T/r/svc/cfg.py"; rm -f "$T/r/svc/cfg.py.bak"
|
|
113
|
+
g add -A; g commit -m flag
|
|
114
|
+
run --range base..feat
|
|
115
|
+
has "flag swept without dashes" "output-dir flag docs.txt:1"
|
|
116
|
+
has "env var swept" "OUT_DIRECTORY env docs.txt:1"
|
|
117
|
+
|
|
118
|
+
# (f) pure additions are not swept
|
|
119
|
+
reset
|
|
120
|
+
echo 'def brand_new_helper(): pass' >> "$T/r/svc/core.py"; echo 'brand_new_helper' > "$T/r/svc/ref.txt"
|
|
121
|
+
g add -A; g commit -m add
|
|
122
|
+
run --range base..feat
|
|
123
|
+
rc 0 "pure addition exits 0"
|
|
124
|
+
lacks "pure addition not swept" "brand_new_helper"
|
|
125
|
+
|
|
126
|
+
# (g) noise bounds: short ids, stop-list words, cap + truncated row
|
|
127
|
+
reset
|
|
128
|
+
sed -i.bak 's/def compute_total(items):/def compute_total(items):\n # type value/' "$T/r/svc/core.py"; rm -f "$T/r/svc/core.py.bak"
|
|
129
|
+
printf 'def abc(): pass\ndef value(): pass\n' > "$T/r/svc/short.py"; g add -A; g commit -q -m short; g checkout -q -b feat2 2>/dev/null
|
|
130
|
+
git -C "$T/r" rm -q svc/short.py; g commit -m rmshort
|
|
131
|
+
run --range feat..feat2
|
|
132
|
+
lacks "3-char identifier skipped" "abc"
|
|
133
|
+
lacks "stop-list word skipped" "value"
|
|
134
|
+
reset
|
|
135
|
+
for i in 1 2 3 4 5 6; do echo "compute_total $i" > "$T/r/svc/many$i.txt"; done
|
|
136
|
+
g add -A; g commit -m many; g checkout -q -b feat3
|
|
137
|
+
sed -i.bak 's/compute_total/compute_sum/' "$T/r/svc/core.py"; rm -f "$T/r/svc/core.py.bak"; g add -A; g commit -m rename
|
|
138
|
+
run --range feat..feat3 --cap 2
|
|
139
|
+
rows=$(printf '%s\n' "$OUT" | grep -c '^compute_total function ')
|
|
140
|
+
if [ "$rows" = 2 ]; then pass=$((pass+1)); else fail=$((fail+1)); echo "FAIL: cap 2 yields $rows rows"; fi
|
|
141
|
+
has "truncated row at cap" "compute_total truncated +"
|
|
142
|
+
g checkout -q -f base; g branch -q -D feat2 feat3
|
|
143
|
+
|
|
144
|
+
# (h) extra exclude glob
|
|
145
|
+
reset
|
|
146
|
+
sed -i.bak 's/compute_total/compute_sum/' "$T/r/svc/core.py"; rm -f "$T/r/svc/core.py.bak"; g add -A; g commit -m rename
|
|
147
|
+
run --range base..feat --exclude-glob 'web/'
|
|
148
|
+
lacks "configured directory glob excludes web/" "web/user_b.ts"
|
|
149
|
+
has "other consumers remain" "svc/user_a.py"
|
|
150
|
+
|
|
151
|
+
# (i) gate mode: unaddressed, then justified
|
|
152
|
+
printf 'CONSUMERS:\n- svc/user_a.py:1 unaffected: imports the alias only\n' > "$T/c1"
|
|
153
|
+
run --range base..feat --consumers "$T/c1"
|
|
154
|
+
rc 1 "gate fails with unaddressed sites"
|
|
155
|
+
has "unaddressed ts consumer named" "UNADDRESSED: compute_total web/user_b.ts:1"
|
|
156
|
+
has "unaddressed go consumer named" "UNADDRESSED: compute_total cmd/main.go:2"
|
|
157
|
+
lacks "justified site not reported" "svc/user_a.py"
|
|
158
|
+
printf 'CONSUMERS:\n- `svc/user_a.py:9` unaffected: alias\n* web/user_b.ts unaffected: re-export\n- cmd/main.go:2 unaffected: comment only\n- svc/user_c.py updated\n' > "$T/c2"
|
|
159
|
+
run --range base..feat --consumers "$T/c2"
|
|
160
|
+
rc 0 "gate passes when every site is justified"
|
|
161
|
+
has "OK line" "OK:"
|
|
162
|
+
printf 'CONSUMERS:\n- cmd/main.go unaffected:\n' > "$T/c3"
|
|
163
|
+
run --range base..feat --consumers "$T/c3"
|
|
164
|
+
rc 1 "reasonless justification does not count"
|
|
165
|
+
|
|
166
|
+
# (j) worktree mode: uncommitted + untracked changes form the change set
|
|
167
|
+
reset
|
|
168
|
+
sed -i.bak 's/compute_total/compute_sum/' "$T/r/svc/core.py"; rm -f "$T/r/svc/core.py.bak"
|
|
169
|
+
echo 'compute_sum(1)' > "$T/r/svc/user_a.py"
|
|
170
|
+
echo 'compute_total again' > "$T/r/svc/untracked_new.txt"
|
|
171
|
+
run --range base..WORKTREE
|
|
172
|
+
has "unstaged rename swept" "compute_total function web/user_b.ts:1"
|
|
173
|
+
lacks "modified file is in the change set" "svc/user_a.py"
|
|
174
|
+
lacks "untracked file is in the change set" "untracked_new.txt"
|
|
175
|
+
g checkout -q -f base; rm -f "$T/r/svc/untracked_new.txt"; g checkout -q -f feat
|
|
176
|
+
|
|
177
|
+
# (k) identifier mode
|
|
178
|
+
run --ident compute_total --ident retry_budget
|
|
179
|
+
rc 0 "ident mode exits 0"
|
|
180
|
+
has "ident mode lists consumers" "compute_total ident svc/user_a.py:1"
|
|
181
|
+
has "ident mode lists the definition too (no change set)" "svc/core.py"
|
|
182
|
+
run --ident abc
|
|
183
|
+
rc 2 "short --ident is a usage error"
|
|
184
|
+
run --ident '-e'
|
|
185
|
+
rc 2 "dash-leading --ident rejected"
|
|
186
|
+
|
|
187
|
+
# (l) hostile identifier text is data, never evaluated
|
|
188
|
+
reset
|
|
189
|
+
printf 'def "$(touch %s/pwned)"():\n' "$T" > /dev/null
|
|
190
|
+
printf '%s\n' "x = '\$(touch $T/pwned)'" '`touch '"$T"'/pwned2`' "key: ; touch $T/pwned3" > "$T/r/svc/hostile.txt"
|
|
191
|
+
g add -A; g commit -m hostile; g checkout -q -b feat4
|
|
192
|
+
git -C "$T/r" rm -q svc/hostile.txt; g commit -m rmhostile
|
|
193
|
+
run --range feat..feat4
|
|
194
|
+
rc 0 "hostile text sweeps cleanly"
|
|
195
|
+
if [ ! -e "$T/pwned" ] && [ ! -e "$T/pwned2" ] && [ ! -e "$T/pwned3" ]; then pass=$((pass+1)); else fail=$((fail+1)); echo "FAIL: diff text was evaluated"; fi
|
|
196
|
+
g checkout -q -f base; g branch -q -D feat4
|
|
197
|
+
|
|
198
|
+
# (m) failure modes are exit 2, never a pass
|
|
199
|
+
run --range nosuchref..feat
|
|
200
|
+
rc 2 "unresolvable base is exit 2"
|
|
201
|
+
run --range base
|
|
202
|
+
rc 2 "malformed range is exit 2"
|
|
203
|
+
OUT=$(bash "$S" --repo "$T" --range base..feat 2>&1); RC=$?
|
|
204
|
+
rc 2 "non-repo is exit 2"
|
|
205
|
+
run --range base..feat --consumers "$T/missing"
|
|
206
|
+
rc 2 "missing consumers file is exit 2"
|
|
207
|
+
run --ident compute_total --consumers "$T/c1"
|
|
208
|
+
rc 2 "--consumers needs --range"
|
|
209
|
+
run
|
|
210
|
+
rc 2 "no mode is exit 2"
|
|
211
|
+
|
|
212
|
+
# (n) survival: an edit that keeps its identifiers flags nothing; prose words and version bumps flag nothing
|
|
213
|
+
reset
|
|
214
|
+
printf 'cfg = get_setting("alpha_key")\n' > "$T/r/svc/surv.py"
|
|
215
|
+
printf 'The tool MUST validate input.\n- `legacy_option` toggles the old path.\n' > "$T/r/docs-note.md"
|
|
216
|
+
printf '{\n "name": "pkg",\n "version": "1.2.3"\n}\n' > "$T/r/package.json"
|
|
217
|
+
printf 'MUST be set. alpha_key lives here. legacy_option is documented. 1.2.3 is pinned.\n' > "$T/r/svc/consumer_notes.txt"
|
|
218
|
+
g add -A; g commit -m survbase; g checkout -q -b feat5
|
|
219
|
+
printf 'cfg = get_setting("alpha_key", default=None)\n' > "$T/r/svc/surv.py"
|
|
220
|
+
printf 'The tool SHOULD validate input.\n- `legacy_option` toggles the old path.\n' > "$T/r/docs-note.md"
|
|
221
|
+
printf '{\n "name": "pkg",\n "version": "1.2.4"\n}\n' > "$T/r/package.json"
|
|
222
|
+
g add -A; g commit -m surv
|
|
223
|
+
run --range feat..feat5
|
|
224
|
+
rc 0 "survival sweep exits 0"
|
|
225
|
+
lacks "kept identifier is not swept" "alpha_key"
|
|
226
|
+
lacks "prose word change is not swept" "MUST"
|
|
227
|
+
lacks "version bump is not swept" "1.2.3"
|
|
228
|
+
lacks "kept backticked token is not swept" "legacy_option"
|
|
229
|
+
g checkout -q -b feat6
|
|
230
|
+
printf 'The tool SHOULD validate input.\n' > "$T/r/docs-note.md"
|
|
231
|
+
g add -A; g commit -m prose2
|
|
232
|
+
run --range feat5..feat6
|
|
233
|
+
has "removed documented prose option is swept" "legacy_option "
|
|
234
|
+
has "backticked token consumer found" "svc/consumer_notes.txt:1"
|
|
235
|
+
g checkout -q -f base; g branch -q -D feat5 feat6
|
|
236
|
+
|
|
237
|
+
# (o) a removed function whose name is still defined outside the change set is ambiguous, not flagged
|
|
238
|
+
reset
|
|
239
|
+
printf 'def helper_probe(x):\n return x\n' > "$T/r/svc/probe_a.py"
|
|
240
|
+
printf 'def helper_probe(y):\n return y\n' > "$T/r/svc/probe_b.py"
|
|
241
|
+
printf 'helper_probe(1)\n' > "$T/r/svc/probe_use.py"
|
|
242
|
+
g add -A; g commit -m probes; g checkout -q -b feat7
|
|
243
|
+
git -C "$T/r" rm -q svc/probe_a.py; g commit -m dropprobe
|
|
244
|
+
run --range feat..feat7
|
|
245
|
+
has "still-defined name is reported ambiguous" "helper_probe ambiguous"
|
|
246
|
+
lacks "ambiguous name has no site rows" "svc/probe_use.py"
|
|
247
|
+
g checkout -q -f base; g branch -q -D feat7
|
|
248
|
+
|
|
249
|
+
# (p) special-character paths can be justified (gate parser splits on the literal ' unaffected: ', not on characters)
|
|
250
|
+
reset
|
|
251
|
+
mkdir -p "$T/r/app/[id]" "$T/r/app/(auth)" "$T/r/docs"
|
|
252
|
+
echo 'compute_total(page)' > "$T/r/app/[id]/page.tsx"
|
|
253
|
+
echo 'compute_total(login)' > "$T/r/app/(auth)/login.tsx"
|
|
254
|
+
echo 'compute_total notes' > "$T/r/docs/my notes.md"
|
|
255
|
+
g add -A; g commit -m specialpaths; g checkout -q -b feat8
|
|
256
|
+
sed -i.bak 's/compute_total/compute_sum/' "$T/r/svc/core.py"; rm -f "$T/r/svc/core.py.bak"; g add -A; g commit -m rename
|
|
257
|
+
printf -- '- svc/user_a.py:1 unaffected: alias\n- web/user_b.ts:1 unaffected: re-export\n- cmd/main.go:2 unaffected: comment\n' > "$T/c4"
|
|
258
|
+
run --range feat..feat8 --consumers "$T/c4"
|
|
259
|
+
rc 1 "special-char paths stay unaddressed until justified"
|
|
260
|
+
has "bracket path reported" "UNADDRESSED: compute_total app/[id]/page.tsx:1"
|
|
261
|
+
has "paren path reported" "UNADDRESSED: compute_total app/(auth)/login.tsx:1"
|
|
262
|
+
has "space path reported" "UNADDRESSED: compute_total docs/my notes.md:1"
|
|
263
|
+
printf -- '%s\n' '- svc/user_a.py:1 unaffected: alias' '- web/user_b.ts:1 unaffected: re-export' '- cmd/main.go:2 unaffected: comment' \
|
|
264
|
+
'- app/[id]/page.tsx:12 unaffected: route param only' '- `app/(auth)/login.tsx:3` unaffected: login copy' '* docs/my notes.md:2 unaffected: prose' > "$T/c5"
|
|
265
|
+
run --range feat..feat8 --consumers "$T/c5"
|
|
266
|
+
rc 0 "special-char paths are accepted as justified"
|
|
267
|
+
has "gate OK" "OK:"
|
|
268
|
+
printf -- '%s\n' '- app/[id]/page.tsx unresolved: not checked' > "$T/c6"
|
|
269
|
+
run --range feat..feat8 --consumers "$T/c6"
|
|
270
|
+
rc 1 "an unresolved line never justifies a site"
|
|
271
|
+
|
|
272
|
+
# (q) advisory mode records sites but never fails
|
|
273
|
+
run --range feat..feat8 --mode advisory --consumers "$T/c4"
|
|
274
|
+
rc 0 "advisory mode exits 0 with unaddressed sites"
|
|
275
|
+
has "advisory prints ADVISORY lines" "ADVISORY: compute_total app/[id]/page.tsx:1"
|
|
276
|
+
lacks "advisory never prints UNADDRESSED" "UNADDRESSED"
|
|
277
|
+
run --range feat..feat8 --mode sideways
|
|
278
|
+
rc 2 "unknown --mode is exit 2"
|
|
279
|
+
|
|
280
|
+
# (r) budget: an exhausted budget stops the run with the advisory BUDGET_EXCEEDED result, never exit 1
|
|
281
|
+
run --range feat..feat8 --budget-seconds 0
|
|
282
|
+
rc 0 "zero budget exits 0 in sweep mode"
|
|
283
|
+
has "zero budget reports BUDGET_EXCEEDED" "BUDGET_EXCEEDED"
|
|
284
|
+
run --range feat..feat8 --budget-seconds 0 --consumers "$T/c6"
|
|
285
|
+
rc 0 "zero budget never fails the gate"
|
|
286
|
+
has "gate prints BUDGET_EXCEEDED" "BUDGET_EXCEEDED:"
|
|
287
|
+
run --range feat..feat8 --budget-seconds abc
|
|
288
|
+
rc 2 "non-numeric budget is exit 2"
|
|
289
|
+
g checkout -q -f base; g branch -q -D feat8
|
|
290
|
+
|
|
291
|
+
# (s) one grep per run (a PATH shim counts git grep calls) and a 3000-hit identifier completes fast
|
|
292
|
+
reset
|
|
293
|
+
mkdir -p "$T/r/bulk"
|
|
294
|
+
i=1; while [ "$i" -le 30 ]; do
|
|
295
|
+
j=1; : > "$T/r/bulk/b$i.txt"; while [ "$j" -le 100 ]; do echo "call heavy_symbol_x $j" >> "$T/r/bulk/b$i.txt"; j=$((j+1)); done
|
|
296
|
+
i=$((i+1))
|
|
297
|
+
done
|
|
298
|
+
printf 'def heavy_symbol_x():\n pass\ndef other_symbol_y():\n pass\ndef third_symbol_z():\n pass\n' > "$T/r/svc/heavy.py"
|
|
299
|
+
echo 'other_symbol_y third_symbol_z' > "$T/r/svc/other_use.txt"
|
|
300
|
+
g add -A; g commit -m bulk; g checkout -q -b feat9
|
|
301
|
+
printf 'def renamed_a():\n pass\ndef renamed_b():\n pass\ndef renamed_c():\n pass\n' > "$T/r/svc/heavy.py"
|
|
302
|
+
g add -A; g commit -m renamebulk
|
|
303
|
+
mkdir "$T/shim"; REALGIT=$(command -v git)
|
|
304
|
+
printf '#!/bin/sh\ncase " $* " in *" grep "*) echo x >> "%s/grepcount" ;; esac\nexec "%s" "$@"\n' "$T" "$REALGIT" > "$T/shim/git"; chmod +x "$T/shim/git"
|
|
305
|
+
: > "$T/grepcount"
|
|
306
|
+
t0=$SECONDS
|
|
307
|
+
OUT=$(PATH="$T/shim:$PATH" bash "$S" --repo "$T/r" --range feat..feat9 2>"$T/err"); RC=$?
|
|
308
|
+
elapsed=$((SECONDS - t0))
|
|
309
|
+
rc 0 "bulk sweep exits 0"
|
|
310
|
+
n=$(wc -l < "$T/grepcount" | tr -d ' ')
|
|
311
|
+
if [ "$n" = 1 ]; then pass=$((pass+1)); else fail=$((fail+1)); echo "FAIL: expected exactly 1 git grep, saw $n"; fi
|
|
312
|
+
if [ "$elapsed" -lt 3 ]; then pass=$((pass+1)); else fail=$((fail+1)); echo "FAIL: 3000-hit sweep took ${elapsed}s (want < 3s)"; fi
|
|
313
|
+
has "heavy identifier is capped" "heavy_symbol_x truncated +2975"
|
|
314
|
+
rows=$(printf '%s\n' "$OUT" | grep -c '^heavy_symbol_x function ')
|
|
315
|
+
if [ "$rows" = 25 ]; then pass=$((pass+1)); else fail=$((fail+1)); echo "FAIL: heavy identifier rows=$rows (want 25)"; fi
|
|
316
|
+
has "sibling identifiers found with the same single grep" "other_symbol_y function svc/other_use.txt:1"
|
|
317
|
+
g checkout -q -f base; g branch -q -D feat9
|
|
318
|
+
|
|
319
|
+
# (t) --eval-history measures flagged sites per merge and prints mean and max (read-only)
|
|
320
|
+
mkdir "$T/h"
|
|
321
|
+
gh_() { git -C "$T/h" -c user.name=t -c user.email=t@example.com -c commit.gpgsign=false "$@" >/dev/null 2>&1; }
|
|
322
|
+
git -C "$T/h" init -q -b main >/dev/null 2>&1 || { git -C "$T/h" init -q; gh_ checkout -b main; }
|
|
323
|
+
printf 'def legacy_entry():\n pass\n' > "$T/h/lib.py"; echo 'legacy_entry()' > "$T/h/use.py"
|
|
324
|
+
gh_ add -A; gh_ commit -m init
|
|
325
|
+
gh_ checkout -q -b topic1; sed -i.bak 's/legacy_entry/modern_entry/' "$T/h/lib.py"; rm -f "$T/h/lib.py.bak"; gh_ add -A; gh_ commit -m t1
|
|
326
|
+
gh_ checkout -q main; gh_ merge --no-ff -m "Merge topic1" topic1
|
|
327
|
+
gh_ checkout -q -b topic2; echo 'brand_new = 1' > "$T/h/new.py"; gh_ add -A; gh_ commit -m t2
|
|
328
|
+
gh_ checkout -q main; gh_ merge --no-ff -m "Merge topic2" topic2
|
|
329
|
+
before=$(git -C "$T/h" rev-parse HEAD)
|
|
330
|
+
OUT=$(bash "$S" --repo "$T/h" --eval-history 5 2>"$T/err"); RC=$?
|
|
331
|
+
rc 0 "eval-history exits 0"
|
|
332
|
+
has "eval prints the flagged merge" "sites=1"
|
|
333
|
+
has "eval prints mean and max" "mean=0.50 max=1 prs=2"
|
|
334
|
+
has "eval reports the guard verdict" "guard=PASS"
|
|
335
|
+
if [ "$before" = "$(git -C "$T/h" rev-parse HEAD)" ] && [ -z "$(git -C "$T/h" status --short)" ]; then pass=$((pass+1)); else fail=$((fail+1)); echo "FAIL: eval-history modified the repo"; fi
|
|
336
|
+
run --eval-history 2 --range base..feat
|
|
337
|
+
rc 2 "eval-history is exclusive with --range"
|
|
338
|
+
|
|
339
|
+
# forge#4037: a 10,000-line YAML rewrite finishes quickly (per-file token sets, not removed x added), and extraction runs
|
|
340
|
+
# under --budget-seconds: a spent budget reports BUDGET_EXCEEDED, advisory, exit 0.
|
|
341
|
+
mkdir "$T/y"; gy() { git -C "$T/y" -c user.name=t -c user.email=t@example.com -c commit.gpgsign=false "$@" >/dev/null 2>&1; }
|
|
342
|
+
git -C "$T/y" init -q -b base >/dev/null 2>&1
|
|
343
|
+
awk 'BEGIN{for(i=1;i<=10000;i++) printf " svc.old-key-%d: value_%d\n", i, i}' > "$T/y/big.yaml"
|
|
344
|
+
gy add -A; gy commit -m base; gy checkout -q -b feat
|
|
345
|
+
awk 'BEGIN{for(i=1;i<=10000;i++) printf " svc.new-key-%d: value_%d\n", i, i}' > "$T/y/big.yaml"
|
|
346
|
+
gy add -A; gy commit -m rewrite
|
|
347
|
+
start=$(date +%s)
|
|
348
|
+
OUT=$(bash "$S" --repo "$T/y" --range base..feat --budget-seconds 20 --mode advisory 2>"$T/err"); RC=$?
|
|
349
|
+
elapsed=$(( $(date +%s) - start ))
|
|
350
|
+
rc 0 "10k-line yaml rewrite exits 0"
|
|
351
|
+
if [ "$elapsed" -le 20 ]; then pass=$((pass+1)); else fail=$((fail+1)); echo "FAIL: 10k-line yaml rewrite took ${elapsed}s"; fi
|
|
352
|
+
OUT=$(bash "$S" --repo "$T/y" --range base..feat --budget-seconds 0 --mode blocking 2>"$T/err"); RC=$?
|
|
353
|
+
rc 0 "zero budget is advisory exit 0"
|
|
354
|
+
has "zero budget reports BUDGET_EXCEEDED" "BUDGET_EXCEEDED"
|
|
355
|
+
|
|
356
|
+
echo "consumer-sweep.test.sh: $pass passed, $fail failed"
|
|
357
|
+
[ "$fail" -eq 0 ]
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# detect-append-registries.sh — find append-only registry files and prove a merge=union policy is safe (forge#3969).
|
|
3
|
+
# SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
4
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
5
|
+
#
|
|
6
|
+
# A registry is a hand-maintained list that most PRs append to (test paths, cron targets). Two PRs appending at the
|
|
7
|
+
# same spot conflict textually although every line is independent, which costs a base-sync remediation round.
|
|
8
|
+
#
|
|
9
|
+
# Usage:
|
|
10
|
+
# detect-append-registries.sh --detect [--repo <dir>] [--since <git date>] [--min-commits N] [--ratio PCT] [--limit N]
|
|
11
|
+
# Rank files from `git log --numstat`: touched by >= N commits (default 5), >= PCT% of them add-only (default 80).
|
|
12
|
+
# Output, one per line: <touches> <add-only> <path> (sorted by touches, descending). Read-only.
|
|
13
|
+
# detect-append-registries.sh --check-union <path> [--repo <dir>]
|
|
14
|
+
# Prove in a throwaway repo (mktemp -d) that two branches appending different lines to <path> conflict without
|
|
15
|
+
# merge=union and merge cleanly with it, both lines surviving. Never touches the caller's repo or git config.
|
|
16
|
+
# Structured files (JSON/YAML/TOML/XML/lock files) are always rejected: union would corrupt ordering or structure.
|
|
17
|
+
# Exit: 0 detect ok / union safe, 1 union NOT safe, 2 usage error
|
|
18
|
+
# Bash 3.2 portable: no assoc arrays, mapfile, grep -P. Honours no FORGEDOCK_HOME (self-contained).
|
|
19
|
+
set -uo pipefail
|
|
20
|
+
|
|
21
|
+
usage() { sed -n '6,17p' "$0" >&2; exit 2; }
|
|
22
|
+
MODE=""; REPO="."; SINCE="90 days ago"; MIN=5; RATIO=80; LIMIT=20; TARGET=""
|
|
23
|
+
while [ $# -gt 0 ]; do
|
|
24
|
+
case "$1" in
|
|
25
|
+
--detect) MODE=detect ;;
|
|
26
|
+
--check-union) MODE=check; TARGET="${2-}"; [ $# -ge 2 ] || usage; shift ;;
|
|
27
|
+
--repo) REPO="${2-}"; [ $# -ge 2 ] || usage; shift ;;
|
|
28
|
+
--since) SINCE="${2-}"; [ $# -ge 2 ] || usage; shift ;;
|
|
29
|
+
--min-commits) MIN="${2-}"; [ $# -ge 2 ] || usage; shift ;;
|
|
30
|
+
--ratio) RATIO="${2-}"; [ $# -ge 2 ] || usage; shift ;;
|
|
31
|
+
--limit) LIMIT="${2-}"; [ $# -ge 2 ] || usage; shift ;;
|
|
32
|
+
*) usage ;;
|
|
33
|
+
esac
|
|
34
|
+
shift
|
|
35
|
+
done
|
|
36
|
+
[ -n "$MODE" ] || usage
|
|
37
|
+
for n in "$MIN" "$RATIO" "$LIMIT"; do case "$n" in ''|*[!0-9]*) echo "detect-append-registries: numeric option expected, got '$n'" >&2; exit 2 ;; esac; done
|
|
38
|
+
|
|
39
|
+
if [ "$MODE" = detect ]; then
|
|
40
|
+
git -C "$REPO" rev-parse --git-dir >/dev/null 2>&1 || { echo "detect-append-registries: not a git repo: $REPO" >&2; exit 2; }
|
|
41
|
+
# numstat: "<added>\t<deleted>\t<path>"; binaries show "-". Merge commits are skipped (--no-merges).
|
|
42
|
+
LOG=$(git -C "$REPO" log --no-merges --since="$SINCE" --numstat --format= 2>/dev/null) || LOG=""
|
|
43
|
+
printf '%s\n' "$LOG" | awk -F'\t' -v min="$MIN" -v ratio="$RATIO" '
|
|
44
|
+
NF >= 3 && $1 ~ /^[0-9]+$/ && $2 ~ /^[0-9]+$/ {
|
|
45
|
+
p = $3; t[p]++; if ($2 == 0 && $1 > 0) a[p]++
|
|
46
|
+
}
|
|
47
|
+
END { for (p in t) if (t[p] >= min && (a[p] + 0) * 100 >= ratio * t[p]) printf "%d %d %s\n", t[p], a[p] + 0, p }
|
|
48
|
+
' | sort -rn -k1,1 -k2,2 | head -n "$LIMIT"
|
|
49
|
+
exit 0
|
|
50
|
+
fi
|
|
51
|
+
|
|
52
|
+
# --- --check-union
|
|
53
|
+
[ -n "$TARGET" ] || usage
|
|
54
|
+
case "$TARGET" in /*|../*|*/../*|..) echo "detect-append-registries: path must be repo-relative: $TARGET" >&2; exit 2 ;; esac
|
|
55
|
+
case "$TARGET" in *[[:space:]]*|*'['*|*'*'*|*'?'*|*'\'*) echo "UNION UNSAFE: $TARGET (whitespace or glob characters in path)"; exit 1 ;; esac
|
|
56
|
+
case "$(printf '%s' "$TARGET" | tr 'A-Z' 'a-z')" in
|
|
57
|
+
*.json|*.jsonc|*.yml|*.yaml|*.toml|*.xml|*.lock|*lock.json|*.sum|*.csv|*.md)
|
|
58
|
+
echo "UNION UNSAFE: $TARGET is a structured or ordered file type (union can corrupt structure, ordering or uniqueness)"; exit 1 ;;
|
|
59
|
+
esac
|
|
60
|
+
|
|
61
|
+
TMP=$(mktemp -d) || { echo "detect-append-registries: mktemp failed" >&2; exit 2; }
|
|
62
|
+
trap 'rm -rf "$TMP"' EXIT
|
|
63
|
+
# hermetic git: no user config, hooks or signing; never reads or writes the caller's repo configuration
|
|
64
|
+
export GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null GIT_CONFIG_NOSYSTEM=1
|
|
65
|
+
export GIT_AUTHOR_NAME=t GIT_AUTHOR_EMAIL=t@example.invalid GIT_COMMITTER_NAME=t GIT_COMMITTER_EMAIL=t@example.invalid
|
|
66
|
+
W="$TMP/w"
|
|
67
|
+
g() { git -C "$W" -c commit.gpgsign=false -c core.hooksPath=/dev/null "$@"; }
|
|
68
|
+
git init -q "$W" 2>/dev/null || { echo "detect-append-registries: git init failed" >&2; exit 2; }
|
|
69
|
+
g checkout -q -b base 2>/dev/null
|
|
70
|
+
mkdir -p "$W/$(dirname "$TARGET")"
|
|
71
|
+
# seed with the real file's committed content when available, else a small stand-in list
|
|
72
|
+
if git -C "$REPO" cat-file -e "HEAD:$TARGET" 2>/dev/null; then
|
|
73
|
+
git -C "$REPO" show "HEAD:$TARGET" > "$W/$TARGET" 2>/dev/null
|
|
74
|
+
else
|
|
75
|
+
printf 'entry-1\nentry-2\nentry-3\n' > "$W/$TARGET"
|
|
76
|
+
fi
|
|
77
|
+
[ -s "$W/$TARGET" ] && [ -n "$(tail -c1 "$W/$TARGET")" ] && printf '\n' >> "$W/$TARGET"
|
|
78
|
+
g add -A >/dev/null; g commit -q -m seed >/dev/null
|
|
79
|
+
|
|
80
|
+
append_branch() { # name line
|
|
81
|
+
g checkout -q -b "$1" base 2>/dev/null
|
|
82
|
+
printf '%s\n' "$2" >> "$W/$TARGET"
|
|
83
|
+
g add -A >/dev/null; g commit -q -m "append $2" >/dev/null
|
|
84
|
+
}
|
|
85
|
+
append_branch side-a "forge-union-probe-a"
|
|
86
|
+
append_branch side-b "forge-union-probe-b"
|
|
87
|
+
|
|
88
|
+
# 1. without union the same-spot appends must conflict (otherwise the probe proves nothing)
|
|
89
|
+
g checkout -q side-a 2>/dev/null
|
|
90
|
+
if g merge -q --no-edit side-b >/dev/null 2>&1; then
|
|
91
|
+
echo "UNION UNSAFE: $TARGET does not conflict without union (probe is not meaningful)"; exit 1
|
|
92
|
+
fi
|
|
93
|
+
g merge --abort >/dev/null 2>&1
|
|
94
|
+
|
|
95
|
+
# 2. with union both lines must survive
|
|
96
|
+
g reset -q --hard side-a
|
|
97
|
+
printf '%s merge=union\n' "$TARGET" > "$W/.git/info/attributes"
|
|
98
|
+
if ! g merge -q --no-edit side-b >/dev/null 2>&1; then
|
|
99
|
+
echo "UNION UNSAFE: $TARGET still conflicts with merge=union"; exit 1
|
|
100
|
+
fi
|
|
101
|
+
if ! grep -qxF 'forge-union-probe-a' "$W/$TARGET" || ! grep -qxF 'forge-union-probe-b' "$W/$TARGET"; then
|
|
102
|
+
echo "UNION UNSAFE: $TARGET lost an appended line under merge=union"; exit 1
|
|
103
|
+
fi
|
|
104
|
+
echo "UNION SAFE: $TARGET (same-spot appends conflict without union, merge cleanly with it)"
|
|
105
|
+
echo "REMINDER: this proves the mechanism only. Add the .gitattributes entry only if every line in $TARGET is independent."
|
|
106
|
+
exit 0
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# detect-append-registries.test.sh — fixture cases for scripts/detect-append-registries.sh (forge#3969).
|
|
3
|
+
# SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
4
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
5
|
+
set -uo pipefail
|
|
6
|
+
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
7
|
+
DA="$ROOT/scripts/detect-append-registries.sh"
|
|
8
|
+
PASS=0; FAILN=0
|
|
9
|
+
ok() { PASS=$((PASS+1)); }
|
|
10
|
+
bad() { FAILN=$((FAILN+1)); echo "FAIL: $1"; }
|
|
11
|
+
command -v git >/dev/null 2>&1 || { echo "detect-append-registries.test.sh: git missing, skipped"; exit 0; }
|
|
12
|
+
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
|
|
13
|
+
export GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null GIT_CONFIG_NOSYSTEM=1
|
|
14
|
+
export GIT_AUTHOR_NAME=t GIT_AUTHOR_EMAIL=t@example.invalid GIT_COMMITTER_NAME=t GIT_COMMITTER_EMAIL=t@example.invalid
|
|
15
|
+
W="$TMP/w"
|
|
16
|
+
g() { git -C "$W" -c commit.gpgsign=false -c core.hooksPath=/dev/null "$@"; }
|
|
17
|
+
git init -q "$W" 2>/dev/null; g checkout -q -b main 2>/dev/null
|
|
18
|
+
mkdir -p "$W/ci"
|
|
19
|
+
printf 'test-1\ntest-2\n' > "$W/ci/tests.list"; printf 'a: 1\n' > "$W/conf.yml"; printf 'x\n' > "$W/other.txt"
|
|
20
|
+
g add -A >/dev/null; g commit -q -m seed >/dev/null
|
|
21
|
+
|
|
22
|
+
# 1. two PRs appending to the same registry conflict without union and merge cleanly with it (ac-3 fixture)
|
|
23
|
+
g checkout -q -b pr1 main 2>/dev/null; printf 'test-pr1\n' >> "$W/ci/tests.list"; g add -A >/dev/null; g commit -q -m pr1 >/dev/null
|
|
24
|
+
g checkout -q -b pr2 main 2>/dev/null; printf 'test-pr2\n' >> "$W/ci/tests.list"; g add -A >/dev/null; g commit -q -m pr2 >/dev/null
|
|
25
|
+
g checkout -q pr1 2>/dev/null
|
|
26
|
+
if g merge -q --no-edit pr2 >/dev/null 2>&1; then bad "plain merge of two same-spot appends should conflict"; else ok; fi
|
|
27
|
+
g merge --abort >/dev/null 2>&1
|
|
28
|
+
printf 'ci/tests.list merge=union\n' > "$W/.git/info/attributes"
|
|
29
|
+
if g merge -q --no-edit pr2 >/dev/null 2>&1 && grep -qxF test-pr1 "$W/ci/tests.list" && grep -qxF test-pr2 "$W/ci/tests.list"; then ok; else bad "union merge of two same-spot appends should be clean with both lines"; fi
|
|
30
|
+
rm -f "$W/.git/info/attributes"
|
|
31
|
+
|
|
32
|
+
# 2. --check-union accepts a plain one-line-per-entry list, existing in the repo or not
|
|
33
|
+
out=$(bash "$DA" --check-union ci/tests.list --repo "$W" 2>&1); rc=$?
|
|
34
|
+
[ "$rc" -eq 0 ] && ok || bad "check-union ci/tests.list expected rc 0, got $rc ($out)"
|
|
35
|
+
out=$(bash "$DA" --check-union new/dir/registry.txt --repo "$W" 2>&1); rc=$?
|
|
36
|
+
[ "$rc" -eq 0 ] && ok || bad "check-union on a new path expected rc 0, got $rc ($out)"
|
|
37
|
+
# the probe never touches the caller's repo
|
|
38
|
+
[ -z "$(g status --porcelain)" ] && [ ! -e "$W/.git/info/attributes" ] && ok || bad "check-union modified the caller repo"
|
|
39
|
+
|
|
40
|
+
# 3. structured files are rejected
|
|
41
|
+
for f in conf.yml data.json pyproject.toml package-lock.json notes.md; do
|
|
42
|
+
out=$(bash "$DA" --check-union "$f" --repo "$W" 2>&1); rc=$?
|
|
43
|
+
[ "$rc" -eq 1 ] && ok || bad "check-union $f expected rc 1, got $rc"
|
|
44
|
+
done
|
|
45
|
+
# unsafe paths
|
|
46
|
+
out=$(bash "$DA" --check-union ../escape --repo "$W" 2>&1); rc=$?; [ "$rc" -eq 2 ] && ok || bad "parent path expected rc 2, got $rc"
|
|
47
|
+
out=$(bash "$DA" --check-union 'a b.txt' --repo "$W" 2>&1); rc=$?; [ "$rc" -eq 1 ] && ok || bad "whitespace path expected rc 1, got $rc"
|
|
48
|
+
out=$(bash "$DA" 2>&1); rc=$?; [ "$rc" -eq 2 ] && ok || bad "no mode expected rc 2, got $rc"
|
|
49
|
+
|
|
50
|
+
# 4. --detect ranks an add-only, high-churn file and ignores a file with deletions or low churn
|
|
51
|
+
i=0
|
|
52
|
+
while [ "$i" -lt 6 ]; do
|
|
53
|
+
i=$((i+1)); printf 'line-%s\n' "$i" >> "$W/ci/tests.list"
|
|
54
|
+
printf 'v%s\n' "$i" > "$W/other.txt" # rewritten each time: add + delete
|
|
55
|
+
g add -A >/dev/null; g commit -q -m "c$i" >/dev/null
|
|
56
|
+
done
|
|
57
|
+
out=$(bash "$DA" --detect --repo "$W" --min-commits 5 2>&1); rc=$?
|
|
58
|
+
[ "$rc" -eq 0 ] && ok || bad "detect expected rc 0, got $rc"
|
|
59
|
+
printf '%s\n' "$out" | grep -qF ' ci/tests.list' && ok || bad "detect should list ci/tests.list (got: $out)"
|
|
60
|
+
printf '%s\n' "$out" | grep -qF ' other.txt' && bad "detect must not list a rewritten file (got: $out)" || ok
|
|
61
|
+
printf '%s\n' "$out" | grep -qF ' conf.yml' && bad "detect must not list a low-churn file (got: $out)" || ok
|
|
62
|
+
|
|
63
|
+
echo "detect-append-registries.test.sh: $PASS passed, $FAILN failed"
|
|
64
|
+
[ "$FAILN" -eq 0 ]
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
version: 2.1
|
|
2
|
+
jobs:
|
|
3
|
+
test:
|
|
4
|
+
docker:
|
|
5
|
+
- image: cimg/base:stable
|
|
6
|
+
steps:
|
|
7
|
+
- checkout
|
|
8
|
+
- run:
|
|
9
|
+
name: unit
|
|
10
|
+
command: echo circle-unit
|
|
11
|
+
secured:
|
|
12
|
+
docker:
|
|
13
|
+
- image: cimg/base:stable
|
|
14
|
+
steps:
|
|
15
|
+
- run: echo secured
|
|
16
|
+
deploy-prod:
|
|
17
|
+
docker:
|
|
18
|
+
- image: cimg/base:stable
|
|
19
|
+
steps:
|
|
20
|
+
- run: echo deploy
|
|
21
|
+
workflows:
|
|
22
|
+
main:
|
|
23
|
+
jobs:
|
|
24
|
+
- test
|
|
25
|
+
- secured:
|
|
26
|
+
context: org-secrets
|
|
27
|
+
requires: [test]
|
|
28
|
+
- deploy-prod:
|
|
29
|
+
requires: [test]
|