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.
Files changed (42) hide show
  1. package/bin/engine/outcome-codes.mjs +3 -0
  2. package/bin/engine/phases.mjs +38 -2
  3. package/bin/engine/telemetry-seam.mjs +6 -0
  4. package/bin/engine/telemetry-seam.test.mjs +4 -0
  5. package/bin/engine-cli.mjs +78 -20
  6. package/bin/engine.mjs +5 -1
  7. package/bin/forgedock.mjs +94 -30
  8. package/bin/init-detect.mjs +68 -3
  9. package/bin/init-enrich.mjs +3 -0
  10. package/bin/init-repo.mjs +213 -0
  11. package/bin/journey.mjs +278 -43
  12. package/bin/lean/batch.mjs +2 -1
  13. package/bin/lean/batch.test.mjs +49 -1
  14. package/bin/lean/conformance.mjs +76 -10
  15. package/bin/lean/conformance.test.mjs +32 -3
  16. package/bin/lean/effects.mjs +96 -11
  17. package/bin/lean/lean.mjs +326 -33
  18. package/bin/lean/lean.test.mjs +618 -10
  19. package/bin/lean/ledger.mjs +67 -0
  20. package/bin/lean/ledger.test.mjs +65 -0
  21. package/bin/lean/orchestrate.mjs +9 -0
  22. package/bin/lean/prompts.mjs +42 -8
  23. package/bin/lean/render.mjs +1 -1
  24. package/bin/lean/verify.mjs +113 -0
  25. package/bin/lean/verify.test.mjs +95 -0
  26. package/commands/adopt.md +3 -0
  27. package/commands/audit.md +3 -8
  28. package/commands/forgedock-init.md +118 -6
  29. package/commands/review-pr.md +2 -2
  30. package/docs/CONFIG.md +52 -3
  31. package/docs/OUTCOME-CODES.md +3 -0
  32. package/package.json +1 -1
  33. package/scripts/bootstrap-validate.mjs +287 -0
  34. package/scripts/check-devdocs-freshness.sh +120 -0
  35. package/scripts/check-devdocs-freshness.test.sh +120 -0
  36. package/scripts/ci-local/run.mjs +13 -2
  37. package/scripts/ci-local.test.sh +13 -1
  38. package/scripts/northstar-docs.test.sh +68 -0
  39. package/scripts/review-prepare.sh +11 -9
  40. package/scripts/review-prepare.test.sh +14 -0
  41. package/scripts/structure-delta.sh +150 -0
  42. 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 (--no-delta only; a per-PR review blocks).
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 ]