forgedock 1.27.0 → 1.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/README.md +4 -1
  2. package/bin/bootstrap.mjs +55 -0
  3. package/bin/engine/outcome-codes.mjs +10 -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 +542 -38
  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 +190 -58
  16. package/bin/lean/conformance.test.mjs +116 -7
  17. package/bin/lean/credentials.mjs +29 -0
  18. package/bin/lean/depcache.mjs +312 -0
  19. package/bin/lean/depcache.test.mjs +298 -0
  20. package/bin/lean/drift.mjs +198 -0
  21. package/bin/lean/drift.test.mjs +223 -0
  22. package/bin/lean/effects.mjs +85 -8
  23. package/bin/lean/execution.e2e.test.mjs +81 -0
  24. package/bin/lean/execution.mjs +431 -0
  25. package/bin/lean/execution.test.mjs +583 -0
  26. package/bin/lean/heavy.mjs +289 -0
  27. package/bin/lean/heavy.test.mjs +207 -0
  28. package/bin/lean/lean.mjs +572 -139
  29. package/bin/lean/lean.test.mjs +1193 -31
  30. package/bin/lean/ledger.mjs +27 -2
  31. package/bin/lean/ledger.test.mjs +36 -1
  32. package/bin/lean/limits.mjs +58 -0
  33. package/bin/lean/limits.test.mjs +150 -0
  34. package/bin/lean/orchestrate.mjs +312 -19
  35. package/bin/lean/prompts.mjs +89 -15
  36. package/bin/lean/refactor.mjs +276 -0
  37. package/bin/lean/refactor.test.mjs +359 -0
  38. package/bin/lean/render.mjs +24 -5
  39. package/bin/lean/review-trigger.mjs +134 -0
  40. package/bin/lean/review-trigger.test.mjs +104 -0
  41. package/bin/lean/sandbox-provider.contract.test.mjs +257 -0
  42. package/bin/lean/sandbox-remote.mjs +142 -0
  43. package/bin/lean/sched.mjs +221 -0
  44. package/bin/lean/sched.test.mjs +310 -0
  45. package/bin/lean/schedd.mjs +247 -0
  46. package/bin/lean/schedd.test.mjs +180 -0
  47. package/bin/lean/toolchain.mjs +228 -0
  48. package/bin/lean/toolchain.test.mjs +154 -0
  49. package/bin/lean/verify.mjs +20 -9
  50. package/bin/lean/verify.test.mjs +26 -3
  51. package/bin/lean/worktree.mjs +69 -0
  52. package/bin/lean/worktree.test.mjs +141 -0
  53. package/bin/runner.mjs +53 -10
  54. package/bin/stats/github-source.mjs +1 -1
  55. package/bin/stats/index.mjs +14 -2
  56. package/bin/stats/query.mjs +72 -0
  57. package/bin/stats/stats.test.mjs +54 -0
  58. package/commands/audit.md +2 -0
  59. package/commands/autopilot.md +2 -0
  60. package/commands/bootstrap.md +35 -0
  61. package/commands/cleanup.md +7 -22
  62. package/commands/forge-stats.md +2 -1
  63. package/commands/forgedock-init.md +7 -0
  64. package/commands/issue.md +89 -830
  65. package/commands/milestone.md +55 -6
  66. package/commands/orchestrate.md +10 -2
  67. package/commands/upgrade-deps.md +3 -3
  68. package/commands/work-on/build/validate.md +4 -0
  69. package/commands/work-on/close.md +2 -0
  70. package/commands/work-on/decompose.md +24 -6
  71. package/docs/CI.md +3 -3
  72. package/docs/CONFIG.md +177 -2
  73. package/docs/OUTCOME-CODES.md +8 -0
  74. package/docs/TELEMETRY.md +1 -1
  75. package/docs/site/command-reference.md +22 -0
  76. package/docs/site/getting-started.md +2 -0
  77. package/docs/site/start-a-new-project.md +327 -0
  78. package/package.json +1 -1
  79. package/scripts/affected-files-check.sh +91 -0
  80. package/scripts/affected-files-check.test.sh +80 -0
  81. package/scripts/bootstrap-create.mjs +393 -0
  82. package/scripts/bootstrap-validate.mjs +36 -2
  83. package/scripts/calibration.mjs +116 -0
  84. package/scripts/check-devdocs-freshness.sh +9 -4
  85. package/scripts/check-devdocs-freshness.test.sh +11 -1
  86. package/scripts/check-spec-markers.sh +3 -0
  87. package/scripts/check-spec-size.sh +29 -11
  88. package/scripts/check-spec-size.test.sh +27 -4
  89. package/scripts/ci-local/lib.mjs +8 -1
  90. package/scripts/ci-local/parallelism.test.mjs +27 -0
  91. package/scripts/ci-local/run.mjs +3 -3
  92. package/scripts/ci-local/scoping.mjs +82 -0
  93. package/scripts/ci-local.test.sh +1 -1
  94. package/scripts/fixtures/symbol-index/other/sample.rb +3 -0
  95. package/scripts/fixtures/symbol-index/py/pkg/__init__.py +1 -0
  96. package/scripts/fixtures/symbol-index/py/pkg/a.py +5 -0
  97. package/scripts/fixtures/symbol-index/py/pkg/b.py +14 -0
  98. package/scripts/fixtures/symbol-index/py/pkg/c.py +1 -0
  99. package/scripts/fixtures/symbol-index/py/sample.py +41 -0
  100. package/scripts/fixtures/symbol-index/ts/sample.ts +13 -0
  101. package/scripts/fixtures/symbol-move/gen_big.py +201 -0
  102. package/scripts/fixtures/symbol-move-ts/gen_big.mjs +56 -0
  103. package/scripts/forge-root.test.sh +1 -1
  104. package/scripts/hotspots.sh +100 -0
  105. package/scripts/hotspots.test.sh +101 -0
  106. package/scripts/issue-create.sh +197 -0
  107. package/scripts/issue-create.test.sh +166 -0
  108. package/scripts/maintenance-scan.sh +185 -0
  109. package/scripts/maintenance-scan.test.sh +89 -0
  110. package/scripts/milestone-drift.sh +206 -0
  111. package/scripts/milestone-drift.test.sh +120 -0
  112. package/scripts/refactor-verify.sh +368 -0
  113. package/scripts/refactor-verify.test.sh +202 -0
  114. package/scripts/structure-delta.sh +10 -5
  115. package/scripts/structure-delta.test.sh +3 -2
  116. package/scripts/symbol-index.mjs +253 -0
  117. package/scripts/symbol-index.py +354 -0
  118. package/scripts/symbol-index.test.sh +119 -0
  119. package/scripts/symbol-move-ts.test.sh +161 -0
  120. package/scripts/symbol-move.mjs +379 -0
  121. package/scripts/symbol-move.py +383 -0
  122. package/scripts/symbol-move.test.sh +168 -0
  123. package/templates/bootstrap/interview.md +34 -0
  124. package/templates/bootstrap/issue-body.md +42 -0
  125. package/templates/bootstrap/needs-human-issue.md +45 -0
  126. package/templates/bootstrap/stack-profiles.md +34 -0
  127. package/templates/bootstrap/technical-spike.md +75 -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));
