@polycode-projects/the-mechanical-code-talker 3.1.0 → 3.1.3

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
@@ -1135,6 +1135,8 @@ The `<where-marker>` slot takes any of *defined*, *declared*, *located*, *implem
1135
1135
  - **touches** — *touched*, *touches*, *changed*, *change*, and more
1136
1136
  - **cochange** — *changed with*, *co-changes with*, *co-change with*, *changes alongside*, and more
1137
1137
  - **reexports** — *exports*, *export*, *re-exports*, *re-export*, and more
1138
+ - **serves** — *serves*, *serve*, *serving*, and more
1139
+ - **denotes** — *denotes*, *denote*, *denoting*, and more
1138
1140
 
1139
1141
  Every question in that table runs against the example graph:
1140
1142
 
@@ -7,3 +7,5 @@
7
7
  {"relation":"touches","definition":"A touch is a commit changing a file or a symbol in the codebase.","sense":"software"}
8
8
  {"relation":"cochange","definition":"Change-coupling is two files that tend to be changed together in the same commits.","sense":"software"}
9
9
  {"relation":"reexports","definition":"A re-export is a module passing another module's definition through as part of its own public API.","sense":"software"}
10
+ {"relation":"serves","definition":"A service edge is one part of the codebase providing or backing another, such as a handler serving a route.","sense":"software"}
11
+ {"relation":"denotes","definition":"A denotation is a vocabulary term naming a code entity, linking the word to the thing it refers to.","sense":"software"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polycode-projects/the-mechanical-code-talker",
3
- "version": "3.1.0",
3
+ "version": "3.1.3",
4
4
  "private": false,
5
5
  "type": "module",
6
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.",
@@ -46,6 +46,8 @@
46
46
  "./generateCompletion": "./src/services/completions.mjs",
47
47
  "./createCompletionsGraphAdapter": "./src/services/completions.mjs",
48
48
  "./ingest": "./src/services/extract-facts.mjs",
49
+ "./digest": "./src/adapters/corpus/digest-bank.mjs",
50
+ "./memory": "./src/adapters/memory/core.mjs",
49
51
  "./repository-interface": "./src/adapters/repository-interface.mjs",
50
52
  "./conformance": "./src/tools/conformance.mjs",
51
53
  "./ask-browser": "./src/surfaces/web/graph-ask-browser-entry.mjs"
@@ -46,6 +46,7 @@ export const MISS_REASONS = Object.freeze({
46
46
  export const EDGE_KINDS = Object.freeze([
47
47
  "imports", "calls", "callsSymbol", "defines", "tests",
48
48
  "touches", "touchesSymbol", "contains", "inherits", "cochange", "reexports",
49
+ "serves", "denotes",
49
50
  ]);
50
51
 
51
52
  /** edge-kind → the `tmct:` object property it realizes (the OWL grounding). */
@@ -61,6 +62,8 @@ export const EDGE_KIND_TO_TMCT = Object.freeze({
61
62
  inherits: "tmct:extends",
62
63
  cochange: "tmct:dependsOn",
63
64
  reexports: "tmct:exports",
65
+ serves: "tmct:serves",
66
+ denotes: "tmct:denotes",
64
67
  });
65
68
 
66
69
  /** The named services, grouped as in the plan's six-group inventory. Names are
@@ -164,6 +164,29 @@ export const RELATIONS = {
164
164
  "exporting",
165
165
  ],
166
166
  },
167
+ // serves/denotes classify edges a PROVIDER declares (mgx:serves / mgx:denotes).
168
+ // tmct's own indexer emits neither, so they are absent from a graph it built
169
+ // and answer honestly empty there; a provider graph that carries them gets the
170
+ // same one-hop traversal every other kind gets, instead of only /describe's
171
+ // kind-agnostic edge walk.
172
+ serves: {
173
+ bare: "serve",
174
+ comment: "subject provides or backs the object — a handler serving a route, a module serving a surface (mgx:serves).",
175
+ verbs: [
176
+ "serves", "serve",
177
+ // gerund (g-drop normalization)
178
+ "serving",
179
+ ],
180
+ },
181
+ denotes: {
182
+ bare: "denote",
183
+ comment: "subject names the object — a glossary/lexicon term denoting a code entity (mgx:denotes).",
184
+ verbs: [
185
+ "denotes", "denote",
186
+ // gerund (g-drop normalization)
187
+ "denoting",
188
+ ],
189
+ },
167
190
  };
168
191
 
169
192
  /** The closed set of reverse `inherits` verb phrasings a strategy checks to
@@ -35,6 +35,7 @@ import { parseKeywordSpot, findPhrase } from "./interpret/strategies/keywords.mj
35
35
  import { runStrategiesSync } from "./interpret/pipeline.mjs";
36
36
  import { mergeStrategyResults, alternateLines } from "./interpret/merge.mjs";
37
37
  import { lookupByProseTokens, splitIdentifierWords } from "./prose.mjs";
38
+ import { articleFor } from "./digest/words.mjs";
38
39
  import { pickPhrase } from "./answer-variants.mjs";
39
40
 
40
41
  // Normalization stays importable from its original site (tests + chat surface).
@@ -137,7 +138,7 @@ function verbFor(kind) {
137
138
  const PLURAL_SUBJECT_VERB = {
138
139
  imports: "import", calls: "call", callsSymbol: "call", inherits: "inherit from",
139
140
  contains: "contain", tests: "test", touches: "touch", cochange: "cochange",
140
- reexports: "export", uses: "use",
141
+ reexports: "export", uses: "use", serves: "serve", denotes: "denote",
141
142
  };
142
143
  const pluralVerbFor = (kind) => PLURAL_SUBJECT_VERB[kind] || verbFor(kind);
143
144
 
@@ -2187,7 +2188,28 @@ function describeFindHit(ind) {
2187
2188
  const label = ["Function", "Method"].includes(ind.class) ? `${ind.label}()` : ind.label;
2188
2189
  if (ind.class === "Module") return label;
2189
2190
  const mod = moduleLabelOf(ind);
2190
- return mod && mod !== "(unknown module)" ? `${label} in ${mod}` : label;
2191
+ return mod ? `${label} in ${mod}` : label;
2192
+ }
2193
+
2194
+ /** Does any branch of a compositional AST filter on test coverage? Keyed off
2195
+ * QUALIFIERS' own `via` field, so a new coverage adjective in the vocabulary
2196
+ * is picked up here without a second list to keep in step. */
2197
+ function filtersOnCoverage(node) {
2198
+ if (!node || typeof node !== "object") return false;
2199
+ if (Array.isArray(node)) return node.some(filtersOnCoverage);
2200
+ if (Array.isArray(node.filters)
2201
+ && node.filters.some((f) => QUALIFIERS[String(f).toLowerCase()]?.via === "tested")) return true;
2202
+ return Object.values(node).some(filtersOnCoverage);
2203
+ }
2204
+
2205
+ /** An empty coverage-filtered set over symbols is a grain mismatch, not an
2206
+ * absent answer: `tests` edges are recorded module to module, so no
2207
+ * function-grain coverage exists to filter on. Say that instead of the
2208
+ * generic rephrase nudge, which would send the reader somewhere unrelated. */
2209
+ function coverageGrainNote(parsed, entityType) {
2210
+ if (!["Function", "Method"].includes(entityType)) return null;
2211
+ if (!filtersOnCoverage(parsed)) return null;
2212
+ return "This index records tests edges module to module, so it holds no function-grain coverage to filter on — ask whether the module a function lives in is tested instead.";
2191
2213
  }
2192
2214
 
2193
2215
  function renderComposite(parsed, result, graph) {
@@ -2210,7 +2232,7 @@ function renderComposite(parsed, result, graph) {
2210
2232
  }
2211
2233
  const hit = result.matches[0];
2212
2234
  const modLabel = moduleLabelOf(hit);
2213
- const definedIn = hit.class === "Module" ? "" : (modLabel && modLabel !== "(unknown module)" ? `, ${pickPhrase("defined-in", hit.id, "defined in")} ${modLabel}` : "");
2235
+ const definedIn = hit.class === "Module" ? "" : (modLabel ? `, ${pickPhrase("defined-in", hit.id, "defined in")} ${modLabel}` : "");
2214
2236
  return { content: `Yes — ${hit.label} is a ${kindSingular}${definedIn}.`, miss: false, ambiguous: false, matches: result.matches };
2215
2237
  }
2216
2238
  if (!result.matches.length) {
@@ -2386,7 +2408,8 @@ function renderComposite(parsed, result, graph) {
2386
2408
  }
2387
2409
  // set-producing
2388
2410
  if (!result.matches.length) {
2389
- return { content: `nothing in the index matches that${result.entityType ? ` (${nounFor(result.entityType, 2)})` : ""}. ${touchesRephraseHint(graph)}`, miss: true, ambiguous: false, matches: [] };
2411
+ const hint = coverageGrainNote(parsed, result.entityType) || touchesRephraseHint(graph);
2412
+ return { content: `nothing in the index matches that${result.entityType ? ` (${nounFor(result.entityType, 2)})` : ""}. ${hint}`, miss: true, ambiguous: false, matches: [] };
2390
2413
  }
2391
2414
  return { content: `${compositeList(result.matches)}.`, miss: false, ambiguous: false, matches: result.matches };
2392
2415
  }
@@ -2963,6 +2986,25 @@ function modifierIsWired(shape, kind, entityType) {
2963
2986
  }
2964
2987
  const TRANSITIVE_MAX_DEPTH = 8; // matches renderImpact's own default (codegraph.mjs)
2965
2988
 
2989
+ /** A graph's own vocabulary nodes carry their definition text under this
2990
+ * property. A code entity's docstring rides `seon:hasDoc` and shares the
2991
+ * plain `doc` key, so the PROP is what separates the two. */
2992
+ const SCHEMA_DOC_PROP = "mgx:schemaDoc";
2993
+
2994
+ /** The definition text an individual publishes about itself, or null. Reading
2995
+ * the meta lane off this attribute rather than a fixed class-name list lets a
2996
+ * graph declare its own documented individual class (a glossary term, say)
2997
+ * and have "what is X" answer for it like any other vocabulary node. */
2998
+ function schemaDefinitionOf(ind) {
2999
+ return (ind?.attributes || []).find((a) => a.prop === SCHEMA_DOC_PROP)?.value || null;
3000
+ }
3001
+
3002
+ function schemaKindWordFor(cls) {
3003
+ if (cls === "SchemaClass") return "a class in the graph's schema";
3004
+ if (cls === "SchemaPredicate") return "a predicate (relation) in the graph's schema";
3005
+ return `${articleFor(cls)} ${cls} in this graph's vocabulary`;
3006
+ }
3007
+
2966
3008
  /** Compile a parsed query into a graph lookup. Pure given (graph, parsed, opts).
2967
3009
  * `opts.contextId` resolves a context pronoun ("this"/"it"/…) when the parse
2968
3010
  * needed one. Returns {matches, objMatch, candidates, traversal, ambiguous,
@@ -2992,7 +3034,7 @@ export function traverse(graph, parsed, { contextId = null, prev = null, pinnedO
2992
3034
  const term = String(parsed.object || "").trim();
2993
3035
  const termLc = term.toLowerCase();
2994
3036
  const match = (graph.individuals || []).find((i) => {
2995
- if (i.class !== "SchemaClass" && i.class !== "SchemaPredicate") return false;
3037
+ if (!schemaDefinitionOf(i)) return false;
2996
3038
  if (String(i.label).toLowerCase() === termLc) return true;
2997
3039
  const token = (i.attributes || []).find((a) => a.key === "token")?.value;
2998
3040
  return token && String(token).toLowerCase() === termLc;
@@ -3435,12 +3477,15 @@ export function traverse(graph, parsed, { contextId = null, prev = null, pinnedO
3435
3477
  // ---- templated renderer: string interpolation + grouping/pluralization/
3436
3478
  // overflow rules, never generation. ----
3437
3479
 
3480
+ /** The module an individual lives in, or null when the index places it in
3481
+ * none — a graph can carry individuals that are not code (a glossary term,
3482
+ * a schema node), and naming a module for those would be a fabrication. */
3438
3483
  function moduleLabelOf(ind) {
3439
3484
  if (ind.class === "Module") return ind.label;
3440
3485
  const site = (ind.attributes || []).find((a) => a.key === "site")?.value;
3441
3486
  if (site) return String(site).split(":")[0];
3442
3487
  const m = String(ind.id || "").match(/^fn:(.+)#/);
3443
- return m ? m[1] : "(unknown module)";
3488
+ return m ? m[1] : null;
3444
3489
  }
3445
3490
 
3446
3491
  function symbolLabelOf(ind) {
@@ -3653,9 +3698,8 @@ function renderCore(parsed, result, graph) {
3653
3698
  if (result.metaCodeClass) {
3654
3699
  return { content: result.metaFallbackText, miss: false, ambiguous: false, matches: result.matches };
3655
3700
  }
3656
- const doc = (result.objMatch.attributes || []).find((a) => a.key === "doc")?.value || "";
3657
- const kindWord = result.objMatch.class === "SchemaClass" ? "a class in the graph's schema" : "a predicate (relation) in the graph's schema";
3658
- return { content: `${result.objMatch.label} is ${kindWord}: ${doc}`, miss: false, ambiguous: false, matches: result.matches };
3701
+ const doc = schemaDefinitionOf(result.objMatch) || "";
3702
+ return { content: `${result.objMatch.label} is ${schemaKindWordFor(result.objMatch.class)}: ${doc}`, miss: false, ambiguous: false, matches: result.matches };
3659
3703
  }
3660
3704
  // mentions: the prose surface — checked before the generic objMatch-null miss
3661
3705
  // below, because a mentions result deliberately carries no resolved object
@@ -3810,8 +3854,15 @@ function renderCore(parsed, result, graph) {
3810
3854
  const lines = m[3] && m[3] !== m[2] ? `lines ${m[2]}-${m[3]}` : `line ${m[2]}`;
3811
3855
  return { content: `${symbolLabelOf(ind)} is defined in ${m[1]} at ${lines}.`, miss: false, ambiguous: false, matches: result.matches };
3812
3856
  }
3857
+ const mod = moduleLabelOf(ind);
3858
+ if (!mod) {
3859
+ return {
3860
+ content: `${ind.label} has no recorded code location in this index — it carries no source site, and nothing places it in a module.`,
3861
+ miss: true, ambiguous: false,
3862
+ };
3863
+ }
3813
3864
  return {
3814
- content: `${symbolLabelOf(ind)} is defined in ${moduleLabelOf(ind)} (no line span recorded in this index).`,
3865
+ content: `${symbolLabelOf(ind)} is defined in ${mod} (no line span recorded in this index).`,
3815
3866
  miss: false, ambiguous: false, matches: result.matches,
3816
3867
  };
3817
3868
  }
@@ -3974,7 +4025,7 @@ function renderCore(parsed, result, graph) {
3974
4025
  // "there is … in {module}" (module trails, not leads).
3975
4026
  const byModule = new Map();
3976
4027
  for (const m of result.matches.slice(0, OVERFLOW_CAP)) {
3977
- const mod = moduleLabelOf(m);
4028
+ const mod = moduleLabelOf(m) || "no recorded module";
3978
4029
  if (!byModule.has(mod)) byModule.set(mod, []);
3979
4030
  byModule.get(mod).push(symbolLabelOf(m));
3980
4031
  }
@@ -4066,8 +4117,7 @@ function fuzzyCascadeWord(w) {
4066
4117
  * question. Exact/substring/prose matches are untouched. */
4067
4118
  function schemaTypoTrap(resolution, term) {
4068
4119
  if (!resolution?.match || resolution.matchedVia !== "fuzzy" || resolution.ambiguous) return false;
4069
- const cls = resolution.match.class;
4070
- if (cls !== "SchemaClass" && cls !== "SchemaPredicate") return false;
4120
+ if (!schemaDefinitionOf(resolution.match)) return false;
4071
4121
  const lc = String(term || "").trim().toLowerCase();
4072
4122
  const kindNoun = fuzzyCascadeWord(lc);
4073
4123
  return !!kindNoun && kindNoun !== lc && !!ENTITY_TO_TYPE[kindNoun];
@@ -42,6 +42,8 @@ const FORWARD_TEMPLATE = Object.freeze({
42
42
  contains: (f) => `what is in ${f}`,
43
43
  defines: (f) => `what does ${f} define`,
44
44
  inherits: (f) => `what does ${f} inherit from`,
45
+ serves: (f) => `what does ${f} serve`,
46
+ denotes: (f) => `what does ${f} denote`,
45
47
  });
46
48
 
47
49
  // Reverse reading (focus is the object): "what <kind> <focus>".
@@ -51,6 +53,8 @@ const REVERSE_TEMPLATE = Object.freeze({
51
53
  tests: (f) => `what tests ${f}`,
52
54
  inherits: (f) => `what inherits from ${f}`,
53
55
  contains: (f) => `what contains ${f}`,
56
+ serves: (f) => `what serves ${f}`,
57
+ denotes: (f) => `what denotes ${f}`,
54
58
  });
55
59
 
56
60
  /** Index a payload into the counts and adjacency the hints read: class →
@@ -75,6 +75,10 @@ const PROP_KIND = {
75
75
  "seon:hassupertype": "inherits",
76
76
  "mgx:changecoupledwith": "cochange",
77
77
  "mgx:reexports": "reexports",
78
+ // provider-declared edges: tmct's own indexer emits neither, so these classify
79
+ // only in a graph supplied through the provider seam
80
+ "mgx:serves": "serves",
81
+ "mgx:denotes": "denotes",
78
82
  // symbol-level edges stay separate kinds so the module-coarse impact closure is unchanged
79
83
  "mgx:touchessymbol": "touchesSymbol",
80
84
  "mgx:callssymbol": "callsSymbol",
@@ -152,6 +156,11 @@ export function resolveSymbol(graph, symbol) {
152
156
  const s = normPath(symbol);
153
157
  if (!s) return { match: null, candidates: [] };
154
158
  const sBase = basename(s);
159
+ // A bare filename (no "/") legitimately fuzzy-matches on basename alone. A
160
+ // multi-segment input reads as a repo-relative path the caller believes exists;
161
+ // matching it against an unrelated file that merely shares a basename would
162
+ // fabricate a hit, so those only match via an exact path suffix, never basename alone.
163
+ const isBareFilename = !s.includes("/");
155
164
  const scored = [];
156
165
  for (const ind of graph.individuals) {
157
166
  const label = normPath(ind.label);
@@ -160,8 +169,7 @@ export function resolveSymbol(graph, symbol) {
160
169
  if (label === s || id === s) score = 100;
161
170
  else if (
162
171
  label.endsWith(`/${s}`) ||
163
- basename(label) === sBase ||
164
- basename(label).replace(/\.[a-z]+$/, "") === sBase
172
+ (isBareFilename && (basename(label) === sBase || basename(label).replace(/\.[a-z]+$/, "") === sBase))
165
173
  )
166
174
  score = 80;
167
175
  else if (label.includes(s)) score = Math.max(10, 50 - (label.length - s.length));
@@ -1090,7 +1098,7 @@ export function compileNameFilter(name, { now = Date.now } = {}) {
1090
1098
  };
1091
1099
  }
1092
1100
 
1093
- export function renderSearch(graph, query, { limit = SEARCH_LIMIT, kind = "", decorator = "", name = "" } = {}) {
1101
+ export function renderSearch(graph, query, { limit = SEARCH_LIMIT, kind = "", decorator = "", name = "", toolNamePrefix = "tmct_" } = {}) {
1094
1102
  const tokens = String(query || "").toLowerCase().split(/[^a-z0-9_]+/).filter(Boolean);
1095
1103
  const wantKind = String(kind || "").trim().toLowerCase();
1096
1104
  const decFilter = String(decorator || "").trim().toLowerCase();
@@ -1104,7 +1112,7 @@ export function renderSearch(graph, query, { limit = SEARCH_LIMIT, kind = "", de
1104
1112
  // default (no kind) keeps the module "where does this live" search unchanged.
1105
1113
  if (wantKind && wantKind !== "module") {
1106
1114
  try {
1107
- return searchSymbols(graph, tokens, { limit, kind: wantKind, decFilter, nameRe });
1115
+ return searchSymbols(graph, tokens, { limit, kind: wantKind, decFilter, nameRe, toolNamePrefix });
1108
1116
  } catch (e) {
1109
1117
  if (e instanceof NameFilterBudgetExceeded) return e.message;
1110
1118
  throw e;
@@ -1113,7 +1121,7 @@ export function renderSearch(graph, query, { limit = SEARCH_LIMIT, kind = "", de
1113
1121
  if (!tokens.length && !nameRe && !decFilter) return "empty query";
1114
1122
  const scored = scoreModules(graph, tokens);
1115
1123
  if (!scored.length) {
1116
- return `no module matches "${query}". Try broader keywords, or tmct_describe <path> if you know where it lives.`;
1124
+ return `no module matches "${query}". Try broader keywords, or ${toolNamePrefix}describe <path> if you know where it lives.`;
1117
1125
  }
1118
1126
  const hits = scored.slice(0, limit);
1119
1127
  const lines = [`${scored.length} module(s) match "${query}" (top ${hits.length}):`];
@@ -1121,7 +1129,7 @@ export function renderSearch(graph, query, { limit = SEARCH_LIMIT, kind = "", de
1121
1129
  const m = matching.length ? ` — matching: ${capJoin([...new Set(matching)], SEARCH_SYMBOLS_SHOWN)}` : "";
1122
1130
  lines.push(`- ${ind.label} (defines ${defineCount} symbol(s))${m}`);
1123
1131
  }
1124
- lines.push("Then tmct_describe <path> for the full sibling list + typed edges, or tmct_impact <path> for dependents.");
1132
+ lines.push(`Then ${toolNamePrefix}describe <path> for the full sibling list + typed edges, or ${toolNamePrefix}impact <path> for dependents.`);
1125
1133
  return lines.join("\n");
1126
1134
  }
1127
1135
 
@@ -1187,11 +1195,11 @@ const CALL_CAP = 30;
1187
1195
 
1188
1196
  /** A class's methods + attributes (with sites/decorators) in one slice — replaces
1189
1197
  * reading the class body. Uses the `contains` (seon:containsCodeEntity) relation. */
1190
- export function renderMembers(graph, ind) {
1198
+ export function renderMembers(graph, ind, { toolNamePrefix = "tmct_" } = {}) {
1191
1199
  const lines = [`${ind.label} — ${classHeading(ind.class)} (id: ${ind.id})`];
1192
1200
  const contains = edgesOfKind(graph, "contains").filter((e) => e.subject === ind.id);
1193
1201
  if (!contains.length) {
1194
- lines.push("members: none recorded (empty class, or members not in the extracted graph). Use tmct_describe for its edges.");
1202
+ lines.push(`members: none recorded (empty class, or members not in the extracted graph). Use ${toolNamePrefix}describe for its edges.`);
1195
1203
  return lines.join("\n");
1196
1204
  }
1197
1205
  const methods = [];
@@ -1205,7 +1213,7 @@ export function renderMembers(graph, ind) {
1205
1213
  }
1206
1214
  if (methods.length) lines.push(`methods (${methods.length}): ${capJoin(methods, MEMBERS_CAP)}`);
1207
1215
  if (attrs.length) lines.push(`attributes (${attrs.length}): ${capJoin(attrs, MEMBERS_CAP)}`);
1208
- lines.push("Use tmct_snippet <Class.member> for an exact body.");
1216
+ lines.push(`Use ${toolNamePrefix}snippet <Class.member> for an exact body.`);
1209
1217
  return lines.join("\n");
1210
1218
  }
1211
1219
 
@@ -1215,7 +1223,7 @@ const attrVal = (ind, key) => (ind?.attributes || []).find((a) => a.key === key)
1215
1223
  * return annotation, raises/catches, self-fields, flags, decorators, one-line doc —
1216
1224
  * so the agent gets the API surface without reading the body. Deterministic ast facts
1217
1225
  * (kept OUT of tmct_context's lean bundle; this is the targeted tool for them). */
1218
- export function renderSignature(graph, ind) {
1226
+ export function renderSignature(graph, ind, { toolNamePrefix = "tmct_" } = {}) {
1219
1227
  const site = siteOf(ind);
1220
1228
  const lines = [`${ind.label} — ${classHeading(ind.class)}${spanTag(site)}`];
1221
1229
  const params = attrVal(ind, "params");
@@ -1242,8 +1250,8 @@ export function renderSignature(graph, ind) {
1242
1250
  if (value) lines.push(`value: ${value}`);
1243
1251
  const doc = attrVal(ind, "doc");
1244
1252
  if (doc) lines.push(`doc: ${doc}`);
1245
- if (lines.length === 1) lines.push("(no signature detail recorded for this symbol — likely a module or attribute; use tmct_snippet for its source.)");
1246
- lines.push("Use tmct_snippet for the exact body.");
1253
+ if (lines.length === 1) lines.push(`(no signature detail recorded for this symbol — likely a module or attribute; use ${toolNamePrefix}snippet for its source.)`);
1254
+ lines.push(`Use ${toolNamePrefix}snippet for the exact body.`);
1247
1255
  return lines.join("\n");
1248
1256
  }
1249
1257
 
@@ -1391,18 +1399,18 @@ export function renderHistory(graph, ind) {
1391
1399
  // classes whose call graph lives on the fn/method-precise callsSymbol edge, not module-coarse calls
1392
1400
  const CALL_SYMBOL_CLASSES = new Set(["Function", "Method"]);
1393
1401
 
1394
- export function renderCallers(graph, ind) {
1402
+ export function renderCallers(graph, ind, { toolNamePrefix = "tmct_" } = {}) {
1395
1403
  // symbol grain: a fine symbol's callers are the SUBJECTS of callsSymbol edges into it.
1396
1404
  if (CALL_SYMBOL_CLASSES.has(ind.class)) {
1397
1405
  const callers = [...new Set(edgesOfKind(graph, "callsSymbol").filter((e) => e.object === ind.id).map((e) => e.subjectLabel || e.subject))];
1398
- if (!callers.length) return `${ind.label}: no recorded callers (fine-grained call edges are conservative — absence is not proof). Try tmct_impact for the full reverse closure.`;
1406
+ if (!callers.length) return `${ind.label}: no recorded callers (fine-grained call edges are conservative — absence is not proof). Try ${toolNamePrefix}impact for the full reverse closure.`;
1399
1407
  return `${ind.label} — called by ${callers.length} symbol(s):\n ${capJoin(callers, CALL_CAP, "\n ")}`;
1400
1408
  }
1401
1409
  const modId = moduleIdOf(graph, ind);
1402
1410
  if (!modId) return `cannot map ${ind.label} to a module.`;
1403
1411
  const modLabel = graph.byId.get(modId)?.label || modId;
1404
1412
  const callers = [...new Set(edgesOfKind(graph, "calls").filter((e) => e.object === modId).map((e) => e.subjectLabel || e.subject))];
1405
- if (!callers.length) return `${modLabel}: no recorded callers (calls are coarse/import-backed — absence is not proof). Try tmct_impact for the full reverse closure.`;
1413
+ if (!callers.length) return `${modLabel}: no recorded callers (calls are coarse/import-backed — absence is not proof). Try ${toolNamePrefix}impact for the full reverse closure.`;
1406
1414
  return `${modLabel} — called by ${callers.length} module(s):\n ${capJoin(callers, CALL_CAP, "\n ")}`;
1407
1415
  }
1408
1416
 
@@ -1632,7 +1640,7 @@ export function scoreSymbolsRanked(graph, tokens, { kind, decFilter = "", nameRe
1632
1640
  return hits;
1633
1641
  }
1634
1642
 
1635
- function searchSymbols(graph, tokens, { limit = SEARCH_LIMIT, kind, decFilter, nameRe }) {
1643
+ function searchSymbols(graph, tokens, { limit = SEARCH_LIMIT, kind, decFilter, nameRe, toolNamePrefix = "tmct_" }) {
1636
1644
  const targetClass = SYMBOL_CLASSES[kind];
1637
1645
  if (!targetClass) return `unknown kind "${kind}" (use function, class, method, attribute, or module).`;
1638
1646
  const hits = scoreSymbolsRanked(graph, tokens, { kind, decFilter, nameRe });
@@ -1640,7 +1648,7 @@ function searchSymbols(graph, tokens, { limit = SEARCH_LIMIT, kind, decFilter, n
1640
1648
  const top = hits.slice(0, limit);
1641
1649
  const lines = [`${hits.length} ${kind}(s) match (top ${top.length}):`];
1642
1650
  for (const { ind } of top) lines.push(`- ${ind.label}${spanTag(siteOf(ind))}`);
1643
- lines.push("Then tmct_snippet <name> for the exact body, or tmct_describe for its edges.");
1651
+ lines.push(`Then ${toolNamePrefix}snippet <name> for the exact body, or ${toolNamePrefix}describe for its edges.`);
1644
1652
  return lines.join("\n");
1645
1653
  }
1646
1654
 
@@ -30,6 +30,7 @@ export const ENTITY_CLASSES = Object.freeze([
30
30
  export const EDGE_PREDICATES = Object.freeze([
31
31
  "imports", "calls", "callsSymbol", "defines", "tests",
32
32
  "touches", "touchesSymbol", "contains", "inherits", "cochange", "reexports",
33
+ "serves", "denotes",
33
34
  ]);
34
35
 
35
36
  /** The closed effect vocabulary — every way an operator's declared delta can
@@ -219,6 +219,10 @@ export const RELATION_TERM = Object.freeze({
219
219
  export: "reexports", exports: "reexports", exporting: "reexports", exported: "reexports",
220
220
  reexport: "reexports", reexports: "reexports", reexporting: "reexports",
221
221
  "re-export": "reexports", "re-exports": "reexports", "re-exporting": "reexports",
222
+ // provider-declared kinds: tmct's own indexer emits neither, so a graph it
223
+ // built degrades to the honest two-band "no edges" answer rather than a miss.
224
+ serve: "serves", serves: "serves", serving: "serves", served: "serves",
225
+ denote: "denotes", denotes: "denotes", denoting: "denotes", denoted: "denotes",
222
226
  });
223
227
 
224
228
  /** concept key → the relationKind()s whose edges it enumerates. A concept can span
@@ -234,6 +238,8 @@ const RELATION_KINDS = Object.freeze({
234
238
  touches: ["touches", "touchesSymbol"],
235
239
  cochange: ["cochange"],
236
240
  reexports: ["reexports"],
241
+ serves: ["serves"],
242
+ denotes: ["denotes"],
237
243
  });
238
244
 
239
245
  /** concept key → the verb phrase that renders an edge as an English sentence
@@ -248,6 +254,8 @@ const RELATION_RENDER = Object.freeze({
248
254
  touches: { verb: "touches", edgeNoun: "touch" },
249
255
  cochange: { verb: "changes together with", edgeNoun: "change-coupling" },
250
256
  reexports: { verb: "re-exports", edgeNoun: "re-export" },
257
+ serves: { verb: "serves", edgeNoun: "service" },
258
+ denotes: { verb: "denotes", edgeNoun: "denotation" },
251
259
  });
252
260
 
253
261
  /** Per concept key, the candidate follow-up shapes in priority order — each
@@ -290,6 +298,14 @@ const RELATION_FOLLOWUP_SHAPES = Object.freeze({
290
298
  { side: "subj", make: (x) => `what does ${x} export` },
291
299
  { side: "obj", make: (x) => `where is ${x} defined` },
292
300
  ],
301
+ serves: [
302
+ { side: "subj", make: (x) => `what does ${x} serve` },
303
+ { side: "obj", make: (x) => `what serves ${x}` },
304
+ ],
305
+ denotes: [
306
+ { side: "subj", make: (x) => `what does ${x} denote` },
307
+ { side: "obj", make: (x) => `what denotes ${x}` },
308
+ ],
293
309
  });
294
310
 
295
311
  /** How many example edges the relation force shows before the remainder is held for
@@ -186,6 +186,12 @@ const EMBEDDED_MEANS_RE = /^what\s+((?:an?\s+|the\s+)?[\w'-]+(?:\s+[\w'-]+){0,2}
186
186
  * untouched, a relation/interrogative remainder unwraps to itself, anything
187
187
  * else bridges to "describe <thing>". */
188
188
  const SHOW_GIVE_ME_RE = /^(?:show|give)\s+me\s+(?:the\s+)?(.+?)\??$/i;
189
+ /** An "everything I need [to <verb>|for X]" remainder always bridges to
190
+ * "describe <thing>", even when its purpose clause happens to contain a
191
+ * relation verb ("everything I need to change X") — RELATION_VERB_RE's
192
+ * bag-of-words probe would otherwise misread that verb as making the
193
+ * remainder itself an already-relational clause. */
194
+ const EVERYTHING_I_NEED_RE = /^everything\s+i(?:'d|\s+would)?\s+need\b/i;
189
195
 
190
196
  /** Leading STACCATO connective before an ALREADY well-formed question ("and
191
197
  * what imports it") -> the question alone. Gated on the remainder starting
@@ -196,6 +202,12 @@ const SHOW_GIVE_ME_RE = /^(?:show|give)\s+me\s+(?:the\s+)?(.+?)\??$/i;
196
202
  * noise-strip layer never drops. */
197
203
  const LEADING_CONNECTIVE_RE = /^(?:and|also|so|then|now|but)\s+(.+)$/i;
198
204
  const QUESTION_AUX_LEAD_RE = /^(?:does|do|did|is|are|was|were|has|have|had|can|could|will|would|should)\b/i;
205
+ /** A bare relative-clause remainder ("the tests that cover it"), the shape an
206
+ * anaphoric follow-up takes when it names a thing instead of asking outright.
207
+ * Anchored on a determiner and a short head noun so a mid-clause boolean
208
+ * branch ("and are tested", "and call Y") never reaches it. */
209
+ const RELATIVE_CLAUSE_LEAD_RE =
210
+ /^(?:the|its|their|his|her|our|my)\s+[\w'-]+(?:\s+[\w'-]+){0,2}\s+(?:that|which|who)\s+\S/i;
199
211
 
200
212
  /** A topic-switch/self-interruption preamble ("actually never mind, <Q>"),
201
213
  * repeating so a stack of markers peels in one pass. Distinct from
@@ -248,7 +260,9 @@ export function applyPreambleFrames(text) {
248
260
  if (m) {
249
261
  const rest = m[1].trim();
250
262
  if (!isListingRemainder(rest)) {
251
- q = (RELATION_VERB_RE.test(rest) || INTERROGATIVE_LEAD_RE.test(rest)) ? rest : `describe ${rest}`;
263
+ const isRelationClause = !EVERYTHING_I_NEED_RE.test(rest)
264
+ && (RELATION_VERB_RE.test(rest) || INTERROGATIVE_LEAD_RE.test(rest));
265
+ q = isRelationClause ? rest : `describe ${rest}`;
252
266
  }
253
267
  }
254
268
  m = q.match(LEADING_CONNECTIVE_RE);
@@ -261,6 +275,7 @@ export function applyPreambleFrames(text) {
261
275
  INTERROGATIVE_LEAD_RE.test(rest) || QUESTION_AUX_LEAD_RE.test(rest)
262
276
  || TOPIC_SWITCH_PREAMBLE_RE.test(rest) || ACK_PREAMBLE_RE.test(rest)
263
277
  || HEDGE_ADVERB_PREAMBLE_RE.test(rest) || BROWSING_PREAMBLE_RE.test(rest)
278
+ || RELATIVE_CLAUSE_LEAD_RE.test(rest)
264
279
  ) q = rest;
265
280
  }
266
281
  if (q === before) break;
@@ -2,8 +2,10 @@
2
2
  // the rankers that read what they build.
3
3
 
4
4
  /** Does this module path (or a lowercased path-shaped label) belong to test code?
5
- * Covers `test/` and `tests/` segments, Python's `test_*.py` convention, and
6
- * .NET's `*.Tests` assembly suffix. Case-sensitive: callers holding mixed-case
7
- * paths lowercase first. */
5
+ * Covers `test/` and `tests/` segments, a hyphen/underscore-prefixed test directory
6
+ * (`behaviour-tests/`, `unit_test/`), Python's `test_*.py` convention, .NET's
7
+ * `*.Tests` assembly suffix, and a `.test`/`.spec` file anywhere in the tree.
8
+ * Case-sensitive: callers holding mixed-case paths lowercase first. */
8
9
  export const isTestPath = (p) =>
9
- /(^|\/)tests?\//.test(p) || /(^|\/)test_[^/]*\.py$/.test(p) || /\.tests(\.|$)/.test(p);
10
+ /(^|\/)tests?\//.test(p) || /(^|\/)test_[^/]*\.py$/.test(p) || /\.tests(\.|$)/.test(p)
11
+ || /(^|\/)[^/]*[-_]tests?\//.test(p) || /\.(test|spec)\.[cm]?[jt]sx?$/.test(p);