@polycode-projects/seonix 0.10.14 → 0.10.18

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polycode-projects/seonix",
3
- "version": "0.10.14",
3
+ "version": "0.10.18",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "SEONIX — a deterministic, offline, $0 code-graph tool that makes a cheap coding agent edit like an expensive one. Indexes a repo with Python ast + git (zero model calls) and renders a bounded edit-digest.",
@@ -50,7 +50,7 @@
50
50
  "setup": "node scripts/setup.mjs",
51
51
  "bench": "node bench/run.mjs",
52
52
  "bench:quick": "node bench/run.mjs --instances 1 --runs 1",
53
- "bench:telemetry": "node bench/run.mjs --suite django-lh --arms seonix-ask --instances 1 --runs 1 --telemetry",
53
+ "bench:telemetry": "node bench/run.mjs --suite django-lh --arms seonix --instances 1 --runs 1 --telemetry",
54
54
  "join:telemetry": "node scripts/join-telemetry.mjs",
55
55
  "bench:lite": "node bench/run.mjs --suite lite",
56
56
  "bench:seonix": "node bench/run.mjs --suite django-lh --arms seonix,seonix-min --runs 5",
@@ -89,7 +89,7 @@
89
89
  },
90
90
  "dependencies": {
91
91
  "@modelcontextprotocol/sdk": "^1.29.0",
92
- "@polycode-projects/the-mechanical-code-talker": "^1.0.4",
92
+ "@polycode-projects/the-mechanical-code-talker": "^1.3.0",
93
93
  "cytoscape": "^3.30.0",
94
94
  "smol-toml": "^1.7.0",
95
95
  "tree-sitter": "^0.21.1",
@@ -180,8 +180,17 @@
180
180
  const m = String(ind?.id || "").match(/^fn:(.+)#/);
181
181
  return m ? `mod:${m[1]}` : null;
182
182
  }
183
+ var HISTORY_CAP = 15;
183
184
 
184
185
  // node_modules/@polycode-projects/the-mechanical-code-talker/src/ask-vocab.mjs
186
+ var INHERITS_REVERSE_VERB_LIST = [
187
+ "is a superclass of",
188
+ "are a superclass of",
189
+ "is a parent class of",
190
+ "are a parent class of",
191
+ "superclass",
192
+ "superclasses"
193
+ ];
185
194
  var RELATIONS = {
186
195
  imports: {
187
196
  comment: "Module -> Module: subject's import graph references object (usesComplexType).",
@@ -323,6 +332,17 @@
323
332
  verbs: [
324
333
  "inherits from",
325
334
  "inherit from",
335
+ // bare "inherits"/"inherit" (Tier 6 playtest, §3b surface-variation axis):
336
+ // this list's own SIBLING verb "extends"/"extend" already works bare, with
337
+ // no "from" required, but "inherits"/"inherit" — arguably the MORE common
338
+ // everyday phrasing of the two ("TaskController inherits Controller",
339
+ // "does TaskController inherit Controller") — had no bare form at all,
340
+ // only the "... from" variant. VERB_ALT's longest-first sort (already
341
+ // relied on elsewhere in this file for the same reason) means "inherits
342
+ // from"/"inherit from" still win whenever "from" actually follows, so
343
+ // this is purely additive.
344
+ "inherits",
345
+ "inherit",
326
346
  "extends",
327
347
  "extend",
328
348
  "subclasses",
@@ -347,7 +367,13 @@
347
367
  "inheriting from",
348
368
  "inheriting",
349
369
  "subclassing",
350
- "extends from"
370
+ "extends from",
371
+ // REVERSE phrasings (Seonix Batch 2 Fix 2, see INHERITS_REVERSE_VERB_LIST's own
372
+ // comment above): "is/are a|the superclass/parent class of" — folded in here so
373
+ // VERB_TO_KIND maps them to "inherits" like every other verb in this list; the
374
+ // subject/object SWAP their direction requires is handled at parse time by the
375
+ // strategies that build the "ask" shape, not here.
376
+ ...INHERITS_REVERSE_VERB_LIST
351
377
  ]
352
378
  },
353
379
  touches: {
@@ -424,7 +450,17 @@
424
450
  // producing a confidently-empty answer regardless of real cochange data.
425
451
  "changed together with",
426
452
  "change together with",
427
- "changes together with"
453
+ "changes together with",
454
+ // Present-tense bare form (Seonix Batch 4/5 follow-up): only the past tense
455
+ // "changed with" existed above — "what changes with X"/"what usually changes
456
+ // with X" fell through entirely (ENTITY_TO_TYPE's own "changes"->"Change"
457
+ // pseudo-type noun risked consuming the word first; see parseRelationalOrQualified's
458
+ // guard against exactly that in ask.mjs).
459
+ "changes with",
460
+ "change with",
461
+ "tends to change with",
462
+ "tend to change with",
463
+ "usually changes with"
428
464
  ]
429
465
  },
430
466
  reexports: {
@@ -447,8 +483,30 @@
447
483
  ]
448
484
  }
449
485
  };
486
+ var INHERITS_REVERSE_VERBS = Object.freeze([
487
+ ...INHERITS_REVERSE_VERB_LIST,
488
+ "is the superclass of",
489
+ "are the superclass of",
490
+ "is the parent class of"
491
+ ]);
450
492
  var WHERE_MARKERS = Object.freeze(["defined", "declared", "located", "implemented"]);
451
493
  var MENTION_MARKERS = Object.freeze(["mentioned", "referenced"]);