@@ -0,0 +1,354 @@
1
+ #!/usr/bin/env python3
2
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
3
+ # SPDX-License-Identifier: AGPL-3.0-or-later
4
+ #
5
+ # symbol-index.py: deterministic Python symbol index + module import-cycle detector for refactor:split (forge#4208).
6
+ # stdlib only (ast). No LLM, no network. Never emits source text, docstrings or literal values.
7
+ #
8
+ # Usage: python3 scripts/symbol-index.py <file.py | dir>...
9
+ # Output (stdout, one JSON object):
10
+ # {"symbols":[{name,kind,start,end,lines,public[,file]}], "edges":[{from,to}],
11
+ # "imports":[{module,names,line[,file]}], "cycles":[[module,...],...]}
12
+ # kind: function|async_function|class|method|const. Methods (and nested classes) are named Class.method.
13
+ # start includes decorators; lines = end - start + 1. public = no leading underscore (method dunders count as
14
+ # public), and listed in a static __all__ when the module defines one.
15
+ # One .py file: names are bare. Otherwise (a directory or several files) each entry carries `file` and edge ends
16
+ # are "<module>:<symbol>"; module names are dotted paths relative to each root (a root that has __init__.py is
17
+ # itself the top package, so root .../pkg yields pkg.a). edges = references from one indexed symbol to another.
18
+ # cycles = strongly connected components (size > 1, or a self-import) of the module-level import graph between
19
+ # indexed modules; function-local and `if TYPE_CHECKING:` imports are ignored. Everything is sorted, so a
20
+ # before/after pair of runs can be diffed for new cycles.
21
+ # Unsupported input (non-.py file, no .py files, a file that does not parse) prints only
22
+ # {"unsupported":"<lang>","reason":"..."} and exits 0: never partial output.
23
+ # Exit: 0 ok or unsupported | 2 usage or missing path.
24
+
25
+ import ast
26
+ import json
27
+ import os
28
+ import sys
29
+
30
+ LANGS = {
31
+ ".ts": "ts", ".tsx": "ts", ".mts": "ts", ".cts": "ts",
32
+ ".js": "js", ".jsx": "js", ".mjs": "js", ".cjs": "js",
33
+ ".go": "go", ".rs": "rust", ".rb": "ruby", ".java": "java", ".kt": "kotlin",
34
+ ".c": "c", ".h": "c", ".cc": "cpp", ".cpp": "cpp", ".hpp": "cpp", ".cs": "csharp",
35
+ ".php": "php", ".swift": "swift", ".sh": "shell", ".bash": "shell",
36
+ }
37
+ SKIP_DIRS = {"__pycache__", "node_modules", "site-packages", "venv", "env"}
38
+
39
+
40
+ class Unsupported(Exception):
41
+ def __init__(self, lang, reason):
42
+ super().__init__(reason)
43
+ self.lang, self.reason = lang, reason
44
+
45
+
46
+ def lang_of(path):
47
+ ext = os.path.splitext(path)[1].lower()
48
+ return LANGS.get(ext, ext[1:] if ext else "unknown")
49
+
50
+
51
+ def is_private(name):
52
+ return name.startswith("_")
53
+
54
+
55
+ def is_dunder(name):
56
+ return len(name) > 4 and name.startswith("__") and name.endswith("__")
57
+
58
+
59
+ def static_all(tree):
60
+ """Names in a statically readable module-level __all__, or None when absent or not static."""
61
+ names, seen = [], False
62
+ for node in tree.body:
63
+ if isinstance(node, ast.Assign) and any(isinstance(t, ast.Name) and t.id == "__all__" for t in node.targets):
64
+ value, seen, names = node.value, True, []
65
+ elif isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name) and node.target.id == "__all__":
66
+ value, seen, names = node.value, True, []
67
+ elif isinstance(node, ast.AugAssign) and isinstance(node.target, ast.Name) and node.target.id == "__all__":
68
+ value = node.value
69
+ else:
70
+ continue
71
+ if not isinstance(value, (ast.List, ast.Tuple)):
72
+ return None
73
+ for elt in value.elts:
74
+ if not (isinstance(elt, ast.Constant) and isinstance(elt.value, str)):
75
+ return None
76
+ names.append(elt.value)
77
+ return set(names) if seen else None
78
+
79
+
80
+ def def_start(node):
81
+ decos = getattr(node, "decorator_list", None) or []
82
+ return min([node.lineno] + [d.lineno for d in decos])
83
+
84
+
85
+ def is_type_checking(test):
86
+ return (isinstance(test, ast.Name) and test.id == "TYPE_CHECKING") or (
87
+ isinstance(test, ast.Attribute) and test.attr == "TYPE_CHECKING")
88
+
89
+
90
+ def module_imports(body):
91
+ """Import statements at module level (descending into if/try/with, never into def/class or TYPE_CHECKING)."""
92
+ out = []
93
+ for node in body:
94
+ if isinstance(node, (ast.Import, ast.ImportFrom)):
95
+ out.append(node)
96
+ elif isinstance(node, ast.If):
97
+ if not is_type_checking(node.test):
98
+ out.extend(module_imports(node.body))
99
+ out.extend(module_imports(node.orelse))
100
+ elif isinstance(node, (ast.Try, getattr(ast, "TryStar", ast.Try))):
101
+ out.extend(module_imports(node.body))
102
+ for h in node.handlers:
103
+ out.extend(module_imports(h.body))
104
+ out.extend(module_imports(node.orelse))
105
+ out.extend(module_imports(node.finalbody))
106
+ elif isinstance(node, (ast.With, ast.AsyncWith)):
107
+ out.extend(module_imports(node.body))
108
+ return out
109
+
110
+
111
+ class Module:
112
+ def __init__(self, path, name, is_pkg):
113
+ self.path, self.name, self.is_pkg = path, name, is_pkg
114
+ self.symbols = [] # dicts without file
115
+ self.nodes = {} # symbol name -> (ast node, owning class name or None)
116
+ self.imports = [] # (ast import node)
117
+ self.tree = None
118
+
119
+ def parse(self):
120
+ try:
121
+ with open(self.path, "rb") as fh:
122
+ self.tree = ast.parse(fh.read(), filename=self.path)
123
+ except SyntaxError as e:
124
+ raise Unsupported("python", "parse-error: %s:%s" % (self.path, e.lineno or 0))
125
+ except (ValueError, UnicodeDecodeError, OSError):
126
+ raise Unsupported("python", "parse-error: %s:0" % self.path)
127
+ exported = static_all(self.tree)
128
+
129
+ def top_public(name):
130
+ return not is_private(name) and (exported is None or name in exported)
131
+
132
+ def add(name, kind, node, public, owner=None):
133
+ start, end = def_start(node), node.end_lineno or node.lineno
134
+ self.symbols.append({"name": name, "kind": kind, "start": start, "end": end,
135
+ "lines": end - start + 1, "public": public})
136
+ self.nodes[name] = (node, owner)
137
+
138
+ def add_class(node, prefix, public):
139
+ name = prefix + node.name
140
+ add(name, "class", node, public)
141
+ for item in node.body:
142
+ if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)):
143
+ mpub = public and (not is_private(item.name) or is_dunder(item.name))
144
+ add(name + "." + item.name, "method", item, mpub, owner=name)
145
+ elif isinstance(item, ast.ClassDef):
146
+ add_class(item, name + ".", public and not is_private(item.name))
147
+
148
+ for node in self.tree.body:
149
+ if isinstance(node, ast.FunctionDef):
150
+ add(node.name, "function", node, top_public(node.name))
151
+ elif isinstance(node, ast.AsyncFunctionDef):
152
+ add(node.name, "async_function", node, top_public(node.name))
153
+ elif isinstance(node, ast.ClassDef):
154
+ add_class(node, "", top_public(node.name))
155
+ elif isinstance(node, (ast.Assign, ast.AnnAssign)):
156
+ targets = node.targets if isinstance(node, ast.Assign) else [node.target]
157
+ for t in targets:
158
+ for n in ([t] if isinstance(t, ast.Name) else getattr(t, "elts", [])):
159
+ if isinstance(n, ast.Name) and n.id not in self.nodes:
160
+ add(n.id, "const", node, top_public(n.id))
161
+ self.imports = module_imports(self.tree.body)
162
+
163
+ def package(self):
164
+ return self.name if self.is_pkg else self.name.rpartition(".")[0]
165
+
166
+ def resolve(self, node):
167
+ """Absolute module named by an ImportFrom (relative levels resolved), or None when it climbs past the root."""
168
+ if node.level == 0:
169
+ return node.module or ""
170
+ parts = self.package().split(".") if self.package() else []
171
+ if node.level - 1 > len(parts):
172
+ return None
173
+ base = parts[:len(parts) - (node.level - 1)]
174
+ if node.module:
175
+ base = base + node.module.split(".")
176
+ return ".".join(base)
177
+
178
+
179
+ def walk_roots(paths):
180
+ """[(path, module name, is_pkg)] for every .py file under the given roots, sorted."""
181
+ found = []
182
+ for root in paths:
183
+ root = os.path.normpath(root)
184
+ if os.path.isfile(root):
185
+ if not root.endswith(".py"):
186
+ raise Unsupported(lang_of(root), "unsupported-language")
187
+ stem = os.path.splitext(os.path.basename(root))[0]
188
+ found.append((root, stem, False))
189
+ continue
190
+ prefix = os.path.basename(os.path.abspath(root)) if os.path.isfile(os.path.join(root, "__init__.py")) else ""
191
+ for dirpath, dirnames, filenames in os.walk(root):
192
+ dirnames[:] = sorted(d for d in dirnames if not d.startswith(".") and d not in SKIP_DIRS)
193
+ for fn in sorted(filenames):
194
+ if not fn.endswith(".py"):
195
+ continue
196
+ path = os.path.join(dirpath, fn)
197
+ rel = os.path.relpath(path, root)[:-3].split(os.sep)
198
+ is_pkg = rel[-1] == "__init__"
199
+ if is_pkg:
200
+ rel = rel[:-1]
201
+ name = ".".join(([prefix] if prefix else []) + rel)
202
+ found.append((path, name, is_pkg))
203
+ if not found:
204
+ raise Unsupported("unknown", "no-python-files")
205
+ return sorted(found)
206
+
207
+
208
+ def tarjan(graph):
209
+ """Strongly connected components, iterative (no recursion limit on large packages)."""
210
+ index, low, on, stack, comps, counter = {}, {}, set(), [], [], [0]
211
+ for start in sorted(graph):
212
+ if start in index:
213
+ continue
214
+ work = [(start, iter(sorted(graph[start])))]
215
+ index[start] = low[start] = counter[0]; counter[0] += 1
216
+ stack.append(start); on.add(start)
217
+ while work:
218
+ v, it = work[-1]
219
+ advanced = False
220
+ for w in it:
221
+ if w not in index:
222
+ index[w] = low[w] = counter[0]; counter[0] += 1
223
+ stack.append(w); on.add(w)
224
+ work.append((w, iter(sorted(graph.get(w, ())))))
225
+ advanced = True
226
+ break
227
+ if w in on:
228
+ low[v] = min(low[v], index[w])
229
+ if advanced:
230
+ continue
231
+ work.pop()
232
+ if work:
233
+ low[work[-1][0]] = min(low[work[-1][0]], low[v])
234
+ if low[v] == index[v]:
235
+ comp = []
236
+ while True:
237
+ w = stack.pop(); on.discard(w); comp.append(w)
238
+ if w == v:
239
+ break
240
+ comps.append(sorted(comp))
241
+ return comps
242
+
243
+
244
+ def build(paths):
245
+ entries = walk_roots(paths)
246
+ multi = not (len(entries) == 1 and os.path.isfile(paths[0]) and len(paths) == 1)
247
+ modules, by_name = [], {}
248
+ for path, name, is_pkg in entries:
249
+ m = Module(path, name, is_pkg)
250
+ m.parse()
251
+ modules.append(m)
252
+ by_name.setdefault(name, m)
253
+
254
+ def ref(m, sym):
255
+ return "%s:%s" % (m.name, sym) if multi else sym
256
+
257
+ symbols, imports, edges, graph = [], [], set(), {m.name: set() for m in modules}
258
+ for m in modules:
259
+ rel = os.path.relpath(m.path)
260
+ for s in m.symbols:
261
+ symbols.append(dict(s, file=rel) if multi else s)
262
+ # bindings: local name -> (module, symbol or None for a module object)
263
+ bindings, first_import = {}, len(imports)
264
+ for node in m.imports:
265
+ if isinstance(node, ast.Import):
266
+ for a in node.names:
267
+ imports.append({"module": a.name, "names": [], "line": node.lineno})
268
+ if a.name in by_name:
269
+ graph[m.name].add(a.name)
270
+ if a.asname:
271
+ bindings[a.asname] = (a.name, None)
272
+ else:
273
+ bindings[a.name.split(".")[0]] = (a.name.split(".")[0], None)
274
+ else:
275
+ target = m.resolve(node)
276
+ shown = target if target is not None else "." * node.level + (node.module or "")
277
+ imports.append({"module": shown, "names": [a.name for a in node.names], "line": node.lineno})
278
+ if target is None:
279
+ continue
280
+ for a in node.names:
281
+ sub = (target + "." + a.name) if target else a.name
282
+ if sub in by_name:
283
+ graph[m.name].add(sub)
284
+ bindings[a.asname or a.name] = (sub, None)
285
+ elif target in by_name:
286
+ graph[m.name].add(target)
287
+ bindings[a.asname or a.name] = (target, a.name)
288
+ local = set(m.nodes)
289
+ for name, (node, owner) in m.nodes.items():
290
+ src = ref(m, name)
291
+ first_arg = None
292
+ if owner and isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.args.args:
293
+ first_arg = node.args.args[0].arg
294
+ body = [node]
295
+ if isinstance(node, ast.ClassDef): # methods and nested classes are their own symbols
296
+ body = node.decorator_list + node.bases + [k.value for k in node.keywords] + [
297
+ b for b in node.body if not isinstance(b, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef))]
298
+ elif isinstance(node, (ast.Assign, ast.AnnAssign)):
299
+ body = [node.value] if node.value is not None else []
300
+ for root in body:
301
+ for n in ast.walk(root):
302
+ to = None
303
+ if isinstance(n, ast.Name) and isinstance(n.ctx, ast.Load):
304
+ if n.id in local:
305
+ to = ref(m, n.id)
306
+ elif n.id in bindings:
307
+ mod, sym = bindings[n.id]
308
+ if sym is not None and sym in by_name[mod].nodes:
309
+ to = "%s:%s" % (mod, sym)
310
+ elif isinstance(n, ast.Attribute) and isinstance(n.value, ast.Name):
311
+ base = n.value.id
312
+ if owner and base == first_arg and (owner + "." + n.attr) in local:
313
+ to = ref(m, owner + "." + n.attr)
314
+ elif base in local and (base + "." + n.attr) in local:
315
+ to = ref(m, base + "." + n.attr)
316
+ elif base in bindings and bindings[base][1] is None:
317
+ mod = bindings[base][0]
318
+ if mod in by_name and n.attr in by_name[mod].nodes:
319
+ to = "%s:%s" % (mod, n.attr)
320
+ if to is not None and to != src:
321
+ edges.add((src, to))
322
+ if multi:
323
+ for imp in imports[first_import:]:
324
+ imp["file"] = rel
325
+
326
+ cycles = sorted(c for c in tarjan(graph) if len(c) > 1 or c[0] in graph[c[0]])
327
+ if multi:
328
+ symbols.sort(key=lambda s: (s["file"], s["start"], s["name"]))
329
+ imports.sort(key=lambda i: (i["file"], i["line"], i["module"]))
330
+ else:
331
+ symbols.sort(key=lambda s: (s["start"], s["name"]))
332
+ imports.sort(key=lambda i: (i["line"], i["module"]))
333
+ return {"symbols": symbols, "edges": [{"from": f, "to": t} for f, t in sorted(edges)],
334
+ "imports": imports, "cycles": cycles}
335
+
336
+
337
+ def main(argv):
338
+ if not argv or argv[0] in ("-h", "--help"):
339
+ sys.stderr.write("usage: symbol-index.py <file.py | dir>...\n")
340
+ return 2
341
+ for p in argv:
342
+ if not os.path.exists(p):
343
+ sys.stderr.write("symbol-index: no such path: %s\n" % p)
344
+ return 2
345
+ try:
346
+ out = build(argv)
347
+ except Unsupported as u:
348
+ out = {"unsupported": u.lang, "reason": u.reason}
349
+ sys.stdout.write(json.dumps(out, sort_keys=True, separators=(",", ":")) + "\n")
350
+ return 0
351
+
352
+
353
+ if __name__ == "__main__":
354
+ sys.exit(main(sys.argv[1:]))