@polycode-projects/seonix 0.2.0 → 0.3.0

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/README.md CHANGED
@@ -1,21 +1,49 @@
1
1
  # @polycode-projects/seonix
2
2
 
3
3
  **A deterministic, offline, $0 code-graph tool that makes a cheap coding agent
4
- edit like an expensive one.** SEONIX indexes a repository with Python's `ast` +
5
- `git log` **zero model calls** into a typed code graph, then renders a bounded
6
- **edit digest** you inject once at session start. Your agent edits from turn 1
7
- without fishing through the repo.
8
-
9
- > **Headline.** On Django localisation tasks the no-MCP `seonix` digest cut input
10
- > tokens **+72% median on Sonnet 4.6** and **+65% on Haiku 4.5** (−58% billed cost
11
- > per both-solved task), **6/6 solved**, **$0 to index**. The win is
12
- > model-conditional — full method, numbers and the measurement contract are in the
13
- > repo.
4
+ edit like an expensive one.** SEONIX indexes a repository with Python's `ast` and
5
+ `git log`, with **zero model calls**, into a typed code graph, then renders a
6
+ bounded **edit digest** you inject once at session start. Your agent edits from
7
+ turn 1 without fishing through the repo.
8
+
9
+ > **Headline.** Measured as **mean** input tokens per both-solved task (paired
10
+ > arms, identical model pin): on the Django localisation suite the no-MCP
11
+ > `seonix` digest cut mean input tokens **38% on Haiku 4.5** (`BENCHMARK_016.md`
12
+ > restatement, N=5). Under the score-gap default shipped 2026-07-02 (R1b), the
13
+ > re-tested Django cell on Sonnet 4.6 shows a **41% mean** cut and a C# cell on
14
+ > Sonnet 4.6 shows **57.9% mean**, clearing the ≥50% decision bar. Indexing costs
15
+ > **$0**. The win is model-conditional; full method, numbers and the measurement
16
+ > contract are in the repo.
14
17
 
15
18
  - **Website / live demo:** https://seonix.polycode.co.uk
16
19
  - **Source + benchmarks:** https://gitlab.com/polycode-projects/seonix
17
20
  - **Licence:** AGPL-3.0-only
18
21
 
22
+ ## Cross-model comparison
23
+
24
+ seonix on the cheaper **Sonnet 4.6** vs the flagship **Opus 4.8** out of the box,
25
+ mean input tokens per solved run (N=3; N=5 where an otb cell reuses the B015
26
+ baseline). `+` means seonix on Sonnet used fewer tokens.
27
+
28
+ | full task name | otb / Opus 4.8 | solved | seonix / Sonnet 4.6 | solved | token change |
29
+ | --- | --- | --- | --- | --- | --- |
30
+ | ts-hono-utils-helper (simple) | 135k | 3/3 | 117k | 3/3 | +13% |
31
+ | ts-hono-response-time-middleware | 345k | 3/3 | 555k | 3/3 | −61% |
32
+ | ts-hono-json-envelope | 1,102k | 3/3 | 864k | 3/3 | +22% |
33
+ | py-django-truncatelines-filter | 610k | 5/5 | 524k | 5/5 | +14% |
34
+ | cs-eshoponweb-order-service-total | 318k | 5/5 | 154k | 3/3 | +51% |
35
+ | cs-eshoponweb-basket-service-total | 353k | 5/5 | 255k | 3/3 | +28% |
36
+ | js-express-request-id | 330k | 5/5 | 267k | 3/3 | +19% |
37
+ | js-express-json-envelope | 174k | 5/5 | 185k | 3/3 | −7% |
38
+
39
+ seonix on Sonnet used fewer input tokens than otb on Opus on six of these eight
40
+ tasks at equal correctness on these tasks. This crosses model and arm together, so
41
+ it is an illustration of practical value, not the paired within-model measurement
42
+ the ≥50% decision rule uses. Across all 13 measured seonix cells the drop in input
43
+ tokens is 0.91-correlated (Pearson) with the drop in turns (slope about 0.71):
44
+ seonix saves tokens by letting the agent skip exploration turns, so where it
45
+ removes no turns there is little to save.
46
+
19
47
  ## Install
20
48
 
21
49
  ```bash
@@ -25,8 +53,11 @@ npx @polycode-projects/seonix index /abs/path/to/repo
25
53
  ```
26
54
 
