forgedock 1.27.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.
- package/README.md +4 -1
- package/bin/bootstrap.mjs +55 -0
- package/bin/engine/outcome-codes.mjs +10 -0
- package/bin/engine/telemetry-seam.mjs +37 -8
- package/bin/engine/telemetry-seam.test.mjs +31 -0
- package/bin/engine-cli.mjs +542 -38
- package/bin/engine.mjs +8 -4
- package/bin/forgedock.mjs +75 -9
- package/bin/gc.mjs +580 -0
- package/bin/lean/admission.mjs +33 -3
- package/bin/lean/batch.mjs +22 -4
- package/bin/lean/batch.test.mjs +5 -3
- package/bin/lean/checks.mjs +199 -0
- package/bin/lean/checks.test.mjs +183 -0
- package/bin/lean/conformance.mjs +190 -58
- package/bin/lean/conformance.test.mjs +116 -7
- package/bin/lean/credentials.mjs +29 -0
- package/bin/lean/depcache.mjs +312 -0
- package/bin/lean/depcache.test.mjs +298 -0
- package/bin/lean/drift.mjs +198 -0
- package/bin/lean/drift.test.mjs +223 -0
- package/bin/lean/effects.mjs +85 -8
- package/bin/lean/execution.e2e.test.mjs +81 -0
- package/bin/lean/execution.mjs +431 -0
- package/bin/lean/execution.test.mjs +583 -0
- package/bin/lean/heavy.mjs +289 -0
- package/bin/lean/heavy.test.mjs +207 -0
- package/bin/lean/lean.mjs +572 -139
- package/bin/lean/lean.test.mjs +1193 -31
- package/bin/lean/ledger.mjs +27 -2
- package/bin/lean/ledger.test.mjs +36 -1
- package/bin/lean/limits.mjs +58 -0
- package/bin/lean/limits.test.mjs +150 -0
- package/bin/lean/orchestrate.mjs +312 -19
- package/bin/lean/prompts.mjs +89 -15
- package/bin/lean/refactor.mjs +276 -0
- package/bin/lean/refactor.test.mjs +359 -0
- package/bin/lean/render.mjs +24 -5
- package/bin/lean/review-trigger.mjs +134 -0
- package/bin/lean/review-trigger.test.mjs +104 -0
- package/bin/lean/sandbox-provider.contract.test.mjs +257 -0
- package/bin/lean/sandbox-remote.mjs +142 -0
- package/bin/lean/sched.mjs +221 -0
- package/bin/lean/sched.test.mjs +310 -0
- package/bin/lean/schedd.mjs +247 -0
- package/bin/lean/schedd.test.mjs +180 -0
- package/bin/lean/toolchain.mjs +228 -0
- package/bin/lean/toolchain.test.mjs +154 -0
- package/bin/lean/verify.mjs +20 -9
- package/bin/lean/verify.test.mjs +26 -3
- package/bin/lean/worktree.mjs +69 -0
- package/bin/lean/worktree.test.mjs +141 -0
- package/bin/runner.mjs +53 -10
- package/bin/stats/github-source.mjs +1 -1
- package/bin/stats/index.mjs +14 -2
- package/bin/stats/query.mjs +72 -0
- package/bin/stats/stats.test.mjs +54 -0
- package/commands/audit.md +2 -0
- package/commands/autopilot.md +2 -0
- package/commands/bootstrap.md +35 -0
- package/commands/cleanup.md +7 -22
- package/commands/forge-stats.md +2 -1
- package/commands/forgedock-init.md +7 -0
- package/commands/issue.md +89 -830
- package/commands/milestone.md +55 -6
- package/commands/orchestrate.md +10 -2
- package/commands/upgrade-deps.md +3 -3
- package/commands/work-on/build/validate.md +4 -0
- package/commands/work-on/close.md +2 -0
- package/commands/work-on/decompose.md +24 -6
- package/docs/CI.md +3 -3
- package/docs/CONFIG.md +177 -2
- package/docs/OUTCOME-CODES.md +8 -0
- package/docs/TELEMETRY.md +1 -1
- package/docs/site/command-reference.md +22 -0
- package/docs/site/getting-started.md +2 -0
- package/docs/site/start-a-new-project.md +327 -0
- package/package.json +1 -1
- package/scripts/affected-files-check.sh +91 -0
- package/scripts/affected-files-check.test.sh +80 -0
- package/scripts/bootstrap-create.mjs +393 -0
- package/scripts/bootstrap-validate.mjs +36 -2
- package/scripts/calibration.mjs +116 -0
- package/scripts/check-devdocs-freshness.sh +9 -4
- package/scripts/check-devdocs-freshness.test.sh +11 -1
- package/scripts/check-spec-markers.sh +3 -0
- package/scripts/check-spec-size.sh +29 -11
- package/scripts/check-spec-size.test.sh +27 -4
- package/scripts/ci-local/lib.mjs +8 -1
- package/scripts/ci-local/parallelism.test.mjs +27 -0
- package/scripts/ci-local/run.mjs +3 -3
- package/scripts/ci-local/scoping.mjs +82 -0
- package/scripts/ci-local.test.sh +1 -1
- package/scripts/fixtures/symbol-index/other/sample.rb +3 -0
- package/scripts/fixtures/symbol-index/py/pkg/__init__.py +1 -0
- package/scripts/fixtures/symbol-index/py/pkg/a.py +5 -0
- package/scripts/fixtures/symbol-index/py/pkg/b.py +14 -0
- package/scripts/fixtures/symbol-index/py/pkg/c.py +1 -0
- package/scripts/fixtures/symbol-index/py/sample.py +41 -0
- package/scripts/fixtures/symbol-index/ts/sample.ts +13 -0
- package/scripts/fixtures/symbol-move/gen_big.py +201 -0
- package/scripts/fixtures/symbol-move-ts/gen_big.mjs +56 -0
- package/scripts/forge-root.test.sh +1 -1
- package/scripts/hotspots.sh +100 -0
- package/scripts/hotspots.test.sh +101 -0
- package/scripts/issue-create.sh +197 -0
- package/scripts/issue-create.test.sh +166 -0
- package/scripts/maintenance-scan.sh +185 -0
- package/scripts/maintenance-scan.test.sh +89 -0
- package/scripts/milestone-drift.sh +206 -0
- package/scripts/milestone-drift.test.sh +120 -0
- package/scripts/refactor-verify.sh +368 -0
- package/scripts/refactor-verify.test.sh +202 -0
- package/scripts/structure-delta.sh +10 -5
- package/scripts/structure-delta.test.sh +3 -2
- package/scripts/symbol-index.mjs +253 -0
- package/scripts/symbol-index.py +354 -0
- package/scripts/symbol-index.test.sh +119 -0
- package/scripts/symbol-move-ts.test.sh +161 -0
- package/scripts/symbol-move.mjs +379 -0
- package/scripts/symbol-move.py +383 -0
- package/scripts/symbol-move.test.sh +168 -0
- package/templates/bootstrap/interview.md +34 -0
- package/templates/bootstrap/issue-body.md +42 -0
- package/templates/bootstrap/needs-human-issue.md +45 -0
- package/templates/bootstrap/stack-profiles.md +34 -0
- package/templates/bootstrap/technical-spike.md +75 -0
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
3
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
4
|
+
#
|
|
5
|
+
# maintenance-scan.sh — deterministic health signals → maintenance issue candidates (forge#4197). No LLM: git, jq,
|
|
6
|
+
# wc and awk, plus the repo's own dead-code tools when installed. Run from inside the repo.
|
|
7
|
+
#
|
|
8
|
+
# Usage: maintenance-scan.sh [--repo owner/repo] [--file-lines N] [--commits-days D] [--doc-kb K]
|
|
9
|
+
# [--open-issues FILE] [--out-dir DIR] [--print-issue-args]
|
|
10
|
+
# Findings (one candidate each, labels `maintenance` + the kind):
|
|
11
|
+
# refactor:split tracked file over --file-lines lines (default forge.yaml maintenance.file_lines, else 2000) with
|
|
12
|
+
# commits in the last D days (default 90); linguist-generated/-vendored and vendored dirs excluded
|
|
13
|
+
# refactor a workspace package dependency both ways (A->B and B->A), from scripts/structure-delta.sh
|
|
14
|
+
# dead-code vulture / knip / ts-prune on PATH (and relevant to the repo) reporting findings, one per tool
|
|
15
|
+
# docs tracked *.md over --doc-kb KB (default forge.yaml maintenance.doc_kb, else 64)
|
|
16
|
+
# Dedup: a candidate is dropped when an open issue body carries its `<!-- FORGE:MAINT:<kind>:<key> -->` marker, or its
|
|
17
|
+
# title is a near-duplicate (scripts/issue-dedup.sh) of an open issue naming the same key. --open-issues FILE (JSON array
|
|
18
|
+
# of {number,title,body}) replaces the one `gh issue list` call; it then matches markers and exact titles only.
|
|
19
|
+
# Output: a JSON array of {kind,key,title,labels,body_file,marker,numbers}, `[]` when everything is under threshold.
|
|
20
|
+
# Body files (with one FORGE:BODY-INTEGRITY marker, per /issue's programmatic contract) go to --out-dir (default a
|
|
21
|
+
# mktemp dir under $TMPDIR). --print-issue-args prints one `/issue --title ... --body-file ... --label ...` line per
|
|
22
|
+
# candidate instead, for `Skill(skill="{FORGE_SKILL_PREFIX}issue", args=<line>)`.
|
|
23
|
+
# Exit: 0 ok | 2 usage, not a git repo, or open issues unreadable. bash 3.2 portable.
|
|
24
|
+
set -uo pipefail
|
|
25
|
+
|
|
26
|
+
die() { echo "maintenance-scan: $*" >&2; exit 2; }
|
|
27
|
+
command -v jq >/dev/null 2>&1 || die "jq is required"
|
|
28
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
29
|
+
|
|
30
|
+
REPO="" FILE_LINES="" DAYS=90 DOC_KB="" OPEN_FILE="" OUT_DIR="" PRINT_ARGS=false
|
|
31
|
+
while [ $# -gt 0 ]; do
|
|
32
|
+
case "$1" in
|
|
33
|
+
--repo) REPO="${2:-}"; shift 2 ;;
|
|
34
|
+
--file-lines) FILE_LINES="${2:-}"; shift 2 ;;
|
|
35
|
+
--commits-days) DAYS="${2:-}"; shift 2 ;;
|
|
36
|
+
--doc-kb) DOC_KB="${2:-}"; shift 2 ;;
|
|
37
|
+
--open-issues) OPEN_FILE="${2:-}"; shift 2 ;;
|
|
38
|
+
--out-dir) OUT_DIR="${2:-}"; shift 2 ;;
|
|
39
|
+
--print-issue-args) PRINT_ARGS=true; shift ;;
|
|
40
|
+
-h|--help) sed -n '5,24p' "$0"; exit 0 ;;
|
|
41
|
+
*) die "unknown argument: $1" ;;
|
|
42
|
+
esac
|
|
43
|
+
done
|
|
44
|
+
|
|
45
|
+
ROOT=$(git rev-parse --show-toplevel 2>/dev/null) || die "not inside a git repository"
|
|
46
|
+
cd "$ROOT" || die "cannot cd to $ROOT"
|
|
47
|
+
|
|
48
|
+
# forge.yaml `maintenance:` block (block form only, like bin/forge-utils.mjs parseForgeYaml): direct children only, so
|
|
49
|
+
# a sub-map's keys (` hotspot:` → ` file_lines:`, forge#4195) never stand in for the section's own
|
|
50
|
+
yaml_maint() {
|
|
51
|
+
[ -f forge.yaml ] || return 0
|
|
52
|
+
awk -v k="$1" '
|
|
53
|
+
/^[A-Za-z_][A-Za-z0-9_]*:[[:space:]]*(#.*)?$/ { on = ($0 ~ /^maintenance:/); ci = -1; next }
|
|
54
|
+
/^[^[:space:]#]/ { on = 0 }
|
|
55
|
+
on && /^[[:space:]]*(#|$)/ { next }
|
|
56
|
+
on { match($0, /^[[:space:]]*/); if (ci >= 0 && RLENGTH > ci) next; ci = RLENGTH }
|
|
57
|
+
on && $0 ~ "^[[:space:]]+" k ":" { sub("^[[:space:]]+" k ":[[:space:]]*", ""); sub("[[:space:]]*#.*$", ""); gsub(/"/, ""); print; exit }
|
|
58
|
+
' forge.yaml
|
|
59
|
+
}
|
|
60
|
+
[ -n "$FILE_LINES" ] || FILE_LINES=$(yaml_maint file_lines)
|
|
61
|
+
[ -n "$DOC_KB" ] || DOC_KB=$(yaml_maint doc_kb)
|
|
62
|
+
FILE_LINES=${FILE_LINES:-2000} DOC_KB=${DOC_KB:-64}
|
|
63
|
+
for v in "$FILE_LINES" "$DAYS" "$DOC_KB"; do case "$v" in ''|*[!0-9]*) die "thresholds must be positive integers" ;; esac; done
|
|
64
|
+
|
|
65
|
+
T=$(mktemp -d "${TMPDIR:-/tmp}/maintenance-scan.XXXXXX") || die "mktemp failed"
|
|
66
|
+
trap 'rm -rf "$T"' EXIT
|
|
67
|
+
if [ -z "$OUT_DIR" ]; then OUT_DIR=$(mktemp -d "${TMPDIR:-/tmp}/maintenance-scan-out.XXXXXX") || die "mktemp failed"; fi
|
|
68
|
+
mkdir -p "$OUT_DIR" || die "cannot create $OUT_DIR"
|
|
69
|
+
OUT_DIR=$(cd "$OUT_DIR" && pwd)
|
|
70
|
+
|
|
71
|
+
# ---- open issues, once ----
|
|
72
|
+
if [ -n "$OPEN_FILE" ]; then
|
|
73
|
+
jq -e 'type == "array"' "$OPEN_FILE" >/dev/null 2>&1 || die "--open-issues must be a JSON array"
|
|
74
|
+
cp "$OPEN_FILE" "$T/open.json"
|
|
75
|
+
else
|
|
76
|
+
gh issue list ${REPO:+-R "$REPO"} --state open --limit 1000 --json number,title,body > "$T/open.json" 2>/dev/null || die "cannot list open issues (gh)"
|
|
77
|
+
fi
|
|
78
|
+
|
|
79
|
+
# ---- tracked files, minus generated/vendored ----
|
|
80
|
+
git ls-files > "$T/all"
|
|
81
|
+
git check-attr --stdin linguist-generated linguist-vendored < "$T/all" \
|
|
82
|
+
| awk -F': ' '$3 == "set" || $3 == "true" { print $1 }' | sort -u > "$T/excluded"
|
|
83
|
+
grep -vE '(^|/)(node_modules|vendor|third_party|dist|build)/' "$T/all" | sort | comm -23 - "$T/excluded" > "$T/files"
|
|
84
|
+
|
|
85
|
+
# candidates: "kind<TAB>key<TAB>title<TAB>numbers-json<TAB>evidence"
|
|
86
|
+
: > "$T/cands"
|
|
87
|
+
add() { printf '%s\t%s\t%s\t%s\t%s\n' "$1" "$2" "$3" "$4" "$5" >> "$T/cands"; }
|
|
88
|
+
|
|
89
|
+
# ---- hotspots: over-threshold files with commits in the window ----
|
|
90
|
+
git log --since="$DAYS days ago" --format= --name-only 2>/dev/null | awk 'NF' | sort | uniq -c \
|
|
91
|
+
| awk '{ n = $1; sub(/^ *[0-9]+ /, ""); print $0 "\t" n }' > "$T/churn"
|
|
92
|
+
tr '\n' '\0' < "$T/files" | xargs -0 wc -l 2>/dev/null \
|
|
93
|
+
| awk -v max="$FILE_LINES" '{ n = $1; p = $0; sub(/^ *[0-9]+ /, "", p); if (n > max && p != "total") print p "\t" n }' > "$T/big"
|
|
94
|
+
while IFS="$(printf '\t')" read -r path lines; do
|
|
95
|
+
[ -f "$path" ] || continue
|
|
96
|
+
grep -Iq . "$path" 2>/dev/null || continue # binary
|
|
97
|
+
commits=$(awk -F'\t' -v p="$path" '$1 == p { print $2; exit }' "$T/churn")
|
|
98
|
+
[ -n "$commits" ] && [ "$commits" -gt 0 ] || continue
|
|
99
|
+
add "refactor:split" "$path" "split $path ($lines loc, $commits commits/${DAYS}d)" \
|
|
100
|
+
"{\"lines\":$lines,\"commits\":$commits,\"days\":$DAYS,\"threshold\":$FILE_LINES}" \
|
|
101
|
+
"\`$path\` has $lines lines (budget $FILE_LINES) and $commits commits in the last $DAYS days."
|
|
102
|
+
done < "$T/big"
|
|
103
|
+
|
|
104
|
+
# ---- backwards edges: workspace packages depending on each other both ways ----
|
|
105
|
+
if git show HEAD:package.json 2>/dev/null | jq -e '.workspaces' >/dev/null 2>&1 || git cat-file -e HEAD:pnpm-workspace.yaml 2>/dev/null; then
|
|
106
|
+
first=$(git rev-list --max-parents=0 HEAD | tail -1)
|
|
107
|
+
if sd=$("$SCRIPT_DIR/structure-delta.sh" "$first" HEAD 2>/dev/null); then
|
|
108
|
+
printf '%s' "$sd" | jq -r '[(.package_edges_added // [])[] | [.from, .to]] | unique as $e
|
|
109
|
+
| $e[] | . as $p | select($p[0] < $p[1] and ($e | index([[$p[1], $p[0]]])) != null) | "\($p[0])\t\($p[1])"' 2>/dev/null \
|
|
110
|
+
| while IFS="$(printf '\t')" read -r a b; do
|
|
111
|
+
add "refactor" "$a->$b" "untangle $a <-> $b (pkg cycle)" "{\"edges\":2}" \
|
|
112
|
+
"Workspace package \`$a\` depends on \`$b\` and \`$b\` depends on \`$a\`."
|
|
113
|
+
done
|
|
114
|
+
fi
|
|
115
|
+
fi
|
|
116
|
+
|
|
117
|
+
# ---- dead code: the repo's own tools, only when installed and relevant ----
|
|
118
|
+
count_lines() { awk 'NF' | wc -l | tr -d ' '; }
|
|
119
|
+
dead() { # tool, count
|
|
120
|
+
[ "$2" -gt 0 ] 2>/dev/null || return 0
|
|
121
|
+
add "dead-code" "$1" "remove $2 unused ($1)" "{\"findings\":$2}" "\`$1\` reports $2 unused items."
|
|
122
|
+
}
|
|
123
|
+
if command -v vulture >/dev/null 2>&1 && grep -q '\.py$' "$T/files"; then
|
|
124
|
+
dead vulture "$(grep '\.py$' "$T/files" | tr '\n' '\0' | xargs -0 vulture 2>/dev/null | count_lines)"
|
|
125
|
+
fi
|
|
126
|
+
if [ -f package.json ]; then
|
|
127
|
+
command -v knip >/dev/null 2>&1 && dead knip "$(knip --no-progress --reporter compact 2>/dev/null | count_lines)"
|
|
128
|
+
command -v ts-prune >/dev/null 2>&1 && dead ts-prune "$(ts-prune 2>/dev/null | grep -v '(used in module)' | count_lines)"
|
|
129
|
+
fi
|
|
130
|
+
|
|
131
|
+
# ---- oversized docs ----
|
|
132
|
+
grep -E '\.md$' "$T/files" | tr '\n' '\0' | xargs -0 wc -c 2>/dev/null \
|
|
133
|
+
| awk -v max="$((DOC_KB * 1024))" '{ n = $1; p = $0; sub(/^ *[0-9]+ /, "", p); if (n > max && p != "total") print p "\t" n }' \
|
|
134
|
+
| while IFS="$(printf '\t')" read -r path bytes; do
|
|
135
|
+
kb=$(( (bytes + 1023) / 1024 ))
|
|
136
|
+
add "docs" "$path" "trim $path ($kb KB > $DOC_KB KB)" "{\"kb\":$kb,\"threshold_kb\":$DOC_KB}" \
|
|
137
|
+
"\`$path\` is $kb KB; agents load docs under $DOC_KB KB whole."
|
|
138
|
+
done
|
|
139
|
+
|
|
140
|
+
# ---- dedup, body files, output ----
|
|
141
|
+
slug() { printf '%s' "$1" | tr -c 'A-Za-z0-9._-' '_' | cut -c1-80; }
|
|
142
|
+
title_dup() { # title, key: an open near-duplicate title that names the same key
|
|
143
|
+
if [ -n "$OPEN_FILE" ]; then
|
|
144
|
+
jq -e --arg t "$1" 'any(.[]; .title == $t)' "$T/open.json" >/dev/null
|
|
145
|
+
else
|
|
146
|
+
local hit
|
|
147
|
+
hit=$("$SCRIPT_DIR/issue-dedup.sh" "$1" ${REPO:+-R "$REPO"} 2>/dev/null) && return 1
|
|
148
|
+
case "$hit" in *"$2"*) return 0 ;; *) return 1 ;; esac
|
|
149
|
+
fi
|
|
150
|
+
}
|
|
151
|
+
: > "$T/out.jsonl"
|
|
152
|
+
sort -t "$(printf '\t')" -k1,1 -k2,2 -u "$T/cands" | while IFS="$(printf '\t')" read -r kind key title numbers evidence; do
|
|
153
|
+
marker="FORGE:MAINT:$kind:$key"
|
|
154
|
+
jq -e --arg m "<!-- $marker -->" 'any(.[]; (.body // "") | contains($m))' "$T/open.json" >/dev/null && continue
|
|
155
|
+
title_dup "$title" "$key" && continue
|
|
156
|
+
id="maint_$(slug "$kind")_$(printf '%s' "$kind:$key" | cksum | awk '{print $1}')"
|
|
157
|
+
body="$OUT_DIR/${id}_$(slug "${key%.md}").md"
|
|
158
|
+
cat > "$body" <<EOF
|
|
159
|
+
## Problem
|
|
160
|
+
|
|
161
|
+
$evidence Found by \`scripts/maintenance-scan.sh\`; it is dispatched within the maintenance budget (forge.yaml \`maintenance.share\`).
|
|
162
|
+
|
|
163
|
+
## Evidence
|
|
164
|
+
|
|
165
|
+
\`\`\`json
|
|
166
|
+
$numbers
|
|
167
|
+
\`\`\`
|
|
168
|
+
|
|
169
|
+
## Acceptance Criteria
|
|
170
|
+
|
|
171
|
+
- [ ] \`scripts/maintenance-scan.sh\` no longer reports \`$kind\` for \`$key\`
|
|
172
|
+
- [ ] Behaviour is unchanged (existing tests pass)
|
|
173
|
+
|
|
174
|
+
<!-- $marker -->
|
|
175
|
+
<!-- FORGE:BODY-INTEGRITY:$id -->
|
|
176
|
+
EOF
|
|
177
|
+
jq -n -c --arg kind "$kind" --arg key "$key" --arg title "$title" --arg body "$body" --arg marker "$marker" --argjson numbers "$numbers" \
|
|
178
|
+
'{kind: $kind, key: $key, title: $title, labels: ["maintenance", $kind], body_file: $body, marker: $marker, numbers: $numbers}' >> "$T/out.jsonl"
|
|
179
|
+
done
|
|
180
|
+
|
|
181
|
+
if [ "$PRINT_ARGS" = true ]; then
|
|
182
|
+
jq -r '"/issue --title \"" + (.title | gsub("\""; "\\\"")) + "\" --body-file \"" + .body_file + "\" --label maintenance --label " + .kind' "$T/out.jsonl"
|
|
183
|
+
else
|
|
184
|
+
jq -s -c '.' "$T/out.jsonl"
|
|
185
|
+
fi
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# maintenance-scan.test.sh — cases for scripts/maintenance-scan.sh (forge#4197). Throwaway repos under $TMPDIR, no network.
|
|
3
|
+
# SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
4
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
5
|
+
set -uo pipefail
|
|
6
|
+
|
|
7
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
8
|
+
SCAN="$SCRIPT_DIR/maintenance-scan.sh"
|
|
9
|
+
TMP="$(mktemp -d "${TMPDIR:-/tmp}/maintenance-scan-test.XXXXXX")"; trap 'rm -rf "$TMP"' EXIT
|
|
10
|
+
PASS=0 FAILN=0
|
|
11
|
+
ok() { PASS=$((PASS + 1)); }
|
|
12
|
+
fail() { FAILN=$((FAILN + 1)); echo "FAIL: $*"; }
|
|
13
|
+
check() { if [ "$2" = "$3" ]; then ok; else fail "$1: expected [$3] got [$2]"; fi; }
|
|
14
|
+
|
|
15
|
+
# A PATH without any dead-code tool (a host install must not leak into the cases).
|
|
16
|
+
BIN="$TMP/bin"; mkdir -p "$BIN"
|
|
17
|
+
for t in git jq awk sed tr wc sort comm uniq cut grep cksum mktemp xargs cat cp mkdir rm dirname head tail env bash; do
|
|
18
|
+
p=$(command -v "$t") && ln -sf "$p" "$BIN/$t"
|
|
19
|
+
done
|
|
20
|
+
export GIT_AUTHOR_NAME=t GIT_AUTHOR_EMAIL=t@t GIT_COMMITTER_NAME=t GIT_COMMITTER_EMAIL=t@t
|
|
21
|
+
NONE="$TMP/none.json"; echo '[]' > "$NONE"
|
|
22
|
+
scan() { PATH="$BIN" bash "$SCAN" --open-issues "${OPEN:-$NONE}" --out-dir "$TMP/out" "$@"; }
|
|
23
|
+
|
|
24
|
+
R="$TMP/repo"; mkdir -p "$R"; cd "$R" || exit 1
|
|
25
|
+
git init -q . && echo "# r" > README.md && git add . && git commit -qm init
|
|
26
|
+
|
|
27
|
+
# ---- 1. below thresholds: [] ----
|
|
28
|
+
seq 1 50 > small.py; git add . && git commit -qm small
|
|
29
|
+
check "below thresholds" "$(scan --file-lines 100)" "[]"
|
|
30
|
+
|
|
31
|
+
# ---- 2. one over-threshold file: one refactor:split candidate with numbers ----
|
|
32
|
+
seq 1 300 > big.py; git add . && git commit -qm big1; echo x >> big.py; git commit -qam big2
|
|
33
|
+
seq 1 400 > gen.py; echo "gen.py linguist-generated" > .gitattributes; git add . && git commit -qm gen
|
|
34
|
+
out=$(scan --file-lines 100)
|
|
35
|
+
check "one hotspot" "$(printf '%s' "$out" | jq -c '[.[] | [.kind, .key]]')" '[["refactor:split","big.py"]]'
|
|
36
|
+
check "title numbers" "$(printf '%s' "$out" | jq -r '.[0].title')" "split big.py (301 loc, 2 commits/90d)"
|
|
37
|
+
check "numbers" "$(printf '%s' "$out" | jq -c '.[0].numbers')" '{"lines":301,"commits":2,"days":90,"threshold":100}'
|
|
38
|
+
check "labels" "$(printf '%s' "$out" | jq -c '.[0].labels')" '["maintenance","refactor:split"]'
|
|
39
|
+
bf=$(printf '%s' "$out" | jq -r '.[0].body_file')
|
|
40
|
+
check "body marker" "$(grep -c 'FORGE:MAINT:refactor:split:big.py' "$bf")" "1"
|
|
41
|
+
check "integrity marker" "$(grep -cE '<!-- FORGE:BODY-INTEGRITY:[^>]+ -->' "$bf")" "1"
|
|
42
|
+
# thresholds from forge.yaml (block form)
|
|
43
|
+
printf 'maintenance:\n enabled: true\n file_lines: 100\n' > forge.yaml; git add . && git commit -qm cfg
|
|
44
|
+
check "forge.yaml threshold" "$(scan | jq -c '[.[].key]')" '["big.py"]'
|
|
45
|
+
# a sub-map's key (maintenance.hotspot.file_lines, forge#4195) is not the section's own threshold
|
|
46
|
+
printf 'maintenance:\n enabled: true\n hotspot:\n file_lines: 5000\n file_lines: 100\n' > forge.yaml; git add . && git commit -qm cfg2
|
|
47
|
+
check "sub-map key ignored" "$(scan | jq -c '[.[].key]')" '["big.py"]'
|
|
48
|
+
|
|
49
|
+
# ---- 3. dedup: an open issue carrying the marker, or the exact title ----
|
|
50
|
+
jq -n --arg b "x <!-- FORGE:MAINT:refactor:split:big.py --> y" '[{number: 1, title: "other", body: $b}]' > "$TMP/open1.json"
|
|
51
|
+
check "dedup by marker" "$(OPEN="$TMP/open1.json" scan)" "[]"
|
|
52
|
+
jq -n '[{number: 2, title: "split big.py (301 loc, 2 commits/90d)", body: ""}]' > "$TMP/open2.json"
|
|
53
|
+
check "dedup by title" "$(OPEN="$TMP/open2.json" scan)" "[]"
|
|
54
|
+
|
|
55
|
+
# ---- 4. oversized doc ----
|
|
56
|
+
awk 'BEGIN { for (i = 0; i < 3000; i++) print "doc line padding padding padding" }' > ARCH.md; git add . && git commit -qm doc
|
|
57
|
+
check "doc candidate" "$(scan --doc-kb 64 | jq -c '[.[] | select(.kind == "docs") | [.key, .numbers.kb]]')" '[["ARCH.md",97]]'
|
|
58
|
+
check "doc under budget" "$(scan --doc-kb 200 | jq -c '[.[] | select(.kind == "docs")]')" "[]"
|
|
59
|
+
|
|
60
|
+
# ---- 5. dead code from a stubbed knip (only when package.json exists) ----
|
|
61
|
+
printf '#!/usr/bin/env bash\nprintf "a.ts: x\\nb.ts: y\\nc.ts: z\\n"\n' > "$BIN/knip"; chmod +x "$BIN/knip"
|
|
62
|
+
check "knip needs package.json" "$(scan | jq -c '[.[] | select(.kind == "dead-code")]')" "[]"
|
|
63
|
+
echo '{"name":"r"}' > package.json; git add . && git commit -qm pkg
|
|
64
|
+
check "knip candidate" "$(scan | jq -c '[.[] | select(.kind == "dead-code") | [.key, .numbers.findings, .title]]')" '[["knip",3,"remove 3 unused (knip)"]]'
|
|
65
|
+
rm -f "$BIN/knip"
|
|
66
|
+
|
|
67
|
+
# ---- 6. determinism and /issue argument lines ----
|
|
68
|
+
a=$(scan); b=$(scan)
|
|
69
|
+
check "deterministic" "$a" "$b"
|
|
70
|
+
args=$(scan --print-issue-args)
|
|
71
|
+
check "issue-arg lines" "$(printf '%s\n' "$args" | grep -c '^/issue --title ".*" --body-file ".*" --label maintenance --label ')" "$(printf '%s' "$a" | jq length)"
|
|
72
|
+
|
|
73
|
+
# ---- 7. a workspace package cycle ----
|
|
74
|
+
W="$TMP/ws"; mkdir -p "$W"; cd "$W" || exit 1
|
|
75
|
+
git init -q . && echo "# w" > README.md && git add . && git commit -qm init
|
|
76
|
+
echo '{"name":"root","private":true,"workspaces":["packages/*"]}' > package.json
|
|
77
|
+
mkdir -p packages/a packages/b packages/c
|
|
78
|
+
echo '{"name":"@s/a","dependencies":{"@s/b":"*"}}' > packages/a/package.json
|
|
79
|
+
echo '{"name":"@s/b","dependencies":{"@s/a":"*"}}' > packages/b/package.json
|
|
80
|
+
echo '{"name":"@s/c","dependencies":{"@s/a":"*"}}' > packages/c/package.json
|
|
81
|
+
git add . && git commit -qm ws
|
|
82
|
+
check "package cycle" "$(scan | jq -c '[.[] | select(.kind == "refactor") | [.key, .title]]')" '[["@s/a->@s/b","untangle @s/a <-> @s/b (pkg cycle)"]]'
|
|
83
|
+
|
|
84
|
+
# ---- 8. usage and no model call ----
|
|
85
|
+
PATH="$BIN" bash "$SCAN" --bogus >/dev/null 2>&1; check "usage exit" "$?" "2"
|
|
86
|
+
check "no LLM invocation" "$(grep -nEi '\b(claude|codex|anthropic|openai|ollama)\b' "$SCAN" | grep -v '^[0-9]*:#' || true)" ""
|
|
87
|
+
|
|
88
|
+
echo "maintenance-scan.test.sh: $PASS passed, $FAILN failed"
|
|
89
|
+
[ "$FAILN" -eq 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 ]
|