494
+ var TRAILING_SCOPE_FILLER = Object.freeze([
495
+ "in this graph",
496
+ "in the graph",
497
+ "in this codebase",
498
+ "in the codebase",
499
+ "in this repo",
500
+ "in the repo",
501
+ "here"
502
+ ]);
503
+ var TRAILING_SCOPE_FILLER_RE = new RegExp(
504
+ `\\s+(?:${TRAILING_SCOPE_FILLER.join("|")})\\s*[?.!]*$`,
505
+ "i"
506
+ );
507
+ function stripTrailingScopeFiller(text) {
508
+ return text.replace(TRAILING_SCOPE_FILLER_RE, "").trim();
509
+ }
452
510
  var VERB_TO_KIND = Object.freeze(
453
511
  Object.fromEntries(
454
512
  Object.entries(RELATIONS).flatMap(([kind, { verbs }]) => verbs.map((v) => [v, kind]))
@@ -637,6 +695,14 @@
637
695
  "mnay": "many",
638
696
  "amny": "many",
639
697
  "mnany": "many",
698
+ // "hwo" (Tier 6 playtest, §3b typo axis): a transposed-letter typo of "how" —
699
+ // "hwo many classes are there" used to lose the aggregate/list trigger
700
+ // outright ("how many" only reads as a count trigger when both words are
701
+ // exact), same failure class as "manyn"/"mnay" just above, one word to the
702
+ // left of it. "how" is grammar-owned via AGGREGATE_TRIGGERS' own "how many"/
703
+ // "how much" entries (test/ask-vocab.test.mjs's canonical-value check
704
+ // splits those multi-word triggers into individual words).
705
+ "hwo": "how",
640
706
  "coutn": "count",
641
707
  "conut": "count",
642
708
  "cuont": "count",
@@ -981,6 +1047,8 @@
981
1047
  );
982
1048
  var MISSPELLING_RE = correctionRe(MISSPELLINGS);
983
1049
  var WRONG_WORD_RE = correctionRe(WRONG_WORDS);
1050
+ var KIND_NOUN_ANAPHORA_RE = /\b(this|that)\s+(class|module|function|method|attribute|variable|file|commit)\b/gi;
1051
+ var VERB_ALTERNATION = Object.keys(VERB_TO_KIND).sort((a, b) => b.length - a.length).map(escapeRegex).join("|");
984
1052
  var RELATION_VERB_RE = new RegExp(
985
1053
  "\\b(?:" + Object.keys(VERB_TO_KIND).sort((a, b) => b.length - a.length).map(escapeRegex).join("|") + ")\\b",
986
1054
  "i"
@@ -1008,23 +1076,42 @@
1008
1076
  const words = rest.replace(/\?+\s*$/, "").trim().split(/\s+/);
1009
1077
  return LISTING_TAIL_KINDS.has((words[words.length - 1] || "").toLowerCase());
1010
1078
  };
1011
- var GREETING_PREAMBLE_RE = /^(?:hi|hiya|hello|hey|yo|howdy|g'?day|good\s+(?:morning|afternoon|evening|day)|greetings|salutations)(?:\s+there)?\s*[,.—–-]\s*(?:(?:just\s+a\s+)?quick\s+question\s*[,:—–-]?\s*)?(.+)$/i;
1079
+ var GREETING_PREAMBLE_RE = /^(?:hi|hiya|hello|hey|yo|howdy|g'?day|yeah\s+nah|good\s+(?:morning|afternoon|evening|day)|greetings|salutations)(?:\s+(?:there|pardner|folks|friend|mate))?\s*[,.—–-]\s*(?:(?:just\s+a\s+)?quick\s+question\s*[,:—–-]?\s*)?(.+)$/i;
1012
1080
  var THANKS_PREAMBLE_RE = /^(?:thanks|thank\s+you|many\s+thanks|thx|ty|cheers)(?:\s+(?:so\s+much|a\s+lot|very\s+much|a\s+bunch))?\s*[,—–-]\s*(?:(?:just\s+a\s+)?quick\s+question\s*[,:—–-]?\s*)?(.+)$/i;
1081
+ var ACK_PREAMBLE_RE = /^(?:(?:ok(?:ay)?|aight|cool|alright|sure|right|fine|great|nice|got it|gotcha|sounds good|no worries|no problem)[\s,]+)+(.+)$/i;
1082
+ var BROWSING_PREAMBLE_RE = /^just\s+(?:poking\s+around|looking\s+around|browsing|exploring|checking\s+(?:this|it)\s+out)\s*[,.—–-]\s*(.+)$/i;
1083
+ var HEDGE_ADVERB_PREAMBLE_RE = /^(?:(?:maybe|possibly|perhaps)\s+)+(.+)$/i;
1084
+ var TROUBLE_ASIDE_RE = /,?\s*if\s+(?:it'?s|it\s+is|that'?s|that\s+is)\s+not\s+too\s+much\s+(?:trouble|bother|hassle)\s*,?\s*/i;
1013
1085
  var MODAL_WRAPPER_RE = /^(?:can|could|would|will)\s+you\s+(?:please\s+)?(.+?)(?:[,\s]+please)?\??$/i;
1014
1086
  var EXPLAIN_WRAPPER_RE = /^explain\s+(?:to\s+me\s+|please\s+)*(.+?)\??$/i;
1087
+ var TELL_ME_WRAPPER_RE = /^tell\s+me\s+(.+?)\??$/i;
1015
1088
  var SHOW_GIVE_ME_RE = /^(?:show|give)\s+me\s+(?:the\s+)?(.+?)\??$/i;
1089
+ var LEADING_CONNECTIVE_RE = /^(?:and|also|so|then|now|but)\s+(.+)$/i;
1090
+ var QUESTION_AUX_LEAD_RE = /^(?:does|do|did|is|are|was|were|has|have|had|can|could|will|would|should)\b/i;
1091
+ var TOPIC_SWITCH_PREAMBLE_RE = /^(?:(?:actually|no\s+wait|wait|hold\s+on|never\s+mind|scratch\s+that|on\s+second\s+thought|i\s+mean(?:t)?)[\s,.]+)+(.+)$/i;
1016
1092
  function applyPreambleFrames(text) {
1017
1093
  let q = String(text || "");
1094
+ q = q.replace(TROUBLE_ASIDE_RE, " ").replace(/\s+/g, " ").trim();
1018
1095
  for (let pass = 0; pass < 3; pass++) {
1019
1096
  const before = q;
1020
1097
  let m = q.match(GREETING_PREAMBLE_RE);
1021
1098
  if (m) q = m[1].trim();
1022
1099
  m = q.match(THANKS_PREAMBLE_RE);
1023
1100
  if (m) q = m[1].trim();
1101
+ m = q.match(ACK_PREAMBLE_RE);
1102
+ if (m) q = m[1].trim();
1103
+ m = q.match(BROWSING_PREAMBLE_RE);
1104
+ if (m) q = m[1].trim();
1105
+ m = q.match(HEDGE_ADVERB_PREAMBLE_RE);
1106
+ if (m) q = m[1].trim();
1107
+ m = q.match(TOPIC_SWITCH_PREAMBLE_RE);
1108
+ if (m) q = m[1].trim();
1024
1109
  m = q.match(MODAL_WRAPPER_RE);
1025
1110
  if (m) q = m[1].trim();
1026
1111
  m = q.match(EXPLAIN_WRAPPER_RE);
1027
1112
  if (m && INTERROGATIVE_LEAD_RE.test(m[1].trim())) q = m[1].trim();
1113
+ m = q.match(TELL_ME_WRAPPER_RE);
1114
+ if (m && INTERROGATIVE_LEAD_RE.test(m[1].trim())) q = m[1].trim();
1028
1115
  m = q.match(SHOW_GIVE_ME_RE);
1029
1116
  if (m) {
1030
1117
  const rest = m[1].trim();
@@ -1032,6 +1119,11 @@
1032
1119
  q = RELATION_VERB_RE.test(rest) || INTERROGATIVE_LEAD_RE.test(rest) ? rest : `describe ${rest}`;
1033
1120
  }
1034
1121
  }
1122
+ m = q.match(LEADING_CONNECTIVE_RE);
1123
+ if (m) {
1124
+ const rest = m[1].trim();
1125
+ if (INTERROGATIVE_LEAD_RE.test(rest) || QUESTION_AUX_LEAD_RE.test(rest) || TOPIC_SWITCH_PREAMBLE_RE.test(rest) || ACK_PREAMBLE_RE.test(rest) || HEDGE_ADVERB_PREAMBLE_RE.test(rest) || BROWSING_PREAMBLE_RE.test(rest)) q = rest;
1126
+ }
1035
1127
  if (q === before) break;
1036
1128
  }
1037
1129
  return q;
@@ -1102,6 +1194,7 @@
1102
1194
  q = q.replace(CONTRACTION_RE, (m) => CONTRACTIONS[m.toLowerCase()]);
1103
1195
  q = q.replace(MISSPELLING_RE, (m) => MISSPELLINGS[m.toLowerCase()]);
1104
1196
  q = q.replace(WRONG_WORD_RE, (m) => WRONG_WORDS[m.toLowerCase()]);
1197
+ q = q.replace(KIND_NOUN_ANAPHORA_RE, (_, pron) => pron);
1105
1198
  q = q.replace(G_DROP, "$1ing");
1106
1199
  q = applyPreambleFrames(q);
1107
1200
  q = applySelfCorrectionFrames(q);
@@ -1136,6 +1229,23 @@
1136
1229
  { re: /^(?:the\s+)?(?:members?|methods?|attributes?|contents)\s+of\s+(?:the\s+)?(.+?)\??$/i, to: (m) => `what does ${m[1]} contain` },
1137
1230
  // "what's in X" / "what is in X" (contraction already expanded; sha handled above)
1138
1231
  { re: /^what\s+is\s+(?:in|inside)\s+(?:the\s+)?(.+?)\??$/i, to: (m) => `what does ${m[1]} contain` },
1232
+ // "what else is in X" (0.9.15 Tier-1 single-touch playtest) — the natural
1233
+ // "besides what I already know" drill-down after a members-of-class answer.
1234
+ // Distinct from the "what else does X <verb>" family (which the compositional
1235
+ // grammar already tolerates, dropping "else" as noise on its own): the "is
1236
+ // in" idiom is NOT a compositional marker, so parseComposite never sees it and
1237
+ // "what else is in X" fell through to the strategies with NO candidate at all
1238
+ // (neither recognizes the bare "is in" idiom once "else" sits in front of it).
1239
+ // The only rescue was the relaxation cascade's drop-unmatched layer — but that
1240
+ // layer refuses to accept a relaxed reading that still renders an honest EMPTY
1241
+ // (by design: relaxation must turn a miss into a real answer, never into
1242
+ // another kind of miss), so a genuinely empty class ("what else is in
1243
+ // Task.complete" — a method, no members) bottomed out at the bare grammar
1244
+ // wall instead of the specific "no contains edges" receipt. Routing this
1245
+ // frame onto the SAME direct "what does X contain" path the plain "what is
1246
+ // in X" frame above already uses sidesteps the cascade's conservative gate
1247
+ // entirely, so a real empty is reported honestly instead of walled.
1248
+ { re: /^what\s+else\s+is\s+(?:in|inside)\s+(?:the\s+)?(.+?)\??$/i, to: (m) => `what does ${m[1]} contain` },
1139
1249
  // WHERE-DEFINED → "where is X defined". PAST TENSE ONLY ("what defined X", "what
1140
1250
  // declared X"): the PRESENT "what defines X" already parses as a reverse-defines
1141
1251
  // query (the module defining symbol X — test/ask.test.mjs pins that), so rewriting
@@ -1226,7 +1336,26 @@
1226
1336
  // grammar wall ("no module matching 'needs'…"). Route it onto the same attributive
1227
1337
  // survey the bare "what is untested" frame lands on. Closed to the tests/coverage
1228
1338
  // object, so it can't swallow a general "what needs X".
1229
- { re: /^what\s+needs\s+(?:to\s+be\s+)?(?:a\s+)?(?:tested|tests?|testing|coverage|covering)\??$/i, to: () => "untested modules" }
1339
+ { re: /^what\s+needs\s+(?:to\s+be\s+)?(?:a\s+)?(?:tested|tests?|testing|coverage|covering)\??$/i, to: () => "untested modules" },
1340
+ // DOES-X-VERB-ANYTHING-ELSE → the plain forward "what does X <verb>" listing
1341
+ // (0.9.15 Tier-1 single-touch playtest). A very natural drill-down follow-up
1342
+ // after a relation answer — "does listTasks call anything else", "does
1343
+ // src/handlers/tasks.mjs import something else" — used to dead-end: "anything"/
1344
+ // "something" [else] is a placeholder standing in for "the rest of the list",
1345
+ // not a real object term, but the two parse strategies disagreed on the SPAN
1346
+ // (grammar kept "anything else" whole as the object, keyword-spot dropped
1347
+ // "anything" and kept only "else"), landing on the {ambiguousParse} surface —
1348
+ // two nonsense readings offered as if one might be right. "what does X <verb>"
1349
+ // is the exact working canonical shape (see the MEMBERS-of-class frames above),
1350
+ // so rewriting the whole closed pattern onto it sidesteps the disagreement
1351
+ // instead of teaching either strategy's tokenizer to special-case "else".
1352
+ // Anchored to the closed VERB_TO_KIND vocabulary so it can never swallow a
1353
+ // genuine named object that happens to start with "any"/"some" (only the bare
1354
+ // placeholder nouns "anything"/"something", optionally trailed by "else", match).
1355
+ {
1356
+ re: new RegExp(`^(?:do|does)\\s+(.+?)\\s+(${VERB_ALTERNATION})\\s+(?:anything|something)(?:\\s+else)?\\??$`, "i"),
1357
+ to: (m) => `what does ${m[1]} ${m[2]}`
1358
+ }
1230
1359
  ]);
1231
1360
  function applyPhrasingFrames(text) {
1232
1361
  for (const frame of PHRASING_FRAMES) {
@@ -1372,17 +1501,28 @@
1372
1501
  // does/is/do/did, which the reverse/forward templates below never match (those start with
1373
1502
  // which/what), so precedence between T1 and the rest is structural, not a tie-break guess.
1374
1503
  // "did" joins does/do for the past-tense commit forms ("did commit <sha> touch X").
1504
+ // REVERSE VERB SWAP (Seonix Batch 2 Fix 2): this template fixes subject/object by regex
1505
+ // capture POSITION, not by the verb's semantic direction — fine for every forward verb
1506
+ // ("subclass of", "imports", …), but "is X a superclass of Y" MEANS the reverse of "is X
1507
+ // a subclass of Y" (Y inherits from X, not X from Y). INHERITS_REVERSE_VERBS (ask-vocab.mjs)
1508
+ // is the closed set of such reverse phrasings; when the matched verb is one of them,
1509
+ // subject/object are swapped here, once, at parse time — so downstream evaluation (ask.mjs)
1510
+ // sees "is Y a subclass of X" and needs zero changes of its own. (In practice this exact
1511
+ // regex only ever matches a does/do/did lead, so "is …" phrasings actually reach the "ask"
1512
+ // shape via keywords.mjs's decomposition strategy instead — that strategy applies the same
1513
+ // swap for the same reason; this branch is kept for any does/do/did-led phrasing that names
1514
+ // a reverse verb, and for structural symmetry with that sibling strategy.)
1375
1515
  {
1376
1516
  name: "ask",
1377
1517
  re: new RegExp(`^(?:does|do|did)\\s+(.+?)\\s+(${VERB_ALT})\\s+(.+?)\\??$`, "i"),
1378
- build: (m) => ({
1379
- shape: "ask",
1380
- entityType: null,
1381
- modifier: "direct",
1382
- kind: VERB_TO_KIND[m[2].toLowerCase()],
1383
- subject: m[1].trim(),
1384
- object: m[3].trim()
1385
- })
1518
+ build: (m) => {
1519
+ const verb = m[2].toLowerCase();
1520
+ const kind = VERB_TO_KIND[verb];
1521
+ let subject = m[1].trim();
1522
+ let object = m[3].trim();
1523
+ if (INHERITS_REVERSE_VERBS.includes(verb)) [subject, object] = [object, subject];
1524
+ return { shape: "ask", entityType: null, modifier: "direct", kind, subject, object };
1525
+ }
1386
1526
  },
1387
1527
  // T2 reverse: "which <entity> [<modifier>] <verb> <object>" — the operator's own example shape.
1388
1528
  {
@@ -1422,15 +1562,32 @@
1422
1562
  build: (m) => ({ shape: "meta", entityType: null, modifier: "direct", kind: "meta", object: m[1].trim() })
1423
1563
  },
1424
1564
  // T5 meta: "what is a/an <term>" — the OTHER worked phrasing ("what is a Commit").
1425
- // The indefinite article is REQUIRED (not optional): a bare "what is <anything>"
1426
- // would also swallow "what is the meaning of this codebase" (an existing, deliberately
1427
- // honest grammar-miss regression case — ask.test.mjs/ask-dual-strategy.test.mjs both
1428
- // assert it stays null), which never mentions "a"/"an" before its tail. Requiring the
1429
- // article keeps this template's reach to the one worked shape without reopening that.
1565
+ // Seonix Batch 2 Fix 1: the indefinite article is now OPTIONAL, but the BARE
1566
+ // (no-article) form is restricted to the CLOSED vocabulary ENTITY_TO_TYPE already
1567
+ // imported above (function/method/class/module/attribute/variable/change/commit,
1568
+ // singular and plural) — build() returns null (same "this template didn't actually
1569
+ // match, keep scanning" contract T8/"when" below already relies on see
1570
+ // parseAnchored's own docblock) when the bare form's object isn't one of those
1571
+ // closed terms, so the scan falls through exactly as if this template had not
1572
+ // matched at all. This keeps "what is a doohickey"/"widget"/"gizmo" (still routed
1573
+ // here via the WITH-article, fully unrestricted `(.+?)` path — pinned to resolve as
1574
+ // an honest meta miss downstream, ask-combo.test.mjs/chat-readback.test.mjs) working
1575
+ // unmodified, while ALSO keeping the two pinned bare-form honest misses intact:
1576
+ // "what is the meaning of this codebase" (ask.test.mjs/ask-dual-strategy.test.mjs)
1577
+ // and "what is exposed" (ask.test.mjs:840) both have bare objects absent from
1578
+ // ENTITY_TO_TYPE, so build() still rejects them and they still fall through to null.
1579
+ // Fix 3: stripTrailingScopeFiller (ask-vocab.mjs) trims a curated trailing clause
1580
+ // ("what is a Module in this graph" -> "Module") off the object before it's
1581
+ // returned, so a scoping tail never corrupts the lookup term either the bare-form
1582
+ // check above or downstream resolution/rendering perform.
1430
1583
  {
1431
1584
  name: "meta-whatis",
1432
- re: new RegExp(`^what\\s+(?:is|are)\\s+(?:an?)\\s+(.+?)\\??$`, "i"),
1433
- build: (m) => ({ shape: "meta", entityType: null, modifier: "direct", kind: "meta", object: m[1].trim() })
1585
+ re: new RegExp(`^what\\s+(?:is|are)\\s+(?:(an?)\\s+)?(.+?)\\??$`, "i"),
1586
+ build: (m) => {
1587
+ const object = m[2].trim();
1588
+ if (!m[1] && !ENTITY_TO_TYPE[object.toLowerCase()]) return null;
1589
+ return { shape: "meta", entityType: null, modifier: "direct", kind: "meta", object: stripTrailingScopeFiller(object) };
1590
+ }
1434
1591
  },
1435
1592
  // T6 mention: "where is <term> mentioned/referenced" — the prose/mentions surface
1436
1593
  // (2026-07-02 query families). Tried BEFORE T7: T7's trailing marker is optional,
@@ -1592,7 +1749,13 @@
1592
1749
  return { shape: agentNamed ? "forward" : "reverse", entityType, modifier, kind, object };
1593
1750
  }
1594
1751
  }
1595
- if (beforeText && afterText) return { shape: "ask", entityType: null, modifier: "direct", kind, subject: beforeText, object: afterText };
1752
+ if (beforeText && afterText) {
1753
+ const verbPhrase = canonWords.slice(verbHit.start, verbHit.end).join(" ");
1754
+ let subject = beforeText;
1755
+ let object = afterText;
1756
+ if (INHERITS_REVERSE_VERBS.includes(verbPhrase)) [subject, object] = [object, subject];
1757
+ return { shape: "ask", entityType: null, modifier: "direct", kind, subject, object };
1758
+ }
1596
1759
  if (afterText) return { shape: "reverse", entityType, modifier, kind, object: afterText };
1597
1760
  if (beforeText) return { shape: "forward", entityType, modifier: "direct", kind, object: beforeText };
1598
1761
  return null;
@@ -2326,6 +2489,10 @@
2326
2489
  var NEST_SENTINEL = "zzinnerset";
2327
2490
  var PRED_LEAD_SKIP = /* @__PURE__ */ new Set(["that", "which", "who", "are", "is", "was", "were", "do", "does", "also", "still", "both", "and", "then", "though"]);
2328
2491
  var FRAME_WORDS = /* @__PURE__ */ new Set(["which", "what", "who", "list", "show", "find", "give", "me", "us", "all"]);
2492
+ var COPULA_WORDS = /* @__PURE__ */ new Set(["are", "is", "was", "were"]);
2493
+ function dropLeadCopula(bw, blc) {
2494
+ return blc.length && COPULA_WORDS.has(blc[0]) ? { bw: bw.slice(1), blc: blc.slice(1) } : { bw, blc };
2495
+ }
2329
2496
  var entityNoun = (w) => ENTITY_TO_TYPE[w] ? { entityType: ENTITY_TO_TYPE[w], placeholder: false } : PLACEHOLDER_NOUNS.includes(w) ? { entityType: null, placeholder: true } : null;
2330
2497
  var isGerundVerb = (w) => !!VERB_TO_KIND[w] && w.endsWith("ing");
2331
2498
  function parseSimpleClause(text, nlp) {
@@ -2334,7 +2501,7 @@
2334
2501
  function parseComposite(text, nlp) {
2335
2502
  const w = splitWords(text);
2336
2503
  const lc = w.map((x) => x.toLowerCase());
2337
- return parseExistence(w, lc) || parseNegation(text, nlp, 0) || parseForwardNegation(w, lc, nlp) || parseTemporal(w, lc, nlp, 0) || parseAnaphora(w, lc, nlp) || parseAggregate(w, lc, nlp) || parseSuperlative(w, lc, nlp) || parseFind(w, lc, nlp, 0) || parseList(w, lc, nlp, 0) || parseNested(w, lc, nlp, 0) || parseRelationalOrQualified(w, lc, nlp, 0);
2504
+ return parseExistence(w, lc) || parseQualifierCheck(w, lc) || parseNegation(text, nlp, 0) || parseForwardNegation(w, lc, nlp) || parseTemporal(w, lc, nlp, 0) || parseAnaphora(w, lc, nlp) || parseAggregate(w, lc, nlp) || parseSuperlative(w, lc, nlp) || parseFind(w, lc, nlp, 0) || parseList(w, lc, nlp, 0) || parseNested(w, lc, nlp, 0) || parseRelationalOrQualified(w, lc, nlp, 0);
2338
2505
  }
2339
2506
  function complementAst(entityType, diffAtom) {
2340
2507
  return {
@@ -2563,6 +2730,27 @@
2563
2730
  }
2564
2731
  return null;
2565
2732
  }
2733
+ function parseQualifierCheck(w, lc) {
2734
+ if (lc[0] !== "is" && lc[0] !== "are") return null;
2735
+ if (lc[1] === "there") return null;
2736
+ let qualIdx = -1;
2737
+ let negated = false;
2738
+ for (let i = 1; i < lc.length; i += 1) {
2739
+ if (QUALIFIERS[lc[i]]) {
2740
+ qualIdx = i;
2741
+ negated = lc[i - 1] === "not";
2742
+ break;
2743
+ }
2744
+ }
2745
+ if (qualIdx < 0) return null;
2746
+ let termStart = 1;
2747
+ if (lc[termStart] === "the") termStart += 1;
2748
+ let termEnd = negated ? qualIdx - 1 : qualIdx;
2749
+ if (termEnd > termStart && (lc[termEnd - 1] === "a" || lc[termEnd - 1] === "an")) termEnd -= 1;
2750
+ const term = termEnd > termStart ? w.slice(termStart, termEnd).join(" ").trim() : "";
2751
+ if (!term) return { node: "miss", reason: `"is/are <qualifier>" needs a named thing to check first` };
2752
+ return { node: "qualCheck", term, qualifier: lc[qualIdx], negated };
2753
+ }
2566
2754
  var AGG_TAIL_FILLER = /* @__PURE__ */ new Set([
2567
2755
  "total",
2568
2756
  "altogether",
@@ -2708,6 +2896,15 @@
2708
2896
  break;
2709
2897
  }
2710
2898
  }
2899
+ if (!metric) {
2900
+ for (let i = extIdx - 1; i >= 0; i -= 1) {
2901
+ if (EDGE_NOUN_TO_METRIC[lc[i]]) {
2902
+ metric = EDGE_NOUN_TO_METRIC[lc[i]];
2903
+ metricNoun = lc[i];
2904
+ break;
2905
+ }
2906
+ }
2907
+ }
2711
2908
  const connectivity = lc.includes("connected") || lc.slice(extIdx, extIdx + 2).join(" ") === "most connected" || ["largest", "biggest", "smallest"].includes(lc[extIdx]);
2712
2909
  if (!metric) {
2713
2910
  if (connectivity) {
@@ -2765,8 +2962,9 @@
2765
2962
  const bw = branches[b];
2766
2963
  const blc = bw.map((x) => x.toLowerCase());
2767
2964
  const op = b === 0 ? "intersection" : ops[b - 1];
2768
- if (bw.length && blc.every((x) => QUALIFIERS[x])) {
2769
- atoms.push({ op, kind: "qual", filters: blc });
2965
+ const qc = dropLeadCopula(bw, blc);
2966
+ if (qc.blc.length && qc.blc.every((x) => QUALIFIERS[x])) {
2967
+ atoms.push({ op, kind: "qual", filters: qc.blc });
2770
2968
  continue;
2771
2969
  }
2772
2970
  if (blc[0] === "of" || blc[0] === "in") {
@@ -2783,6 +2981,7 @@
2783
2981
  }
2784
2982
  return { atoms };
2785
2983
  }
2984
+ var RECENT_COMMIT_LEAD = /* @__PURE__ */ new Set(["recent", "latest", "newest"]);
2786
2985
  function parseRelationalOrQualified(w, lc, nlp, depth) {
2787
2986
  const findHead = parseFindPredicateHead(w, lc);
2788
2987
  if (findHead) {
@@ -2806,6 +3005,9 @@
2806
3005
  const noun = i < lc.length ? entityNoun(lc[i]) : null;
2807
3006
  if (!noun) {
2808
3007
  const nextNoun = i + 1 < lc.length ? entityNoun(lc[i + 1]) : null;
3008
+ if (RECENT_COMMIT_LEAD.has(lc[i]) && nextNoun && nextNoun.entityType === "Commit" && i + 2 === lc.length) {
3009
+ return { node: "recentCommits" };
3010
+ }
2809
3011
  if ((framed || quals.length) && nextNoun && /^[a-z]+$/.test(lc[i]) && !VERB_TO_KIND[lc[i]] && !STOPWORDS2.has(lc[i]) && !CASCADE_NOISE_SET.has(lc[i])) {
2810
3012
  return { node: "find", entityType: nextNoun.entityType, term: w[i] };
2811
3013
  }
@@ -2824,11 +3026,40 @@
2824
3026
  }
2825
3027
  const membershipLed = predLc[0] === "of" || predLc[0] === "in";
2826
3028
  const gerundLed = predLc.length > 0 && isGerundVerb(predLc[0]);
2827
- if (!(quals.length || relFlag || membershipLed || gerundLed)) return null;
3029
+ const boolQualLed = predWords.length > 0 && splitBoolean(predLc, predWords).branches.some((bw) => {
3030
+ const { blc } = dropLeadCopula(bw, bw.map((x) => x.toLowerCase()));
3031
+ return blc.length && blc.every((x) => QUALIFIERS[x]);
3032
+ });
3033
+ const sameVerbBranches = predWords.length > 0 ? splitBoolean(predLc, predWords).branches : [];
3034
+ let sameVerbLed = false;
3035
+ if (sameVerbBranches.length > 1) {
3036
+ const firstBlc = sameVerbBranches[0].map((x) => x.toLowerCase());
3037
+ const firstVh = findPhrase(firstBlc, VERB_TO_KIND);
3038
+ if (firstVh && firstVh.start === 0) {
3039
+ sameVerbLed = sameVerbBranches.slice(1).every((bw) => {
3040
+ const blc = bw.map((x) => x.toLowerCase());
3041
+ const vh = findPhrase(blc, VERB_TO_KIND);
3042
+ return vh && vh.start === 0 ? vh.kind === firstVh.kind : !vh;
3043
+ });
3044
+ }
3045
+ }
3046
+ let differentVerbLed = false;
3047
+ if (sameVerbBranches.length > 1) {
3048
+ const firstBlc = sameVerbBranches[0].map((x) => x.toLowerCase());
3049
+ const firstVh = findPhrase(firstBlc, VERB_TO_KIND);
3050
+ if (firstVh && firstVh.start === 0) {
3051
+ differentVerbLed = sameVerbBranches.slice(1).every((bw) => {
3052
+ const blc = bw.map((x) => x.toLowerCase());
3053
+ const vh = findPhrase(blc, VERB_TO_KIND);
3054
+ return !!vh && vh.start === 0 && vh.end - vh.start === 1;
3055
+ });
3056
+ }
3057
+ }
3058
+ if (!(quals.length || relFlag || membershipLed || gerundLed || boolQualLed || sameVerbLed || differentVerbLed)) return null;
2828
3059
  if (!predWords.length) {
2829
3060
  let base = { node: "allOfClass", entityType };
2830
3061
  if (!quals.length) return { node: "miss", reason: "nothing to filter or traverse" };
2831
- return { node: "qualifier", filters: quals, inner: base };
3062
+ return { node: "qualifier", filters: quals, inner: base, entityType };
2832
3063
  }
2833
3064
  const subjPrefix = noun.placeholder ? "what" : `which ${entWord}`;
2834
3065
  const { branches, ops } = splitBoolean(predLc, predWords);
@@ -2838,8 +3069,9 @@
2838
3069
  const bw = branches[b];
2839
3070
  const blc = bw.map((x) => x.toLowerCase());
2840
3071
  const op = b === 0 ? "seed" : ops[b - 1];
2841
- if (bw.length && blc.every((x) => QUALIFIERS[x])) {
2842
- atoms.push({ op, kind: "qual", filters: blc });
3072
+ const qc = dropLeadCopula(bw, blc);
3073
+ if (qc.blc.length && qc.blc.every((x) => QUALIFIERS[x])) {
3074
+ atoms.push({ op, kind: "qual", filters: qc.blc });
2843
3075
  continue;
2844
3076
  }
2845
3077
  if (blc[0] === "of" || blc[0] === "in") {
@@ -2861,7 +3093,7 @@
2861
3093
  } else {
2862
3094
  result = { node: "boolean", entityType, atoms };
2863
3095
  }
2864
- if (quals.length) result = { node: "qualifier", filters: quals, inner: result };
3096
+ if (quals.length) result = { node: "qualifier", filters: quals, inner: result, entityType };
2865
3097
  return result;
2866
3098
  }
2867
3099
  function parseBranchAst(text, nlp, depth) {
@@ -2960,6 +3192,33 @@
2960
3192
  if (ind.class === "Module") return ind.id;
2961
3193
  return qualSets(graph).moduleOfSymbol.get(ind.id) || null;
2962
3194
  }
3195
+ function membershipOwnSet(graph, id, entityType) {
3196
+ const objs = uniqueById(MEMBERSHIP_KINDS.flatMap((k) => forwardOverSet(graph, k, /* @__PURE__ */ new Set([id]))));
3197
+ return entityType ? objs.filter((o) => o.class === entityType) : objs;
3198
+ }
3199
+ function resolveMembershipOwner(graph, term) {
3200
+ const r = resolveObject(graph, term);
3201
+ if (!(r.match && r.tier === 1)) {
3202
+ const dirMods = directoryScopeModules(graph, term);
3203
+ if (dirMods.length) return { kind: "dir", mods: dirMods };
3204
+ }
3205
+ if (!r.match) return { kind: "miss" };
3206
+ return { kind: "single", id: r.match.id, entityClass: r.match.class, label: r.match.label };
3207
+ }
3208
+ function computeMembership(graph, ownerId, ownerClass, entityType, filterFn) {
3209
+ const pass = filterFn || (() => true);
3210
+ const own = membershipOwnSet(graph, ownerId, entityType).filter(pass);
3211
+ if (own.length || !inheritsApplicable(graph, ownerClass)) {
3212
+ return { own, inherited: [], viaId: null, viaLabel: null };
3213
+ }
3214
+ for (const ancId of ancestorsOf(graph, ownerId)) {
3215
+ const anc = graph.byId.get(ancId);
3216
+ if (!anc) continue;
3217
+ const ancOwn = membershipOwnSet(graph, ancId, entityType).filter(pass);
3218
+ if (ancOwn.length) return { own, inherited: ancOwn, viaId: ancId, viaLabel: anc.label };
3219
+ }
3220
+ return { own, inherited: [], viaId: null, viaLabel: null };
3221
+ }
2963
3222
  function qualHolds(graph, ind, spec) {
2964
3223
  if (!spec) return false;
2965
3224
  switch (spec.via) {
@@ -3128,22 +3387,19 @@
3128
3387
  return forwardOverSet(graph, ast.kind, ids);
3129
3388
  }
3130
3389
  case "membership": {
3131
- const r = resolveObject(graph, ast.term);
3132
- if (!(r.match && r.tier === 1)) {
3133
- const dirMods = directoryScopeModules(graph, ast.term);
3134
- if (dirMods.length) {
3135
- if (!ast.entityType || ast.entityType === "Module") return dirMods;
3136
- const ids2 = new Set(dirMods.map((m) => m.id));
3137
- const objs2 = uniqueById(MEMBERSHIP_KINDS.flatMap((k) => forwardOverSet(graph, k, ids2)));
3138
- return objs2.filter((o) => o.class === ast.entityType);
3139
- }
3390
+ const owner = resolveMembershipOwner(graph, ast.term);
3391
+ if (owner.kind === "dir") {
3392
+ if (!ast.entityType || ast.entityType === "Module") return owner.mods;
3393
+ const ids = new Set(owner.mods.map((m) => m.id));
3394
+ const objs = uniqueById(MEMBERSHIP_KINDS.flatMap((k) => forwardOverSet(graph, k, ids)));
3395
+ return objs.filter((o) => o.class === ast.entityType);
3140
3396
  }
3141
- if (!r.match) return [];
3142
- const ids = /* @__PURE__ */ new Set([r.match.id]);
3143
- const objs = uniqueById(MEMBERSHIP_KINDS.flatMap((k) => forwardOverSet(graph, k, ids)));
3144
- return ast.entityType ? objs.filter((o) => o.class === ast.entityType) : objs;
3397
+ if (owner.kind === "miss") return [];
3398
+ const { own, inherited } = computeMembership(graph, owner.id, owner.entityClass, ast.entityType);
3399
+ return own.length ? own : inherited;
3145
3400
  }
3146
3401
  case "qualifier": {
3402
+ if (ast.inner.node === "membership") return evalMembershipComposite(graph, ast, opts).matches;
3147
3403
  const base = evalSet(graph, ast.inner, opts);
3148
3404
  return base.filter((ind) => ast.filters.every((f) => qualHolds(graph, ind, QUALIFIERS[f])));
3149
3405
  }
@@ -3183,7 +3439,8 @@
3183
3439
  function evalAnaphora(graph, ast, opts) {
3184
3440
  const prev = opts && opts.prev;
3185
3441
  if (!Array.isArray(prev) || !prev.length) return { compositeMiss: true, reason: "no-prev", matches: [] };
3186
- let items = prev.map((id) => graph.byId.get(id)).filter(Boolean);
3442
+ const baseItems = prev.map((id) => graph.byId.get(id)).filter(Boolean);
3443
+ let items = baseItems;
3187
3444
  const f = ast.filter;
3188
3445
  if (f && f.type === "qual") {
3189
3446
  items = items.filter((ind) => f.filters.every((q) => qualHolds(graph, ind, QUALIFIERS[q])));
@@ -3197,7 +3454,8 @@
3197
3454
  items = items.filter((ind) => ok.has(ind.id));
3198
3455
  }
3199
3456
  }
3200
- const common = items.length && items.every((x) => x.class === items[0].class) ? items[0].class : null;
3457
+ const sameClass = (list) => list.length && list.every((x) => x.class === list[0].class) ? list[0].class : null;
3458
+ const common = items.length ? sameClass(items) : sameClass(baseItems);
3201
3459
  if (ast.mode === "count") return { compositeKind: "count", count: items.length, entityType: common, matches: [] };
3202
3460
  return { compositeKind: "set", matches: items, entityType: common };
3203
3461
  }
@@ -3219,6 +3477,12 @@
3219
3477
  }
3220
3478
  return n;
3221
3479
  }
3480
+ function evalRecentCommits(graph) {
3481
+ const commits = graph.individuals.filter((i) => i.class === "Commit");
3482
+ const dateOf = (c) => String((c.attributes || []).find((a) => a.key === "date")?.value || "");
3483
+ commits.sort((a, b) => dateOf(b).localeCompare(dateOf(a)));
3484
+ return { compositeKind: "recentCommits", matches: commits };
3485
+ }
3222
3486
  function evalTemporal(graph, ast, opts) {
3223
3487
  const inner = evalSet(graph, ast.inner, opts);
3224
3488
  const ids = new Set(inner.map((i) => i.id));
@@ -3236,6 +3500,34 @@
3236
3500
  const winners = scored.filter((s) => s.score === best).map((s) => s.ind);
3237
3501
  return { compositeKind: "superlative", entityType: ast.entityType, metricNoun: ast.metricNoun, extreme: ast.extreme, score: best, matches: winners };
3238
3502
  }
3503
+ function evalMembershipComposite(graph, ast, opts) {
3504
+ const qualNode = ast.node === "qualifier" && ast.inner.node === "membership" ? ast : null;
3505
+ const memNode = qualNode ? qualNode.inner : ast;
3506
+ const entityType = memNode.entityType;
3507
+ const filterFn = qualNode ? (ind) => qualNode.filters.every((f) => qualHolds(graph, ind, QUALIFIERS[f])) : null;
3508
+ const owner = resolveMembershipOwner(graph, memNode.term);
3509
+ if (owner.kind === "dir") {
3510
+ let objs;
3511
+ if (!entityType || entityType === "Module") objs = owner.mods;
3512
+ else {
3513
+ const ids = new Set(owner.mods.map((m) => m.id));
3514
+ objs = uniqueById(MEMBERSHIP_KINDS.flatMap((k) => forwardOverSet(graph, k, ids))).filter((o) => o.class === entityType);
3515
+ }
3516
+ if (filterFn) objs = objs.filter(filterFn);
3517
+ return { compositeKind: "set", matches: objs, entityType };
3518
+ }
3519
+ if (owner.kind === "miss") return { compositeKind: "set", matches: [], entityType };
3520
+ const { own, inherited, viaLabel } = computeMembership(graph, owner.id, owner.entityClass, entityType, filterFn);
3521
+ const inheritedNotOwn = !own.length && inherited.length > 0;
3522
+ return {
3523
+ compositeKind: "membership",
3524
+ entityType,
3525
+ matches: inheritedNotOwn ? inherited : own,
3526
+ inheritedNotOwn,
3527
+ viaLabel,
3528
+ ownerLabel: owner.label
3529
+ };
3530
+ }
3239
3531
  function evalExists(graph, ast) {
3240
3532
  const { entityType, term, scopeModule } = ast;
3241
3533
  let scopeMatch = null;
@@ -3260,14 +3552,28 @@
3260
3552
  const pool = scopeMatch ? refineToEntities(graph, /* @__PURE__ */ new Set([scopeMatch.id]), entityType) : graph.individuals.filter((i) => i.class === entityType);
3261
3553
  return { compositeKind: "exists", entityType, term: null, scopeModule, scopeMatch, matches: pool };
3262
3554
  }
3555
+ function evalQualCheck(graph, ast, opts) {
3556
+ const { term, qualifier, negated } = ast;
3557
+ const r = resolveTermOrContext(graph, term, opts.contextId);
3558
+ if (r.unresolvedPronoun) return { compositeKind: "qualCheck", qualCheckMiss: "pronoun", term, matches: [] };
3559
+ if (!r.match) return { compositeKind: "qualCheck", qualCheckMiss: "unresolved", term, matches: [] };
3560
+ const rawHolds = qualHolds(graph, r.match, QUALIFIERS[qualifier]);
3561
+ const holds = negated ? !rawHolds : rawHolds;
3562
+ return { compositeKind: "qualCheck", subject: r.match, qualifier, negated, holds, matches: [r.match] };
3563
+ }
3263
3564
  function evalComposite(graph, ast, opts = {}) {
3264
3565
  if (ast.node === "miss") return { compositeMiss: true, reason: ast.reason || null, matches: [] };
3265
3566
  if (ast.node === "exists") return evalExists(graph, ast);
3567
+ if (ast.node === "qualCheck") return evalQualCheck(graph, ast, opts);
3266
3568
  if (ast.node === "count") return { compositeKind: "count", count: evalSet(graph, ast.base, opts).length, entityType: ast.entityType, matches: [] };
3267
3569
  if (ast.node === "list") return { compositeKind: "list", matches: evalSet(graph, ast.base, opts), entityType: ast.entityType, scoped: ast.scoped };
3268
3570
  if (ast.node === "superlative") return evalSuperlative(graph, ast);
3269
3571
  if (ast.node === "temporal") return evalTemporal(graph, ast, opts);
3572
+ if (ast.node === "recentCommits") return evalRecentCommits(graph);
3270
3573
  if (ast.node === "anaphora") return evalAnaphora(graph, ast, opts);
3574
+ if (ast.node === "membership" || ast.node === "qualifier" && ast.inner.node === "membership") {
3575
+ return evalMembershipComposite(graph, ast, opts);
3576
+ }
3271
3577
  if (ast.node === "find") {
3272
3578
  const { narrow, broad } = computeFind(graph, ast.entityType, ast.term);
3273
3579
  return {
@@ -3318,6 +3624,23 @@
3318
3624
  }
3319
3625
  return { content: `Yes \u2014 ${compositeList(result.matches)}${scopeSuffix}.`, miss: false, ambiguous: false, matches: result.matches };
3320
3626
  }
3627
+ if (result.compositeKind === "qualCheck") {
3628
+ if (result.qualCheckMiss === "pronoun") {
3629
+ return { content: `"${result.term}" needs a selected node to refer to \u2014 click a node first, or name it directly.`, miss: true, ambiguous: false };
3630
+ }
3631
+ if (result.qualCheckMiss === "unresolved") {
3632
+ return { content: `couldn't find "${result.term}" in the index to check.`, miss: true, ambiguous: false };
3633
+ }
3634
+ const label = result.subject.label;
3635
+ const truePhrase = result.negated ? `not ${result.qualifier}` : result.qualifier;
3636
+ const falsePhrase = result.negated ? result.qualifier : `not ${result.qualifier}`;
3637
+ return {
3638
+ content: `${result.holds ? "Yes" : "No"} \u2014 ${label} is ${result.holds ? truePhrase : falsePhrase}.`,
3639
+ miss: false,
3640
+ ambiguous: false,
3641
+ matches: result.matches
3642
+ };
3643
+ }
3321
3644
  if (result.compositeKind === "count") {
3322
3645
  const noun = result.entityType ? nounFor(result.entityType, result.count) : result.count === 1 ? "result" : "results";
3323
3646
  return { content: `${result.count} ${noun}.`, miss: false, ambiguous: false, matches: [] };
@@ -3330,6 +3653,23 @@
3330
3653
  const hint = !result.scoped && scopeable && result.matches.length > OVERFLOW_CAP ? ` \u2014 narrow with "${nounFor(result.entityType, 2)} in <module>"` : "";
3331
3654
  return { content: `${compositeList(result.matches)}${hint}.`, miss: false, ambiguous: false, matches: result.matches };
3332
3655
  }
3656
+ if (result.compositeKind === "membership") {
3657
+ if (!result.matches.length) {
3658
+ return { content: `nothing in the index matches that${result.entityType ? ` (${nounFor(result.entityType, 2)})` : ""}.`, miss: true, ambiguous: false, matches: [] };
3659
+ }
3660
+ if (result.inheritedNotOwn) {
3661
+ const kindPlural = nounFor(result.entityType, 2);
3662
+ const ownerPhrase = result.ownerLabel ? `${result.ownerLabel} has no own ${kindPlural}` : `no own ${kindPlural}`;
3663
+ return {
3664
+ content: `${ownerPhrase} \u2014 inherited from ${result.viaLabel}: ${compositeList(result.matches)}.`,
3665
+ miss: false,
3666
+ ambiguous: false,
3667
+ matches: result.matches,
3668
+ inheritedNotOwn: true
3669
+ };
3670
+ }
3671
+ return { content: `${compositeList(result.matches)}.`, miss: false, ambiguous: false, matches: result.matches };
3672
+ }
3333
3673
  if (result.compositeKind === "find") {
3334
3674
  const typeNoun = nounFor(result.entityType, 1);
3335
3675
  if (!result.matches.length) {
@@ -3347,6 +3687,22 @@
3347
3687
  }
3348
3688
  return { content: `${cited}.`, miss: false, ambiguous: false, matches: result.matches };
3349
3689
  }
3690
+ if (result.compositeKind === "recentCommits") {
3691
+ if (!result.matches.length) return { content: `no commits recorded in this index.`, miss: true, ambiguous: false, matches: [] };
3692
+ const dateOf = (c) => String((c.attributes || []).find((a) => a.key === "date")?.value || "");
3693
+ const shown = result.matches.slice(0, HISTORY_CAP).map((c) => {
3694
+ const day = dateOf(c).slice(0, 10);
3695
+ const msg = (c.attributes || []).find((a) => a.key === "message")?.value || "";
3696
+ return `${c.label}${day ? ` (${day})` : ""}${msg ? ` \u2014 ${msg}` : ""}`;
3697
+ });
3698
+ const tail = result.matches.length > HISTORY_CAP ? ` \u2026+${result.matches.length - HISTORY_CAP} more` : "";
3699
+ return {
3700
+ content: `${result.matches.length} recent commit(s): ${shown.join(", ")}${tail}.`,
3701
+ miss: false,
3702
+ ambiguous: false,
3703
+ matches: result.matches
3704
+ };
3705
+ }
3350
3706
  if (result.compositeKind === "superlative") {
3351
3707
  if (!result.matches.length) return { content: `no ${nounFor(result.entityType, 2)} to rank in this index.`, miss: true, ambiguous: false };
3352
3708
  const lead = result.extreme === "most" ? "the most" : "the fewest";
@@ -3394,7 +3750,19 @@
3394
3750
  function componentSet(s) {
3395
3751
  return new Set(String(s).toLowerCase().split(/[^a-z0-9]+/).filter(Boolean));
3396
3752
  }
3397
- function resolveObject(graph, term, { expectedClass = null } = {}) {
3753
+ function derivationalStem(w) {
3754
+ if (w.length < 5) return w;
3755
+ const stripped = w.replace(/(ing|ers|ors|er|or)$/, "");
3756
+ if (stripped === w) return w;
3757
+ return stripped.length >= 3 && stripped[stripped.length - 1] === stripped[stripped.length - 2] ? stripped.slice(0, -1) : stripped;
3758
+ }
3759
+ function joinedForm(label) {
3760
+ return String(label || "").replace(/\.[a-z0-9]+$/i, "").toLowerCase().replace(/[/\-_.]+/g, "");
3761
+ }
3762
+ function joinedQueryForm(term) {
3763
+ return String(term || "").trim().replace(/^(?:the|a|an)\s+/i, "").toLowerCase().replace(/[\s\-_]+/g, "");
3764
+ }
3765
+ function resolveObjectCore(graph, term, { expectedClass = null } = {}) {
3398
3766
  const t = String(term || "").trim();
3399
3767
  if (!t) return { match: null, candidates: [], tier: null, ambiguous: false };
3400
3768
  const tLc = t.toLowerCase();
@@ -3435,15 +3803,54 @@
3435
3803
  }
3436
3804
  }
3437
3805
  } else {
3438
- const termComps = componentSet(t);
3806
+ const termComps = [...componentSet(t)];
3807
+ const pathToken = tLc.split(/\s+/).find((tok) => tok.includes("/"));
3808
+ const slashStem = pathToken ? pathToken.split("/").pop().replace(/\.[a-z0-9]+$/, "") : null;
3809
+ const qWords = t.trim().replace(/^(?:the|a|an)\s+/i, "").trim().split(/\s+/).filter(Boolean);
3810
+ const isMultiWord = qWords.length >= 2;
3811
+ const qJoined = isMultiWord ? joinedQueryForm(t) : null;
3439
3812
  for (const m of pool) {
3440
3813
  const label = String(m.label || "").toLowerCase();
3441
- if (label.includes(tLc)) {
3814
+ const stem = label.split("/").pop().replace(/\.[a-z0-9]+$/, "");
3815
+ if (stem === tLc) {
3816
+ scored.push({ ind: m, score: 5e3 });
3817
+ continue;
3818
+ }
3819
+ if (tLc.length >= 4 && (stem.startsWith(tLc) || stem.endsWith(tLc))) {
3820
+ scored.push({ ind: m, score: 4e3 - Math.abs(stem.length - tLc.length) });
3821
+ continue;
3822
+ }
3823
+ if (isMultiWord) {
3824
+ const candJoined = joinedForm(m.label);
3825
+ if (candJoined && candJoined === qJoined) {
3826
+ scored.push({ ind: m, score: 5e3 });
3827
+ continue;
3828
+ }
3829
+ const hasExplicitSeparator = /[/_-]/.test(m.label) || /\.[a-z0-9]+$/i.test(String(m.label || ""));
3830
+ if (candJoined && hasExplicitSeparator && qJoined.length >= 4 && candJoined.includes(qJoined)) {
3831
+ scored.push({ ind: m, score: 2e3 - Math.abs(candJoined.length - qJoined.length) });
3832
+ continue;
3833
+ }
3834
+ }
3835
+ if (m.class === "Module") {
3836
+ const termRoot = derivationalStem(tLc);
3837
+ if (termRoot !== tLc && termRoot === derivationalStem(stem)) {
3838
+ const bound = fuzzyBound(tLc);
3839
+ if (editDistance(stem, tLc, bound) > bound) {
3840
+ scored.push({ ind: m, score: 3e3 - Math.abs(stem.length - tLc.length) });
3841
+ continue;
3842
+ }
3843
+ }
3844
+ }
3845
+ if (tLc.length >= 4 && label.includes(tLc)) {
3442
3846
  scored.push({ ind: m, score: 1e3 - Math.abs(label.length - tLc.length) });
3443
3847
  continue;
3444
3848
  }
3445
- const overlap = [...termComps].filter((c) => componentSet(m.label).has(c)).length;
3446
- if (overlap > 0) scored.push({ ind: m, score: overlap * 10 });
3849
+ const labelComps = componentSet(m.label);
3850
+ const overlap = termComps.filter((c) => labelComps.has(c)).length;
3851
+ if (overlap > 0 && (!slashStem || labelComps.has(slashStem))) {
3852
+ scored.push({ ind: m, score: overlap / termComps.length * 10 });
3853
+ }
3447
3854
  }
3448
3855
  }
3449
3856
  scored.sort((a, b) => b.score - a.score);
@@ -3457,8 +3864,9 @@
3457
3864
  ambiguous: tied.length > 0
3458
3865
  };
3459
3866
  }
3867
+ const pathShaped = dotted || tLc.includes("/");
3460
3868
  let proseResult = null;
3461
- const proseHits = !dotted && typeof lookupByProseTokens === "function" ? lookupByProseTokens(graph.proseIndex, t).filter((h) => !expectedClass || graph.byId.get(h.id)?.class === expectedClass) : [];
3869
+ const proseHits = !pathShaped && typeof lookupByProseTokens === "function" ? lookupByProseTokens(graph.proseIndex, t).filter((h) => !expectedClass || graph.byId.get(h.id)?.class === expectedClass) : [];
3462
3870
  if (proseHits.length) {
3463
3871
  const [best, ...rest] = proseHits;
3464
3872
  const bestInd = graph.byId.get(best.id);
@@ -3501,6 +3909,29 @@
3501
3909
  }
3502
3910
  return proseResult || { match: null, candidates: [], tier: null, ambiguous: false };
3503
3911
  }
3912
+ var LEADING_ARTICLE_RE = /^(?:the|a|an)\s+/i;
3913
+ var TRAILING_GRAIN_WORD_RE = new RegExp(`\\s+(${Object.keys(ENTITY_TO_TYPE).join("|")})$`, "i");
3914
+ function resolveObject(graph, term, opts = {}) {
3915
+ const { expectedClass = null } = opts;
3916
+ if (!expectedClass) {
3917
+ const raw = String(term || "").trim();
3918
+ const stripped = raw.replace(LEADING_ARTICLE_RE, "").trim();
3919
+ const grainMatch = stripped.match(TRAILING_GRAIN_WORD_RE);
3920
+ if (grainMatch) {
3921
+ const head = stripped.slice(0, grainMatch.index).trim();
3922
+ const grainClass = ENTITY_TO_TYPE[grainMatch[1].toLowerCase()];
3923
+ if (head && grainClass) {
3924
+ const rGrain = resolveObjectCore(graph, head, { expectedClass: grainClass });
3925
+ if (rGrain?.match?.id && !rGrain.ambiguous) return rGrain;
3926
+ }
3927
+ }
3928
+ if (stripped && stripped !== raw) {
3929
+ const rStripped = resolveObjectCore(graph, stripped, opts);
3930
+ if (rStripped?.match?.id && !rStripped.ambiguous) return rStripped;
3931
+ }
3932
+ }
3933
+ return resolveObjectCore(graph, term, opts);
3934
+ }
3504
3935
  function resolveTermOrContext(graph, term, contextId) {
3505
3936
  if (CONTEXT_PRONOUNS.includes(String(term || "").trim().toLowerCase())) {
3506
3937
  if (!contextId) return { match: null, candidates: [], tier: null, ambiguous: false, unresolvedPronoun: true };
@@ -4149,10 +4580,14 @@
4149
4580
  const r = resolveObject(graph, t);
4150
4581
  return !!r.match && r.tier != null && r.tier <= 3;
4151
4582
  };
4152
- const hasRealTerm = (s) => splitWords(String(s || "")).some((w) => {
4153
- const lc = w.toLowerCase();
4154
- return !CONTENT_VOCAB.has(lc) && !STRUCTURAL_WORDS.has(lc);
4155
- });
4583
+ const hasRealTerm = (s) => {
4584
+ const whole = String(s || "").trim().toLowerCase();
4585
+ if (CONTEXT_PRONOUNS.includes(whole)) return true;
4586
+ return splitWords(whole).some((w) => {
4587
+ const lc = w.toLowerCase();
4588
+ return !CONTENT_VOCAB.has(lc) && !STRUCTURAL_WORDS.has(lc);
4589
+ });
4590
+ };
4156
4591
  const TERM_SHAPES = /* @__PURE__ */ new Set(["reverse", "forward", "where", "when", "ask"]);
4157
4592
  const attempt = (toks) => {
4158
4593
  const text = toks.join(" ");
@@ -4243,6 +4678,20 @@
4243
4678
  }
4244
4679
  return null;
4245
4680
  }
4681
+ var LAST_COMMIT_PHRASE_RE = /\b(?:the\s+)?(?:last|latest|most\s+recent)\s+commit\b/i;
4682
+ var BARE_WHEN_COMMIT_RE = /^when\s+(?:was|were|is|did|does|do)\s+commit\s+[0-9a-fA-F:]+$/i;
4683
+ function substituteLastCommitPhrase(graph, query) {
4684
+ const q = String(query || "");
4685
+ if (!graph || !LAST_COMMIT_PHRASE_RE.test(q)) return q;
4686
+ const commits = graph.individuals.filter((i) => i.class === "Commit");
4687
+ if (!commits.length) return q;
4688
+ const dateOf = (c) => String((c.attributes || []).find((a) => a.key === "date")?.value || "");
4689
+ const newest = [...commits].sort((a, b) => dateOf(b).localeCompare(dateOf(a)))[0];
4690
+ if (!newest) return q;
4691
+ const out = q.replace(LAST_COMMIT_PHRASE_RE, `commit ${newest.label}`);
4692
+ const bareTrimmed = out.trim().replace(/[?.!]+$/, "");
4693
+ return BARE_WHEN_COMMIT_RE.test(bareTrimmed) ? `${bareTrimmed} touched` : out;
4694
+ }
4246
4695
  function ask(graph, query, { contextId = null, nlp = void 0, prev = null } = {}) {
4247
4696
  if (isHelpRequest(query)) {
4248
4697
  return {
@@ -4260,6 +4709,7 @@
4260
4709
  }
4261
4710
  };
4262
4711
  }
4712
+ query = substituteLastCommitPhrase(graph, query);
4263
4713
  const direct = parseQuery(query, { nlp });
4264
4714
  let parsed = direct;
4265
4715
  let relaxed = null;
@@ -1 +1 @@
1
- 1.0.4
1
+ 1.3.0
@@ -0,0 +1,157 @@
1
+ // concept-digest.mjs — PROTOTYPE, not wired into any product surface (PLAN_CONCEPT_DIGEST.md).
2
+ //
3
+ // Extracts concept words from a task prompt (reusing prose.mjs's two tokenizer primitives, §3.1),
4
+ // then sweeps them type-scoped across the graph for literal file:line hits (§3.2). The
5
+ // ask()-driven ontology sweep (§3.3) and the combined two-section digest (§3.5) are NOT
6
+ // implemented this round — see the TODOs below and PLAN_CONCEPT_DIGEST.md §6/§7.
7
+ //
8
+ // §3.2(b): `identComponents` below is a standalone duplicate of codegraph.mjs's private (non-
9
+ // exported) helper of the same name — pure string manipulation, no graph-shape dependency, so
10
+ // duplicating it carries no correctness risk. `definesIndex`'s edge lookup, by contrast, uses
11
+ // codegraph.mjs's own EXPORTED `edgesOfKind`/`relationKind` machinery directly rather than
12
+ // re-deriving relation classification locally — that classification (PROP_KIND, the mgx/seon
13
+ // alias table) is exactly the kind of thing that must never drift between two copies. Only
14
+ // `matchSymbols`-shaped per-kind scoring (§3.2(a)) is still standalone-duplicated pending the
15
+ // codegraph.mjs refactor PLAN_CONCEPT_DIGEST.md §3.2(a)/§7 phase 2 proposes.
16
+
17
+ import { splitIdentifierWords, tokenizeProse } from "./prose.mjs";
18
+ import { siteOf, edgesOfKind } from "./codegraph.mjs";
19
+
20
+ const SYMBOL_CLASSES = { function: "Function", class: "Class", method: "Method", attribute: "Attribute", route: "Route" };
21
+ export const LITERAL_SWEEP_KINDS = ["module", "function", "class", "method", "attribute", "route"];
22
+
23
+ /** §3.1: the task prompt's concept words. NOT a bare `proseTokensFor({name, doc: promptText})`
24
+ * call — proseTokensFor is built for a single SYMBOL name + its doc, and its `name` half
25
+ * (splitIdentifierWords) deliberately carries NO stopword filter, because a real identifier
26
+ * never contains an English stopword in the first place. A whole task PROMPT is different: it
27
+ * mixes ordinary stopword-laden prose with embedded identifiers ("fix Truncator.chars"), so
28
+ * feeding the same full prompt text through both proseTokensFor halves re-admits "the"/"and"/
29
+ * "its" via the identifier-decomposition path even though tokenizeProse already dropped them
30
+ * from the prose path (caught by this file's own test suite, §6 — see the "reuses prose.mjs's
31
+ * stopword filter" test). The correct composition: decompose identifiers FIRST
32
+ * (splitIdentifierWords, camelCase/snake/dotted boundaries), then run the DECOMPOSED text back
33
+ * through tokenizeProse so the stopword filter applies to both origins uniformly, unioned with
34
+ * tokenizeProse over the raw prompt (covers plain prose words untouched by decomposition). */
35
+ export function extractConceptWords(promptText) {
36
+ const prose = tokenizeProse(promptText);
37
+ const decomposed = tokenizeProse(splitIdentifierWords(promptText).join(" "));
38
+ return [...new Set([...prose, ...decomposed])].sort();
39
+ }
40
+
41
+ /** Mirrors codegraph.mjs's private `identComponents` (camelCase/snake_case boundary split) —
42
+ * duplicated per the file-header note above, not imported (it is not exported there). */
43
+ function identComponents(name) {
44
+ return new Set(String(name).replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase().split(/[^a-z0-9]+/).filter(Boolean));
45
+ }
46
+
47
+ /** subject id -> [defined symbol labels], built off codegraph.mjs's own EXPORTED `edgesOfKind`
48
+ * (which in turn uses the real `relationKind` classifier — the mgx/seon PROP_KIND alias table
49
+ * lives there ONCE; this file must never re-derive it, see the file-header note above). */
50
+ function definesIndex(graph) {
51
+ const idx = new Map();
52
+ for (const e of edgesOfKind(graph, "defines")) {
53
+ if (!idx.has(e.subject)) idx.set(e.subject, []);
54
+ idx.get(e.subject).push(e.objectLabel || e.object);
55
+ }
56
+ return idx;
57
+ }
58
+
59
+ function siteFileLine(graph, id) {
60
+ const ind = graph.byId?.get?.(id);
61
+ const site = ind ? siteOf(ind) : null;
62
+ return site ? { file: site.path, line: site.start } : { file: null, line: null };
63
+ }
64
+
65
+ /** One module's combined literal-match text bag: lowercased path components + every defined
66
+ * symbol's name components + (when present) its `prose_tokens` attribute — the same "per-module
67
+ * combined text" idiom moduleEmbedTexts/definesIndex already use in codegraph.mjs (§3.2). */
68
+ function moduleTextBag(ind, defines) {
69
+ const bag = new Set();
70
+ for (const c of String(ind.label).toLowerCase().split(/[^a-z0-9]+/).filter(Boolean)) bag.add(c);
71
+ for (const d of defines) for (const c of identComponents(d)) bag.add(c);
72
+ const prose = (ind.attributes || []).find((a) => a.key === "prose_tokens")?.value;
73
+ if (prose) for (const t of String(prose).split(/\s+/).filter(Boolean)) bag.add(t);
74
+ return bag;
75
+ }
76
+
77
+ /** §3.2 module-kind sweep: strict AND over each module's text bag (every concept word must be
78
+ * present somewhere in the module), falling back to a coverage-ranked list when the AND-set is
79
+ * empty — the same "try strict first, relax only on a genuine miss" shape as tmct's ask()
80
+ * relaxation cascade (PLAN_CONCEPT_DIGEST.md §3.2). Never throws, never silently returns
81
+ * nothing when SOME module matched at least one word. */
82
+ function sweepModules(graph, conceptWords) {
83
+ const defIdx = definesIndex(graph);
84
+ const andHits = [];
85
+ const coverageHits = [];
86
+ for (const ind of graph.individuals) {
87
+ if ((ind.class || "") !== "Module") continue;
88
+ const defines = defIdx.get(ind.id) || [];
89
+ const bag = moduleTextBag(ind, defines);
90
+ const matched = conceptWords.filter((w) => bag.has(w));
91
+ if (!matched.length) continue;
92
+ const { file, line } = siteFileLine(graph, ind.id);
93
+ const hit = {
94
+ id: ind.id, label: ind.label, kind: "module", file: file || ind.label, line,
95
+ literalScore: matched.length, literalMatchedWords: matched, ontologyHits: [],
96
+ };
97
+ coverageHits.push(hit);
98
+ if (matched.length === conceptWords.length) andHits.push(hit);
99
+ }
100
+ const rank = (a, b) => b.literalScore - a.literalScore || String(a.label).length - String(b.label).length;
101
+ if (andHits.length) return andHits.sort(rank);
102
+ return coverageHits.sort(rank);
103
+ }
104
+
105
+ /** §3.2 symbol-kind sweep (function/class/method/attribute/route): coverage-ranked, NOT AND —
106
+ * see PLAN_CONCEPT_DIGEST.md §3.2 for why AND is reserved for the module grain only. Mirrors
107
+ * searchSymbols's own coverage-not-AND scoring shape (codegraph.mjs:1622-1656), applied per
108
+ * concept word instead of per raw query token. */
109
+ function sweepSymbols(graph, conceptWords, kind) {
110
+ const targetClass = SYMBOL_CLASSES[kind];
111
+ if (!targetClass) return [];
112
+ const hits = [];
113
+ for (const ind of graph.individuals) {
114
+ if ((ind.class || "") !== targetClass) continue;
115
+ const labelLc = String(ind.label).toLowerCase();
116
+ const comps = identComponents(ind.label);
117
+ const matched = conceptWords.filter((w) => comps.has(w) || labelLc.includes(w));
118
+ if (!matched.length) continue;
119
+ const { file, line } = siteFileLine(graph, ind.id);
120
+ hits.push({
121
+ id: ind.id, label: ind.label, kind, file, line,
122
+ literalScore: matched.length, literalMatchedWords: matched, ontologyHits: [],
123
+ });
124
+ }
125
+ return hits.sort((a, b) => b.literalScore - a.literalScore || String(a.label).length - String(b.label).length);
126
+ }
127
+
128
+ /** §3.2: the full type-specific literal sweep — module-kind AND (coverage fallback) UNION
129
+ * symbol-kind coverage sweeps, one per requested kind. Returns a flat, per-kind-ranked hit list
130
+ * (NOT deduped/combined with an ontology sweep — that composition is buildConceptDigest's job,
131
+ * §3.5, not yet implemented — see the TODO at the bottom of this file). */
132
+ export function literalSweep(graph, conceptWords, { kinds = LITERAL_SWEEP_KINDS } = {}) {
133
+ const words = [...new Set(conceptWords)];
134
+ if (!words.length) return [];
135
+ const out = [];
136
+ if (kinds.includes("module")) out.push(...sweepModules(graph, words));
137
+ for (const kind of kinds) {
138
+ if (kind === "module" || !SYMBOL_CLASSES[kind]) continue;
139
+ out.push(...sweepSymbols(graph, words, kind));
140
+ }
141
+ return out;
142
+ }
143
+
144
+ // TODO(concept-digest, ontology sweep — PLAN_CONCEPT_DIGEST.md §3.3/§7 phase 1): sentence-split
145
+ // the prompt + templated subclass/superclass/find probes over extractConceptWords' output, run
146
+ // through makeSeonixProvider(graph).ask(fragment) in-process (never a subprocess — §3.4), map
147
+ // tmct_ask.matches[].id -> siteOf for file:line. Not implemented this round.
148
+ export function ontologySweep(_graph, _promptText, _opts = {}) {
149
+ throw new Error("concept-digest: ontologySweep is not implemented yet — see PLAN_CONCEPT_DIGEST.md §3.3/§7");
150
+ }
151
+
152
+ // TODO(concept-digest, combine — PLAN_CONCEPT_DIGEST.md §3.5/§7 phase 1): dedupe literalSweep +
153
+ // ontologySweep by individual id into the two-section {literal, ontology} digest + rendered text.
154
+ // Depends on ontologySweep above; not implemented this round.
155
+ export function buildConceptDigest(_graph, _promptText, _opts = {}) {
156
+ throw new Error("concept-digest: buildConceptDigest is not implemented yet — see PLAN_CONCEPT_DIGEST.md §3.5/§7");
157
+ }
package/src/extract.mjs CHANGED
@@ -438,21 +438,35 @@ export function buildEntities(modules, commits, { generatedAt = "", symbolHistor
438
438
  }
439
439
 
440
440
  // symbol-granular calls: caller fn/method → callee fn/class/method. The top-level
441
- // function/class registry resolves first, unchanged (exactly ONE in-repo definition —
442
- // same unique-name discipline as the module-coarse calls). On a complete miss there,
443
- // receiver-typed call texts fall through to the tiered METHOD registry above, so
444
- // `_svc.ProcessOrder(...)` reaches `B.ProcessOrder` even though no top-level symbol
445
- // carries that name (C#/Java estates have none). Ambiguous / external names are
446
- // dropped (honest Group-A). Reuses the per-function call names the extractors parsed.
441
+ // function/class registry resolves first (exactly ONE in-repo definition — same
442
+ // unique-name discipline as the module-coarse calls) UNLESS the name is globally
443
+ // ambiguous but the CALLING module's own imports narrow it to one candidate (a
444
+ // same-named top-level helper the caller never imported must not block resolution
445
+ // of the one it did e.g. two files each define `parseEntities`, only one is
446
+ // imported by a given caller; mirrors the same-module/imported-tiering already used
447
+ // for base-class resolution above, minus a same-module tier: two SIBLING top-level
448
+ // defines of one name, in the SAME module as the caller, stay ambiguous — see
449
+ // "ambiguous call names are dropped" in test/extract.test.mjs). On a complete
450
+ // registry miss, receiver-typed call texts fall through to the tiered METHOD
451
+ // registry above, so `_svc.ProcessOrder(...)` reaches `B.ProcessOrder` even though no
452
+ // top-level symbol carries that name (C#/Java estates have none). Still-ambiguous /
453
+ // external names are dropped (honest Group-A). Reuses the per-function call names
454
+ // the extractors parsed.
447
455
  if ((d.kind === "function" || d.kind === "method") && d.calls?.length) {
448
456
  for (const callName of d.calls) {
449
457
  const ident = lastIdent(callName);
450
458
  if (!ident) continue;
451
459
  const ids = nameToSymbolIds.get(ident);
452
460
  let callee = null;
453
- if (ids?.size === 1) callee = [...ids][0];
454
- else if (!ids) callee = resolveMethodCallee(callName, ident, m.path); // method fallback on a clean miss only
455
- if (!callee) continue; // ambiguous or external → drop
461
+ if (ids?.size === 1) {
462
+ callee = [...ids][0];
463
+ } else if (ids?.size > 1) {
464
+ const reachable = [...ids].filter((id) => imports.has(id.slice(3, id.lastIndexOf("#"))));
465
+ if (reachable.length === 1) callee = reachable[0];
466
+ } else {
467
+ callee = resolveMethodCallee(callName, ident, m.path); // method fallback on a clean miss only
468
+ }
469
+ if (!callee) continue; // still ambiguous or external → drop
456
470
  if (callee === oid) continue; // self-recursion not an edge
457
471
  const ckey = `${oid}>${callee}`;
458
472
  if (seenCallSymbol.has(ckey)) continue;