@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 +3 -3
- package/src/ask-browser.bundle.js +503 -53
- package/src/ask-browser.bundle.version +1 -1
- package/src/concept-digest.mjs +157 -0
- package/src/extract.mjs +23 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@polycode-projects/seonix",
|
|
3
|
-
"version": "0.10.
|
|
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
|
|
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
|
|
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
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
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
|
-
//
|
|
1426
|
-
//
|
|
1427
|
-
//
|
|
1428
|
-
//
|
|
1429
|
-
//
|
|
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) =>
|
|
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)
|
|
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
|
-
|
|
2769
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2842
|
-
|
|
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
|
|
3132
|
-
if (
|
|
3133
|
-
|
|
3134
|
-
|
|
3135
|
-
|
|
3136
|
-
|
|
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 (
|
|
3142
|
-
const
|
|
3143
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
3446
|
-
|
|
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 = !
|
|
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) =>
|
|
4153
|
-
const
|
|
4154
|
-
|
|
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
|
|
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
|
|
442
|
-
//
|
|
443
|
-
//
|
|
444
|
-
//
|
|
445
|
-
//
|
|
446
|
-
//
|
|
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)
|
|
454
|
-
|
|
455
|
-
if (
|
|
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;
|