27
55
  **Runtime prerequisites:** Node ≥ 24, **Python 3**, and **git** on `PATH` (the
28
- indexer shells out to Python's `ast` and `git log`). Indexing is fully local and
29
- deterministic no API keys, no network calls, **$0**.
56
+ indexer shells out to Python's `ast` and `git log`). A Java runtime is needed
57
+ only to index Java through JavaParser, and the .NET SDK only to index C# through
58
+ Roslyn; without them, indexing for that language falls back to the bundled
59
+ tree-sitter parser. Indexing is fully local and deterministic: no API keys, no
60
+ network calls, **$0**.
30
61
 
31
62
  ## Quickstart
32
63
 
@@ -44,15 +75,42 @@ seonix cli digest '{"repo_path":"/abs/path/to/repo","modules":["<top hits>"]}' >
44
75
  claude --prompt "Use the seonix digest below to localise and make the change. $(cat digest.txt)"
45
76
  ```
46
77
 
47
- Add `.seonix/` to your `.gitignore` it is machine-local and rebuilt from source.
78
+ Add `.seonix/` to your `.gitignore`; it is machine-local and rebuilt from source.
79
+
80
+ ## Indexing multiple repositories
81
+
82
+ `index_repository` takes one of three argument forms:
83
+
84
+ ```bash
85
+ # one repo (unchanged): artifacts to <repo_path>/.seonix/, module ids unprefixed
86
+ seonix cli index_repository '{"repo_path":"/abs/repo"}'
87
+
88
+ # n explicit repos, merged into ONE graph at <out_root>/.seonix/
89
+ seonix cli index_repository '{"repo_paths":["/abs/repo1","/abs/repo2"],"out_root":"/abs/dir"}'
90
+
91
+ # estate discovery: every immediate child directory of multi_root that contains a
92
+ # .git (dir or file) is indexed as a repo; artifacts to <multi_root>/.seonix/
93
+ seonix cli index_repository '{"multi_root":"/abs/estate"}'
94
+ ```
95
+
96
+ The forms are mutually exclusive. `out_root` defaults to the deepest common
97
+ ancestor directory of the paths (or the current directory if that ancestor is the
98
+ filesystem root). In the merged graph every module id and label is prefixed with
99
+ the repo's directory basename (`mod:<repoName>/<relpath>`); when two repos share a
100
+ basename the first in path sort order keeps the bare name and later ones get `-2`,
101
+ `-3`, … appended. Each repo's git history and `.seonixignore` are handled per repo,
102
+ exactly as in single mode. Cross-repo call edges only arise where a symbol name
103
+ resolves uniquely across the whole merged graph; ambiguous names are dropped, as
104
+ within one repo. Query a merged graph by passing the merge root as `repo_path`
105
+ (e.g. `seonix cli seonix_search '{"repo_path":"/abs/estate","query":"…"}'`).
48
106
 
49
107
  ## The shape that ships
50
108
 
51
109
  The product is the **no-MCP digest path**: render the digest, inject it once, run
52
- with **no server**. The interactive MCP tool loop is retired even mitigated it
53
- never cleared the 50% bar; the round-trip *count*, not the resident schema, is the
54
- cost. Use `seonix_search` (`locate`, self-derives the target) in production; pass
55
- a known target (`oracle`) only to measure the headroom.
110
+ with **no server**. The interactive MCP tool loop is retired. Even mitigated it
111
+ never cleared the 50% bar, because the cost lies in the round-trip count rather
112
+ than the resident schema. Use `seonix_search` (`locate`, self-derives the target)
113
+ in production; pass a known target (`oracle`) only to measure the headroom.
56
114
 
57
115
  ## Visualise
58
116
 
@@ -61,17 +119,68 @@ seonix viz --focus <Symbol> --out graph.html
61
119
  ```
62
120
 
63
121
  Renders a focused, portable Cytoscape sub-graph of the typed code-map as one
64
- self-contained HTML file the same view that runs live on the website.
122
+ self-contained HTML file, the same view that runs live on the website.
123
+
124
+ ## Chat
125
+
126
+ ```bash
127
+ seonix chat [--repo /abs/path]
128
+ ```
129
+
130
+ An interactive prompt over the mechanical `seonix_ask` engine (deterministic, no
131
+ model calls): type a question about the indexed codebase — e.g. `which modules
132
+ import walk.mjs` — and the graph answers; `/exit` (or Ctrl+C/Ctrl+D) leaves. Each
133
+ session is appended per turn to `<repo>/.seonix/session-<uuidv7>.log`, and becomes
134
+ first-class graph data like a commit: a structured sidecar
135
+ (`.seonix/sessions/*.jsonl`) records which entities each turn asked about, the
136
+ session enters `graph.json` as a `Session` individual with `mgx:asksAbout` edges
137
+ (re-attached on every re-index), and it appears on the `seonix viz` timeline.
138
+
139
+ A real transcript against this repository's own index (verified answers):
140
+
141
+ ```text
142
+ seon> where is buildContextBundle defined
143
+ function buildContextBundle() is defined in packages/seonix/src/server.mjs at lines 138-260.
144
+
145
+ seon> what uses codegraph.mjs
146
+ bench/arms.mjs and bench/run.mjs and packages/seonix/src/ask.mjs and
147
+ packages/seonix/src/chat.mjs and packages/seonix/src/extract.mjs and
148
+ packages/seonix/src/server.mjs and packages/seonix/src/viz.mjs and scripts/rank-gate.mjs.
149
+
150
+ seon> when did walk.mjs change
151
+ packages/seonix/src/walk.mjs was last touched by commit a73ac1ba8bfb on 2026-07-01
152
+ ("feat(seonix): .seonixignore excludes […]"); 1 earlier commit recorded.
153
+
154
+ seon> which changes touch commit 85073031
155
+ commit 85073031ff05 touched modules packages/seonix/src/viz.mjs and
156
+ packages/seonix/test/viz.test.mjs; functions buildViewerData() and cytoscapeSource().
157
+
158
+ seon> which functions call bulidContextBundle
159
+ assuming you meant buildContextBundle: in packages/seonix/src/server.mjs
160
+ there is function dispatchTool().
161
+ ```
162
+
163
+ Question shapes: `which <functions|classes|modules> <import|call|use|test|touch> <name>`,
164
+ `what does <module> <import|export>`, `what uses <name>`, `where is <name> defined`,
165
+ `where is <name> mentioned`, `when did <name> change`, `which changes touch commit <sha>`,
166
+ `what does <term> mean`. Misspellings are corrected from a curated table, close
167
+ near-misses resolve through a bounded edit-distance pass and are announced
168
+ ("assuming you meant"), and anything the graph cannot answer is a stated miss with
169
+ a rephrase hint, never a guess.
65
170
 
66
171
  ## Languages
67
172
 
68
- Python (stdlib `ast`), JS/TS (ts-morph), and C# (Roslyn, with a tree-sitter
69
- fallback) all merged into the one typed graph. The C# extractor source ships in
70
- `roslyn/`; build it on the consumer side if you index C#.
173
+ Python (stdlib `ast`), JS/TS (ts-morph), C# (Roslyn, with a tree-sitter fallback)
174
+ and Java (JavaParser, with a tree-sitter fallback), all merged into the one typed
175
+ graph. The C# Roslyn helper's source ships in the package under `roslyn/`; build
176
+ it on the consumer side (needs the .NET SDK) if you index C#. The Java JavaParser
177
+ helper is a jar built from the repository's `java/` directory (`mvn package`) and
178
+ is not included in the npm package, so a package install indexes Java with the
179
+ tree-sitter fallback.
71
180
 
