@polycode-projects/seonix 0.1.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 ADDED
@@ -0,0 +1,77 @@
1
+ # @polycode-projects/seonix
2
+
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.
14
+
15
+ - **Website / live demo:** https://seonix.polycode.co.uk
16
+ - **Source + benchmarks:** https://gitlab.com/polycode-projects/seonix
17
+ - **Licence:** AGPL-3.0-only
18
+
19
+ ## Install
20
+
21
+ ```bash
22
+ npm i -g @polycode-projects/seonix # global — gives you the `seonix` bin on PATH
23
+ # or one-shot, no install:
24
+ npx @polycode-projects/seonix index /abs/path/to/repo
25
+ ```
26
+
27
+ **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**.
30
+
31
+ ## Quickstart
32
+
33
+ ```bash
34
+ # 1. index your repo (deterministic, offline) — writes a machine-local .seonix/
35
+ seonix index /abs/path/to/repo
36
+
37
+ # 2. self-locate the touched modules from the task text (no human-chosen target)
38
+ seonix cli seonix_search '{"repo_path":"/abs/path/to/repo","query":"<your task>"}'
39
+
40
+ # 3. render the bounded edit digest for those modules
41
+ seonix cli digest '{"repo_path":"/abs/path/to/repo","modules":["<top hits>"]}' > digest.txt
42
+
43
+ # 4. inject digest.txt once at session start, then run your coding agent
44
+ claude --prompt "Use the seonix digest below to localise and make the change. $(cat digest.txt)"
45
+ ```
46
+
47
+ Add `.seonix/` to your `.gitignore` — it is machine-local and rebuilt from source.
48
+
49
+ ## The shape that ships
50
+
51
+ 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.
56
+
57
+ ## Visualise
58
+
59
+ ```bash
60
+ seonix viz --focus <Symbol> --out graph.html
61
+ ```
62
+
63
+ 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.
65
+
66
+ ## Languages
67
+
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#.
71
+
72
+ ## Licence & safety
73
+
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
76
+ credits SEON + FAMIX). SEONIX never writes to your global agent config and never
77
+ phones home; everything it writes lives under `.seonix/`.
package/bin/cli.mjs ADDED
@@ -0,0 +1,248 @@
1
+ #!/usr/bin/env node
2
+ // seonix — local typed-edge code-graph MCP. Two-mode contract, mirroring
3
+ // codebase-memory-mcp so it drops into the same benchmark harness:
4
+ //
5
+ // seonix → stdio MCP server
6
+ // seonix cli index_repository '{"repo_path":"<abs>"}' → deterministic index
7
+ // seonix cli digest '{"repo_path":"<abs>","modules":[…]}' → architecture map + per-module
8
+ // context bundles to stdout (no-MCP arm)
9
+ // seonix cli <toolName> '{…args}' → invoke ANY MCP tool via Bash (no MCP)
10
+ // seonix hook-augment → PreToolUse Grep/Glob augmenter (stdin→stdout)
11
+ // seonix viz [--focus <sym>] [--depth N] [--out f.html] → render a focused sub-graph to HTML
12
+ //
13
+ // The index step is offline and model-free (Python ast + git). It writes the
14
+ // graph artifact to <repo_path>/.seonix/graph.json; the server (started with
15
+ // cwd = that repo) loads it by default. No flags, no config files.
16
+
17
+ import { join } from "node:path";
18
+ import { startServer } from "../src/server.mjs";
19
+ import { dispatchTool, buildContextBundle } from "../src/server.mjs";
20
+ import { indexRepository } from "../src/extract.mjs";
21
+ import { loadConfig, DEFAULT_GRAPH_REL } from "../src/config.mjs";
22
+ import * as source from "../src/source.mjs";
23
+ import { parseEntities, renderSearch, rankModulesByProximity, searchModulesRanked } from "../src/codegraph.mjs";
24
+
25
+ /** Build a config pointed at a specific repo's artifact (for `cli` sub-commands that
26
+ * take a repo_path), or fall back to the cwd-derived default. */
27
+ function configFor(repoPath) {
28
+ return repoPath ? { graphFile: join(repoPath, DEFAULT_GRAPH_REL) } : loadConfig();
29
+ }
30
+
31
+ /** Parse the trailing JSON payload of a `cli` sub-command (best-effort). */
32
+ function parsePayload(payload) {
33
+ if (!payload) return {};
34
+ try { return JSON.parse(payload); }
35
+ catch { return null; }
36
+ }
37
+
38
+ const DIGEST_MODULE_CAP = 12; // bound the no-MCP digest — a handful of changed modules
39
+ const DIGEST_SECONDARY_CAP = 2; // B2: at most this many SECONDARY modules get a (trimmed) bundle
40
+ const TIER_RANK = { NONE: 0, TINY: 1, MID: 2, LARGE: 3, FULL: 4 };
41
+
42
+ /** `cli digest` — print a machine-readable header + a repo architecture map + the
43
+ * seonix_context edit bundle for each requested module to stdout (reuses the server's exact
44
+ * renderer via buildContextBundle, so no render logic is duplicated). This stdout is injected
45
+ * into the no-MCP arm's prompt.
46
+ *
47
+ * B2: the FIRST (primary) module gets a full size-adaptive bundle; the remaining modules are
48
+ * RANKED by import/cochange proximity to the primary and only the top few get a TRIMMED
49
+ * (signatures + insertion region) bundle — so a 2-module task no longer pays for two full
50
+ * bundles. The leading header line lets the rig record tier/topup telemetry. */
51
+ async function runDigest(args) {
52
+ const repoPath = args.repo_path;
53
+ if (!repoPath) { process.stderr.write("seonix: digest requires repo_path\n"); process.exit(2); }
54
+ const modules = Array.isArray(args.modules) ? args.modules.slice(0, DIGEST_MODULE_CAP) : [];
55
+ // Tuning-flag contract (threaded to buildContextBundle → sizeBundle): `min` → leanest TINY/no
56
+ // top-up; `untuned` → pre-B010 escalation. Neither → the tuned default. The digest header still
57
+ // reports the EFFECTIVE tier/topup returned per module, so rig telemetry stays correct.
58
+ const min = Boolean(args.min);
59
+ const untuned = Boolean(args.untuned);
60
+ const config = configFor(repoPath);
61
+ const body = [];
62
+ let effTier = "NONE"; // largest tier emitted across all modules
63
+ let topup = false; // whether any module's auto-sizing escalated above TINY
64
+ let emitted = 0; // module bundles actually emitted (primary + trimmed secondaries)
65
+
66
+ try {
67
+ body.push("# Repository architecture\n" + (await dispatchTool("seonix_architecture", {}, { config })));
68
+ } catch (e) {
69
+ body.push(`# Repository architecture\n(unavailable: ${e?.message || e})`);
70
+ }
71
+
72
+ const emit = async (m, trim) => {
73
+ try {
74
+ const { text, tier, topup: t } = await buildContextBundle({ symbol: m, min, untuned }, { config, source, trim });
75
+ body.push(`\n# Context bundle: ${m}${trim ? " (secondary, trimmed)" : ""}\n` + text);
76
+ if ((TIER_RANK[tier] || 0) > (TIER_RANK[effTier] || 0)) effTier = tier;
77
+ if (t) topup = true;
78
+ emitted += 1;
79
+ } catch (e) {
80
+ body.push(`\n# Context bundle: ${m}\n(no bundle: ${e?.message || e})`);
81
+ }
82
+ };
83
+
84
+ if (modules.length) {
85
+ const [primary, ...rest] = modules;
86
+ // rank the secondaries by proximity to the primary (best-effort: keep input order on error)
87
+ let ranked = rest;
88
+ if (rest.length) {
89
+ try { ranked = rankModulesByProximity(parseEntities(await source.fetchEntities(config)), primary, rest); }
90
+ catch { ranked = rest; }
91
+ }
92
+ const secondaries = ranked.slice(0, DIGEST_SECONDARY_CAP);
93
+ const overflow = ranked.slice(DIGEST_SECONDARY_CAP);
94
+ await emit(primary, false);
95
+ for (const m of secondaries) await emit(m, true);
96
+ if (overflow.length) body.push(`\n# Related modules (not expanded; query seonix_context if needed): ${overflow.join(", ")}`);
97
+ }
98
+
99
+ // HARD CONTRACT: first line is the machine-readable digest header the rig greps.
100
+ const header = `# seonix-digest tier=${effTier} topup=${topup} modules=${emitted}`;
101
+ process.stdout.write([header, ...body].join("\n") + "\n");
102
+ }
103
+
104
+ /** Read all of stdin (best-effort; resolves "" if none). */
105
+ function readStdin() {
106
+ return new Promise((resolve) => {
107
+ let data = "";
108
+ if (process.stdin.isTTY) return resolve("");
109
+ process.stdin.setEncoding("utf8");
110
+ process.stdin.on("data", (c) => (data += c));
111
+ process.stdin.on("end", () => resolve(data));
112
+ process.stdin.on("error", () => resolve(data));
113
+ });
114
+ }
115
+
116
+ /** PreToolUse Grep/Glob augmenter: emit seon graph context as additionalContext.
117
+ * Mirrors cbm's hook-augment. Silent on any failure (exit 0, no output) so it can
118
+ * never block a tool call. */
119
+ async function hookAugment() {
120
+ try {
121
+ const raw = await readStdin();
122
+ const evt = JSON.parse(raw || "{}");
123
+ const ti = evt.tool_input || evt.toolInput || {};
124
+ const query = String(ti.pattern || ti.query || ti.glob || "").trim();
125
+ if (!query) return;
126
+ const graph = parseEntities(await source.fetchEntities(loadConfig()));
127
+ if (!graph.individuals.length) return;
128
+ const text = renderSearch(graph, query);
129
+ if (!text || /^no module matches|^empty query/.test(text)) return;
130
+ const additionalContext =
131
+ "seon typed code-map hits for this search (prefer seonix_context / seonix_snippet over reading files):\n" + text;
132
+ process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: "PreToolUse", additionalContext } }));
133
+ } catch {
134
+ /* silent — never block the tool */
135
+ }
136
+ }
137
+
138
+ async function main() {
139
+ const [mode, sub, payload] = process.argv.slice(2);
140
+
141
+ if (mode === "cli") {
142
+ // index mode: `cli index_repository '{"repo_path":"<abs>"}'`
143
+ if (sub === "index_repository") {
144
+ const args = parsePayload(payload);
145
+ if (args === null) {
146
+ process.stderr.write("seonix: index_repository expects a JSON arg, e.g. '{\"repo_path\":\"/abs\"}'\n");
147
+ process.exit(2);
148
+ }
149
+ const repoPath = args.repo_path;
150
+ if (!repoPath) {
151
+ process.stderr.write("seonix: index_repository requires repo_path\n");
152
+ process.exit(2);
153
+ }
154
+ const t0 = Date.now();
155
+ const { graphFile, counts } = await indexRepository(repoPath);
156
+ process.stderr.write(
157
+ `seonix: indexed ${counts.modules} modules, ${counts.functions} symbols, ` +
158
+ `${counts.edges} edges (${counts.callsSymbol} callsSymbol, ${counts.touchesSymbol} touchesSymbol), ` +
159
+ `${counts.commits} commits in ${Date.now() - t0}ms → ${graphFile}\n`,
160
+ );
161
+ process.stderr.write(
162
+ `seonix: history-symbol pass +${counts.historyMs}ms over ${counts.baseMs}ms base ` +
163
+ `= ${counts.historyPct}% (depth ${counts.historySymbolDepth}; budget ≤10%)\n`,
164
+ );
165
+ return;
166
+ }
167
+
168
+ // digest mode: architecture map + per-module context bundles → stdout (no-MCP arm)
169
+ if (sub === "digest") {
170
+ const args = parsePayload(payload);
171
+ if (args === null) {
172
+ process.stderr.write("seonix: digest expects a JSON arg, e.g. '{\"repo_path\":\"/abs\",\"modules\":[…]}'\n");
173
+ process.exit(2);
174
+ }
175
+ await runDigest(args);
176
+ return;
177
+ }
178
+
179
+ // locate mode (TUNING #3): `cli seonix_locate '{"query":"…","repo_path":"<abs>"}'` emits the
180
+ // ranked modules as `<relpath>\t<score>`, one per line (highest first), using renderSearch's
181
+ // exact ranking. The rig keeps rank-1 always and rank-2 only when score2/score1 is close — it
182
+ // needs the raw scores, which the text renderer hides. Independent of the tuning flags.
183
+ if (sub === "seonix_locate") {
184
+ const args = parsePayload(payload);
185
+ if (args === null) {
186
+ process.stderr.write("seonix: seonix_locate expects a JSON arg, e.g. '{\"query\":\"…\",\"repo_path\":\"/abs\"}'\n");
187
+ process.exit(2);
188
+ }
189
+ const config = configFor(args.repo_path);
190
+ try {
191
+ const graph = parseEntities(await source.fetchEntities(config));
192
+ const ranked = searchModulesRanked(graph, args.query || "");
193
+ process.stdout.write(ranked.map((r) => `${r.path}\t${r.score}`).join("\n") + "\n");
194
+ } catch (e) {
195
+ process.stderr.write(`seonix: ${e?.message || e}\n`);
196
+ process.exit(1);
197
+ }
198
+ return;
199
+ }
200
+
201
+ // tool-query fallback: any other `cli <toolName> '{…}'` routes to the MCP dispatcher,
202
+ // so "cold" tools are invokable from Bash without an MCP connection.
203
+ if (sub) {
204
+ const args = parsePayload(payload);
205
+ if (args === null) {
206
+ process.stderr.write(`seonix: ${sub} expects a JSON arg, e.g. '{"symbol":"<name>"}'\n`);
207
+ process.exit(2);
208
+ }
209
+ const config = configFor(args.repo_path);
210
+ try {
211
+ const text = await dispatchTool(sub, args, { config });
212
+ process.stdout.write(text + "\n");
213
+ } catch (e) {
214
+ process.stderr.write(`seonix: ${e?.message || e}\n`);
215
+ process.exit(1);
216
+ }
217
+ return;
218
+ }
219
+
220
+ process.stderr.write("seonix: `cli` needs a sub-command (index_repository | digest | <toolName>)\n");
221
+ process.exit(2);
222
+ }
223
+
224
+ if (mode === "hook-augment") {
225
+ await hookAugment();
226
+ return;
227
+ }
228
+
229
+ if (mode === "viz") {
230
+ const { runVizCli } = await import("../src/viz.mjs");
231
+ await runVizCli(process.argv.slice(3));
232
+ return;
233
+ }
234
+
235
+ if (mode === undefined) {
236
+ await startServer(); // bare invocation → MCP stdio server
237
+ return;
238
+ }
239
+
240
+ process.stderr.write(`seonix: unknown invocation "${process.argv.slice(2).join(" ")}". ` +
241
+ "Use bare (MCP server), `cli index_repository …`, `hook-augment`, or `viz`.\n");
242
+ process.exit(2);
243
+ }
244
+
245
+ main().catch((e) => {
246
+ process.stderr.write(`seonix: ${e?.message || e}\n`);
247
+ process.exit(1);
248
+ });
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "@polycode-projects/seonix",
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "type": "module",
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.",
7
+ "license": "AGPL-3.0-only",
8
+ "author": "Polycode Limited",
9
+ "homepage": "https://seonix.polycode.co.uk",
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://gitlab.com/polycode-projects/seonix.git",
13
+ "directory": "packages/seonix"
14
+ },
15
+ "bugs": {
16
+ "url": "https://gitlab.com/polycode-projects/seonix/-/issues"
17
+ },
18
+ "keywords": [
19
+ "code-graph",
20
+ "mcp",
21
+ "coding-agent",
22
+ "ast",
23
+ "claude-code",
24
+ "offline",
25
+ "deterministic",
26
+ "token-reduction"
27
+ ],
28
+ "engines": {
29
+ "node": ">=24"
30
+ },
31
+ "bin": {
32
+ "seonix": "./bin/cli.mjs"
33
+ },
34
+ "files": [
35
+ "bin/",
36
+ "src/",
37
+ "roslyn/*.cs",
38
+ "roslyn/*.csproj",
39
+ "README.md",
40
+ "LICENSE"
41
+ ],
42
+ "publishConfig": {
43
+ "access": "public"
44
+ },
45
+ "dependencies": {
46
+ "@modelcontextprotocol/sdk": "^1.29.0",
47
+ "cytoscape": "^3.30.0",
48
+ "ts-morph": "^24.0.0"
49
+ },
50
+ "scripts": {
51
+ "test": "node --test \"test/**/*.test.mjs\"",
52
+ "viz": "node bin/cli.mjs viz"
53
+ }
54
+ }
@@ -0,0 +1,150 @@
1
+ // Roslyn (syntax-level) C# extractor — bake-off candidate + picked C# path.
2
+ // Parses each .cs with CSharpSyntaxTree.ParseText (NO compilation / no nuget
3
+ // restore of the target repo) → deterministic, offline, zero-LLM. Emits the SAME
4
+ // {modules:[{path,dotted,imports,defines,calls,exports}]} contract the Python
5
+ // extract_ast.py prints, so seonix's extract.mjs/codegraph.mjs/digest consume it
6
+ // unchanged. Semantic-model promotion (resolved call targets/types, Group-B->A) is
7
+ // the documented ceiling, NOT done here (syntax-level keeps it project-agnostic).
8
+ using System.Text.Json;
9
+ using Microsoft.CodeAnalysis;
10
+ using Microsoft.CodeAnalysis.CSharp;
11
+ using Microsoft.CodeAnalysis.CSharp.Syntax;
12
+
13
+ var skipDirs = new HashSet<string>(StringComparer.OrdinalIgnoreCase) {
14
+ ".git",".seonix",".hg",".svn","node_modules","bin","obj","out","dist","build","packages","TestResults",".vs"
15
+ };
16
+
17
+ if (args.Length < 1) { Console.Error.WriteLine("usage: roslyn-extract <root>"); return 2; }
18
+ var root = Path.GetFullPath(args[0]);
19
+
20
+ var files = new List<string>();
21
+ void Walk(string dir) {
22
+ string[] entries;
23
+ try { entries = Directory.GetFileSystemEntries(dir); } catch { return; }
24
+ Array.Sort(entries, StringComparer.Ordinal);
25
+ foreach (var e in entries) {
26
+ var name = Path.GetFileName(e);
27
+ if (Directory.Exists(e)) {
28
+ if (skipDirs.Contains(name) || name.StartsWith(".")) continue;
29
+ Walk(e);
30
+ } else if (name.EndsWith(".cs", StringComparison.OrdinalIgnoreCase)
31
+ && !name.EndsWith(".g.cs", StringComparison.OrdinalIgnoreCase)
32
+ && !name.EndsWith(".Designer.cs", StringComparison.OrdinalIgnoreCase)
33
+ && !name.EndsWith(".AssemblyInfo.cs", StringComparison.OrdinalIgnoreCase)) {
34
+ files.Add(e);
35
+ }
36
+ }
37
+ }
38
+ Walk(root);
39
+
40
+ string Rel(string abs) => Path.GetRelativePath(root, abs).Replace('\\', '/');
41
+ int LineOf(SyntaxNode n) => n.GetLocation().GetLineSpan().StartLinePosition.Line + 1;
42
+ int EndLineOf(SyntaxNode n) => n.GetLocation().GetLineSpan().EndLinePosition.Line + 1;
43
+ string Clip(string s, int n) => s.Length <= n ? s : s.Substring(0, n);
44
+ string OneLine(string s) => System.Text.RegularExpressions.Regex.Replace(s ?? "", "\\s+", " ").Trim();
45
+
46
+ List<string> CallsIn(SyntaxNode body) {
47
+ var set = new SortedSet<string>(StringComparer.Ordinal);
48
+ if (body == null) return new List<string>();
49
+ foreach (var inv in body.DescendantNodes().OfType<InvocationExpressionSyntax>()) {
50
+ var expr = inv.Expression;
51
+ string name = expr switch {
52
+ MemberAccessExpressionSyntax ma => ma.ToString(),
53
+ IdentifierNameSyntax id => id.Identifier.Text,
54
+ _ => expr.ToString()
55
+ };
56
+ name = OneLine(name);
57
+ if (name.Length > 0) set.Add(Clip(name, 80));
58
+ }
59
+ return set.ToList();
60
+ }
61
+
62
+ string Vis(SyntaxTokenList mods) {
63
+ if (mods.Any(m => m.Text == "private")) return "private";
64
+ if (mods.Any(m => m.Text == "protected")) return "protected";
65
+ return "";
66
+ }
67
+ bool IsPublic(SyntaxTokenList mods) => mods.Any(m => m.Text == "public");
68
+
69
+ var modules = new List<object>();
70
+ var failures = new List<string>();
71
+
72
+ foreach (var file in files) {
73
+ string text;
74
+ try { text = File.ReadAllText(file); } catch { failures.Add(Rel(file)); continue; }
75
+ SyntaxTree tree;
76
+ try { tree = CSharpSyntaxTree.ParseText(text); }
77
+ catch { failures.Add(Rel(file)); continue; }
78
+ var rootNode = tree.GetRoot();
79
+ if (rootNode.ContainsDiagnostics) { /* Roslyn recovers; extract best-effort */ }
80
+
81
+ var imports = new SortedSet<string>(StringComparer.Ordinal);
82
+ foreach (var u in rootNode.DescendantNodes().OfType<UsingDirectiveSyntax>()) {
83
+ if (u.StaticKeyword.Text == "static") continue;
84
+ var nm = u.Name?.ToString();
85
+ if (!string.IsNullOrEmpty(nm)) imports.Add(nm);
86
+ }
87
+
88
+ string primaryNs = rootNode.DescendantNodes().OfType<BaseNamespaceDeclarationSyntax>()
89
+ .Select(n => n.Name.ToString()).FirstOrDefault() ?? "";
90
+
91
+ var defines = new List<object>();
92
+ var exports = new SortedSet<string>(StringComparer.Ordinal);
93
+
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>();
97
+ 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()
100
+ };
101
+ if (IsPublic(type.Modifiers)) exports.Add(cname);
102
+ var v = Vis(type.Modifiers); if (v != "") cdef["visibility"] = v;
103
+ defines.Add(cdef);
104
+
105
+ foreach (var mem in type.Members) {
106
+ if (mem is MethodDeclarationSyntax m) {
107
+ var md = new Dictionary<string, object> {
108
+ ["name"] = $"{cname}.{m.Identifier.Text}", ["kind"] = "method",
109
+ ["lineno"] = LineOf(m), ["end_lineno"] = EndLineOf(m), ["decorators"] = m.AttributeLists.SelectMany(a => a.Attributes).Select(a => OneLine(a.ToString())).ToList(),
110
+ ["params"] = Clip(OneLine(string.Join(", ", m.ParameterList.Parameters.Select(p => p.ToString()))), 160),
111
+ ["returns"] = Clip(OneLine(m.ReturnType.ToString()), 80),
112
+ ["calls"] = CallsIn(m.Body as SyntaxNode ?? m.ExpressionBody)
113
+ };
114
+ if (m.Modifiers.Any(x => x.Text == "static")) md["is_static"] = true;
115
+ var mv = Vis(m.Modifiers); if (mv != "") md["visibility"] = mv;
116
+ defines.Add(md);
117
+ } else if (mem is ConstructorDeclarationSyntax ctor) {
118
+ defines.Add(new Dictionary<string, object> {
119
+ ["name"] = $"{cname}.{ctor.Identifier.Text}", ["kind"] = "method",
120
+ ["lineno"] = LineOf(ctor), ["end_lineno"] = EndLineOf(ctor), ["decorators"] = new List<string>(),
121
+ ["params"] = Clip(OneLine(string.Join(", ", ctor.ParameterList.Parameters.Select(p => p.ToString()))), 160),
122
+ ["calls"] = CallsIn(ctor.Body as SyntaxNode ?? ctor.ExpressionBody)
123
+ });
124
+ } else if (mem is PropertyDeclarationSyntax prop) {
125
+ defines.Add(new Dictionary<string, object> {
126
+ ["name"] = $"{cname}.{prop.Identifier.Text}", ["kind"] = "attribute",
127
+ ["lineno"] = LineOf(prop), ["end_lineno"] = EndLineOf(prop), ["decorators"] = new List<string>()
128
+ });
129
+ } else if (mem is FieldDeclarationSyntax fld) {
130
+ foreach (var vd in fld.Declaration.Variables) {
131
+ defines.Add(new Dictionary<string, object> {
132
+ ["name"] = $"{cname}.{vd.Identifier.Text}", ["kind"] = "attribute",
133
+ ["lineno"] = LineOf(fld), ["end_lineno"] = EndLineOf(fld), ["decorators"] = new List<string>()
134
+ });
135
+ }
136
+ }
137
+ }
138
+ }
139
+
140
+ string dotted = primaryNs.Length > 0 ? primaryNs : Rel(file).Replace(".cs", "").Replace('/', '.');
141
+ modules.Add(new Dictionary<string, object> {
142
+ ["path"] = Rel(file), ["dotted"] = dotted, ["imports"] = imports.ToList(),
143
+ ["defines"] = defines, ["calls"] = CallsIn(rootNode), ["exports"] = exports.ToList()
144
+ });
145
+ }
146
+
147
+ var payload = new Dictionary<string, object> { ["modules"] = modules, ["failures"] = failures, ["fileCount"] = files.Count };
148
+ var opts = new JsonSerializerOptions { WriteIndented = false };
149
+ Console.Out.Write(JsonSerializer.Serialize(payload, opts));
150
+ return 0;
@@ -0,0 +1,13 @@
1
+ <Project Sdk="Microsoft.NET.Sdk">
2
+ <PropertyGroup>
3
+ <OutputType>Exe</OutputType>
4
+ <TargetFramework>net8.0</TargetFramework>
5
+ <ImplicitUsings>enable</ImplicitUsings>
6
+ <Nullable>disable</Nullable>
7
+ <AssemblyName>roslyn-extract</AssemblyName>
8
+ <InvariantGlobalization>true</InvariantGlobalization>
9
+ </PropertyGroup>
10
+ <ItemGroup>
11
+ <PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.9.2" />
12
+ </ItemGroup>
13
+ </Project>