@polycode-projects/seonix 0.10.14 → 0.10.16

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.16",
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",
@@ -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;