@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/bin/cli.mjs +95 -12
- package/package.json +5 -2
- package/src/ask-vocab.mjs +258 -0
- package/src/ask.mjs +762 -0
- package/src/browser.mjs +769 -0
- package/src/codegraph.mjs +352 -23
- package/src/cs_treesitter.mjs +2 -2
- package/src/extract.mjs +51 -8
- package/src/extract_lang.mjs +17 -2
- package/src/java_javaparser.mjs +54 -0
- package/src/java_treesitter.mjs +216 -0
- package/src/jsts_tsc.mjs +2 -2
- package/src/prose.mjs +145 -0
- package/src/schema-docs.mjs +225 -0
- package/src/server.mjs +35 -4
- package/src/temporal.mjs +559 -0
- package/src/viz.mjs +666 -94
- package/src/walk.mjs +52 -5
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:
|
|
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
|
|
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,
|
package/src/extract_lang.mjs
CHANGED
|
@@ -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
|
+
}
|