@polycode-projects/the-mechanical-code-talker 2.11.11 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -2
- package/bin/tmct.mjs +33 -2
- package/corpus/tier2/generate.mjs +1 -1
- package/package.json +3 -2
- package/src/adapters/source.mjs +8 -6
- package/src/adapters/toml-config.mjs +3 -2
- package/src/domain/cli-verbs.mjs +9 -0
- package/src/domain/grammar/ace.mjs +16 -1
- package/src/index/extract-jsts.mjs +251 -0
- package/src/index/extract-python.mjs +46 -0
- package/src/index/extract_ast.py +364 -0
- package/src/index/index-repo.mjs +173 -0
- package/src/index/registry.mjs +37 -0
- package/src/index/spawn.mjs +35 -0
- package/src/index/walk.mjs +0 -0
- package/src/services/adventure-viz.mjs +23 -1
- package/src/services/adventure.mjs +69 -3
- package/src/services/chat-session.mjs +3 -3
- package/src/services/chat.mjs +12 -10
- package/src/services/research-viz.mjs +34 -6
- package/src/services/research.mjs +154 -44
- package/src/surfaces/web/memory-ask-browser.bundle.js +72 -72
- package/src/surfaces/web/research-browser-entry.mjs +12 -1
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";
|
|
@@ -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 /
|
|
133
|
-
// unmodified; clamping and default-filling is
|
|
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 || {};
|
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",
|
|
@@ -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 };
|