72
181
  ## Licence & safety
73
182
 
74
- **AGPL-3.0-only.** The network clause is intended for a hosted MCP tool hosted
75
- derivatives stay open. The `seonix:` ontology vocabulary is CC-BY-4.0 (NOTICE
183
+ **AGPL-3.0-only.** The network clause is intended for a hosted MCP tool, so that
184
+ hosted derivatives stay open. The `seonix:` ontology vocabulary is CC-BY-4.0 (NOTICE
76
185
  credits SEON + FAMIX). SEONIX never writes to your global agent config and never
77
186
  phones home; everything it writes lives under `.seonix/`.
package/bin/cli.mjs CHANGED
@@ -6,6 +6,13 @@
6
6
  // seonix cli index_repository '{"repo_path":"<abs>"}' → deterministic index
7
7
  // (honours <repo>/.seonixignore — gitignore-subset patterns; pass
8
8
  // `"ignores":false` in the JSON arg to index everything regardless)
9
+ // seonix cli index_repository '{"repo_paths":["<abs1>","<abs2>",…],"out_root":"<abs-dir>"}' → index
10
+ // n repos into ONE merged graph at <out_root>/.seonix/ (module ids prefixed with each repo's
11
+ // directory basename; out_root defaults to the paths' deepest common ancestor directory).
12
+ // Mutually exclusive with repo_path.
13
+ // seonix cli index_repository '{"multi_root":"<abs-dir>"}' → estate discovery: every immediate
14
+ // child directory carrying a .git (dir or file) is indexed as a repo, merged into
15
+ // <multi_root>/.seonix/ (discovered/skipped counts logged to stderr)
9
16
  // seonix cli digest '{"repo_path":"<abs>","modules":[…]}' → architecture map + per-module
10
17
  // context bundles to stdout (no-MCP arm)
11
18
  // seonix cli digest '{"repo_path":"<abs>","query":"<free text>"}' → same, but auto-locates +
@@ -19,9 +26,10 @@
19
26
  // (e.g. seonix cli seonix_ask '{"query":"<free text>"}' — mechanical, no-LLM NL question
20
27
  // over the graph, PLAN_MECHANICAL_CHAT.md; no bespoke wiring needed, this fallback covers it)
21
28
  // seonix hook-augment → PreToolUse Grep/Glob augmenter (stdin→stdout)
22
- // seonix viz [--focus <sym>] [--depth N] [--out f.html] [--data-out f.json] [--repo-url <gitlab url> --ref main] [--site-nav] → render a focused sub-graph to HTML (one shared viewer; data embedded, or split out with --data-out)
23
- // seonix viz --browser-out f.html [--browser-data-out f.json] [--scope product|<prefixes>] → also render the Chronograph code browser (temporal scrub + two-cursor diff + narration + ghost-branch merges + keyboard nav; PLAN_CODE_BROWSER.md)
29
+ // seonix viz [--focus <sym>] [--depth N] [--out f.html] [--data-out f.json] [--repo-url <gitlab url> --ref main] [--site-nav] → render a focused sub-graph to HTML (one shared viewer; data embedded, or split out with --data-out). By default also writes code-browser.html + timeline.html next to --out with a working header nav; --graph-only suppresses the siblings
30
+ // seonix viz --browser-out f.html [--browser-data-out f.json] [--timeline-out f.html] [--scope product|<prefixes>] → override the sibling artifact paths (Chronograph code browser: temporal scrub + two-cursor diff + narration + ghost-branch merges + keyboard nav; archive/PLAN_CODE_BROWSER.md)
24
31
  // seonix viz --serve [--port N] [--focus <sym>] → serve the same viewer against THIS repo's index (the local equivalent of the site's #viz; code browser at /code-browser.html, live-reannotating on HEAD change via /code-browser-version)
32
+ // seonix chat [--repo <abs>] → interactive prompt over the mechanical seonix_ask engine; /exit to leave; session log → <repo>/.seonix/session-<uuidv7>.log
25
33
  //
26
34
  // The index step is offline and model-free (Python ast + git). It writes the
27
35
  // graph artifact to <repo_path>/.seonix/graph.json; the server (started with
@@ -30,7 +38,7 @@
30
38
  import { join } from "node:path";
31
39
  import { startServer } from "../src/server.mjs";
32
40
  import { dispatchTool, buildContextBundle } from "../src/server.mjs";
33
- import { indexRepository } from "../src/extract.mjs";
41
+ import { indexRepository, indexRepositories, discoverRepos } from "../src/extract.mjs";
34
42
  import { loadConfig, DEFAULT_GRAPH_REL } from "../src/config.mjs";
35
43
  import * as source from "../src/source.mjs";
36
44
  import { parseEntities, renderSearch, rankModulesByProximity, searchModulesRanked, selectRankedModules, DEFAULT_SCORE_GAP } from "../src/codegraph.mjs";
@@ -173,29 +181,68 @@ async function main() {
173
181
  const [mode, sub, payload] = process.argv.slice(2);
174
182
 
175
183
  if (mode === "cli") {
176
- // index mode: `cli index_repository '{"repo_path":"<abs>"}'`
184
+ // index mode: `cli index_repository '{"repo_path":"<abs>"}'` (single repo, unchanged),
185
+ // '{"repo_paths":[…],"out_root":"<abs>"}' (explicit n-repo merge) or
186
+ // '{"multi_root":"<abs>"}' (discover child repos, merge into <multi_root>/.seonix/).
177
187
  if (sub === "index_repository") {
178
188
  const args = parsePayload(payload);
179
189
  if (args === null) {
180
190
  process.stderr.write("seonix: index_repository expects a JSON arg, e.g. '{\"repo_path\":\"/abs\"}'\n");
181
191
  process.exit(2);
182
192
  }
183
- const repoPath = args.repo_path;
184
- if (!repoPath) {
185
- process.stderr.write("seonix: index_repository requires repo_path\n");
193
+ const given = ["repo_path", "repo_paths", "multi_root"].filter((k) => args[k] !== undefined);
194
+ if (given.length > 1) {
195
+ process.stderr.write(`seonix: ${given.join(" + ")} are mutually exclusive — pass exactly one\n`);
196
+ process.exit(2);
197
+ }
198
+ if (given.length === 0) {
199
+ process.stderr.write("seonix: index_repository requires repo_path, repo_paths or multi_root\n");
186
200
  process.exit(2);
187
201
  }
188
202
  const t0 = Date.now();
189
- const { graphFile, counts } = await indexRepository(repoPath, { ignores: args.ignores !== false });
190
- process.stderr.write(
191
- `seonix: indexed ${counts.modules} modules, ${counts.functions} symbols, ` +
192
- `${counts.edges} edges (${counts.callsSymbol} callsSymbol, ${counts.touchesSymbol} touchesSymbol), ` +
193
- `${counts.commits} commits in ${Date.now() - t0}ms → ${graphFile}\n`,
194
- );
195
- process.stderr.write(
196
- `seonix: history-symbol pass +${counts.historyMs}ms over ${counts.baseMs}ms base ` +
197
- `= ${counts.historyPct}% (depth ${counts.historySymbolDepth}; budget ≤10%)\n`,
198
- );
203
+ const emitSummary = (graphFile, counts, head) => {
204
+ process.stderr.write(
205
+ `seonix: indexed ${head}${counts.modules} modules, ${counts.functions} symbols, ` +
206
+ `${counts.edges} edges (${counts.callsSymbol} callsSymbol, ${counts.touchesSymbol} touchesSymbol), ` +
207
+ `${counts.commits} commits in ${Date.now() - t0}ms → ${graphFile}\n`,
208
+ );
209
+ process.stderr.write(
210
+ `seonix: history-symbol pass +${counts.historyMs}ms over ${counts.baseMs}ms base ` +
211
+ `= ${counts.historyPct}% (depth ${counts.historySymbolDepth}; budget ≤10%)\n`,
212
+ );
213
+ };
214
+
215
+ if (args.repo_path) {
216
+ const { graphFile, counts } = await indexRepository(args.repo_path, { ignores: args.ignores !== false });
217
+ emitSummary(graphFile, counts, "");
218
+ return;
219
+ }
220
+
221
+ let repoPaths = args.repo_paths;
222
+ let outRoot = args.out_root || "";
223
+ if (args.multi_root) {
224
+ const { repos, skipped } = await discoverRepos(args.multi_root);
225
+ process.stderr.write(
226
+ `seonix: multi_root ${args.multi_root}: ${repos.length} repo(s) discovered` +
227
+ (skipped.length ? `; skipped ${skipped.length} non-repo child dir(s): ${skipped.join(", ")}` : "") + "\n",
228
+ );
229
+ if (!repos.length) {
230
+ process.stderr.write("seonix: multi_root contains no repositories (no child directory has a .git)\n");
231
+ process.exit(2);
232
+ }
233
+ repoPaths = repos;
234
+ outRoot = args.multi_root;
235
+ }
236
+ if (!Array.isArray(repoPaths) || repoPaths.length === 0) {
237
+ process.stderr.write("seonix: repo_paths must be a non-empty array of absolute paths\n");
238
+ process.exit(2);
239
+ }
240
+ const { graphFile, counts, repos } = await indexRepositories(repoPaths, {
241
+ ignores: args.ignores !== false,
242
+ outRoot,
243
+ log: (line) => process.stderr.write(`seonix: ${line}\n`),
244
+ });
245
+ emitSummary(graphFile, counts, `${repos.length} repos, `);
199
246
  return;
200
247
  }
201
248
 
@@ -315,6 +362,13 @@ async function main() {
315
362
  return;
316
363
  }
317
364
 
365
+ if (mode === "chat") {
366
+ const { runChat } = await import("../src/chat.mjs");
367
+ const i = process.argv.indexOf("--repo");
368
+ await runChat({ repoPath: i !== -1 ? process.argv[i + 1] : undefined });
369
+ return;
370
+ }
371
+
318
372
  if (mode === undefined) {
319
373
  await startServer(); // bare invocation → MCP stdio server
320
374
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polycode-projects/seonix",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
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.",
@@ -45,10 +45,12 @@
45
45
  "dependencies": {
46
46
  "@modelcontextprotocol/sdk": "^1.29.0",
47
47
  "cytoscape": "^3.30.0",
48
- "ts-morph": "^24.0.0",
49
48
  "tree-sitter": "^0.21.1",
50
49
  "tree-sitter-c-sharp": "^0.21.3",
51
- "tree-sitter-java": "^0.21.0"
50
+ "tree-sitter-java": "^0.21.0",
51
+ "typescript": "~5.6.2",
52
+ "wink-eng-lite-web-model": "^1.8.1",
53
+ "wink-nlp": "^2.4.0"
52
54
  },
53
55
  "scripts": {
54
56
  "test": "node --test \"test/**/*.test.mjs\"",
package/roslyn/Program.cs CHANGED
@@ -56,6 +56,20 @@ List<string> CallsIn(SyntaxNode body) {
56
56
  name = OneLine(name);
57
57
  if (name.Length > 0) set.Add(Clip(name, 80));
58
58
  }
59
+ // Object creations count as call edges to the created type: `new X(...)` → "X".
60
+ // Always the BARE type identifier (generics/qualifiers stripped) so extract.mjs's
61
+ // unique-lastIdent registry can resolve it to the internal Class. Implicit
62
+ // `new(...)` (no type syntax) is a different node type and stays excluded.
63
+ foreach (var oc in body.DescendantNodes().OfType<ObjectCreationExpressionSyntax>()) {
64
+ var t = oc.Type;
65
+ if (t is QualifiedNameSyntax q) t = q.Right; // rightmost identifier
66
+ string tn = t switch {
67
+ GenericNameSyntax g => g.Identifier.Text,
68
+ IdentifierNameSyntax id => id.Identifier.Text,
69
+ _ => null
70
+ };
71
+ if (!string.IsNullOrEmpty(tn)) set.Add(Clip(tn, 80));
72
+ }
59
73
  return set.ToList();
60
74
  }
61
75
 
@@ -66,6 +80,38 @@ string Vis(SyntaxTokenList mods) {
66
80
  }
67
81
  bool IsPublic(SyntaxTokenList mods) => mods.Any(m => m.Text == "public");
68
82
 
83
+ // Nested types qualify as Outer.Inner (members then inherit it via cname+"."+member).
84
+ string QualName(BaseTypeDeclarationSyntax t) {
85
+ var name = t.Identifier.Text;
86
+ for (var p = t.Parent; p is BaseTypeDeclarationSyntax outer; p = p.Parent)
87
+ name = outer.Identifier.Text + "." + name;
88
+ return name;
89
+ }
90
+
91
+ // kind stays "class" for every type declaration; subkind distinguishes the flavour
92
+ // (record covers `record struct` too — same syntax node). Plain class → null (omitted).
93
+ string SubkindOf(BaseTypeDeclarationSyntax t) => t switch {
94
+ InterfaceDeclarationSyntax => "interface",
95
+ EnumDeclarationSyntax => "enum",
96
+ RecordDeclarationSyntax => "record",
97
+ StructDeclarationSyntax => "struct",
98
+ _ => null
99
+ };
100
+
101
+ // First non-empty line of the XML doc comment (///), tags stripped, clipped to 120
102
+ // chars — mirrors the Java extractor's javadoc emission (extract.mjs consumes d.doc).
103
+ string DocOf(SyntaxNode n) {
104
+ foreach (var trivia in n.GetLeadingTrivia()) {
105
+ if (trivia.GetStructure() is not DocumentationCommentTriviaSyntax dc) continue;
106
+ var text = System.Text.RegularExpressions.Regex.Replace(dc.ToFullString(), "<[^>]*>", " ");
107
+ foreach (var rawLine in text.Split('\n')) {
108
+ var s = rawLine.Trim().TrimStart('/').Trim();
109
+ if (s.Length > 0) return Clip(s, 120);
110
+ }
111
+ }
112
+ return "";
113
+ }
114
+
69
115
  var modules = new List<object>();
70
116
  var failures = new List<string>();
71
117
 
@@ -91,17 +137,34 @@ foreach (var file in files) {
91
137
  var defines = new List<object>();
92
138
  var exports = new SortedSet<string>(StringComparer.Ordinal);
93
139
 
94
- foreach (var type in rootNode.DescendantNodes().OfType<TypeDeclarationSyntax>()) {
95
- var cname = type.Identifier.Text;
96
- var bases = type.BaseList?.Types.Select(t => Clip(OneLine(t.ToString()), 80)).ToList() ?? new List<string>();
140
+ // BaseTypeDeclarationSyntax covers classes/interfaces/structs/records AND enums
141
+ // (TypeDeclarationSyntax alone missed EnumDeclarationSyntax entirely). Document
142
+ // order, so nested types follow their parent names qualify via QualName.
143
+ foreach (var btd in rootNode.DescendantNodes().OfType<BaseTypeDeclarationSyntax>()) {
144
+ var cname = QualName(btd);
145
+ var bases = btd.BaseList?.Types.Select(t => Clip(OneLine(t.ToString()), 80)).ToList() ?? new List<string>();
97
146
  var cdef = new Dictionary<string, object> {
98
- ["name"] = cname, ["kind"] = "class", ["lineno"] = LineOf(type), ["end_lineno"] = EndLineOf(type),
99
- ["bases"] = bases, ["decorators"] = type.AttributeLists.SelectMany(a => a.Attributes).Select(a => OneLine(a.ToString())).ToList()
147
+ ["name"] = cname, ["kind"] = "class", ["lineno"] = LineOf(btd), ["end_lineno"] = EndLineOf(btd),
148
+ ["bases"] = bases, ["decorators"] = btd.AttributeLists.SelectMany(a => a.Attributes).Select(a => OneLine(a.ToString())).ToList()
100
149
  };
101
- if (IsPublic(type.Modifiers)) exports.Add(cname);
102
- var v = Vis(type.Modifiers); if (v != "") cdef["visibility"] = v;
150
+ var subkind = SubkindOf(btd); if (subkind != null) cdef["subkind"] = subkind;
151
+ var cdoc = DocOf(btd); if (cdoc != "") cdef["doc"] = cdoc;
152
+ if (IsPublic(btd.Modifiers)) exports.Add(cname);
153
+ var v = Vis(btd.Modifiers); if (v != "") cdef["visibility"] = v;
103
154
  defines.Add(cdef);
104
155
 
156
+ if (btd is EnumDeclarationSyntax en) {
157
+ foreach (var em in en.Members) {
158
+ defines.Add(new Dictionary<string, object> {
159
+ ["name"] = $"{cname}.{em.Identifier.Text}", ["kind"] = "attribute",
160
+ ["lineno"] = LineOf(em), ["end_lineno"] = EndLineOf(em), ["decorators"] = new List<string>(),
161
+ ["is_constant"] = true
162
+ });
163
+ }
164
+ continue;
165
+ }
166
+ if (btd is not TypeDeclarationSyntax type) continue;
167
+
105
168
  foreach (var mem in type.Members) {
106
169
  if (mem is MethodDeclarationSyntax m) {
107
170
  var md = new Dictionary<string, object> {
@@ -113,19 +176,24 @@ foreach (var file in files) {
113
176
  };
114
177
  if (m.Modifiers.Any(x => x.Text == "static")) md["is_static"] = true;
115
178
  var mv = Vis(m.Modifiers); if (mv != "") md["visibility"] = mv;
179
+ var mdoc = DocOf(m); if (mdoc != "") md["doc"] = mdoc;
116
180
  defines.Add(md);
117
181
  } else if (mem is ConstructorDeclarationSyntax ctor) {
118
- defines.Add(new Dictionary<string, object> {
182
+ var ctd = new Dictionary<string, object> {
119
183
  ["name"] = $"{cname}.{ctor.Identifier.Text}", ["kind"] = "method",
120
184
  ["lineno"] = LineOf(ctor), ["end_lineno"] = EndLineOf(ctor), ["decorators"] = new List<string>(),
121
185
  ["params"] = Clip(OneLine(string.Join(", ", ctor.ParameterList.Parameters.Select(p => p.ToString()))), 160),
122
186
  ["calls"] = CallsIn(ctor.Body as SyntaxNode ?? ctor.ExpressionBody)
123
- });
187
+ };
188
+ var cdoc2 = DocOf(ctor); if (cdoc2 != "") ctd["doc"] = cdoc2;
189
+ defines.Add(ctd);
124
190
  } else if (mem is PropertyDeclarationSyntax prop) {
125
- defines.Add(new Dictionary<string, object> {
191
+ var pd = new Dictionary<string, object> {
126
192
  ["name"] = $"{cname}.{prop.Identifier.Text}", ["kind"] = "attribute",
127
193
  ["lineno"] = LineOf(prop), ["end_lineno"] = EndLineOf(prop), ["decorators"] = new List<string>()
128
- });
194
+ };
195
+ var pdoc = DocOf(prop); if (pdoc != "") pd["doc"] = pdoc;
196
+ defines.Add(pd);
129
197
  } else if (mem is FieldDeclarationSyntax fld) {
130
198
  foreach (var vd in fld.Declaration.Variables) {
131
199
  defines.Add(new Dictionary<string, object> {
@@ -0,0 +1,73 @@
1
+ // ask-nlp.mjs — the OPTIONAL wink-nlp adapter behind ask.mjs's lemma/POS tier.
2
+ //
3
+ // BOUNDARY (hard, do not move): this file is Node-only and is NEVER inlined into
4
+ // the viewer bundle. viz.mjs's askSource() inlines codegraph.mjs + ask-vocab.mjs +
5
+ // ask.mjs ONLY and strips their import lines, so the portable single-file HTML has
6
+ // no wink, no model, and no `nlpAdapter` binding at all — ask.mjs reaches this
7
+ // module exclusively through a `typeof nlpAdapter === "function"` guard and
8
+ // degrades to adapter-less parsing (lemma/POS tiers off; the curated tables and
9
+ // the bounded edit-distance tier still work, browser and Node alike). Keeping the
10
+ // ~1MB CJS model out of the page is the point of the split.
11
+ //
12
+ // wink-nlp and wink-eng-lite-web-model are CJS — loaded via createRequire, the
13
+ // same Node-module-resolution approach viz.mjs uses to locate cytoscape (resolve
14
+ // through the module system, never a guessed path). The require happens lazily on
15
+ // first use and failure is cached as null: a checkout without the optional deps
16
+ // installed answers exactly like the browser bundle, it never throws.
17
+
18
+ import { createRequire } from "node:module";
19
+
20
+ let cached; // undefined = not tried yet; null = unavailable (tried once, honestly off)
21
+
22
+ /** Lazily build the {lemma, posTags} adapter, or null when wink isn't loadable.
23
+ * Deterministic: wink-nlp's tagger/lemmatiser is a fixed model with no sampling,
24
+ * so the same input always yields the same tags/lemmas across processes. */
25
+ export function nlpAdapter() {
26
+ if (cached !== undefined) return cached;
27
+ try {
28
+ const require = createRequire(import.meta.url);
29
+ const winkNLP = require("wink-nlp");
30
+ const model = require("wink-eng-lite-web-model");
31
+ const nlp = winkNLP(model);
32
+ const its = nlp.its;
33
+ cached = {
34
+ /** Lowercase lemma of a single token ("imported" -> "import"); the word
35
+ * itself when wink has nothing better (unknown words come back as-is). */
36
+ lemma(word) {
37
+ const w = String(word || "");
38
+ try {
39
+ const out = nlp.readDoc(w).tokens().out(its.lemma);
40
+ return String(out[0] || w).toLowerCase();
41
+ } catch {
42
+ return w.toLowerCase();
43
+ }
44
+ },
45
+ /** UPOS tags aligned to the CALLER's word array. wink re-tokenizes (it
46
+ * splits "walk.mjs" into three tokens), so each input word is greedily
47
+ * matched to the run of wink tokens that spell it and takes its FIRST
48
+ * sub-token's tag; null per word on any surprise, never a throw. */
49
+ posTags(words) {
50
+ try {
51
+ const toks = nlp.readDoc(words.join(" ")).tokens();
52
+ const texts = toks.out();
53
+ const tags = toks.out(its.pos);
54
+ const out = [];
55
+ let k = 0;
56
+ for (const w of words) {
57
+ if (k >= texts.length) { out.push(null); continue; }
58
+ out.push(tags[k]);
59
+ let acc = texts[k];
60
+ k += 1;
61
+ while (acc.length < w.length && k < texts.length) { acc += texts[k]; k += 1; }
62
+ }
63
+ return out;
64
+ } catch {
65
+ return words.map(() => null);
66
+ }
67
+ },
68
+ };
69
+ } catch {
70
+ cached = null;
71
+ }
72
+ return cached;
73
+ }