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.
Files changed (159) hide show
  1. package/README.md +4 -1
  2. package/bin/bootstrap.mjs +55 -0
  3. package/bin/engine/outcome-codes.mjs +39 -0
  4. package/bin/engine/telemetry-seam.mjs +37 -8
  5. package/bin/engine/telemetry-seam.test.mjs +31 -0
  6. package/bin/engine-cli.mjs +601 -42
  7. package/bin/engine.mjs +8 -4
  8. package/bin/forgedock.mjs +75 -9
  9. package/bin/gc.mjs +580 -0
  10. package/bin/lean/admission.mjs +33 -3
  11. package/bin/lean/batch.mjs +22 -4
  12. package/bin/lean/batch.test.mjs +5 -3
  13. package/bin/lean/checks.mjs +199 -0
  14. package/bin/lean/checks.test.mjs +183 -0
  15. package/bin/lean/conformance.mjs +234 -57
  16. package/bin/lean/conformance.test.mjs +168 -7
  17. package/bin/lean/context.mjs +333 -0
  18. package/bin/lean/context.test.mjs +279 -0
  19. package/bin/lean/credentials.mjs +29 -0
  20. package/bin/lean/depcache.mjs +312 -0
  21. package/bin/lean/depcache.test.mjs +298 -0
  22. package/bin/lean/drift.mjs +198 -0
  23. package/bin/lean/drift.test.mjs +223 -0
  24. package/bin/lean/effects.mjs +104 -9
  25. package/bin/lean/execution.e2e.test.mjs +81 -0
  26. package/bin/lean/execution.mjs +431 -0
  27. package/bin/lean/execution.test.mjs +583 -0
  28. package/bin/lean/heavy.mjs +289 -0
  29. package/bin/lean/heavy.test.mjs +207 -0
  30. package/bin/lean/lean.mjs +723 -154
  31. package/bin/lean/lean.test.mjs +1603 -35
  32. package/bin/lean/ledger.mjs +27 -2
  33. package/bin/lean/ledger.test.mjs +36 -1
  34. package/bin/lean/limits.mjs +58 -0
  35. package/bin/lean/limits.test.mjs +150 -0
  36. package/bin/lean/orchestrate.mjs +389 -19
  37. package/bin/lean/prefilter.mjs +2 -2
  38. package/bin/lean/prefilter.test.mjs +3 -0
  39. package/bin/lean/prompts.mjs +106 -17
  40. package/bin/lean/recovery.test.mjs +230 -0
  41. package/bin/lean/refactor.mjs +276 -0
  42. package/bin/lean/refactor.test.mjs +359 -0
  43. package/bin/lean/render.mjs +49 -10
  44. package/bin/lean/review-trigger.mjs +134 -0
  45. package/bin/lean/review-trigger.test.mjs +104 -0
  46. package/bin/lean/sandbox-provider.contract.test.mjs +257 -0
  47. package/bin/lean/sandbox-remote.mjs +142 -0
  48. package/bin/lean/sched.mjs +221 -0
  49. package/bin/lean/sched.test.mjs +310 -0
  50. package/bin/lean/schedd.mjs +247 -0
  51. package/bin/lean/schedd.test.mjs +180 -0
  52. package/bin/lean/testmap.mjs +84 -0
  53. package/bin/lean/testmap.test.mjs +58 -0
  54. package/bin/lean/toolchain.mjs +228 -0
  55. package/bin/lean/toolchain.test.mjs +154 -0
  56. package/bin/lean/verify.mjs +66 -10
  57. package/bin/lean/verify.test.mjs +104 -3
  58. package/bin/lean/worktree.mjs +69 -0
  59. package/bin/lean/worktree.test.mjs +141 -0
  60. package/bin/runner.mjs +53 -10
  61. package/bin/stats/github-source.mjs +1 -1
  62. package/bin/stats/index.mjs +24 -2
  63. package/bin/stats/local-source.mjs +13 -0
  64. package/bin/stats/query.mjs +99 -0
  65. package/bin/stats/stats.test.mjs +94 -1
  66. package/commands/audit.md +2 -0
  67. package/commands/autopilot.md +2 -0
  68. package/commands/bootstrap.md +35 -0
  69. package/commands/cleanup.md +7 -22
  70. package/commands/forge-stats.md +2 -1
  71. package/commands/forgedock-init.md +7 -0
  72. package/commands/issue.md +89 -830
  73. package/commands/milestone.md +55 -6
  74. package/commands/orchestrate.md +62 -8
  75. package/commands/upgrade-deps.md +3 -3
  76. package/commands/work-on/build/validate.md +4 -0
  77. package/commands/work-on/close.md +2 -0
  78. package/commands/work-on/decompose.md +24 -6
  79. package/docs/CI.md +3 -3
  80. package/docs/CONFIG.md +192 -2
  81. package/docs/OUTCOME-CODES.md +22 -0
  82. package/docs/RELEASE-NOTES.md +9 -0
  83. package/docs/TELEMETRY.md +1 -1
  84. package/docs/site/command-reference.md +22 -0
  85. package/docs/site/getting-started.md +2 -0
  86. package/docs/site/start-a-new-project.md +327 -0
  87. package/package.json +1 -1
  88. package/scripts/affected-files-check.sh +91 -0
  89. package/scripts/affected-files-check.test.sh +80 -0
  90. package/scripts/bootstrap-create.mjs +393 -0
  91. package/scripts/bootstrap-validate.mjs +36 -2
  92. package/scripts/calibration.mjs +116 -0
  93. package/scripts/check-devdocs-freshness.sh +9 -4
  94. package/scripts/check-devdocs-freshness.test.sh +11 -1
  95. package/scripts/check-spec-markers.sh +4 -0
  96. package/scripts/check-spec-size.sh +29 -11
  97. package/scripts/check-spec-size.test.sh +27 -4
  98. package/scripts/ci-local/lib.mjs +8 -1
  99. package/scripts/ci-local/parallelism.test.mjs +27 -0
  100. package/scripts/ci-local/run.mjs +3 -3
  101. package/scripts/ci-local/scoping.mjs +82 -0
  102. package/scripts/ci-local.test.sh +1 -1
  103. package/scripts/fixtures/symbol-index/other/sample.rb +3 -0
  104. package/scripts/fixtures/symbol-index/py/pkg/__init__.py +1 -0
  105. package/scripts/fixtures/symbol-index/py/pkg/a.py +5 -0
  106. package/scripts/fixtures/symbol-index/py/pkg/b.py +14 -0
  107. package/scripts/fixtures/symbol-index/py/pkg/c.py +1 -0
  108. package/scripts/fixtures/symbol-index/py/sample.py +41 -0
  109. package/scripts/fixtures/symbol-index/ts/sample.ts +13 -0
  110. package/scripts/fixtures/symbol-move/gen_big.py +201 -0
  111. package/scripts/fixtures/symbol-move-ts/gen_big.mjs +56 -0
  112. package/scripts/fixtures/test-map/go/.github/workflows/ci.yml +23 -0
  113. package/scripts/fixtures/test-map/go/Makefile +5 -0
  114. package/scripts/fixtures/test-map/go/README.md +1 -0
  115. package/scripts/fixtures/test-map/go/go.mod +3 -0
  116. package/scripts/fixtures/test-map/go/internal/util/util.go +3 -0
  117. package/scripts/fixtures/test-map/go/main.go +3 -0
  118. package/scripts/fixtures/test-map/node/package.json +10 -0
  119. package/scripts/fixtures/test-map/node/packages/ui/package.json +4 -0
  120. package/scripts/fixtures/test-map/node/packages/ui/src/button.tsx +1 -0
  121. package/scripts/fixtures/test-map/node/pnpm-lock.yaml +1 -0
  122. package/scripts/fixtures/test-map/node/src/a.ts +1 -0
  123. package/scripts/fixtures/test-map/node/vitest.config.ts +1 -0
  124. package/scripts/fixtures/test-map/python/pkg/__init__.py +0 -0
  125. package/scripts/fixtures/test-map/python/pkg/mod.py +2 -0
  126. package/scripts/fixtures/test-map/python/pyproject.toml +9 -0
  127. package/scripts/fixtures/test-map/python/tests/test_mod.py +5 -0
  128. package/scripts/fixtures/test-map/python/uv.lock +1 -0
  129. package/scripts/forge-root.test.sh +1 -1
  130. package/scripts/hotspots.sh +100 -0
  131. package/scripts/hotspots.test.sh +101 -0
  132. package/scripts/issue-create.sh +197 -0
  133. package/scripts/issue-create.test.sh +166 -0
  134. package/scripts/maintenance-scan.sh +185 -0
  135. package/scripts/maintenance-scan.test.sh +89 -0
  136. package/scripts/milestone-drift.sh +206 -0
  137. package/scripts/milestone-drift.test.sh +120 -0
  138. package/scripts/refactor-verify.sh +368 -0
  139. package/scripts/refactor-verify.test.sh +202 -0
  140. package/scripts/repo-facts.sh +146 -0
  141. package/scripts/repo-facts.test.sh +106 -0
  142. package/scripts/structure-delta.sh +10 -5
  143. package/scripts/structure-delta.test.sh +3 -2
  144. package/scripts/symbol-index.mjs +253 -0
  145. package/scripts/symbol-index.py +354 -0
  146. package/scripts/symbol-index.test.sh +119 -0
  147. package/scripts/symbol-move-ts.test.sh +161 -0
  148. package/scripts/symbol-move.mjs +379 -0
  149. package/scripts/symbol-move.py +383 -0
  150. package/scripts/symbol-move.test.sh +168 -0
  151. package/scripts/test-map.sh +211 -0
  152. package/scripts/test-map.test.sh +165 -0
  153. package/scripts/wait-ci-green.sh +10 -4
  154. package/scripts/wait-ci-green.test.sh +9 -0
  155. package/templates/bootstrap/interview.md +34 -0
  156. package/templates/bootstrap/issue-body.md +42 -0
  157. package/templates/bootstrap/needs-human-issue.md +45 -0
  158. package/templates/bootstrap/stack-profiles.md +34 -0
  159. 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) { split(k, a, SUBSEP); print a[1] "\t" a[2] "\t" (add[k] + 0) "\t" (rem[k] + 0) }
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));