@polycode-projects/the-mechanical-code-talker 0.2.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/LICENSE +373 -0
- package/README.md +108 -0
- package/ROADMAP.md +209 -0
- package/bin/cli.mjs +226 -0
- package/bin/tmct.mjs +47 -0
- package/package.json +46 -0
- package/src/ask-nlp.mjs +73 -0
- package/src/ask-vocab.mjs +687 -0
- package/src/ask.mjs +2403 -0
- package/src/chat.mjs +642 -0
- package/src/codegraph.mjs +1972 -0
- package/src/config.mjs +27 -0
- package/src/embed.mjs +191 -0
- package/src/graph-build.mjs +428 -0
- package/src/index.mjs +25 -0
- package/src/prose-nlp.mjs +52 -0
- package/src/prose.mjs +187 -0
- package/src/schema-docs.mjs +254 -0
- package/src/server.mjs +393 -0
- package/src/sessions.mjs +220 -0
- package/src/source.mjs +54 -0
- package/src/telemetry.mjs +90 -0
- package/src/toml-config.mjs +183 -0
- package/src/uuid.mjs +16 -0
package/src/index.mjs
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// @polycode-projects/the-mechanical-code-talker (tmct) — library entry point.
|
|
2
|
+
//
|
|
3
|
+
// tmct began as a whole-package lift of an earlier chat surface (see README
|
|
4
|
+
// provenance). Internal module filenames and symbols were kept to preserve the
|
|
5
|
+
// shape and its green test suite; the branding throughout is now `tmct`.
|
|
6
|
+
//
|
|
7
|
+
// This entry re-exports the adapter primitives a library consumer needs. The
|
|
8
|
+
// clean chat/primitives split — pulling the movable grammar out of ask.mjs away
|
|
9
|
+
// from the core primitives it currently shares a file with — is deferred to
|
|
10
|
+
// ROADMAP.md.
|
|
11
|
+
|
|
12
|
+
// Chat surface (also reachable as the `./chat` subpath export).
|
|
13
|
+
export { runChat, COMMANDS, answerCount, renderStats } from "./chat.mjs";
|
|
14
|
+
|
|
15
|
+
// Grammar / NL-over-graph primitives.
|
|
16
|
+
export { ask, resolveObject } from "./ask.mjs";
|
|
17
|
+
|
|
18
|
+
// Graph traversal primitives.
|
|
19
|
+
export { relationKind, impactClosure } from "./codegraph.mjs";
|
|
20
|
+
|
|
21
|
+
// Tool dispatch (slash-commands and CLI tool calls route through here).
|
|
22
|
+
export { dispatchTool } from "./server.mjs";
|
|
23
|
+
|
|
24
|
+
// The single graph-load choke point — the adapter's data-provider seam.
|
|
25
|
+
export { fetchEntities } from "./source.mjs";
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// prose-nlp.mjs — the OPTIONAL wink-nlp lemma loader behind prose.mjs's LEMMA layer.
|
|
2
|
+
//
|
|
3
|
+
// Deliberately a SEPARATE loader from ask-nlp.mjs (same createRequire pattern, same
|
|
4
|
+
// deps) rather than an import of it: ask-nlp.mjs belongs to the ask-engine surface
|
|
5
|
+
// and is under active concurrent work — the prose pre-pass must not couple its
|
|
6
|
+
// index-build path to that file's export shape. The ~20 duplicated lines are the
|
|
7
|
+
// decoupling fee; the wink model itself is loaded lazily and at most once per
|
|
8
|
+
// process either way.
|
|
9
|
+
//
|
|
10
|
+
// BOUNDARY (same as ask-nlp.mjs, hard): Node-only, never inlined into the viewer
|
|
11
|
+
// bundle. prose.mjs is itself never inlined by viz.mjs's askSource(), so nothing
|
|
12
|
+
// browser-side can reach this module. wink-nlp + wink-eng-lite-web-model are CJS —
|
|
13
|
+
// loaded via createRequire, lazily, with failure cached as null: a checkout without
|
|
14
|
+
// the optional deps simply builds no lemma layer (honestly absent), it never throws.
|
|
15
|
+
//
|
|
16
|
+
// Determinism: wink's lemmatiser is a fixed trained model with no sampling — the
|
|
17
|
+
// same token always yields the same lemma across runs and processes, which is what
|
|
18
|
+
// lets the lemma layer meet the "byte-identical proseIndex across builds" contract.
|
|
19
|
+
|
|
20
|
+
import { createRequire } from "node:module";
|
|
21
|
+
|
|
22
|
+
let cached; // undefined = not tried yet; null = unavailable (tried once, honestly off)
|
|
23
|
+
|
|
24
|
+
/** Lazily build a `lemma(word) -> string` function, or null when wink isn't loadable.
|
|
25
|
+
* Results are memoized per token: the layer build lemmatises each unique vocabulary
|
|
26
|
+
* token once, not once per posting. */
|
|
27
|
+
export function proseLemma() {
|
|
28
|
+
if (cached !== undefined) return cached;
|
|
29
|
+
try {
|
|
30
|
+
const require = createRequire(import.meta.url);
|
|
31
|
+
const winkNLP = require("wink-nlp");
|
|
32
|
+
const model = require("wink-eng-lite-web-model");
|
|
33
|
+
const nlp = winkNLP(model);
|
|
34
|
+
const its = nlp.its;
|
|
35
|
+
const memo = new Map();
|
|
36
|
+
cached = (word) => {
|
|
37
|
+
const w = String(word || "");
|
|
38
|
+
if (memo.has(w)) return memo.get(w);
|
|
39
|
+
let out;
|
|
40
|
+
try {
|
|
41
|
+
out = String(nlp.readDoc(w).tokens().out(its.lemma)[0] || w).toLowerCase();
|
|
42
|
+
} catch {
|
|
43
|
+
out = w.toLowerCase();
|
|
44
|
+
}
|
|
45
|
+
memo.set(w, out);
|
|
46
|
+
return out;
|
|
47
|
+
};
|
|
48
|
+
} catch {
|
|
49
|
+
cached = null;
|
|
50
|
+
}
|
|
51
|
+
return cached;
|
|
52
|
+
}
|
package/src/prose.mjs
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// prose.mjs — the second-pass prose extraction + cross-reference index (PLAN_PROSE_INDEX.md).
|
|
2
|
+
//
|
|
3
|
+
// Deterministic, zero-model-calls, zero dependencies: pure string transforms + a plain-object
|
|
4
|
+
// inverted index over data graph-build.mjs's typed pass has already produced (identifier names,
|
|
5
|
+
// captured doc text). Two token sources, one combined set per individual:
|
|
6
|
+
// 1. identifier decomposition — names are often literal sentence fragments
|
|
7
|
+
// ("calculateTotalPriceIncludingTax" -> calculate/total/price/including/tax); splitting
|
|
8
|
+
// them turns every symbol/module name into free-text search surface for zero extra cost.
|
|
9
|
+
// 2. prose literals — docstrings/doc-comments already captured as the `doc` attribute.
|
|
10
|
+
// Python (extract_ast.py) and JS/TS (jsts_tsc.mjs's firstDocLine) already populate this;
|
|
11
|
+
// C#/Java's PRIMARY extractors (cs_roslyn.mjs, java_javaparser.mjs) shell out to compiled
|
|
12
|
+
// Roslyn/JavaParser binaries — adding doc capture there means modifying and rebuilding
|
|
13
|
+
// external .NET/JVM tooling, not a JS-side change. Deliberately deferred (PLAN_PROSE_INDEX.md
|
|
14
|
+
// backlog); this pass consumes whatever `doc` is already present, regardless of source
|
|
15
|
+
// language, so C#/Java modules still get identifier-decomposition tokens today.
|
|
16
|
+
//
|
|
17
|
+
// Tokenizer pattern (stopwords, length bounds, per-doc token cap) mirrors marginalia's
|
|
18
|
+
// app/lib/text-index.mjs — a proven lexical-inverted-index tokenizer for the same "search by
|
|
19
|
+
// keyword, no embeddings, no stemmer dependency" problem, adapted for code identifiers.
|
|
20
|
+
//
|
|
21
|
+
// Storage: (a) a `prose_tokens` attribute (space-joined, deduped, sorted) on each individual —
|
|
22
|
+
// the graph stays self-describing, works if a consumer only has one individual in hand; AND
|
|
23
|
+
// (b) a real inverted index (word -> [individual ids]) built from those same tokens and
|
|
24
|
+
// attached as `entities.proseIndex` — an O(1) word lookup for consumers (resolveObject-style
|
|
25
|
+
// fuzzy object-term resolution, scoreModules-style lexical boosting) instead of scanning every
|
|
26
|
+
// individual's attributes. Both are derived from the identical token set, so they can never
|
|
27
|
+
// disagree; (b) is just (a) inverted once, cheaply, at build time.
|
|
28
|
+
|
|
29
|
+
const STOPWORDS = new Set(
|
|
30
|
+
("a an and or but the of to in on at for with from by as is are was were be been being " +
|
|
31
|
+
"it its this that these those i you he she they we me my your our do does did not no " +
|
|
32
|
+
"yes if then else than so such can will would should could may might about into over " +
|
|
33
|
+
"under out up down off again more most some any all what which who whom whose when " +
|
|
34
|
+
"where why how").split(/\s+/),
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
const MAX_TOKEN_LEN = 40; // drops hash-like/garbage tokens (marginalia text-index.mjs)
|
|
38
|
+
const MAX_TOKENS_PER_DOC = 120; // bounds cost on a pathologically long docstring/name
|
|
39
|
+
|
|
40
|
+
/** Split an identifier or a path-like name into lowercase word tokens.
|
|
41
|
+
* Handles camelCase, PascalCase, snake_case, kebab-case, dotted names, path
|
|
42
|
+
* separators, and acronym runs ("HTTPSConnection" -> https/connection,
|
|
43
|
+
* "parseXML" -> parse/xml). Filters single-character tokens (loop-variable noise). */
|
|
44
|
+
export function splitIdentifierWords(raw) {
|
|
45
|
+
if (!raw) return [];
|
|
46
|
+
let s = String(raw).replace(/\.[A-Za-z0-9]+$/, ""); // strip a trailing file extension only
|
|
47
|
+
s = s
|
|
48
|
+
.replace(/[/\\]/g, " ") // path separators
|
|
49
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2") // camelCase / word|Digit boundary
|
|
50
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2") // acronym run -> TitleCase (HTTPSConnection)
|
|
51
|
+
.replace(/([A-Za-z])([0-9])/g, "$1 $2")
|
|
52
|
+
.replace(/([0-9])([A-Za-z])/g, "$1 $2")
|
|
53
|
+
.replace(/[_\-.]+/g, " ");
|
|
54
|
+
return s.split(/\s+/).map((w) => w.toLowerCase()).filter((w) => w.length > 1 && w.length <= MAX_TOKEN_LEN);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Tokenize free prose (a docstring/doc-comment) — lowercase words, punctuation stripped,
|
|
58
|
+
* common stopwords and single-/over-length tokens dropped, capped at MAX_TOKENS_PER_DOC. */
|
|
59
|
+
export function tokenizeProse(text) {
|
|
60
|
+
if (!text) return [];
|
|
61
|
+
const out = [];
|
|
62
|
+
const seen = new Set();
|
|
63
|
+
for (const raw of String(text).toLowerCase().split(/[^a-z0-9]+/)) {
|
|
64
|
+
if (raw.length < 2 || raw.length > MAX_TOKEN_LEN || STOPWORDS.has(raw)) continue;
|
|
65
|
+
if (seen.has(raw)) continue;
|
|
66
|
+
seen.add(raw);
|
|
67
|
+
out.push(raw);
|
|
68
|
+
if (out.length >= MAX_TOKENS_PER_DOC) break;
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The combined, deduped, sorted token set for one individual: its (decomposed) name
|
|
74
|
+
* plus any captured doc text. Returns [] if there's nothing to index (never null). */
|
|
75
|
+
export function proseTokensFor({ name, doc } = {}) {
|
|
76
|
+
const set = new Set([...splitIdentifierWords(name), ...tokenizeProse(doc)]);
|
|
77
|
+
return [...set].sort();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Second pass (PLAN_PROSE_INDEX.md): attach a `prose_tokens` attribute to every
|
|
81
|
+
* individual in `individuals`, deriving tokens from its name and captured prose text.
|
|
82
|
+
* Class-aware source selection: Module/Function/Method/Class/Attribute/GlobalVariable use
|
|
83
|
+
* `label` (a real identifier/path — decomposable, e.g. "calculateTotalPrice") + a `doc`
|
|
84
|
+
* attribute if present (docstring/doc-comment). Commit is different: its `label` is a
|
|
85
|
+
* truncated SHA (hex noise if decomposed, e.g. "e6a9419567f7" -> "9419567" garbage) — skip
|
|
86
|
+
* decomposing it and tokenize its `message` attribute instead, which is the real prose
|
|
87
|
+
* (commit messages are often the richest free text in the whole graph). Mutates and
|
|
88
|
+
* returns the same array — safe to call once after the typed individuals are built.
|
|
89
|
+
* `enabled=false` is a no-op (the disable path `TMCT_PROSE_INDEX=0` in a graph writer), so
|
|
90
|
+
* the core typed graph is never affected by turning this pass off. */
|
|
91
|
+
export function attachProseTokens(individuals, { enabled = true } = {}) {
|
|
92
|
+
if (!enabled) return individuals;
|
|
93
|
+
for (const ind of individuals) {
|
|
94
|
+
const attrs = ind.attributes || [];
|
|
95
|
+
const isCommit = ind.class === "Commit";
|
|
96
|
+
const name = isCommit ? null : ind.label;
|
|
97
|
+
const doc = isCommit
|
|
98
|
+
? attrs.find((a) => a.key === "message")?.value
|
|
99
|
+
: attrs.find((a) => a.key === "doc")?.value;
|
|
100
|
+
const tokens = proseTokensFor({ name, doc });
|
|
101
|
+
if (tokens.length) {
|
|
102
|
+
ind.attributes = [...(ind.attributes || []), { prop: "mgx:hasProseTokens", key: "prose_tokens", value: tokens.join(" ") }];
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return individuals;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Build the inverted index (word -> sorted, deduped [individual ids]) from individuals
|
|
109
|
+
* that already carry a `prose_tokens` attribute (i.e. after attachProseTokens ran).
|
|
110
|
+
* Plain object, JSON-serializable — this is what lands as `entities.proseIndex`. */
|
|
111
|
+
export function buildProseIndex(individuals) {
|
|
112
|
+
const index = Object.create(null);
|
|
113
|
+
for (const ind of individuals) {
|
|
114
|
+
const tokAttr = (ind.attributes || []).find((a) => a.key === "prose_tokens");
|
|
115
|
+
if (!tokAttr?.value) continue;
|
|
116
|
+
for (const word of tokAttr.value.split(" ")) {
|
|
117
|
+
if (!index[word]) index[word] = [];
|
|
118
|
+
index[word].push(ind.id);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
for (const word of Object.keys(index)) index[word].sort();
|
|
122
|
+
return index;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Consumer-facing lookup: individual ids whose prose tokens overlap `query` (free text,
|
|
126
|
+
* tokenized the same way as a docstring), ranked by overlap count (most shared words
|
|
127
|
+
* first). This is the integration point for ask.mjs's resolveObject (fuzzy object-term
|
|
128
|
+
* resolution beyond exact/substring match) and codegraph.mjs's scoreModules (a lexical
|
|
129
|
+
* boost source) — see PLAN_PROSE_INDEX.md for the exact call-site recommendation; not
|
|
130
|
+
* wired into either file here to avoid colliding with concurrent work on them.
|
|
131
|
+
* `proseIndex` is `entities.proseIndex` (buildProseIndex's output). */
|
|
132
|
+
export function lookupByProseTokens(proseIndex, query, { limit = 10 } = {}) {
|
|
133
|
+
const queryTokens = [...new Set([...splitIdentifierWords(query), ...tokenizeProse(query)])];
|
|
134
|
+
if (!queryTokens.length) return [];
|
|
135
|
+
const scoreById = new Map();
|
|
136
|
+
for (const word of queryTokens) {
|
|
137
|
+
for (const id of proseIndex?.[word] || []) {
|
|
138
|
+
scoreById.set(id, (scoreById.get(id) || 0) + 1);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return [...scoreById.entries()]
|
|
142
|
+
.sort((a, b) => b[1] - a[1] || String(a[0]).localeCompare(b[0]))
|
|
143
|
+
.slice(0, limit)
|
|
144
|
+
.map(([id, score]) => ({ id, score }));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** OPT-IN read accessor for codegraph.mjs's `proseLayers` locate signal (this file's index
|
|
148
|
+
* build is unchanged — this only READS the layers the pre-pass already wrote). Given a query
|
|
149
|
+
* `token`, return the individual ids reachable through the NORMALISED prose layers
|
|
150
|
+
* (spell-corrected / canonical-schema-term / stem / lemma) stored under
|
|
151
|
+
* `proseIndex["tmct:layers"]` — the same normalised layers ask.mjs's resolveObject consults,
|
|
152
|
+
* here surfaced for the locate SCORER so a task-text word that only overlaps a module via a
|
|
153
|
+
* normalised form still resolves.
|
|
154
|
+
*
|
|
155
|
+
* Layer shape consumed (an inverted index keyed by the NORMALISED token, mirroring the verbatim
|
|
156
|
+
* top level, just normalised):
|
|
157
|
+
* proseIndex["tmct:layers"] = { <layerName>: { <normalisedToken>: [id, …] }, … }
|
|
158
|
+
* A posting may be a plain id array or `{ ids: [...] }` — both are tolerated. The raw query
|
|
159
|
+
* token is looked up directly against every layer's keys, so a token whose surface form is
|
|
160
|
+
* already a canonical/stem/lemma/spell-corrected key hits; the accessor never itself normalises
|
|
161
|
+
* the query (it owns no normaliser — those live in the concurrent ask/prose-nlp surface), so it
|
|
162
|
+
* can never disagree with the build's normalisation, only under-fire safely.
|
|
163
|
+
*
|
|
164
|
+
* Returns { ids, via }: `ids` a deduped, sorted (stable/deterministic) id list; `via` the
|
|
165
|
+
* sorted layer names that produced them, joined with "+", for a scorer's provenance — or null
|
|
166
|
+
* when nothing hit. Absent / malformed / pre-layers `proseIndex` → { ids: [], via: null }: a
|
|
167
|
+
* safe no-op, so the opt-in flag degrades to nothing on a graph indexed before layers existed.
|
|
168
|
+
* Accepts either a `proseIndex` object or a parsed graph (reads its `.proseIndex`). */
|
|
169
|
+
export function proseLayerHits(proseIndex, token) {
|
|
170
|
+
const src = proseIndex && (proseIndex["tmct:layers"] ? proseIndex : proseIndex.proseIndex);
|
|
171
|
+
const layers = src && src["tmct:layers"];
|
|
172
|
+
const t = String(token || "").toLowerCase();
|
|
173
|
+
const empty = { ids: [], via: null };
|
|
174
|
+
if (!t || !layers || typeof layers !== "object") return empty;
|
|
175
|
+
const ids = new Set();
|
|
176
|
+
const via = new Set();
|
|
177
|
+
for (const name of Object.keys(layers)) {
|
|
178
|
+
const layer = layers[name];
|
|
179
|
+
if (!layer || typeof layer !== "object") continue;
|
|
180
|
+
const posting = layer[t];
|
|
181
|
+
const list = Array.isArray(posting) ? posting : Array.isArray(posting?.ids) ? posting.ids : null;
|
|
182
|
+
if (!list?.length) continue;
|
|
183
|
+
for (const id of list) ids.add(id);
|
|
184
|
+
via.add(name);
|
|
185
|
+
}
|
|
186
|
+
return ids.size ? { ids: [...ids].sort(), via: [...via].sort().join("+") } : empty;
|
|
187
|
+
}
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
// schema-docs.mjs — the single source of truth for tmct's own ontology documentation.
|
|
2
|
+
//
|
|
3
|
+
// tmct's typed graph (src/graph-build.mjs's buildEntities, queried by codegraph.mjs)
|
|
4
|
+
// has had its schema documented since day one, but only in scattered code comments
|
|
5
|
+
// (graph-build.mjs's header, the `note` fields on some — not all — of the `vocabulary`
|
|
6
|
+
// array entries it emits) — readable by a human editing the source, invisible to anything
|
|
7
|
+
// that only sees graph.json. This file completes that documentation (every entity class,
|
|
8
|
+
// every predicate actually emitted — verified against real `prop:`/`class:` literals in
|
|
9
|
+
// graph-build.mjs, not just what the existing vocabulary array already claimed) and
|
|
10
|
+
// `ingestSchemaDocs()` (called by a graph writer after buildEntities) merges it
|
|
11
|
+
// into a graph build so a question like "what does cochange mean" or "what is a
|
|
12
|
+
// Commit" is answerable by querying the SAME graph the same way any other question is —
|
|
13
|
+
// not via separate hardcoded documentation logic.
|
|
14
|
+
//
|
|
15
|
+
// GLOBAL, NOT PER-REPO: this documentation does not vary by repository — "Module" means
|
|
16
|
+
// the same thing indexing Django or a JS library — so it is static, committed data
|
|
17
|
+
// (this file), never recomputed at index time. Ingesting it is a fixed-size merge, not a
|
|
18
|
+
// computation (see the ~0ms measured delta in schema-docs.test.mjs).
|
|
19
|
+
//
|
|
20
|
+
// Mirrors the pattern in marginalia's app/lib/vocab.mjs (a `comment`/description per
|
|
21
|
+
// schema term, single-sourced) minus the RDF/OWL external-alignment machinery, which
|
|
22
|
+
// doesn't apply here — tmct's schema is code-relationship-specific, not general-domain.
|
|
23
|
+
|
|
24
|
+
// ---- entity classes (7 — verified against every `class: "X"` individual literal in
|
|
25
|
+
// graph-build.mjs) -----------------------------------------------------------------------
|
|
26
|
+
export const CLASS_DOCS = Object.freeze([
|
|
27
|
+
{ name: "Module", description:
|
|
28
|
+
"A source file — one importable unit within the indexed repository (a .py/.mjs/.ts/" +
|
|
29
|
+
".cs/.java file, etc). The coarsest unit tmct ranks, locates, and injects as a " +
|
|
30
|
+
"digest; every other entity class belongs to exactly one Module." },
|
|
31
|
+
{ name: "Class", description:
|
|
32
|
+
"A class definition inside a Module. Contains Methods and Attributes (seon:" +
|
|
33
|
+
"containsCodeEntity) and may declare a base class (seon:hasSuperType)." },
|
|
34
|
+
{ name: "Function", description:
|
|
35
|
+
"A top-level, module-scope function definition — not a method (methods belong to a " +
|
|
36
|
+
"Class and are their own entity class)." },
|
|
37
|
+
{ name: "Method", description:
|
|
38
|
+
"A function defined inside a Class — an instance, static, or class method. Distinct " +
|
|
39
|
+
"from Function so class membership (seon:containsCodeEntity) and free functions " +
|
|
40
|
+
"never get confused." },
|
|
41
|
+
{ name: "Attribute", description:
|
|
42
|
+
"A field or property belonging to a Class — either a class-level assignment or a " +
|
|
43
|
+
"self-scoped instance field seen in a method body (self.<name> =). Distinct from a " +
|
|
44
|
+
"module-level GlobalVariable." },
|
|
45
|
+
{ name: "GlobalVariable", description:
|
|
46
|
+
"A module-level variable or constant assignment that belongs to no Class or " +
|
|
47
|
+
"function — a name defined directly in a Module's top-level scope." },
|
|
48
|
+
{ name: "Commit", description:
|
|
49
|
+
"A single recorded git commit. Carries author/date/message attributes and connects " +
|
|
50
|
+
"to the Modules it touched (mgx:touchedByCommit) and, more precisely, the specific " +
|
|
51
|
+
"symbols whose current source span its changed lines intersect (mgx:touchesSymbol)." },
|
|
52
|
+
{ name: "Session", description:
|
|
53
|
+
"One recorded `tmct chat` session — a runtime observation, not a source " +
|
|
54
|
+
"derivation. Carries started/ended timestamps (temporal ordering, like a Commit's " +
|
|
55
|
+
"date), a turn count and the queries asked, and connects to the entities its turns " +
|
|
56
|
+
"resolved or answered with (mgx:asksAbout). Recorded under .tmct/sessions/ and " +
|
|
57
|
+
"re-attached on every re-index (sessions.mjs)." },
|
|
58
|
+
]);
|
|
59
|
+
|
|
60
|
+
// ---- predicates / attributes (every `prop:` token actually emitted by buildEntities,
|
|
61
|
+
// verified by grep against graph-build.mjs — not just what the pre-existing `vocabulary`
|
|
62
|
+
// array already documented; three real gaps found this way: seon:startsAt, mgx:value,
|
|
63
|
+
// mgx:dotted, plus the prose second-pass's mgx:hasProseTokens) ------------------------
|
|
64
|
+
export const PREDICATE_DOCS = Object.freeze([
|
|
65
|
+
// ---- object properties (edges between individuals) ----
|
|
66
|
+
{ prop: "mgx:importsNamespace", kind: "imports", description:
|
|
67
|
+
"Module → Module. The subject module has a top-level import statement that resolves " +
|
|
68
|
+
"to a module already present in this repository's index. External/unresolved " +
|
|
69
|
+
"imports are dropped, never guessed." },
|
|
70
|
+
{ prop: "mgx:callsCoarse", kind: "calls", description:
|
|
71
|
+
"Module → Module. An import-backed, module-granular \"this file calls into that " +
|
|
72
|
+
"file\" edge — not resolved to a specific symbol (see callsSymbol for that)." },
|
|
73
|
+
{ prop: "mgx:callsSymbol", kind: "callsSymbol", description:
|
|
74
|
+
"Function/Method → Function/Class. A call site resolved to exactly one same-named " +
|
|
75
|
+
"definition in the repository — unambiguous names only; an ambiguous or external " +
|
|
76
|
+
"call is dropped rather than guessed (no wrong edge)." },
|
|
77
|
+
{ prop: "seon:declaresMethod", kind: "defines", description:
|
|
78
|
+
"Module → Function/Class/Method/Attribute/GlobalVariable. What a module's top-level " +
|
|
79
|
+
"scope actually declares." },
|
|
80
|
+
{ prop: "seon:containsCodeEntity", kind: "contains", description:
|
|
81
|
+
"Class → Method/Attribute. Class membership — which methods and fields belong to " +
|
|
82
|
+
"which class." },
|
|
83
|
+
{ prop: "mgx:touchedByCommit", kind: "touches", description:
|
|
84
|
+
"Commit → Module. From `git log --name-only`, restricted to modules already in the " +
|
|
85
|
+
"index — which files a commit's diff lists, at file granularity." },
|
|
86
|
+
{ prop: "mgx:touchesSymbol", kind: "touchesSymbol", description:
|
|
87
|
+
"Commit → Function/Method/Class/Attribute. A commit's changed-line-range intersected " +
|
|
88
|
+
"with a symbol's current source span — WHICH specific function or class a commit " +
|
|
89
|
+
"actually edited, not just which file it touched." },
|
|
90
|
+
{ prop: "mgx:testsCoverage", kind: "tests", description:
|
|
91
|
+
"Module → Module. A test module's own internal imports — a lightweight \"this test " +
|
|
92
|
+
"file exercises that source file\" signal, not line-level coverage-tool data." },
|
|
93
|
+
{ prop: "seon:hasSuperType", kind: "inherits", description:
|
|
94
|
+
"Class → Class. A class's base class, resolved to an internal Class when the name " +
|
|
95
|
+
"matches one defined in the repo; otherwise recorded as an external `ext:` reference." },
|
|
96
|
+
{ prop: "mgx:changeCoupledWith", kind: "cochange", description:
|
|
97
|
+
"Module ↔ Module. Two modules frequently committed together, independent of any " +
|
|
98
|
+
"import or call relationship — surfaces coupling that static analysis alone misses " +
|
|
99
|
+
"(e.g. a module and its test file, or two files that always change together for a " +
|
|
100
|
+
"reason no import edge captures)." },
|
|
101
|
+
{ prop: "mgx:reExports", kind: "reexports", description:
|
|
102
|
+
"Module → Function/Class. A module's public-API (`__all__`) entry that re-exports a " +
|
|
103
|
+
"symbol defined or imported elsewhere — answers \"where is X importable from,\" not " +
|
|
104
|
+
"just \"where is X defined.\"" },
|
|
105
|
+
{ prop: "mgx:asksAbout", kind: "asksAbout", description:
|
|
106
|
+
"Session → any entity. A chat session's turn resolved this entity as its subject " +
|
|
107
|
+
"or cited it in the answer — which parts of the codebase a human actually asked " +
|
|
108
|
+
"about. A runtime observation (owned term, no SEON equivalent); references that no " +
|
|
109
|
+
"longer resolve after a re-index are dropped and counted, never guessed." },
|
|
110
|
+
{ prop: "mgx:hasProseTokens", kind: "prose", description:
|
|
111
|
+
"Individual → word tokens (an attribute, not a between-individuals edge). The " +
|
|
112
|
+
"decomposed word sequence extracted from an identifier's name (camelCase/snake_case " +
|
|
113
|
+
"split) and any doc-comment prose, used by the second-pass prose-to-symbol " +
|
|
114
|
+
"cross-reference index (PLAN_PROSE_INDEX.md) to resolve free-text object terms that " +
|
|
115
|
+
"don't exact-match an identifier." },
|
|
116
|
+
|
|
117
|
+
// ---- attributes (individual-scoped facts, not edges) ----
|
|
118
|
+
{ prop: "seon:startsAt", kind: "attribute", description:
|
|
119
|
+
"The `path:line` or `path:start-end` source location of a Function/Method/Class/" +
|
|
120
|
+
"Attribute/GlobalVariable — where in its Module the definition actually lives." },
|
|
121
|
+
{ prop: "mgx:dotted", kind: "attribute", description:
|
|
122
|
+
"A Module's dotted import name (e.g. `pkg.sub.mod` for Python), when the language " +
|
|
123
|
+
"has one — used to resolve import statements to internal modules." },
|
|
124
|
+
{ prop: "mgx:exportsAll", kind: "attribute", description:
|
|
125
|
+
"A Module's literal `__all__` membership list, stored even for entries that don't " +
|
|
126
|
+
"resolve to a real symbol — so the digest can always tell an agent \"this module has " +
|
|
127
|
+
"an __all__, add your new symbol to it.\"" },
|
|
128
|
+
{ prop: "mgx:decorator", kind: "attribute", description:
|
|
129
|
+
"Python/framework decorators applied to a Function/Method/Class definition (e.g. " +
|
|
130
|
+
"`@property`, `@app.route(...)`), as written." },
|
|
131
|
+
{ prop: "mgx:value", kind: "attribute", description:
|
|
132
|
+
"The literal right-hand-side value of a GlobalVariable's assignment, when the AST " +
|
|
133
|
+
"extractor could capture one cheaply (a constant or simple literal)." },
|
|
134
|
+
{ prop: "mgx:sessionStarted", kind: "attribute", description:
|
|
135
|
+
"A chat Session's start time, ISO-8601 — the timestamp the session enters the " +
|
|
136
|
+
"timeline at (the Session analogue of a Commit's date)." },
|
|
137
|
+
{ prop: "mgx:sessionEnded", kind: "attribute", description:
|
|
138
|
+
"A chat Session's end time, ISO-8601 (the /exit, EOF, or last recorded turn)." },
|
|
139
|
+
{ prop: "mgx:sessionTurns", kind: "attribute", description:
|
|
140
|
+
"How many query turns a chat Session ran (non-empty, non-/exit inputs)." },
|
|
141
|
+
{ prop: "mgx:sessionQueries", kind: "attribute", description:
|
|
142
|
+
"The queries a chat Session asked, ' | '-joined and length-capped — what the " +
|
|
143
|
+
"human wanted to know, in their own words." },
|
|
144
|
+
{ prop: "mgx:sessionDroppedEdges", kind: "attribute", description:
|
|
145
|
+
"How many of a Session's recorded entity references could not be re-resolved " +
|
|
146
|
+
"against the current graph (renamed/removed code) — those edges are dropped, " +
|
|
147
|
+
"never guessed, and this attribute keeps the loss honest and visible." },
|
|
148
|
+
{ prop: "mgx:commitAuthor", kind: "attribute", description: "A Commit's author name (git %an)." },
|
|
149
|
+
{ prop: "mgx:commitDate", kind: "attribute", description:
|
|
150
|
+
"A Commit's author date, ISO-8601 (git %aI)." },
|
|
151
|
+
{ prop: "mgx:commitMessage", kind: "attribute", description:
|
|
152
|
+
"A Commit's subject line (git %s), capped to 120 characters." },
|
|
153
|
+
{ prop: "seon:hasParameter", kind: "attribute", description:
|
|
154
|
+
"A Function/Method's formal parameter list, as a signature string (the AST's " +
|
|
155
|
+
"unparsed argument list — not resolved types)." },
|
|
156
|
+
{ prop: "seon:hasReturnType", kind: "attribute", description:
|
|
157
|
+
"A Function/Method's return type ANNOTATION exactly as written — not an inferred or " +
|
|
158
|
+
"resolved type." },
|
|
159
|
+
{ prop: "seon:throwsException", kind: "attribute", description:
|
|
160
|
+
"Exception class names literally named in a `raise` statement inside the definition — " +
|
|
161
|
+
"not resolved to their own Class individuals." },
|
|
162
|
+
{ prop: "seon:catchesException", kind: "attribute", description:
|
|
163
|
+
"Exception type names literally named in an `except` handler inside the definition." },
|
|
164
|
+
{ prop: "seon:accessesField", kind: "attribute", description:
|
|
165
|
+
"`self.<field>` names a Method reads or writes — self-scoped only (an honest, " +
|
|
166
|
+
"narrow signal, not general data-flow analysis)." },
|
|
167
|
+
{ prop: "seon:isStatic", kind: "attribute", description:
|
|
168
|
+
"The definition is decorated `@staticmethod` or `@classmethod`." },
|
|
169
|
+
{ prop: "seon:isAbstract", kind: "attribute", description:
|
|
170
|
+
"The definition is decorated `@abstractmethod` or `@abstractproperty`." },
|
|
171
|
+
{ prop: "seon:isConstant", kind: "attribute", description:
|
|
172
|
+
"An ALL_CAPS module-level GlobalVariable — a naming-convention signal, not enforced " +
|
|
173
|
+
"immutability." },
|
|
174
|
+
{ prop: "seon:subKind", kind: "attribute", description:
|
|
175
|
+
"The flavour of a Class define when it is not a plain class: interface, enum, struct " +
|
|
176
|
+
"or record. The graph keeps kind=class for every type declaration; this attribute " +
|
|
177
|
+
"carries the distinction." },
|
|
178
|
+
{ prop: "seon:hasAccessModifier", kind: "attribute", description:
|
|
179
|
+
"Visibility inferred from a leading underscore (private/protected); a public member " +
|
|
180
|
+
"carries no value for this attribute." },
|
|
181
|
+
{ prop: "seon:hasDoc", kind: "attribute", description:
|
|
182
|
+
"The first line of a docstring, length-capped — a one-line purpose summary without " +
|
|
183
|
+
"the full docstring body." },
|
|
184
|
+
]);
|
|
185
|
+
|
|
186
|
+
const classDocByName = new Map(CLASS_DOCS.map((c) => [c.name, c]));
|
|
187
|
+
const predicateDocByProp = new Map(PREDICATE_DOCS.map((p) => [p.prop, p]));
|
|
188
|
+
|
|
189
|
+
/** Meta-individual ids are namespaced (`schema:class:`/`schema:predicate:`) so they can
|
|
190
|
+
* never collide with a real code entity's id (`mod:`/`fn:`/`commit:`) and are trivially
|
|
191
|
+
* filterable out of normal code-entity queries by anything that cares to. */
|
|
192
|
+
const classIndividual = ({ name, description }) => ({
|
|
193
|
+
id: `schema:class:${name}`,
|
|
194
|
+
label: name,
|
|
195
|
+
class: "SchemaClass",
|
|
196
|
+
derived_from: [],
|
|
197
|
+
mentions: [],
|
|
198
|
+
attributes: [{ prop: "mgx:schemaDoc", key: "doc", value: description }],
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
const predicateIndividual = ({ prop, kind, description }) => ({
|
|
202
|
+
id: `schema:predicate:${prop}`,
|
|
203
|
+
// The label is the human-facing relation name (what a user would actually type — "what
|
|
204
|
+
// does cochange mean" — matching ask-vocab.mjs's VERB_TO_KIND/kind vocabulary), not the
|
|
205
|
+
// raw prop token; the token is kept as a separate attribute for exact lookup.
|
|
206
|
+
label: kind,
|
|
207
|
+
class: "SchemaPredicate",
|
|
208
|
+
derived_from: [],
|
|
209
|
+
mentions: [],
|
|
210
|
+
attributes: [
|
|
211
|
+
{ prop: "mgx:schemaDoc", key: "doc", value: description },
|
|
212
|
+
{ prop: "mgx:schemaToken", key: "token", value: prop },
|
|
213
|
+
],
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
/** Merge the static schema documentation into a just-built `entities` payload (the
|
|
217
|
+
* object `buildEntities` returns, before it's serialized to graph.json):
|
|
218
|
+
* - `entities.classes[].description` filled in for every known class,
|
|
219
|
+
* - `entities.vocabulary[].note` backfilled wherever it was missing (existing notes
|
|
220
|
+
* are left as-is — this only fills gaps, it doesn't overwrite a human's wording),
|
|
221
|
+
* - `entities.individuals` gains one SchemaClass + one SchemaPredicate individual per
|
|
222
|
+
* documented term, so the SAME graph traversal that answers "what calls X" can also
|
|
223
|
+
* answer "what is a Commit" or "what does cochange mean".
|
|
224
|
+
* Mutates and returns `entities`. Idempotent (safe to call more than once — individuals
|
|
225
|
+
* are only appended if not already present by id). Pure w.r.t. repo content: the output
|
|
226
|
+
* is identical regardless of what was indexed. */
|
|
227
|
+
export function ingestSchemaDocs(entities) {
|
|
228
|
+
if (!entities || typeof entities !== "object") return entities;
|
|
229
|
+
|
|
230
|
+
if (Array.isArray(entities.classes)) {
|
|
231
|
+
for (const c of entities.classes) {
|
|
232
|
+
const doc = classDocByName.get(c.name);
|
|
233
|
+
if (doc && c.description === undefined) c.description = doc.description;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
if (Array.isArray(entities.vocabulary)) {
|
|
237
|
+
for (const v of entities.vocabulary) {
|
|
238
|
+
const doc = predicateDocByProp.get(v.prop);
|
|
239
|
+
if (doc && !v.note) v.note = doc.description;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
entities.individuals ||= [];
|
|
244
|
+
const existingIds = new Set(entities.individuals.map((i) => i?.id));
|
|
245
|
+
for (const c of CLASS_DOCS) {
|
|
246
|
+
const ind = classIndividual(c);
|
|
247
|
+
if (!existingIds.has(ind.id)) { entities.individuals.push(ind); existingIds.add(ind.id); }
|
|
248
|
+
}
|
|
249
|
+
for (const p of PREDICATE_DOCS) {
|
|
250
|
+
const ind = predicateIndividual(p);
|
|
251
|
+
if (!existingIds.has(ind.id)) { entities.individuals.push(ind); existingIds.add(ind.id); }
|
|
252
|
+
}
|
|
253
|
+
return entities;
|
|
254
|
+
}
|