@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.
- package/README.md +4 -2
- package/bin/tmct.mjs +33 -2
- package/corpus/tier2/generate.mjs +1 -1
- package/package.json +3 -2
- package/src/adapters/source.mjs +8 -6
- package/src/adapters/toml-config.mjs +3 -2
- package/src/domain/cli-verbs.mjs +9 -0
- package/src/domain/grammar/ace.mjs +16 -1
- package/src/index/extract-jsts.mjs +251 -0
- package/src/index/extract-python.mjs +46 -0
- package/src/index/extract_ast.py +364 -0
- package/src/index/index-repo.mjs +173 -0
- package/src/index/registry.mjs +37 -0
- package/src/index/spawn.mjs +35 -0
- package/src/index/walk.mjs +0 -0
- package/src/services/adventure-viz.mjs +23 -1
- package/src/services/adventure.mjs +69 -3
- package/src/services/chat-session.mjs +3 -3
- package/src/services/chat.mjs +12 -10
- package/src/services/research-viz.mjs +34 -6
- package/src/services/research.mjs +154 -44
- package/src/surfaces/web/memory-ask-browser.bundle.js +72 -72
- package/src/surfaces/web/research-browser-entry.mjs +12 -1
|
@@ -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></span>
|
|
932
|
-
<input id="objInput" type="text" placeholder="examine
|
|
946
|
+
<input id="objInput" type="text" placeholder="examine lamp, take key…" 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");
|