forgedock 1.26.0 → 1.28.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 (137) hide show
  1. package/README.md +4 -1
  2. package/bin/bootstrap.mjs +55 -0
  3. package/bin/engine/outcome-codes.mjs +13 -0
  4. package/bin/engine/phases.mjs +38 -2
  5. package/bin/engine/telemetry-seam.mjs +41 -6
  6. package/bin/engine/telemetry-seam.test.mjs +35 -0
  7. package/bin/engine-cli.mjs +616 -54
  8. package/bin/engine.mjs +13 -5
  9. package/bin/forgedock.mjs +169 -39
  10. package/bin/gc.mjs +580 -0
  11. package/bin/init-detect.mjs +68 -3
  12. package/bin/init-enrich.mjs +3 -0
  13. package/bin/init-repo.mjs +213 -0
  14. package/bin/journey.mjs +278 -43
  15. package/bin/lean/admission.mjs +33 -3
  16. package/bin/lean/batch.mjs +24 -5
  17. package/bin/lean/batch.test.mjs +54 -4
  18. package/bin/lean/checks.mjs +199 -0
  19. package/bin/lean/checks.test.mjs +183 -0
  20. package/bin/lean/conformance.mjs +235 -37
  21. package/bin/lean/conformance.test.mjs +143 -5
  22. package/bin/lean/credentials.mjs +29 -0
  23. package/bin/lean/depcache.mjs +312 -0
  24. package/bin/lean/depcache.test.mjs +298 -0
  25. package/bin/lean/drift.mjs +198 -0
  26. package/bin/lean/drift.test.mjs +223 -0
  27. package/bin/lean/effects.mjs +178 -16
  28. package/bin/lean/execution.e2e.test.mjs +81 -0
  29. package/bin/lean/execution.mjs +431 -0
  30. package/bin/lean/execution.test.mjs +583 -0
  31. package/bin/lean/heavy.mjs +289 -0
  32. package/bin/lean/heavy.test.mjs +207 -0
  33. package/bin/lean/lean.mjs +877 -151
  34. package/bin/lean/lean.test.mjs +1798 -28
  35. package/bin/lean/ledger.mjs +92 -0
  36. package/bin/lean/ledger.test.mjs +100 -0
  37. package/bin/lean/limits.mjs +58 -0
  38. package/bin/lean/limits.test.mjs +150 -0
  39. package/bin/lean/orchestrate.mjs +321 -19
  40. package/bin/lean/prompts.mjs +125 -17
  41. package/bin/lean/refactor.mjs +276 -0
  42. package/bin/lean/refactor.test.mjs +359 -0
  43. package/bin/lean/render.mjs +25 -6
  44. package/bin/lean/review-trigger.mjs +134 -0
  45. package/bin/lean/review-trigger.test.mjs +104 -0
  46. package/bin/lean/sandbox-provider.contract.test.mjs +257 -0
  47. package/bin/lean/sandbox-remote.mjs +142 -0
  48. package/bin/lean/sched.mjs +221 -0
  49. package/bin/lean/sched.test.mjs +310 -0
  50. package/bin/lean/schedd.mjs +247 -0
  51. package/bin/lean/schedd.test.mjs +180 -0
  52. package/bin/lean/toolchain.mjs +228 -0
  53. package/bin/lean/toolchain.test.mjs +154 -0
  54. package/bin/lean/verify.mjs +124 -0
  55. package/bin/lean/verify.test.mjs +118 -0
  56. package/bin/lean/worktree.mjs +69 -0
  57. package/bin/lean/worktree.test.mjs +141 -0
  58. package/bin/runner.mjs +53 -10
  59. package/bin/stats/github-source.mjs +1 -1
  60. package/bin/stats/index.mjs +14 -2
  61. package/bin/stats/query.mjs +72 -0
  62. package/bin/stats/stats.test.mjs +54 -0
  63. package/commands/adopt.md +3 -0
  64. package/commands/audit.md +5 -8
  65. package/commands/autopilot.md +2 -0
  66. package/commands/bootstrap.md +35 -0
  67. package/commands/cleanup.md +7 -22
  68. package/commands/forge-stats.md +2 -1
  69. package/commands/forgedock-init.md +125 -6
  70. package/commands/issue.md +89 -830
  71. package/commands/milestone.md +55 -6
  72. package/commands/orchestrate.md +10 -2
  73. package/commands/review-pr.md +2 -2
  74. package/commands/upgrade-deps.md +3 -3
  75. package/commands/work-on/build/validate.md +4 -0
  76. package/commands/work-on/close.md +2 -0
  77. package/commands/work-on/decompose.md +24 -6
  78. package/docs/CI.md +3 -3
  79. package/docs/CONFIG.md +227 -3
  80. package/docs/OUTCOME-CODES.md +11 -0
  81. package/docs/TELEMETRY.md +1 -1
  82. package/docs/site/command-reference.md +22 -0
  83. package/docs/site/getting-started.md +2 -0
  84. package/docs/site/start-a-new-project.md +327 -0
  85. package/package.json +1 -1
  86. package/scripts/affected-files-check.sh +91 -0
  87. package/scripts/affected-files-check.test.sh +80 -0
  88. package/scripts/bootstrap-create.mjs +393 -0
  89. package/scripts/bootstrap-validate.mjs +321 -0
  90. package/scripts/calibration.mjs +116 -0
  91. package/scripts/check-devdocs-freshness.sh +125 -0
  92. package/scripts/check-devdocs-freshness.test.sh +130 -0
  93. package/scripts/check-spec-markers.sh +3 -0
  94. package/scripts/check-spec-size.sh +29 -11
  95. package/scripts/check-spec-size.test.sh +27 -4
  96. package/scripts/ci-local/lib.mjs +8 -1
  97. package/scripts/ci-local/parallelism.test.mjs +27 -0
  98. package/scripts/ci-local/run.mjs +16 -5
  99. package/scripts/ci-local/scoping.mjs +82 -0
  100. package/scripts/ci-local.test.sh +14 -2
  101. package/scripts/fixtures/symbol-index/other/sample.rb +3 -0
  102. package/scripts/fixtures/symbol-index/py/pkg/__init__.py +1 -0
  103. package/scripts/fixtures/symbol-index/py/pkg/a.py +5 -0
  104. package/scripts/fixtures/symbol-index/py/pkg/b.py +14 -0
  105. package/scripts/fixtures/symbol-index/py/pkg/c.py +1 -0
  106. package/scripts/fixtures/symbol-index/py/sample.py +41 -0
  107. package/scripts/fixtures/symbol-index/ts/sample.ts +13 -0
  108. package/scripts/fixtures/symbol-move/gen_big.py +201 -0
  109. package/scripts/fixtures/symbol-move-ts/gen_big.mjs +56 -0
  110. package/scripts/forge-root.test.sh +1 -1
  111. package/scripts/hotspots.sh +100 -0
  112. package/scripts/hotspots.test.sh +101 -0
  113. package/scripts/issue-create.sh +197 -0
  114. package/scripts/issue-create.test.sh +166 -0
  115. package/scripts/maintenance-scan.sh +185 -0
  116. package/scripts/maintenance-scan.test.sh +89 -0
  117. package/scripts/milestone-drift.sh +206 -0
  118. package/scripts/milestone-drift.test.sh +120 -0
  119. package/scripts/northstar-docs.test.sh +68 -0
  120. package/scripts/refactor-verify.sh +368 -0
  121. package/scripts/refactor-verify.test.sh +202 -0
  122. package/scripts/review-prepare.sh +11 -9
  123. package/scripts/review-prepare.test.sh +14 -0
  124. package/scripts/structure-delta.sh +155 -0
  125. package/scripts/structure-delta.test.sh +136 -0
  126. package/scripts/symbol-index.mjs +253 -0
  127. package/scripts/symbol-index.py +354 -0
  128. package/scripts/symbol-index.test.sh +119 -0
  129. package/scripts/symbol-move-ts.test.sh +161 -0
  130. package/scripts/symbol-move.mjs +379 -0
  131. package/scripts/symbol-move.py +383 -0
  132. package/scripts/symbol-move.test.sh +168 -0
  133. package/templates/bootstrap/interview.md +34 -0
  134. package/templates/bootstrap/issue-body.md +42 -0
  135. package/templates/bootstrap/needs-human-issue.md +45 -0
  136. package/templates/bootstrap/stack-profiles.md +34 -0
  137. package/templates/bootstrap/technical-spike.md +75 -0
