@polycode-projects/the-mechanical-code-talker 2.11.11 → 3.0.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,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
@@ -428,6 +428,20 @@ export function pillsForRoom(rows, state, here) {
428
428
  return roomAffordances(rows, state, here);
429
429
  }
430
430
 
431
+ /** The chat input's grounding placeholder, built from an affordance list: the
432
+ * first couple of OBJECT commands (examine/take/open/unlock/talk to <thing>)
433
+ * spelled with the room's real props, so the empty input teaches the grounded
434
+ * noun form ("examine lamp, take letter…") rather than a bare pronoun the
435
+ * player has no antecedent for yet. `fallback` stands in when the list holds
436
+ * no object command (a room with only exits, or a just-emptied object dock).
437
+ * Pure and `.toString()`-splice-safe — the page splices it in and drives both
438
+ * input docks off it every redraw. */
439
+ export function groundedPlaceholder(actions, fallback) {
440
+ const objectActions = (actions || []).filter((a) => /^(?:examine|take|open|unlock|talk to) /.test(a));
441
+ if (!objectActions.length) return fallback;
442
+ return objectActions.slice(0, 2).join(", ") + "…";
443
+ }
444
+
431
445
  /** Edit mode's cursor-driven suggestion pills for one typed `term`: the
432
446
  * lateral SKOS neighbourhood (`relatedForTerm`'s own synonyms/related
433
447
  * concepts) plus the vertical rdfs:subClassOf ancestor chain
@@ -929,7 +943,7 @@ ${THEME_TOKENS_CSS}
929
943
  <div class="docklog" id="objDockLog" aria-live="polite"></div>
930
944
  <form class="chatask" id="objForm">
931
945
  <span class="prompt mono">tmct&gt;</span>
932
- <input id="objInput" type="text" placeholder="examine it, take it&hellip;" aria-label="Type a command for this object">
946
+ <input id="objInput" type="text" placeholder="examine lamp, take key&hellip;" aria-label="Type a command for this object">
933
947
  </form>
934
948
  </div>
935
949
  </div>
@@ -975,6 +989,7 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
975
989
  const carriedItems = ${carriedItems.toString()};
976
990
  const visitedRoomGraph = ${visitedRoomGraph.toString()};
977
991
  const allRoomIds = ${allRoomIds.toString()};
992
+ const groundedPlaceholder = ${groundedPlaceholder.toString()};
978
993
  const spriteAncestryRows = ${spriteAncestryRows.toString()};
979
994
  const factsForSubject = ${factsForSubject.toString()};
980
995
  const renderWorldEditorText = ${renderWorldEditorText.toString()};
@@ -1260,6 +1275,9 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
1260
1275
  if (!lastSnapshot || !objLightboxSubject) { objPillsEl.innerHTML = ""; return; }
1261
1276
  const actions = objectPillsFor(lastSnapshot.rows, lastSnapshot.state, lastSnapshot.here, objLightboxSubject);
1262
1277
  objPillsEl.innerHTML = actions.map((a) => '<button type="button" class="pill">' + esc(a) + "</button>").join("");
1278
+ // Grounded to the open object itself ("take lamp…"), so the dock teaches
1279
+ // the noun form even though a bare pronoun now binds here too.
1280
+ objInputEl.placeholder = groundedPlaceholder(actions, "examine " + objLightboxSubject);
1263
1281
  }
1264
1282
  // A dock turn: paused, echoed into the main transcript AND the dock, run on
1265
1283
  // the one session, then the main page and the lightbox both redraw off the
@@ -1369,6 +1387,10 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
1369
1387
  function renderPills(rows, state, here) {
1370
1388
  const actions = pillsFor(rows, state, here);
1371
1389
  pillsEl.innerHTML = actions.map((a) => '<button type="button" class="pill">' + esc(a) + "</button>").join("");
1390
+ // The empty input teaches the grounded noun form off the same affordance
1391
+ // list the pills read — real props from THIS room, so a first-time player
1392
+ // types "examine lamp", not a pronoun with nothing to bind to yet.
1393
+ chatqEl.placeholder = groundedPlaceholder(actions, "go north");
1372
1394
  }
1373
1395
  pillsEl.addEventListener("click", (e) => {
1374
1396
  const btn = e.target.closest(".pill");