@polycode-projects/the-mechanical-code-talker 2.11.11 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md 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 is not an indexer, so it consumes a graph through a typed contract any
1154
- producer can implement. That contract is first-class: a **versioned (1.1.0),
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 keeps no codebase index of its own.
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; no codebase index of its own.
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
- // PLAN_AGENTS.md Phase 1's "wider general-knowledge seed set" bullet: the
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": "2.11.11",
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; no codebase index of its own.",
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
  },
@@ -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 PROVIDER SEAM (docs/adapter-contract.md):
7
- // any graph producer can feed tmct either by writing the entities-payload JSON
8
- // where config.graphFile points, or by registering a custom loader with
9
- // registerProvider() no indexer is ever imported here. tmct only READS
10
- // through this seam; its own writes go to .tmct/memory/ (src/memory/), never
11
- // back into a provider's artifact.
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";
@@ -129,8 +129,9 @@ export async function normalizeConfig(raw, { configDir } = {}) {
129
129
 
130
130
  // Research-lane knobs (src/services/research.mjs): sparse PASS-THROUGH,
131
131
  // same discipline as [games.*] — the raw `[research]` table
132
- // (fanout_limit / depth_limit / min_interval_ms, snake_case) rides through
133
- // unmodified; clamping and default-filling is resolveResearchConfig's job.
132
+ // (fanout_limit / max_depth / max_topics / min_interval_ms, snake_case)
133
+ // rides through unmodified; clamping and default-filling is
134
+ // resolveResearchConfig's job.
134
135
  if (src.research !== undefined) cfg.research = src.research;
135
136
 
136
137
  const idx = src.index || {};
@@ -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",
@@ -528,6 +528,15 @@ export function parseAce(sentence, lexicon = loadLexicon()) {
528
528
  const IMPERATIVE_VERBS = new Set(["go", "take", "drop", "open", "unlock", "close", "give", "look", "talk", "examine"]);
529
529
  const IMPERATIVE_DIRECTIONS = new Set(["north", "south", "east", "west", "up", "down"]);
530
530
 
531
+ // The object pronouns an imperative object slot may carry ("examine it", "take
532
+ // them", "talk to him"). This parser only MARKS such a slot with the bare
533
+ // pronoun as its term — the antecedent lives in the running world, not the
534
+ // sentence, so binding it to a concrete object is the adventure lane's job (it
535
+ // alone holds the session's FOCUS). Kept out of resolveNP's lexicon gate on
536
+ // purpose: a pronoun is never a declared noun, so without this it rides out as
537
+ // residue and mis-declines as an unknown word.
538
+ export const OBJECT_PRONOUNS = new Set(["it", "them", "him", "her"]);
539
+
531
540
  const VERB_SYNONYMS = new Map([
532
541
  ["pick up", "take"], ["pick", "take"], ["grab", "take"],
533
542
  ["put down", "drop"], ["set down", "drop"], ["leave", "drop"],
@@ -603,8 +612,14 @@ function resolveImperativeVerb(toks) {
603
612
  return { ...retried, corrected: { from: first, to: fixedFirst } };
604
613
  }
605
614
 
606
- /** Resolve one imperative object phrase to its bare lexicon term. */
615
+ /** Resolve one imperative object phrase to its bare lexicon term. A lone
616
+ * object pronoun ("it", "them", "him", "her") rides through as its own term
617
+ * for the lane to bind against the session focus — never a lexicon lookup,
618
+ * never residue. */
607
619
  function imperativeNP(lexicon, tokens) {
620
+ if (tokens.length === 1 && OBJECT_PRONOUNS.has(tokens[0].toLowerCase())) {
621
+ return { term: tokens[0].toLowerCase(), unknown: [] };
622
+ }
608
623
  const np = resolveNP(lexicon, tokens);
609
624
  if (np.term == null) return { term: null, unknown: np.unknown };
610
625
  return { term: local(lexicon, np.term), unknown: [] };
@@ -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 };