@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/LICENSE +661 -0
- package/README.md +77 -0
- package/bin/cli.mjs +248 -0
- package/package.json +54 -0
- package/roslyn/Program.cs +150 -0
- package/roslyn/RoslynExtract.csproj +13 -0
- package/src/codegraph.mjs +1313 -0
- package/src/config.mjs +28 -0
- package/src/cs_roslyn.mjs +38 -0
- package/src/cs_treesitter.mjs +140 -0
- package/src/extract.mjs +624 -0
- package/src/extract_ast.py +366 -0
- package/src/extract_lang.mjs +61 -0
- package/src/jsts_tsc.mjs +211 -0
- package/src/server.mjs +389 -0
- package/src/source.mjs +35 -0
- package/src/viz.mjs +218 -0
- package/src/walk.mjs +39 -0
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>
|