@polycode-projects/the-mechanical-code-talker 2.11.12 → 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/domain/cli-verbs.mjs +9 -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 +3 -3
- package/src/services/chat.mjs +5 -5
package/README.md
CHANGED
|
@@ -1150,8 +1150,10 @@ node bin/tmct.mjs cli tmct_untested '{"repo_path":"examples/mini-webapp"}'
|
|
|
1150
1150
|
|
|
1151
1151
|
## The repository interface
|
|
1152
1152
|
|
|
1153
|
-
tmct
|
|
1154
|
-
|
|
1153
|
+
tmct consumes a graph through a typed contract any producer can implement —
|
|
1154
|
+
including its own `tmct index` command, which walks a repo's source and writes
|
|
1155
|
+
the graph, and any external producer (seonix, a CI indexer, a hand-written JSON
|
|
1156
|
+
file) feeding the same seam. That contract is first-class: a **versioned (1.1.0),
|
|
1155
1157
|
OWL-grounded, machine-readable service definition** (`docs/repository-interface.md`
|
|
1156
1158
|
plus a JSON schema) of every service, its arguments, result types, and error
|
|
1157
1159
|
contract. The interface returns a miss as a normal value. It never throws to
|
package/bin/tmct.mjs
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
// tmct — The Mechanical Code Talker. The headline entry is CHAT: a bare
|
|
3
3
|
// invocation drops you into a tolerant, offline, $0 prompt that guides you
|
|
4
4
|
// toward precision queries about a repository (ELIZA/PARRY-style, but obsessed
|
|
5
|
-
// with software). No model calls; tmct
|
|
5
|
+
// with software). No model calls; tmct indexes a repo on request (tmct index)
|
|
6
|
+
// or reads a graph any other producer wrote.
|
|
6
7
|
//
|
|
7
8
|
// tmct → interactive chat (the headline)
|
|
8
9
|
// tmct chat [--repo <abs>] [--plain] → same, explicit
|
|
@@ -49,7 +50,7 @@ process.on("warning", (warning) => {
|
|
|
49
50
|
const HELP = `tmct — The Mechanical Code Talker
|
|
50
51
|
|
|
51
52
|
A tolerant, offline, $0 chat that guides you toward precision queries about a
|
|
52
|
-
software repository. No model calls;
|
|
53
|
+
software repository. No model calls; index a repo with \`tmct index\`, or read any producer's graph.
|
|
53
54
|
|
|
54
55
|
Usage:
|
|
55
56
|
${renderUsage()}
|
|
@@ -986,6 +987,36 @@ async function main() {
|
|
|
986
987
|
return;
|
|
987
988
|
}
|
|
988
989
|
|
|
990
|
+
if (mode === "index") {
|
|
991
|
+
// `tmct index` — the code-graph PRODUCER. Walks a repo's own source, parses
|
|
992
|
+
// it (JS/TS today, via the TypeScript compiler API), reads git history, and
|
|
993
|
+
// writes <repo>/.tmct/graph.json — the same artifact chat/serve/cli read
|
|
994
|
+
// through the provider seam. This is tmct producing a graph for the first
|
|
995
|
+
// time; the seam that consumes one is unchanged.
|
|
996
|
+
const rest = process.argv.slice(3);
|
|
997
|
+
const { strFlag } = await import("../src/services/cli-args.mjs");
|
|
998
|
+
const { resolve: resolvePath } = await import("node:path");
|
|
999
|
+
const { indexRepository } = await import("../src/index/index-repo.mjs");
|
|
1000
|
+
const repoFlag = strFlag(rest, ["--repo"]);
|
|
1001
|
+
const repoRoot = repoFlag ? resolvePath(process.cwd(), repoFlag) : process.cwd();
|
|
1002
|
+
const noHistory = rest.includes("--no-history");
|
|
1003
|
+
const stats = await indexRepository(repoRoot, noHistory ? { historyDepth: 0 } : {});
|
|
1004
|
+
for (const { pass, message } of stats.gitErrors || []) {
|
|
1005
|
+
process.stderr.write(`tmct index: WARNING git history pass '${pass}' — ${message} (graph built without those edges)\n`);
|
|
1006
|
+
}
|
|
1007
|
+
const perLang = Object.entries(stats.perLang)
|
|
1008
|
+
.map(([lang, s]) => `${lang}: ${s.modules} modules, ${s.symbols} symbols`).join("; ");
|
|
1009
|
+
const kib = (stats.bytes / 1024).toFixed(1);
|
|
1010
|
+
process.stdout.write(
|
|
1011
|
+
`tmct index — wrote ${stats.graphFile} (${stats.modules} modules, ${stats.symbols} symbols; ${kib} KiB)\n`
|
|
1012
|
+
+ (perLang ? `${perLang}\n` : "no supported source found under the repo\n"),
|
|
1013
|
+
);
|
|
1014
|
+
if (stats.failures?.length) {
|
|
1015
|
+
process.stderr.write(`tmct index: ${stats.failures.length} file(s) failed to parse (skipped): ${stats.failures.slice(0, 5).join(", ")}${stats.failures.length > 5 ? ", …" : ""}\n`);
|
|
1016
|
+
}
|
|
1017
|
+
return;
|
|
1018
|
+
}
|
|
1019
|
+
|
|
989
1020
|
if (mode === "import") {
|
|
990
1021
|
// `tmct import` — activate+seed into an ALREADY-initialized repo, reusing
|
|
991
1022
|
// the SAME resolvePluggableInput/activatePluggableInput seam `init`'s own
|
|
@@ -173,7 +173,7 @@ export const CORPUSES = {
|
|
|
173
173
|
],
|
|
174
174
|
},
|
|
175
175
|
|
|
176
|
-
//
|
|
176
|
+
// The wider general-knowledge seed set: the
|
|
177
177
|
// three corpuses above are all code-domain-specific (a LANGUAGE or a cloud
|
|
178
178
|
// DOMAIN); this one deliberately is NOT — everyday-knowledge concepts (the
|
|
179
179
|
// natural world, weather, food, common objects) with zero code-domain
|
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@polycode-projects/the-mechanical-code-talker",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
|
-
"description": "The Mechanical Code Talker (tmct) — a tolerant, offline, $0 chat surface that guides you toward precision queries about a software repository. ELIZA/PARRY-style but domain-obsessed with code. No model calls;
|
|
6
|
+
"description": "The Mechanical Code Talker (tmct) — a tolerant, offline, $0 chat surface that guides you toward precision queries about a software repository. ELIZA/PARRY-style but domain-obsessed with code. No model calls; indexes a repo on request (tmct index) or reads any producer's graph.",
|
|
7
7
|
"keywords": [
|
|
8
8
|
"chatbot",
|
|
9
9
|
"no-llm",
|
|
@@ -78,6 +78,7 @@
|
|
|
78
78
|
"ink": "^7.1.0",
|
|
79
79
|
"react": "^19.2.7",
|
|
80
80
|
"smol-toml": "^1.7.0",
|
|
81
|
+
"typescript": "~5.6.2",
|
|
81
82
|
"wink-eng-lite-web-model": "^1.8.1",
|
|
82
83
|
"wink-nlp": "^2.4.0"
|
|
83
84
|
},
|
package/src/adapters/source.mjs
CHANGED
|
@@ -3,12 +3,14 @@
|
|
|
3
3
|
// stub it); in production it reads the JSON artifact the deterministic indexer
|
|
4
4
|
// wrote to config.graphFile. No network, no model calls.
|
|
5
5
|
//
|
|
6
|
-
// This module is the
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
// registerProvider()
|
|
10
|
-
//
|
|
11
|
-
//
|
|
6
|
+
// This module is the READ SEAM (docs/adapter-contract.md): any graph producer
|
|
7
|
+
// can feed tmct either by writing the entities-payload JSON where
|
|
8
|
+
// config.graphFile points, or by registering a custom loader with
|
|
9
|
+
// registerProvider(). The PRODUCER lives elsewhere — tmct's own `tmct index`
|
|
10
|
+
// (src/index/) is one such producer, and it writes through that same file path,
|
|
11
|
+
// not through this module. Keeping the reader and the producer in separate module
|
|
12
|
+
// trees is deliberate: this module only READS, and tmct's own writes go to
|
|
13
|
+
// .tmct/memory/ (src/memory/), never back into a provider's graph artifact.
|
|
12
14
|
|
|
13
15
|
import { readFile } from "node:fs/promises";
|
|
14
16
|
import { ToolError } from "./config.mjs";
|
package/src/domain/cli-verbs.mjs
CHANGED
|
@@ -68,6 +68,15 @@ export const CLI_VERBS = [
|
|
|
68
68
|
{ flag: "[--memory-backend <default|memory|sqlite>]", prose: ["write tmct.toml's [memory] backend", "(same flag name as `tmct chat`) — a later `tmct chat`", "in this repo picks it up with no flag needed"] },
|
|
69
69
|
],
|
|
70
70
|
},
|
|
71
|
+
{
|
|
72
|
+
mode: "index",
|
|
73
|
+
errorLabel: "index",
|
|
74
|
+
usage: "tmct index [--repo <abs>]",
|
|
75
|
+
prose: ["produce a code graph from a repo's OWN source (default: cwd):"],
|
|
76
|
+
flags: [
|
|
77
|
+
{ flag: "[--no-history]", prose: ["walk the tree, parse JS/TS with the TypeScript compiler", "API, read git history, and write <repo>/.tmct/graph.json —", "the artifact chat/serve/cli then read. --no-history skips", "the git passes (no commit/touches/cochange edges)"] },
|
|
78
|
+
],
|
|
79
|
+
},
|
|
71
80
|
{
|
|
72
81
|
mode: "import",
|
|
73
82
|
errorLabel: "import",
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
// JS/TS extractor — TypeScript compiler API (direct `typescript` dep). Uses
|
|
2
|
+
// ts.createSourceFile per file (no Program / no type-checker): the fast,
|
|
3
|
+
// deterministic, offline structural pass. Covers .ts/.tsx/.js/.jsx/.mjs/.cjs.
|
|
4
|
+
// Emits the `{path,dotted,imports,defines,calls,exports}` contract
|
|
5
|
+
// graph-build.mjs's buildEntities() consumes.
|
|
6
|
+
//
|
|
7
|
+
// Both module systems are read: ESM import/export declarations AND a CommonJS
|
|
8
|
+
// pass (top-level CJS require calls with literal specifiers → imports;
|
|
9
|
+
// module.exports / exports.name assignments → exports) — CJS-only repos were
|
|
10
|
+
// producing edge-empty graphs before the CJS pass existed.
|
|
11
|
+
//
|
|
12
|
+
// Fidelity note: params/returns are ANNOTATION strings (Group-A mechanical), not
|
|
13
|
+
// resolved types — the same honesty the Python path keeps ("returns = annotation
|
|
14
|
+
// only"). A type-RESOLVED pass (real Program + checker) is a research horizon,
|
|
15
|
+
// not run here.
|
|
16
|
+
import { readFile } from "node:fs/promises";
|
|
17
|
+
import { dirname, join } from "node:path";
|
|
18
|
+
import ts from "typescript";
|
|
19
|
+
import { walk, relPath } from "./walk.mjs";
|
|
20
|
+
|
|
21
|
+
const EXTS = [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"];
|
|
22
|
+
const stripExt = (p) => p.replace(/\.(tsx?|jsx?|mjs|cjs)$/i, "");
|
|
23
|
+
|
|
24
|
+
function scriptKind(path) {
|
|
25
|
+
if (/\.tsx$/i.test(path)) return ts.ScriptKind.TSX;
|
|
26
|
+
if (/\.ts$/i.test(path)) return ts.ScriptKind.TS;
|
|
27
|
+
if (/\.jsx$/i.test(path)) return ts.ScriptKind.JSX;
|
|
28
|
+
return ts.ScriptKind.JS;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const lineOf = (sf, node) => sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1;
|
|
32
|
+
const endLineOf = (sf, node) => sf.getLineAndCharacterOfPosition(node.getEnd()).line + 1;
|
|
33
|
+
|
|
34
|
+
/** "(a: string, b = 3)" → "a: string, b = 3" (annotation strings; not resolved). */
|
|
35
|
+
function paramsText(sf, node) {
|
|
36
|
+
const ps = node.parameters || [];
|
|
37
|
+
return ps.map((p) => p.getText(sf).replace(/\s+/g, " ").trim()).join(", ").slice(0, 160);
|
|
38
|
+
}
|
|
39
|
+
function returnText(sf, node) {
|
|
40
|
+
return node.type ? node.type.getText(sf).replace(/\s+/g, " ").trim().slice(0, 80) : "";
|
|
41
|
+
}
|
|
42
|
+
function firstDocLine(sf, node) {
|
|
43
|
+
const ranges = ts.getLeadingCommentRanges(sf.text, node.getFullStart()) || [];
|
|
44
|
+
for (const r of ranges) {
|
|
45
|
+
const raw = sf.text.slice(r.pos, r.end);
|
|
46
|
+
const m = raw.replace(/^\/\*\*?|\*\/$/g, "").split("\n").map((l) => l.replace(/^\s*\*?\s?/, "").trim()).find(Boolean);
|
|
47
|
+
if (m) return m.slice(0, 120);
|
|
48
|
+
}
|
|
49
|
+
return "";
|
|
50
|
+
}
|
|
51
|
+
const hasMod = (node, kind) => (node.modifiers || []).some((m) => m.kind === kind);
|
|
52
|
+
function visibilityOf(node) {
|
|
53
|
+
if (hasMod(node, ts.SyntaxKind.PrivateKeyword)) return "private";
|
|
54
|
+
if (hasMod(node, ts.SyntaxKind.ProtectedKeyword)) return "protected";
|
|
55
|
+
return "";
|
|
56
|
+
}
|
|
57
|
+
function decoratorsOf(sf, node) {
|
|
58
|
+
const ds = ts.canHaveDecorators?.(node) ? ts.getDecorators?.(node) : null;
|
|
59
|
+
return (ds || []).map((d) => d.expression.getText(sf).replace(/\s+/g, " ").slice(0, 80));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Collect callee names within a node body (coarse; unparsed call target). */
|
|
63
|
+
function callsIn(sf, node) {
|
|
64
|
+
const out = new Set();
|
|
65
|
+
const visit = (n) => {
|
|
66
|
+
if (ts.isCallExpression(n)) {
|
|
67
|
+
const t = n.expression;
|
|
68
|
+
let name = "";
|
|
69
|
+
if (ts.isIdentifier(t)) name = t.text;
|
|
70
|
+
else if (ts.isPropertyAccessExpression(t)) name = t.getText(sf);
|
|
71
|
+
if (name) out.add(name.slice(0, 80));
|
|
72
|
+
}
|
|
73
|
+
ts.forEachChild(n, visit);
|
|
74
|
+
};
|
|
75
|
+
ts.forEachChild(node, visit);
|
|
76
|
+
return [...out].sort();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function extractFile(absPath, root) {
|
|
80
|
+
const text = await readFile(absPath, "utf8").catch(() => null);
|
|
81
|
+
if (text == null) return { failed: true, path: relPath(root, absPath) };
|
|
82
|
+
const path = relPath(root, absPath);
|
|
83
|
+
let sf;
|
|
84
|
+
try {
|
|
85
|
+
sf = ts.createSourceFile(path, text, ts.ScriptTarget.Latest, true, scriptKind(path));
|
|
86
|
+
} catch {
|
|
87
|
+
return { failed: true, path };
|
|
88
|
+
}
|
|
89
|
+
const dir = dirname(path);
|
|
90
|
+
const imports = new Set();
|
|
91
|
+
const calls = new Set();
|
|
92
|
+
const defines = [];
|
|
93
|
+
const exports = new Set();
|
|
94
|
+
|
|
95
|
+
const resolveSpecifier = (spec) => {
|
|
96
|
+
if (!spec || !spec.startsWith(".")) { if (spec) imports.add(spec); return; } // bare/external
|
|
97
|
+
const joined = join(dir, spec).split(/[\\/]/).join("/");
|
|
98
|
+
const base = stripExt(joined);
|
|
99
|
+
imports.add(base);
|
|
100
|
+
imports.add(`${base}/index`); // dir-import fallback (./foo → ./foo/index)
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
// CommonJS pass: CJS require / module.exports repos were leaving graphs
|
|
104
|
+
// edge-empty. Top-level statements only, STRICTLY literal arguments — a
|
|
105
|
+
// computed specifier is skipped, never guessed (the house "no wrong edge" rule).
|
|
106
|
+
/** A CJS require call with a single literal arg → its specifier, else null. */
|
|
107
|
+
const requireSpecifier = (node) =>
|
|
108
|
+
node && ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === "require" &&
|
|
109
|
+
node.arguments.length === 1 && ts.isStringLiteralLike(node.arguments[0])
|
|
110
|
+
? node.arguments[0].text
|
|
111
|
+
: null;
|
|
112
|
+
/** Peel a `.member` access / parens down to the require call (never enters functions). */
|
|
113
|
+
const requireIn = (expr) => {
|
|
114
|
+
let e = expr;
|
|
115
|
+
while (e && (ts.isPropertyAccessExpression(e) || ts.isParenthesizedExpression(e) || ts.isNonNullExpression?.(e))) e = e.expression;
|
|
116
|
+
return requireSpecifier(e);
|
|
117
|
+
};
|
|
118
|
+
const isModuleExports = (e) =>
|
|
119
|
+
ts.isPropertyAccessExpression(e) && ts.isIdentifier(e.expression) && e.expression.text === "module" && e.name.text === "exports";
|
|
120
|
+
const isExportsRef = (e) => isModuleExports(e) || (ts.isIdentifier(e) && e.text === "exports");
|
|
121
|
+
|
|
122
|
+
const addFn = (name, node, kind, extra = {}) => {
|
|
123
|
+
defines.push({
|
|
124
|
+
name, kind, lineno: lineOf(sf, node), end_lineno: endLineOf(sf, node),
|
|
125
|
+
decorators: decoratorsOf(sf, node),
|
|
126
|
+
params: paramsText(sf, node), returns: returnText(sf, node),
|
|
127
|
+
calls: callsIn(sf, node), doc: firstDocLine(sf, node),
|
|
128
|
+
...(visibilityOf(node) ? { visibility: visibilityOf(node) } : {}),
|
|
129
|
+
...extra,
|
|
130
|
+
});
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
for (const stmt of sf.statements) {
|
|
134
|
+
// imports / re-exports
|
|
135
|
+
if (ts.isImportDeclaration(stmt) && stmt.moduleSpecifier && ts.isStringLiteral(stmt.moduleSpecifier)) {
|
|
136
|
+
resolveSpecifier(stmt.moduleSpecifier.text);
|
|
137
|
+
} else if (ts.isExportDeclaration(stmt) && stmt.moduleSpecifier && ts.isStringLiteral(stmt.moduleSpecifier)) {
|
|
138
|
+
resolveSpecifier(stmt.moduleSpecifier.text);
|
|
139
|
+
}
|
|
140
|
+
const exported = hasMod(stmt, ts.SyntaxKind.ExportKeyword);
|
|
141
|
+
|
|
142
|
+
if (ts.isFunctionDeclaration(stmt) && stmt.name) {
|
|
143
|
+
addFn(stmt.name.text, stmt, "function");
|
|
144
|
+
if (exported) exports.add(stmt.name.text);
|
|
145
|
+
} else if (ts.isClassDeclaration(stmt) && stmt.name) {
|
|
146
|
+
const cname = stmt.name.text;
|
|
147
|
+
const bases = [];
|
|
148
|
+
for (const h of stmt.heritageClauses || []) for (const t of h.types) bases.push(t.getText(sf).slice(0, 80));
|
|
149
|
+
defines.push({
|
|
150
|
+
name: cname, kind: "class", lineno: lineOf(sf, stmt), end_lineno: endLineOf(sf, stmt),
|
|
151
|
+
bases, decorators: decoratorsOf(sf, stmt), doc: firstDocLine(sf, stmt),
|
|
152
|
+
});
|
|
153
|
+
if (exported) exports.add(cname);
|
|
154
|
+
for (const mem of stmt.members) {
|
|
155
|
+
if (ts.isMethodDeclaration(mem) || ts.isConstructorDeclaration(mem) ||
|
|
156
|
+
ts.isGetAccessor(mem) || ts.isSetAccessor(mem)) {
|
|
157
|
+
const mn = mem.name ? mem.name.getText(sf) : "constructor";
|
|
158
|
+
addFn(`${cname}.${mn}`, mem, "method");
|
|
159
|
+
} else if (ts.isPropertyDeclaration(mem) && mem.name) {
|
|
160
|
+
defines.push({
|
|
161
|
+
name: `${cname}.${mem.name.getText(sf)}`, kind: "attribute",
|
|
162
|
+
lineno: lineOf(sf, mem), end_lineno: endLineOf(sf, mem), decorators: decoratorsOf(sf, mem),
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
} else if (ts.isInterfaceDeclaration(stmt) && stmt.name) {
|
|
167
|
+
// interfaces map to Class nodes (type surface) — useful for TS-heavy repos
|
|
168
|
+
defines.push({
|
|
169
|
+
name: stmt.name.text, kind: "class", subkind: "interface",
|
|
170
|
+
lineno: lineOf(sf, stmt), end_lineno: endLineOf(sf, stmt),
|
|
171
|
+
bases: (stmt.heritageClauses || []).flatMap((h) => h.types.map((t) => t.getText(sf).slice(0, 80))),
|
|
172
|
+
decorators: [], doc: firstDocLine(sf, stmt),
|
|
173
|
+
});
|
|
174
|
+
if (exported) exports.add(stmt.name.text);
|
|
175
|
+
} else if (ts.isVariableStatement(stmt)) {
|
|
176
|
+
const isConst = (stmt.declarationList.flags & ts.NodeFlags.Const) !== 0;
|
|
177
|
+
for (const d of stmt.declarationList.declarations) {
|
|
178
|
+
// CJS import: const X = <require 'spec'> / const {a} = <require 'spec'> /
|
|
179
|
+
// const Y = <require 'spec'>.member — BEFORE the identifier-only gate so a
|
|
180
|
+
// destructuring binding still records the import edge.
|
|
181
|
+
const reqSpec = requireIn(d.initializer);
|
|
182
|
+
if (reqSpec != null) resolveSpecifier(reqSpec);
|
|
183
|
+
if (!ts.isIdentifier(d.name)) continue;
|
|
184
|
+
const vn = d.name.text;
|
|
185
|
+
const init = d.initializer;
|
|
186
|
+
// arrow/function assigned to a const → treat as a function define
|
|
187
|
+
if (init && (ts.isArrowFunction(init) || ts.isFunctionExpression(init))) {
|
|
188
|
+
addFn(vn, init, "function");
|
|
189
|
+
} else if (isConst && /^[A-Z0-9_]+$/.test(vn)) {
|
|
190
|
+
defines.push({ name: vn, kind: "global", lineno: lineOf(sf, stmt), end_lineno: endLineOf(sf, stmt),
|
|
191
|
+
decorators: [], value: (init ? init.getText(sf) : "").replace(/\s+/g, " ").slice(0, 80), is_constant: true });
|
|
192
|
+
} else if (init && ts.isCallExpression(init)) {
|
|
193
|
+
// live-object global (e.g. const router = Router()) — a registration anchor
|
|
194
|
+
defines.push({ name: vn, kind: "global", lineno: lineOf(sf, stmt), end_lineno: endLineOf(sf, stmt),
|
|
195
|
+
decorators: [], value: init.getText(sf).replace(/\s+/g, " ").slice(0, 80) });
|
|
196
|
+
}
|
|
197
|
+
if (exported) exports.add(vn);
|
|
198
|
+
}
|
|
199
|
+
} else if (ts.isExportAssignment?.(stmt)) {
|
|
200
|
+
exports.add("default");
|
|
201
|
+
} else if (ts.isExpressionStatement(stmt)) {
|
|
202
|
+
// CJS exports + side-effect/re-export requires. Walk `=` chains so
|
|
203
|
+
// `exports = module.exports = X` records one default export, and
|
|
204
|
+
// `module.exports = <require './lib'>` records both the export and the import.
|
|
205
|
+
let expr = stmt.expression;
|
|
206
|
+
while (ts.isBinaryExpression(expr) && expr.operatorToken.kind === ts.SyntaxKind.EqualsToken) {
|
|
207
|
+
const lhs = expr.left;
|
|
208
|
+
if (isExportsRef(lhs)) exports.add("default");
|
|
209
|
+
else if (ts.isPropertyAccessExpression(lhs) && isExportsRef(lhs.expression)) {
|
|
210
|
+
exports.add(lhs.name.text);
|
|
211
|
+
}
|
|
212
|
+
expr = expr.right;
|
|
213
|
+
}
|
|
214
|
+
const reqSpec = requireIn(expr); // covers a bare side-effect require too
|
|
215
|
+
if (reqSpec != null) resolveSpecifier(reqSpec);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// module-level coarse calls (whole file)
|
|
220
|
+
const visit = (n) => {
|
|
221
|
+
if (ts.isCallExpression(n)) {
|
|
222
|
+
const t = n.expression;
|
|
223
|
+
let name = "";
|
|
224
|
+
if (ts.isIdentifier(t)) name = t.text;
|
|
225
|
+
else if (ts.isPropertyAccessExpression(t)) name = t.getText(sf);
|
|
226
|
+
if (name) calls.add(name.slice(0, 80));
|
|
227
|
+
}
|
|
228
|
+
ts.forEachChild(n, visit);
|
|
229
|
+
};
|
|
230
|
+
ts.forEachChild(sf, visit);
|
|
231
|
+
|
|
232
|
+
return {
|
|
233
|
+
path, dotted: stripExt(path),
|
|
234
|
+
imports: [...imports].sort(), defines, calls: [...calls].sort(), exports: [...exports].sort(),
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Ingest a whole tree → {modules, failures, fileCount} contract. */
|
|
239
|
+
export async function ingest(root, { ignore = null } = {}) {
|
|
240
|
+
const files = await walk(root, EXTS, ignore);
|
|
241
|
+
const modules = [];
|
|
242
|
+
const failures = [];
|
|
243
|
+
for (const f of files) {
|
|
244
|
+
const mod = await extractFile(f, root);
|
|
245
|
+
if (mod.failed) failures.push(mod.path);
|
|
246
|
+
else modules.push(mod);
|
|
247
|
+
}
|
|
248
|
+
return { modules, failures, fileCount: files.length };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export const meta = { id: "tsc", language: "js/ts", lib: "typescript compiler API", exts: EXTS };
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Python extractor — stdlib `ast`, zero npm dependency. Runs extract_ast.py as a
|
|
2
|
+
// subprocess over the whole repo (the script does its own deterministic walk and
|
|
3
|
+
// emits one JSON doc: {modules:[{path,dotted,imports,defines,calls,exports}]} —
|
|
4
|
+
// the same contract the in-process JS/TS extractor emits) and parses the result.
|
|
5
|
+
//
|
|
6
|
+
// Requires a `python3` interpreter at index time (TMCT_PYTHON overrides). This is
|
|
7
|
+
// a runtime tool, not an npm dependency — no parser library ships. When no .py
|
|
8
|
+
// files are present the subprocess never runs; when python3 is missing but .py
|
|
9
|
+
// files exist, the backend degrades: it skips them and reports the count as
|
|
10
|
+
// failures rather than crashing the whole index (JS/TS still produces its graph).
|
|
11
|
+
import { dirname, join } from "node:path";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
import { walk, relPath } from "./walk.mjs";
|
|
14
|
+
import { exec } from "./spawn.mjs";
|
|
15
|
+
|
|
16
|
+
const AST_SCRIPT = join(dirname(fileURLToPath(import.meta.url)), "extract_ast.py");
|
|
17
|
+
const EXTS = [".py"];
|
|
18
|
+
const PYTHON = () => process.env.TMCT_PYTHON || "python3";
|
|
19
|
+
|
|
20
|
+
/** Ingest every .py file under `root` → {modules, failures, fileCount} contract.
|
|
21
|
+
* `ignore` prunes the presence check; extract_ast.py applies its own SKIP_DIRS
|
|
22
|
+
* when it walks, so the module set follows the script's exclusions. */
|
|
23
|
+
export async function ingest(root, { ignore = null } = {}) {
|
|
24
|
+
const files = await walk(root, EXTS, ignore);
|
|
25
|
+
if (files.length === 0) return { modules: [], failures: [], fileCount: 0 };
|
|
26
|
+
|
|
27
|
+
const python = PYTHON();
|
|
28
|
+
const res = await exec(python, [AST_SCRIPT, root]);
|
|
29
|
+
if (res.code !== 0) {
|
|
30
|
+
const why = res.stderr.trim().split("\n").pop()?.slice(-200) || `exit ${res.code}`;
|
|
31
|
+
process.stderr.write(
|
|
32
|
+
`tmct index: python backend skipped ${files.length} .py file(s) — "${python}" unavailable or failed (${why})\n`,
|
|
33
|
+
);
|
|
34
|
+
return { modules: [], failures: files.map((f) => relPath(root, f)), fileCount: files.length };
|
|
35
|
+
}
|
|
36
|
+
let parsed;
|
|
37
|
+
try { parsed = JSON.parse(res.stdout); }
|
|
38
|
+
catch {
|
|
39
|
+
process.stderr.write(`tmct index: python backend produced non-JSON (is "${python}" a Python 3.9+ interpreter?)\n`);
|
|
40
|
+
return { modules: [], failures: files.map((f) => relPath(root, f)), fileCount: files.length };
|
|
41
|
+
}
|
|
42
|
+
const modules = Array.isArray(parsed?.modules) ? parsed.modules : [];
|
|
43
|
+
return { modules, failures: [], fileCount: files.length };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const meta = { id: "ast", language: "python", lib: "python stdlib ast", exts: EXTS };
|
|
@@ -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
|
|
@@ -332,9 +332,9 @@ export async function createSession({
|
|
|
332
332
|
// no code graph → point at how to GET one (a graph producer / --repo / the shipped
|
|
333
333
|
// example), and at what IS answerable now — `vocabHint` is only ever a term
|
|
334
334
|
// 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\`
|
|
335
|
+
// never a hardcoded example that might not have been seeded. tmct can index a
|
|
336
|
+
// repo itself (`tmct index`) or read a graph any other producer wrote.
|
|
337
|
+
...(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
338
|
"pass --repo <path> to target a different repo",
|
|
339
339
|
"ask a question, or /help for commands (/stats for an overview) — /exit to leave",
|
|
340
340
|
];
|
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 —
|