@polycode-projects/seonix 0.1.0 → 0.2.1

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/src/extract.mjs CHANGED
@@ -23,6 +23,9 @@ import { dirname, join } from "node:path";
23
23
  import { fileURLToPath } from "node:url";
24
24
  import * as codegraph from "./codegraph.mjs"; // optional renderToolsCatalog (other agent owns this file)
25
25
  import { ingestRepo, LANG_EXTS } from "./extract_lang.mjs";
26
+ import { loadIgnores } from "./walk.mjs";
27
+ import { attachProseTokens, buildProseIndex } from "./prose.mjs";
28
+ import { ingestSchemaDocs } from "./schema-docs.mjs";
26
29
 
27
30
  const here = dirname(fileURLToPath(import.meta.url));
28
31
  const AST_SCRIPT = join(here, "extract_ast.py");
@@ -92,6 +95,18 @@ async function runAst(repoPath, python) {
92
95
  return Array.isArray(parsed?.modules) ? parsed.modules : [];
93
96
  }
94
97
 
98
+ // Paths git-log itself must never diff, mirroring .seonixignore's top-level excludes.
99
+ // loadIgnores()/the `ignore` predicate (indexRepository, below) only filters the
100
+ // FINAL module list — by then git log -p has already run and Node has already
101
+ // buffered its full output. For a directory carrying many large files (results/:
102
+ // 9,315 files / 529MB as of 2026-07-02), that historical patch content alone is
103
+ // gigabytes, which OOM'd a memory-constrained CI runner even though .seonixignore
104
+ // correctly kept those paths out of the resulting graph. Pathspec excludes stop git
105
+ // from generating that content at all. Keep in sync with .seonixignore (candidate
106
+ // for the seonix.toml config consolidation to share one list instead of two).
107
+ export const GIT_LOG_EXCLUDE = ["corpus", "target", "vendor", "infra/cdk.out", "results"];
108
+ const gitPathspecExcludes = () => GIT_LOG_EXCLUDE.map((p) => `:(exclude)${p}`);
109
+
95
110
  /** git log → [{sha, author, date, subject, files[]}] over the last N commits
96
111
  * (best-effort; [] if no repo). Header record fields are \x1e-separated; commits
97
112
  * are \x1f-separated; the subject (%s) is single-line so it never collides with
@@ -99,7 +114,7 @@ async function runAst(repoPath, python) {
99
114
  async function runGitLog(repoPath, depth = gitDepth()) {
100
115
  const { code, stdout } = await exec("git",
101
116
  ["log", `-n`, String(depth), "--no-renames", "--name-only",
102
- "--pretty=format:%x1f%H%x1e%an%x1e%aI%x1e%s", "--", "."],
117
+ "--pretty=format:%x1f%H%x1e%an%x1e%aI%x1e%s", "--", ".", ...gitPathspecExcludes()],
103
118
  { cwd: repoPath });
104
119
  if (code !== 0) return [];
105
120
  const out = [];
@@ -125,7 +140,7 @@ async function runGitLogHunks(repoPath, depth = historySymbolDepth()) {
125
140
  if (!depth) return [];
126
141
  const { code, stdout } = await exec("git",
127
142
  ["log", `-n`, String(depth), "--no-renames", "--no-color", "--unified=0",
128
- "--pretty=format:%x1f%H", "--", "."],
143
+ "--pretty=format:%x1f%H", "--", ".", ...gitPathspecExcludes()],
129
144
  { cwd: repoPath });
130
145
  if (code !== 0) return [];
131
146
  const out = [];
@@ -160,7 +175,7 @@ async function runGitLogHunks(repoPath, depth = historySymbolDepth()) {
160
175
  /** Build the `entities` payload from the parsed modules + git history.
161
176
  * `symbolHistory` (optional) is the runGitLogHunks() output — per-commit changed
162
177
  * line ranges, intersected with symbol spans to emit mgx:touchesSymbol edges. */
