forgedock 1.27.0 → 1.29.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 +39 -0
- package/bin/engine/telemetry-seam.mjs +37 -8
- package/bin/engine/telemetry-seam.test.mjs +31 -0
- package/bin/engine-cli.mjs +601 -42
- 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 +234 -57
- package/bin/lean/conformance.test.mjs +168 -7
- package/bin/lean/context.mjs +333 -0
- package/bin/lean/context.test.mjs +279 -0
- 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 +104 -9
- 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 +723 -154
- package/bin/lean/lean.test.mjs +1603 -35
- 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 +389 -19
- package/bin/lean/prefilter.mjs +2 -2
- package/bin/lean/prefilter.test.mjs +3 -0
- package/bin/lean/prompts.mjs +106 -17
- package/bin/lean/recovery.test.mjs +230 -0
- package/bin/lean/refactor.mjs +276 -0
- package/bin/lean/refactor.test.mjs +359 -0
- package/bin/lean/render.mjs +49 -10
- 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/testmap.mjs +84 -0
- package/bin/lean/testmap.test.mjs +58 -0
- package/bin/lean/toolchain.mjs +228 -0
- package/bin/lean/toolchain.test.mjs +154 -0
- package/bin/lean/verify.mjs +66 -10
- package/bin/lean/verify.test.mjs +104 -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 +24 -2
- package/bin/stats/local-source.mjs +13 -0
- package/bin/stats/query.mjs +99 -0
- package/bin/stats/stats.test.mjs +94 -1
- 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 +62 -8
- 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 +192 -2
- package/docs/OUTCOME-CODES.md +22 -0
- package/docs/RELEASE-NOTES.md +9 -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 +4 -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/fixtures/test-map/go/.github/workflows/ci.yml +23 -0
- package/scripts/fixtures/test-map/go/Makefile +5 -0
- package/scripts/fixtures/test-map/go/README.md +1 -0
- package/scripts/fixtures/test-map/go/go.mod +3 -0
- package/scripts/fixtures/test-map/go/internal/util/util.go +3 -0
- package/scripts/fixtures/test-map/go/main.go +3 -0
- package/scripts/fixtures/test-map/node/package.json +10 -0
- package/scripts/fixtures/test-map/node/packages/ui/package.json +4 -0
- package/scripts/fixtures/test-map/node/packages/ui/src/button.tsx +1 -0
- package/scripts/fixtures/test-map/node/pnpm-lock.yaml +1 -0
- package/scripts/fixtures/test-map/node/src/a.ts +1 -0
- package/scripts/fixtures/test-map/node/vitest.config.ts +1 -0
- package/scripts/fixtures/test-map/python/pkg/__init__.py +0 -0
- package/scripts/fixtures/test-map/python/pkg/mod.py +2 -0
- package/scripts/fixtures/test-map/python/pyproject.toml +9 -0
- package/scripts/fixtures/test-map/python/tests/test_mod.py +5 -0
- package/scripts/fixtures/test-map/python/uv.lock +1 -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/repo-facts.sh +146 -0
- package/scripts/repo-facts.test.sh +106 -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/scripts/test-map.sh +211 -0
- package/scripts/test-map.test.sh +165 -0
- package/scripts/wait-ci-green.sh +10 -4
- package/scripts/wait-ci-green.test.sh +9 -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,146 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
3
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
4
|
+
#
|
|
5
|
+
# repo-facts.sh — deterministic repo facts the builder reads instead of re-learning them each lane (forge#4272). No LLM,
|
|
6
|
+
# no network, runs none of the repo's commands: git + jq + awk + node. Codebase-agnostic.
|
|
7
|
+
#
|
|
8
|
+
# Usage: repo-facts.sh [--repo-path DIR] [--output FILE | --stdout]
|
|
9
|
+
# default output <repo>/.forge/repo-facts.json, rewritten on every run (an optional cache; the builder prompt cites
|
|
10
|
+
# it by path when present, deleting it restores today's prompts).
|
|
11
|
+
# Output: {"version":1,
|
|
12
|
+
# "toolchain": bin/lean/toolchain.mjs detectPins(repo) {pm, node, notes} (forge#4163),
|
|
13
|
+
# "slow_suites": [{command, source, why}]: test-map entries (.forge/test-map.json when valid, else test-map.sh) whose
|
|
14
|
+
# command names e2e|playwright|cypress|integration|docker, or whose CI job sets `services:` or timeout-minutes >= 20,
|
|
15
|
+
# "layer_rules": {packages:[{name,dir}], edges:[{from,to,kind,manifest}], boundary_configs:[path]}: the workspace
|
|
16
|
+
# package graph at HEAD, same workspace and edge definitions as scripts/structure-delta.sh (forge#4159); boundary
|
|
17
|
+
# configs are eslint/dependency-cruiser/Nx files naming boundary rules,
|
|
18
|
+
# "hotspots": scripts/hotspots.sh over the tracked files, over-threshold and not excluded, by hotspot desc, max 20
|
|
19
|
+
# (forge#4195)}
|
|
20
|
+
# Exit: 0 ok | 2 usage or bad repo path. bash 3.2 portable.
|
|
21
|
+
set -uo pipefail
|
|
22
|
+
|
|
23
|
+
die() { echo "repo-facts: $*" >&2; exit 2; }
|
|
24
|
+
command -v jq >/dev/null 2>&1 || die "jq is required"
|
|
25
|
+
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
26
|
+
REPO="."; OUT=""; STDOUT=0
|
|
27
|
+
while [ $# -gt 0 ]; do
|
|
28
|
+
case "$1" in
|
|
29
|
+
--repo-path) [ $# -ge 2 ] && [ -n "$2" ] || die "usage: repo-facts.sh [--repo-path DIR] [--output FILE | --stdout]"; REPO="$2"; shift 2 ;;
|
|
30
|
+
--output) [ $# -ge 2 ] && [ -n "$2" ] || die "usage: repo-facts.sh [--repo-path DIR] [--output FILE | --stdout]"; OUT="$2"; shift 2 ;;
|
|
31
|
+
--stdout) STDOUT=1; shift ;;
|
|
32
|
+
*) die "unknown option: $1" ;;
|
|
33
|
+
esac
|
|
34
|
+
done
|
|
35
|
+
[ -d "$REPO" ] || die "repo path not found: $REPO"
|
|
36
|
+
case "$OUT" in ""|/*) ;; *) OUT="$PWD/$OUT" ;; esac
|
|
37
|
+
cd "$REPO" || die "cannot enter $REPO"
|
|
38
|
+
|
|
39
|
+
T=$(mktemp -d "${TMPDIR:-/tmp}/repo-facts.XXXXXX") || die "mktemp failed"
|
|
40
|
+
trap 'rm -rf "$T"' EXIT
|
|
41
|
+
HAS_HEAD=0; git rev-parse --verify -q "HEAD^{commit}" >/dev/null 2>&1 && HAS_HEAD=1
|
|
42
|
+
# -z and quotepath=off (#4298): non-ASCII and spaced paths come out verbatim, never C-quoted
|
|
43
|
+
if [ "$HAS_HEAD" = 1 ]; then git -c core.quotepath=off ls-tree -r -z --name-only HEAD | tr '\0' '\n' > "$T/files"; else : > "$T/files"; fi
|
|
44
|
+
# file content at HEAD
|
|
45
|
+
at_head() { git show "HEAD:$1" 2>/dev/null; }
|
|
46
|
+
|
|
47
|
+
# ---- toolchain (reuses #4163 detection) ----
|
|
48
|
+
TOOLCHAIN=$(node --input-type=module -e '
|
|
49
|
+
const { detectPins } = await import(process.argv[1]);
|
|
50
|
+
console.log(JSON.stringify(await detectPins(process.argv[2])));
|
|
51
|
+
' "$HERE/../bin/lean/toolchain.mjs" "$PWD" 2>/dev/null) || TOOLCHAIN=""
|
|
52
|
+
printf '%s' "$TOOLCHAIN" | jq -e 'type == "object"' >/dev/null 2>&1 || TOOLCHAIN='{"pm":null,"node":null,"notes":["toolchain detection failed"]}'
|
|
53
|
+
|
|
54
|
+
# ---- slow suites ----
|
|
55
|
+
MAP=""
|
|
56
|
+
[ -f .forge/test-map.json ] && MAP=$(jq -c 'select(.version == 1 and (.entries | type == "array"))' .forge/test-map.json 2>/dev/null)
|
|
57
|
+
[ -n "$MAP" ] || MAP=$(bash "$HERE/test-map.sh" --stdout 2>/dev/null)
|
|
58
|
+
[ -n "$MAP" ] || MAP='{"version":1,"entries":[]}'
|
|
59
|
+
# CI jobs: "workflow#jobs.<job> TAB services(0|1) TAB timeout-minutes(0 when unset)"
|
|
60
|
+
: > "$T/jobs"
|
|
61
|
+
grep -E '^\.github/workflows/[^/]+\.(yml|yaml)$' "$T/files" | while IFS= read -r wf; do
|
|
62
|
+
[ -f "$wf" ] || continue
|
|
63
|
+
awk -v wf="$wf" '
|
|
64
|
+
function indent(s) { match(s, /^ */); return RLENGTH }
|
|
65
|
+
function flush() { if (job != "") print wf "#jobs." job "\t" svc "\t" tmo }
|
|
66
|
+
/^jobs:[ \t]*$/ { on = 1; next }
|
|
67
|
+
on && /^[^ \t#]/ { flush(); job = ""; on = 0; next }
|
|
68
|
+
on && /^ +[A-Za-z0-9_-]+:[ \t]*$/ && (jind == 0 || indent($0) == jind) { flush(); jind = indent($0); job = $0; sub(/^ +/, "", job); sub(/:.*$/, "", job); svc = 0; tmo = 0; next }
|
|
69
|
+
on && job != "" && indent($0) == jind + 2 && /^ *services:/ { svc = 1 }
|
|
70
|
+
on && job != "" && indent($0) == jind + 2 && /^ *timeout-minutes:[ \t]*[0-9]+/ { t = $0; sub(/^ *timeout-minutes:[ \t]*/, "", t); tmo = t + 0 }
|
|
71
|
+
END { flush() }
|
|
72
|
+
' "$wf" >> "$T/jobs"
|
|
73
|
+
done
|
|
74
|
+
JOBS=$(jq -R -s -c '[split("\n")[] | select(length > 0) | split("\t") | {key: .[0], value: {services: (.[1] == "1"), timeout: (.[2] | tonumber)}}] | from_entries' < "$T/jobs")
|
|
75
|
+
SLOW=$(printf '%s' "$MAP" | jq -c --argjson jobs "$JOBS" '
|
|
76
|
+
[.entries[] | . as $e
|
|
77
|
+
| ($e.command | ascii_downcase | [scan("e2e|playwright|cypress|integration|docker")] | first) as $w
|
|
78
|
+
| ($jobs[$e.source] // null) as $j
|
|
79
|
+
| (if $w then "command names \($w)"
|
|
80
|
+
elif $j and $j.services then "CI job \($e.source) uses services"
|
|
81
|
+
elif $j and $j.timeout >= 20 then "CI job \($e.source) sets timeout-minutes \($j.timeout)"
|
|
82
|
+
else null end) as $why
|
|
83
|
+
| select($why) | {command: $e.command, source: $e.source, why: $why}]')
|
|
84
|
+
|
|
85
|
+
# ---- layer rules: workspace packages and their edges at HEAD (structure-delta.sh definitions) ----
|
|
86
|
+
{
|
|
87
|
+
at_head package.json | jq -r '(.workspaces // empty) | if type == "array" then .[] elif type == "object" then (.packages // [])[] else empty end | strings' 2>/dev/null
|
|
88
|
+
at_head pnpm-workspace.yaml | awk '
|
|
89
|
+
/^packages:/ { on = 1; next }
|
|
90
|
+
on && /^[^ \t#-]/ { on = 0 }
|
|
91
|
+
on && /^[ \t]*-[ \t]*/ { sub(/^[ \t]*-[ \t]*/, ""); sub(/[ \t]+#.*$/, ""); gsub(/["\047]/, ""); sub(/[ \t]+$/, ""); if ($0 != "") print }'
|
|
92
|
+
} | sed 's#^\./##; s#/$##' | sort -u > "$T/globs"
|
|
93
|
+
to_re() { printf '%s' "$1" | sed 's/[.+^$()|{}[]/\\&/g; s/\*\*/%%/g; s/\*/[^\/]*/g; s/%%/.*/g; s/?/[^\/]/g'; }
|
|
94
|
+
: > "$T/inc"; : > "$T/exc"; : > "$T/names"; : > "$T/deps"
|
|
95
|
+
while IFS= read -r g; do
|
|
96
|
+
case "$g" in \!*) echo "^$(to_re "${g#!}")\$" >> "$T/exc" ;; *) echo "^$(to_re "$g")\$" >> "$T/inc" ;; esac
|
|
97
|
+
done < "$T/globs"
|
|
98
|
+
if [ -s "$T/inc" ]; then
|
|
99
|
+
grep -E '/package\.json$' "$T/files" | grep -v -E '(^|/)node_modules/' | while IFS= read -r m; do
|
|
100
|
+
d=${m%/package.json}
|
|
101
|
+
grep -qE -f "$T/inc" <<<"$d" || continue
|
|
102
|
+
if [ -s "$T/exc" ] && grep -qE -f "$T/exc" <<<"$d"; then continue; fi
|
|
103
|
+
at_head "$m" > "$T/pj" || continue
|
|
104
|
+
n=$(jq -r '.name // empty | strings' "$T/pj" 2>/dev/null)
|
|
105
|
+
[ -n "$n" ] || continue
|
|
106
|
+
printf '%s\t%s\t%s\n' "$d" "$m" "$n" >> "$T/names"
|
|
107
|
+
jq -r --arg m "$m" '["dependencies","devDependencies","peerDependencies"][] as $k | (.[$k] // {}) | objects | keys[] | [$m, $k, .] | @tsv' "$T/pj" 2>/dev/null >> "$T/deps"
|
|
108
|
+
done
|
|
109
|
+
fi
|
|
110
|
+
PACKAGES=$(jq -R -s -c '[split("\n")[] | select(length > 0) | split("\t") | {name: .[2], dir: .[0]}] | sort_by(.name)' < "$T/names")
|
|
111
|
+
EDGES=$(awk -F'\t' '
|
|
112
|
+
FILENAME == ARGV[1] { name[$2] = $3; ws[$3] = 1; next }
|
|
113
|
+
($3 in ws) && ($1 in name) && name[$1] != $3 { print name[$1] "\t" $3 "\t" $2 "\t" $1 }
|
|
114
|
+
' "$T/names" "$T/deps" | sort -u | jq -R -s -c '[split("\n")[] | select(length > 0) | split("\t") | {from: .[0], to: .[1], kind: .[2], manifest: .[3]}]')
|
|
115
|
+
: > "$T/bfiles"
|
|
116
|
+
while IFS= read -r f; do
|
|
117
|
+
case "${f##*/}" in
|
|
118
|
+
.eslintrc|.eslintrc.*|eslint.config.*|.dependency-cruiser.*|nx.json|project.json)
|
|
119
|
+
# here-string, not `at_head | grep -q`: under pipefail an early grep exit SIGPIPEs at_head and reads as no match (#4298)
|
|
120
|
+
c=$(at_head "$f"); grep -qE 'boundaries|forbidden|depConstraints|DependOnLibsWithTags|no-restricted-paths' <<<"$c" && printf '%s\n' "$f" >> "$T/bfiles" ;;
|
|
121
|
+
esac
|
|
122
|
+
done < "$T/files"
|
|
123
|
+
BOUNDARY=$(jq -R -s -c 'split("\n") | map(select(length > 0))' < "$T/bfiles")
|
|
124
|
+
|
|
125
|
+
# ---- hotspots (reuses #4195 facts) ----
|
|
126
|
+
# hotspots.sh with no paths reports its config; only files over file_lines can be hotspots, so only those are passed
|
|
127
|
+
HOT='[]'
|
|
128
|
+
FL=$(bash "$HERE/hotspots.sh" 2>/dev/null | jq -r 'select(.enabled) | .file_lines' 2>/dev/null)
|
|
129
|
+
if [ -s "$T/files" ] && [ -n "$FL" ]; then
|
|
130
|
+
while IFS= read -r f; do
|
|
131
|
+
[ -f "$f" ] || continue
|
|
132
|
+
n=$(wc -l < "$f" | tr -d ' '); [ "$n" -gt "$FL" ] && printf '%s\n' "$f"
|
|
133
|
+
done < "$T/files" > "$T/big"
|
|
134
|
+
H=""; [ -s "$T/big" ] && { H=$(tr '\n' '\0' < "$T/big" | xargs -0 -n 200 bash "$HERE/hotspots.sh" -- 2>/dev/null) || H=""; }
|
|
135
|
+
HOT=$(printf '%s' "$H" | jq -s -c '[.[].files[]? | select(.over and (.excluded | not)) | {path, lines, commits_90d, hotspot}] | sort_by(-.hotspot, .path) | .[:20]' 2>/dev/null) || HOT='[]'
|
|
136
|
+
[ -n "$HOT" ] || HOT='[]'
|
|
137
|
+
fi
|
|
138
|
+
|
|
139
|
+
FACTS=$(jq -n -c --argjson tc "$TOOLCHAIN" --argjson slow "$SLOW" --argjson pk "$PACKAGES" --argjson ed "$EDGES" --argjson bc "$BOUNDARY" --argjson hot "$HOT" \
|
|
140
|
+
'{version: 1, toolchain: $tc, slow_suites: $slow, layer_rules: {packages: $pk, edges: $ed, boundary_configs: $bc}, hotspots: $hot}') || die "jq failed"
|
|
141
|
+
|
|
142
|
+
if [ "$STDOUT" = 1 ]; then printf '%s\n' "$FACTS"; exit 0; fi
|
|
143
|
+
[ -n "$OUT" ] || OUT=".forge/repo-facts.json"
|
|
144
|
+
mkdir -p "$(dirname "$OUT")" || die "cannot create $(dirname "$OUT")"
|
|
145
|
+
printf '%s\n' "$FACTS" > "$OUT.tmp.$$" && mv "$OUT.tmp.$$" "$OUT" || die "cannot write $OUT"
|
|
146
|
+
echo "$OUT"
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# repo-facts.test.sh — cases for scripts/repo-facts.sh (forge#4272).
|
|
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)/repo-facts.sh"
|
|
7
|
+
PASS=0; FAILN=0
|
|
8
|
+
TMP="$(mktemp -d "${TMPDIR:-/tmp}/repo-facts-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/$1"; mkdir -p "$TMP/$1"; cd "$TMP/$1" || exit 1
|
|
16
|
+
git init -q -b main; 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
|
+
lines() { i=0; : > "$1"; while [ $i -lt "$2" ]; do echo "l$i" >> "$1"; i=$((i+1)); done; }
|
|
20
|
+
|
|
21
|
+
# ---- 1. pins, slow suites, package edge, boundary config, hotspot ----
|
|
22
|
+
new_repo full
|
|
23
|
+
mkdir -p packages/a packages/b .github/workflows src
|
|
24
|
+
cat > package.json <<'EOF'
|
|
25
|
+
{"name":"root","private":true,"packageManager":"pnpm@9.1.0","workspaces":["packages/*"],
|
|
26
|
+
"scripts":{"test":"vitest run","lint":"eslint .","test:e2e":"playwright test"}}
|
|
27
|
+
EOF
|
|
28
|
+
echo "20.11.1" > .nvmrc
|
|
29
|
+
echo '{"name":"@x/a","dependencies":{"@x/b":"workspace:*","left-pad":"1.0.0"}}' > packages/a/package.json
|
|
30
|
+
echo '{"name":"@x/b","scripts":{"test":"vitest run"}}' > packages/b/package.json
|
|
31
|
+
echo "module.exports = { rules: { 'boundaries/element-types': ['error'] } };" > .eslintrc.js
|
|
32
|
+
echo "module.exports = { rules: { semi: 'error' } };" > packages/b/.eslintrc.js
|
|
33
|
+
cat > .github/workflows/ci.yml <<'EOF'
|
|
34
|
+
on: push
|
|
35
|
+
jobs:
|
|
36
|
+
db:
|
|
37
|
+
runs-on: ubuntu-latest
|
|
38
|
+
services:
|
|
39
|
+
postgres:
|
|
40
|
+
image: postgres
|
|
41
|
+
steps:
|
|
42
|
+
- run: pnpm run test
|
|
43
|
+
long:
|
|
44
|
+
runs-on: ubuntu-latest
|
|
45
|
+
timeout-minutes: 45
|
|
46
|
+
steps:
|
|
47
|
+
- run: make lint-all
|
|
48
|
+
quick:
|
|
49
|
+
runs-on: ubuntu-latest
|
|
50
|
+
timeout-minutes: 5
|
|
51
|
+
steps:
|
|
52
|
+
- run: make fmt
|
|
53
|
+
EOF
|
|
54
|
+
lines src/big.ts 2100; commit c1
|
|
55
|
+
echo x >> src/big.ts; lines src/small.ts 10; commit c2
|
|
56
|
+
OUT="$(bash "$S" --stdout)"; rc=$?
|
|
57
|
+
eq "rc" "$rc" 0
|
|
58
|
+
eq "version" "$(q '.version')" 1
|
|
59
|
+
eq "pm pin" "$(q '.toolchain.pm')" '{"name":"pnpm","version":"9.1.0","source":"package.json packageManager"}'
|
|
60
|
+
eq "node pin" "$(q '.toolchain.node')" '{"spec":"20.11.1","source":".nvmrc"}'
|
|
61
|
+
eq "packages" "$(q '.layer_rules.packages')" '[{"name":"@x/a","dir":"packages/a"},{"name":"@x/b","dir":"packages/b"}]'
|
|
62
|
+
eq "edge" "$(q '.layer_rules.edges')" '[{"from":"@x/a","to":"@x/b","kind":"dependencies","manifest":"packages/a/package.json"}]'
|
|
63
|
+
eq "boundary configs" "$(q '.layer_rules.boundary_configs')" '[".eslintrc.js"]'
|
|
64
|
+
eq "services job" "$(q '[.slow_suites[] | select(.command == "pnpm run test") | .why]')" '["CI job .github/workflows/ci.yml#jobs.db uses services"]'
|
|
65
|
+
eq "timeout job" "$(q '[.slow_suites[] | select(.command == "make lint-all") | .why]')" '["CI job .github/workflows/ci.yml#jobs.long sets timeout-minutes 45"]'
|
|
66
|
+
eq "quick job not slow" "$(q '[.slow_suites[] | select(.command == "make fmt")] | length')" 0
|
|
67
|
+
eq "hotspot" "$(q '.hotspots')" '[{"path":"src/big.ts","lines":2101,"commits_90d":2,"hotspot":4202}]'
|
|
68
|
+
|
|
69
|
+
# ---- 2. a cached test map is read: its e2e command is a slow suite ----
|
|
70
|
+
mkdir -p .forge
|
|
71
|
+
echo '{"version":1,"entries":[{"globs":["e2e/**"],"command":"pnpm exec playwright test","cwd":".","source":"package.json#scripts.test:e2e"}]}' > .forge/test-map.json
|
|
72
|
+
OUT="$(bash "$S" --stdout)"
|
|
73
|
+
eq "map e2e" "$(q '.slow_suites')" '[{"command":"pnpm exec playwright test","source":"package.json#scripts.test:e2e","why":"command names playwright"}]'
|
|
74
|
+
|
|
75
|
+
# ---- 3. default output file, rerun stable ----
|
|
76
|
+
P="$(bash "$S")"; eq "output path" "$P" ".forge/repo-facts.json"
|
|
77
|
+
cp .forge/repo-facts.json "$TMP/first.json"; bash "$S" >/dev/null
|
|
78
|
+
if cmp -s .forge/repo-facts.json "$TMP/first.json"; then ok; else bad "rerun not identical"; fi
|
|
79
|
+
|
|
80
|
+
# ---- 4. a repo with none of these: empty sections, exit 0 ----
|
|
81
|
+
new_repo bare
|
|
82
|
+
echo hi > README.md; commit init
|
|
83
|
+
OUT="$(bash "$S" --stdout)"; rc=$?
|
|
84
|
+
eq "bare rc" "$rc" 0
|
|
85
|
+
eq "bare" "$(q '[.toolchain.pm, .toolchain.node, .slow_suites, .layer_rules, .hotspots]')" '[null,null,[],{"packages":[],"edges":[],"boundary_configs":[]},[]]'
|
|
86
|
+
new_repo empty
|
|
87
|
+
OUT="$(bash "$S" --stdout)"; eq "no commits rc" "$?" 0
|
|
88
|
+
eq "no commits" "$(q '.hotspots')" '[]'
|
|
89
|
+
|
|
90
|
+
# ---- 4b. #4298: a big boundary config matching early (pipefail + grep -q SIGPIPE) and non-ASCII / spaced paths ----
|
|
91
|
+
new_repo paths
|
|
92
|
+
mkdir -p "packages/é b" "apps/ü"
|
|
93
|
+
echo '{"name":"root","private":true,"workspaces":["packages/*"]}' > package.json
|
|
94
|
+
echo '{"name":"@x/c"}' > "packages/é b/package.json"
|
|
95
|
+
{ echo "export default [{ rules: { 'boundaries/element-types': ['error'] } }];"; i=0; while [ $i -lt 20000 ]; do echo "// padding line $i to overflow the pipe buffer ......................................"; i=$((i+1)); done; } > "apps/ü/eslint.config.js"
|
|
96
|
+
commit init
|
|
97
|
+
OUT="$(bash "$S" --stdout)"; eq "paths rc" "$?" 0
|
|
98
|
+
eq "big boundary config in a non-ASCII dir" "$(q '.layer_rules.boundary_configs')" '["apps/ü/eslint.config.js"]'
|
|
99
|
+
eq "spaced non-ASCII package dir" "$(q '.layer_rules.packages')" '[{"name":"@x/c","dir":"packages/é b"}]'
|
|
100
|
+
|
|
101
|
+
# ---- 5. usage ----
|
|
102
|
+
bash "$S" --repo-path "$TMP/missing" >/dev/null 2>&1; eq "bad repo path" "$?" 2
|
|
103
|
+
bash "$S" --bogus >/dev/null 2>&1; eq "unknown option" "$?" 2
|
|
104
|
+
|
|
105
|
+
echo "repo-facts.test.sh: $PASS passed, $FAILN failed"
|
|
106
|
+
[ "$FAILN" -eq 0 ]
|
|
@@ -9,11 +9,12 @@
|
|
|
9
9
|
# Output (stdout, one JSON object):
|
|
10
10
|
# {} when the repo declares no workspace packages (root package.json `workspaces` / pnpm-workspace.yaml), else
|
|
11
11
|
# {"package_edges_added":[{from,to,kind,manifest}], "package_edges_removed":[...],
|
|
12
|
-
# "import_pairs":[{from,to,files_added,files_removed}],
|
|
12
|
+
# "import_pairs":[{from,to,files_added,files_removed,files[]}],
|
|
13
13
|
# "boundary_config_changed":{changed,files[],loosens,tightens}}
|
|
14
14
|
# A package edge is a dependencies/devDependencies/peerDependencies key naming another workspace package. An import
|
|
15
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.
|
|
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).
|
|
17
18
|
# Exit: 0 ok | 2 usage or bad ref. bash 3.2 portable.
|
|
18
19
|
set -uo pipefail
|
|
19
20
|
|
|
@@ -122,14 +123,18 @@ IMPORTS=$(awk -F'\t' '
|
|
|
122
123
|
END {
|
|
123
124
|
for (k in hit) {
|
|
124
125
|
split(k, a, SUBSEP); f = a[1]; to = a[2]; s = a[3]
|
|
125
|
-
if (s == "+" && !((f SUBSEP to SUBSEP "-") in hit)) add[fromof[f] SUBSEP to]
|
|
126
|
+
if (s == "+" && !((f SUBSEP to SUBSEP "-") in hit)) { add[fromof[f] SUBSEP to]++; files[fromof[f] SUBSEP to, add[fromof[f] SUBSEP to]] = f }
|
|
126
127
|
if (s == "-" && !((f SUBSEP to SUBSEP "+") in hit)) rem[fromof[f] SUBSEP to]++
|
|
127
128
|
}
|
|
128
129
|
for (k in add) pair[k] = 1
|
|
129
130
|
for (k in rem) pair[k] = 1
|
|
130
|
-
for (k in pair) {
|
|
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
|
+
}
|
|
131
136
|
}
|
|
132
|
-
' "$T/dirs" "$T/wsnames" "$T/diff" | sort | jq -R -s -c 'split("\n") | map(select(length > 0) | split("\t") | {from: .[0], to: .[1], files_added: (.[2] | tonumber), files_removed: (.[3] | tonumber)})')
|
|
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])})')
|
|
133
138
|
|
|
134
139
|
# ---- boundary config (eslint-plugin-boundaries, dependency-cruiser, Nx depConstraints) ----
|
|
135
140
|
: > "$T/bfiles"; LOOSE=false; TIGHT=false
|
|
@@ -68,7 +68,7 @@ eq "monorepo rc" "$rc" 0
|
|
|
68
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
69
|
eq "edges removed" "$(q '.package_edges_removed')" '[{"from":"@scope/web","to":"@scope/old","kind":"dependencies","manifest":"apps/web/package.json"}]'
|
|
70
70
|
eq "non-workspace dep ignored" "$(q '[.package_edges_added[] | select(.to == "zod" or .to == "pg")] | length')" 0
|
|
71
|
-
eq "import pairs" "$(q '.import_pairs | sort_by(.to)')" '[{"from":"@scope/web","to":"@scope/db","files_added":1,"files_removed":0},{"from":"@scope/web","to":"@scope/old","files_added":0,"files_removed":1},{"from":"@scope/web","to":"@scope/x","files_added":2,"files_removed":0}]'
|
|
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
72
|
eq "import type never counted" "$(q '[.import_pairs[] | select(.to == "@scope/core")] | length')" 0
|
|
73
73
|
eq "no boundary change" "$(q '.boundary_config_changed')" '{"changed":false,"files":[],"loosens":false,"tightens":false}'
|
|
74
74
|
# the two-arg form agrees
|
|
@@ -127,7 +127,8 @@ for t in gh curl wget npm npx pnpm yarn nc ssh; do printf '#!/bin/sh\necho "%s"
|
|
|
127
127
|
start=$(date +%s%N 2>/dev/null)
|
|
128
128
|
OUT="$(PATH="$TMP/bin:$PATH" bash "$S" master...big 2>/dev/null || PATH="$TMP/bin:$PATH" bash "$S" main...big)"
|
|
129
129
|
end=$(date +%s%N 2>/dev/null)
|
|
130
|
-
eq "300-file count" "$(q '.import_pairs')" '[{"from":"@s/a","to":"@s/b","files_added":300,"files_removed":0}]'
|
|
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"]'
|
|
131
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
|
|
132
133
|
if [ -e "$TMP/net.log" ]; then bad "network tool invoked: $(tr '\n' ' ' < "$TMP/net.log")"; else ok; fi
|
|
133
134
|
|
|
@@ -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));
|