@@ -0,0 +1,206 @@
1
+ #!/usr/bin/env bash
2
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
3
+ # SPDX-License-Identifier: AGPL-3.0-or-later
4
+ #
5
+ # milestone-drift.sh — deterministic structural drift of a milestone branch since its fork point (forge#4161). No LLM,
6
+ # no network: git + jq + awk, plus `jscpd` only when it is on PATH. Run from inside the repo (fetch the refs first).
7
+ #
8
+ # Usage: milestone-drift.sh <slug> [--base <ref>] [--head <ref>] [--summary FILE] [--top N] [--format json|text]
9
+ # [--pair-growth N] [--package-lines N] [--file-lines N]
10
+ # --base default origin/<forge.yaml branches.staging, else staging> (the local branch when origin/ is absent)
11
+ # --head default origin/milestone/<slug> (the local branch when origin/ is absent)
12
+ # --summary the orchestrate FORGE:DRIFT summary JSON (bin/lean/drift.mjs); adds the PRs behind each import pair
13
+ # thresholds default to the forge.yaml `orchestrate:` block (`drift:` keys pair_growth, package_lines, file_lines);
14
+ # none set means `crossings: []`. A value crosses when it is greater than its threshold.
15
+ # Facts, from the fork point F = git merge-base base head to head:
16
+ # pairs import-pair growth per from->to (files_added - files_removed), one scripts/structure-delta.sh F head run
17
+ # new_edges workspace package dependency edges added (same run)
18
+ # files top-N files by line growth (added - removed), one `git diff --numstat F head`; linguist-generated /
19
+ # linguist-vendored paths (.gitattributes at head) and forge.yaml `orchestrate.generated_globs`
20
+ # (comma-separated shell patterns, `*` crosses `/`) are excluded and listed in `excluded`
21
+ # packages top-N workspace packages by line growth (longest package dir prefix); [] without workspaces
22
+ # duplication jscpd statistics over the changed files when jscpd is installed and head is checked out, else null
23
+ # Output: one JSON object {milestone,base,head,fork_point,pairs,new_edges,files,packages,excluded,duplication,
24
+ # thresholds,crossings[{kind:pair_growth|package_lines|file_lines,key,value,threshold,package_path}]}, or with
25
+ # --format text a short report of the same facts.
26
+ # Exit: 0 ok | 2 usage or bad ref. bash 3.2 portable.
27
+ set -uo pipefail
28
+
29
+ die() { echo "milestone-drift: $*" >&2; exit 2; }
30
+ command -v jq >/dev/null 2>&1 || die "jq is required"
31
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
32
+
33
+ SLUG="" BASE="" HEAD="" SUMMARY="" TOP=10 FORMAT=json PAIR_T="" PKG_T="" FILE_T=""
34
+ while [ $# -gt 0 ]; do
35
+ case "$1" in
36
+ --base) BASE="${2:-}"; shift 2 ;;
37
+ --head) HEAD="${2:-}"; shift 2 ;;
38
+ --summary) SUMMARY="${2:-}"; shift 2 ;;
39
+ --top) TOP="${2:-}"; shift 2 ;;
40
+ --format) FORMAT="${2:-}"; shift 2 ;;
41
+ --pair-growth) PAIR_T="${2:-}"; shift 2 ;;
42
+ --package-lines) PKG_T="${2:-}"; shift 2 ;;
43
+ --file-lines) FILE_T="${2:-}"; shift 2 ;;
44
+ -h|--help) sed -n '5,27p' "$0"; exit 0 ;;
45
+ -*) die "unknown argument: $1" ;;
46
+ *) [ -z "$SLUG" ] || die "one slug only"; SLUG="$1"; shift ;;
47
+ esac
48
+ done
49
+ SLUG="${SLUG#milestone/}"
50
+ [ -n "$SLUG" ] || die "usage: milestone-drift.sh <slug> [--base <ref>] [--head <ref>] [--summary FILE] [--format json|text]"
51
+ case "$FORMAT" in json|text) ;; *) die "--format must be json or text" ;; esac
52
+ case "$TOP" in ''|*[!0-9]*) die "--top must be a positive integer" ;; esac
53
+
54
+ ROOT=$(git rev-parse --show-toplevel 2>/dev/null) || die "not inside a git repository"
55
+ cd "$ROOT" || die "cannot cd to $ROOT"
56
+
57
+ # forge.yaml: key under a top-level block (any depth below it), block form only
58
+ yaml_key() { # block key
59
+ [ -f forge.yaml ] || return 0
60
+ awk -v b="$1" -v k="$2" '
61
+ /^[A-Za-z_][A-Za-z0-9_]*:[[:space:]]*(#.*)?$/ { on = ($0 ~ "^" b ":"); next }
62
+ /^[^[:space:]#]/ { on = 0 }
63
+ on && $0 ~ "^[[:space:]]+" k ":" { sub("^[[:space:]]+" k ":[[:space:]]*", ""); sub("[[:space:]]*#.*$", ""); gsub(/["\047]/, ""); print; exit }
64
+ ' forge.yaml
65
+ }
66
+ [ -n "$PAIR_T" ] || PAIR_T=$(yaml_key orchestrate pair_growth)
67
+ [ -n "$PKG_T" ] || PKG_T=$(yaml_key orchestrate package_lines)
68
+ [ -n "$FILE_T" ] || FILE_T=$(yaml_key orchestrate file_lines)
69
+ GLOBS=$(yaml_key orchestrate generated_globs)
70
+ for v in "$PAIR_T" "$PKG_T" "$FILE_T"; do case "$v" in ''|*[!0-9]*) [ -z "$v" ] || die "thresholds must be positive integers" ;; esac; done
71
+
72
+ ref_or_local() { # origin/<b> when it resolves, else <b>
73
+ if git rev-parse --verify -q "origin/$1^{commit}" >/dev/null; then echo "origin/$1"; else echo "$1"; fi
74
+ }
75
+ if [ -z "$BASE" ]; then s=$(yaml_key branches staging); BASE=$(ref_or_local "${s:-staging}"); fi
76
+ [ -n "$HEAD" ] || HEAD=$(ref_or_local "milestone/$SLUG")
77
+ git rev-parse --verify -q "$BASE^{commit}" >/dev/null || die "bad base ref: $BASE"
78
+ git rev-parse --verify -q "$HEAD^{commit}" >/dev/null || die "bad head ref: $HEAD"
79
+ HEAD_SHA=$(git rev-parse "$HEAD^{commit}")
80
+ FORK=$(git merge-base "$BASE" "$HEAD") || die "no merge base for $BASE and $HEAD"
81
+ if [ -n "$SUMMARY" ]; then jq -e 'type == "object"' "$SUMMARY" >/dev/null 2>&1 || die "--summary must be a JSON object"; fi
82
+
83
+ T=$(mktemp -d "${TMPDIR:-/tmp}/milestone-drift.XXXXXX") || die "mktemp failed"
84
+ trap 'rm -rf "$T"' EXIT
85
+ TAB="$(printf '\t')"
86
+
87
+ # ---- pairs and edges: the existing structure delta from the fork point ----
88
+ SD=$("$SCRIPT_DIR/structure-delta.sh" "$FORK" "$HEAD_SHA" 2>/dev/null) || SD='{}'
89
+ printf '%s' "$SD" | jq -e 'type == "object"' >/dev/null 2>&1 || SD='{}'
90
+ printf '%s' "$SD" > "$T/sd.json"
91
+ if [ -n "$SUMMARY" ]; then cp "$SUMMARY" "$T/summary.json"; else echo '{}' > "$T/summary.json"; fi
92
+
93
+ # ---- workspace package dirs at head: "dir<TAB>name", longest dir first ----
94
+ {
95
+ git show "$HEAD_SHA: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
96
+ git show "$HEAD_SHA:pnpm-workspace.yaml" 2>/dev/null | awk '
97
+ /^packages:/ { on = 1; next }
98
+ on && /^[^ \t#-]/ { on = 0 }
99
+ on && /^[ \t]*-[ \t]*/ { sub(/^[ \t]*-[ \t]*/, ""); sub(/[ \t]+#.*$/, ""); gsub(/["\047]/, ""); sub(/[ \t]+$/, ""); if ($0 != "") print }'
100
+ } | sed 's#^\./##; s#/$##' | sort -u > "$T/globs"
101
+ to_re() { printf '%s' "$1" | sed 's/[.+^$()|{}[]/\\&/g; s/\*\*/%%/g; s/\*/[^\/]*/g; s/%%/.*/g; s/?/[^\/]/g'; }
102
+ : > "$T/inc"; : > "$T/exc"; : > "$T/dirs"
103
+ while IFS= read -r g; do
104
+ case "$g" in \!*) echo "^$(to_re "${g#!}")\$" >> "$T/exc" ;; *) echo "^$(to_re "$g")\$" >> "$T/inc" ;; esac
105
+ done < "$T/globs"
106
+ if [ -s "$T/inc" ]; then
107
+ git ls-tree -r --name-only "$HEAD_SHA" | grep -E '/package\.json$' | grep -vE '(^|/)node_modules/' | while IFS= read -r m; do
108
+ d=${m%/package.json}
109
+ printf '%s\n' "$d" | grep -qE -f "$T/inc" || continue
110
+ if [ -s "$T/exc" ] && printf '%s\n' "$d" | grep -qE -f "$T/exc"; then continue; fi
111
+ n=$(git show "$HEAD_SHA:$m" 2>/dev/null | jq -r '.name // empty | strings' 2>/dev/null)
112
+ [ -n "$n" ] && printf '%s\t%s\t%s\n' "${#d}" "$d" "$n"
113
+ done | sort -t "$TAB" -k1,1nr | cut -f2- > "$T/dirs"
114
+ fi
115
+
116
+ # ---- size growth: one numstat, generated/vendored excluded ----
117
+ git diff --numstat --no-renames "$FORK" "$HEAD_SHA" 2>/dev/null | awk -F'\t' 'NF >= 3 && $1 ~ /^[0-9]+$/ && $2 ~ /^[0-9]+$/' > "$T/numstat"
118
+ cut -f3 "$T/numstat" > "$T/paths"
119
+ : > "$T/excluded"
120
+ if [ -s "$T/paths" ]; then
121
+ # attributes as of head (--source); a git without it reads the working tree's .gitattributes
122
+ { git check-attr --source="$HEAD_SHA" --stdin linguist-generated linguist-vendored < "$T/paths" 2>/dev/null \
123
+ || git check-attr --stdin linguist-generated linguist-vendored < "$T/paths"; } \
124
+ | awk -F': ' '$3 == "set" || $3 == "true" { print $1 }' >> "$T/excluded"
125
+ if [ -n "$GLOBS" ]; then
126
+ while IFS= read -r p; do
127
+ oldifs=$IFS; IFS=,
128
+ for g in $GLOBS; do
129
+ g=$(printf '%s' "$g" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//')
130
+ [ -n "$g" ] || continue
131
+ # shellcheck disable=SC2254 # the glob is the pattern
132
+ case "$p" in $g) echo "$p" >> "$T/excluded"; break ;; esac
133
+ done
134
+ IFS=$oldifs
135
+ done < "$T/paths"
136
+ fi
137
+ fi
138
+ sort -u "$T/excluded" -o "$T/excluded"
139
+ # kept rows: "path<TAB>added<TAB>removed<TAB>package-dir<TAB>package-name"
140
+ awk -F'\t' '
141
+ FILENAME == ARGV[1] { ex[$0] = 1; next }
142
+ FILENAME == ARGV[2] { nd++; dir[nd] = $1; nm[nd] = $2; next }
143
+ ($3 in ex) { next }
144
+ { d = ""; n = ""; for (i = 1; i <= nd; i++) if (index($3, dir[i] "/") == 1) { d = dir[i]; n = nm[i]; break }
145
+ print $3 "\t" $1 "\t" $2 "\t" d "\t" n }
146
+ ' "$T/excluded" "$T/dirs" "$T/numstat" > "$T/kept"
147
+
148
+ jq -R -s -c 'split("\n") | map(select(length > 0) | split("\t") | {path: .[0], added: (.[1] | tonumber), removed: (.[2] | tonumber), pkg_dir: .[3], pkg: .[4]})' "$T/kept" > "$T/kept.json"
149
+ jq -R -s -c 'split("\n") | map(select(length > 0))' "$T/excluded" > "$T/excluded.json"
150
+ jq -R -s -c 'split("\n") | map(select(length > 0) | split("\t") | {path: .[0], name: .[1]})' "$T/dirs" > "$T/dirs.json"
151
+
152
+ # ---- duplication: the repo's own jscpd, only when installed and head is the working tree ----
153
+ DUP=null
154
+ if command -v jscpd >/dev/null 2>&1 && [ "$(git rev-parse HEAD 2>/dev/null)" = "$HEAD_SHA" ] && [ -s "$T/kept" ]; then
155
+ cut -f1 "$T/kept" | while IFS= read -r p; do [ -f "$p" ] && printf '%s\0' "$p"; done > "$T/dupfiles"
156
+ if [ -s "$T/dupfiles" ]; then
157
+ xargs -0 jscpd --silent --reporters json --output "$T/jscpd" < "$T/dupfiles" >/dev/null 2>&1
158
+ if [ -f "$T/jscpd/jscpd-report.json" ]; then
159
+ DUP=$(jq -c '(.statistics.total // {}) | {clones: (.clones // 0), duplicated_lines: (.duplicatedLines // 0), percentage: (.percentage // 0)}' "$T/jscpd/jscpd-report.json" 2>/dev/null) || DUP=null
160
+ [ -n "$DUP" ] || DUP=null
161
+ fi
162
+ fi
163
+ fi
164
+
165
+ num_or_null() { if [ -n "$1" ]; then printf '%s' "$1"; else printf 'null'; fi; }
166
+ REPORT=$(jq -n -c \
167
+ --arg milestone "$SLUG" --arg base "$BASE" --arg head "$HEAD" --arg fork "$FORK" --argjson top "$TOP" \
168
+ --slurpfile sd "$T/sd.json" --slurpfile summary "$T/summary.json" --slurpfile kept "$T/kept.json" \
169
+ --slurpfile excluded "$T/excluded.json" --slurpfile dirs "$T/dirs.json" --argjson dup "$DUP" \
170
+ --argjson pt "$(num_or_null "$PAIR_T")" --argjson kt "$(num_or_null "$PKG_T")" --argjson ft "$(num_or_null "$FILE_T")" '
171
+ ($sd[0]) as $sd | ($summary[0]) as $sum | ($kept[0]) as $kept | ($dirs[0]) as $dirs
172
+ | ($dirs | map({key: .name, value: .path}) | from_entries) as $dirOf
173
+ | [($sd.import_pairs // [])[]
174
+ | {from, to, growth: ((.files_added // 0) - (.files_removed // 0)), files_added: (.files_added // 0),
175
+ files_removed: (.files_removed // 0), files: (.files // []),
176
+ prs: (($sum.pairs // {})["\(.from):\(.to)"].prs // [])}]
177
+ | sort_by(-.growth, .from, .to) as $pairs
178
+ | [$kept[] | {path, added, removed, growth: (.added - .removed), package: (if .pkg == "" then null else .pkg end)}]
179
+ | sort_by(-.growth, .path) as $files
180
+ | [$kept[] | select(.pkg != "")] | group_by(.pkg)
181
+ | map({name: .[0].pkg, path: .[0].pkg_dir, added: (map(.added) | add), removed: (map(.removed) | add)} | .growth = (.added - .removed))
182
+ | sort_by(-.growth, .name) as $packages
183
+ | {milestone: $milestone, base: $base, head: $head, fork_point: $fork,
184
+ pairs: $pairs, new_edges: ($sd.package_edges_added // []),
185
+ files: $files[:$top], packages: $packages[:$top], excluded: $excluded[0], duplication: $dup,
186
+ thresholds: {pair_growth: $pt, package_lines: $kt, file_lines: $ft},
187
+ crossings: (
188
+ (if $pt == null then [] else [$pairs[] | select(.growth > $pt)
189
+ | {kind: "pair_growth", key: "\(.from)->\(.to)", value: .growth, threshold: $pt, package_path: ($dirOf[.from] // null)}] end)
190
+ + (if $kt == null then [] else [$packages[] | select(.growth > $kt)
191
+ | {kind: "package_lines", key: .name, value: .growth, threshold: $kt, package_path: .path}] end)
192
+ + (if $ft == null then [] else [$files[] | select(.growth > $ft)
193
+ | {kind: "file_lines", key: .path, value: .growth, threshold: $ft, package_path: (if .package == null then null else $dirOf[.package] end)}] end))}
194
+ ') || die "report failed"
195
+
196
+ if [ "$FORMAT" = json ]; then printf '%s\n' "$REPORT"; exit 0; fi
197
+ printf '%s' "$REPORT" | jq -r '
198
+ def line(f): if length == 0 then " (none)" else map(" " + f) | join("\n") end;
199
+ "milestone/\(.milestone) drift since \(.fork_point[:12]) (base \(.base), head \(.head))",
200
+ "import pairs (net files):", (.pairs | line("\(.from) -> \(.to) +\(.growth)\(if (.prs | length) > 0 then " PRs " + (.prs | map("#\(.)") | join(", ")) else "" end)")),
201
+ "new package edges:", (.new_edges | line("\(.from) -> \(.to) (\(.kind), \(.manifest))")),
202
+ "top files by growth:", (.files | line("\(.path) +\(.added) -\(.removed)")),
203
+ "top packages by growth:", (.packages | line("\(.name) (\(.path)) +\(.added) -\(.removed)")),
204
+ "excluded (generated/vendored): \(.excluded | length)",
205
+ (if .duplication == null then "duplication: n/a (jscpd not run)" else "duplication: \(.duplication.clones) clones, \(.duplication.duplicated_lines) lines (\(.duplication.percentage)%)" end),
206
+ (if (.crossings | length) == 0 then "thresholds: none crossed" else "thresholds crossed:", (.crossings | line("\(.kind) \(.key): \(.value) > \(.threshold)")) end)'
@@ -0,0 +1,120 @@
1
+ #!/usr/bin/env bash
2
+ # milestone-drift.test.sh — cases for scripts/milestone-drift.sh (forge#4161).
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)/milestone-drift.sh"
7
+ PASS=0; FAILN=0
8
+ TMP="$(mktemp -d "${TMPDIR:-/tmp}/milestone-drift-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 -b staging; 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
+ lines() { i=0; while [ "$i" -lt "$2" ]; do echo "export const v$i = $i;"; i=$((i+1)); done >> "$1"; }
21
+
22
+ # ---- fixture milestone: two packages, an app, generated files ----
23
+ new_repo
24
+ printf '{"name":"root","private":true,"workspaces":["apps/*","packages/*"]}\n' > package.json
25
+ pj apps/web '{"name":"@s/web","dependencies":{"@s/db":"workspace:*"}}'
26
+ pj packages/db '{"name":"@s/db"}'
27
+ pj packages/core '{"name":"@s/core"}'
28
+ mkdir -p apps/web/src packages/db/src packages/core/src
29
+ echo "export const a = 1;" > apps/web/src/a.ts
30
+ echo "export const d = 1;" > packages/db/src/d.ts
31
+ echo "export const c = 1;" > packages/core/src/c.ts
32
+ printf 'packages/db/src/schema.gen.ts linguist-generated\n' > .gitattributes
33
+ commit base
34
+ FORK_SHA=$(git rev-parse HEAD)
35
+ git checkout -q -b milestone/m1
36
+ # lane 1: two app files import the db package, db grows a lot
37
+ echo "import { d } from '@s/db';" > apps/web/src/one.ts
38
+ echo "import { d } from '@s/db';" > apps/web/src/two.ts
39
+ lines packages/db/src/big.ts 40
40
+ commit "lane 1"
41
+ # lane 2: a new package edge web -> core, a third db import, generated and globbed files grow
42
+ pj apps/web '{"name":"@s/web","dependencies":{"@s/db":"workspace:*","@s/core":"workspace:*"}}'
43
+ echo "import { d } from '@s/db';" > apps/web/src/three.ts
44
+ echo "import { c } from '@s/core';" > apps/web/src/four.ts
45
+ lines packages/db/src/schema.gen.ts 500
46
+ mkdir -p data; lines data/fixtures.json 300
47
+ lines apps/web/src/a.ts 5
48
+ commit "lane 2"
49
+ # staging moves on after the fork: never part of the milestone's drift
50
+ git checkout -q staging; lines packages/core/src/c.ts 900; commit "staging only"; git checkout -q milestone/m1
51
+
52
+ OUT="$(bash "$S" m1)"; rc=$?
53
+ eq "rc" "$rc" 0
54
+ eq "fork point" "$(printf '%s' "$OUT" | jq -r '.fork_point')" "$FORK_SHA"
55
+ eq "refs default to the local staging and milestone branches" "$(q '[.base, .head]')" '["staging","milestone/m1"]'
56
+ eq "pair growth" "$(q '[.pairs[] | {from, to, growth}]')" '[{"from":"@s/web","to":"@s/db","growth":3},{"from":"@s/web","to":"@s/core","growth":1}]'
57
+ eq "pair files" "$(q '.pairs[0].files')" '["apps/web/src/one.ts","apps/web/src/three.ts","apps/web/src/two.ts"]'
58
+ eq "new package edges" "$(q '[.new_edges[] | [.from, .to, .kind]]')" '[["@s/web","@s/core","dependencies"]]'
59
+ eq "top files (generated excluded, staging-only change absent)" "$(q '[.files[] | [.path, .growth]] | .[:2]')" '[["data/fixtures.json",300],["packages/db/src/big.ts",40]]'
60
+ eq "generated file excluded" "$(q '.excluded')" '["packages/db/src/schema.gen.ts"]'
61
+ eq "packages by growth" "$(q '[.packages[] | [.name, .path, .growth]]')" '[["@s/db","packages/db",40],["@s/web","apps/web",9]]'
62
+ eq "file outside packages has no package" "$(q '.files[0].package')" 'null'
63
+ eq "no thresholds, no crossings" "$(q '[.crossings, .thresholds]')" '[[],{"pair_growth":null,"package_lines":null,"file_lines":null}]'
64
+ eq "no jscpd, no duplication" "$(PATH="/usr/bin:/bin" bash "$S" m1 | jq -c '.duplication')" 'null'
65
+
66
+ # forge.yaml glob exclusion and thresholds from the orchestrate.drift block
67
+ cat > forge.yaml <<'EOF'
68
+ branches:
69
+ staging: staging
70
+ orchestrate:
71
+ drift:
72
+ pair_growth: 2
73
+ package_lines: 30
74
+ file_lines: 250
75
+ generated_globs: data/*, *.lock
76
+ EOF
77
+ OUT="$(bash "$S" m1)"
78
+ eq "glob excluded" "$(q '.excluded')" '["data/fixtures.json","packages/db/src/schema.gen.ts"]'
79
+ eq "thresholds read from forge.yaml" "$(q '.thresholds')" '{"pair_growth":2,"package_lines":30,"file_lines":250}'
80
+ eq "crossings" "$(q '[.crossings[] | [.kind, .key, .value, .threshold, .package_path]]')" '[["pair_growth","@s/web->@s/db",3,2,"apps/web"],["package_lines","@s/db",40,30,"packages/db"]]'
81
+ # flags override forge.yaml; below every threshold nothing crosses
82
+ OUT="$(bash "$S" m1 --pair-growth 3 --package-lines 40 --file-lines 40)"
83
+ eq "at the threshold is not a crossing" "$(q '.crossings')" '[]'
84
+ rm forge.yaml
85
+
86
+ # --summary attributes PRs to pairs
87
+ printf '{"v":1,"pairs":{"@s/web:@s/db":{"files_added":3,"files_removed":0,"prs":[11,12]}}}\n' > "$TMP/summary.json"
88
+ OUT="$(bash "$S" m1 --summary "$TMP/summary.json")"
89
+ eq "pair PRs from the summary" "$(q '.pairs[0].prs')" '[11,12]'
90
+
91
+ # text format
92
+ TXT="$(bash "$S" milestone/m1 --format text --file-lines 100)"
93
+ case "$TXT" in *"@s/web -> @s/db +3"*"file_lines data/fixtures.json: 300 > 100"*) ok ;; *) bad "text report: $TXT" ;; esac
94
+
95
+ # optional jscpd: used only when on PATH and head is the working tree
96
+ mkdir -p "$TMP/bin"
97
+ cat > "$TMP/bin/jscpd" <<'EOF'
98
+ #!/usr/bin/env bash
99
+ while [ $# -gt 0 ]; do case "$1" in --output) mkdir -p "$2"; printf '{"statistics":{"total":{"clones":2,"duplicatedLines":14,"percentage":3.5}}}' > "$2/jscpd-report.json"; shift 2 ;; *) shift ;; esac; done
100
+ EOF
101
+ chmod +x "$TMP/bin/jscpd"
102
+ eq "jscpd statistics" "$(PATH="$TMP/bin:$PATH" bash "$S" m1 | jq -c '.duplication')" '{"clones":2,"duplicated_lines":14,"percentage":3.5}'
103
+ git checkout -q staging
104
+ eq "jscpd skipped when head is not checked out" "$(PATH="$TMP/bin:$PATH" bash "$S" m1 | jq -c '.duplication')" 'null'
105
+
106
+ # ---- repo without workspaces: no packages, pairs empty ----
107
+ new_repo
108
+ echo "x" > a.txt; commit base
109
+ git checkout -q -b milestone/plain; lines a.txt 3; commit change
110
+ OUT="$(bash "$S" plain)"
111
+ eq "plain repo" "$(q '[.pairs, .new_edges, .packages, [.files[] | .path]]')" '[[],[],[],["a.txt"]]'
112
+
113
+ # ---- usage errors exit 2 ----
114
+ bash "$S" >/dev/null 2>&1; eq "no slug" "$?" 2
115
+ bash "$S" nope >/dev/null 2>&1; eq "missing milestone branch" "$?" 2
116
+ bash "$S" plain --pair-growth x >/dev/null 2>&1; eq "bad threshold" "$?" 2
117
+ bash "$S" plain --format yaml >/dev/null 2>&1; eq "bad format" "$?" 2
118
+
119
+ echo "milestone-drift: $PASS passed, $FAILN failed"
120
+ [ "$FAILN" -eq 0 ]
@@ -0,0 +1,68 @@
1
+ #!/usr/bin/env bash
2
+ # northstar-docs.test.sh — NORTHSTAR.md is present, small, pointed at, and devdocs selective loading still works (forge#4185).
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
+ cd "$ROOT" || exit 1
8
+ PASS=0; FAILN=0
9
+ ok() { PASS=$((PASS+1)); }
10
+ bad() { FAILN=$((FAILN+1)); echo "FAIL: $1"; }
11
+ check() { if eval "$2"; then ok; else bad "$1"; fi; }
12
+
13
+ # NORTHSTAR.md: exists, under 8 KB, no unresolved ⟨confirm⟩ markers.
14
+ check "NORTHSTAR.md exists" '[ -f NORTHSTAR.md ]'
15
+ check "NORTHSTAR.md is under 8 KB" '[ "$(wc -c < NORTHSTAR.md | tr -d " ")" -lt 8192 ]'
16
+ check "NORTHSTAR.md has no ⟨confirm⟩ markers" '! grep -q "⟨confirm⟩" NORTHSTAR.md'
17
+ for s in "## Where a fix belongs" "## Runtime map" "## Non-negotiables" "## Anti-goals"; do
18
+ check "NORTHSTAR.md has section '$s'" 'grep -q "^$s" NORTHSTAR.md'
19
+ done
20
+
21
+ # CLAUDE.md and AGENTS.md point at it in their first line; CLAUDE.md is tracked (not gitignored).
22
+ check "CLAUDE.md first line points at NORTHSTAR.md" 'head -1 CLAUDE.md | grep -q "NORTHSTAR\.md"'
23
+ check "AGENTS.md first line points at NORTHSTAR.md" 'head -1 AGENTS.md | grep -q "NORTHSTAR\.md"'
24
+ check "AGENTS.md keeps its Codex section" 'grep -q "^# ForgeDock for Codex" AGENTS.md'
25
+ check "root CLAUDE.md is not gitignored" '! git check-ignore -q CLAUDE.md'
26
+ check "nested CLAUDE.md stays gitignored" 'git check-ignore -q sub/CLAUDE.md'
27
+
28
+ # "Pipeline Hardening Principles" resolves to NORTHSTAR.md only; audit.md points there, not at CLAUDE.md.
29
+ HITS=$(grep -rl "Pipeline Hardening Principles" --exclude-dir=.git --exclude-dir=node_modules --exclude-dir=.forge-lean \
30
+ --exclude=northstar-docs.test.sh . | sed 's|^\./||')
31
+ check "'Pipeline Hardening Principles' resolves to NORTHSTAR.md only (got: $HITS)" '[ "$HITS" = "NORTHSTAR.md" ]'
32
+ check "commands/audit.md references NORTHSTAR.md" 'grep -q "NORTHSTAR\.md" commands/audit.md'
33
+ check "commands/audit.md has no CLAUDE.md reference" '! grep -q "CLAUDE\.md" commands/audit.md'
34
+
35
+ # lean dossier is indexed.
36
+ check "devdocs/modules/lean.md exists" '[ -f devdocs/modules/lean.md ]'
37
+ check "index.yaml modules has modules/lean.md" 'awk "/^modules:/{m=1;next} m&&/^[^[:space:]#]/{m=0} m" devdocs/index.yaml | grep -q "path: modules/lean.md"'
38
+ check "index.yaml has a lean domain" 'awk "/^domains:/{m=1;next} m&&/^[^[:space:]#]/{m=0} m" devdocs/index.yaml | grep -q "^ lean:"'
39
+
40
+ # Selective-loading regression: replay commands/work-on/build/context.md's always_load extraction (quote strip written portably).
41
+ INDEX_CONTENT=$(cat devdocs/index.yaml)
42
+ ALWAYS_LOAD_PATHS=$(echo "$INDEX_CONTENT" \
43
+ | awk '/^always_load:/,/^[a-z]/' \
44
+ | grep '^\s*-\s*path:' \
45
+ | sed 's/.*path:\s*//' \
46
+ | tr -d "\"'" \
47
+ | tr -d '[:space:]')
48
+ check "always_load set is empty (NORTHSTAR.md loads via CLAUDE.md/AGENTS.md)" '[ -z "$ALWAYS_LOAD_PATHS" ]'
49
+ check "always_load is still the empty list" 'grep -qx "always_load: \[\]" devdocs/index.yaml'
50
+ PARSE_LINE="awk '/^always_load:/,/^[a-z]/'"
51
+ check "context.md still parses always_load this way" 'grep -qF "$PARSE_LINE" commands/work-on/build/context.md'
52
+ for d in pipeline github architecture devdocs scripts bin infra decisions lean; do
53
+ check "domain '$d' present" 'grep -q "^ $d:" devdocs/index.yaml'
54
+ done
55
+ for m in runner orchestrate lean; do
56
+ check "module '$m' present" 'grep -q "^ - name: $m$" devdocs/index.yaml'
57
+ done
58
+ # Every concrete (non-glob) doc path the index names exists under devdocs/.
59
+ PATHS=$(grep -E '^[[:space:]]*-?[[:space:]]*path:' devdocs/index.yaml | sed 's/.*path:[[:space:]]*//' | tr -d "\"'" | grep -v '\*' | sort -u)
60
+ while IFS= read -r p; do
61
+ [ -n "$p" ] || continue
62
+ check "indexed doc exists: devdocs/$p" '[ -f "devdocs/$p" ]'
63
+ done <<EOF_PATHS
64
+ $PATHS
65
+ EOF_PATHS
66
+
67
+ echo "northstar-docs.test.sh: $PASS passed, $FAILN failed"
68
+ [ "$FAILN" = 0 ]