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.
- package/README.md +4 -1
- package/bin/bootstrap.mjs +55 -0
- package/bin/engine/outcome-codes.mjs +13 -0
- package/bin/engine/phases.mjs +38 -2
- package/bin/engine/telemetry-seam.mjs +41 -6
- package/bin/engine/telemetry-seam.test.mjs +35 -0
- package/bin/engine-cli.mjs +616 -54
- package/bin/engine.mjs +13 -5
- package/bin/forgedock.mjs +169 -39
- package/bin/gc.mjs +580 -0
- package/bin/init-detect.mjs +68 -3
- package/bin/init-enrich.mjs +3 -0
- package/bin/init-repo.mjs +213 -0
- package/bin/journey.mjs +278 -43
- package/bin/lean/admission.mjs +33 -3
- package/bin/lean/batch.mjs +24 -5
- package/bin/lean/batch.test.mjs +54 -4
- package/bin/lean/checks.mjs +199 -0
- package/bin/lean/checks.test.mjs +183 -0
- package/bin/lean/conformance.mjs +235 -37
- package/bin/lean/conformance.test.mjs +143 -5
- 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 +178 -16
- 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 +877 -151
- package/bin/lean/lean.test.mjs +1798 -28
- package/bin/lean/ledger.mjs +92 -0
- package/bin/lean/ledger.test.mjs +100 -0
- package/bin/lean/limits.mjs +58 -0
- package/bin/lean/limits.test.mjs +150 -0
- package/bin/lean/orchestrate.mjs +321 -19
- package/bin/lean/prompts.mjs +125 -17
- package/bin/lean/refactor.mjs +276 -0
- package/bin/lean/refactor.test.mjs +359 -0
- package/bin/lean/render.mjs +25 -6
- 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 +124 -0
- package/bin/lean/verify.test.mjs +118 -0
- 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/adopt.md +3 -0
- package/commands/audit.md +5 -8
- 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 +125 -6
- package/commands/issue.md +89 -830
- package/commands/milestone.md +55 -6
- package/commands/orchestrate.md +10 -2
- package/commands/review-pr.md +2 -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 +227 -3
- package/docs/OUTCOME-CODES.md +11 -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 +321 -0
- package/scripts/calibration.mjs +116 -0
- package/scripts/check-devdocs-freshness.sh +125 -0
- package/scripts/check-devdocs-freshness.test.sh +130 -0
- 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 +16 -5
- package/scripts/ci-local/scoping.mjs +82 -0
- package/scripts/ci-local.test.sh +14 -2
- 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/northstar-docs.test.sh +68 -0
- package/scripts/refactor-verify.sh +368 -0
- package/scripts/refactor-verify.test.sh +202 -0
- package/scripts/review-prepare.sh +11 -9
- package/scripts/review-prepare.test.sh +14 -0
- package/scripts/structure-delta.sh +155 -0
- package/scripts/structure-delta.test.sh +136 -0
- 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,155 @@
|
|
|
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,files[]}],
|
|
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. `files` names the
|
|
17
|
+
# files that added the import (sorted, at most 20; forge#4165 telemetry and trajectory).
|
|
18
|
+
# Exit: 0 ok | 2 usage or bad ref. bash 3.2 portable.
|
|
19
|
+
set -uo pipefail
|
|
20
|
+
|
|
21
|
+
die() { echo "structure-delta: $*" >&2; exit 2; }
|
|
22
|
+
command -v jq >/dev/null 2>&1 || die "jq is required"
|
|
23
|
+
case "${1:-}" in
|
|
24
|
+
*...*) BASE="${1%%...*}"; HEAD="${1#*...}" ;;
|
|
25
|
+
"") die "usage: structure-delta.sh <base>...<head>" ;;
|
|
26
|
+
*) BASE="$1"; HEAD="${2:-}" ;;
|
|
27
|
+
esac
|
|
28
|
+
[ -n "$BASE" ] && [ -n "$HEAD" ] || die "usage: structure-delta.sh <base>...<head>"
|
|
29
|
+
git rev-parse --verify -q "$BASE^{commit}" >/dev/null || die "bad base ref: $BASE"
|
|
30
|
+
git rev-parse --verify -q "$HEAD^{commit}" >/dev/null || die "bad head ref: $HEAD"
|
|
31
|
+
OLD=$(git merge-base "$BASE" "$HEAD") || die "no merge base for $BASE and $HEAD"
|
|
32
|
+
NEW=$(git rev-parse "$HEAD^{commit}")
|
|
33
|
+
|
|
34
|
+
T=$(mktemp -d "${TMPDIR:-/tmp}/structure-delta.XXXXXX") || die "mktemp failed"
|
|
35
|
+
trap 'rm -rf "$T"' EXIT
|
|
36
|
+
|
|
37
|
+
# ---- workspace globs, from the repo only ----
|
|
38
|
+
for ref in "$OLD" "$NEW"; do
|
|
39
|
+
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
|
|
40
|
+
git show "$ref:pnpm-workspace.yaml" 2>/dev/null | awk '
|
|
41
|
+
/^packages:/ { on = 1; next }
|
|
42
|
+
on && /^[^ \t#-]/ { on = 0 }
|
|
43
|
+
on && /^[ \t]*-[ \t]*/ { sub(/^[ \t]*-[ \t]*/, ""); sub(/[ \t]+#.*$/, ""); gsub(/["\047]/, ""); sub(/[ \t]+$/, ""); if ($0 != "") print }'
|
|
44
|
+
done | sed 's#^\./##; s#/$##' | sort -u > "$T/globs"
|
|
45
|
+
[ -s "$T/globs" ] || { echo '{}'; exit 0; }
|
|
46
|
+
|
|
47
|
+
# glob -> anchored ERE (`**` any depth, `*` one segment); `!glob` excludes
|
|
48
|
+
to_re() { printf '%s' "$1" | sed 's/[.+^$()|{}[]/\\&/g; s/\*\*/%%/g; s/\*/[^\/]*/g; s/%%/.*/g; s/?/[^\/]/g'; }
|
|
49
|
+
: > "$T/inc"; : > "$T/exc"
|
|
50
|
+
while IFS= read -r g; do
|
|
51
|
+
case "$g" in \!*) echo "^$(to_re "${g#!}")\$" >> "$T/exc" ;; *) echo "^$(to_re "$g")\$" >> "$T/inc" ;; esac
|
|
52
|
+
done < "$T/globs"
|
|
53
|
+
|
|
54
|
+
# ---- workspace manifests at each ref: lines "ref<TAB>dir<TAB>manifest" ----
|
|
55
|
+
: > "$T/manifests"
|
|
56
|
+
for side in old new; do
|
|
57
|
+
ref=$OLD; [ "$side" = new ] && ref=$NEW
|
|
58
|
+
git ls-tree -r --name-only "$ref" | grep -E '/package\.json$' | grep -v '(^|/)node_modules/' | while IFS= read -r m; do
|
|
59
|
+
d=${m%/package.json}
|
|
60
|
+
printf '%s\n' "$d" | grep -qE -f "$T/inc" || continue
|
|
61
|
+
if [ -s "$T/exc" ] && printf '%s\n' "$d" | grep -qE -f "$T/exc"; then continue; fi
|
|
62
|
+
printf '%s\t%s\t%s\n' "$side" "$d" "$m"
|
|
63
|
+
done >> "$T/manifests"
|
|
64
|
+
done
|
|
65
|
+
|
|
66
|
+
# ---- names and deps: lines "side<TAB>dir<TAB>manifest<TAB>name" and "side<TAB>manifest<TAB>kind<TAB>dep" ----
|
|
67
|
+
: > "$T/names"; : > "$T/deps"
|
|
68
|
+
while IFS="$(printf '\t')" read -r side d m; do
|
|
69
|
+
ref=$OLD; [ "$side" = new ] && ref=$NEW
|
|
70
|
+
git show "$ref:$m" 2>/dev/null > "$T/pj" || continue
|
|
71
|
+
n=$(jq -r '.name // empty | strings' "$T/pj" 2>/dev/null) || continue
|
|
72
|
+
[ -n "$n" ] || continue
|
|
73
|
+
printf '%s\t%s\t%s\t%s\n' "$side" "$d" "$m" "$n" >> "$T/names"
|
|
74
|
+
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"
|
|
75
|
+
done < "$T/manifests"
|
|
76
|
+
[ -s "$T/names" ] || { echo '{}'; exit 0; }
|
|
77
|
+
cut -f4 "$T/names" | sort -u > "$T/wsnames"
|
|
78
|
+
|
|
79
|
+
# ---- package edges: "manifest<TAB>from<TAB>to<TAB>kind" per side, then set difference ----
|
|
80
|
+
for side in old new; do
|
|
81
|
+
awk -F'\t' -v s="$side" '
|
|
82
|
+
FILENAME == ARGV[1] { ws[$1] = 1; next }
|
|
83
|
+
FILENAME == ARGV[2] { if ($1 == s) name[$3] = $4; next }
|
|
84
|
+
$1 == s && ($4 in ws) && ($2 in name) && name[$2] != $4 { print $2 "\t" name[$2] "\t" $4 "\t" $3 }
|
|
85
|
+
' "$T/wsnames" "$T/names" "$T/deps" | sort -u > "$T/edges.$side"
|
|
86
|
+
done
|
|
87
|
+
edges_json() { jq -R -s -c 'split("\n") | map(select(length > 0) | split("\t") | {from: .[1], to: .[2], kind: .[3], manifest: .[0]})'; }
|
|
88
|
+
ADDED=$(comm -13 "$T/edges.old" "$T/edges.new" | edges_json)
|
|
89
|
+
REMOVED=$(comm -23 "$T/edges.old" "$T/edges.new" | edges_json)
|
|
90
|
+
|
|
91
|
+
# ---- import pairs: one -U0 diff pass over source files ----
|
|
92
|
+
# workspace dirs (both sides, longest first) for "which package owns this file"
|
|
93
|
+
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"
|
|
94
|
+
git diff -U0 --no-color --no-renames --no-ext-diff "$OLD" "$NEW" -- \
|
|
95
|
+
'*.js' '*.jsx' '*.ts' '*.tsx' '*.mjs' '*.cjs' '*.mts' '*.cts' '*.vue' '*.svelte' '*.astro' 2>/dev/null > "$T/diff"
|
|
96
|
+
IMPORTS=$(awk -F'\t' '
|
|
97
|
+
FILENAME == ARGV[1] { nd++; dir[nd] = $1; own[nd] = $2; next }
|
|
98
|
+
FILENAME == ARGV[2] { nw++; ws[nw] = $1; next }
|
|
99
|
+
function owner(f, i) { for (i = 1; i <= nd; i++) if (index(f, dir[i] "/") == 1) return own[i]; return "" }
|
|
100
|
+
function istest(f) { return f ~ /(^|\/)(test|tests|__tests__|spec|specs)\// || f ~ /\.(test|spec)\.[A-Za-z0-9]+$/ }
|
|
101
|
+
# true when line quotes exactly name n: a quote, n, then a closing quote of the same kind or a "/" subpath
|
|
102
|
+
function quotes(line, n, q, qi, rest, p, nx) {
|
|
103
|
+
for (qi = 1; qi <= 2; qi++) {
|
|
104
|
+
q = (qi == 1) ? "\"" : "\047"; rest = line
|
|
105
|
+
while ((p = index(rest, q n)) > 0) {
|
|
106
|
+
nx = substr(rest, p + 1 + length(n), 1)
|
|
107
|
+
if (nx == q || nx == "/") return 1
|
|
108
|
+
rest = substr(rest, p + 1)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return 0
|
|
112
|
+
}
|
|
113
|
+
/^diff --git / { f = $0; sub(/^diff --git a\/.* b\//, "", f); from = owner(f); skip = (from == "" || istest(f)); hdr = 1; next }
|
|
114
|
+
/^@@/ { hdr = 0; next }
|
|
115
|
+
hdr { next }
|
|
116
|
+
/^[+-]/ {
|
|
117
|
+
if (skip) next
|
|
118
|
+
s = substr($0, 1, 1); l = substr($0, 2)
|
|
119
|
+
if (l ~ /^[ \t]*(import|export)[ \t]+type([ \t{]|$)/) next
|
|
120
|
+
for (i = 1; i <= nw; i++) if (ws[i] != from && quotes(l, ws[i])) hit[f SUBSEP ws[i] SUBSEP s] = 1
|
|
121
|
+
fromof[f] = from
|
|
122
|
+
}
|
|
123
|
+
END {
|
|
124
|
+
for (k in hit) {
|
|
125
|
+
split(k, a, SUBSEP); f = a[1]; to = a[2]; s = a[3]
|
|
126
|
+
if (s == "+" && !((f SUBSEP to SUBSEP "-") in hit)) { add[fromof[f] SUBSEP to]++; files[fromof[f] SUBSEP to, add[fromof[f] SUBSEP to]] = f }
|
|
127
|
+
if (s == "-" && !((f SUBSEP to SUBSEP "+") in hit)) rem[fromof[f] SUBSEP to]++
|
|
128
|
+
}
|
|
129
|
+
for (k in add) pair[k] = 1
|
|
130
|
+
for (k in rem) pair[k] = 1
|
|
131
|
+
for (k in pair) {
|
|
132
|
+
split(k, a, SUBSEP); fl = ""
|
|
133
|
+
for (i = 1; i <= add[k] + 0; i++) fl = fl (i > 1 ? "\037" : "") files[k, i]
|
|
134
|
+
print a[1] "\t" a[2] "\t" (add[k] + 0) "\t" (rem[k] + 0) "\t" fl
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
' "$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), files: ((.[4] // "") | split("\u001f") | map(select(length > 0)) | sort | .[:20])})')
|
|
138
|
+
|
|
139
|
+
# ---- boundary config (eslint-plugin-boundaries, dependency-cruiser, Nx depConstraints) ----
|
|
140
|
+
: > "$T/bfiles"; LOOSE=false; TIGHT=false
|
|
141
|
+
git diff --name-only --no-renames "$OLD" "$NEW" | while IFS= read -r f; do
|
|
142
|
+
case "${f##*/}" in .eslintrc|.eslintrc.*|eslint.config.*|.dependency-cruiser.*|nx.json|project.json) printf '%s\n' "$f" ;; esac
|
|
143
|
+
done > "$T/bcand"
|
|
144
|
+
while IFS= read -r f; do
|
|
145
|
+
git diff -U0 --no-color --no-renames "$OLD" "$NEW" -- "$f" | grep -E '^[+-][^+-]' > "$T/bd" || true
|
|
146
|
+
grep -qE 'boundaries|allow|disallow|forbidden|depConstraints|DependOnLibsWithTags' "$T/bd" || continue
|
|
147
|
+
printf '%s\n' "$f" >> "$T/bfiles"
|
|
148
|
+
if grep -E '^\+' "$T/bd" | grep -iv 'disallow' | grep -qE 'allow|onlyDependOnLibsWithTags.*\*'; then LOOSE=true; fi
|
|
149
|
+
if grep -E '^-' "$T/bd" | grep -iv 'disallow' | grep -qE 'allow'; then TIGHT=true; fi
|
|
150
|
+
if grep -E '^\+' "$T/bd" | grep -qE 'disallow|forbidden|notDependOnLibsWithTags'; then TIGHT=true; fi
|
|
151
|
+
done < "$T/bcand"
|
|
152
|
+
BFILES=$(jq -R -s -c 'split("\n") | map(select(length > 0))' < "$T/bfiles")
|
|
153
|
+
|
|
154
|
+
jq -n -c --argjson a "$ADDED" --argjson r "$REMOVED" --argjson i "$IMPORTS" --argjson b "$BFILES" --argjson lo "$LOOSE" --argjson ti "$TIGHT" \
|
|
155
|
+
'{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,136 @@
|
|
|
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,"files":["apps/web/src/value.ts"]},{"from":"@scope/web","to":"@scope/old","files_added":0,"files_removed":1,"files":[]},{"from":"@scope/web","to":"@scope/x","files_added":2,"files_removed":0,"files":["apps/web/src/dyn.ts","apps/web/src/sub.ts"]}]'
|
|
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 | map(del(.files))')" '[{"from":"@s/a","to":"@s/b","files_added":300,"files_removed":0}]'
|
|
131
|
+
eq "files capped at 20, sorted (forge#4165)" "$(q '.import_pairs[0].files | [length, .[0]]')" '[20,"packages/a/f0.ts"]'
|
|
132
|
+
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
|
|
133
|
+
if [ -e "$TMP/net.log" ]; then bad "network tool invoked: $(tr '\n' ' ' < "$TMP/net.log")"; else ok; fi
|
|
134
|
+
|
|
135
|
+
echo "structure-delta.test.sh: $PASS passed, $FAILN failed"
|
|
136
|
+
[ "$FAILN" -eq 0 ]
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
3
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
4
|
+
//
|
|
5
|
+
// symbol-index.mjs: JS/TS twin of scripts/symbol-index.py (forge#4208), same output schema.
|
|
6
|
+
// No new dependencies: the `typescript` package is loaded from the target repo (resolved from the target path, then
|
|
7
|
+
// cwd; NODE_PATH is honoured), else the TypeScript bundled with ts-morph (forge#4212). When neither is installed the
|
|
8
|
+
// run prints only {"unsupported":"ts"|"js","reason":"no-parser"} and exits 0.
|
|
9
|
+
// .py targets are handed to symbol-index.py. Never emits source text or literal values.
|
|
10
|
+
//
|
|
11
|
+
// Usage: node scripts/symbol-index.mjs <file | dir>...
|
|
12
|
+
// Output: {"symbols":[{name,kind,start,end,lines,public[,file]}], "edges":[{from,to}],
|
|
13
|
+
// "imports":[{module,names,line[,file]}], "cycles":[[module,...],...]}
|
|
14
|
+
// kind: function|async_function|class|method|const|interface|type|enum. public = exported (methods: exported class
|
|
15
|
+
// and not private/#private). One file: bare names; otherwise `file` on entries and "<module>:<symbol>" edge ends,
|
|
16
|
+
// module = path relative to its root without extension (index files name their directory). cycles = strongly
|
|
17
|
+
// connected components of the relative-import graph between indexed modules (`import type` ignored).
|
|
18
|
+
// Exit: 0 ok or unsupported | 2 usage or missing path.
|
|
19
|
+
|
|
20
|
+
import { spawnSync } from "node:child_process";
|
|
21
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
22
|
+
import { createRequire } from "node:module";
|
|
23
|
+
import path from "node:path";
|
|
24
|
+
import { fileURLToPath } from "node:url";
|
|
25
|
+
|
|
26
|
+
const TS_EXT = [".ts", ".tsx", ".mts", ".cts"];
|
|
27
|
+
const JS_EXT = [".js", ".jsx", ".mjs", ".cjs"];
|
|
28
|
+
const SKIP_DIRS = new Set(["node_modules", "dist", "build", "coverage"]);
|
|
29
|
+
const OTHER = { ".go": "go", ".rs": "rust", ".rb": "ruby", ".java": "java", ".sh": "shell", ".php": "php" };
|
|
30
|
+
|
|
31
|
+
const emit = (o) => process.stdout.write(JSON.stringify(sortKeys(o)) + "\n");
|
|
32
|
+
function sortKeys(v) {
|
|
33
|
+
if (Array.isArray(v)) return v.map(sortKeys);
|
|
34
|
+
if (v && typeof v === "object") return Object.fromEntries(Object.keys(v).sort().map((k) => [k, sortKeys(v[k])]));
|
|
35
|
+
return v;
|
|
36
|
+
}
|
|
37
|
+
const langOf = (ext) => (TS_EXT.includes(ext) ? "ts" : JS_EXT.includes(ext) ? "js" : ext === ".py" ? "python" : OTHER[ext] || ext.slice(1) || "unknown");
|
|
38
|
+
const cmp = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
39
|
+
|
|
40
|
+
function loadTs(targets) {
|
|
41
|
+
const bases = [...targets.map((t) => path.resolve(statSync(t).isDirectory() ? t : path.dirname(t))), process.cwd()];
|
|
42
|
+
for (const b of bases) {
|
|
43
|
+
// typescript >= 7 (native port) ships no in-process compiler API at the package root: skip it.
|
|
44
|
+
try { const ts = createRequire(path.join(b, "noop.js"))("typescript"); if (typeof ts?.createSourceFile === "function") return ts; } catch { /* try next */ }
|
|
45
|
+
}
|
|
46
|
+
// forge#4212: a repo with only ts-morph (the refactor:split TS mover) has TypeScript bundled in @ts-morph/common.
|
|
47
|
+
for (const b of bases) {
|
|
48
|
+
try { const ts = createRequire(path.join(b, "noop.js"))("ts-morph").ts; if (typeof ts?.createSourceFile === "function") return ts; } catch { /* try next */ }
|
|
49
|
+
}
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function collect(roots) {
|
|
54
|
+
const found = [];
|
|
55
|
+
for (const root of roots) {
|
|
56
|
+
if (statSync(root).isFile()) {
|
|
57
|
+
const ext = path.extname(root).toLowerCase();
|
|
58
|
+
found.push({ file: root, module: path.basename(root, path.extname(root)), ext });
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
const walk = (dir) => {
|
|
62
|
+
for (const ent of readdirSync(dir, { withFileTypes: true }).sort((a, b) => cmp(a.name, b.name))) {
|
|
63
|
+
const p = path.join(dir, ent.name);
|
|
64
|
+
if (ent.isDirectory()) { if (!ent.name.startsWith(".") && !SKIP_DIRS.has(ent.name)) walk(p); continue; }
|
|
65
|
+
const ext = path.extname(ent.name).toLowerCase();
|
|
66
|
+
if (![...TS_EXT, ...JS_EXT].includes(ext) || /\.d\.[cm]?ts$/.test(ent.name)) continue;
|
|
67
|
+
let mod = path.relative(root, p).slice(0, -ext.length).split(path.sep).join("/");
|
|
68
|
+
if (mod === "index" || mod.endsWith("/index")) mod = mod.slice(0, -"index".length).replace(/\/$/, "") || ".";
|
|
69
|
+
found.push({ file: p, module: mod, ext });
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
walk(root);
|
|
73
|
+
}
|
|
74
|
+
return found.sort((a, b) => cmp(a.file, b.file));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function tarjan(graph) {
|
|
78
|
+
let i = 0;
|
|
79
|
+
const index = new Map(), low = new Map(), on = new Set(), stack = [], comps = [];
|
|
80
|
+
const visit = (v) => {
|
|
81
|
+
index.set(v, i); low.set(v, i); i++; stack.push(v); on.add(v);
|
|
82
|
+
for (const w of [...(graph.get(v) || [])].sort()) {
|
|
83
|
+
if (!index.has(w)) { visit(w); low.set(v, Math.min(low.get(v), low.get(w))); }
|
|
84
|
+
else if (on.has(w)) low.set(v, Math.min(low.get(v), index.get(w)));
|
|
85
|
+
}
|
|
86
|
+
if (low.get(v) === index.get(v)) {
|
|
87
|
+
const comp = [];
|
|
88
|
+
let w;
|
|
89
|
+
do { w = stack.pop(); on.delete(w); comp.push(w); } while (w !== v);
|
|
90
|
+
comps.push(comp.sort());
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
for (const v of [...graph.keys()].sort()) if (!index.has(v)) visit(v);
|
|
94
|
+
return comps;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function index(ts, entries, multi) {
|
|
98
|
+
const K = ts.SyntaxKind;
|
|
99
|
+
const byFile = new Map(entries.map((e) => [path.resolve(e.file), e]));
|
|
100
|
+
const symbols = [], imports = [], edges = new Set(), graph = new Map(entries.map((e) => [e.module, new Set()]));
|
|
101
|
+
const tables = new Map(); // module -> Map(name -> node)
|
|
102
|
+
const parsed = entries.map((e) => {
|
|
103
|
+
const src = ts.createSourceFile(e.file, readFileSync(e.file, "utf8"), ts.ScriptTarget.Latest, true,
|
|
104
|
+
e.ext.endsWith("x") ? ts.ScriptKind.TSX : undefined);
|
|
105
|
+
if (src.parseDiagnostics?.length) throw { lang: langOf(e.ext), reason: `parse-error: ${e.file}` };
|
|
106
|
+
return { e, src };
|
|
107
|
+
});
|
|
108
|
+
const line = (src, pos) => src.getLineAndCharacterOfPosition(pos).line + 1;
|
|
109
|
+
const exported = (n) => (ts.getCombinedModifierFlags?.(n) ?? 0) & ts.ModifierFlags.Export;
|
|
110
|
+
const ref = (e, name) => (multi ? `${e.module}:${name}` : name);
|
|
111
|
+
|
|
112
|
+
for (const { e, src } of parsed) {
|
|
113
|
+
const table = new Map();
|
|
114
|
+
tables.set(e.module, table);
|
|
115
|
+
const add = (name, kind, node, pub, owner = null) => {
|
|
116
|
+
const start = line(src, node.getStart(src)), end = line(src, node.getEnd());
|
|
117
|
+
symbols.push({ name, kind, start, end, lines: end - start + 1, public: !!pub, ...(multi ? { file: e.file } : {}) });
|
|
118
|
+
table.set(name, { node, owner });
|
|
119
|
+
};
|
|
120
|
+
const exportNames = new Set();
|
|
121
|
+
for (const st of src.statements) {
|
|
122
|
+
if (ts.isExportDeclaration(st) && !st.moduleSpecifier && st.exportClause && ts.isNamedExports(st.exportClause)) {
|
|
123
|
+
for (const el of st.exportClause.elements) exportNames.add((el.propertyName || el.name).text);
|
|
124
|
+
} else if (ts.isExportAssignment(st) && ts.isIdentifier(st.expression)) exportNames.add(st.expression.text);
|
|
125
|
+
}
|
|
126
|
+
const pub = (n, name) => exported(n) || exportNames.has(name);
|
|
127
|
+
for (const st of src.statements) {
|
|
128
|
+
if (ts.isFunctionDeclaration(st) && st.name) {
|
|
129
|
+
const isAsync = (st.modifiers || []).some((m) => m.kind === K.AsyncKeyword);
|
|
130
|
+
add(st.name.text, isAsync ? "async_function" : "function", st, pub(st, st.name.text));
|
|
131
|
+
} else if (ts.isClassDeclaration(st) && st.name) {
|
|
132
|
+
const cname = st.name.text, cpub = pub(st, cname);
|
|
133
|
+
add(cname, "class", st, cpub);
|
|
134
|
+
for (const m of st.members) {
|
|
135
|
+
if (!(ts.isMethodDeclaration(m) || ts.isGetAccessor(m) || ts.isSetAccessor(m) || ts.isConstructorDeclaration(m))) continue;
|
|
136
|
+
const mname = ts.isConstructorDeclaration(m) ? "constructor" : m.name && (m.name.text ?? m.name.getText(src));
|
|
137
|
+
if (!mname || table.has(`${cname}.${mname}`)) continue;
|
|
138
|
+
const priv = (m.modifiers || []).some((x) => x.kind === K.PrivateKeyword) || String(mname).startsWith("#");
|
|
139
|
+
add(`${cname}.${mname}`, "method", m, cpub && !priv, cname);
|
|
140
|
+
}
|
|
141
|
+
} else if (ts.isVariableStatement(st)) {
|
|
142
|
+
for (const d of st.declarationList.declarations) {
|
|
143
|
+
if (ts.isIdentifier(d.name)) add(d.name.text, "const", d, exported(st) || exportNames.has(d.name.text));
|
|
144
|
+
}
|
|
145
|
+
} else if (ts.isInterfaceDeclaration(st)) add(st.name.text, "interface", st, pub(st, st.name.text));
|
|
146
|
+
else if (ts.isTypeAliasDeclaration(st)) add(st.name.text, "type", st, pub(st, st.name.text));
|
|
147
|
+
else if (ts.isEnumDeclaration(st)) add(st.name.text, "enum", st, pub(st, st.name.text));
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const resolveSpec = (fromFile, spec) => {
|
|
152
|
+
if (!spec.startsWith(".")) return null;
|
|
153
|
+
const base = path.resolve(path.dirname(fromFile), spec);
|
|
154
|
+
const stem = base.replace(/\.(m|c)?js$/, "");
|
|
155
|
+
const cands = [base, ...[...TS_EXT, ...JS_EXT].flatMap((x) => [stem + x, path.join(base, "index" + x)])];
|
|
156
|
+
for (const c of cands) if (byFile.has(c)) return byFile.get(c).module;
|
|
157
|
+
return null;
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
for (const { e, src } of parsed) {
|
|
161
|
+
const table = tables.get(e.module), bindings = new Map();
|
|
162
|
+
for (const st of src.statements) {
|
|
163
|
+
const isImp = ts.isImportDeclaration(st), isRe = ts.isExportDeclaration(st) && st.moduleSpecifier;
|
|
164
|
+
if (!isImp && !isRe) continue;
|
|
165
|
+
const spec = st.moduleSpecifier.text, names = [];
|
|
166
|
+
const typeOnly = isImp ? !!st.importClause?.isTypeOnly : !!st.isTypeOnly;
|
|
167
|
+
const target = resolveSpec(e.file, spec);
|
|
168
|
+
const clause = isImp ? st.importClause : null;
|
|
169
|
+
if (clause?.name) { names.push("default"); bindings.set(clause.name.text, [target, "default"]); }
|
|
170
|
+
const nb = isImp ? clause?.namedBindings : st.exportClause;
|
|
171
|
+
if (nb && ts.isNamespaceImport(nb)) { names.push("*"); bindings.set(nb.name.text, [target, null]); }
|
|
172
|
+
else if (nb && (ts.isNamedImports(nb) || ts.isNamedExports(nb))) {
|
|
173
|
+
for (const el of nb.elements) {
|
|
174
|
+
const orig = (el.propertyName || el.name).text;
|
|
175
|
+
names.push(orig);
|
|
176
|
+
if (isImp) bindings.set(el.name.text, [target, orig]);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
imports.push({ module: target ?? spec, names: names.sort(), line: line(src, st.getStart(src)), ...(multi ? { file: e.file } : {}) });
|
|
180
|
+
if (target !== null && !typeOnly) graph.get(e.module).add(target);
|
|
181
|
+
}
|
|
182
|
+
for (const [name, { node, owner }] of table) {
|
|
183
|
+
const from = ref(e, name);
|
|
184
|
+
const roots = ts.isClassDeclaration(node)
|
|
185
|
+
? [...(node.heritageClauses || []), ...node.members.filter((m) => !(ts.isMethodDeclaration(m) || ts.isGetAccessor(m) || ts.isSetAccessor(m) || ts.isConstructorDeclaration(m)))]
|
|
186
|
+
: [node];
|
|
187
|
+
const visit = (n) => {
|
|
188
|
+
let to = null;
|
|
189
|
+
if (ts.isPropertyAccessExpression(n) && ts.isIdentifier(n.name)) {
|
|
190
|
+
const obj = n.expression;
|
|
191
|
+
if (owner && obj.kind === K.ThisKeyword && table.has(`${owner}.${n.name.text}`)) to = ref(e, `${owner}.${n.name.text}`);
|
|
192
|
+
else if (ts.isIdentifier(obj) && bindings.has(obj.text) && bindings.get(obj.text)[1] === null) {
|
|
193
|
+
const [mod] = bindings.get(obj.text);
|
|
194
|
+
if (mod !== null && tables.get(mod)?.has(n.name.text)) to = `${mod}:${n.name.text}`;
|
|
195
|
+
}
|
|
196
|
+
} else if (ts.isIdentifier(n) && !(n.parent && ts.isPropertyAccessExpression(n.parent) && n.parent.name === n)) {
|
|
197
|
+
if (n !== node.name && table.has(n.text)) to = ref(e, n.text);
|
|
198
|
+
else if (bindings.has(n.text)) {
|
|
199
|
+
const [mod, sym] = bindings.get(n.text);
|
|
200
|
+
if (mod !== null && sym !== null && tables.get(mod)?.has(sym)) to = `${mod}:${sym}`;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
if (to && to !== from) edges.add(JSON.stringify([from, to]));
|
|
204
|
+
ts.forEachChild(n, visit);
|
|
205
|
+
};
|
|
206
|
+
for (const r of roots) visit(r);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const cycles = tarjan(graph).filter((c) => c.length > 1 || graph.get(c[0]).has(c[0])).sort((a, b) => cmp(a.join("\0"), b.join("\0")));
|
|
211
|
+
symbols.sort((a, b) => cmp(a.file || "", b.file || "") || a.start - b.start || cmp(a.name, b.name));
|
|
212
|
+
imports.sort((a, b) => cmp(a.file || "", b.file || "") || a.line - b.line || cmp(a.module, b.module));
|
|
213
|
+
return {
|
|
214
|
+
symbols,
|
|
215
|
+
edges: [...edges].sort().map((s) => { const [from, to] = JSON.parse(s); return { from, to }; }),
|
|
216
|
+
imports,
|
|
217
|
+
cycles,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function main(argv) {
|
|
222
|
+
if (!argv.length || argv[0] === "-h" || argv[0] === "--help") {
|
|
223
|
+
process.stderr.write("usage: symbol-index.mjs <file | dir>...\n");
|
|
224
|
+
return 2;
|
|
225
|
+
}
|
|
226
|
+
for (const p of argv) if (!existsSync(p)) { process.stderr.write(`symbol-index: no such path: ${p}\n`); return 2; }
|
|
227
|
+
const files = argv.filter((p) => statSync(p).isFile());
|
|
228
|
+
const exts = files.map((f) => path.extname(f).toLowerCase());
|
|
229
|
+
if (exts.includes(".py")) {
|
|
230
|
+
if (exts.some((x) => x !== ".py")) return emit({ unsupported: "mixed", reason: "mixed-languages" }), 0;
|
|
231
|
+
const py = path.join(path.dirname(fileURLToPath(import.meta.url)), "symbol-index.py");
|
|
232
|
+
const r = spawnSync("python3", [py, ...argv], { stdio: "inherit" });
|
|
233
|
+
if (r.error) return emit({ unsupported: "python", reason: "no-python3" }), 0;
|
|
234
|
+
return r.status ?? 2;
|
|
235
|
+
}
|
|
236
|
+
const bad = exts.find((x) => ![...TS_EXT, ...JS_EXT].includes(x));
|
|
237
|
+
if (bad !== undefined) return emit({ unsupported: langOf(bad), reason: "unknown-language" }), 0;
|
|
238
|
+
const entries = collect(argv);
|
|
239
|
+
if (!entries.length) return emit({ unsupported: "unknown", reason: "no-js-ts-files" }), 0;
|
|
240
|
+
const lang = entries.some((e) => TS_EXT.includes(e.ext)) ? "ts" : "js";
|
|
241
|
+
const ts = loadTs(argv);
|
|
242
|
+
if (!ts) return emit({ unsupported: lang, reason: "no-parser" }), 0;
|
|
243
|
+
const multi = !(argv.length === 1 && files.length === 1);
|
|
244
|
+
let out;
|
|
245
|
+
try { out = index(ts, entries, multi); } catch (err) {
|
|
246
|
+
if (err && err.reason) return emit({ unsupported: err.lang, reason: err.reason }), 0;
|
|
247
|
+
throw err;
|
|
248
|
+
}
|
|
249
|
+
emit(out);
|
|
250
|
+
return 0;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
process.exitCode = main(process.argv.slice(2));
|