@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.
- package/README.md +4 -2
- package/bin/tmct.mjs +62 -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 +8 -0
- package/src/domain/cli-verbs.mjs +9 -0
- package/src/domain/codeplan/graph-delta.mjs +239 -0
- package/src/domain/codeplan/graph-predicates.mjs +0 -0
- package/src/domain/codeplan/operators.mjs +232 -0
- package/src/domain/codeplan/planner.mjs +87 -0
- 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/chat-session.mjs +10 -3
- package/src/services/chat.mjs +5 -5
- package/src/services/sessions.mjs +3 -1
- package/src/surfaces/web/memory-ask-browser.bundle.js +89 -89
|
@@ -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
|
|
336
|
-
//
|
|
337
|
-
...(noCodeGraph ? [`for code structure, point me at a .tmct/graph.json with --repo <path> or try \`npm run example:mini\`
|
|
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
|
];
|
package/src/services/chat.mjs
CHANGED
|
@@ -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
|
|
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`.
|
|
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 —
|
|
13013
|
-
note(trace, "intermediate: HONEST-EMPTY POLISH — the loaded graph has 0 modules, so the dead-end got a
|
|
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 —
|