forgedock 1.26.0 → 1.27.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 +3 -0
- package/bin/engine/phases.mjs +38 -2
- package/bin/engine/telemetry-seam.mjs +6 -0
- package/bin/engine/telemetry-seam.test.mjs +4 -0
- package/bin/engine-cli.mjs +78 -20
- package/bin/engine.mjs +5 -1
- package/bin/forgedock.mjs +94 -30
- package/bin/init-detect.mjs +68 -3
- package/bin/init-enrich.mjs +3 -0
- package/bin/init-repo.mjs +213 -0
- package/bin/journey.mjs +278 -43
- package/bin/lean/batch.mjs +2 -1
- package/bin/lean/batch.test.mjs +49 -1
- package/bin/lean/conformance.mjs +76 -10
- package/bin/lean/conformance.test.mjs +32 -3
- package/bin/lean/effects.mjs +96 -11
- package/bin/lean/lean.mjs +326 -33
- package/bin/lean/lean.test.mjs +618 -10
- package/bin/lean/ledger.mjs +67 -0
- package/bin/lean/ledger.test.mjs +65 -0
- package/bin/lean/orchestrate.mjs +9 -0
- package/bin/lean/prompts.mjs +42 -8
- package/bin/lean/render.mjs +1 -1
- package/bin/lean/verify.mjs +113 -0
- package/bin/lean/verify.test.mjs +95 -0
- package/commands/adopt.md +3 -0
- package/commands/audit.md +3 -8
- package/commands/forgedock-init.md +118 -6
- package/commands/review-pr.md +2 -2
- package/docs/CONFIG.md +52 -3
- package/docs/OUTCOME-CODES.md +3 -0
- package/package.json +1 -1
- package/scripts/bootstrap-validate.mjs +287 -0
- package/scripts/check-devdocs-freshness.sh +120 -0
- package/scripts/check-devdocs-freshness.test.sh +120 -0
- package/scripts/ci-local/run.mjs +13 -2
- package/scripts/ci-local.test.sh +13 -1
- package/scripts/northstar-docs.test.sh +68 -0
- package/scripts/review-prepare.sh +11 -9
- package/scripts/review-prepare.test.sh +14 -0
- package/scripts/structure-delta.sh +150 -0
- package/scripts/structure-delta.test.sh +135 -0
|
@@ -21,7 +21,8 @@
|
|
|
21
21
|
# reported "SKIPPED — typescript.build not configured", and blocking it would stop every deploy of a repo
|
|
22
22
|
# with plain .js files and no build command, ForgeDock itself included).
|
|
23
23
|
# --no-delta skip the FULL/DELTA/BASE_SYNC_ONLY resolver: the review mode is always FULL (a staging bundle review always reviews the
|
|
24
|
-
# whole bundle), without widening the roster to the full union the way --thorough does
|
|
24
|
+
# whole bundle), without widening the roster to the full union the way --thorough does; it also enables the pinned
|
|
25
|
+
# local-git diff fallback when `gh pr diff` fails (e.g. HTTP 406 over 20,000 lines), which a milestone->staging PR gets without it
|
|
25
26
|
# Env: FORGE_CONFIG (forge.yaml path; default: the main checkout's forge.yaml, then the git top-level one),
|
|
26
27
|
# REVIEW_PREPARE_SLEEP (seconds between mergeability retries, default 5),
|
|
27
28
|
# REVIEW_PREPARE_INDEX_TIMEOUT (seconds the code-index build may take, default 120; on timeout or failure the slices say
|
|
@@ -126,15 +127,22 @@ case "$NFILES" in ''|*[!0-9]*) NFILES="" ;; esac
|
|
|
126
127
|
LINES=$((ADDITIONS + DELETIONS))
|
|
127
128
|
[ -z "$PR_INFO" ] || [ -n "$REVIEW_SHA" ] || block "could not read the PR head SHA"
|
|
128
129
|
|
|
130
|
+
# Lane (the router already diverted staging->main; these gate the diff fallback, the full-build check and the roster).
|
|
131
|
+
IS_STAGING_TO_MAIN="false"; [ "$HEAD" = "staging" ] && [ "$BASE" = "main" ] && IS_STAGING_TO_MAIN="true"
|
|
132
|
+
IS_MILESTONE_TO_STAGING="false"; case "$HEAD" in milestone/*) [ "$BASE" = "staging" ] && IS_MILESTONE_TO_STAGING="true" ;; esac
|
|
133
|
+
REQUIRES_FULL_BUILD="false"
|
|
134
|
+
{ [ "$IS_STAGING_TO_MAIN" = "true" ] || [ "$IS_MILESTONE_TO_STAGING" = "true" ]; } && REQUIRES_FULL_BUILD="true"
|
|
135
|
+
|
|
129
136
|
FILES=""; DIFF=""
|
|
130
137
|
if [ -z "$BLOCKERS" ]; then
|
|
131
138
|
FILES=$(gh pr diff "$PR_NUMBER" -R "$REPO" --name-only 2>/dev/null) || FILES="__FAILED__"
|
|
132
139
|
[ "$FILES" = "__FAILED__" ] || DIFF=$(gh pr diff "$PR_NUMBER" -R "$REPO" 2>/dev/null) || FILES="__FAILED__"
|
|
133
140
|
if [ "$FILES" = "__FAILED__" ]; then
|
|
134
|
-
# A staging bundle can exceed the API's diff size limit: read it from the long-lived branches instead
|
|
141
|
+
# A staging bundle or a milestone->staging PR can exceed the API's diff size limit: read it from the long-lived branches instead
|
|
142
|
+
# (--no-delta or the milestone lane only, both always FULL; any other per-PR review blocks — forge#4154).
|
|
135
143
|
FILES=""; DIFF=""
|
|
136
144
|
# Pinned (forge#3935): same-repo PRs only (a fork's branch name resolves to origin's own branch), and the fetched tip must equal REVIEW_SHA; the diff is taken against REVIEW_SHA, never the branch name.
|
|
137
|
-
if [ "$NO_DELTA" = "true" ] && [ -n "$BASE" ] && [ -n "$HEAD" ] && [ "$(printf '%s' "$PR_INFO" | jq -r .isCrossRepository 2>/dev/null)" = "false" ] && printf '%s' "$REVIEW_SHA" | grep -qE '^[0-9a-f]{40}$' \
|
|
145
|
+
if { [ "$NO_DELTA" = "true" ] || [ "$IS_MILESTONE_TO_STAGING" = "true" ]; } && [ -n "$BASE" ] && [ -n "$HEAD" ] && [ "$(printf '%s' "$PR_INFO" | jq -r .isCrossRepository 2>/dev/null)" = "false" ] && printf '%s' "$REVIEW_SHA" | grep -qE '^[0-9a-f]{40}$' \
|
|
138
146
|
&& git fetch -q origin "$BASE" "$HEAD" 2>/dev/null && [ "$(git rev-parse -q --verify "refs/remotes/origin/${HEAD}^{commit}" 2>/dev/null)" = "$REVIEW_SHA" ]; then
|
|
139
147
|
FILES=$(git diff --name-only "origin/${BASE}...${REVIEW_SHA}" 2>/dev/null) && DIFF=$(git diff "origin/${BASE}...${REVIEW_SHA}" 2>/dev/null) || { FILES=""; DIFF=""; }
|
|
140
148
|
fi
|
|
@@ -161,12 +169,6 @@ if [ -n "$PR_AUTHOR" ]; then
|
|
|
161
169
|
[ "$PRIOR_MERGED_COUNT" -eq 0 ] && IS_FIRST_TIME_CONTRIBUTOR="true"
|
|
162
170
|
fi
|
|
163
171
|
|
|
164
|
-
# Lane (the router already diverted staging->main; these still gate the full-build check and the roster).
|
|
165
|
-
IS_STAGING_TO_MAIN="false"; [ "$HEAD" = "staging" ] && [ "$BASE" = "main" ] && IS_STAGING_TO_MAIN="true"
|
|
166
|
-
IS_MILESTONE_TO_STAGING="false"; case "$HEAD" in milestone/*) [ "$BASE" = "staging" ] && IS_MILESTONE_TO_STAGING="true" ;; esac
|
|
167
|
-
REQUIRES_FULL_BUILD="false"
|
|
168
|
-
{ [ "$IS_STAGING_TO_MAIN" = "true" ] || [ "$IS_MILESTONE_TO_STAGING" = "true" ]; } && REQUIRES_FULL_BUILD="true"
|
|
169
|
-
|
|
170
172
|
# --- optional PR checkout (throwaway review checkout only) ---
|
|
171
173
|
if [ "$CHECKOUT_PR" = "true" ] && [ -z "$BLOCKERS" ]; then
|
|
172
174
|
ORIG_REF=$(git symbolic-ref -q --short HEAD 2>/dev/null || git rev-parse HEAD 2>/dev/null || echo "")
|
|
@@ -287,6 +287,20 @@ CROSS_REPO=true setup pr-branch main "README.md" "$DIFF_PLAIN"; : > "$FIX/difffa
|
|
|
287
287
|
prep "$ROOT" --no-delta; rc_is "--no-delta fallback refuses a cross-repository PR" 3
|
|
288
288
|
setup feat/x staging "README.md" "$DIFF_PLAIN"; : > "$FIX/difffail"
|
|
289
289
|
prep "$ROOT" --no-delta; rc_is "--no-delta with unfetchable branches still fails closed" 3; eq "unfetchable blocked" .status BLOCKED
|
|
290
|
+
# forge#4154: a milestone->staging PR (always FULL) gets the same pinned fallback without --no-delta; other per-PR lanes still block
|
|
291
|
+
git -C "$REPO_DIR" branch -f milestone/m1 "$SHA_B"; git -C "$REPO_DIR" branch -f staging "$SHA_A"
|
|
292
|
+
setup milestone/m1 staging "README.md" "$DIFF_PLAIN"; : > "$FIX/difffail"
|
|
293
|
+
prep "$ROOT"; rc_is "milestone->staging falls back to git when the API diff fails" 0; eq "milestone fallback status" .status OK
|
|
294
|
+
eq "milestone fallback lane" .lane.is_milestone_to_staging true; eq "milestone fallback mode is FULL" .delta.mode FULL
|
|
295
|
+
eq "milestone fallback file list comes from git" '.full_files | index("services/api/auth.py") != null' true
|
|
296
|
+
setup milestone/m1 staging "README.md" "$DIFF_PLAIN" "0000000000000000000000000000000000000000"; : > "$FIX/difffail"
|
|
297
|
+
prep "$ROOT"; rc_is "milestone fallback refuses a branch tip that is not the review SHA" 3
|
|
298
|
+
CROSS_REPO=true setup milestone/m1 staging "README.md" "$DIFF_PLAIN"; : > "$FIX/difffail"
|
|
299
|
+
prep "$ROOT"; rc_is "milestone fallback refuses a cross-repository PR" 3
|
|
300
|
+
git -C "$REPO_DIR" branch -f feat/x "$SHA_B"
|
|
301
|
+
setup feat/x staging "README.md" "$DIFF_PLAIN"; : > "$FIX/difffail"
|
|
302
|
+
prep "$ROOT"; rc_is "a fetchable feat->staging per-PR review still blocks without --no-delta" 3; eq "feat blocked" '.blockers | join(" ") | test("gh pr diff failed")' true
|
|
303
|
+
git -C "$REPO_DIR" branch -D milestone/m1 staging feat/x >/dev/null
|
|
290
304
|
setup feat/x staging "README.md" "$DIFF_PLAIN"
|
|
291
305
|
for bad_args in "--pr abc --repo o/r" "--pr 7 --repo noslash" "--pr 7 --repo o/r;x/y" "--pr 7 --repo o/r --bogus" "--repo o/r --pr"; do
|
|
292
306
|
# shellcheck disable=SC2086
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
3
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
4
|
+
#
|
|
5
|
+
# structure-delta.sh — deterministic cross-package structure facts for one change (forge#4159). No LLM, no network:
|
|
6
|
+
# git + jq only. Run from the repo root.
|
|
7
|
+
#
|
|
8
|
+
# Usage: structure-delta.sh <base>...<head> | <base> <head> (the old side is git merge-base base head)
|
|
9
|
+
# Output (stdout, one JSON object):
|
|
10
|
+
# {} when the repo declares no workspace packages (root package.json `workspaces` / pnpm-workspace.yaml), else
|
|
11
|
+
# {"package_edges_added":[{from,to,kind,manifest}], "package_edges_removed":[...],
|
|
12
|
+
# "import_pairs":[{from,to,files_added,files_removed}],
|
|
13
|
+
# "boundary_config_changed":{changed,files[],loosens,tightens}}
|
|
14
|
+
# A package edge is a dependencies/devDependencies/peerDependencies key naming another workspace package. An import
|
|
15
|
+
# pair counts changed non-test source files whose added (removed) lines quote an exact workspace name, optionally with
|
|
16
|
+
# a subpath ('@s/x' or '@s/x/sub'); `import type` / `export type` lines are ignored. Counts only report.
|
|
17
|
+
# Exit: 0 ok | 2 usage or bad ref. bash 3.2 portable.
|
|
18
|
+
set -uo pipefail
|
|
19
|
+
|
|
20
|
+
die() { echo "structure-delta: $*" >&2; exit 2; }
|
|
21
|
+
command -v jq >/dev/null 2>&1 || die "jq is required"
|
|
22
|
+
case "${1:-}" in
|
|
23
|
+
*...*) BASE="${1%%...*}"; HEAD="${1#*...}" ;;
|
|
24
|
+
"") die "usage: structure-delta.sh <base>...<head>" ;;
|
|
25
|
+
*) BASE="$1"; HEAD="${2:-}" ;;
|
|
26
|
+
esac
|
|
27
|
+
[ -n "$BASE" ] && [ -n "$HEAD" ] || die "usage: structure-delta.sh <base>...<head>"
|
|
28
|
+
git rev-parse --verify -q "$BASE^{commit}" >/dev/null || die "bad base ref: $BASE"
|
|
29
|
+
git rev-parse --verify -q "$HEAD^{commit}" >/dev/null || die "bad head ref: $HEAD"
|
|
30
|
+
OLD=$(git merge-base "$BASE" "$HEAD") || die "no merge base for $BASE and $HEAD"
|
|
31
|
+
NEW=$(git rev-parse "$HEAD^{commit}")
|
|
32
|
+
|
|
33
|
+
T=$(mktemp -d "${TMPDIR:-/tmp}/structure-delta.XXXXXX") || die "mktemp failed"
|
|
34
|
+
trap 'rm -rf "$T"' EXIT
|
|
35
|
+
|
|
36
|
+
# ---- workspace globs, from the repo only ----
|
|
37
|
+
for ref in "$OLD" "$NEW"; do
|
|
38
|
+
git show "$ref:package.json" 2>/dev/null | jq -r '(.workspaces // empty) | if type == "array" then .[] elif type == "object" then (.packages // [])[] else empty end | strings' 2>/dev/null
|
|
39
|
+
git show "$ref:pnpm-workspace.yaml" 2>/dev/null | awk '
|
|
40
|
+
/^packages:/ { on = 1; next }
|
|
41
|
+
on && /^[^ \t#-]/ { on = 0 }
|
|
42
|
+
on && /^[ \t]*-[ \t]*/ { sub(/^[ \t]*-[ \t]*/, ""); sub(/[ \t]+#.*$/, ""); gsub(/["\047]/, ""); sub(/[ \t]+$/, ""); if ($0 != "") print }'
|
|
43
|
+
done | sed 's#^\./##; s#/$##' | sort -u > "$T/globs"
|
|
44
|
+
[ -s "$T/globs" ] || { echo '{}'; exit 0; }
|
|
45
|
+
|
|
46
|
+
# glob -> anchored ERE (`**` any depth, `*` one segment); `!glob` excludes
|
|
47
|
+
to_re() { printf '%s' "$1" | sed 's/[.+^$()|{}[]/\\&/g; s/\*\*/%%/g; s/\*/[^\/]*/g; s/%%/.*/g; s/?/[^\/]/g'; }
|
|
48
|
+
: > "$T/inc"; : > "$T/exc"
|
|
49
|
+
while IFS= read -r g; do
|
|
50
|
+
case "$g" in \!*) echo "^$(to_re "${g#!}")\$" >> "$T/exc" ;; *) echo "^$(to_re "$g")\$" >> "$T/inc" ;; esac
|
|
51
|
+
done < "$T/globs"
|
|
52
|
+
|
|
53
|
+
# ---- workspace manifests at each ref: lines "ref<TAB>dir<TAB>manifest" ----
|
|
54
|
+
: > "$T/manifests"
|
|
55
|
+
for side in old new; do
|
|
56
|
+
ref=$OLD; [ "$side" = new ] && ref=$NEW
|
|
57
|
+
git ls-tree -r --name-only "$ref" | grep -E '/package\.json$' | grep -v '(^|/)node_modules/' | while IFS= read -r m; do
|
|
58
|
+
d=${m%/package.json}
|
|
59
|
+
printf '%s\n' "$d" | grep -qE -f "$T/inc" || continue
|
|
60
|
+
if [ -s "$T/exc" ] && printf '%s\n' "$d" | grep -qE -f "$T/exc"; then continue; fi
|
|
61
|
+
printf '%s\t%s\t%s\n' "$side" "$d" "$m"
|
|
62
|
+
done >> "$T/manifests"
|
|
63
|
+
done
|
|
64
|
+
|
|
65
|
+
# ---- names and deps: lines "side<TAB>dir<TAB>manifest<TAB>name" and "side<TAB>manifest<TAB>kind<TAB>dep" ----
|
|
66
|
+
: > "$T/names"; : > "$T/deps"
|
|
67
|
+
while IFS="$(printf '\t')" read -r side d m; do
|
|
68
|
+
ref=$OLD; [ "$side" = new ] && ref=$NEW
|
|
69
|
+
git show "$ref:$m" 2>/dev/null > "$T/pj" || continue
|
|
70
|
+
n=$(jq -r '.name // empty | strings' "$T/pj" 2>/dev/null) || continue
|
|
71
|
+
[ -n "$n" ] || continue
|
|
72
|
+
printf '%s\t%s\t%s\t%s\n' "$side" "$d" "$m" "$n" >> "$T/names"
|
|
73
|
+
jq -r --arg s "$side" --arg m "$m" '["dependencies","devDependencies","peerDependencies"][] as $k | (.[$k] // {}) | objects | keys[] | [$s, $m, $k, .] | @tsv' "$T/pj" 2>/dev/null >> "$T/deps"
|
|
74
|
+
done < "$T/manifests"
|
|
75
|
+
[ -s "$T/names" ] || { echo '{}'; exit 0; }
|
|
76
|
+
cut -f4 "$T/names" | sort -u > "$T/wsnames"
|
|
77
|
+
|
|
78
|
+
# ---- package edges: "manifest<TAB>from<TAB>to<TAB>kind" per side, then set difference ----
|
|
79
|
+
for side in old new; do
|
|
80
|
+
awk -F'\t' -v s="$side" '
|
|
81
|
+
FILENAME == ARGV[1] { ws[$1] = 1; next }
|
|
82
|
+
FILENAME == ARGV[2] { if ($1 == s) name[$3] = $4; next }
|
|
83
|
+
$1 == s && ($4 in ws) && ($2 in name) && name[$2] != $4 { print $2 "\t" name[$2] "\t" $4 "\t" $3 }
|
|
84
|
+
' "$T/wsnames" "$T/names" "$T/deps" | sort -u > "$T/edges.$side"
|
|
85
|
+
done
|
|
86
|
+
edges_json() { jq -R -s -c 'split("\n") | map(select(length > 0) | split("\t") | {from: .[1], to: .[2], kind: .[3], manifest: .[0]})'; }
|
|
87
|
+
ADDED=$(comm -13 "$T/edges.old" "$T/edges.new" | edges_json)
|
|
88
|
+
REMOVED=$(comm -23 "$T/edges.old" "$T/edges.new" | edges_json)
|
|
89
|
+
|
|
90
|
+
# ---- import pairs: one -U0 diff pass over source files ----
|
|
91
|
+
# workspace dirs (both sides, longest first) for "which package owns this file"
|
|
92
|
+
cut -f2,4 "$T/names" | sort -u | awk -F'\t' '{ print length($1) "\t" $0 }' | sort -t"$(printf '\t')" -k1,1nr | cut -f2- > "$T/dirs"
|
|
93
|
+
git diff -U0 --no-color --no-renames --no-ext-diff "$OLD" "$NEW" -- \
|
|
94
|
+
'*.js' '*.jsx' '*.ts' '*.tsx' '*.mjs' '*.cjs' '*.mts' '*.cts' '*.vue' '*.svelte' '*.astro' 2>/dev/null > "$T/diff"
|
|
95
|
+
IMPORTS=$(awk -F'\t' '
|
|
96
|
+
FILENAME == ARGV[1] { nd++; dir[nd] = $1; own[nd] = $2; next }
|
|
97
|
+
FILENAME == ARGV[2] { nw++; ws[nw] = $1; next }
|
|
98
|
+
function owner(f, i) { for (i = 1; i <= nd; i++) if (index(f, dir[i] "/") == 1) return own[i]; return "" }
|
|
99
|
+
function istest(f) { return f ~ /(^|\/)(test|tests|__tests__|spec|specs)\// || f ~ /\.(test|spec)\.[A-Za-z0-9]+$/ }
|
|
100
|
+
# true when line quotes exactly name n: a quote, n, then a closing quote of the same kind or a "/" subpath
|
|
101
|
+
function quotes(line, n, q, qi, rest, p, nx) {
|
|
102
|
+
for (qi = 1; qi <= 2; qi++) {
|
|
103
|
+
q = (qi == 1) ? "\"" : "\047"; rest = line
|
|
104
|
+
while ((p = index(rest, q n)) > 0) {
|
|
105
|
+
nx = substr(rest, p + 1 + length(n), 1)
|
|
106
|
+
if (nx == q || nx == "/") return 1
|
|
107
|
+
rest = substr(rest, p + 1)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return 0
|
|
111
|
+
}
|
|
112
|
+
/^diff --git / { f = $0; sub(/^diff --git a\/.* b\//, "", f); from = owner(f); skip = (from == "" || istest(f)); hdr = 1; next }
|
|
113
|
+
/^@@/ { hdr = 0; next }
|
|
114
|
+
hdr { next }
|
|
115
|
+
/^[+-]/ {
|
|
116
|
+
if (skip) next
|
|
117
|
+
s = substr($0, 1, 1); l = substr($0, 2)
|
|
118
|
+
if (l ~ /^[ \t]*(import|export)[ \t]+type([ \t{]|$)/) next
|
|
119
|
+
for (i = 1; i <= nw; i++) if (ws[i] != from && quotes(l, ws[i])) hit[f SUBSEP ws[i] SUBSEP s] = 1
|
|
120
|
+
fromof[f] = from
|
|
121
|
+
}
|
|
122
|
+
END {
|
|
123
|
+
for (k in hit) {
|
|
124
|
+
split(k, a, SUBSEP); f = a[1]; to = a[2]; s = a[3]
|
|
125
|
+
if (s == "+" && !((f SUBSEP to SUBSEP "-") in hit)) add[fromof[f] SUBSEP to]++
|
|
126
|
+
if (s == "-" && !((f SUBSEP to SUBSEP "+") in hit)) rem[fromof[f] SUBSEP to]++
|
|
127
|
+
}
|
|
128
|
+
for (k in add) pair[k] = 1
|
|
129
|
+
for (k in rem) pair[k] = 1
|
|
130
|
+
for (k in pair) { split(k, a, SUBSEP); print a[1] "\t" a[2] "\t" (add[k] + 0) "\t" (rem[k] + 0) }
|
|
131
|
+
}
|
|
132
|
+
' "$T/dirs" "$T/wsnames" "$T/diff" | sort | jq -R -s -c 'split("\n") | map(select(length > 0) | split("\t") | {from: .[0], to: .[1], files_added: (.[2] | tonumber), files_removed: (.[3] | tonumber)})')
|
|
133
|
+
|
|
134
|
+
# ---- boundary config (eslint-plugin-boundaries, dependency-cruiser, Nx depConstraints) ----
|
|
135
|
+
: > "$T/bfiles"; LOOSE=false; TIGHT=false
|
|
136
|
+
git diff --name-only --no-renames "$OLD" "$NEW" | while IFS= read -r f; do
|
|
137
|
+
case "${f##*/}" in .eslintrc|.eslintrc.*|eslint.config.*|.dependency-cruiser.*|nx.json|project.json) printf '%s\n' "$f" ;; esac
|
|
138
|
+
done > "$T/bcand"
|
|
139
|
+
while IFS= read -r f; do
|
|
140
|
+
git diff -U0 --no-color --no-renames "$OLD" "$NEW" -- "$f" | grep -E '^[+-][^+-]' > "$T/bd" || true
|
|
141
|
+
grep -qE 'boundaries|allow|disallow|forbidden|depConstraints|DependOnLibsWithTags' "$T/bd" || continue
|
|
142
|
+
printf '%s\n' "$f" >> "$T/bfiles"
|
|
143
|
+
if grep -E '^\+' "$T/bd" | grep -iv 'disallow' | grep -qE 'allow|onlyDependOnLibsWithTags.*\*'; then LOOSE=true; fi
|
|
144
|
+
if grep -E '^-' "$T/bd" | grep -iv 'disallow' | grep -qE 'allow'; then TIGHT=true; fi
|
|
145
|
+
if grep -E '^\+' "$T/bd" | grep -qE 'disallow|forbidden|notDependOnLibsWithTags'; then TIGHT=true; fi
|
|
146
|
+
done < "$T/bcand"
|
|
147
|
+
BFILES=$(jq -R -s -c 'split("\n") | map(select(length > 0))' < "$T/bfiles")
|
|
148
|
+
|
|
149
|
+
jq -n -c --argjson a "$ADDED" --argjson r "$REMOVED" --argjson i "$IMPORTS" --argjson b "$BFILES" --argjson lo "$LOOSE" --argjson ti "$TIGHT" \
|
|
150
|
+
'{package_edges_added: $a, package_edges_removed: $r, import_pairs: $i, boundary_config_changed: {changed: ($b | length > 0), files: $b, loosens: $lo, tightens: $ti}}'
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# structure-delta.test.sh — cases for scripts/structure-delta.sh (forge#4159).
|
|
3
|
+
# SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
4
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
5
|
+
set -uo pipefail
|
|
6
|
+
S="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/structure-delta.sh"
|
|
7
|
+
PASS=0; FAILN=0
|
|
8
|
+
TMP="$(mktemp -d "${TMPDIR:-/tmp}/structure-delta-test.XXXXXX")"; trap 'rm -rf "$TMP"' EXIT
|
|
9
|
+
ok() { PASS=$((PASS+1)); }
|
|
10
|
+
bad() { FAILN=$((FAILN+1)); echo "FAIL: $1"; }
|
|
11
|
+
eq() { if [ "$2" = "$3" ]; then ok; else bad "$1 (got: $2 want: $3)"; fi }
|
|
12
|
+
q() { printf '%s' "$OUT" | jq -c "$1"; }
|
|
13
|
+
|
|
14
|
+
new_repo() {
|
|
15
|
+
rm -rf "$TMP/w"; mkdir -p "$TMP/w"; cd "$TMP/w" || exit 1
|
|
16
|
+
git init -q; git config user.email t@example.com; git config user.name t; git config commit.gpgsign false
|
|
17
|
+
}
|
|
18
|
+
commit() { git add -A; git commit -qm "$1"; }
|
|
19
|
+
pj() { mkdir -p "$1"; printf '%s\n' "$2" > "$1/package.json"; }
|
|
20
|
+
|
|
21
|
+
# ---- 1. single-package repo: {} and exit 0 ----
|
|
22
|
+
new_repo
|
|
23
|
+
printf '{"name":"solo","dependencies":{"left-pad":"1"}}\n' > package.json
|
|
24
|
+
mkdir -p src; echo "import x from 'left-pad';" > src/a.ts; commit base
|
|
25
|
+
git checkout -q -b feat; echo "import y from 'solo/x';" >> src/a.ts; commit change
|
|
26
|
+
OUT="$(bash "$S" master...feat 2>/dev/null || bash "$S" main...feat)"; rc=$?
|
|
27
|
+
eq "single-package rc" "$rc" 0
|
|
28
|
+
eq "single-package empty JSON" "$OUT" "{}"
|
|
29
|
+
# workspaces declared but no package matches: still {}
|
|
30
|
+
printf '{"name":"solo","workspaces":["packages/*"]}\n' > package.json; commit ws
|
|
31
|
+
OUT="$(bash "$S" HEAD~1 HEAD)"; eq "workspaces with no packages" "$OUT" "{}"
|
|
32
|
+
|
|
33
|
+
# ---- 2. monorepo fixture ----
|
|
34
|
+
new_repo
|
|
35
|
+
printf '{"name":"root","private":true,"workspaces":["apps/*","packages/*"],"dependencies":{"@scope/db":"1"}}\n' > package.json
|
|
36
|
+
pj apps/web '{"name":"@scope/web","dependencies":{"@scope/db":"workspace:*","@scope/old":"workspace:*","react":"18"}}'
|
|
37
|
+
pj packages/db '{"name":"@scope/db","dependencies":{"pg":"8"}}'
|
|
38
|
+
pj packages/x '{"name":"@scope/x"}'
|
|
39
|
+
pj packages/old '{"name":"@scope/old"}'
|
|
40
|
+
pj packages/core '{"name":"@scope/core"}'
|
|
41
|
+
mkdir -p apps/web/src apps/web/test
|
|
42
|
+
echo "export const a = 1;" > apps/web/src/existing.ts
|
|
43
|
+
echo "import { db } from '@scope/old';" > apps/web/src/legacy.ts
|
|
44
|
+
commit base
|
|
45
|
+
BASE_SHA=$(git rev-parse HEAD)
|
|
46
|
+
git checkout -q -b feat
|
|
47
|
+
# edges: added (db -> core), dev-only (x -> db), removed (web -> old), non-workspace ignored (db -> zod), root ignored
|
|
48
|
+
pj packages/db '{"name":"@scope/db","dependencies":{"pg":"8","zod":"3","@scope/core":"workspace:*"}}'
|
|
49
|
+
pj packages/x '{"name":"@scope/x","devDependencies":{"@scope/db":"workspace:*"}}'
|
|
50
|
+
pj apps/web '{"name":"@scope/web","dependencies":{"@scope/db":"workspace:*","react":"18"}}'
|
|
51
|
+
# imports
|
|
52
|
+
cat > apps/web/src/value.ts <<'EOF'
|
|
53
|
+
import { db } from "@scope/db";
|
|
54
|
+
import type { Row } from '@scope/core';
|
|
55
|
+
EOF
|
|
56
|
+
echo 'const m = await import("@scope/x");' > apps/web/src/dyn.ts
|
|
57
|
+
echo "import { sub } from '@scope/x/sub';" > apps/web/src/sub.ts
|
|
58
|
+
echo "import { o } from '@scope-other/x';" > apps/web/src/other.ts
|
|
59
|
+
echo "export type { T } from '@scope/db';" > apps/web/src/typeonly.ts
|
|
60
|
+
echo "import { db } from '@scope/db';" > apps/web/test/db.ts
|
|
61
|
+
echo "import { db } from '@scope/db';" > apps/web/src/db.test.ts
|
|
62
|
+
echo "// mentions @scope/db without quotes" > apps/web/src/comment.ts
|
|
63
|
+
echo "import { db } from '@scope/db-extra';" > apps/web/src/prefix.ts
|
|
64
|
+
rm apps/web/src/legacy.ts
|
|
65
|
+
commit change
|
|
66
|
+
OUT="$(bash "$S" "$BASE_SHA...feat")"; rc=$?
|
|
67
|
+
eq "monorepo rc" "$rc" 0
|
|
68
|
+
eq "edges added" "$(q '.package_edges_added | sort_by(.from)')" '[{"from":"@scope/db","to":"@scope/core","kind":"dependencies","manifest":"packages/db/package.json"},{"from":"@scope/x","to":"@scope/db","kind":"devDependencies","manifest":"packages/x/package.json"}]'
|
|
69
|
+
eq "edges removed" "$(q '.package_edges_removed')" '[{"from":"@scope/web","to":"@scope/old","kind":"dependencies","manifest":"apps/web/package.json"}]'
|
|
70
|
+
eq "non-workspace dep ignored" "$(q '[.package_edges_added[] | select(.to == "zod" or .to == "pg")] | length')" 0
|
|
71
|
+
eq "import pairs" "$(q '.import_pairs | sort_by(.to)')" '[{"from":"@scope/web","to":"@scope/db","files_added":1,"files_removed":0},{"from":"@scope/web","to":"@scope/old","files_added":0,"files_removed":1},{"from":"@scope/web","to":"@scope/x","files_added":2,"files_removed":0}]'
|
|
72
|
+
eq "import type never counted" "$(q '[.import_pairs[] | select(.to == "@scope/core")] | length')" 0
|
|
73
|
+
eq "no boundary change" "$(q '.boundary_config_changed')" '{"changed":false,"files":[],"loosens":false,"tightens":false}'
|
|
74
|
+
# the two-arg form agrees
|
|
75
|
+
eq "two-arg form" "$(bash "$S" "$BASE_SHA" feat | jq -c '.package_edges_added | length')" 2
|
|
76
|
+
|
|
77
|
+
# moving an import within a file (- and + of the same target) is not growth
|
|
78
|
+
git checkout -q -b move feat
|
|
79
|
+
printf '// header\nimport { db } from "@scope/db";\nimport type { Row } from "@scope/core";\n' > apps/web/src/value.ts; commit move
|
|
80
|
+
OUT="$(bash "$S" feat...move)"; eq "moved import not counted" "$(q '.import_pairs')" '[]'
|
|
81
|
+
|
|
82
|
+
# ---- 3. boundary config: loosen, tighten ----
|
|
83
|
+
git checkout -q -b eslint-base feat
|
|
84
|
+
cat > eslint.config.mjs <<'EOF'
|
|
85
|
+
export default [{ rules: { "boundaries/element-types": [2, { default: "disallow", rules: [
|
|
86
|
+
{ from: ["app"], allow: ["core"] },
|
|
87
|
+
] }] } }];
|
|
88
|
+
EOF
|
|
89
|
+
commit eslint
|
|
90
|
+
git checkout -q -b loosen
|
|
91
|
+
awk '{ print } /allow: \["core"\]/ { print " { from: [\"app\"], allow: [\"package\"] }," }' eslint.config.mjs > "$TMP/e" && cat "$TMP/e" > eslint.config.mjs; commit loosen
|
|
92
|
+
OUT="$(bash "$S" eslint-base...loosen)"
|
|
93
|
+
eq "loosen" "$(q '.boundary_config_changed')" '{"changed":true,"files":["eslint.config.mjs"],"loosens":true,"tightens":false}'
|
|
94
|
+
git checkout -q -b tighten eslint-base
|
|
95
|
+
grep -v 'allow: \["core"\]' eslint.config.mjs > "$TMP/e" && cat "$TMP/e" > eslint.config.mjs; commit tighten
|
|
96
|
+
OUT="$(bash "$S" eslint-base...tighten)"
|
|
97
|
+
eq "tighten" "$(q '.boundary_config_changed')" '{"changed":true,"files":["eslint.config.mjs"],"loosens":false,"tightens":true}'
|
|
98
|
+
|
|
99
|
+
# ---- 4. pnpm-workspace.yaml globs with a negation ----
|
|
100
|
+
new_repo
|
|
101
|
+
printf '{"name":"root","private":true}\n' > package.json
|
|
102
|
+
printf "packages:\n - 'packages/**'\n - \"!packages/ignored\"\n" > pnpm-workspace.yaml
|
|
103
|
+
pj packages/a '{"name":"a"}'
|
|
104
|
+
pj packages/nested/b '{"name":"b"}'
|
|
105
|
+
pj packages/ignored '{"name":"ignored"}'
|
|
106
|
+
commit base
|
|
107
|
+
git checkout -q -b feat
|
|
108
|
+
pj packages/a '{"name":"a","dependencies":{"b":"workspace:*","ignored":"workspace:*"}}'
|
|
109
|
+
commit edge
|
|
110
|
+
OUT="$(bash "$S" HEAD~1...HEAD)"
|
|
111
|
+
eq "pnpm globs, ** depth, negation" "$(q '[.package_edges_added[] | .to]')" '["b"]'
|
|
112
|
+
|
|
113
|
+
# ---- 5. bad ref and usage exit 2 ----
|
|
114
|
+
bash "$S" nope...HEAD >/dev/null 2>&1; eq "bad ref exit" "$?" 2
|
|
115
|
+
bash "$S" >/dev/null 2>&1; eq "usage exit" "$?" 2
|
|
116
|
+
|
|
117
|
+
# ---- 6. 300-file diff in under 2 s, no network tools invoked ----
|
|
118
|
+
new_repo
|
|
119
|
+
printf '{"name":"root","workspaces":["packages/*"]}\n' > package.json
|
|
120
|
+
for p in a b c d e f; do pj "packages/$p" "{\"name\":\"@s/$p\"}"; done
|
|
121
|
+
commit base
|
|
122
|
+
git checkout -q -b big
|
|
123
|
+
i=0; while [ $i -lt 300 ]; do printf "import { v } from '@s/b';\nexport const n$i = v;\n" > "packages/a/f$i.ts"; i=$((i+1)); done
|
|
124
|
+
commit big
|
|
125
|
+
mkdir -p "$TMP/bin"
|
|
126
|
+
for t in gh curl wget npm npx pnpm yarn nc ssh; do printf '#!/bin/sh\necho "%s" >> "%s/net.log"\nexit 1\n' "$t" "$TMP" > "$TMP/bin/$t"; chmod +x "$TMP/bin/$t"; done
|
|
127
|
+
start=$(date +%s%N 2>/dev/null)
|
|
128
|
+
OUT="$(PATH="$TMP/bin:$PATH" bash "$S" master...big 2>/dev/null || PATH="$TMP/bin:$PATH" bash "$S" main...big)"
|
|
129
|
+
end=$(date +%s%N 2>/dev/null)
|
|
130
|
+
eq "300-file count" "$(q '.import_pairs')" '[{"from":"@s/a","to":"@s/b","files_added":300,"files_removed":0}]'
|
|
131
|
+
case "$start$end" in *N*|"") ok ;; *) ms=$(( (end - start) / 1000000 )); if [ "$ms" -lt 2000 ]; then ok; else bad "300-file diff took ${ms} ms (limit 2000)"; fi ;; esac
|
|
132
|
+
if [ -e "$TMP/net.log" ]; then bad "network tool invoked: $(tr '\n' ' ' < "$TMP/net.log")"; else ok; fi
|
|
133
|
+
|
|
134
|
+
echo "structure-delta.test.sh: $PASS passed, $FAILN failed"
|
|
135
|
+
[ "$FAILN" -eq 0 ]
|