@polycode-projects/the-mechanical-code-talker 0.5.0 → 0.7.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/src/codegraph.mjs CHANGED
@@ -1396,7 +1396,20 @@ export function renderHistory(graph, ind) {
1396
1396
  }
1397
1397
 
1398
1398
  /** Modules that call into the target's module (one hop over `calls`). */
1399
+ // Symbol-grain classes whose call graph lives on the fn/method-precise `callsSymbol`
1400
+ // edge, not the module-coarse `calls`. When the resolved target IS one of these, callers/
1401
+ // callees must read the SYMBOL node's own edges — mapping it to its enclosing module (the
1402
+ // old behaviour) both mislabels the answer with `mod:<path>` and scans the wrong edge set,
1403
+ // so "Widget.render --callsSymbol--> fnAlpha" was reported as "no recorded callers".
1404
+ const CALL_SYMBOL_CLASSES = new Set(["Function", "Method"]);
1405
+
1399
1406
  export function renderCallers(graph, ind) {
1407
+ // symbol grain: a fine symbol's callers are the SUBJECTS of callsSymbol edges into it.
1408
+ if (CALL_SYMBOL_CLASSES.has(ind.class)) {
1409
+ const callers = [...new Set(edgesOfKind(graph, "callsSymbol").filter((e) => e.object === ind.id).map((e) => e.subjectLabel || e.subject))];
1410
+ 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.`;
1411
+ return `${ind.label} — called by ${callers.length} symbol(s):\n ${capJoin(callers, CALL_CAP, "\n ")}`;
1412
+ }
1400
1413
  const modId = moduleIdOf(graph, ind);
1401
1414
  if (!modId) return `cannot map ${ind.label} to a module.`;
1402
1415
  const modLabel = graph.byId.get(modId)?.label || modId;
@@ -1405,8 +1418,15 @@ export function renderCallers(graph, ind) {
1405
1418
  return `${modLabel} — called by ${callers.length} module(s):\n ${capJoin(callers, CALL_CAP, "\n ")}`;
1406
1419
  }
1407
1420
 
1408
- /** Modules the target's module calls into (one hop over `calls`). */
1421
+ /** Callees of the target: the fn/method-precise callsSymbol edges when it is a fine symbol,
1422
+ * else the module-coarse `calls` one hop from its module. */
1409
1423
  export function renderCallees(graph, ind) {
1424
+ // symbol grain: a fine symbol's callees are the OBJECTS of its callsSymbol edges.
1425
+ if (CALL_SYMBOL_CLASSES.has(ind.class)) {
1426
+ const callees = [...new Set(edgesOfKind(graph, "callsSymbol").filter((e) => e.subject === ind.id).map((e) => e.objectLabel || e.object))];
1427
+ if (!callees.length) return `${ind.label}: no recorded callees (calls only stdlib/external, or fine-grained call edges are not in the extracted graph).`;
1428
+ return `${ind.label} — calls into ${callees.length} symbol(s):\n ${capJoin(callees, CALL_CAP, "\n ")}`;
1429
+ }
1410
1430
  const modId = moduleIdOf(graph, ind);
1411
1431
  if (!modId) return `cannot map ${ind.label} to a module.`;
1412
1432
  const modLabel = graph.byId.get(modId)?.label || modId;
@@ -1953,20 +1973,23 @@ export function renderCochanges(graph, ind) {
1953
1973
 
1954
1974
  const EXPORTS_CAP = 40;
1955
1975
 
1956
- /** A module's public export surface: each __all__ name → the module that actually
1957
- * defines it (so re-export hubs like __init__ are explicit). */
1976
+ /** A module's public export surface: each exported name → the module that actually
1977
+ * defines it (so re-export hubs like __init__ / an index barrel are explicit). Reads the
1978
+ * `reexports` edge (mgx:reExports), which the extractor emits for ANY public-API construct
1979
+ * — Python `__all__` AND JS/TS `export` / `export { … } from …` — so the wording stays
1980
+ * language-neutral rather than implying a Python-only `__all__`. */
1958
1981
  export function renderExports(graph, ind) {
1959
1982
  const modId = moduleIdOf(graph, ind);
1960
1983
  if (!modId) return `cannot map ${ind.label} to a module.`;
1961
1984
  const modLabel = graph.byId.get(modId)?.label || modId;
1962
1985
  const edges = edgesOfKind(graph, "reexports").filter((e) => e.subject === modId);
1963
- if (!edges.length) return `${modLabel}: no public exports recorded (no literal __all__, or none resolved).`;
1986
+ if (!edges.length) return `${modLabel}: no public exports recorded (no export list / __all__ found, or none resolved).`;
1964
1987
  const list = edges.slice(0, EXPORTS_CAP).map((e) => {
1965
1988
  const origin = graph.byId.get(e.object);
1966
1989
  const where = origin ? siteOf(origin) : null;
1967
1990
  const from = where ? ` ← ${where.path}` : "";
1968
1991
  return `${e.objectLabel || e.object}${from}`;
1969
1992
  });
1970
- return `${modLabel} — public API (${edges.length} export(s) via __all__):\n ${list.join("\n ")}` +
1993
+ return `${modLabel} — public API (${edges.length} export(s)):\n ${list.join("\n ")}` +
1971
1994
  (edges.length > EXPORTS_CAP ? `\n …+${edges.length - EXPORTS_CAP} more` : "");
1972
1995
  }
@@ -0,0 +1,393 @@
1
+ // concept.mjs — "the concept force": compose a THREE-BAND answer to a vague
2
+ // "what is a X" touch, when tmct KNOWS the concept X (a curated definition) AND
3
+ // HAS instances of it (individuals in the code graph and/or remembered isa facts).
4
+ //
5
+ // 1. THE FACT — the definition of X (lead clause of the corpus/seon entry).
6
+ // 2. THE EXAMPLES — real instances of X: code-graph individuals whose class maps
7
+ // to X (capped ~3, stable graph order, each a real node), plus any remembered
8
+ // "A is a X" facts.
9
+ // 3. THE GUIDED FOLLOW-UP — 2-3 concrete, RUNNABLE next questions built from the
10
+ // real instances × the query shapes valid for that kind, EACH PRE-CHECKED by
11
+ // actually running it through ask() so a suggestion can never miss.
12
+ //
13
+ // PURE given (graph, term, {definition, factRows}) — the follow-up validator calls
14
+ // ask() (deterministic, no model), so the whole composition is reproducible. The
15
+ // caller (chat.mjs) owns the async edges: loading corpus/seon/definitions.jsonl and
16
+ // the memory fact rows, and rendering through the response template. This module
17
+ // never fabricates: every example is a real individual/fact and every follow-up is
18
+ // validated against the same graph before it is offered.
19
+
20
+ import { ask } from "./ask.mjs";
21
+ import { relationKind } from "./codegraph.mjs";
22
+
23
+ /** A vague concept term (normalized, singular — normFactTerm's output) → the graph
24
+ * individual `class` it enumerates. The closed set of code-structure concepts the
25
+ * seon lexicon + the graph both understand; anything outside it is not a "concept
26
+ * force" touch (a general-vocabulary term like "cache" has a definition but no
27
+ * enumerable graph class, so it falls back to the ordinary definition surface). */
28
+ export const CONCEPT_CLASS = Object.freeze({
29
+ class: "Class",
30
+ module: "Module",
31
+ function: "Function",
32
+ method: "Method",
33
+ attribute: "Attribute",
34
+ variable: "GlobalVariable",
35
+ constant: "GlobalVariable",
36
+ commit: "Commit",
37
+ });
38
+
39
+ /** class → [singular, plural] noun for the examples band's count ("(3 classes)"). */
40
+ const CLASS_NOUN = Object.freeze({
41
+ Class: ["class", "classes"],
42
+ Module: ["module", "modules"],
43
+ Function: ["function", "functions"],
44
+ Method: ["method", "methods"],
45
+ Attribute: ["attribute", "attributes"],
46
+ GlobalVariable: ["variable", "variables"],
47
+ Commit: ["commit", "commits"],
48
+ });
49
+
50
+ /** The isa-family predicates that make a remembered fact an INSTANCE statement
51
+ * ("A is a X" — rdf:type). A subclass relation (rdfs:subClassOf) is not an
52
+ * instance, so it never contributes an example. */
53
+ const ISA_INSTANCE_PREDICATES = new Set(["rdf:type"]);
54
+
55
+ /** Per graph-class, the candidate follow-up shapes in priority order. Each builder
56
+ * takes ONE real instance label and returns a query string; the builder is offered
57
+ * only after the query VALIDATES against the live graph, so a shape that can't
58
+ * resolve for any instance is silently dropped. Shapes are curated to be exactly
59
+ * the ones ask.mjs's grammar answers for that kind. */
60
+ const FOLLOWUP_SHAPES = Object.freeze({
61
+ Class: [
62
+ (x) => `which classes inherit from ${x}`,
63
+ (x) => `what does ${x} contain`,
64
+ (x) => `where is ${x} defined`,
65
+ ],
66
+ Module: [
67
+ (x) => `what does ${x} import`,
68
+ (x) => `which modules import ${x}`,
69
+ (x) => `where is ${x} defined`,
70
+ ],
71
+ Function: [
72
+ (x) => `what calls ${x}`,
73
+ (x) => `what does ${x} call`,
74
+ (x) => `where is ${x} defined`,
75
+ ],
76
+ Method: [
77
+ (x) => `which class contains ${x}`,
78
+ (x) => `what calls ${x}`,
79
+ (x) => `where is ${x} defined`,
80
+ ],
81
+ Attribute: [
82
+ (x) => `which class contains ${x}`,
83
+ (x) => `where is ${x} defined`,
84
+ ],
85
+ GlobalVariable: [
86
+ (x) => `where is ${x} defined`,
87
+ (x) => `where is ${x} mentioned`,
88
+ ],
89
+ Commit: [
90
+ (x) => `what did commit ${x} touch`,
91
+ (x) => `when did ${x} change`,
92
+ ],
93
+ });
94
+
95
+ // The examples/instances listing shows up to 32 before truncating (the remainder is
96
+ // paginated by the chat shell's "more" mechanism); the GUIDED follow-ups stay small.
97
+ const MAX_EXAMPLES = 32;
98
+ const MAX_FOLLOWUPS = 3;
99
+
100
+ /** The lead clause of a curated definition — cut at the first "; " / ": " so the
101
+ * FACT band is one crisp sentence ("A class is a template that defines the
102
+ * structure and behaviour of objects."), the rest of the entry left implicit. */
103
+ function leadSentence(def) {
104
+ const s = String(def).trim();
105
+ const m = s.match(/^(.*?)[;:]\s/);
106
+ const head = (m ? m[1] : s).replace(/[.;:\s]+$/, "");
107
+ return `${head}.`;
108
+ }
109
+
110
+ function listJoin(a) {
111
+ return a.length > 1 ? `${a.slice(0, -1).join(", ")} and ${a[a.length - 1]}` : a[0];
112
+ }
113
+
114
+ /** Does a candidate follow-up query actually resolve to a real, non-empty answer?
115
+ * Runs it through ask() (deterministic) and checks the honest-miss flag + matches —
116
+ * the exact same engine the user would hit, so a validated suggestion is guaranteed
117
+ * to land. Failure-tolerant: any throw counts as "does not resolve". */
118
+ function resolves(graph, query) {
119
+ try {
120
+ const r = ask(graph, query);
121
+ return !!(r && r.tmct_ask && r.tmct_ask.miss === false
122
+ && Array.isArray(r.tmct_ask.matches) && r.tmct_ask.matches.length > 0);
123
+ } catch {
124
+ return false;
125
+ }
126
+ }
127
+
128
+ /** Build up to MAX_FOLLOWUPS validated follow-ups for a class's instances. For each
129
+ * shape in priority order, find the instances whose query resolves and offer it for
130
+ * the first such instance NOT already used by an earlier follow-up (so the set
131
+ * showcases DIFFERENT real nodes where possible); a shape no instance satisfies is
132
+ * dropped entirely. Deterministic (graph order in, first-fit out). */
133
+ function buildFollowups(graph, cls, instanceLabels) {
134
+ const shapes = FOLLOWUP_SHAPES[cls] || [];
135
+ const used = new Set();
136
+ const out = [];
137
+ for (const shape of shapes) {
138
+ if (out.length >= MAX_FOLLOWUPS) break;
139
+ const valid = instanceLabels.filter((x) => resolves(graph, shape(x)));
140
+ if (!valid.length) continue;
141
+ const pick = valid.find((x) => !used.has(x)) ?? valid[0];
142
+ used.add(pick);
143
+ out.push(shape(pick));
144
+ }
145
+ return out;
146
+ }
147
+
148
+ /** Compose the three bands for a concept term, or null when it is NOT a concept-force
149
+ * case — the term is not a known enumerable concept, has no curated definition, or
150
+ * has NO instances anywhere (code graph and memory both empty). Returns the pieces as
151
+ * strings so the caller can render them through a data template:
152
+ * { definition, examples, followups, instances:[{id,label,type,module}] }
153
+ * `examples` is always non-empty when non-null (we only fire with real instances);
154
+ * `followups` is "" when no validated next-question exists, else a "\nWant to go
155
+ * deeper? Try:\n • …" block. */
156
+ export function composeConcept(graph, term, { definition = null, factRows = [] } = {}) {
157
+ const cls = CONCEPT_CLASS[term];
158
+ if (!cls || !definition) return null;
159
+
160
+ const individuals = (graph && Array.isArray(graph.individuals)) ? graph.individuals : [];
161
+ const graphInstances = individuals.filter((i) => i && i.class === cls);
162
+ const graphLabels = graphInstances.map((i) => i.label);
163
+ const graphLower = new Set(graphLabels.map((l) => String(l).toLowerCase()));
164
+
165
+ // remembered "A is a X" instance facts (rdf:type), objects matching this term —
166
+ // subjects are the instance names, deduped against graph instances by label.
167
+ const memoryLabels = [];
168
+ for (const f of factRows) {
169
+ if (!ISA_INSTANCE_PREDICATES.has(f.predicate)) continue;
170
+ if (String(f.object).toLowerCase() !== term) continue;
171
+ const sub = String(f.subject || "").trim();
172
+ if (sub && !graphLower.has(sub.toLowerCase()) && !memoryLabels.includes(sub)) memoryLabels.push(sub);
173
+ }
174
+
175
+ if (!graphInstances.length && !memoryLabels.length) return null; // no instances → honest miss stands
176
+
177
+ const [sing, plur] = CLASS_NOUN[cls] || [cls.toLowerCase(), `${cls.toLowerCase()}s`];
178
+
179
+ // BAND 1 — the fact.
180
+ const bandDefinition = leadSentence(definition);
181
+
182
+ // BAND 2 — the examples. Up to MAX_EXAMPLES individuals are listed; a longer class
183
+ // holds its remainder for the shell's "more" pagination (say 'more' to see them).
184
+ const shownGraph = graphLabels.slice(0, MAX_EXAMPLES);
185
+ const remainderLabels = graphLabels.slice(MAX_EXAMPLES);
186
+ let bandExamples = "";
187
+ if (shownGraph.length) {
188
+ const total = graphInstances.length;
189
+ const more = remainderLabels.length
190
+ ? ` …and ${remainderLabels.length} more — say 'more' to see them.`
191
+ : "";
192
+ bandExamples = `In this codebase, for example: ${listJoin(shownGraph)} (${total} ${total === 1 ? sing : plur}).${more}`;
193
+ }
194
+ const shownMemory = memoryLabels.slice(0, MAX_EXAMPLES);
195
+ if (shownMemory.length) {
196
+ const lead = bandExamples ? " " : "";
197
+ const verb = shownMemory.length === 1 ? "is a" : "are";
198
+ bandExamples += `${lead}You've also told me ${listJoin(shownMemory)} ${verb} ${sing}.`;
199
+ }
200
+
201
+ // BAND 3 — the guided follow-ups (validated; only real code-graph instances can
202
+ // seed a runnable graph query, so memory-only concepts simply get no follow-ups).
203
+ const followupQueries = buildFollowups(graph, cls, graphLabels);
204
+ const bandFollowups = followupQueries.length
205
+ ? `\nWant to go deeper? Try:\n${followupQueries.map((q) => ` • ${q}`).join("\n")}`
206
+ : "";
207
+
208
+ return {
209
+ definition: bandDefinition,
210
+ examples: bandExamples,
211
+ followups: bandFollowups,
212
+ followupQueries,
213
+ instances: graphInstances.slice(0, MAX_EXAMPLES).map((i) => ({
214
+ id: i.id, label: i.label, type: i.class,
215
+ })),
216
+ // the un-shown instance labels + their plural noun, for the shell's "more"
217
+ // pagination — empty when nothing was truncated.
218
+ remainder: remainderLabels,
219
+ noun: plur,
220
+ };
221
+ }
222
+
223
+ // ============================================================================
224
+ // THE RELATION CONCEPT FORCE — the same three-band shape (definition + real
225
+ // example EDGES + validated follow-ups) for a vague touch on a RELATION/edge kind
226
+ // ("what about imports", "what are the calls", "tell me about contains"). Where
227
+ // composeConcept enumerates INDIVIDUALS of a class, composeRelation enumerates the
228
+ // EDGES of a relation kind, and seeds its follow-ups from real edge endpoints.
229
+ // PURE given (graph, relTerm, {definition}); every example is a real edge and every
230
+ // follow-up is validated via resolves() before it is offered. Never fabricates.
231
+ // ============================================================================
232
+
233
+ /** A vague relation term (lower-cased) → the internal concept key it enumerates.
234
+ * The closed set of edge-kind concepts the seon relation table + the graph both
235
+ * understand; a term outside it is not a relation-force touch. Nouns, gerunds and
236
+ * a couple of synonyms all collapse to one key. */
237
+ export const RELATION_TERM = Object.freeze({
238
+ import: "imports", imports: "imports", importing: "imports", imported: "imports",
239
+ call: "calls", calls: "calls", calling: "calls", called: "calls", invoke: "calls", invokes: "calls", invoking: "calls",
240
+ contain: "contains", contains: "contains", containing: "contains", containment: "contains", member: "contains", members: "contains",
241
+ inherit: "inherits", inherits: "inherits", inheriting: "inherits", inheritance: "inherits",
242
+ extend: "inherits", extends: "inherits", extending: "inherits", subclass: "inherits", subclasses: "inherits", subclassing: "inherits",
243
+ test: "tests", tests: "tests", testing: "tests", tested: "tests", coverage: "tests",
244
+ define: "defines", defines: "defines", defining: "defines", defined: "defines", definition: "defines", definitions: "defines", declaration: "defines",
245
+ touch: "touches", touches: "touches", touching: "touches", touched: "touches",
246
+ cochange: "cochange", "co-change": "cochange", "change-coupling": "cochange", coupled: "cochange",
247
+ });
248
+
249
+ /** concept key → the relationKind()s whose edges it enumerates. A concept can span
250
+ * several graph kinds (calls is both module-coarse and symbol-granular; touches is
251
+ * file- and symbol-level) — the force gathers edges across the whole set. */
252
+ const RELATION_KINDS = Object.freeze({
253
+ imports: ["imports"],
254
+ calls: ["calls", "callsSymbol"],
255
+ contains: ["contains"],
256
+ inherits: ["inherits"],
257
+ tests: ["tests"],
258
+ defines: ["defines"],
259
+ touches: ["touches", "touchesSymbol"],
260
+ cochange: ["cochange"],
261
+ });
262
+
263
+ /** concept key → the verb phrase that renders an edge as an English sentence
264
+ * ("a imports b"), and the singular edge-noun for the count ("(18 import edges)"). */
265
+ const RELATION_RENDER = Object.freeze({
266
+ imports: { verb: "imports", edgeNoun: "import" },
267
+ calls: { verb: "calls", edgeNoun: "call" },
268
+ contains: { verb: "contains", edgeNoun: "containment" },
269
+ inherits: { verb: "inherits from", edgeNoun: "inheritance" },
270
+ tests: { verb: "tests", edgeNoun: "test" },
271
+ defines: { verb: "defines", edgeNoun: "definition" },
272
+ touches: { verb: "touches", edgeNoun: "touch" },
273
+ cochange: { verb: "changes together with", edgeNoun: "change-coupling" },
274
+ });
275
+
276
+ /** Per concept key, the candidate follow-up shapes in priority order. Each shape
277
+ * draws a real endpoint from one SIDE of the edges (subject or object) and builds a
278
+ * query; a shape is offered only once the query VALIDATES against the live graph
279
+ * (resolves()), so a shape no endpoint satisfies is silently dropped. Curated to be
280
+ * exactly the shapes ask.mjs answers for that kind. */
281
+ const RELATION_FOLLOWUP_SHAPES = Object.freeze({
282
+ imports: [
283
+ { side: "obj", make: (x) => `which modules import ${x}` },
284
+ { side: "subj", make: (x) => `what does ${x} import` },
285
+ ],
286
+ calls: [
287
+ { side: "obj", make: (x) => `what calls ${x}` },
288
+ { side: "subj", make: (x) => `what does ${x} call` },
289
+ ],
290
+ contains: [
291
+ { side: "subj", make: (x) => `what does ${x} contain` },
292
+ { side: "obj", make: (x) => `which class contains ${x}` },
293
+ ],
294
+ inherits: [
295
+ { side: "obj", make: (x) => `which classes inherit from ${x}` },
296
+ { side: "subj", make: (x) => `where is ${x} defined` },
297
+ ],
298
+ tests: [
299
+ { side: "obj", make: (x) => `what tests ${x}` },
300
+ { side: "obj", make: (x) => `where is ${x} defined` },
301
+ ],
302
+ defines: [
303
+ { side: "obj", make: (x) => `where is ${x} defined` },
304
+ { side: "subj", make: (x) => `what does ${x} contain` },
305
+ ],
306
+ touches: [
307
+ { side: "obj", make: (x) => `when did ${x} change` },
308
+ { side: "subj", make: (x) => `what did commit ${x} touch` },
309
+ ],
310
+ cochange: [
311
+ { side: "obj", make: (x) => `where is ${x} defined` },
312
+ { side: "subj", make: (x) => `which modules import ${x}` },
313
+ ],
314
+ });
315
+
316
+ /** How many example edges the relation force shows before the remainder is held for
317
+ * the shell's "more" pagination (edges render as full sentences, so a much smaller
318
+ * page than the noun force's 32 instances reads better). */
319
+ const MAX_EDGE_EXAMPLES = 3;
320
+
321
+ const edgeSubjectLabel = (e) => String(e.subjectLabel || e.subject);
322
+ const edgeObjectLabel = (e) => String(e.objectLabel || e.object);
323
+
324
+ /** Build up to MAX_FOLLOWUPS validated follow-ups for a relation's edges. Same
325
+ * first-fit discipline as buildFollowups: for each shape in priority order, find the
326
+ * endpoints (of that shape's side) whose query resolves and offer it for the first
327
+ * such endpoint not already used, so the set showcases DIFFERENT real nodes. */
328
+ function buildRelationFollowups(graph, key, subjLabels, objLabels) {
329
+ const shapes = RELATION_FOLLOWUP_SHAPES[key] || [];
330
+ const used = new Set();
331
+ const out = [];
332
+ for (const shape of shapes) {
333
+ if (out.length >= MAX_FOLLOWUPS) break;
334
+ const pool = shape.side === "subj" ? subjLabels : objLabels;
335
+ const valid = pool.filter((x) => resolves(graph, shape.make(x)));
336
+ if (!valid.length) continue;
337
+ const pick = valid.find((x) => !used.has(x)) ?? valid[0];
338
+ used.add(pick);
339
+ out.push(shape.make(pick));
340
+ }
341
+ return out;
342
+ }
343
+
344
+ /** Compose the three bands for a RELATION concept term, or null when it is NOT a
345
+ * relation-force case — the term is not a known enumerable relation, has no curated
346
+ * definition, or the graph has NO edges of that kind (honest miss stands, never a
347
+ * fabricated edge). Returns the same string-band shape composeConcept does:
348
+ * { definition, examples, followups, followupQueries, remainder, noun }
349
+ * `examples` is always non-empty when non-null (we only fire with real edges);
350
+ * `followups` is "" when no validated next-question exists. */
351
+ export function composeRelation(graph, relTerm, { definition = null } = {}) {
352
+ const key = RELATION_TERM[String(relTerm || "").toLowerCase()];
353
+ if (!key || !definition) return null;
354
+ const kinds = RELATION_KINDS[key] || [];
355
+ const groups = (graph && Array.isArray(graph.relations) ? graph.relations : [])
356
+ .filter((g) => kinds.includes(relationKind(g)));
357
+ const edges = groups.flatMap((g) => (Array.isArray(g.edges) ? g.edges : []));
358
+ if (!edges.length) return null; // no edges of this kind → honest miss stands
359
+ const total = groups.reduce((s, g) => s + (Number(g.count) || (g.edges || []).length), 0);
360
+
361
+ const { verb, edgeNoun } = RELATION_RENDER[key] || { verb: key, edgeNoun: key };
362
+
363
+ // BAND 1 — the fact (the relation defined as a verb/relationship).
364
+ const bandDefinition = leadSentence(definition);
365
+
366
+ // BAND 2 — the example edges, rendered as English sentences with a count. The
367
+ // first MAX_EDGE_EXAMPLES are shown; the remainder is held for "more" pagination.
368
+ const rendered = edges.map((e) => `${edgeSubjectLabel(e)} ${verb} ${edgeObjectLabel(e)}`);
369
+ const shown = rendered.slice(0, MAX_EDGE_EXAMPLES);
370
+ const remainder = rendered.slice(MAX_EDGE_EXAMPLES);
371
+ const countNoun = `${edgeNoun} edge${total === 1 ? "" : "s"}`;
372
+ const more = remainder.length ? ` …and ${remainder.length} more — say 'more' to see them.` : "";
373
+ const bandExamples = `In this codebase, for example: ${listJoin(shown)} (${total} ${countNoun}).${more}`;
374
+
375
+ // BAND 3 — the guided follow-ups, seeded from DISTINCT real edge endpoints and
376
+ // validated against the live graph, so an offered follow-up can never miss.
377
+ const subjLabels = [...new Set(edges.map(edgeSubjectLabel))];
378
+ const objLabels = [...new Set(edges.map(edgeObjectLabel))];
379
+ const followupQueries = buildRelationFollowups(graph, key, subjLabels, objLabels);
380
+ const bandFollowups = followupQueries.length
381
+ ? `\nWant to go deeper? Try:\n${followupQueries.map((q) => ` • ${q}`).join("\n")}`
382
+ : "";
383
+
384
+ return {
385
+ definition: bandDefinition,
386
+ examples: bandExamples,
387
+ followups: bandFollowups,
388
+ followupQueries,
389
+ relation: key,
390
+ remainder,
391
+ noun: countNoun,
392
+ };
393
+ }
@@ -0,0 +1,166 @@
1
+ // conformance.mjs — the Repository-Interface CONTRACT TEST SUITE as a reusable kit.
2
+ //
3
+ // PLAN_REPOSITORY_INTERFACE.md deliverable 3: an implementation is CONFORMANT iff it
4
+ // passes `runConformance(name, makeProvider)`. tmct's own fixture + bootstrap providers
5
+ // pass it in `npm test`; an EXTERNAL producer (seonix) imports this kit from the
6
+ // published package and runs the SAME suite against its native provider to claim
7
+ // conformance — conformance is the suite, not prose. It is provider-agnostic: it
8
+ // asserts the SHAPE + the error contract; data-bearing truth is asserted by the caller
9
+ // where its provider carries data.
10
+ //
11
+ // Public surface (exported here + via the package "./conformance" subpath):
12
+ // runConformance(name, makeProvider), assertResult, assertIndividual, assertEdge.
13
+ import { test } from "node:test";
14
+ import assert from "node:assert/strict";
15
+ import {
16
+ INTERFACE_VERSION,
17
+ MISS_REASONS,
18
+ EDGE_KINDS,
19
+ SERVICES,
20
+ SOURCE_SERVICES,
21
+ isHit,
22
+ isMiss,
23
+ } from "./repository-interface.mjs";
24
+
25
+ const REASONS = new Set(Object.values(MISS_REASONS));
26
+
27
+ // ---- Individual / Edge / Result shape validators (the shared wire types) ------
28
+
29
+ export function assertIndividual(ind, where) {
30
+ assert.equal(typeof ind.id, "string", `${where}: Individual.id is a string`);
31
+ assert.equal(typeof ind.label, "string", `${where}: Individual.label is a string`);
32
+ assert.equal(typeof ind.class, "string", `${where}: Individual.class is a string (a tmct: class token)`);
33
+ assert.ok(Array.isArray(ind.attributes), `${where}: Individual.attributes is an array`);
34
+ for (const a of ind.attributes) {
35
+ assert.ok("key" in a && "value" in a && "prop" in a, `${where}: attribute has {key,value,prop}`);
36
+ }
37
+ }
38
+
39
+ export function assertEdge(e, where) {
40
+ for (const k of ["subject", "object", "predicate", "prop"]) {
41
+ assert.ok(k in e, `${where}: Edge carries required "${k}"`);
42
+ }
43
+ assert.equal(typeof e.subject, "string", `${where}: Edge.subject is a string`);
44
+ assert.equal(typeof e.object, "string", `${where}: Edge.object is a string`);
45
+ }
46
+
47
+ export function assertResult(r, where) {
48
+ assert.ok(r && typeof r === "object", `${where}: a Result object`);
49
+ assert.equal(typeof r.ok, "boolean", `${where}: Result.ok is a boolean`);
50
+ if (r.ok) {
51
+ assert.ok("value" in r, `${where}: a hit carries value`);
52
+ } else {
53
+ assert.ok(r.miss && REASONS.has(r.miss.reason), `${where}: a miss carries a CLOSED-set reason (got ${r.miss?.reason})`);
54
+ assert.equal(typeof r.miss.detail, "string", `${where}: miss.detail is a string`);
55
+ }
56
+ }
57
+
58
+ // =============================================================================
59
+ // The provider-agnostic conformance kit. Run it against any implementation.
60
+ // =============================================================================
61
+ export function runConformance(name, makeProvider) {
62
+ test(`[${name}] advertises the full interface + version`, () => {
63
+ const svc = makeProvider();
64
+ assert.equal(svc.version, INTERFACE_VERSION, "declares the interface version");
65
+ for (const service of SERVICES) {
66
+ assert.ok(svc.capabilities.includes(service), `capability advertises "${service}"`);
67
+ assert.equal(typeof svc[service], "function", `implements "${service}" as a function`);
68
+ }
69
+ });
70
+
71
+ test(`[${name}] every service returns a well-formed Result (or an honest empty)`, () => {
72
+ const svc = makeProvider();
73
+ // Resolution-family with a term that certainly does not exist → a well-formed result.
74
+ for (const [service, args] of [
75
+ ["resolve", ["definitely-not-a-symbol-xyz"]],
76
+ ["describe", ["no:such:id"]],
77
+ ["members", ["no:such:id"]],
78
+ ["subclasses", ["no:such:id"]],
79
+ ["exports", ["no:such:id"]],
80
+ ["signature", ["no:such:id"]],
81
+ ["impact", ["no:such:id"]],
82
+ ["history", ["no:such:id"]],
83
+ ["snippet", ["no:such:id"]],
84
+ ["context", ["no-such-symbol"]],
85
+ ["architecture", [{}]],
86
+ ["untested", []],
87
+ ["stats", []],
88
+ ["search", ["", {}]],
89
+ ["ask", ["what is here"]],
90
+ ]) {
91
+ const r = svc[service](...args);
92
+ assertResult(r, `${name}.${service}`);
93
+ }
94
+ });
95
+
96
+ test(`[${name}] the error contract: a clean miss is a value, never a throw`, () => {
97
+ const svc = makeProvider();
98
+ // An unresolved term is a first-class UNRESOLVED_TERM miss on every id-taking service.
99
+ for (const service of ["describe", "members", "subclasses", "exports", "signature", "impact", "history"]) {
100
+ const r = svc[service]("no:such:id:at:all");
101
+ assert.ok(isMiss(r), `${service} on an absent id misses`);
102
+ assert.equal(r.miss.reason, MISS_REASONS.UNRESOLVED_TERM, `${service} → UNRESOLVED_TERM`);
103
+ }
104
+ });
105
+
106
+ test(`[${name}] edges: closed kind vocabulary; unknown kind is misuse (throws)`, () => {
107
+ const svc = makeProvider();
108
+ // A valid kind on a missing id misses (UNRESOLVED_TERM), never throws.
109
+ const r = svc.edges("no:such:id", EDGE_KINDS[0]);
110
+ assertResult(r, `${name}.edges`);
111
+ // An unknown kind is a programmer error, not a domain miss.
112
+ assert.throws(() => svc.edges("no:such:id", "not-a-real-kind"), TypeError);
113
+ });
114
+
115
+ test(`[${name}] source services answer NO_SOURCE (not a throw) when no working tree`, () => {
116
+ const svc = makeProvider();
117
+ if (svc.sourceAccess) return; // a source-capable provider is exempt from this shape
118
+ for (const service of SOURCE_SERVICES) {
119
+ // Use whatever the provider resolves; on empty graphs this is UNRESOLVED_TERM, on
120
+ // data-bearing graphs NO_SOURCE — both are valid closed-set misses.
121
+ const arg = service === "context" ? "x" : "no:such:id";
122
+ const r = svc[service](arg);
123
+ assert.ok(isMiss(r), `${service} misses without a working tree`);
124
+ assert.ok(
125
+ [MISS_REASONS.NO_SOURCE, MISS_REASONS.UNRESOLVED_TERM].includes(r.miss.reason),
126
+ `${service} miss reason is NO_SOURCE or UNRESOLVED_TERM (got ${r.miss.reason})`,
127
+ );
128
+ }
129
+ });
130
+
131
+ test(`[${name}] stats / untested / architecture never miss — empty is a hit`, () => {
132
+ const svc = makeProvider();
133
+ const stats = svc.stats();
134
+ assert.ok(isHit(stats), "stats is always a hit");
135
+ assert.equal(typeof stats.value.total, "number");
136
+ assert.ok(Array.isArray(stats.value.classes));
137
+ assert.ok(isHit(svc.untested()), "untested is always a hit");
138
+ assert.ok(Array.isArray(svc.untested().value.modules));
139
+ assert.ok(isHit(svc.architecture({})), "architecture is always a hit");
140
+ });
141
+
142
+ test(`[${name}] concurrent/re-entrant: two handles, interleaved reads, stable & independent`, async () => {
143
+ const a = makeProvider();
144
+ const b = makeProvider();
145
+ // Fire the whole read surface concurrently across two independent handles; assert
146
+ // every result is well-formed and that a second identical call is byte-stable.
147
+ const calls = [
148
+ () => a.stats(),
149
+ () => b.stats(),
150
+ () => a.architecture({}),
151
+ () => a.untested(),
152
+ () => b.search("", {}),
153
+ () => a.resolve("x"),
154
+ () => b.ask("anything"),
155
+ () => a.describe("no:such:id"),
156
+ ];
157
+ const first = await Promise.all(calls.map((c) => Promise.resolve().then(c)));
158
+ for (const r of first) assertResult(r, `${name}.concurrent`);
159
+ const second = await Promise.all(calls.map((c) => Promise.resolve().then(c)));
160
+ // Determinism across handles: same query, same JSON.
161
+ assert.equal(JSON.stringify(first.map((r) => r.ok)), JSON.stringify(second.map((r) => r.ok)));
162
+ // The two handles are independent objects (no shared mutable state leak).
163
+ assert.notEqual(a, b);
164
+ assert.deepEqual(a.stats().value, b.stats().value, "same graph → same stats across handles");
165
+ });
166
+ }