@polycode-projects/the-mechanical-code-talker 0.2.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/src/config.mjs ADDED
@@ -0,0 +1,27 @@
1
+ // Configuration for the tmct tool layer. Unlike the marginalia original
2
+ // (remote API + key), this reads a LOCAL graph artifact — so the only knob is
3
+ // where that artifact lives.
4
+ //
5
+ // TMCT_GRAPH_FILE — path to the JSON graph artifact. Default:
6
+ // <cwd>/.tmct/graph.json. Run with cwd = the repo, and the
7
+ // default resolves to that repo's artifact — no config needed.
8
+
9
+ import { join } from "node:path";
10
+
11
+ export const DEFAULT_GRAPH_REL = join(".tmct", "graph.json");
12
+
13
+ /** A clean, caller-facing tool error — message shown to the caller verbatim,
14
+ * never a stack. */
15
+ export class ToolError extends Error {
16
+ constructor(message) {
17
+ super(message);
18
+ this.name = "ToolError";
19
+ }
20
+ }
21
+
22
+ export function loadConfig(env = process.env, cwd = process.cwd()) {
23
+ const graphFile = env.TMCT_GRAPH_FILE && env.TMCT_GRAPH_FILE.trim()
24
+ ? env.TMCT_GRAPH_FILE.trim()
25
+ : join(cwd, DEFAULT_GRAPH_REL);
26
+ return { graphFile };
27
+ }
package/src/embed.mjs ADDED
@@ -0,0 +1,191 @@
1
+ // embed.mjs — deterministic static-embedding lookup (model2vec potion-base-8M).
2
+ // PLAN_SEON_TUNING.md §7.6(5b), 2026-07-02 library-leverage review.
3
+ //
4
+ // The honest "near-LLM" locate lever: a STATIC per-token embedding table (model2vec's
5
+ // potion-base-8M, MIT — 29,528 WordPiece subwords × 256 fp32 dims, ~30 MB) read straight
6
+ // from its safetensors export, mean-pooled and L2-normalised. No ONNX runtime, no model
7
+ // calls, no network after the one-time fetch — pure table lookup + float arithmetic, so
8
+ // the same text always embeds to the same vector ($0, deterministic, offline).
9
+ //
10
+ // Dependency choice (documented per the review): the safetensors format is an 8-byte LE
11
+ // header length + JSON header + raw little-endian tensor bytes, and the tokenizer is a
12
+ // plain Bert-style WordPiece (tokenizer.json: BertNormalizer lowercase + BertPreTokenizer
13
+ // + greedy longest-match with "##" continuations) — both are small enough to hand-roll
14
+ // with node built-ins, so neither the @yarflam/potion-base-8m fallback package nor an HF
15
+ // tokenizer dependency is taken. Numerical intent follows model2vec's own encode (subword
16
+ // ids WITHOUT the [CLS]/[SEP] template, mean pool, normalize per config.json) — exact
17
+ // float parity with the Python lib is not claimed; determinism and rank usefulness are.
18
+ //
19
+ // The weights are NEVER committed and NEVER in the npm package: they live in the
20
+ // gitignored vendor/ tree (fetched by scripts/fetch-embeddings.mjs, `npm run
21
+ // refs:embeddings`, SHA-256-pinned like the repo's other binary artefacts). Everything
22
+ // here degrades gracefully — loadEmbedder() returns null when the weights dir is absent,
23
+ // and callers (codegraph.mjs's opt-in embedRank, scripts/rank-gate.mjs) no-op with a
24
+ // clear note instead of failing, so CI/tests never require the 30 MB download.
25
+
26
+ import { readFileSync, existsSync } from "node:fs";
27
+ import { join, dirname } from "node:path";
28
+ import { fileURLToPath } from "node:url";
29
+
30
+ const MODEL_FILE = "model.safetensors";
31
+ const TOKENIZER_FILE = "tokenizer.json";
32
+ const CONFIG_FILE = "config.json";
33
+
34
+ /** Default artifact dir: $TMCT_EMBED_DIR, else <repo>/vendor/embeddings/potion-base-8M
35
+ * (gitignored via vendor/ — the location scripts/fetch-embeddings.mjs writes). */
36
+ export function defaultEmbeddingsDir() {
37
+ if (process.env.TMCT_EMBED_DIR) return process.env.TMCT_EMBED_DIR;
38
+ const here = dirname(fileURLToPath(import.meta.url)); // packages/tmct/src
39
+ return join(here, "..", "..", "..", "vendor", "embeddings", "potion-base-8M");
40
+ }
41
+
42
+ // ---- safetensors (hand-rolled: u64le header length + JSON header + raw tensors) -------
43
+
44
+ /** Read the single 2-D F32 embedding tensor from a safetensors file →
45
+ * { matrix: Float32Array (row-major), rows, dim }. model2vec exports exactly one
46
+ * tensor named "embeddings"; any lone 2-D F32 tensor is accepted for test fixtures. */
47
+ function readSafetensors(file) {
48
+ const buf = readFileSync(file);
49
+ const headerLen = Number(buf.readBigUInt64LE(0));
50
+ const header = JSON.parse(buf.subarray(8, 8 + headerLen).toString("utf8"));
51
+ const name = header.embeddings
52
+ ? "embeddings"
53
+ : Object.keys(header).find((k) => k !== "__metadata__" && header[k]?.shape?.length === 2);
54
+ const t = name && header[name];
55
+ if (!t) throw new Error(`no 2-D tensor in ${file}`);
56
+ if (t.dtype !== "F32") throw new Error(`unsupported dtype ${t.dtype} in ${file} (only F32)`);
57
+ const [rows, dim] = t.shape;
58
+ const [start, end] = t.data_offsets;
59
+ const bytes = buf.subarray(8 + headerLen + start, 8 + headerLen + end);
60
+ if (bytes.byteLength !== rows * dim * 4) throw new Error(`tensor size mismatch in ${file}`);
61
+ // Copy into a fresh ArrayBuffer: the slice's byteOffset inside the file buffer is not
62
+ // guaranteed 4-byte aligned, and Float32Array requires alignment.
63
+ const matrix = new Float32Array(rows * dim);
64
+ new Uint8Array(matrix.buffer).set(bytes);
65
+ return { matrix, rows, dim };
66
+ }
67
+
68
+ // ---- Bert-style WordPiece tokenizer (from tokenizer.json) -----------------------------
69
+
70
+ const isPunct = (ch) => {
71
+ const c = ch.codePointAt(0);
72
+ // ASCII punctuation ranges (Bert treats these as standalone tokens) + general unicode P/S.
73
+ return (c >= 33 && c <= 47) || (c >= 58 && c <= 64) || (c >= 91 && c <= 96) || (c >= 123 && c <= 126) ||
74
+ /[\p{P}\p{S}]/u.test(ch);
75
+ };
76
+ const isCjk = (ch) => {
77
+ const c = ch.codePointAt(0);
78
+ return (c >= 0x4e00 && c <= 0x9fff) || (c >= 0x3400 && c <= 0x4dbf) ||
79
+ (c >= 0xf900 && c <= 0xfaff) || (c >= 0x20000 && c <= 0x2ffff);
80
+ };
81
+
82
+ function makeTokenizer(spec) {
83
+ if (spec?.model?.type !== "WordPiece") {
84
+ throw new Error(`unsupported tokenizer model "${spec?.model?.type}" (only WordPiece)`);
85
+ }
86
+ const vocab = spec.model.vocab; // token -> id
87
+ const contPrefix = spec.model.continuing_subword_prefix ?? "##";
88
+ const maxChars = spec.model.max_input_chars_per_word ?? 100;
89
+ const unkId = vocab[spec.model.unk_token] ?? null;
90
+ const lowercase = spec.normalizer?.lowercase !== false;
91
+
92
+ // BertNormalizer: clean control chars, pad CJK, lowercase (+ strip accents when lowercasing).
93
+ const normalize = (text) => {
94
+ let s = String(text).replace(/[\u0000\ufffd]/g, "").replace(/[\u0001-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, " ");
95
+ s = [...s].map((ch) => (isCjk(ch) ? ` ${ch} ` : ch)).join("");
96
+ if (lowercase) s = s.toLowerCase().normalize("NFD").replace(/\p{Mn}/gu, "");
97
+ return s;
98
+ };
99
+ // BertPreTokenizer: split on whitespace; every punctuation char is its own word.
100
+ const preTokenize = (s) => {
101
+ const words = [];
102
+ for (const chunk of s.split(/\s+/)) {
103
+ if (!chunk) continue;
104
+ let cur = "";
105
+ for (const ch of chunk) {
106
+ if (isPunct(ch)) {
107
+ if (cur) { words.push(cur); cur = ""; }
108
+ words.push(ch);
109
+ } else cur += ch;
110
+ }
111
+ if (cur) words.push(cur);
112
+ }
113
+ return words;
114
+ };
115
+ // Greedy longest-match WordPiece per word; a word with no valid segmentation → [UNK].
116
+ const wordPiece = (word) => {
117
+ if (word.length > maxChars) return unkId == null ? [] : [unkId];
118
+ const ids = [];
119
+ let start = 0;
120
+ while (start < word.length) {
121
+ let end = word.length;
122
+ let id = null;
123
+ while (end > start) {
124
+ const piece = (start > 0 ? contPrefix : "") + word.slice(start, end);
125
+ if (vocab[piece] !== undefined) { id = vocab[piece]; break; }
126
+ end--;
127
+ }
128
+ if (id == null) return unkId == null ? [] : [unkId];
129
+ ids.push(id);
130
+ start = end;
131
+ }
132
+ return ids;
133
+ };
134
+ // No [CLS]/[SEP] template — model2vec pools content subwords only.
135
+ return (text) => preTokenize(normalize(text)).flatMap(wordPiece);
136
+ }
137
+
138
+ // ---- embedder ---------------------------------------------------------------------------
139
+
140
+ const LOADED = new Map(); // dir -> embedder | null (per-process cache; weights load once)
141
+
142
+ /** Load tokenizer + embedding matrix from `dir` (default: defaultEmbeddingsDir()).
143
+ * Returns null — silently — when the artifacts are absent (the one-time
144
+ * `npm run refs:embeddings` fetch has not run): callers no-op, never fail.
145
+ * The returned embedder is { dim, dir, embed(text) → L2-normalised Float32Array }. */
146
+ export function loadEmbedder({ dir = defaultEmbeddingsDir() } = {}) {
147
+ if (LOADED.has(dir)) return LOADED.get(dir);
148
+ const modelFile = join(dir, MODEL_FILE);
149
+ const tokFile = join(dir, TOKENIZER_FILE);
150
+ if (!existsSync(modelFile) || !existsSync(tokFile)) {
151
+ LOADED.set(dir, null);
152
+ return null;
153
+ }
154
+ const { matrix, rows, dim } = readSafetensors(modelFile);
155
+ const tokenize = makeTokenizer(JSON.parse(readFileSync(tokFile, "utf8")));
156
+ let cfg = {};
157
+ try { cfg = JSON.parse(readFileSync(join(dir, CONFIG_FILE), "utf8")); } catch { /* optional */ }
158
+ const doNormalize = cfg.normalize !== false;
159
+
160
+ const embed = (text) => {
161
+ const ids = tokenize(text);
162
+ const v = new Float32Array(dim);
163
+ if (!ids.length) return v; // zero vector: cosine 0 against everything
164
+ for (const id of ids) {
165
+ if (id < 0 || id >= rows) continue;
166
+ const off = id * dim;
167
+ for (let j = 0; j < dim; j++) v[j] += matrix[off + j];
168
+ }
169
+ for (let j = 0; j < dim; j++) v[j] /= ids.length; // mean pool
170
+ if (doNormalize) {
171
+ let norm = 0;
172
+ for (let j = 0; j < dim; j++) norm += v[j] * v[j];
173
+ norm = Math.sqrt(norm);
174
+ if (norm > 0) for (let j = 0; j < dim; j++) v[j] /= norm;
175
+ }
176
+ return v;
177
+ };
178
+ const embedder = { dim, dir, embed };
179
+ LOADED.set(dir, embedder);
180
+ return embedder;
181
+ }
182
+
183
+ /** Cosine similarity. Over L2-normalised vectors this is just the dot product, but the
184
+ * full form is kept so unnormalised test fixtures behave. 0 when either vector is zero. */
185
+ export function cosine(a, b) {
186
+ let dot = 0, na = 0, nb = 0;
187
+ const n = Math.min(a.length, b.length);
188
+ for (let i = 0; i < n; i++) { dot += a[i] * b[i]; na += a[i] * a[i]; nb += b[i] * b[i]; }
189
+ if (na === 0 || nb === 0) return 0;
190
+ return dot / (Math.sqrt(na) * Math.sqrt(nb));
191
+ }
@@ -0,0 +1,428 @@
1
+ // graph-build.mjs  the PURE assembly of the typed `entities` payload from
2
+ // already-parsed module + commit records. No subprocesses, no filesystem, no git:
3
+ // data in, graph out  which is why tests build in-memory graphs through it, and
4
+ // why it is the write-path primitive conversation memory grows on (sessions.mjs
5
+ // folds session records into the same shape).
6
+ //
7
+ // Typed edges produced (all provenance-stamped). Prop tokens follow the SEON
8
+ // vocabulary (se-on.org, FAMIX-derived) where a term exists, with an `mgx:`
9
+ // extension namespace:
10
+ // seon:usesComplexType Module -> Module (internal import targets, via registry)
11
+ // seon:declaresMethod Module -> CodeEntity (top-level functions/classes/methods/attrs)
12
+ // seon:invokesMethod Module -> Module (coarse + import-backed)
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 x 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)
19
+
20
+ import { attachProseTokens, buildProseIndex } from "./prose.mjs";
21
+
22
+ const isTestPath = (p) =>
23
+ p.startsWith("tests/") || /(^|\/)tests?\//.test(p) || /(^|\/)test_[^/]*\.py$/.test(p) || /\.tests(\.|$)/.test(p);
24
+
25
+ const lastIdent = (name) => {
26
+ const m = String(name).match(/([A-Za-z_][A-Za-z0-9_]*)\s*$/);
27
+ return m ? m[1] : null;
28
+ };
29
+
30
+ /** Build the `entities` payload from the parsed modules + git history.
31
+ * `symbolHistory` (optional) is the runGitLogHunks() output — per-commit changed
32
+ * line ranges, intersected with symbol spans to emit mgx:touchesSymbol edges. */
33
+ export function buildEntities(modules, commits, { generatedAt = "", symbolHistory = [], prose = true } = {}) {
34
+ const modById = new Map(); // path -> module record
35
+ const dottedToPath = new Map(); // dotted module name -> path
36
+ const nameToPaths = new Map(); // top-level symbol name -> Set<path> (for call resolution)
37
+ const nameToSymbolIds = new Map(); // top-level symbol name -> Set<fnId> (for symbol-granular calls)
38
+
39
+ const modId = (p) => `mod:${p}`;
40
+ const fnId = (p, name) => `fn:${p}#${name}`;
41
+
42
+ for (const m of modules) {
43
+ modById.set(m.path, m);
44
+ if (m.dotted) dottedToPath.set(m.dotted, m.path);
45
+ }
46
+ // Registry of top-level functions/classes by simple name — used to resolve coarse
47
+ // call targets AND inheritance bases to a single internal module (else dropped).
48
+ const classToPaths = new Map(); // class name -> Set<path> (for base resolution)
49
+ for (const m of modules) {
50
+ for (const d of m.defines || []) {
51
+ if (d.kind === "method" || d.kind === "attribute" || d.kind === "global") continue; // not standalone call targets
52
+ const register = (name) => {
53
+ if (!nameToPaths.has(name)) nameToPaths.set(name, new Set());
54
+ nameToPaths.get(name).add(m.path);
55
+ if (!nameToSymbolIds.has(name)) nameToSymbolIds.set(name, new Set());
56
+ nameToSymbolIds.get(name).add(fnId(m.path, d.name));
57
+ if (d.kind === "class") {
58
+ if (!classToPaths.has(name)) classToPaths.set(name, new Set());
59
+ classToPaths.get(name).add(m.path);
60
+ }
61
+ };
62
+ register(d.name);
63
+ // Nested types (Java/C#) define dotted names like Outer.Inner — ALSO register the
64
+ // simple name so `new Inner()` / `extends Inner` still resolve. Same Set semantics:
65
+ // a second definition of the simple name makes it ambiguous → dropped, honest.
66
+ if (d.kind === "class" && d.name.includes(".")) {
67
+ const simple = lastIdent(d.name);
68
+ if (simple && simple !== d.name) register(simple);
69
+ }
70
+ }
71
+ }
72
+ // Resolve a class SIMPLE name at a path to the id of its (possibly nested, dotted)
73
+ // define — nameToSymbolIds keeps full ids, so a unique in-path match wins; an exact
74
+ // plain define beats a nested one; else fall back to the literal id (pre-nesting shape).
75
+ const classIdAt = (path, ident) => {
76
+ const pre = `fn:${path}#`;
77
+ const exact = `${pre}${ident}`;
78
+ const ids = [...(nameToSymbolIds.get(ident) || [])].filter((i) => i.startsWith(pre));
79
+ if (ids.includes(exact) || ids.length !== 1) return exact;
80
+ return ids[0];
81
+ };
82
+
83
+ // resolve a module's import candidates to internal module paths
84
+ const internalImports = (m) => {
85
+ const set = new Set();
86
+ for (const cand of m.imports || []) {
87
+ let path = dottedToPath.get(cand);
88
+ if (!path) {
89
+ // `from a.b import c` → cand "a.b.c" may be a symbol; fall back to the package "a.b".
90
+ const parent = cand.includes(".") ? cand.slice(0, cand.lastIndexOf(".")) : "";
91
+ path = parent && dottedToPath.get(parent);
92
+ }
93
+ if (path && path !== m.path) set.add(path);
94
+ }
95
+ return set;
96
+ };
97
+
98
+ const importEdges = [];
99
+ const definesEdges = [];
100
+ const callEdges = [];
101
+ const testEdges = [];
102
+ const containsEdges = [];
103
+ const inheritsEdges = [];
104
+ const callSymbolEdges = [];
105
+ const fnIndividuals = [];
106
+ const symbolSpansByPath = new Map(); // path -> [{id, label, start, end}] (for touchesSymbol)
107
+ const seenFn = new Set();
108
+ const seenContains = new Set();
109
+ const seenInherits = new Set();
110
+ const seenCallSymbol = new Set();
111
+
112
+ const CLASS_OF = { class: "Class", method: "Method", attribute: "Attribute", function: "Function", global: "GlobalVariable" };
113
+ const shortName = (name) => (name.includes(".") ? name.slice(name.lastIndexOf(".") + 1) : name);
114
+ const ownerName = (name) => name.slice(0, name.lastIndexOf("."));
115
+
116
+ for (const m of modules) {
117
+ const imports = internalImports(m);
118
+ for (const target of imports) {
119
+ const edge = { subject: modId(m.path), object: modId(target), subjectLabel: m.path, objectLabel: target };
120
+ (isTestPath(m.path) ? testEdges : importEdges).push(edge);
121
+ }
122
+
123
+ for (const d of m.defines || []) {
124
+ const oid = fnId(m.path, d.name);
125
+ definesEdges.push({ subject: modId(m.path), object: oid, subjectLabel: m.path, objectLabel: d.name });
126
+
127
+ if (!seenFn.has(oid)) {
128
+ seenFn.add(oid);
129
+ const startLn = Number(d.lineno) || 0;
130
+ const endLn = Number(d.end_lineno) > startLn ? Number(d.end_lineno) : startLn;
131
+ const span = endLn > startLn ? `${startLn}-${endLn}` : `${startLn}`;
132
+ if (startLn) {
133
+ if (!symbolSpansByPath.has(m.path)) symbolSpansByPath.set(m.path, []);
134
+ symbolSpansByPath.get(m.path).push({ id: oid, label: d.name, start: startLn, end: endLn });
135
+ }
136
+ const attrs = [{ prop: "seon:startsAt", key: "site", value: `${m.path}:${span}` }];
137
+ const decs = (d.decorators || []).filter(Boolean);
138
+ if (decs.length) attrs.push({ prop: "mgx:decorator", key: "decorators", value: decs.join(", ") });
139
+ if (d.kind === "global" && d.value) attrs.push({ prop: "mgx:value", key: "value", value: d.value });
140
+ // mechanical enrichments (deterministic ast facts; emitted only when present
141
+ // so the graph stays lean) — surfaced via seon_signature, NOT the lean bundle.
142
+ const list = (v) => (Array.isArray(v) ? v.join(", ") : String(v ?? ""));
143
+ if (d.params) attrs.push({ prop: "seon:hasParameter", key: "params", value: String(d.params) });
144
+ if (d.returns) attrs.push({ prop: "seon:hasReturnType", key: "returns", value: String(d.returns) });
145
+ if (d.raises?.length) attrs.push({ prop: "seon:throwsException", key: "raises", value: list(d.raises) });
146
+ if (d.catches?.length) attrs.push({ prop: "seon:catchesException", key: "catches", value: list(d.catches) });
147
+ if (d.self_fields?.length) attrs.push({ prop: "seon:accessesField", key: "self_fields", value: list(d.self_fields) });
148
+ if (d.subkind) attrs.push({ prop: "seon:subKind", key: "subkind", value: String(d.subkind) });
149
+ if (d.is_static) attrs.push({ prop: "seon:isStatic", key: "isStatic", value: "true" });
150
+ if (d.is_abstract) attrs.push({ prop: "seon:isAbstract", key: "isAbstract", value: "true" });
151
+ if (d.is_constant) attrs.push({ prop: "seon:isConstant", key: "isConstant", value: "true" });
152
+ if (d.visibility) attrs.push({ prop: "seon:hasAccessModifier", key: "visibility", value: String(d.visibility) });
153
+ if (d.doc) attrs.push({ prop: "seon:hasDoc", key: "doc", value: String(d.doc) });
154
+ fnIndividuals.push({
155
+ id: oid, label: d.name, class: CLASS_OF[d.kind] || "Function",
156
+ derived_from: [], mentions: [], attributes: attrs,
157
+ });
158
+ }
159
+
160
+ // class membership: Class → Method/Attribute (the new info; module→symbol is `defines`)
161
+ if (d.kind === "method" || d.kind === "attribute") {
162
+ const owner = ownerName(d.name);
163
+ if (owner) {
164
+ const ownerId = fnId(m.path, owner);
165
+ const ckey = `${ownerId}>${oid}`;
166
+ if (!seenContains.has(ckey)) {
167
+ seenContains.add(ckey);
168
+ containsEdges.push({ subject: ownerId, object: oid, subjectLabel: owner, objectLabel: shortName(d.name) });
169
+ }
170
+ }
171
+ }
172
+
173
+ // inheritance: Class → base. Resolve to an internal Class id only when the base
174
+ // name is defined in exactly ONE internal module AND that module is imported here
175
+ // (mirrors the coarse-call discipline — avoids linking `argparse.Action` to an
176
+ // unrelated internal `Action`). Otherwise keep it external as ext:<base>, honest.
177
+ if (d.kind === "class") {
178
+ for (const base of d.bases || []) {
179
+ const ident = lastIdent(base);
180
+ if (!ident) continue;
181
+ const defs = classToPaths.get(ident);
182
+ let object = `ext:${ident}`;
183
+ if (defs && defs.has(m.path)) {
184
+ object = classIdAt(m.path, ident); // same-module base wins (local name scoping), even if the name is globally ambiguous
185
+ } else if (defs && defs.size === 1) {
186
+ const targetPath = [...defs][0];
187
+ if (imports.has(targetPath)) object = classIdAt(targetPath, ident);
188
+ }
189
+ const ikey = `${oid}>${object}`;
190
+ if (seenInherits.has(ikey) || object === oid) continue;
191
+ seenInherits.add(ikey);
192
+ inheritsEdges.push({ subject: oid, object, subjectLabel: d.name, objectLabel: base });
193
+ }
194
+ }
195
+
196
+ // symbol-granular calls: caller fn/method → callee fn/class, resolved ONLY when the
197
+ // callee simple name has exactly ONE in-repo definition (same unique-name discipline
198
+ // as the module-coarse calls). Ambiguous / receiver-typed / external names are dropped
199
+ // (honest Group-A). Reuses the per-function call names already parsed by extract_ast.py.
200
+ if ((d.kind === "function" || d.kind === "method") && d.calls?.length) {
201
+ for (const callName of d.calls) {
202
+ const ident = lastIdent(callName);
203
+ if (!ident) continue;
204
+ const ids = nameToSymbolIds.get(ident);
205
+ if (!ids || ids.size !== 1) continue; // ambiguous or external → drop
206
+ const callee = [...ids][0];
207
+ if (callee === oid) continue; // self-recursion not an edge
208
+ const ckey = `${oid}>${callee}`;
209
+ if (seenCallSymbol.has(ckey)) continue;
210
+ seenCallSymbol.add(ckey);
211
+ callSymbolEdges.push({ subject: oid, object: callee, subjectLabel: d.name, objectLabel: ident });
212
+ }
213
+ }
214
+ }
215
+
216
+ // coarse, import-backed calls: a callee name defined in exactly one imported module.
217
+ if (!isTestPath(m.path)) {
218
+ const seen = new Set();
219
+ for (const callName of m.calls || []) {
220
+ const ident = lastIdent(callName);
221
+ if (!ident) continue;
222
+ const defs = nameToPaths.get(ident);
223
+ if (!defs || defs.size !== 1) continue; // ambiguous → drop (honest)
224
+ const target = [...defs][0];
225
+ if (target === m.path || !imports.has(target) || seen.has(target)) continue;
226
+ seen.add(target);
227
+ callEdges.push({ subject: modId(m.path), object: modId(target), subjectLabel: m.path, objectLabel: target });
228
+ }
229
+ }
230
+ }
231
+
232
+ // git history → touches edges + per-module commit provenance + commit metadata
233
+ const touchEdges = [];
234
+ const touchedBy = new Map(); // path -> [git:<sha>]
235
+ const commitIndividuals = [];
236
+ const commitIds = new Set();
237
+ const MSG_CAP = 120;
238
+ const commitInd = (sha, short, c = null) => {
239
+ const attrs = [];
240
+ if (c?.author) attrs.push({ prop: "mgx:commitAuthor", key: "author", value: String(c.author) });
241
+ if (c?.date) attrs.push({ prop: "mgx:commitDate", key: "date", value: String(c.date) });
242
+ if (c?.subject) attrs.push({ prop: "mgx:commitMessage", key: "message", value: String(c.subject).slice(0, MSG_CAP) });
243
+ return { id: `commit:${sha}`, label: short, class: "Commit", derived_from: [], mentions: [], attributes: attrs };
244
+ };
245
+ for (const c of commits) {
246
+ const short = c.sha.slice(0, 12);
247
+ let touchedAny = false;
248
+ for (const f of c.files) {
249
+ if (!modById.has(f)) continue;
250
+ touchEdges.push({ subject: `commit:${c.sha}`, object: modId(f), subjectLabel: short, objectLabel: f });
251
+ if (!touchedBy.has(f)) touchedBy.set(f, []);
252
+ touchedBy.get(f).push(`git:${short}`);
253
+ touchedAny = true;
254
+ }
255
+ // !commitIds.has: a merged multi-repo commit list CAN repeat a sha (two clones of
256
+ // the same project indexed under different names) — one Commit individual per sha.
257
+ if (touchedAny && !commitIds.has(c.sha)) {
258
+ commitIds.add(c.sha);
259
+ commitIndividuals.push(commitInd(c.sha, short, c));
260
+ }
261
+ }
262
+
263
+ // symbol-granular history: intersect each commit's changed line ranges with the
264
+ // (current) symbol spans in that file → mgx:touchesSymbol (Commit → CodeEntity).
265
+ const touchSymbolEdges = [];
266
+ const seenSymTouch = new Set();
267
+ for (const c of symbolHistory) {
268
+ const short = c.sha.slice(0, 12);
269
+ for (const [path, ranges] of Object.entries(c.ranges || {})) {
270
+ const syms = symbolSpansByPath.get(path);
271
+ if (!syms || !ranges.length) continue;
272
+ for (const s of syms) {
273
+ if (!ranges.some(([a, b]) => a <= s.end && b >= s.start)) continue;
274
+ const key = `${c.sha}>${s.id}`;
275
+ if (seenSymTouch.has(key)) continue;
276
+ seenSymTouch.add(key);
277
+ touchSymbolEdges.push({ subject: `commit:${c.sha}`, object: s.id, subjectLabel: short, objectLabel: s.label });
278
+ // make sure the commit individual exists (symbol depth may differ from module depth)
279
+ if (!commitIds.has(c.sha)) { commitIds.add(c.sha); commitIndividuals.push(commitInd(c.sha, short, null)); }
280
+ }
281
+ }
282
+ }
283
+
284
+ // change-coupling: modules co-changed in the same commit (git co-occurrence) — the
285
+ // "what usually changes together" signal for an editing agent. Undirected, thresholded,
286
+ // capped per node; mega-commits skipped (noise). Each edge carries its co-change count.
287
+ const COCHANGE_MIN = 2; // co-occur in ≥ N commits
288
+ const COCHANGE_MAX_COMMIT = 50; // skip sweeping refactors (O(n²) noise)
289
+ const COCHANGE_PER_NODE = 12; // cap neighbours per module
290
+ const pairCount = new Map(); // "ab" (a<b lexical) -> count
291
+ for (const c of commits) {
292
+ const mods = [...new Set((c.files || []).filter((f) => modById.has(f)))];
293
+ if (mods.length < 2 || mods.length > COCHANGE_MAX_COMMIT) continue;
294
+ for (let i = 0; i < mods.length; i += 1) {
295
+ for (let j = i + 1; j < mods.length; j += 1) {
296
+ const [a, b] = mods[i] < mods[j] ? [mods[i], mods[j]] : [mods[j], mods[i]];
297
+ const key = `${a}${b}`;
298
+ pairCount.set(key, (pairCount.get(key) || 0) + 1);
299
+ }
300
+ }
301
+ }
302
+ const cochangeEdges = [];
303
+ const cochangePerNode = new Map();
304
+ for (const [key, n] of [...pairCount.entries()].filter(([, c]) => c >= COCHANGE_MIN).sort((x, y) => y[1] - x[1])) {
305
+ const [a, b] = key.split("");
306
+ if ((cochangePerNode.get(a) || 0) >= COCHANGE_PER_NODE || (cochangePerNode.get(b) || 0) >= COCHANGE_PER_NODE) continue;
307
+ cochangePerNode.set(a, (cochangePerNode.get(a) || 0) + 1);
308
+ cochangePerNode.set(b, (cochangePerNode.get(b) || 0) + 1);
309
+ cochangeEdges.push({ subject: modId(a), object: modId(b), subjectLabel: a, objectLabel: b, weight: n });
310
+ }
311
+
312
+ // re-exports / public API: a module's literal __all__ entries, resolved to the symbol
313
+ // they expose — either defined locally, or re-exported from an imported internal module.
314
+ // Answers "where is X importable from" and makes __init__ re-export hubs explicit.
315
+ const reExportEdges = [];
316
+ const seenReExport = new Set();
317
+ for (const m of modules) {
318
+ if (!m.exports || !m.exports.length) continue;
319
+ const imports = internalImports(m);
320
+ for (const name of m.exports) {
321
+ let object = null;
322
+ if (seenFn.has(fnId(m.path, name))) {
323
+ object = fnId(m.path, name); // exported a locally-defined symbol
324
+ } else {
325
+ const defs = nameToPaths.get(name);
326
+ if (defs && defs.size === 1) {
327
+ const target = [...defs][0];
328
+ if (imports.has(target)) object = fnId(target, name); // true re-export from an imported module
329
+ }
330
+ }
331
+ if (!object) continue;
332
+ const key = `${m.path}>${object}`;
333
+ if (seenReExport.has(key)) continue;
334
+ seenReExport.add(key);
335
+ reExportEdges.push({ subject: modId(m.path), object, subjectLabel: m.path, objectLabel: name });
336
+ }
337
+ }
338
+
339
+ const moduleIndividuals = modules.map((m) => ({
340
+ id: modId(m.path), label: m.path, class: "Module",
341
+ derived_from: touchedBy.get(m.path) || [], mentions: [],
342
+ attributes: [
343
+ { prop: "mgx:dotted", key: "dotted", value: m.dotted || "" },
344
+ // Literal __all__ membership (the public-API surface a new sibling must JOIN to be
345
+ // importable). Stored even when entries don't resolve to a symbol, so seon_context
346
+ // can always tell the agent "this module has an __all__ — add your symbol".
347
+ ...(m.exports?.length ? [{ prop: "mgx:exportsAll", key: "all", value: m.exports.join(", ") }] : []),
348
+ ],
349
+ }));
350
+
351
+ const rel = (predicate, prop, edges) => ({ predicate, prop, count: edges.length, examples: edges });
352
+ const countClass = (c) => fnIndividuals.filter((i) => i.class === c).length;
353
+ const sampleClass = (c) => fnIndividuals.filter((i) => i.class === c).slice(0, 3).map((i) => i.label);
354
+
355
+ // Second pass (PLAN_PROSE_INDEX.md) — see the returned `proseIndex` field's comment below.
356
+ const allIndividuals = attachProseTokens(
357
+ [...moduleIndividuals, ...fnIndividuals, ...commitIndividuals], { enabled: prose },
358
+ );
359
+ const proseIndex = prose ? buildProseIndex(allIndividuals) : {};
360
+
361
+ return {
362
+ generated_at: generatedAt,
363
+ // SEON (se-on.org, FAMIX-derived) vocabulary + our `mgx:` extension, documented
364
+ // for readers; the graph is JSON-label-only (no RDF store — see PLAN_SEON_RDF.md).
365
+ prefixes: {
366
+ seon: "http://se-on.org/ontologies/seon.owl#",
367
+ mgx: "urn:tmct:mgx#",
368
+ rdfs: "http://www.w3.org/2000/01/rdf-schema#",
369
+ },
370
+ vocabulary: [
371
+ { prop: "mgx:importsNamespace", predicate: "imports", note: "module→module; SEON usesComplexType is type→type, so owned (cf. main:dependsOn)" },
372
+ { prop: "mgx:callsCoarse", predicate: "calls", note: "module→module, import-backed; NOT SEON's method→method invokesMethod" },
373
+ { prop: "mgx:callsSymbol", predicate: "callsSymbol", note: "caller fn/method→callee fn/class; symbol-granular, unique-name-resolved (Group A); cf. seon:invokesMethod" },
374
+ { prop: "seon:declaresMethod", predicate: "defines" },
375
+ { prop: "seon:containsCodeEntity", predicate: "contains" },
376
+ { prop: "mgx:touchedByCommit", predicate: "touches", note: "owned; seon:history is not a real SEON property (cf. history:isCommittedIn)" },
377
+ { prop: "mgx:touchesSymbol", predicate: "touchesSymbol", note: "Commit→CodeEntity; changed-line-range ∩ symbol span; owned (cf. seon-hist)" },
378
+ { prop: "mgx:commitAuthor", note: "commit author name (%an)" },
379
+ { prop: "mgx:commitDate", note: "commit author date, ISO-8601 (%aI)" },
380
+ { prop: "mgx:commitMessage", note: "commit subject line (%s), capped to 120 chars" },
381
+ { prop: "mgx:testsCoverage", predicate: "tests", note: "no SEON term — our extension" },
382
+ { prop: "seon:hasSuperType", predicate: "inherits" },
383
+ { prop: "mgx:changeCoupledWith", predicate: "cochange", note: "git co-change; owned (no SEON term; cf. domain-spanning change-couplings.owl)" },
384
+ { prop: "mgx:reExports", predicate: "reexports", note: "public API / re-export surface (__all__); owned, no SEON term" },
385
+ { prop: "mgx:exportsAll", note: "literal __all__ membership list on a module (the public surface a new sibling must join)" },
386
+ { prop: "mgx:decorator", note: "Python/framework decorators — our extension" },
387
+ { prop: "seon:hasParameter", note: "formal parameter list (signature string, ast.unparse of the args)" },
388
+ { prop: "seon:hasReturnType", note: "return ANNOTATION string only (not a resolved type — that is Group B)" },
389
+ { prop: "seon:throwsException", note: "exception names from `raise` statements (literal, not resolved)" },
390
+ { prop: "seon:catchesException", note: "exception types from `except` handlers" },
391
+ { prop: "seon:accessesField", note: "self.<field> names a method touches (self-scoped only — honest)" },
392
+ { prop: "seon:isStatic", note: "@staticmethod/@classmethod" },
393
+ { prop: "seon:isAbstract", note: "@abstractmethod/@abstractproperty" },
394
+ { prop: "seon:isConstant", note: "ALL_CAPS module global" },
395
+ { prop: "seon:subKind", note: "type flavour on a Class define when not a plain class (interface/enum/struct/record); kind stays class" },
396
+ { prop: "seon:hasAccessModifier", note: "visibility from leading underscore (private/protected); public omitted" },
397
+ { prop: "seon:hasDoc", note: "first docstring line (capped) — one-line purpose without the body" },
398
+ ],
399
+ classes: [
400
+ { name: "Module", count: moduleIndividuals.length, sample: moduleIndividuals.slice(0, 3).map((i) => i.label) },
401
+ { name: "Class", count: countClass("Class"), sample: sampleClass("Class") },
402
+ { name: "Function", count: countClass("Function"), sample: sampleClass("Function") },
403
+ { name: "Method", count: countClass("Method"), sample: sampleClass("Method") },
404
+ { name: "Attribute", count: countClass("Attribute"), sample: sampleClass("Attribute") },
405
+ { name: "GlobalVariable", count: countClass("GlobalVariable"), sample: sampleClass("GlobalVariable") },
406
+ { name: "Commit", count: commitIndividuals.length, sample: commitIndividuals.slice(0, 3).map((i) => i.label) },
407
+ ],
408
+ objectProperties: [
409
+ rel("imports", "mgx:importsNamespace", importEdges),
410
+ rel("calls", "mgx:callsCoarse", callEdges),
411
+ rel("callsSymbol", "mgx:callsSymbol", callSymbolEdges),
412
+ rel("tests", "mgx:testsCoverage", testEdges),
413
+ rel("defines", "seon:declaresMethod", definesEdges),
414
+ rel("touches", "mgx:touchedByCommit", touchEdges),
415
+ rel("touchesSymbol", "mgx:touchesSymbol", touchSymbolEdges),
416
+ rel("contains", "seon:containsCodeEntity", containsEdges),
417
+ rel("inherits", "seon:hasSuperType", inheritsEdges),
418
+ rel("cochange", "mgx:changeCoupledWith", cochangeEdges),
419
+ rel("reexports", "mgx:reExports", reExportEdges),
420
+ ],
421
+ individuals: allIndividuals,
422
+ // Second pass (PLAN_PROSE_INDEX.md): word -> [individual ids], inverted from the
423
+ // `prose_tokens` attribute attachProseTokens just attached. Disable via
424
+ // TMCT_PROSE_INDEX=0 (indexRepository, below) — {} when off. The typed graph above
425
+ // (individuals' core fields, all edges) is byte-identical either way.
426
+ proseIndex,
427
+ };
428
+ }