@polycode-projects/the-mechanical-code-talker 1.0.3 → 1.0.4

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/ask.mjs +124 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polycode-projects/the-mechanical-code-talker",
3
- "version": "1.0.3",
3
+ "version": "1.0.4",
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; no codebase index of its own.",
package/src/ask.mjs CHANGED
@@ -311,7 +311,8 @@ function parseSimpleClause(text, nlp) {
311
311
  function parseComposite(text, nlp) {
312
312
  const w = splitWords(text);
313
313
  const lc = w.map((x) => x.toLowerCase());
314
- return parseNegation(text, nlp, 0)
314
+ return parseExistence(w, lc)
315
+ || parseNegation(text, nlp, 0)
315
316
  || parseForwardNegation(w, lc, nlp)
316
317
  || parseTemporal(w, lc, nlp, 0)
317
318
  || parseAnaphora(w, lc, nlp)
@@ -580,6 +581,72 @@ function parsePredicateFilter(words, nlp) {
580
581
  return undefined;
581
582
  }
582
583
 
584
+ /** EXISTENCE: "is there a/an <kind> [called|named <term>] [in <module>] [anywhere]"
585
+ * and "are there any <kind>(s) [called|named <term>] [in <module>]" — a genuine
586
+ * existence question ("does this kind/name exist at all", optionally scoped to a
587
+ * module), answered directly against class/kind membership rather than routed
588
+ * through the relation-verb machinery. Triage bug (2026-07-09, seonix dogfooding):
589
+ * with no dedicated recognizer, "is there a class called Store anywhere" fell
590
+ * through to the legacy keyword-spot strategy, whose lemma tier canonicalizes
591
+ * "called" -> "call" (a `calls` verb — ask-vocab.mjs) and silently answered a
592
+ * DIFFERENT question ("which classes call Store") with a confidently-wrong-shaped
593
+ * negative, even though a class named Store genuinely exists. "is there a class in
594
+ * <module>" walled out the same way — no marker in this grammar recognized it at
595
+ * all. Scoped to a tight closed shape: a leading "is there a/an" or "are there any"
596
+ * immediately followed by a recognized entity-kind noun, then ONLY "called"/"named
597
+ * <term>", "in <module>", the two combined, or an empty/"anywhere"/"at all" tail —
598
+ * anything else (a relative clause, a verb phrase: "is there a class THAT CALLS
599
+ * Store") is a genuine relationship question and is left untouched for the
600
+ * relation parsers below, never swallowed here. */
601
+ function parseExistence(w, lc) {
602
+ let i;
603
+ if (lc[0] === "is" && lc[1] === "there") i = 2;
604
+ else if (lc[0] === "are" && lc[1] === "there") i = 2;
605
+ else return null;
606
+ const article = lc[i];
607
+ if (article === "a" || article === "an" || article === "any") i += 1;
608
+ else return null;
609
+ const noun = i < lc.length ? entityNoun(lc[i]) : null;
610
+ if (!noun || noun.placeholder || !noun.entityType) return null;
611
+ const entityType = noun.entityType;
612
+ i += 1;
613
+
614
+ let rest = lc.slice(i);
615
+ let restW = w.slice(i);
616
+ // trailing filler — "anywhere" / "at all" — stripped so it never gets misread as
617
+ // a (nonexistent) module/name term below.
618
+ if (rest.length && rest[rest.length - 1] === "anywhere") {
619
+ rest = rest.slice(0, -1); restW = restW.slice(0, -1);
620
+ } else if (rest.length >= 2 && rest[rest.length - 2] === "at" && rest[rest.length - 1] === "all") {
621
+ rest = rest.slice(0, -2); restW = restW.slice(0, -2);
622
+ }
623
+
624
+ if (!rest.length) return { node: "exists", entityType, term: null, scopeModule: null };
625
+
626
+ if (rest[0] === "called" || rest[0] === "named") {
627
+ if (rest.length < 2) return { node: "miss", reason: `"${rest[0]}" needs a name afterward` };
628
+ const inIdx = rest.indexOf("in", 1);
629
+ if (inIdx > 0) {
630
+ const term = restW.slice(1, inIdx).join(" ").trim();
631
+ const scopeModule = restW.slice(inIdx + 1).join(" ").trim();
632
+ if (!term || !scopeModule) return { node: "miss", reason: `a named existence check needs both a name and a module after "in"` };
633
+ return { node: "exists", entityType, term, scopeModule };
634
+ }
635
+ const term = restW.slice(1).join(" ").trim();
636
+ return term ? { node: "exists", entityType, term, scopeModule: null }
637
+ : { node: "miss", reason: `"${rest[0]}" needs a name afterward` };
638
+ }
639
+
640
+ if (rest[0] === "in") {
641
+ const scopeModule = restW.slice(1).join(" ").trim();
642
+ return scopeModule ? { node: "exists", entityType, term: null, scopeModule }
643
+ : { node: "miss", reason: `"in" needs a module afterward` };
644
+ }
645
+
646
+ return null; // a relative clause / verb phrase / anything else — genuinely a
647
+ // different (relationship) question; leave it for the parsers below.
648
+ }
649
+
583
650
  /** Trailing "and that's the whole question" filler an aggregate/list tail can carry
584
651
  * ("how many classes are there", "list functions in total", "which classes exist in
585
652
  * the index") — a count/list over a bare kind is frequently phrased with such a tail,
@@ -1479,10 +1546,42 @@ function evalSuperlative(graph, ast) {
1479
1546
  return { compositeKind: "superlative", entityType: ast.entityType, metricNoun: ast.metricNoun, extreme: ast.extreme, score: best, matches: winners };
1480
1547
  }
1481
1548
 
1549
+ /** EXISTENCE eval — "is there a/an <kind> [called/named <term>] [in <module>]": a
1550
+ * direct membership/name check against the graph, never routed through the
1551
+ * relation-verb machinery. A named check resolves the term against the SAME
1552
+ * tiered resolveObject() every other named-lookup shape uses (expectedClass pins
1553
+ * the pool to the asked kind, so "is there a class called Store" can never
1554
+ * resolve to a same-named function/module); a scope clause resolves the module
1555
+ * the same way and narrows the check to that module's own `defines` edges
1556
+ * (refineToEntities — the same primitive members-of-a-module questions use). */
1557
+ function evalExists(graph, ast) {
1558
+ const { entityType, term, scopeModule } = ast;
1559
+ let scopeMatch = null;
1560
+ if (scopeModule) {
1561
+ const r = resolveObject(graph, scopeModule, { expectedClass: "Module" });
1562
+ if (!r.match) return { compositeKind: "exists", entityType, term, scopeModule, scopeMiss: true, matches: [] };
1563
+ scopeMatch = r.match;
1564
+ }
1565
+ if (term) {
1566
+ const r = resolveObject(graph, term, { expectedClass: entityType });
1567
+ const inScope = !scopeMatch || (r.match && moduleIdOf(graph, r.match) === scopeMatch.id);
1568
+ const hit = r.match && inScope;
1569
+ return {
1570
+ compositeKind: "exists", entityType, term, scopeModule, scopeMatch,
1571
+ matches: hit ? [r.match] : [],
1572
+ };
1573
+ }
1574
+ const pool = scopeMatch
1575
+ ? refineToEntities(graph, new Set([scopeMatch.id]), entityType)
1576
+ : graph.individuals.filter((i) => i.class === entityType);
1577
+ return { compositeKind: "exists", entityType, term: null, scopeModule, scopeMatch, matches: pool };
1578
+ }
1579
+
1482
1580
  /** Compile any compositional AST to a result object traverse() returns for the
1483
1581
  * simple path — {matches, …} plus compositeKind/compositeMiss flags render() reads. */
1484
1582
  export function evalComposite(graph, ast, opts = {}) {
1485
1583
  if (ast.node === "miss") return { compositeMiss: true, reason: ast.reason || null, matches: [] };
1584
+ if (ast.node === "exists") return evalExists(graph, ast);
1486
1585
  if (ast.node === "count") return { compositeKind: "count", count: evalSet(graph, ast.base, opts).length, entityType: ast.entityType, matches: [] };
1487
1586
  if (ast.node === "list") return { compositeKind: "list", matches: evalSet(graph, ast.base, opts), entityType: ast.entityType, scoped: ast.scoped };
1488
1587
  if (ast.node === "superlative") return evalSuperlative(graph, ast);
@@ -1532,6 +1631,30 @@ function renderComposite(parsed, result) {
1532
1631
  }
1533
1632
  return { content: `couldn't compile this compositional question${result.reason ? ` (${result.reason})` : ""}. ${compositionalHint()}.`, miss: true, ambiguous: false };
1534
1633
  }
1634
+ // exists: "is there a/an <kind> [called/named <term>] [in <module>]" — an
1635
+ // honest Yes/No membership check, never routed through the relation-verb
1636
+ // machinery (see parseExistence's own doc for the bug this fixes).
1637
+ if (result.compositeKind === "exists") {
1638
+ if (result.scopeMiss) {
1639
+ return { content: `no module matching "${result.scopeModule}" found in the index.`, miss: true, ambiguous: false };
1640
+ }
1641
+ const kindSingular = nounFor(result.entityType, 1);
1642
+ const kindPlural = nounFor(result.entityType, 2);
1643
+ const scopeSuffix = result.scopeMatch ? ` in ${result.scopeMatch.label}` : "";
1644
+ if (result.term) {
1645
+ if (!result.matches.length) {
1646
+ return { content: `No — no ${kindSingular} named "${result.term}" found${scopeSuffix}.`, miss: true, ambiguous: false };
1647
+ }
1648
+ const hit = result.matches[0];
1649
+ const modLabel = moduleLabelOf(hit);
1650
+ const definedIn = hit.class === "Module" ? "" : (modLabel && modLabel !== "(unknown module)" ? `, defined in ${modLabel}` : "");
1651
+ return { content: `Yes — ${hit.label} is a ${kindSingular}${definedIn}.`, miss: false, ambiguous: false, matches: result.matches };
1652
+ }
1653
+ if (!result.matches.length) {
1654
+ return { content: `No — no ${kindPlural} found${scopeSuffix}.`, miss: true, ambiguous: false };
1655
+ }
1656
+ return { content: `Yes — ${compositeList(result.matches)}${scopeSuffix}.`, miss: false, ambiguous: false, matches: result.matches };
1657
+ }
1535
1658
  if (result.compositeKind === "count") {
1536
1659
  const noun = result.entityType ? nounFor(result.entityType, result.count) : (result.count === 1 ? "result" : "results");
1537
1660
  return { content: `${result.count} ${noun}.`, miss: false, ambiguous: false, matches: [] };