@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.
@@ -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
+ }