@polycode-projects/the-mechanical-code-talker 2.11.12 → 3.0.1

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,364 @@
1
+ #!/usr/bin/env python3
2
+ """Deterministic, offline static extraction for tmct (Python).
3
+
4
+ Stdlib `ast` only — no third-party parser, no model calls. Walks every *.py file
5
+ under repo_path and emits ONE JSON document on stdout:
6
+
7
+ {"modules": [
8
+ {"path": "<repo-relative>", "dotted": "django.utils.text",
9
+ "imports": ["django.utils.functional", ...], # candidate dotted targets
10
+ "defines": [{"name": "slugify", "kind": "function", "lineno": 12,
11
+ "decorators": ["register.filter(is_safe=True)"]},
12
+ {"name": "Truncator", "kind": "class", "bases": ["object"], ...},
13
+ {"name": "Truncator.chars", "kind": "method", ...},
14
+ {"name": "Truncator.text", "kind": "attribute", ...}, ...],
15
+ "calls": ["str.strip", "re.sub", ...]}, # coarse callee names
16
+ ...
17
+ ]}
18
+
19
+ Resolution of import candidates and call targets to internal modules happens in
20
+ buildEntities against the registry of discovered modules — this script stays a
21
+ pure per-file parser. Run: python3 extract_ast.py <repo_path>.
22
+ """
23
+
24
+ import ast
25
+ import json
26
+ import os
27
+ import sys
28
+
29
+ SKIP_DIRS = {".git", ".tmct", ".hg", ".svn", "node_modules", ".venv", "venv",
30
+ "__pycache__", ".tox", ".mypy_cache", ".pytest_cache", "build", "dist"}
31
+
32
+
33
+ def dotted_for(rel_path):
34
+ """Repo-relative .py path -> dotted module + its package."""
35
+ parts = rel_path[:-3].split(os.sep) # drop ".py"
36
+ if parts and parts[-1] == "__init__":
37
+ parts = parts[:-1]
38
+ pkg = ".".join(parts)
39
+ return pkg, pkg # a package: dotted == its own package
40
+ dotted = ".".join(parts)
41
+ pkg = ".".join(parts[:-1])
42
+ return dotted, pkg
43
+
44
+
45
+ def import_targets(node, pkg):
46
+ """Candidate dotted module targets for one Import/ImportFrom node."""
47
+ out = []
48
+ if isinstance(node, ast.Import):
49
+ for alias in node.names:
50
+ out.append(alias.name) # import a.b.c -> "a.b.c"
51
+ elif isinstance(node, ast.ImportFrom):
52
+ if node.level: # relative: from . / from .mod import x
53
+ base_parts = pkg.split(".") if pkg else []
54
+ base_parts = base_parts[: len(base_parts) - (node.level - 1)] if node.level > 1 else base_parts
55
+ base = ".".join(base_parts)
56
+ mod = f"{base}.{node.module}" if node.module else base
57
+ else:
58
+ mod = node.module or ""
59
+ if mod:
60
+ out.append(mod)
61
+ # `from a.b import c` may import submodule a.b.c — record as a candidate too.
62
+ for alias in node.names:
63
+ if alias.name and alias.name != "*":
64
+ out.append(f"{mod}.{alias.name}")
65
+ return out
66
+
67
+
68
+ def decorator_str(dec):
69
+ try:
70
+ return ast.unparse(dec)
71
+ except Exception:
72
+ return ""
73
+
74
+
75
+ def names_in_target(tgt):
76
+ """Plain-name assignment targets (Name, or Name elements of a Tuple/List)."""
77
+ if isinstance(tgt, ast.Name):
78
+ return [tgt.id]
79
+ if isinstance(tgt, (ast.Tuple, ast.List)):
80
+ out = []
81
+ for el in tgt.elts:
82
+ out.extend(names_in_target(el))
83
+ return out
84
+ return []
85
+
86
+
87
+ # --- mechanical enrichments (deterministic ast facts; no type inference) ----------
88
+ # params, return annotation, raises/catches, self-field access, static/abstract/
89
+ # visibility flags, and the first docstring line. All are free from `ast` alone;
90
+ # everything here stays honest (e.g. raises are the literal `raise` targets, not a
91
+ # resolved type).
92
+
93
+ def _unparse(node):
94
+ try:
95
+ return ast.unparse(node)
96
+ except Exception:
97
+ return ""
98
+
99
+
100
+ def _exc_name(node):
101
+ """The named type of a raised/caught exception expression (drop the call args)."""
102
+ if isinstance(node, ast.Call):
103
+ node = node.func
104
+ return _unparse(node)
105
+
106
+
107
+ def raised_excs(fn):
108
+ out = []
109
+ for n in ast.walk(fn):
110
+ if isinstance(n, ast.Raise) and n.exc is not None:
111
+ nm = _exc_name(n.exc)
112
+ if nm:
113
+ out.append(nm)
114
+ return sorted(set(out))
115
+
116
+
117
+ def caught_excs(fn):
118
+ out = []
119
+ for n in ast.walk(fn):
120
+ if isinstance(n, ast.ExceptHandler) and n.type is not None:
121
+ t = n.type
122
+ elts = t.elts if isinstance(t, (ast.Tuple, ast.List)) else [t]
123
+ for el in elts:
124
+ nm = _exc_name(el)
125
+ if nm:
126
+ out.append(nm)
127
+ return sorted(set(out))
128
+
129
+
130
+ def self_field_names(fn):
131
+ """`self.x` attribute names touched in a method body (read or write)."""
132
+ out = set()
133
+ for n in ast.walk(fn):
134
+ if isinstance(n, ast.Attribute) and isinstance(n.value, ast.Name) and n.value.id == "self":
135
+ out.add(n.attr)
136
+ return sorted(out)
137
+
138
+
139
+ def calls_in(fn):
140
+ """Coarse callee names invoked WITHIN one function/method body (for the
141
+ symbol-granular call graph). Names are the unparsed call target (e.g.
142
+ 'helper', 'self.foo', 're.sub'); resolution to a single in-repo symbol id —
143
+ and the unique-name discipline — happens in buildEntities. Reuses the existing
144
+ per-function ast walk (no extra parse pass)."""
145
+ out = set()
146
+ for n in ast.walk(fn):
147
+ if isinstance(n, ast.Call):
148
+ try:
149
+ nm = ast.unparse(n.func)
150
+ except Exception:
151
+ nm = ""
152
+ if nm:
153
+ out.add(nm)
154
+ return sorted(out)
155
+
156
+
157
+ def first_doc_line(node):
158
+ try:
159
+ d = ast.get_docstring(node, clean=True)
160
+ except Exception:
161
+ d = None
162
+ if not d:
163
+ return ""
164
+ return d.strip().split("\n", 1)[0].strip()[:120]
165
+
166
+
167
+ def visibility_of(name):
168
+ short = name.rsplit(".", 1)[-1]
169
+ if short.startswith("__") and not short.endswith("__"):
170
+ return "private"
171
+ if short.startswith("_") and not short.startswith("__"):
172
+ return "protected"
173
+ return "" # public is the default — omitted to keep the graph lean
174
+
175
+
176
+ def func_extras(node, name, decorators, is_method):
177
+ """Compact, only-when-present enrichment dict for a function/method define."""
178
+ extras = {}
179
+ sig = _unparse(node.args)
180
+ if sig:
181
+ extras["params"] = sig[:160]
182
+ if getattr(node, "returns", None) is not None:
183
+ r = _unparse(node.returns)
184
+ if r:
185
+ extras["returns"] = r[:80]
186
+ raises = raised_excs(node)
187
+ if raises:
188
+ extras["raises"] = raises[:12]
189
+ catches = caught_excs(node)
190
+ if catches:
191
+ extras["catches"] = catches[:12]
192
+ if is_method:
193
+ fields = self_field_names(node)
194
+ if fields:
195
+ extras["self_fields"] = fields[:24]
196
+ # per-function callee names — the raw material for the symbol-granular
197
+ # callsSymbol edge (caller fn -> callee fn). Collected here so the subject
198
+ # (the enclosing def) is known; buildEntities resolves names to symbol ids.
199
+ callees = calls_in(node)
200
+ if callees:
201
+ extras["calls"] = callees[:50]
202
+ decset = " ".join(decorators)
203
+ if "staticmethod" in decset or "classmethod" in decset:
204
+ extras["is_static"] = True
205
+ if "abstractmethod" in decset or "abstractproperty" in decset:
206
+ extras["is_abstract"] = True
207
+ vis = visibility_of(name)
208
+ if vis:
209
+ extras["visibility"] = vis
210
+ doc = first_doc_line(node)
211
+ if doc:
212
+ extras["doc"] = doc
213
+ return extras
214
+
215
+
216
+ def parse_module(src, rel_path):
217
+ dotted, pkg = dotted_for(rel_path)
218
+ try:
219
+ tree = ast.parse(src, filename=rel_path)
220
+ except (SyntaxError, ValueError):
221
+ return None # skip unparseable files (py2 fixtures, templates, etc.)
222
+
223
+ imports = []
224
+ defines = []
225
+ calls = set()
226
+ globals_seen = set()
227
+ exports = [] # names in a literal __all__ (the module's declared public surface)
228
+
229
+ def string_list(v):
230
+ # names from a literal list/tuple of string constants (else [])
231
+ if not isinstance(v, (ast.List, ast.Tuple)):
232
+ return []
233
+ out = []
234
+ for el in v.elts:
235
+ if isinstance(el, ast.Constant) and isinstance(el.value, str):
236
+ out.append(el.value)
237
+ return out
238
+
239
+ def end_of(n):
240
+ return getattr(n, "end_lineno", None) or n.lineno
241
+
242
+ def short_value(v):
243
+ try:
244
+ s = ast.unparse(v)
245
+ except Exception:
246
+ return ""
247
+ return s.replace("\n", " ")[:80]
248
+
249
+ def add_global(name, target_node, value_node):
250
+ # Module-level "live object" globals (RHS is a call, e.g. register =
251
+ # template.Library()) and ALL-CAPS constants — the registration anchors and
252
+ # config values a sibling-adding task must replicate. Skip noisy locals.
253
+ if name in globals_seen or value_node is None:
254
+ return
255
+ is_call = isinstance(value_node, ast.Call)
256
+ if not (is_call or name.isupper()):
257
+ return
258
+ globals_seen.add(name)
259
+ rec = {"name": name, "kind": "global", "lineno": target_node.lineno,
260
+ "end_lineno": end_of(target_node), "decorators": [],
261
+ "value": short_value(value_node)}
262
+ if name.isupper():
263
+ rec["is_constant"] = True
264
+ vis = visibility_of(name)
265
+ if vis:
266
+ rec["visibility"] = vis
267
+ defines.append(rec)
268
+
269
+ # Top-level defs/classes, plus one level of class methods (e.g. Truncator.chars).
270
+ for node in tree.body:
271
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
272
+ decs = [decorator_str(d) for d in node.decorator_list]
273
+ defines.append({"name": node.name, "kind": "function", "lineno": node.lineno,
274
+ "end_lineno": end_of(node), "decorators": decs,
275
+ **func_extras(node, node.name, decs, is_method=False)})
276
+ elif isinstance(node, ast.ClassDef):
277
+ cdoc = first_doc_line(node)
278
+ cvis = visibility_of(node.name)
279
+ cls_extra = {}
280
+ if cdoc:
281
+ cls_extra["doc"] = cdoc
282
+ if cvis:
283
+ cls_extra["visibility"] = cvis
284
+ defines.append({"name": node.name, "kind": "class", "lineno": node.lineno,
285
+ "end_lineno": end_of(node),
286
+ "bases": [decorator_str(b) for b in node.bases],
287
+ "decorators": [decorator_str(d) for d in node.decorator_list],
288
+ **cls_extra})
289
+ seen_attrs = set()
290
+ for sub in node.body:
291
+ if isinstance(sub, (ast.FunctionDef, ast.AsyncFunctionDef)):
292
+ mdecs = [decorator_str(d) for d in sub.decorator_list]
293
+ defines.append({"name": f"{node.name}.{sub.name}", "kind": "method",
294
+ "lineno": sub.lineno, "end_lineno": end_of(sub),
295
+ "decorators": mdecs,
296
+ **func_extras(sub, f"{node.name}.{sub.name}", mdecs, is_method=True)})
297
+ elif isinstance(sub, ast.AnnAssign) and isinstance(sub.target, ast.Name):
298
+ if sub.target.id not in seen_attrs:
299
+ seen_attrs.add(sub.target.id)
300
+ defines.append({"name": f"{node.name}.{sub.target.id}", "kind": "attribute",
301
+ "lineno": sub.lineno, "end_lineno": end_of(sub), "decorators": []})
302
+ elif isinstance(sub, ast.Assign):
303
+ for tgt in sub.targets:
304
+ for nm in names_in_target(tgt):
305
+ if nm in seen_attrs:
306
+ continue
307
+ seen_attrs.add(nm)
308
+ defines.append({"name": f"{node.name}.{nm}", "kind": "attribute",
309
+ "lineno": sub.lineno, "end_lineno": end_of(sub), "decorators": []})
310
+ elif isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name):
311
+ if node.target.id == "__all__" and node.value is not None:
312
+ exports = string_list(node.value)
313
+ else:
314
+ add_global(node.target.id, node, node.value)
315
+ elif isinstance(node, ast.Assign):
316
+ if any(isinstance(t, ast.Name) and t.id == "__all__" for t in node.targets):
317
+ exports = string_list(node.value)
318
+ else:
319
+ for tgt in node.targets:
320
+ for nm in names_in_target(tgt):
321
+ add_global(nm, node, node.value)
322
+
323
+ for node in ast.walk(tree):
324
+ if isinstance(node, (ast.Import, ast.ImportFrom)):
325
+ imports.extend(import_targets(node, pkg))
326
+ elif isinstance(node, ast.Call):
327
+ try:
328
+ name = ast.unparse(node.func)
329
+ except Exception:
330
+ name = ""
331
+ if name:
332
+ calls.add(name)
333
+
334
+ return {"path": rel_path.replace(os.sep, "/"), "dotted": dotted,
335
+ "imports": sorted(set(imports)), "defines": defines, "calls": sorted(calls),
336
+ "exports": exports}
337
+
338
+
339
+ def main():
340
+ if len(sys.argv) < 2:
341
+ sys.stderr.write("usage: extract_ast.py <repo_path>\n")
342
+ sys.exit(2)
343
+ root = os.path.abspath(sys.argv[1])
344
+ modules = []
345
+ for dirpath, dirnames, filenames in os.walk(root):
346
+ dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS and not d.startswith(".")]
347
+ for fn in filenames:
348
+ if not fn.endswith(".py"):
349
+ continue
350
+ abs_path = os.path.join(dirpath, fn)
351
+ rel_path = os.path.relpath(abs_path, root)
352
+ try:
353
+ with open(abs_path, "r", encoding="utf-8") as fh:
354
+ src = fh.read()
355
+ except (OSError, UnicodeDecodeError):
356
+ continue
357
+ mod = parse_module(src, rel_path)
358
+ if mod:
359
+ modules.append(mod)
360
+ json.dump({"modules": modules}, sys.stdout)
361
+
362
+
363
+ if __name__ == "__main__":
364
+ main()
@@ -0,0 +1,173 @@
1
+ // The deterministic, offline code-graph PRODUCER. Walks a repo, runs the
2
+ // registered language extractors, reads git history, and assembles the typed
3
+ // `entities` payload via graph-build.mjs's buildEntities() — the one write-path
4
+ // primitive tmct already had but never called against real source. Writes
5
+ // <repo>/.tmct/graph.json, the artifact the provider seam (source.mjs) reads.
6
+ //
7
+ // ZERO model calls: CPU-bound static parsing + git only. This is the write side
8
+ // of the reader/producer boundary source.mjs documents — source.mjs READS a
9
+ // graph, this module PRODUCES one; they are deliberately separate modules.
10
+
11
+ import { writeFile, mkdir, stat } from "node:fs/promises";
12
+ import { dirname, join } from "node:path";
13
+ import { buildEntities } from "../adapters/graph-build.mjs";
14
+ import { ingestSchemaDocs } from "../tools/schema-docs.mjs";
15
+ import { loadIgnores, relPath } from "./walk.mjs";
16
+ import { ingestRepo, LANG_EXTS } from "./registry.mjs";
17
+ import { exec } from "./spawn.mjs";
18
+
19
+ // Every file extension the index covers — gates the git-log file filters so
20
+ // history is collected for exactly the languages the extractors parsed.
21
+ const INDEXED_EXTS = new Set(LANG_EXTS);
22
+ const isIndexedFile = (f) => {
23
+ const dot = f.lastIndexOf(".");
24
+ return dot >= 0 && INDEXED_EXTS.has(f.slice(dot).toLowerCase());
25
+ };
26
+
27
+ const GIT_LOG_COMMITS = 300; // module-level history depth (cheap; name-only)
28
+ const HISTORY_SYMBOL_DEPTH = 120; // symbol-level line-range pass depth (the costly one)
29
+ // Git history is the one place this producer shells an unbounded command over
30
+ // arbitrary repo history, so it gets a hard wall-clock timeout: a wedged or
31
+ // pathological `git log` can never hang an index.
32
+ const GIT_TIMEOUT_MS = 300_000;
33
+
34
+ function gitDepth(env = process.env) {
35
+ const n = Number(env.TMCT_GIT_DEPTH);
36
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : GIT_LOG_COMMITS;
37
+ }
38
+ function historySymbolDepth(env = process.env) {
39
+ const raw = env.TMCT_HISTORY_SYMBOL_DEPTH;
40
+ if (raw === undefined || raw === "") return HISTORY_SYMBOL_DEPTH;
41
+ const n = Number(raw);
42
+ return Number.isFinite(n) && n >= 0 ? Math.floor(n) : HISTORY_SYMBOL_DEPTH;
43
+ }
44
+
45
+ /** A one-line failure reason for a git-history exec, or null on clean success.
46
+ * Partial parseable output is ALWAYS kept by the caller — this only records WHY
47
+ * the result may be incomplete, so an index never silently loses history edges
48
+ * without saying so. */
49
+ function gitPassError({ code, stderr, timedOut, truncated }) {
50
+ if (timedOut) return "timed out after 300s";
51
+ if (code !== 0) {
52
+ const tail = String(stderr || "").trim().split("\n").pop()?.slice(-200) || "";
53
+ return `git exited ${code}${tail ? `: ${tail}` : ""}`;
54
+ }
55
+ if (truncated) return "output truncated — history incomplete";
56
+ return null;
57
+ }
58
+
59
+ /** git log → {commits:[{sha, author, date, subject, files[]}], error}. Header
60
+ * record fields are \x1e-separated; commits are \x1f-separated. */
61
+ async function runGitLog(repoPath, depth = gitDepth()) {
62
+ const res = await exec("git",
63
+ ["log", "-n", String(depth), "--no-renames", "--name-only",
64
+ "--pretty=format:%x1f%H%x1e%an%x1e%aI%x1e%s"],
65
+ { cwd: repoPath, timeout: GIT_TIMEOUT_MS });
66
+ const out = [];
67
+ for (const chunk of res.stdout.split("\x1f")) {
68
+ const nl = chunk.indexOf("\n");
69
+ const header = (nl === -1 ? chunk : chunk.slice(0, nl)).trim();
70
+ if (!header) continue;
71
+ const [sha, author = "", date = "", subject = ""] = header.split("\x1e");
72
+ if (!sha) continue;
73
+ const files = (nl === -1 ? "" : chunk.slice(nl + 1))
74
+ .split("\n").map((l) => l.trim()).filter(isIndexedFile);
75
+ out.push({ sha: sha.trim(), author, date, subject, files });
76
+ }
77
+ return { commits: out, error: gitPassError(res) };
78
+ }
79
+
80
+ /** git log -p --unified=0 → {hunks:[{sha, ranges:{path:[[start,end],…]}}], error}.
81
+ * Parses the NEW-side hunk header (`+c,d`) into the changed line range; the
82
+ * assembly step intersects those with current symbol spans. Depth 0 → no pass. */
83
+ async function runGitLogHunks(repoPath, depth = historySymbolDepth()) {
84
+ if (!depth) return { hunks: [], error: null };
85
+ const res = await exec("git",
86
+ ["log", "-n", String(depth), "--no-renames", "--no-color", "--unified=0",
87
+ "--pretty=format:%x1f%H"],
88
+ { cwd: repoPath, timeout: GIT_TIMEOUT_MS });
89
+ const out = [];
90
+ let cur = null;
91
+ let file = null;
92
+ for (const line of res.stdout.split("\n")) {
93
+ if (line.startsWith("\x1f")) {
94
+ cur = { sha: line.slice(1).trim(), ranges: {} };
95
+ if (cur.sha) out.push(cur); else cur = null;
96
+ file = null;
97
+ continue;
98
+ }
99
+ if (!cur) continue;
100
+ if (line.startsWith("+++ ")) {
101
+ const m = line.match(/^\+\+\+ b\/(.+?)\s*$/);
102
+ file = m && isIndexedFile(m[1]) ? m[1] : null;
103
+ if (file && !cur.ranges[file]) cur.ranges[file] = [];
104
+ continue;
105
+ }
106
+ if (file && line.startsWith("@@")) {
107
+ const m = line.match(/@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/);
108
+ if (!m) continue;
109
+ const start = Number(m[1]);
110
+ const count = m[2] === undefined ? 1 : Number(m[2]);
111
+ cur.ranges[file].push(count > 0 ? [start, start + count - 1] : [start, start]);
112
+ }
113
+ }
114
+ return { hunks: out, error: gitPassError(res) };
115
+ }
116
+
117
+ /** One repo's raw extraction — parsers + git, NO graph assembly. `historyDepth`:
118
+ * undefined → defaults; 0 → skip both git passes; N>0 → cap both at N. */
119
+ export async function extractRepo(repoPath, { ignores = true, historyDepth } = {}) {
120
+ const ignore = ignores ? await loadIgnores(repoPath) : null;
121
+ const skipHistory = historyDepth === 0;
122
+ const nameDepth = historyDepth === undefined ? gitDepth() : historyDepth;
123
+ const symbolDepth = historyDepth === undefined ? historySymbolDepth() : historyDepth;
124
+ // A git-less repo is a first-class case — no `.git`, no history, silently.
125
+ const hasGit = skipHistory ? false : await stat(join(repoPath, ".git")).then(() => true).catch(() => false);
126
+
127
+ const gitErrors = [];
128
+ const [langResult, gitLog] = await Promise.all([
129
+ ingestRepo(repoPath, { ignore }),
130
+ hasGit ? runGitLog(repoPath, nameDepth) : Promise.resolve({ commits: [], error: null }),
131
+ ]);
132
+ if (gitLog.error) gitErrors.push({ pass: "name-only", message: gitLog.error });
133
+
134
+ const runSymbol = hasGit && symbolDepth > 0;
135
+ const hunks = runSymbol ? await runGitLogHunks(repoPath, symbolDepth) : { hunks: [], error: null };
136
+ if (hunks.error) gitErrors.push({ pass: "symbol-hunk", message: hunks.error });
137
+
138
+ return {
139
+ modules: langResult.modules, perLang: langResult.perLang, failures: langResult.failures,
140
+ commits: gitLog.commits, symbolHistory: hunks.hunks, gitErrors,
141
+ historyDepth: { name: hasGit ? nameDepth : 0, symbol: runSymbol ? symbolDepth : 0 },
142
+ };
143
+ }
144
+
145
+ /** Assemble the entities payload from a raw extraction. Runs ingestSchemaDocs so
146
+ * the written artifact is the writer-ingested form (the same convention the
147
+ * committed fixtures carry — schema self-docs are baked in at produce time). */
148
+ export function assembleEntities({ modules, commits = [], symbolHistory = [], generatedAt = "", prose = true }) {
149
+ const entities = buildEntities(modules, commits, { generatedAt, symbolHistory, prose });
150
+ ingestSchemaDocs(entities);
151
+ return entities;
152
+ }
153
+
154
+ /** Index a repo end to end: extract + assemble + write <repo>/.tmct/graph.json.
155
+ * Returns {graphFile, bytes, modules, symbols, perLang, gitErrors, historyDepth}. */
156
+ export async function indexRepository(repoPath, { ignores = true, historyDepth, prose = true, generatedAt } = {}) {
157
+ const raw = await extractRepo(repoPath, { ignores, historyDepth });
158
+ const entities = assembleEntities({
159
+ modules: raw.modules, commits: raw.commits, symbolHistory: raw.symbolHistory,
160
+ generatedAt: generatedAt ?? new Date().toISOString(), prose,
161
+ });
162
+ const graphFile = join(repoPath, ".tmct", "graph.json");
163
+ await mkdir(dirname(graphFile), { recursive: true });
164
+ const payload = JSON.stringify(entities);
165
+ await writeFile(graphFile, payload);
166
+ const symbols = raw.modules.reduce((n, m) => n + (m.defines?.length || 0), 0);
167
+ return {
168
+ graphFile, bytes: payload.length, modules: raw.modules.length, symbols,
169
+ perLang: raw.perLang, failures: raw.failures, gitErrors: raw.gitErrors, historyDepth: raw.historyDepth,
170
+ };
171
+ }
172
+
173
+ export { relPath };
@@ -0,0 +1,37 @@
1
+ // The extractor REGISTRY — the single place a language backend plugs in. Keyed
2
+ // by language; `exts` is the file-extension surface it owns, `extractor` the
3
+ // module that turns those files into the shared `{modules:[…]}` contract. Every
4
+ // backend emits the SAME shape (path, dotted, imports, defines, calls, exports),
5
+ // so everything downstream (index-repo.mjs, buildEntities) is language-agnostic.
6
+ import * as jsts from "./extract-jsts.mjs";
7
+ import * as python from "./extract-python.mjs";
8
+
9
+ export const REGISTRY = {
10
+ "js/ts": { exts: jsts.meta.exts, extractor: jsts },
11
+ python: { exts: python.meta.exts, extractor: python },
12
+ };
13
+
14
+ /** The full set of file extensions the registry covers (sorted, lower-cased). */
15
+ export const LANG_EXTS = [...new Set(Object.values(REGISTRY).flatMap((r) => r.exts))].sort();
16
+
17
+ /** Parse every registered language under `root` and merge the per-language module
18
+ * lists into one. Returns {modules, perLang, totalFiles, failures}. A language
19
+ * with zero files present is silently absent from `perLang`. */
20
+ export async function ingestRepo(root, { ignore = null } = {}) {
21
+ const allModules = [];
22
+ const perLang = {};
23
+ let totalFiles = 0;
24
+ const allFailures = [];
25
+ for (const [lang, { extractor }] of Object.entries(REGISTRY)) {
26
+ const t0 = Date.now();
27
+ const { modules, failures, fileCount } = await extractor.ingest(root, { ignore });
28
+ const ms = Date.now() - t0;
29
+ if (fileCount === 0) continue; // language not present
30
+ for (const m of modules) allModules.push(m);
31
+ totalFiles += fileCount;
32
+ for (const f of failures) allFailures.push(f);
33
+ const symbols = modules.reduce((n, m) => n + (m.defines?.length || 0), 0);
34
+ perLang[lang] = { lib: extractor.meta.lib, files: fileCount, modules: modules.length, symbols, failures: failures.length, ms };
35
+ }
36
+ return { modules: allModules, perLang, totalFiles, failures: allFailures };
37
+ }
@@ -0,0 +1,35 @@
1
+ // Shared child-process runner for the producer's out-of-process backends (git
2
+ // history, the Python AST extractor). Collects stdout, never rejects, and arms an
3
+ // optional SIGKILL wall-clock so a wedged subprocess can never hang an index.
4
+
5
+ import { spawn } from "node:child_process";
6
+
7
+ /** spawn, collect stdout; resolve {code, stdout, stderr, timedOut, truncated}
8
+ * (never reject). `timeout` (ms, >0) arms a SIGKILL wall-clock; `truncated` is set
9
+ * when stdout exceeded maxBuffer (dropped bytes → an incomplete result the caller
10
+ * must NOT treat as authoritative). */
11
+ export function exec(cmd, args, { cwd, maxBuffer = 512 * 1024 * 1024, timeout = 0 } = {}) {
12
+ return new Promise((resolve) => {
13
+ const child = spawn(cmd, args, { cwd });
14
+ let stdout = "";
15
+ let stderr = "";
16
+ let size = 0;
17
+ let truncated = false;
18
+ let timedOut = false;
19
+ const timer = timeout > 0 ? setTimeout(() => { timedOut = true; child.kill("SIGKILL"); }, timeout) : null;
20
+ child.stdout?.on("data", (d) => { size += d.length; if (size <= maxBuffer) stdout += d; else truncated = true; });
21
+ child.stderr?.on("data", (d) => (stderr += d));
22
+ child.on("close", (code) => {
23
+ if (timer) clearTimeout(timer);
24
+ if (timedOut) {
25
+ resolve({ code: -1, stdout, stderr: stderr + `timed out after ${Math.round(timeout / 1000)}s`, timedOut: true, truncated });
26
+ } else {
27
+ resolve({ code: code ?? -1, stdout, stderr, timedOut: false, truncated });
28
+ }
29
+ });
30
+ child.on("error", (err) => {
31
+ if (timer) clearTimeout(timer);
32
+ resolve({ code: -1, stdout, stderr: stderr + String(err), timedOut, truncated });
33
+ });
34
+ });
35
+ }
Binary file
@@ -209,6 +209,13 @@ export async function createSession({
209
209
  // the flag/env tiers above stay authoritative when set.
210
210
  if (toml?.corpus?.tier === "tier3") liveReferenceOn = true;
211
211
 
212
+ // tmct.toml's [graph] read_only turns any session against this repo into a
213
+ // read-only one, exactly as --ephemeral does: the graph is read for
214
+ // structure but nothing (upsert, logs, memory) is written back into the
215
+ // repo's .tmct/. Committed example fixtures carry it so a plain
216
+ // `tmct chat --repo examples/<x>` never mutates the hand-stamped graph.
217
+ if (toml?.graph?.readOnly) ephemeral = true;
218
+
212
219
  // Ephemeral: keep config.graphFile pointing at the READ graph, but divert the
213
220
  // write base (repo → logs/memory/sessions) to a throwaway temp dir. The committed
214
221
  // target is never touched; the demo's memory simply doesn't persist across runs.
@@ -332,9 +339,9 @@ export async function createSession({
332
339
  // no code graph → point at how to GET one (a graph producer / --repo / the shipped
333
340
  // example), and at what IS answerable now — `vocabHint` is only ever a term
334
341
  // confirmed to resolve in THIS session's actual seed state (see vocabExampleHint),
335
- // never a hardcoded example that might not have been seeded. tmct reads graphs;
336
- // it never indexes code itself.
337
- ...(noCodeGraph ? [`for code structure, point me at a .tmct/graph.json with --repo <path> or try \`npm run example:mini\` (tmct reads graphs, it doesn't index code). ${vocabHint}`] : []),
342
+ // never a hardcoded example that might not have been seeded. tmct can index a
343
+ // repo itself (`tmct index`) or read a graph any other producer wrote.
344
+ ...(noCodeGraph ? [`for code structure, index this repo with \`tmct index\`, or point me at a .tmct/graph.json with --repo <path> (or try \`npm run example:mini\`). ${vocabHint}`] : []),
338
345
  "pass --repo <path> to target a different repo",
339
346
  "ask a question, or /help for commands (/stats for an overview) — /exit to leave",
340
347
  ];
@@ -602,7 +602,7 @@ export function answerCount(graph, query) {
602
602
  // empty — an honest, non-dangling message pointing at how to load one.
603
603
  if (!kinds.length) {
604
604
  return `I can't count "${noun}" — no code graph is loaded yet, so there's nothing to count ` +
605
- `(point me at one with --repo, or run "npm run example:mini").`;
605
+ `(index this repo with "tmct index", point me at another with --repo, or run "npm run example:mini").`;
606
606
  }
607
607
  return `I can't count "${noun}". I count: ${kinds.join(", ")}. ` +
608
608
  `Try "how many classes are there".`;
@@ -2077,8 +2077,8 @@ function orientationAnswer(templates, graph, vocabHint) {
2077
2077
  * null), matching the file's "never crash, always degrade to one honest line"
2078
2078
  * ethos. Kept short and hand-written so it never drifts silently. */
2079
2079
  const ORIENTATION_EMPTY_FALLBACK = "I'm tmct — a deterministic, offline chat assistant (no LLM). "
2080
- + "For code structure (imports, calls, definitions) point me at a repo with `--repo <path>`, "
2081
- + "or try the shipped example `npm run example:mini`. tmct reads graphs; it doesn't index code itself. /help for commands.";
2080
+ + "For code structure (imports, calls, definitions) run `tmct index` here, point me at a repo with `--repo <path>`, "
2081
+ + "or try the shipped example `npm run example:mini`. /help for commands.";
2082
2082
 
2083
2083
  /** A dynamic orientation string for the meta/self lane: a /stats-style overview
2084
2084
  * when a code graph is loaded, else the honest empty-graph orientation — rendered
@@ -13009,8 +13009,8 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
13009
13009
  answer = `${answer}\n(I don't know that yet — you can teach me: say "remember: <thing> is a <kind>".)`;
13010
13010
  note(trace, "intermediate: HONEST-EMPTY POLISH — a browser miss points at the teach lane, not the CLI-only --repo remedy");
13011
13011
  } else {
13012
- answer = `${answer}\n(this repo has no code graph — for structure, point me at a \`.tmct/graph.json\` with \`--repo <path>\` or run \`npm run example:mini\`; tmct doesn't index code itself.)`;
13013
- note(trace, "intermediate: HONEST-EMPTY POLISH — the loaded graph has 0 modules, so the dead-end got a --repo/tmct init pointer appended");
13012
+ answer = `${answer}\n(this repo has no code graph — index it with \`tmct index\`, point me at a \`.tmct/graph.json\` with \`--repo <path>\`, or run \`npm run example:mini\`.)`;
13013
+ note(trace, "intermediate: HONEST-EMPTY POLISH — the loaded graph has 0 modules, so the dead-end got a tmct index/--repo pointer appended");
13014
13014
  }
13015
13015
  }
13016
13016
  // TEACH-OFFER: a "what is X" miss where X is genuinely unknown EVERYWHERE —