@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 +2 -2
- 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.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
|
|
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
|
|
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;
|