163
- export function buildEntities(modules, commits, { generatedAt = "", symbolHistory = [] } = {}) {
178
+ export function buildEntities(modules, commits, { generatedAt = "", symbolHistory = [], prose = true } = {}) {
164
179
  const modById = new Map(); // path -> module record
165
180
  const dottedToPath = new Map(); // dotted module name -> path
166
181
  const nameToPaths = new Map(); // top-level symbol name -> Set<path> (for call resolution)
@@ -459,6 +474,12 @@ export function buildEntities(modules, commits, { generatedAt = "", symbolHistor
459
474
  const countClass = (c) => fnIndividuals.filter((i) => i.class === c).length;
460
475
  const sampleClass = (c) => fnIndividuals.filter((i) => i.class === c).slice(0, 3).map((i) => i.label);
461
476
 
477
+ // Second pass (PLAN_PROSE_INDEX.md) — see the returned `proseIndex` field's comment below.
478
+ const allIndividuals = attachProseTokens(
479
+ [...moduleIndividuals, ...fnIndividuals, ...commitIndividuals], { enabled: prose },
480
+ );
481
+ const proseIndex = prose ? buildProseIndex(allIndividuals) : {};
482
+
462
483
  return {
463
484
  generated_at: generatedAt,
464
485
  // SEON (se-on.org, FAMIX-derived) vocabulary + our `mgx:` extension, documented
@@ -518,7 +539,12 @@ export function buildEntities(modules, commits, { generatedAt = "", symbolHistor
518
539
  rel("cochange", "mgx:changeCoupledWith", cochangeEdges),
519
540
  rel("reexports", "mgx:reExports", reExportEdges),
520
541
  ],
521
- individuals: [...moduleIndividuals, ...fnIndividuals, ...commitIndividuals],
542
+ individuals: allIndividuals,
543
+ // Second pass (PLAN_PROSE_INDEX.md): word -> [individual ids], inverted from the
544
+ // `prose_tokens` attribute attachProseTokens just attached. Disable via
545
+ // SEONIX_PROSE_INDEX=0 (indexRepository, below) — {} when off. The typed graph above
546
+ // (individuals' core fields, all edges) is byte-identical either way.
547
+ proseIndex,
522
548
  };
523
549
  }
524
550
 
@@ -571,26 +597,41 @@ function localToolsCatalog(cliPath) {
571
597
  * symbol-history (line-range) pass so the latter can be kept within the ≤10% budget.
572
598
  * @returns {Promise<{graphFile, counts}>}
573
599
  */
574
- export async function indexRepository(repoPath, { python = process.env.SEONIX_PYTHON || process.env.SEON_PYTHON || "python3", generatedAt = "" } = {}) {
600
+ export async function indexRepository(repoPath, { python = process.env.SEONIX_PYTHON || process.env.SEON_PYTHON || "python3", generatedAt = "", ignores = true } = {}) {
575
601
  const t0 = Date.now();
602
+ // Second pass (PLAN_PROSE_INDEX.md): on by default; SEONIX_PROSE_INDEX=0 disables it
603
+ // (mirrors SEONIX_HISTORY_SYMBOL_DEPTH=0's disable convention above).
604
+ const proseEnabled = process.env.SEONIX_PROSE_INDEX !== "0";
605
+ // `.seonixignore` (opt-out: `ignores:false`, the benchmark rig's flag). The matcher
606
+ // prunes the in-process walkers (extract_lang) at parse time; the module filter below
607
+ // is the AUTHORITATIVE cut — it also covers extractors that walk on their own
608
+ // (extract_ast.py, the Java fat-jar, Roslyn), and buildEntities drops history edges
609
+ // for any module not in the list, so the whole graph honours the same exclusions.
610
+ const ignore = ignores ? await loadIgnores(repoPath) : null;
576
611
  // base extraction: ast (Python) + multi-language front-end (TS/JS, C#) + module-level
577
612
  // git history (the cheap, full-depth pass). The Python and language passes are
578
613
  // independent parsers; their `{modules:[…]}` outputs share one contract and are merged
579
614
  // (dedupe by path — Python from runAst wins) before buildEntities.
580
615
  const [pyModules, langResult, commits] = await Promise.all([
581
616
  runAst(repoPath, python),
582
- ingestRepo(repoPath),
617
+ ingestRepo(repoPath, { ignore }),
583
618
  runGitLog(repoPath, gitDepth()),
584
619
  ]);
585
620
  const seenPaths = new Set(pyModules.map((m) => m.path));
586
- const modules = [...pyModules, ...langResult.modules.filter((m) => !seenPaths.has(m.path))];
621
+ const merged = [...pyModules, ...langResult.modules.filter((m) => !seenPaths.has(m.path))];
622
+ const modules = ignore ? merged.filter((m) => !ignore(m.path)) : merged;
587
623
  const baseMs = Date.now() - t0;
588
624
  // added symbol-history pass (the costly per-commit hunk diff) — budgeted via depth
589
625
  const tHist = Date.now();
590
626
  const symbolHistory = await runGitLogHunks(repoPath, historySymbolDepth());
591
627
  const historyMs = Date.now() - tHist;
592
628
 
593
- const entities = buildEntities(modules, commits, { generatedAt, symbolHistory });
629
+ const entities = buildEntities(modules, commits, { generatedAt, symbolHistory, prose: proseEnabled });
630
+ // Schema self-documentation (schema-docs.mjs): static, repo-independent — merges in
631
+ // SchemaClass/SchemaPredicate individuals + backfills entities.classes[].description
632
+ // and entities.vocabulary[].note, so "what does cochange mean" is answerable by the
633
+ // same graph traversal as any other question. Fixed-size, ~0ms marginal cost.
634
+ ingestSchemaDocs(entities);
594
635
  const graphFile = join(repoPath, ".seonix", "graph.json");
595
636
  await mkdir(dirname(graphFile), { recursive: true });
596
637
  await writeFile(graphFile, JSON.stringify(entities));
@@ -616,6 +657,8 @@ export async function indexRepository(repoPath, { python = process.env.SEONIX_PY
616
657
  callsSymbol: propCount("mgx:callsSymbol"),
617
658
  touchesSymbol: propCount("mgx:touchesSymbol"),
618
659
  historySymbolDepth: historySymbolDepth(),
660
+ proseEnabled,
661
+ proseWords: Object.keys(entities.proseIndex || {}).length,
619
662
  baseMs,
620
663
  historyMs,
621
664
  historyPct,
@@ -10,6 +10,8 @@
10
10
  import * as jstsTsc from "./jsts_tsc.mjs";
11
11
  import * as csRoslyn from "./cs_roslyn.mjs";
12
12
  import * as csTree from "./cs_treesitter.mjs";
13
+ import * as javaJavaparser from "./java_javaparser.mjs";
14
+ import * as javaTree from "./java_treesitter.mjs";
13
15
 
14
16
  // THE EXTRACTOR REGISTRY — the single place a new language plugs in. Keyed by
15
17
  // language; `exts` is the file-extension surface it owns, `pick` the default
@@ -17,6 +19,7 @@ import * as csTree from "./cs_treesitter.mjs";
17
19
  export const REGISTRY = {
18
20
  "js/ts": { exts: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"], pick: jstsTsc, alt: null },
19
21
  "c#": { exts: [".cs"], pick: csRoslyn, alt: csTree },
22
+ "java": { exts: [".java"], pick: javaJavaparser, alt: javaTree },
20
23
  };
21
24
 
22
25
  /** The full set of file extensions this front-end indexes (sorted, lower-cased).
@@ -32,15 +35,27 @@ async function chooseCs(prefer) {
32
35
  return csTree;
33
36
  }
34
37
 
38
+ /** Choose the Java extractor: JavaParser+SymbolSolver (JDK helper) when a `java`
39
+ * runtime and the built jar are present, else the tree-sitter fallback. */
40
+ async function chooseJava(prefer) {
41
+ if (prefer === "treesitter") return javaTree;
42
+ if (prefer === "javaparser" || prefer === undefined) {
43
+ if (await javaJavaparser.toolAvailable()) return javaJavaparser;
44
+ }
45
+ return javaTree;
46
+ }
47
+
35
48
  /**
36
49
  * Parse every supported non-Python language under `root` and merge the results.
37
50
  * @returns {Promise<{modules:Array, perLang:Object, totalFiles:number, failures:Array}>}
38
51
  */
39
- export async function ingestRepo(root, { cs } = {}) {
52
+ export async function ingestRepo(root, { cs, java, ignore = null } = {}) {
40
53
  const csExtractor = await chooseCs(cs);
54
+ const javaExtractor = await chooseJava(java);
41
55
  const plan = [
42
56
  { lang: "js/ts", extractor: jstsTsc },
43
57
  { lang: "c#", extractor: csExtractor },
58
+ { lang: "java", extractor: javaExtractor },
44
59
  ];
45
60
  const allModules = [];
46
61
  const perLang = {};
@@ -48,7 +63,7 @@ export async function ingestRepo(root, { cs } = {}) {
48
63
  const allFailures = [];
49
64
  for (const { lang, extractor } of plan) {
50
65
  const t0 = Date.now();
51
- const { modules, failures, fileCount } = await extractor.ingest(root);
66
+ const { modules, failures, fileCount } = await extractor.ingest(root, { ignore });
52
67
  const ms = Date.now() - t0;
53
68
  if (fileCount === 0) continue; // language not present
54
69
  allModules.push(...modules);
@@ -0,0 +1,54 @@
1
+ // Java extractor — JavaParser + JavaSymbolSolver (resolved calls) via the bundled
2
+ // JVM helper. PICKED Java candidate when a JDK/`java` runtime is present. Spawns
3
+ // `java -jar java/dist/seonix-java-extract.jar <root>` which prints the
4
+ // {modules:[…]} contract; this wrapper just shells out and parses it. Falling back
5
+ // (java_treesitter) is the caller's job when `java`/the jar is absent.
6
+ import { spawn } from "node:child_process";
7
+ import { dirname, join } from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+ import { access } from "node:fs/promises";
10
+
11
+ const here = dirname(fileURLToPath(import.meta.url));
12
+ const TOOL_JAR = join(here, "..", "java", "dist", "seonix-java-extract.jar");
13
+
14
+ function run(cmd, args) {
15
+ return new Promise((resolve) => {
16
+ const child = spawn(cmd, args);
17
+ let out = ""; let err = "";
18
+ child.stdout.on("data", (d) => (out += d));
19
+ child.stderr.on("data", (d) => (err += d));
20
+ child.on("close", (code) => resolve({ code: code ?? -1, out, err }));
21
+ child.on("error", (e) => resolve({ code: -1, out, err: String(e) }));
22
+ });
23
+ }
24
+
25
+ let _javaOk = null;
26
+ async function javaOnPath() {
27
+ if (_javaOk !== null) return _javaOk;
28
+ const { code } = await run("java", ["-version"]);
29
+ _javaOk = code === 0;
30
+ return _javaOk;
31
+ }
32
+
33
+ async function jarExists() {
34
+ try { await access(TOOL_JAR); return true; } catch { return false; }
35
+ }
36
+
37
+ /** True iff a `java` runtime is on PATH AND the shaded fat-jar is present. */
38
+ export async function toolAvailable() {
39
+ if (!(await jarExists())) return false;
40
+ return javaOnPath();
41
+ }
42
+
43
+ export async function ingest(root) {
44
+ if (!(await jarExists())) throw new Error("seonix-java-extract.jar not built (run `mvn package` in java/)");
45
+ if (!(await javaOnPath())) throw new Error("`java` runtime not on PATH (install a JDK, or use the tree-sitter fallback)");
46
+ const { code, out, err } = await run("java", ["-jar", TOOL_JAR, root]);
47
+ if (code !== 0) throw new Error(`seonix-java-extract failed (exit ${code}): ${err.slice(-300)}`);
48
+ let parsed;
49
+ try { parsed = JSON.parse(out); }
50
+ catch { throw new Error(`seonix-java-extract produced non-JSON (${out.slice(0, 120)})`); }
51
+ return { modules: parsed.modules || [], failures: parsed.failures || [], fileCount: parsed.fileCount || 0 };
52
+ }
53
+
54
+ export const meta = { id: "javaparser", language: "java", lib: "JavaParser + JavaSymbolSolver (resolved calls)", exts: [".java"] };
@@ -0,0 +1,216 @@
1
+ // Java extractor — tree-sitter-java (CST-only) fallback for the bake-off. The
2
+ // no-JDK path: a uniform CST, no symbol solver (so call targets are syntax-level
3
+ // names, NOT resolved to in-repo/JDK qualified names — that promotion is the
4
+ // JavaParser backend's job). Emits the same {modules:[{path,dotted,imports,
5
+ // defines,calls,exports}]} contract so codegraph.mjs/digest consume it unchanged.
6
+ // Module identity is per-file; `dotted` is the file's package + primary type.
7
+ import { readFile } from "node:fs/promises";
8
+ import { createRequire } from "node:module";
9
+ import { walk, relPath } from "./walk.mjs";
10
+
11
+ // tree-sitter is the OPTIONAL fallback backend (used only when a JDK/`java` is
12
+ // absent). It is a native module and NOT a declared seonix dependency, so the
13
+ // require is LAZY — importing this module (e.g. via extract_lang's REGISTRY) must
14
+ // not force the native build. Resolved on first parse; a clear error if missing.
15
+ const require = createRequire(import.meta.url);
16
+ let _Parser = null;
17
+ let _Java = null;
18
+ function loadTreeSitter() {
19
+ if (_Parser) return { Parser: _Parser, Java: _Java };
20
+ try {
21
+ _Parser = require("tree-sitter");
22
+ _Java = require("tree-sitter-java");
23
+ } catch (e) {
24
+ throw new Error(
25
+ `tree-sitter Java fallback unavailable (install tree-sitter + tree-sitter-java, or use the JavaParser backend): ${e?.message || e}`,
26
+ );
27
+ }
28
+ return { Parser: _Parser, Java: _Java };
29
+ }
30
+
31
+ const EXTS = [".java"];
32
+ const line = (n) => n.startPosition.row + 1;
33
+ const endLine = (n) => n.endPosition.row + 1;
34
+ const oneLine = (s) => (s || "").replace(/\s+/g, " ").trim();
35
+ const clip = (s, n) => (s.length <= n ? s : s.slice(0, n));
36
+ const fieldText = (n, f) => n.childForFieldName(f)?.text || "";
37
+
38
+ const TYPE_DECLS = new Set(["class_declaration", "interface_declaration", "enum_declaration", "record_declaration"]);
39
+ const BASE_WRAPPERS = new Set(["superclass", "super_interfaces", "extends_interfaces", "interfaces"]);
40
+ const TYPE_NODES = new Set(["type_identifier", "generic_type", "scoped_type_identifier"]);
41
+
42
+ /** Callee names within a node (syntax-level; unresolved). */
43
+ function collectCalls(node) {
44
+ const out = new Set();
45
+ const stack = [node];
46
+ while (stack.length) {
47
+ const n = stack.pop();
48
+ if (n.type === "method_invocation") {
49
+ const nm = fieldText(n, "name");
50
+ if (nm) out.add(clip(nm, 80));
51
+ }
52
+ for (let i = 0; i < n.namedChildCount; i++) stack.push(n.namedChild(i));
53
+ }
54
+ return [...out].sort();
55
+ }
56
+
57
+ /** Base types (extends + implements) declared on a type node. */
58
+ function basesOf(node) {
59
+ const bases = [];
60
+ for (let i = 0; i < node.namedChildCount; i++) {
61
+ const c = node.namedChild(i);
62
+ if (!BASE_WRAPPERS.has(c.type)) continue;
63
+ const stack = [c];
64
+ while (stack.length) {
65
+ const w = stack.pop();
66
+ if (TYPE_NODES.has(w.type)) { bases.push(clip(oneLine(w.text), 80)); continue; }
67
+ for (let j = 0; j < w.namedChildCount; j++) stack.push(w.namedChild(j));
68
+ }
69
+ }
70
+ return bases;
71
+ }
72
+
73
+ function modifiersNode(node) {
74
+ for (let i = 0; i < node.namedChildCount; i++) {
75
+ if (node.namedChild(i).type === "modifiers") return node.namedChild(i);
76
+ }
77
+ return null;
78
+ }
79
+ function decoratorsFrom(mods) {
80
+ if (!mods) return [];
81
+ const out = [];
82
+ for (let i = 0; i < mods.namedChildCount; i++) {
83
+ const c = mods.namedChild(i);
84
+ if (c.type.endsWith("annotation")) out.push(clip(oneLine(c.text), 80));
85
+ }
86
+ return out;
87
+ }
88
+ function visFrom(mods) {
89
+ const t = mods?.text || "";
90
+ return /\bprivate\b/.test(t) ? "private" : /\bprotected\b/.test(t) ? "protected" : "";
91
+ }
92
+ const isPublic = (mods) => /\bpublic\b/.test(mods?.text || "");
93
+ const isStatic = (mods) => /\bstatic\b/.test(mods?.text || "");
94
+
95
+ function paramsText(node) {
96
+ const fp = node.childForFieldName("parameters");
97
+ if (!fp) return "";
98
+ return clip(oneLine(fp.text.replace(/^\(|\)$/g, "")), 160);
99
+ }
100
+
101
+ function bodyContainer(typeNode) {
102
+ return typeNode.childForFieldName("body")
103
+ || typeNode.namedChildren?.find((c) => c.type === "class_body" || c.type === "enum_body" || c.type === "interface_body")
104
+ || null;
105
+ }
106
+
107
+ export async function extractFile(absPath, root) {
108
+ const text = await readFile(absPath, "utf8").catch(() => null);
109
+ const path = relPath(root, absPath);
110
+ if (text == null) return { failed: true, path };
111
+ let tree;
112
+ try {
113
+ const { Parser, Java } = loadTreeSitter();
114
+ const p = new Parser(); p.setLanguage(Java);
115
+ tree = p.parse(text);
116
+ } catch { return { failed: true, path }; }
117
+
118
+ const imports = new Set();
119
+ const defines = [];
120
+ const exports = new Set();
121
+ let pkg = "";
122
+
123
+ const stack = [tree.rootNode];
124
+ while (stack.length) {
125
+ const n = stack.pop();
126
+ if (n.type === "package_declaration" && !pkg) {
127
+ pkg = oneLine(n.namedChildren.map((c) => c.text).join(""));
128
+ } else if (n.type === "import_declaration") {
129
+ if (/\bstatic\b/.test(n.text)) { /* skip static imports (mirror JavaParser) */ }
130
+ else {
131
+ const nm = n.namedChildren.find((c) => /identifier/.test(c.type))?.text;
132
+ if (nm) imports.add(oneLine(nm));
133
+ }
134
+ } else if (TYPE_DECLS.has(n.type)) {
135
+ const cname = fieldText(n, "name");
136
+ if (cname) {
137
+ const mods = modifiersNode(n);
138
+ defines.push({
139
+ name: cname, kind: "class", lineno: line(n), end_lineno: endLine(n),
140
+ bases: basesOf(n), decorators: decoratorsFrom(mods),
141
+ ...(visFrom(mods) ? { visibility: visFrom(mods) } : {}),
142
+ });
143
+ if (isPublic(mods)) exports.add(cname);
144
+
145
+ // record components (formal params) surface as attributes (match JavaParser)
146
+ if (n.type === "record_declaration") {
147
+ const fp = n.childForFieldName("parameters");
148
+ if (fp) for (let i = 0; i < fp.namedChildCount; i++) {
149
+ const fpc = fp.namedChild(i);
150
+ if (fpc.type !== "formal_parameter") continue;
151
+ const pn = fieldText(fpc, "name");
152
+ if (pn) defines.push({ name: `${cname}.${pn}`, kind: "attribute", lineno: line(fpc), end_lineno: endLine(fpc), decorators: [] });
153
+ }
154
+ }
155
+
156
+ const body = bodyContainer(n);
157
+ if (body) {
158
+ for (let i = 0; i < body.namedChildCount; i++) {
159
+ const mem = body.namedChild(i);
160
+ if (mem.type === "method_declaration" || mem.type === "constructor_declaration") {
161
+ const mn = fieldText(mem, "name") || cname;
162
+ const mmods = modifiersNode(mem);
163
+ const returns = mem.type === "method_declaration"
164
+ ? clip(oneLine(mem.childForFieldName("type")?.text || ""), 80) : "";
165
+ defines.push({
166
+ name: `${cname}.${mn}`, kind: "method", lineno: line(mem), end_lineno: endLine(mem),
167
+ decorators: decoratorsFrom(mmods), params: paramsText(mem),
168
+ ...(returns ? { returns } : {}), calls: collectCalls(mem),
169
+ ...(isStatic(mmods) ? { is_static: true } : {}),
170
+ ...(visFrom(mmods) ? { visibility: visFrom(mmods) } : {}),
171
+ });
172
+ } else if (mem.type === "field_declaration") {
173
+ const fmods = modifiersNode(mem);
174
+ for (let j = 0; j < mem.namedChildCount; j++) {
175
+ const vd = mem.namedChild(j);
176
+ if (vd.type !== "variable_declarator") continue;
177
+ const fn = fieldText(vd, "name");
178
+ if (fn) defines.push({
179
+ name: `${cname}.${fn}`, kind: "attribute", lineno: line(mem), end_lineno: endLine(mem),
180
+ decorators: decoratorsFrom(fmods),
181
+ ...(visFrom(fmods) ? { visibility: visFrom(fmods) } : {}),
182
+ });
183
+ }
184
+ }
185
+ }
186
+ }
187
+ }
188
+ }
189
+ // descend, but not back into a type body (members handled above)
190
+ if (!TYPE_DECLS.has(n.type)) for (let i = 0; i < n.namedChildCount; i++) stack.push(n.namedChild(i));
191
+ }
192
+
193
+ const base = path.replace(/\.java$/i, "").split("/").pop();
194
+ const dotted = pkg ? `${pkg}.${base}` : path.replace(/\.java$/i, "").split("/").join(".");
195
+ return {
196
+ path, dotted, imports: [...imports].sort(), defines,
197
+ calls: collectCalls(tree.rootNode), exports: [...exports].sort(),
198
+ };
199
+ }
200
+
201
+ export async function ingest(root, { ignore = null } = {}) {
202
+ const files = await walk(root, EXTS, ignore);
203
+ const modules = [];
204
+ const failures = [];
205
+ for (const f of files) {
206
+ const mod = await extractFile(f, root);
207
+ if (mod.failed) failures.push(mod.path); else modules.push(mod);
208
+ }
209
+ return { modules, failures, fileCount: files.length };
210
+ }
211
+
212
+ export function toolAvailable() {
213
+ try { loadTreeSitter(); return true; } catch { return false; }
214
+ }
215
+
216
+ export const meta = { id: "treesitter-java", language: "java", lib: "tree-sitter-java (CST, syntax-level)", exts: EXTS };
package/src/jsts_tsc.mjs CHANGED
@@ -196,8 +196,8 @@ export async function extractFile(absPath, root) {
196
196
  }
197
197
 
198
198
  /** Ingest a whole tree → {modules:[…]} contract + parse-failure list. */
199
- export async function ingest(root) {
200
- const files = await walk(root, EXTS);
199
+ export async function ingest(root, { ignore = null } = {}) {
200
+ const files = await walk(root, EXTS, ignore);
201
201
  const modules = [];
202
202
  const failures = [];
203
203
  for (const f of files) {
package/src/prose.mjs ADDED
@@ -0,0 +1,145 @@
1
+ // prose.mjs — the second-pass prose extraction + cross-reference index (PLAN_PROSE_INDEX.md).
2
+ //
3
+ // Deterministic, zero-model-calls, zero dependencies: pure string transforms + a plain-object
4
+ // inverted index over data extract.mjs's typed pass has already produced (identifier names,
5
+ // captured doc text). Two token sources, one combined set per individual:
6
+ // 1. identifier decomposition — names are often literal sentence fragments
7
+ // ("calculateTotalPriceIncludingTax" -> calculate/total/price/including/tax); splitting
8
+ // them turns every symbol/module name into free-text search surface for zero extra cost.
9
+ // 2. prose literals — docstrings/doc-comments already captured as the `doc` attribute.
10
+ // Python (extract_ast.py) and JS/TS (jsts_tsc.mjs's firstDocLine) already populate this;
11
+ // C#/Java's PRIMARY extractors (cs_roslyn.mjs, java_javaparser.mjs) shell out to compiled
12
+ // Roslyn/JavaParser binaries — adding doc capture there means modifying and rebuilding
13
+ // external .NET/JVM tooling, not a JS-side change. Deliberately deferred (PLAN_PROSE_INDEX.md
14
+ // backlog); this pass consumes whatever `doc` is already present, regardless of source
15
+ // language, so C#/Java modules still get identifier-decomposition tokens today.
16
+ //
17
+ // Tokenizer pattern (stopwords, length bounds, per-doc token cap) mirrors marginalia's
18
+ // app/lib/text-index.mjs — a proven lexical-inverted-index tokenizer for the same "search by
19
+ // keyword, no embeddings, no stemmer dependency" problem, adapted for code identifiers.
20
+ //
21
+ // Storage: (a) a `prose_tokens` attribute (space-joined, deduped, sorted) on each individual —
22
+ // the graph stays self-describing, works if a consumer only has one individual in hand; AND
23
+ // (b) a real inverted index (word -> [individual ids]) built from those same tokens and
24
+ // attached as `entities.proseIndex` — an O(1) word lookup for consumers (resolveObject-style
25
+ // fuzzy object-term resolution, scoreModules-style lexical boosting) instead of scanning every
26
+ // individual's attributes. Both are derived from the identical token set, so they can never
27
+ // disagree; (b) is just (a) inverted once, cheaply, at build time.
28
+
29
+ const STOPWORDS = new Set(
30
+ ("a an and or but the of to in on at for with from by as is are was were be been being " +
31
+ "it its this that these those i you he she they we me my your our do does did not no " +
32
+ "yes if then else than so such can will would should could may might about into over " +
33
+ "under out up down off again more most some any all what which who whom whose when " +
34
+ "where why how").split(/\s+/),
35
+ );
36
+
37
+ const MAX_TOKEN_LEN = 40; // drops hash-like/garbage tokens (marginalia text-index.mjs)
38
+ const MAX_TOKENS_PER_DOC = 120; // bounds cost on a pathologically long docstring/name
39
+
40
+ /** Split an identifier or a path-like name into lowercase word tokens.
41
+ * Handles camelCase, PascalCase, snake_case, kebab-case, dotted names, path
42
+ * separators, and acronym runs ("HTTPSConnection" -> https/connection,
43
+ * "parseXML" -> parse/xml). Filters single-character tokens (loop-variable noise). */
44
+ export function splitIdentifierWords(raw) {
45
+ if (!raw) return [];
46
+ let s = String(raw).replace(/\.[A-Za-z0-9]+$/, ""); // strip a trailing file extension only
47
+ s = s
48
+ .replace(/[/\\]/g, " ") // path separators
49
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2") // camelCase / word|Digit boundary
50
+ .replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2") // acronym run -> TitleCase (HTTPSConnection)
51
+ .replace(/([A-Za-z])([0-9])/g, "$1 $2")
52
+ .replace(/([0-9])([A-Za-z])/g, "$1 $2")
53
+ .replace(/[_\-.]+/g, " ");
54
+ return s.split(/\s+/).map((w) => w.toLowerCase()).filter((w) => w.length > 1 && w.length <= MAX_TOKEN_LEN);
55
+ }
56
+
57
+ /** Tokenize free prose (a docstring/doc-comment) — lowercase words, punctuation stripped,
58
+ * common stopwords and single-/over-length tokens dropped, capped at MAX_TOKENS_PER_DOC. */
59
+ export function tokenizeProse(text) {
60
+ if (!text) return [];
61
+ const out = [];
62
+ const seen = new Set();
63
+ for (const raw of String(text).toLowerCase().split(/[^a-z0-9]+/)) {
64
+ if (raw.length < 2 || raw.length > MAX_TOKEN_LEN || STOPWORDS.has(raw)) continue;
65
+ if (seen.has(raw)) continue;
66
+ seen.add(raw);
67
+ out.push(raw);
68
+ if (out.length >= MAX_TOKENS_PER_DOC) break;
69
+ }
70
+ return out;
71
+ }
72
+
73
+ /** The combined, deduped, sorted token set for one individual: its (decomposed) name
74
+ * plus any captured doc text. Returns [] if there's nothing to index (never null). */
75
+ export function proseTokensFor({ name, doc } = {}) {
76
+ const set = new Set([...splitIdentifierWords(name), ...tokenizeProse(doc)]);
77
+ return [...set].sort();
78
+ }
79
+
80
+ /** Second pass (PLAN_PROSE_INDEX.md): attach a `prose_tokens` attribute to every
81
+ * individual in `individuals`, deriving tokens from its name and captured prose text.
82
+ * Class-aware source selection: Module/Function/Method/Class/Attribute/GlobalVariable use
83
+ * `label` (a real identifier/path — decomposable, e.g. "calculateTotalPrice") + a `doc`
84
+ * attribute if present (docstring/doc-comment). Commit is different: its `label` is a
85
+ * truncated SHA (hex noise if decomposed, e.g. "e6a9419567f7" -> "9419567" garbage) — skip
86
+ * decomposing it and tokenize its `message` attribute instead, which is the real prose
87
+ * (commit messages are often the richest free text in the whole graph). Mutates and
88
+ * returns the same array — safe to call once after the typed individuals are built.
89
+ * `enabled=false` is a no-op (the disable path `SEONIX_PROSE_INDEX=0` in extract.mjs), so
90
+ * the core typed graph is never affected by turning this pass off. */
91
+ export function attachProseTokens(individuals, { enabled = true } = {}) {
92
+ if (!enabled) return individuals;
93
+ for (const ind of individuals) {
94
+ const attrs = ind.attributes || [];
95
+ const isCommit = ind.class === "Commit";
96
+ const name = isCommit ? null : ind.label;
97
+ const doc = isCommit
98
+ ? attrs.find((a) => a.key === "message")?.value
99
+ : attrs.find((a) => a.key === "doc")?.value;
100
+ const tokens = proseTokensFor({ name, doc });
101
+ if (tokens.length) {
102
+ ind.attributes = [...(ind.attributes || []), { prop: "mgx:hasProseTokens", key: "prose_tokens", value: tokens.join(" ") }];
103
+ }
104
+ }
105
+ return individuals;
106
+ }
107
+
108
+ /** Build the inverted index (word -> sorted, deduped [individual ids]) from individuals
109
+ * that already carry a `prose_tokens` attribute (i.e. after attachProseTokens ran).
110
+ * Plain object, JSON-serializable — this is what lands as `entities.proseIndex`. */
111
+ export function buildProseIndex(individuals) {
112
+ const index = Object.create(null);
113
+ for (const ind of individuals) {
114
+ const tokAttr = (ind.attributes || []).find((a) => a.key === "prose_tokens");
115
+ if (!tokAttr?.value) continue;
116
+ for (const word of tokAttr.value.split(" ")) {
117
+ if (!index[word]) index[word] = [];
118
+ index[word].push(ind.id);
119
+ }
120
+ }
121
+ for (const word of Object.keys(index)) index[word].sort();
122
+ return index;
123
+ }
124
+
125
+ /** Consumer-facing lookup: individual ids whose prose tokens overlap `query` (free text,
126
+ * tokenized the same way as a docstring), ranked by overlap count (most shared words
127
+ * first). This is the integration point for ask.mjs's resolveObject (fuzzy object-term
128
+ * resolution beyond exact/substring match) and codegraph.mjs's scoreModules (a lexical
129
+ * boost source) — see PLAN_PROSE_INDEX.md for the exact call-site recommendation; not
130
+ * wired into either file here to avoid colliding with concurrent work on them.
131
+ * `proseIndex` is `entities.proseIndex` (buildProseIndex's output). */
132
+ export function lookupByProseTokens(proseIndex, query, { limit = 10 } = {}) {
133
+ const queryTokens = [...new Set([...splitIdentifierWords(query), ...tokenizeProse(query)])];
134
+ if (!queryTokens.length) return [];
135
+ const scoreById = new Map();
136
+ for (const word of queryTokens) {
137
+ for (const id of proseIndex?.[word] || []) {
138
+ scoreById.set(id, (scoreById.get(id) || 0) + 1);
139
+ }
140
+ }
141
+ return [...scoreById.entries()]
142
+ .sort((a, b) => b[1] - a[1] || String(a[0]).localeCompare(b[0]))
143
+ .slice(0, limit)
144
+ .map(([id, score]) => ({ id, score }));
145
+ }