forgedock 1.27.0 → 1.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -1
- package/bin/bootstrap.mjs +55 -0
- package/bin/engine/outcome-codes.mjs +10 -0
- package/bin/engine/telemetry-seam.mjs +37 -8
- package/bin/engine/telemetry-seam.test.mjs +31 -0
- package/bin/engine-cli.mjs +542 -38
- package/bin/engine.mjs +8 -4
- package/bin/forgedock.mjs +75 -9
- package/bin/gc.mjs +580 -0
- package/bin/lean/admission.mjs +33 -3
- package/bin/lean/batch.mjs +22 -4
- package/bin/lean/batch.test.mjs +5 -3
- package/bin/lean/checks.mjs +199 -0
- package/bin/lean/checks.test.mjs +183 -0
- package/bin/lean/conformance.mjs +190 -58
- package/bin/lean/conformance.test.mjs +116 -7
- package/bin/lean/credentials.mjs +29 -0
- package/bin/lean/depcache.mjs +312 -0
- package/bin/lean/depcache.test.mjs +298 -0
- package/bin/lean/drift.mjs +198 -0
- package/bin/lean/drift.test.mjs +223 -0
- package/bin/lean/effects.mjs +85 -8
- package/bin/lean/execution.e2e.test.mjs +81 -0
- package/bin/lean/execution.mjs +431 -0
- package/bin/lean/execution.test.mjs +583 -0
- package/bin/lean/heavy.mjs +289 -0
- package/bin/lean/heavy.test.mjs +207 -0
- package/bin/lean/lean.mjs +572 -139
- package/bin/lean/lean.test.mjs +1193 -31
- package/bin/lean/ledger.mjs +27 -2
- package/bin/lean/ledger.test.mjs +36 -1
- package/bin/lean/limits.mjs +58 -0
- package/bin/lean/limits.test.mjs +150 -0
- package/bin/lean/orchestrate.mjs +312 -19
- package/bin/lean/prompts.mjs +89 -15
- package/bin/lean/refactor.mjs +276 -0
- package/bin/lean/refactor.test.mjs +359 -0
- package/bin/lean/render.mjs +24 -5
- package/bin/lean/review-trigger.mjs +134 -0
- package/bin/lean/review-trigger.test.mjs +104 -0
- package/bin/lean/sandbox-provider.contract.test.mjs +257 -0
- package/bin/lean/sandbox-remote.mjs +142 -0
- package/bin/lean/sched.mjs +221 -0
- package/bin/lean/sched.test.mjs +310 -0
- package/bin/lean/schedd.mjs +247 -0
- package/bin/lean/schedd.test.mjs +180 -0
- package/bin/lean/toolchain.mjs +228 -0
- package/bin/lean/toolchain.test.mjs +154 -0
- package/bin/lean/verify.mjs +20 -9
- package/bin/lean/verify.test.mjs +26 -3
- package/bin/lean/worktree.mjs +69 -0
- package/bin/lean/worktree.test.mjs +141 -0
- package/bin/runner.mjs +53 -10
- package/bin/stats/github-source.mjs +1 -1
- package/bin/stats/index.mjs +14 -2
- package/bin/stats/query.mjs +72 -0
- package/bin/stats/stats.test.mjs +54 -0
- package/commands/audit.md +2 -0
- package/commands/autopilot.md +2 -0
- package/commands/bootstrap.md +35 -0
- package/commands/cleanup.md +7 -22
- package/commands/forge-stats.md +2 -1
- package/commands/forgedock-init.md +7 -0
- package/commands/issue.md +89 -830
- package/commands/milestone.md +55 -6
- package/commands/orchestrate.md +10 -2
- package/commands/upgrade-deps.md +3 -3
- package/commands/work-on/build/validate.md +4 -0
- package/commands/work-on/close.md +2 -0
- package/commands/work-on/decompose.md +24 -6
- package/docs/CI.md +3 -3
- package/docs/CONFIG.md +177 -2
- package/docs/OUTCOME-CODES.md +8 -0
- package/docs/TELEMETRY.md +1 -1
- package/docs/site/command-reference.md +22 -0
- package/docs/site/getting-started.md +2 -0
- package/docs/site/start-a-new-project.md +327 -0
- package/package.json +1 -1
- package/scripts/affected-files-check.sh +91 -0
- package/scripts/affected-files-check.test.sh +80 -0
- package/scripts/bootstrap-create.mjs +393 -0
- package/scripts/bootstrap-validate.mjs +36 -2
- package/scripts/calibration.mjs +116 -0
- package/scripts/check-devdocs-freshness.sh +9 -4
- package/scripts/check-devdocs-freshness.test.sh +11 -1
- package/scripts/check-spec-markers.sh +3 -0
- package/scripts/check-spec-size.sh +29 -11
- package/scripts/check-spec-size.test.sh +27 -4
- package/scripts/ci-local/lib.mjs +8 -1
- package/scripts/ci-local/parallelism.test.mjs +27 -0
- package/scripts/ci-local/run.mjs +3 -3
- package/scripts/ci-local/scoping.mjs +82 -0
- package/scripts/ci-local.test.sh +1 -1
- package/scripts/fixtures/symbol-index/other/sample.rb +3 -0
- package/scripts/fixtures/symbol-index/py/pkg/__init__.py +1 -0
- package/scripts/fixtures/symbol-index/py/pkg/a.py +5 -0
- package/scripts/fixtures/symbol-index/py/pkg/b.py +14 -0
- package/scripts/fixtures/symbol-index/py/pkg/c.py +1 -0
- package/scripts/fixtures/symbol-index/py/sample.py +41 -0
- package/scripts/fixtures/symbol-index/ts/sample.ts +13 -0
- package/scripts/fixtures/symbol-move/gen_big.py +201 -0
- package/scripts/fixtures/symbol-move-ts/gen_big.mjs +56 -0
- package/scripts/forge-root.test.sh +1 -1
- package/scripts/hotspots.sh +100 -0
- package/scripts/hotspots.test.sh +101 -0
- package/scripts/issue-create.sh +197 -0
- package/scripts/issue-create.test.sh +166 -0
- package/scripts/maintenance-scan.sh +185 -0
- package/scripts/maintenance-scan.test.sh +89 -0
- package/scripts/milestone-drift.sh +206 -0
- package/scripts/milestone-drift.test.sh +120 -0
- package/scripts/refactor-verify.sh +368 -0
- package/scripts/refactor-verify.test.sh +202 -0
- package/scripts/structure-delta.sh +10 -5
- package/scripts/structure-delta.test.sh +3 -2
- package/scripts/symbol-index.mjs +253 -0
- package/scripts/symbol-index.py +354 -0
- package/scripts/symbol-index.test.sh +119 -0
- package/scripts/symbol-move-ts.test.sh +161 -0
- package/scripts/symbol-move.mjs +379 -0
- package/scripts/symbol-move.py +383 -0
- package/scripts/symbol-move.test.sh +168 -0
- package/templates/bootstrap/interview.md +34 -0
- package/templates/bootstrap/issue-body.md +42 -0
- package/templates/bootstrap/needs-human-issue.md +45 -0
- package/templates/bootstrap/stack-profiles.md +34 -0
- package/templates/bootstrap/technical-spike.md +75 -0
|
@@ -0,0 +1,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:]))
|