@polycode-projects/seonix 0.1.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/LICENSE +661 -0
- package/README.md +77 -0
- package/bin/cli.mjs +248 -0
- package/package.json +54 -0
- package/roslyn/Program.cs +150 -0
- package/roslyn/RoslynExtract.csproj +13 -0
- package/src/codegraph.mjs +1313 -0
- package/src/config.mjs +28 -0
- package/src/cs_roslyn.mjs +38 -0
- package/src/cs_treesitter.mjs +140 -0
- package/src/extract.mjs +624 -0
- package/src/extract_ast.py +366 -0
- package/src/extract_lang.mjs +61 -0
- package/src/jsts_tsc.mjs +211 -0
- package/src/server.mjs +389 -0
- package/src/source.mjs +35 -0
- package/src/viz.mjs +218 -0
- package/src/walk.mjs +39 -0
package/src/extract.mjs
ADDED
|
@@ -0,0 +1,624 @@
|
|
|
1
|
+
// Deterministic, offline graph extraction — the heart of the seon-tool index.
|
|
2
|
+
// Runs the stdlib-`ast` parser (extract_ast.py) over the repo and `git log` for
|
|
3
|
+
// history, then assembles the typed `entities` payload codegraph.mjs consumes.
|
|
4
|
+
// ZERO model calls: CPU-bound static parsing + git only.
|
|
5
|
+
//
|
|
6
|
+
// Typed edges produced (all provenance-stamped). Prop tokens follow the SEON
|
|
7
|
+
// vocabulary (se-on.org, FAMIX-derived) where a term exists, with an `mgx:`
|
|
8
|
+
// extension namespace for the Python/framework reality SEON predates:
|
|
9
|
+
// seon:usesComplexType Module → Module (internal import targets, via registry)
|
|
10
|
+
// seon:declaresMethod Module → CodeEntity (top-level functions/classes/methods/attrs)
|
|
11
|
+
// seon:invokesMethod Module → Module (coarse + import-backed: a called name defined
|
|
12
|
+
// in exactly one imported internal module)
|
|
13
|
+
// mgx:testsCoverage Module → Module (a test module → the internal modules it imports)
|
|
14
|
+
// seon:history Commit → Module (from git log --name-only)
|
|
15
|
+
// mgx:touchesSymbol Commit → CodeEntity (commit changed-line-range ∩ symbol span)
|
|
16
|
+
// mgx:callsSymbol Function/Method → Function/Class (symbol-granular, unambiguous)
|
|
17
|
+
// seon:containsCodeEntity Class → Method/Attribute (class membership)
|
|
18
|
+
// mgx:subclassOf Class → Class (inheritance; internal base resolved, else ext:)
|
|
19
|
+
|
|
20
|
+
import { spawn } from "node:child_process";
|
|
21
|
+
import { writeFile, mkdir } from "node:fs/promises";
|
|
22
|
+
import { dirname, join } from "node:path";
|
|
23
|
+
import { fileURLToPath } from "node:url";
|
|
24
|
+
import * as codegraph from "./codegraph.mjs"; // optional renderToolsCatalog (other agent owns this file)
|
|
25
|
+
import { ingestRepo, LANG_EXTS } from "./extract_lang.mjs";
|
|
26
|
+
|
|
27
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
28
|
+
const AST_SCRIPT = join(here, "extract_ast.py");
|
|
29
|
+
const CLI_SCRIPT = join(here, "..", "bin", "cli.mjs");
|
|
30
|
+
|
|
31
|
+
// Every file extension the index covers: Python (extract_ast.py) + the languages
|
|
32
|
+
// the multi-language front-end (extract_lang.mjs) owns. Gates the git-log file
|
|
33
|
+
// filters so history is collected for ALL indexed languages, not just `.py`.
|
|
34
|
+
const INDEXED_EXTS = new Set([".py", ...LANG_EXTS]);
|
|
35
|
+
const isIndexedFile = (f) => {
|
|
36
|
+
const dot = f.lastIndexOf(".");
|
|
37
|
+
return dot >= 0 && INDEXED_EXTS.has(f.slice(dot).toLowerCase());
|
|
38
|
+
};
|
|
39
|
+
const GIT_LOG_COMMITS = 300; // module-level history depth (cheap; full depth)
|
|
40
|
+
// Symbol-level line-range pass depth. Tuned on the Django corpus (2819 modules, 300-commit
|
|
41
|
+
// window): the per-commit `git log -p --unified=0` hunk pass costs ~0.8% of base at depth 80
|
|
42
|
+
// and ~2% at the full 300 — base is dominated by the Python AST parse, so this stays well
|
|
43
|
+
// under the ≤10% budget. Capped at 120 because line numbers in older commits drift from the
|
|
44
|
+
// CURRENT symbol spans we intersect against, so recent history is also the most accurate.
|
|
45
|
+
const HISTORY_SYMBOL_DEPTH = 120;
|
|
46
|
+
|
|
47
|
+
/** Module-level git history depth (cheap name-only pass). Env: SEONIX_GIT_DEPTH. */
|
|
48
|
+
function gitDepth(env = process.env) {
|
|
49
|
+
const n = Number(env.SEONIX_GIT_DEPTH);
|
|
50
|
+
return Number.isFinite(n) && n > 0 ? Math.floor(n) : GIT_LOG_COMMITS;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Symbol-level history depth — the per-commit hunk pass is the costly one, so this is
|
|
54
|
+
* capped well below gitDepth to keep the added index time ≤10%. Env:
|
|
55
|
+
* SEONIX_HISTORY_SYMBOL_DEPTH (0 disables the symbol-history pass entirely). */
|
|
56
|
+
function historySymbolDepth(env = process.env) {
|
|
57
|
+
const raw = env.SEONIX_HISTORY_SYMBOL_DEPTH;
|
|
58
|
+
if (raw === undefined || raw === "") return HISTORY_SYMBOL_DEPTH;
|
|
59
|
+
const n = Number(raw);
|
|
60
|
+
return Number.isFinite(n) && n >= 0 ? Math.floor(n) : HISTORY_SYMBOL_DEPTH;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** spawn, collect stdout; resolve {code, stdout, stderr} (never reject). */
|
|
64
|
+
function exec(cmd, args, { cwd, maxBuffer = 512 * 1024 * 1024 } = {}) {
|
|
65
|
+
return new Promise((resolve) => {
|
|
66
|
+
const child = spawn(cmd, args, { cwd });
|
|
67
|
+
let stdout = "";
|
|
68
|
+
let stderr = "";
|
|
69
|
+
let size = 0;
|
|
70
|
+
child.stdout?.on("data", (d) => { size += d.length; if (size <= maxBuffer) stdout += d; });
|
|
71
|
+
child.stderr?.on("data", (d) => (stderr += d));
|
|
72
|
+
child.on("close", (code) => resolve({ code: code ?? -1, stdout, stderr }));
|
|
73
|
+
child.on("error", (err) => resolve({ code: -1, stdout, stderr: stderr + String(err) }));
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const isTestPath = (p) =>
|
|
78
|
+
p.startsWith("tests/") || /(^|\/)tests?\//.test(p) || /(^|\/)test_[^/]*\.py$/.test(p) || /\.tests(\.|$)/.test(p);
|
|
79
|
+
|
|
80
|
+
const lastIdent = (name) => {
|
|
81
|
+
const m = String(name).match(/([A-Za-z_][A-Za-z0-9_]*)\s*$/);
|
|
82
|
+
return m ? m[1] : null;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/** Run the Python ast extractor → [{path, dotted, imports, defines, calls}]. */
|
|
86
|
+
async function runAst(repoPath, python) {
|
|
87
|
+
const { code, stdout, stderr } = await exec(python, [AST_SCRIPT, repoPath]);
|
|
88
|
+
if (code !== 0) throw new Error(`extract_ast.py failed (${python}, exit ${code}): ${stderr.trim().slice(-400)}`);
|
|
89
|
+
let parsed;
|
|
90
|
+
try { parsed = JSON.parse(stdout); }
|
|
91
|
+
catch { throw new Error(`extract_ast.py produced non-JSON (is "${python}" a Python 3.9+ interpreter?)`); }
|
|
92
|
+
return Array.isArray(parsed?.modules) ? parsed.modules : [];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** git log → [{sha, author, date, subject, files[]}] over the last N commits
|
|
96
|
+
* (best-effort; [] if no repo). Header record fields are \x1e-separated; commits
|
|
97
|
+
* are \x1f-separated; the subject (%s) is single-line so it never collides with
|
|
98
|
+
* the per-file lines that follow. */
|
|
99
|
+
async function runGitLog(repoPath, depth = gitDepth()) {
|
|
100
|
+
const { code, stdout } = await exec("git",
|
|
101
|
+
["log", `-n`, String(depth), "--no-renames", "--name-only",
|
|
102
|
+
"--pretty=format:%x1f%H%x1e%an%x1e%aI%x1e%s", "--", "."],
|
|
103
|
+
{ cwd: repoPath });
|
|
104
|
+
if (code !== 0) return [];
|
|
105
|
+
const out = [];
|
|
106
|
+
for (const chunk of stdout.split("\x1f")) {
|
|
107
|
+
const nl = chunk.indexOf("\n");
|
|
108
|
+
const header = (nl === -1 ? chunk : chunk.slice(0, nl)).trim();
|
|
109
|
+
if (!header) continue;
|
|
110
|
+
const [sha, author = "", date = "", subject = ""] = header.split("\x1e");
|
|
111
|
+
if (!sha) continue;
|
|
112
|
+
const files = (nl === -1 ? "" : chunk.slice(nl + 1))
|
|
113
|
+
.split("\n").map((l) => l.trim()).filter(isIndexedFile);
|
|
114
|
+
out.push({ sha: sha.trim(), author, date, subject, files });
|
|
115
|
+
}
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** git log -p --unified=0 → [{sha, ranges: {path: [[start,end], …]}}] for the
|
|
120
|
+
* symbol-granular history pass. Parses the NEW-side hunk header (`+c,d`) into the
|
|
121
|
+
* changed line range; extract.mjs intersects those with the (current) symbol spans.
|
|
122
|
+
* This is the costly history pass, so it runs at the budgeted (capped) depth.
|
|
123
|
+
* Best-effort: [] on any git failure or depth 0. */
|
|
124
|
+
async function runGitLogHunks(repoPath, depth = historySymbolDepth()) {
|
|
125
|
+
if (!depth) return [];
|
|
126
|
+
const { code, stdout } = await exec("git",
|
|
127
|
+
["log", `-n`, String(depth), "--no-renames", "--no-color", "--unified=0",
|
|
128
|
+
"--pretty=format:%x1f%H", "--", "."],
|
|
129
|
+
{ cwd: repoPath });
|
|
130
|
+
if (code !== 0) return [];
|
|
131
|
+
const out = [];
|
|
132
|
+
let cur = null;
|
|
133
|
+
let file = null;
|
|
134
|
+
for (const line of stdout.split("\n")) {
|
|
135
|
+
if (line.startsWith("\x1f")) {
|
|
136
|
+
cur = { sha: line.slice(1).trim(), ranges: {} };
|
|
137
|
+
if (cur.sha) out.push(cur); else cur = null;
|
|
138
|
+
file = null;
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
if (!cur) continue;
|
|
142
|
+
if (line.startsWith("+++ ")) {
|
|
143
|
+
// "+++ b/path" (or "+++ /dev/null" for a deletion → skip)
|
|
144
|
+
const m = line.match(/^\+\+\+ b\/(.+?)\s*$/);
|
|
145
|
+
file = m && isIndexedFile(m[1]) ? m[1] : null;
|
|
146
|
+
if (file && !cur.ranges[file]) cur.ranges[file] = [];
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (file && line.startsWith("@@")) {
|
|
150
|
+
const m = line.match(/@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/);
|
|
151
|
+
if (!m) continue;
|
|
152
|
+
const start = Number(m[1]);
|
|
153
|
+
const count = m[2] === undefined ? 1 : Number(m[2]);
|
|
154
|
+
cur.ranges[file].push(count > 0 ? [start, start + count - 1] : [start, start]);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return out;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Build the `entities` payload from the parsed modules + git history.
|
|
161
|
+
* `symbolHistory` (optional) is the runGitLogHunks() output — per-commit changed
|
|
162
|
+
* line ranges, intersected with symbol spans to emit mgx:touchesSymbol edges. */
|
|
163
|
+
export function buildEntities(modules, commits, { generatedAt = "", symbolHistory = [] } = {}) {
|
|
164
|
+
const modById = new Map(); // path -> module record
|
|
165
|
+
const dottedToPath = new Map(); // dotted module name -> path
|
|
166
|
+
const nameToPaths = new Map(); // top-level symbol name -> Set<path> (for call resolution)
|
|
167
|
+
const nameToSymbolIds = new Map(); // top-level symbol name -> Set<fnId> (for symbol-granular calls)
|
|
168
|
+
|
|
169
|
+
const modId = (p) => `mod:${p}`;
|
|
170
|
+
const fnId = (p, name) => `fn:${p}#${name}`;
|
|
171
|
+
|
|
172
|
+
for (const m of modules) {
|
|
173
|
+
modById.set(m.path, m);
|
|
174
|
+
if (m.dotted) dottedToPath.set(m.dotted, m.path);
|
|
175
|
+
}
|
|
176
|
+
// Registry of top-level functions/classes by simple name — used to resolve coarse
|
|
177
|
+
// call targets AND inheritance bases to a single internal module (else dropped).
|
|
178
|
+
const classToPaths = new Map(); // class name -> Set<path> (for base resolution)
|
|
179
|
+
for (const m of modules) {
|
|
180
|
+
for (const d of m.defines || []) {
|
|
181
|
+
if (d.kind === "method" || d.kind === "attribute" || d.kind === "global") continue; // not standalone call targets
|
|
182
|
+
if (!nameToPaths.has(d.name)) nameToPaths.set(d.name, new Set());
|
|
183
|
+
nameToPaths.get(d.name).add(m.path);
|
|
184
|
+
if (!nameToSymbolIds.has(d.name)) nameToSymbolIds.set(d.name, new Set());
|
|
185
|
+
nameToSymbolIds.get(d.name).add(fnId(m.path, d.name));
|
|
186
|
+
if (d.kind === "class") {
|
|
187
|
+
if (!classToPaths.has(d.name)) classToPaths.set(d.name, new Set());
|
|
188
|
+
classToPaths.get(d.name).add(m.path);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// resolve a module's import candidates to internal module paths
|
|
194
|
+
const internalImports = (m) => {
|
|
195
|
+
const set = new Set();
|
|
196
|
+
for (const cand of m.imports || []) {
|
|
197
|
+
let path = dottedToPath.get(cand);
|
|
198
|
+
if (!path) {
|
|
199
|
+
// `from a.b import c` → cand "a.b.c" may be a symbol; fall back to the package "a.b".
|
|
200
|
+
const parent = cand.includes(".") ? cand.slice(0, cand.lastIndexOf(".")) : "";
|
|
201
|
+
path = parent && dottedToPath.get(parent);
|
|
202
|
+
}
|
|
203
|
+
if (path && path !== m.path) set.add(path);
|
|
204
|
+
}
|
|
205
|
+
return set;
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
const importEdges = [];
|
|
209
|
+
const definesEdges = [];
|
|
210
|
+
const callEdges = [];
|
|
211
|
+
const testEdges = [];
|
|
212
|
+
const containsEdges = [];
|
|
213
|
+
const inheritsEdges = [];
|
|
214
|
+
const callSymbolEdges = [];
|
|
215
|
+
const fnIndividuals = [];
|
|
216
|
+
const symbolSpansByPath = new Map(); // path -> [{id, label, start, end}] (for touchesSymbol)
|
|
217
|
+
const seenFn = new Set();
|
|
218
|
+
const seenContains = new Set();
|
|
219
|
+
const seenInherits = new Set();
|
|
220
|
+
const seenCallSymbol = new Set();
|
|
221
|
+
|
|
222
|
+
const CLASS_OF = { class: "Class", method: "Method", attribute: "Attribute", function: "Function", global: "GlobalVariable" };
|
|
223
|
+
const shortName = (name) => (name.includes(".") ? name.slice(name.lastIndexOf(".") + 1) : name);
|
|
224
|
+
const ownerName = (name) => name.slice(0, name.lastIndexOf("."));
|
|
225
|
+
|
|
226
|
+
for (const m of modules) {
|
|
227
|
+
const imports = internalImports(m);
|
|
228
|
+
for (const target of imports) {
|
|
229
|
+
const edge = { subject: modId(m.path), object: modId(target), subjectLabel: m.path, objectLabel: target };
|
|
230
|
+
(isTestPath(m.path) ? testEdges : importEdges).push(edge);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
for (const d of m.defines || []) {
|
|
234
|
+
const oid = fnId(m.path, d.name);
|
|
235
|
+
definesEdges.push({ subject: modId(m.path), object: oid, subjectLabel: m.path, objectLabel: d.name });
|
|
236
|
+
|
|
237
|
+
if (!seenFn.has(oid)) {
|
|
238
|
+
seenFn.add(oid);
|
|
239
|
+
const startLn = Number(d.lineno) || 0;
|
|
240
|
+
const endLn = Number(d.end_lineno) > startLn ? Number(d.end_lineno) : startLn;
|
|
241
|
+
const span = endLn > startLn ? `${startLn}-${endLn}` : `${startLn}`;
|
|
242
|
+
if (startLn) {
|
|
243
|
+
if (!symbolSpansByPath.has(m.path)) symbolSpansByPath.set(m.path, []);
|
|
244
|
+
symbolSpansByPath.get(m.path).push({ id: oid, label: d.name, start: startLn, end: endLn });
|
|
245
|
+
}
|
|
246
|
+
const attrs = [{ prop: "seon:startsAt", key: "site", value: `${m.path}:${span}` }];
|
|
247
|
+
const decs = (d.decorators || []).filter(Boolean);
|
|
248
|
+
if (decs.length) attrs.push({ prop: "mgx:decorator", key: "decorators", value: decs.join(", ") });
|
|
249
|
+
if (d.kind === "global" && d.value) attrs.push({ prop: "mgx:value", key: "value", value: d.value });
|
|
250
|
+
// mechanical enrichments (deterministic ast facts; emitted only when present
|
|
251
|
+
// so the graph stays lean) — surfaced via seon_signature, NOT the lean bundle.
|
|
252
|
+
const list = (v) => (Array.isArray(v) ? v.join(", ") : String(v ?? ""));
|
|
253
|
+
if (d.params) attrs.push({ prop: "seon:hasParameter", key: "params", value: String(d.params) });
|
|
254
|
+
if (d.returns) attrs.push({ prop: "seon:hasReturnType", key: "returns", value: String(d.returns) });
|
|
255
|
+
if (d.raises?.length) attrs.push({ prop: "seon:throwsException", key: "raises", value: list(d.raises) });
|
|
256
|
+
if (d.catches?.length) attrs.push({ prop: "seon:catchesException", key: "catches", value: list(d.catches) });
|
|
257
|
+
if (d.self_fields?.length) attrs.push({ prop: "seon:accessesField", key: "self_fields", value: list(d.self_fields) });
|
|
258
|
+
if (d.is_static) attrs.push({ prop: "seon:isStatic", key: "isStatic", value: "true" });
|
|
259
|
+
if (d.is_abstract) attrs.push({ prop: "seon:isAbstract", key: "isAbstract", value: "true" });
|
|
260
|
+
if (d.is_constant) attrs.push({ prop: "seon:isConstant", key: "isConstant", value: "true" });
|
|
261
|
+
if (d.visibility) attrs.push({ prop: "seon:hasAccessModifier", key: "visibility", value: String(d.visibility) });
|
|
262
|
+
if (d.doc) attrs.push({ prop: "seon:hasDoc", key: "doc", value: String(d.doc) });
|
|
263
|
+
fnIndividuals.push({
|
|
264
|
+
id: oid, label: d.name, class: CLASS_OF[d.kind] || "Function",
|
|
265
|
+
derived_from: [], mentions: [], attributes: attrs,
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// class membership: Class → Method/Attribute (the new info; module→symbol is `defines`)
|
|
270
|
+
if (d.kind === "method" || d.kind === "attribute") {
|
|
271
|
+
const owner = ownerName(d.name);
|
|
272
|
+
if (owner) {
|
|
273
|
+
const ownerId = fnId(m.path, owner);
|
|
274
|
+
const ckey = `${ownerId}>${oid}`;
|
|
275
|
+
if (!seenContains.has(ckey)) {
|
|
276
|
+
seenContains.add(ckey);
|
|
277
|
+
containsEdges.push({ subject: ownerId, object: oid, subjectLabel: owner, objectLabel: shortName(d.name) });
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// inheritance: Class → base. Resolve to an internal Class id only when the base
|
|
283
|
+
// name is defined in exactly ONE internal module AND that module is imported here
|
|
284
|
+
// (mirrors the coarse-call discipline — avoids linking `argparse.Action` to an
|
|
285
|
+
// unrelated internal `Action`). Otherwise keep it external as ext:<base>, honest.
|
|
286
|
+
if (d.kind === "class") {
|
|
287
|
+
for (const base of d.bases || []) {
|
|
288
|
+
const ident = lastIdent(base);
|
|
289
|
+
if (!ident) continue;
|
|
290
|
+
const defs = classToPaths.get(ident);
|
|
291
|
+
let object = `ext:${ident}`;
|
|
292
|
+
if (defs && defs.has(m.path)) {
|
|
293
|
+
object = fnId(m.path, ident); // same-module base wins (local name scoping), even if the name is globally ambiguous
|
|
294
|
+
} else if (defs && defs.size === 1) {
|
|
295
|
+
const targetPath = [...defs][0];
|
|
296
|
+
if (imports.has(targetPath)) object = fnId(targetPath, ident);
|
|
297
|
+
}
|
|
298
|
+
const ikey = `${oid}>${object}`;
|
|
299
|
+
if (seenInherits.has(ikey) || object === oid) continue;
|
|
300
|
+
seenInherits.add(ikey);
|
|
301
|
+
inheritsEdges.push({ subject: oid, object, subjectLabel: d.name, objectLabel: base });
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// symbol-granular calls: caller fn/method → callee fn/class, resolved ONLY when the
|
|
306
|
+
// callee simple name has exactly ONE in-repo definition (same unique-name discipline
|
|
307
|
+
// as the module-coarse calls). Ambiguous / receiver-typed / external names are dropped
|
|
308
|
+
// (honest Group-A). Reuses the per-function call names already parsed by extract_ast.py.
|
|
309
|
+
if ((d.kind === "function" || d.kind === "method") && d.calls?.length) {
|
|
310
|
+
for (const callName of d.calls) {
|
|
311
|
+
const ident = lastIdent(callName);
|
|
312
|
+
if (!ident) continue;
|
|
313
|
+
const ids = nameToSymbolIds.get(ident);
|
|
314
|
+
if (!ids || ids.size !== 1) continue; // ambiguous or external → drop
|
|
315
|
+
const callee = [...ids][0];
|
|
316
|
+
if (callee === oid) continue; // self-recursion not an edge
|
|
317
|
+
const ckey = `${oid}>${callee}`;
|
|
318
|
+
if (seenCallSymbol.has(ckey)) continue;
|
|
319
|
+
seenCallSymbol.add(ckey);
|
|
320
|
+
callSymbolEdges.push({ subject: oid, object: callee, subjectLabel: d.name, objectLabel: ident });
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// coarse, import-backed calls: a callee name defined in exactly one imported module.
|
|
326
|
+
if (!isTestPath(m.path)) {
|
|
327
|
+
const seen = new Set();
|
|
328
|
+
for (const callName of m.calls || []) {
|
|
329
|
+
const ident = lastIdent(callName);
|
|
330
|
+
if (!ident) continue;
|
|
331
|
+
const defs = nameToPaths.get(ident);
|
|
332
|
+
if (!defs || defs.size !== 1) continue; // ambiguous → drop (honest)
|
|
333
|
+
const target = [...defs][0];
|
|
334
|
+
if (target === m.path || !imports.has(target) || seen.has(target)) continue;
|
|
335
|
+
seen.add(target);
|
|
336
|
+
callEdges.push({ subject: modId(m.path), object: modId(target), subjectLabel: m.path, objectLabel: target });
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// git history → touches edges + per-module commit provenance + commit metadata
|
|
342
|
+
const touchEdges = [];
|
|
343
|
+
const touchedBy = new Map(); // path -> [git:<sha>]
|
|
344
|
+
const commitIndividuals = [];
|
|
345
|
+
const commitIds = new Set();
|
|
346
|
+
const MSG_CAP = 120;
|
|
347
|
+
const commitInd = (sha, short, c = null) => {
|
|
348
|
+
const attrs = [];
|
|
349
|
+
if (c?.author) attrs.push({ prop: "mgx:commitAuthor", key: "author", value: String(c.author) });
|
|
350
|
+
if (c?.date) attrs.push({ prop: "mgx:commitDate", key: "date", value: String(c.date) });
|
|
351
|
+
if (c?.subject) attrs.push({ prop: "mgx:commitMessage", key: "message", value: String(c.subject).slice(0, MSG_CAP) });
|
|
352
|
+
return { id: `commit:${sha}`, label: short, class: "Commit", derived_from: [], mentions: [], attributes: attrs };
|
|
353
|
+
};
|
|
354
|
+
for (const c of commits) {
|
|
355
|
+
const short = c.sha.slice(0, 12);
|
|
356
|
+
let touchedAny = false;
|
|
357
|
+
for (const f of c.files) {
|
|
358
|
+
if (!modById.has(f)) continue;
|
|
359
|
+
touchEdges.push({ subject: `commit:${c.sha}`, object: modId(f), subjectLabel: short, objectLabel: f });
|
|
360
|
+
if (!touchedBy.has(f)) touchedBy.set(f, []);
|
|
361
|
+
touchedBy.get(f).push(`git:${short}`);
|
|
362
|
+
touchedAny = true;
|
|
363
|
+
}
|
|
364
|
+
if (touchedAny) {
|
|
365
|
+
commitIds.add(c.sha);
|
|
366
|
+
commitIndividuals.push(commitInd(c.sha, short, c));
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// symbol-granular history: intersect each commit's changed line ranges with the
|
|
371
|
+
// (current) symbol spans in that file → mgx:touchesSymbol (Commit → CodeEntity).
|
|
372
|
+
const touchSymbolEdges = [];
|
|
373
|
+
const seenSymTouch = new Set();
|
|
374
|
+
for (const c of symbolHistory) {
|
|
375
|
+
const short = c.sha.slice(0, 12);
|
|
376
|
+
for (const [path, ranges] of Object.entries(c.ranges || {})) {
|
|
377
|
+
const syms = symbolSpansByPath.get(path);
|
|
378
|
+
if (!syms || !ranges.length) continue;
|
|
379
|
+
for (const s of syms) {
|
|
380
|
+
if (!ranges.some(([a, b]) => a <= s.end && b >= s.start)) continue;
|
|
381
|
+
const key = `${c.sha}>${s.id}`;
|
|
382
|
+
if (seenSymTouch.has(key)) continue;
|
|
383
|
+
seenSymTouch.add(key);
|
|
384
|
+
touchSymbolEdges.push({ subject: `commit:${c.sha}`, object: s.id, subjectLabel: short, objectLabel: s.label });
|
|
385
|
+
// make sure the commit individual exists (symbol depth may differ from module depth)
|
|
386
|
+
if (!commitIds.has(c.sha)) { commitIds.add(c.sha); commitIndividuals.push(commitInd(c.sha, short, null)); }
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// change-coupling: modules co-changed in the same commit (git co-occurrence) — the
|
|
392
|
+
// "what usually changes together" signal for an editing agent. Undirected, thresholded,
|
|
393
|
+
// capped per node; mega-commits skipped (noise). Each edge carries its co-change count.
|
|
394
|
+
const COCHANGE_MIN = 2; // co-occur in ≥ N commits
|
|
395
|
+
const COCHANGE_MAX_COMMIT = 50; // skip sweeping refactors (O(n²) noise)
|
|
396
|
+
const COCHANGE_PER_NODE = 12; // cap neighbours per module
|
|
397
|
+
const pairCount = new Map(); // "ab" (a<b lexical) -> count
|
|
398
|
+
for (const c of commits) {
|
|
399
|
+
const mods = [...new Set((c.files || []).filter((f) => modById.has(f)))];
|
|
400
|
+
if (mods.length < 2 || mods.length > COCHANGE_MAX_COMMIT) continue;
|
|
401
|
+
for (let i = 0; i < mods.length; i += 1) {
|
|
402
|
+
for (let j = i + 1; j < mods.length; j += 1) {
|
|
403
|
+
const [a, b] = mods[i] < mods[j] ? [mods[i], mods[j]] : [mods[j], mods[i]];
|
|
404
|
+
const key = `${a}${b}`;
|
|
405
|
+
pairCount.set(key, (pairCount.get(key) || 0) + 1);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
const cochangeEdges = [];
|
|
410
|
+
const cochangePerNode = new Map();
|
|
411
|
+
for (const [key, n] of [...pairCount.entries()].filter(([, c]) => c >= COCHANGE_MIN).sort((x, y) => y[1] - x[1])) {
|
|
412
|
+
const [a, b] = key.split("");
|
|
413
|
+
if ((cochangePerNode.get(a) || 0) >= COCHANGE_PER_NODE || (cochangePerNode.get(b) || 0) >= COCHANGE_PER_NODE) continue;
|
|
414
|
+
cochangePerNode.set(a, (cochangePerNode.get(a) || 0) + 1);
|
|
415
|
+
cochangePerNode.set(b, (cochangePerNode.get(b) || 0) + 1);
|
|
416
|
+
cochangeEdges.push({ subject: modId(a), object: modId(b), subjectLabel: a, objectLabel: b, weight: n });
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// re-exports / public API: a module's literal __all__ entries, resolved to the symbol
|
|
420
|
+
// they expose — either defined locally, or re-exported from an imported internal module.
|
|
421
|
+
// Answers "where is X importable from" and makes __init__ re-export hubs explicit.
|
|
422
|
+
const reExportEdges = [];
|
|
423
|
+
const seenReExport = new Set();
|
|
424
|
+
for (const m of modules) {
|
|
425
|
+
if (!m.exports || !m.exports.length) continue;
|
|
426
|
+
const imports = internalImports(m);
|
|
427
|
+
for (const name of m.exports) {
|
|
428
|
+
let object = null;
|
|
429
|
+
if (seenFn.has(fnId(m.path, name))) {
|
|
430
|
+
object = fnId(m.path, name); // exported a locally-defined symbol
|
|
431
|
+
} else {
|
|
432
|
+
const defs = nameToPaths.get(name);
|
|
433
|
+
if (defs && defs.size === 1) {
|
|
434
|
+
const target = [...defs][0];
|
|
435
|
+
if (imports.has(target)) object = fnId(target, name); // true re-export from an imported module
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
if (!object) continue;
|
|
439
|
+
const key = `${m.path}>${object}`;
|
|
440
|
+
if (seenReExport.has(key)) continue;
|
|
441
|
+
seenReExport.add(key);
|
|
442
|
+
reExportEdges.push({ subject: modId(m.path), object, subjectLabel: m.path, objectLabel: name });
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
const moduleIndividuals = modules.map((m) => ({
|
|
447
|
+
id: modId(m.path), label: m.path, class: "Module",
|
|
448
|
+
derived_from: touchedBy.get(m.path) || [], mentions: [],
|
|
449
|
+
attributes: [
|
|
450
|
+
{ prop: "mgx:dotted", key: "dotted", value: m.dotted || "" },
|
|
451
|
+
// Literal __all__ membership (the public-API surface a new sibling must JOIN to be
|
|
452
|
+
// importable). Stored even when entries don't resolve to a symbol, so seon_context
|
|
453
|
+
// can always tell the agent "this module has an __all__ — add your symbol".
|
|
454
|
+
...(m.exports?.length ? [{ prop: "mgx:exportsAll", key: "all", value: m.exports.join(", ") }] : []),
|
|
455
|
+
],
|
|
456
|
+
}));
|
|
457
|
+
|
|
458
|
+
const rel = (predicate, prop, edges) => ({ predicate, prop, count: edges.length, examples: edges });
|
|
459
|
+
const countClass = (c) => fnIndividuals.filter((i) => i.class === c).length;
|
|
460
|
+
const sampleClass = (c) => fnIndividuals.filter((i) => i.class === c).slice(0, 3).map((i) => i.label);
|
|
461
|
+
|
|
462
|
+
return {
|
|
463
|
+
generated_at: generatedAt,
|
|
464
|
+
// SEON (se-on.org, FAMIX-derived) vocabulary + our `mgx:` extension, documented
|
|
465
|
+
// for readers; the graph is JSON-label-only (no RDF store — see PLAN_SEON_RDF.md).
|
|
466
|
+
prefixes: {
|
|
467
|
+
seon: "http://se-on.org/ontologies/seon.owl#",
|
|
468
|
+
mgx: "urn:seonix:mgx#",
|
|
469
|
+
rdfs: "http://www.w3.org/2000/01/rdf-schema#",
|
|
470
|
+
},
|
|
471
|
+
vocabulary: [
|
|
472
|
+
{ prop: "mgx:importsNamespace", predicate: "imports", note: "module→module; SEON usesComplexType is type→type, so owned (cf. main:dependsOn)" },
|
|
473
|
+
{ prop: "mgx:callsCoarse", predicate: "calls", note: "module→module, import-backed; NOT SEON's method→method invokesMethod" },
|
|
474
|
+
{ prop: "mgx:callsSymbol", predicate: "callsSymbol", note: "caller fn/method→callee fn/class; symbol-granular, unique-name-resolved (Group A); cf. seon:invokesMethod" },
|
|
475
|
+
{ prop: "seon:declaresMethod", predicate: "defines" },
|
|
476
|
+
{ prop: "seon:containsCodeEntity", predicate: "contains" },
|
|
477
|
+
{ prop: "mgx:touchedByCommit", predicate: "touches", note: "owned; seon:history is not a real SEON property (cf. history:isCommittedIn)" },
|
|
478
|
+
{ prop: "mgx:touchesSymbol", predicate: "touchesSymbol", note: "Commit→CodeEntity; changed-line-range ∩ symbol span; owned (cf. seon-hist)" },
|
|
479
|
+
{ prop: "mgx:commitAuthor", note: "commit author name (%an)" },
|
|
480
|
+
{ prop: "mgx:commitDate", note: "commit author date, ISO-8601 (%aI)" },
|
|
481
|
+
{ prop: "mgx:commitMessage", note: "commit subject line (%s), capped to 120 chars" },
|
|
482
|
+
{ prop: "mgx:testsCoverage", predicate: "tests", note: "no SEON term — our extension" },
|
|
483
|
+
{ prop: "seon:hasSuperType", predicate: "inherits" },
|
|
484
|
+
{ prop: "mgx:changeCoupledWith", predicate: "cochange", note: "git co-change; owned (no SEON term; cf. domain-spanning change-couplings.owl)" },
|
|
485
|
+
{ prop: "mgx:reExports", predicate: "reexports", note: "public API / re-export surface (__all__); owned, no SEON term" },
|
|
486
|
+
{ prop: "mgx:exportsAll", note: "literal __all__ membership list on a module (the public surface a new sibling must join)" },
|
|
487
|
+
{ prop: "mgx:decorator", note: "Python/framework decorators — our extension" },
|
|
488
|
+
{ prop: "seon:hasParameter", note: "formal parameter list (signature string, ast.unparse of the args)" },
|
|
489
|
+
{ prop: "seon:hasReturnType", note: "return ANNOTATION string only (not a resolved type — that is Group B)" },
|
|
490
|
+
{ prop: "seon:throwsException", note: "exception names from `raise` statements (literal, not resolved)" },
|
|
491
|
+
{ prop: "seon:catchesException", note: "exception types from `except` handlers" },
|
|
492
|
+
{ prop: "seon:accessesField", note: "self.<field> names a method touches (self-scoped only — honest)" },
|
|
493
|
+
{ prop: "seon:isStatic", note: "@staticmethod/@classmethod" },
|
|
494
|
+
{ prop: "seon:isAbstract", note: "@abstractmethod/@abstractproperty" },
|
|
495
|
+
{ prop: "seon:isConstant", note: "ALL_CAPS module global" },
|
|
496
|
+
{ prop: "seon:hasAccessModifier", note: "visibility from leading underscore (private/protected); public omitted" },
|
|
497
|
+
{ prop: "seon:hasDoc", note: "first docstring line (capped) — one-line purpose without the body" },
|
|
498
|
+
],
|
|
499
|
+
classes: [
|
|
500
|
+
{ name: "Module", count: moduleIndividuals.length, sample: moduleIndividuals.slice(0, 3).map((i) => i.label) },
|
|
501
|
+
{ name: "Class", count: countClass("Class"), sample: sampleClass("Class") },
|
|
502
|
+
{ name: "Function", count: countClass("Function"), sample: sampleClass("Function") },
|
|
503
|
+
{ name: "Method", count: countClass("Method"), sample: sampleClass("Method") },
|
|
504
|
+
{ name: "Attribute", count: countClass("Attribute"), sample: sampleClass("Attribute") },
|
|
505
|
+
{ name: "GlobalVariable", count: countClass("GlobalVariable"), sample: sampleClass("GlobalVariable") },
|
|
506
|
+
{ name: "Commit", count: commitIndividuals.length, sample: commitIndividuals.slice(0, 3).map((i) => i.label) },
|
|
507
|
+
],
|
|
508
|
+
objectProperties: [
|
|
509
|
+
rel("imports", "mgx:importsNamespace", importEdges),
|
|
510
|
+
rel("calls", "mgx:callsCoarse", callEdges),
|
|
511
|
+
rel("callsSymbol", "mgx:callsSymbol", callSymbolEdges),
|
|
512
|
+
rel("tests", "mgx:testsCoverage", testEdges),
|
|
513
|
+
rel("defines", "seon:declaresMethod", definesEdges),
|
|
514
|
+
rel("touches", "mgx:touchedByCommit", touchEdges),
|
|
515
|
+
rel("touchesSymbol", "mgx:touchesSymbol", touchSymbolEdges),
|
|
516
|
+
rel("contains", "seon:containsCodeEntity", containsEdges),
|
|
517
|
+
rel("inherits", "seon:hasSuperType", inheritsEdges),
|
|
518
|
+
rel("cochange", "mgx:changeCoupledWith", cochangeEdges),
|
|
519
|
+
rel("reexports", "mgx:reExports", reExportEdges),
|
|
520
|
+
],
|
|
521
|
+
individuals: [...moduleIndividuals, ...fnIndividuals, ...commitIndividuals],
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/** Catalog of COLD tools (those reachable only via `cli <tool>`, not the always-on
|
|
526
|
+
* hook) with a one-line "when to use" + example args — written to .seonix/TOOLS.md so
|
|
527
|
+
* a Bash-only agent can discover the invocations. Fallback used until codegraph.mjs
|
|
528
|
+
* exports renderToolsCatalog(cliPath). */
|
|
529
|
+
const COLD_TOOLS = [
|
|
530
|
+
["seonix_describe", '{"symbol":"<path-or-name>"}', "Typed edges (defines/imports/calls/tests/touches) + provenance for one symbol — replaces grep to locate code."],
|
|
531
|
+
["seonix_members", '{"class":"<ClassName>"}', "A class's methods + attributes (file:line, decorators) in one slice."],
|
|
532
|
+
["seonix_subclasses", '{"class":"<ClassName>"}', "Base classes + the transitive set that extends a class."],
|
|
533
|
+
["seonix_impact", '{"module":"<path>"}', "Reverse closure over imports/calls — what breaks if a module changes."],
|
|
534
|
+
["seonix_architecture", '{"package":"<optional/prefix>"}', "Packages + hub modules; the repo shape without reading the tree."],
|
|
535
|
+
["seonix_exports", '{"module":"<path>"}', "A module's __all__ public API resolved to defining modules."],
|
|
536
|
+
["seonix_signature", '{"symbol":"<name>"}', "Params/returns/raises/flags/decorators/doc for one symbol — without its body."],
|
|
537
|
+
["seonix_tests_for", '{"symbol":"<name>"}', "Test modules covering a symbol/module."],
|
|
538
|
+
["seonix_untested", "{}", "Source modules with no covering test module."],
|
|
539
|
+
["seonix_history", '{"symbol":"<name>"}', "Recent commits that touched a symbol's module."],
|
|
540
|
+
["seonix_callers", '{"symbol":"<name>"}', "Modules that call into a symbol's module (one hop)."],
|
|
541
|
+
["seonix_callees", '{"symbol":"<name>"}', "Modules a symbol's module calls into (one hop)."],
|
|
542
|
+
["seonix_cochanges", '{"symbol":"<name>"}', "Modules that historically change in the same commit (edit-these-too)."],
|
|
543
|
+
["seonix_snippet", '{"symbol":"<name>"}', "Exact source of one function/class/Class.method by name."],
|
|
544
|
+
["seonix_context", '{"symbol":"<path-or-name>"}', "START HERE to edit a module: the full edit bundle in one call."],
|
|
545
|
+
["seonix_search", '{"query":"<keywords>"}', "Free-text lookup to discover the right module/symbol when you don't know the path."],
|
|
546
|
+
];
|
|
547
|
+
|
|
548
|
+
function localToolsCatalog(cliPath) {
|
|
549
|
+
const lines = [
|
|
550
|
+
"# seonix cold tools — Bash invocations (no MCP required)",
|
|
551
|
+
"",
|
|
552
|
+
"Each tool answers ONE question in one compact, bounded call — prefer it over Read/Grep.",
|
|
553
|
+
"Run any tool via:",
|
|
554
|
+
"",
|
|
555
|
+
"```bash",
|
|
556
|
+
`node ${cliPath} cli <tool> '<json-args>'`,
|
|
557
|
+
"```",
|
|
558
|
+
"",
|
|
559
|
+
"Pass `\"repo_path\":\"<abs>\"` in the args to point at a specific indexed repo.",
|
|
560
|
+
"",
|
|
561
|
+
];
|
|
562
|
+
for (const [tool, ex, when] of COLD_TOOLS) {
|
|
563
|
+
lines.push(`## ${tool}`, when, "```bash", `node ${cliPath} cli ${tool} '${ex}'`, "```", "");
|
|
564
|
+
}
|
|
565
|
+
return lines.join("\n");
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* Index a repository: parse (ast) + git log → entities → write <repo>/.seonix/graph.json
|
|
570
|
+
* (+ a TOOLS.md catalog of the cold tools). Times the base extraction vs the added
|
|
571
|
+
* symbol-history (line-range) pass so the latter can be kept within the ≤10% budget.
|
|
572
|
+
* @returns {Promise<{graphFile, counts}>}
|
|
573
|
+
*/
|
|
574
|
+
export async function indexRepository(repoPath, { python = process.env.SEONIX_PYTHON || process.env.SEON_PYTHON || "python3", generatedAt = "" } = {}) {
|
|
575
|
+
const t0 = Date.now();
|
|
576
|
+
// base extraction: ast (Python) + multi-language front-end (TS/JS, C#) + module-level
|
|
577
|
+
// git history (the cheap, full-depth pass). The Python and language passes are
|
|
578
|
+
// independent parsers; their `{modules:[…]}` outputs share one contract and are merged
|
|
579
|
+
// (dedupe by path — Python from runAst wins) before buildEntities.
|
|
580
|
+
const [pyModules, langResult, commits] = await Promise.all([
|
|
581
|
+
runAst(repoPath, python),
|
|
582
|
+
ingestRepo(repoPath),
|
|
583
|
+
runGitLog(repoPath, gitDepth()),
|
|
584
|
+
]);
|
|
585
|
+
const seenPaths = new Set(pyModules.map((m) => m.path));
|
|
586
|
+
const modules = [...pyModules, ...langResult.modules.filter((m) => !seenPaths.has(m.path))];
|
|
587
|
+
const baseMs = Date.now() - t0;
|
|
588
|
+
// added symbol-history pass (the costly per-commit hunk diff) — budgeted via depth
|
|
589
|
+
const tHist = Date.now();
|
|
590
|
+
const symbolHistory = await runGitLogHunks(repoPath, historySymbolDepth());
|
|
591
|
+
const historyMs = Date.now() - tHist;
|
|
592
|
+
|
|
593
|
+
const entities = buildEntities(modules, commits, { generatedAt, symbolHistory });
|
|
594
|
+
const graphFile = join(repoPath, ".seonix", "graph.json");
|
|
595
|
+
await mkdir(dirname(graphFile), { recursive: true });
|
|
596
|
+
await writeFile(graphFile, JSON.stringify(entities));
|
|
597
|
+
|
|
598
|
+
// TOOLS.md catalog (prefer the other agent's renderToolsCatalog when it lands).
|
|
599
|
+
// TODO: switch to codegraph.renderToolsCatalog(CLI_SCRIPT) once that export exists.
|
|
600
|
+
const toolsMd = typeof codegraph.renderToolsCatalog === "function"
|
|
601
|
+
? codegraph.renderToolsCatalog(CLI_SCRIPT)
|
|
602
|
+
: localToolsCatalog(CLI_SCRIPT);
|
|
603
|
+
await writeFile(join(repoPath, ".seonix", "TOOLS.md"), toolsMd);
|
|
604
|
+
|
|
605
|
+
const propCount = (prop) => entities.objectProperties.find((g) => g.prop === prop)?.count ?? 0;
|
|
606
|
+
// history budget: the symbol pass should add ≤10% over base extraction time.
|
|
607
|
+
const historyPct = baseMs > 0 ? Math.round((historyMs / baseMs) * 1000) / 10 : 0;
|
|
608
|
+
return {
|
|
609
|
+
graphFile,
|
|
610
|
+
counts: {
|
|
611
|
+
modules: modules.length,
|
|
612
|
+
languages: langResult.perLang,
|
|
613
|
+
functions: entities.classes.find((c) => c.name === "Function")?.count ?? 0,
|
|
614
|
+
commits: commits.length,
|
|
615
|
+
edges: entities.objectProperties.reduce((n, g) => n + g.count, 0),
|
|
616
|
+
callsSymbol: propCount("mgx:callsSymbol"),
|
|
617
|
+
touchesSymbol: propCount("mgx:touchesSymbol"),
|
|
618
|
+
historySymbolDepth: historySymbolDepth(),
|
|
619
|
+
baseMs,
|
|
620
|
+
historyMs,
|
|
621
|
+
historyPct,
|
|
622
|
+
},
|
|
623
|
+
};
|
|
624
|
+
}
|