reverseport 1.3.0 → 1.5.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.
@@ -17,7 +17,7 @@ const VERSION = JSON.parse(readFileSync(path.join(REPO, 'package.json'), 'utf8')
17
17
  const PORT_COMMANDS = new Set([
18
18
  'scan', 'graph', 'symbols', 'scene', 'binary', 'bin', 'archive', 'pck', 'probe', 'extract', 'carve', 'diff',
19
19
  'compare-builds', 'report', 'lookup', 'recon', 'web', 'blueprint', 'rebuild', 'map', 'convert', 'plan', 'unit',
20
- 'write', 'port-scene', 'copy-assets', 'status', 'verify', 'case', 'proof', 'tools', 'serve', 'describe', 'feedback', 'connect',
20
+ 'write', 'port-scene', 'copy-assets', 'status', 'verify', 'case', 'proof', 'tools', 'serve', 'describe', 'feedback', 'connect', 'skills',
21
21
  ]);
22
22
  const ALWAYS_ENGINE = ['setup', 'doctor', 'update', 'uninstall', 'mcp', 'capabilities', 'providers', 'help'];
23
23
 
@@ -1,7 +1,7 @@
1
1
  /** Generated from package.json and package-lock.json; do not edit. */
2
2
  export const PACKAGE_METADATA = {
3
3
  name: "reverseport",
4
- version: "1.3.0", // x-release-please-version
4
+ version: "1.5.0", // x-release-please-version
5
5
  serverSdkVersion: "2.3.1",
6
6
  clientSdkVersion: "2.3.1",
7
7
  coreSdkVersion: "2.3.1",
@@ -1,5 +1,5 @@
1
1
  {
2
- "packageVersion": "1.3.0",
2
+ "packageVersion": "1.5.0",
3
3
  "platform": "win32",
4
4
  "architecture": "x64",
5
5
  "abiVersion": 1,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "reverseport",
3
3
  "mcpName": "io.github.laaielichter/reverseport",
4
- "version": "1.3.0",
4
+ "version": "1.5.0",
5
5
  "description": "Reverse engineer anything. Port it anywhere. One CLI and MCP server that lets coding agents take apart apps, games, binaries and websites — decompile, trace, compare builds, recon without files — and rebuild or port them to Godot, Unity, Unreal, three.js or any language.",
6
6
  "license": "MIT",
7
7
  "repository": {
@@ -301,8 +301,8 @@
301
301
  },
302
302
  "lint-staged": {
303
303
  "*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}": [
304
- "oxfmt",
305
- "oxlint --fix"
304
+ "oxfmt --no-error-on-unmatched-pattern",
305
+ "oxlint --fix --no-error-on-unmatched-pattern"
306
306
  ],
307
307
  "*.{json,md,yml,yaml}": "oxfmt --no-error-on-unmatched-pattern"
308
308
  },
package/port/cli.mjs CHANGED
@@ -118,6 +118,8 @@ Proof (evidence ledger)
118
118
  Other
119
119
  tools [--all] [--format f] list the tools (--all adds the engine's when built); formats: ${FORMATS.join(', ')}
120
120
  describe <tool> one tool's description and input schema (engine tools too, when built)
121
+ skills [list] the skills bundled with this version
122
+ skills get <name> one skill's full guide, matching this version (--json for JSON)
121
123
  feedback notes agents left with share_feedback (stays on this computer)
122
124
  connect [client] config snippet for any agent: MCP clients, HTTP/OpenAPI, OpenAI, Anthropic, Gemini, shell
123
125
  serve HTTP API on http://127.0.0.1:32147 (REVERSEPORT_PORT)
@@ -153,6 +155,20 @@ export async function main(argv = process.argv.slice(2)) {
153
155
  print({ name: t.name, title: t.title, description: t.description, inputSchema: t.inputSchema, annotations: t.annotations, cli: `reverseport ${t.name} ${(POSITIONAL[t.name] ?? []).map(p => `<${p}>`).join(' ')} [--key value] [--args '{…}']`.replace(/\s+/g, ' ').trim() });
154
156
  return 0;
155
157
  }
158
+ if (cmd === 'skills') {
159
+ const { listSkills, getSkill } = await import('./src/skills.mjs');
160
+ const [sub, name] = rest.filter(x => !x.startsWith('--'));
161
+ try {
162
+ if (!sub || sub === 'list') { print({ version: VERSION, skills: await listSkills() }); return 0; }
163
+ if (sub === 'get') {
164
+ const s = await getSkill(name);
165
+ if (argv.includes('--json')) print({ version: VERSION, name: s.name, description: s.description, body: s.body });
166
+ else process.stdout.write(`<!-- ReversePort ${VERSION} · skill ${s.name} -->\n${s.text.endsWith('\n') ? s.text : `${s.text}\n`}`);
167
+ return 0;
168
+ }
169
+ return printError(new RPError('INVALID_ARGUMENT', `Unknown skills command "${sub}". Use: reverseport skills list | reverseport skills get <name>`));
170
+ } catch (e) { return printError(e); }
171
+ }
156
172
  if (cmd === 'feedback') { print(await readFeedback()); return 0; }
157
173
  if (cmd === 'connect') {
158
174
  const { connect } = await import('./src/connect.mjs');
@@ -0,0 +1,135 @@
1
+ // Output budget for agents: a tool result that would flood the agent's context is saved whole to a file in the
2
+ // workspace, and the agent gets a shortened result plus where the full one is. Same idea as coding agents that save
3
+ // long tool output to disk ("full output saved to …"). Files older than a week are removed on the next save.
4
+ import fs from 'node:fs/promises';
5
+ import path from 'node:path';
6
+ import crypto from 'node:crypto';
7
+ import { workspaceDir } from './workspace.mjs';
8
+
9
+ export const DEFAULT_MAX_OUTPUT_BYTES = 256 * 1024;
10
+ const KEEP_DAYS = 7;
11
+ const LONG_STRING = 16 * 1024;
12
+
13
+ /** Byte budget from REVERSEPORT_MAX_OUTPUT_BYTES: a number (at least 4096), or "off"/"0" for no budget. */
14
+ export function outputLimit(env = process.env) {
15
+ const raw = String(env.REVERSEPORT_MAX_OUTPUT_BYTES ?? '').trim().toLowerCase();
16
+ if (raw === 'off' || raw === '0') return Infinity;
17
+ const n = Number(raw);
18
+ return raw && Number.isFinite(n) ? Math.max(4096, Math.floor(n)) : DEFAULT_MAX_OUTPUT_BYTES;
19
+ }
20
+
21
+ const size = v => Buffer.byteLength(JSON.stringify(v) ?? '', 'utf8');
22
+
23
+ /** Every array and long string in a value, with its path and serialized size. */
24
+ function parts(value, at = '', out = []) {
25
+ if (Array.isArray(value)) {
26
+ out.push({ at, kind: 'array', ref: value, bytes: size(value) });
27
+ value.forEach((v, i) => parts(v, `${at}[${i}]`, out));
28
+ } else if (value && typeof value === 'object') {
29
+ for (const [k, v] of Object.entries(value)) parts(v, at ? `${at}.${k}` : k, out);
30
+ } else if (typeof value === 'string' && value.length > LONG_STRING) out.push({ at, kind: 'string', bytes: value.length });
31
+ return out;
32
+ }
33
+
34
+ const isUnder = (at, parent) => at.length > parent.length && at.startsWith(parent) && (parent === '' || at[parent.length] === '.' || at[parent.length] === '[');
35
+
36
+ function setAt(root, at, fn) {
37
+ const keys = [...at.matchAll(/\[(\d+)\]|([^.[\]]+)/g)].map(m => (m[1] !== undefined ? Number(m[1]) : m[2]));
38
+ if (!keys.length) return fn(root);
39
+ let o = root;
40
+ for (const k of keys.slice(0, -1)) o = o[k];
41
+ o[keys.at(-1)] = fn(o[keys.at(-1)]);
42
+ return root;
43
+ }
44
+
45
+ /**
46
+ * Shorten a JSON value until it fits `max` bytes: halve the biggest array (keeping its head) or cut the longest
47
+ * string, again and again. Returns { value, shortened: [{ path, kept, of }] }.
48
+ */
49
+ export function shrink(value, max) {
50
+ let v = structuredClone(value);
51
+ const originals = new Map();
52
+ for (let round = 0; round < 200 && size(v) > max; round++) {
53
+ const cands = parts(v).filter(p => (p.kind === 'array' ? p.ref.length > 1 : true)).sort((a, b) => b.bytes - a.bytes);
54
+ // Start at the biggest part; when most of it sits in one child (a list inside a list item, a long string),
55
+ // shorten that child instead, so one huge entry does not empty the whole list.
56
+ let pick = cands[0];
57
+ if (!pick) break;
58
+ for (;;) {
59
+ const child = cands.find(p => p !== pick && isUnder(p.at, pick.at) && p.bytes >= pick.bytes * 0.6);
60
+ if (!child) break;
61
+ pick = child;
62
+ }
63
+ if (pick.kind === 'array') {
64
+ if (!originals.has(pick.at)) originals.set(pick.at, pick.ref.length);
65
+ v = setAt(v, pick.at, a => a.slice(0, Math.max(1, Math.floor(a.length / 2))));
66
+ } else {
67
+ if (!originals.has(pick.at)) originals.set(pick.at, pick.bytes);
68
+ v = setAt(v, pick.at, s => `${s.slice(0, Math.max(1024, Math.floor(s.length / 2)))}… [cut]`);
69
+ }
70
+ }
71
+ const shortened = [];
72
+ for (const [at, of] of originals) {
73
+ let now; try { now = at ? setAtRead(v, at) : v; } catch { continue; }
74
+ if (Array.isArray(now)) shortened.push({ path: at, kept: now.length, of });
75
+ else if (typeof now === 'string') shortened.push({ path: at, keptChars: now.length, ofChars: of });
76
+ }
77
+ return { value: v, shortened };
78
+ }
79
+
80
+ function setAtRead(root, at) {
81
+ const keys = [...at.matchAll(/\[(\d+)\]|([^.[\]]+)/g)].map(m => (m[1] !== undefined ? Number(m[1]) : m[2]));
82
+ let o = root;
83
+ for (const k of keys) o = o[k];
84
+ return o;
85
+ }
86
+
87
+ async function saveFull(tool, text, ext) {
88
+ const dir = await workspaceDir('tool-output');
89
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
90
+ const file = path.join(dir, `${String(tool).replace(/[^\w-]+/g, '_')}-${stamp}-${crypto.randomBytes(3).toString('hex')}.${ext}`);
91
+ await fs.writeFile(file, text);
92
+ cleanOld(dir).catch(() => {});
93
+ return file;
94
+ }
95
+
96
+ async function cleanOld(dir, now = Date.now()) {
97
+ for (const name of await fs.readdir(dir)) {
98
+ const f = path.join(dir, name);
99
+ const st = await fs.stat(f).catch(() => null);
100
+ if (st?.isFile() && now - st.mtimeMs > KEEP_DAYS * 86400000) await fs.rm(f, { force: true });
101
+ }
102
+ }
103
+
104
+ const NEXT = 'The result was too big for one answer, so lists were shortened (see shortened). The full result is saved in fullOutput: read the part you need from that file, or call again with a narrower request (match, limit, a subfolder or byte range).';
105
+
106
+ /** Budget for one of ReversePort's own results (a JSON value). */
107
+ export async function budgetValue(tool, value, max = outputLimit()) {
108
+ if (value === null || typeof value !== 'object' || !Number.isFinite(max)) return value;
109
+ const full = JSON.stringify(value);
110
+ const bytes = Buffer.byteLength(full, 'utf8');
111
+ if (bytes <= max) return value;
112
+ const fullOutput = await saveFull(tool, full, 'json');
113
+ const room = Math.max(2048, Math.floor(max * 0.9) - 2048);
114
+ const { value: small, shortened } = shrink(value, room);
115
+ const note = { fullOutput, fullBytes: bytes, limitBytes: max, shortened: shortened.slice(0, 20), next: NEXT };
116
+ return Array.isArray(small) ? { result: small, outputTruncated: note } : { ...small, outputTruncated: note };
117
+ }
118
+
119
+ /** Budget for a raw MCP tool result from the deep-analysis engine ({ content, structuredContent?, isError? }). */
120
+ export async function budgetMcpResult(tool, result, max = outputLimit()) {
121
+ if (!result || typeof result !== 'object' || !Number.isFinite(max) || result.isError) return result;
122
+ const texts = (result.content ?? []).filter(c => c?.type === 'text').map(c => c.text ?? '');
123
+ const bytes = Math.max(texts.reduce((n, t) => n + Buffer.byteLength(t, 'utf8'), 0), result.structuredContent ? size(result.structuredContent) : 0);
124
+ if (bytes <= max) return result;
125
+ if (result.structuredContent && typeof result.structuredContent === 'object') {
126
+ const small = await budgetValue(tool, result.structuredContent, max);
127
+ return { ...result, content: [...(result.content ?? []).filter(c => c?.type !== 'text'), { type: 'text', text: JSON.stringify(small) }], structuredContent: small };
128
+ }
129
+ const full = texts.join('\n');
130
+ const fullOutput = await saveFull(tool, full, 'txt');
131
+ const head = Buffer.from(full, 'utf8').subarray(0, Math.max(1024, max - 1024)).toString('utf8').replace(/�+$/, '');
132
+ const lines = full.split('\n').length, shown = head.split('\n').length;
133
+ const marker = `\n\n[showing lines 1-${shown} of ${lines} (${head.length} of ${full.length} characters); full output saved to ${fullOutput}. Read the rest from that file, or call again with a narrower request.]`;
134
+ return { ...result, content: [...(result.content ?? []).filter(c => c?.type !== 'text'), { type: 'text', text: head + marker }] };
135
+ }
@@ -7,6 +7,7 @@ import { requireDir, walk, isText } from './util.mjs';
7
7
  import { languageOf, fileSymbols, stripComments, identifiers, CODE_LANGUAGES } from './symbols.mjs';
8
8
  import { unityGuidMap } from './scene.mjs';
9
9
  import { detectEngines } from './scan.mjs';
10
+ import { workspacePackages, packageCandidates, splitSpecifier, tsconfigAliases } from './jsworkspace.mjs';
10
11
 
11
12
  const JS_EXT = ['.ts', '.tsx', '.mts', '.js', '.mjs', '.cjs', '.jsx', '.vue', '.svelte', '.json'];
12
13
  const TYPE_INDEX_LANGS = new Set(['csharp', 'java', 'kotlin', 'gdscript', 'swift', 'haxe', 'dart', 'scala']);
@@ -61,13 +62,27 @@ export async function buildGraph(dir, { maxFiles = 30000 } = {}) {
61
62
  if (spec.startsWith('res://')) { const r = godotRootOf(from); return exists(path.posix.normalize((r ? `${r}/` : '') + spec.slice(6))); }
62
63
  return exists(path.posix.normalize(path.posix.join(path.posix.dirname(from), spec)));
63
64
  };
64
- const resolveJs = (from, spec) => {
65
- if (!/^\.{1,2}\//.test(spec) && !spec.startsWith('/')) return null;
66
- const base = spec.startsWith('/') ? spec.slice(1) : path.posix.normalize(path.posix.join(path.posix.dirname(from), spec));
65
+ // Monorepos: workspace packages by name and tsconfig "paths" aliases resolve to files in this folder.
66
+ const hasJs = files.some(f => /\.(m?[jt]sx?|cjs|cts)$/.test(f.rel));
67
+ const wsPackages = hasJs ? await workspacePackages(files) : new Map();
68
+ const aliasOf = hasJs ? await tsconfigAliases(files, byRel) : () => [];
69
+ const jsFile = (base, from) => {
67
70
  const stem = base.replace(/\.(m?js|cjs|jsx)$/, '');
71
+ for (const c of [base, ...JS_EXT.map(e => stem + e), ...JS_EXT.map(e => `${base}/index${e}`)]) { const hit = exists(c); if (hit && hit !== from && nodes.has(hit)) return hit; }
68
72
  for (const c of [base, ...JS_EXT.map(e => stem + e), ...JS_EXT.map(e => `${base}/index${e}`)]) { const hit = exists(c); if (hit && hit !== from) return hit; }
69
73
  return null;
70
74
  };
75
+ let workspaceEdges = 0, aliasEdges = 0;
76
+ const resolveJs = (from, spec) => {
77
+ if (/^\.{1,2}\//.test(spec) || spec.startsWith('/')) {
78
+ return jsFile(spec.startsWith('/') ? spec.slice(1) : path.posix.normalize(path.posix.join(path.posix.dirname(from), spec)), from);
79
+ }
80
+ for (const c of aliasOf(from, spec)) { const hit = jsFile(c, from); if (hit) { aliasEdges++; return hit; } }
81
+ const { name, sub } = splitSpecifier(spec);
82
+ const ws = wsPackages.get(name);
83
+ if (ws) for (const c of packageCandidates(ws, sub)) { const hit = jsFile(c, from); if (hit) { workspaceEdges++; return hit; } }
84
+ return null;
85
+ };
71
86
  const resolvePy = (from, spec) => {
72
87
  const dots = spec.match(/^\.*/)[0].length;
73
88
  const mod = spec.slice(dots).replace(/\./g, '/');
@@ -88,10 +103,27 @@ export async function buildGraph(dir, { maxFiles = 30000 } = {}) {
88
103
  for (const b of ['', path.posix.dirname(from)]) for (const c of [`${p}.lua`, `${p}/init.lua`]) { const hit = exists(path.posix.normalize(b ? `${b}/${c}` : c)); if (hit) return hit; }
89
104
  return null;
90
105
  };
91
- const resolveRustMod = (from, name) => {
92
- const dir = path.posix.dirname(from), stem = path.posix.basename(from, '.rs');
93
- const base = ['mod', 'main', 'lib'].includes(stem) ? dir : path.posix.join(dir, stem);
94
- return exists(path.posix.normalize(`${base}/${name}.rs`)) ?? exists(path.posix.normalize(`${base}/${name}/mod.rs`));
106
+ // Rust: a file's child modules live in `<stem>/` (or next to mod.rs/lib.rs/main.rs); `crate::` starts at the
107
+ // crate's src/, and a workspace crate is reachable by its package name (`-` reads as `_`).
108
+ const rustModuleDir = from => { const dir = path.posix.dirname(from), stem = path.posix.basename(from, '.rs'); return ['mod', 'main', 'lib'].includes(stem) ? dir : path.posix.join(dir, stem); };
109
+ const rustModuleFile = dir => exists(`${dir}.rs`) ?? exists(`${dir}/mod.rs`) ?? exists(`${dir}/lib.rs`) ?? exists(`${dir}/main.rs`);
110
+ const resolveRustMod = (from, name) => { const base = rustModuleDir(from); return exists(path.posix.normalize(`${base}/${name}.rs`)) ?? exists(path.posix.normalize(`${base}/${name}/mod.rs`)); };
111
+ const rustCrates = new Map();
112
+ for (const f of files) if (/(^|\/)Cargo\.toml$/.test(f.rel)) {
113
+ const name = (await fs.readFile(f.abs, 'utf8').catch(() => '')).match(/^\[package\][^[]*?^\s*name\s*=\s*"([^"]+)"/ms)?.[1];
114
+ if (name) rustCrates.set(name.replace(/-/g, '_'), path.posix.join(path.posix.dirname(f.rel), 'src').replace(/^\.\//, ''));
115
+ }
116
+ const rustCrateSrc = from => { let dir = path.posix.dirname(from); while (dir && dir !== '.') { if (path.posix.basename(dir) === 'src' && exists(path.posix.join(path.posix.dirname(dir), 'Cargo.toml'))) return dir; dir = path.posix.dirname(dir); } return null; };
117
+ const resolveRustUse = (from, spec) => {
118
+ const segs = spec.split('::').filter(Boolean);
119
+ let base;
120
+ if (segs[0] === 'crate') { base = rustCrateSrc(from); segs.shift(); }
121
+ else if (segs[0] === 'self') { base = rustModuleDir(from); segs.shift(); }
122
+ else if (segs[0] === 'super') { base = rustModuleDir(from); while (segs[0] === 'super') { segs.shift(); base = path.posix.dirname(base); } }
123
+ else if (rustCrates.has(segs[0])) base = rustCrates.get(segs.shift());
124
+ if (!base || base === '.') return null;
125
+ for (let i = segs.length; i >= 1; i--) { const p = `${base}/${segs.slice(0, i).join('/')}`; const hit = exists(`${p}.rs`) ?? exists(`${p}/mod.rs`); if (hit) return hit; }
126
+ return rustModuleFile(base);
95
127
  };
96
128
 
97
129
  for (const n of nodes.values()) {
@@ -115,11 +147,11 @@ export async function buildGraph(dir, { maxFiles = 30000 } = {}) {
115
147
  let hit = null;
116
148
  switch (n.language) {
117
149
  case 'gdscript': case 'shader': hit = resolveRes(n.file, d.spec); break;
118
- case 'javascript': case 'typescript': hit = resolveJs(n.file, d.spec); if (!hit && !/^[./]/.test(d.spec)) external(d.spec.startsWith('@') ? d.spec.split('/').slice(0, 2).join('/') : d.spec.split('/')[0]); break;
150
+ case 'javascript': case 'typescript': hit = resolveJs(n.file, d.spec); if (!hit && !/^[./]/.test(d.spec) && !wsPackages.has(splitSpecifier(d.spec).name)) external(d.spec.startsWith('@') ? d.spec.split('/').slice(0, 2).join('/') : d.spec.split('/')[0]); break;
119
151
  case 'python': hit = resolvePy(n.file, d.spec); if (!hit && !d.spec.startsWith('.')) external(d.spec.split('.')[0]); break;
120
152
  case 'c': case 'c-header': case 'cpp': case 'objc': if (d.kind === 'system') external(`<${d.spec}>`); else { hit = resolveInclude(n.file, d.spec); if (!hit) external(`"${d.spec}"`); } break;
121
153
  case 'lua': hit = resolveLua(n.file, d.spec); if (!hit) external(d.spec); break;
122
- case 'rust': if (d.kind === 'mod') hit = resolveRustMod(n.file, d.spec); else if (!/^(crate|self|super)::/.test(d.spec)) external(d.spec.split('::')[0]); break;
154
+ case 'rust': if (d.kind === 'mod') hit = resolveRustMod(n.file, d.spec); else { hit = resolveRustUse(n.file, d.spec); if (!hit && !/^(crate|self|super)(::|$)/.test(d.spec)) external(d.spec.split('::')[0]); } break;
123
155
  case 'go':
124
156
  if (goModule && d.spec.startsWith(`${goModule}/`)) {
125
157
  const pkgDir = d.spec.slice(goModule.length + 1);
@@ -166,7 +198,25 @@ export async function buildGraph(dir, { maxFiles = 30000 } = {}) {
166
198
  const { sccs, order } = tarjanOrder([...nodes.keys()], out);
167
199
  const cycles = sccs.filter(c => c.length > 1).sort((a, b) => b.length - a.length);
168
200
  const orphans = [...nodes.keys()].filter(k => !inc.get(k) && !entry.has(k) && nodes.get(k).kind !== 'scene' && !(out.get(k).length));
169
- return { root, truncated, unreadable, engines, nodes, edges: edges.filter(e => nodes.has(e.to)), externals, cycles, order, entryPoints: [...entry], orphans, inDegree: inc, outEdges: out };
201
+ let workspace = null;
202
+ if (wsPackages.size > 1 || aliasEdges) {
203
+ // Package-level view: which workspace package uses which (counts of file imports across packages).
204
+ const dirs = [...wsPackages.entries()].map(([name, w]) => ({ name, dir: w.dir })).sort((a, b) => b.dir.length - a.dir.length);
205
+ const pkgOf = rel => dirs.find(d => !d.dir || rel === d.dir || rel.startsWith(`${d.dir}/`))?.name ?? null;
206
+ const files = new Map(), links = new Map();
207
+ for (const k of nodes.keys()) { const p = pkgOf(k); if (p) files.set(p, (files.get(p) ?? 0) + 1); }
208
+ for (const e of edges) {
209
+ const a = pkgOf(e.from), b = pkgOf(e.to);
210
+ if (!a || !b || a === b) continue;
211
+ const key = `${a}\0${b}`; links.set(key, (links.get(key) ?? 0) + 1);
212
+ }
213
+ workspace = {
214
+ packages: wsPackages.size, workspaceEdges, aliasEdges,
215
+ packageList: dirs.map(d => ({ name: d.name, dir: d.dir || '.', files: files.get(d.name) ?? 0 })).sort((a, b) => b.files - a.files),
216
+ packageEdges: [...links.entries()].map(([k, uses]) => { const [from, to] = k.split('\0'); return { from, to, uses }; }).sort((a, b) => b.uses - a.uses),
217
+ };
218
+ }
219
+ return { root, truncated, unreadable, engines, workspace, nodes, edges: edges.filter(e => nodes.has(e.to)), externals, cycles, order, entryPoints: [...entry], orphans, inDegree: inc, outEdges: out };
170
220
  }
171
221
 
172
222
  function commonPrefix(a, b) { let i = 0; while (i < a.length && i < b.length && a[i] === b[i]) i++; return i; }
@@ -206,9 +256,14 @@ export function tarjanOrder(keys, out) {
206
256
  return { sccs, order: sccs.flat() };
207
257
  }
208
258
 
259
+ /** Test files: under a test folder, or named *.test.*, *.spec.*, *_test.*, test_*. */
260
+ export const isTestFile = rel => /(^|\/)(tests?|__tests__|specs?|testing)\//.test(rel) || /(\.(test|spec)\.\w+|_tests?\.\w+)$/.test(rel) || /(^|\/)test_[^/]+$/.test(rel);
261
+
209
262
  export function graphMermaid(graph, max = 60) {
210
263
  const score = k => (graph.inDegree.get(k) ?? 0) + (graph.outEdges.get(k)?.length ?? 0);
211
- const keep = [...graph.nodes.keys()].sort((a, b) => score(b) - score(a)).slice(0, max);
264
+ // Tests depend on many files, so by degree alone they would crowd out the modules a reader wants to see.
265
+ const ranked = [...graph.nodes.keys()].sort((a, b) => score(b) - score(a));
266
+ const keep = [...ranked.filter(k => !isTestFile(k)), ...ranked.filter(isTestFile)].slice(0, max);
212
267
  const id = new Map(keep.map((k, i) => [k, `f${i}`]));
213
268
  const lines = ['graph LR'];
214
269
  for (const k of keep) lines.push(` ${id.get(k)}["${path.posix.basename(k).replace(/"/g, "'")}"]`);
@@ -232,6 +287,7 @@ export async function graphTool({ path: dir, limit, format = 'json' } = {}) {
232
287
  return {
233
288
  root: g.root, truncated: g.truncated, ...(g.unreadable?.length ? { unreadable: g.unreadable, warnings: [`${g.unreadable.length} folder(s) could not be read; the graph does not cover them.`] } : {}), fileCount: g.nodes.size, edgeCount: g.edges.length,
234
289
  entryPoints: g.entryPoints, cycles: g.cycles.slice(0, 20).map(c => c.slice(0, 30)), orphans: g.orphans.slice(0, 100),
290
+ ...(g.workspace ? { workspace: { packages: g.workspace.packages, resolvedByPackageName: g.workspace.workspaceEdges, resolvedByTsconfigPaths: g.workspace.aliasEdges, packageList: g.workspace.packageList.slice(0, max), packageEdges: g.workspace.packageEdges.slice(0, edgeMax) } } : {}),
235
291
  mostUsed: files.slice(0, 25), externals: ext,
236
292
  portOrder: g.order.slice(0, max), portOrderTruncated: g.order.length > max,
237
293
  edges: g.edges.slice(0, edgeMax), edgesTruncated: g.edges.length > edgeMax,
@@ -0,0 +1,155 @@
1
+ // JavaScript/TypeScript monorepos: workspace packages (npm/yarn/pnpm/bun workspaces, turbo, nx, lerna)
2
+ // and tsconfig "paths" aliases. Lets the graph follow `@scope/pkg/sub` and `@/foo` imports to real files
3
+ // instead of counting them as outside libraries.
4
+ import fs from 'node:fs/promises';
5
+ import path from 'node:path';
6
+
7
+ /** JSON with comments and trailing commas (tsconfig, turbo.json, jsconfig). Returns null when damaged. */
8
+ export function parseJsonc(text) {
9
+ if (typeof text !== 'string') return null;
10
+ let out = '', i = 0, inStr = false;
11
+ while (i < text.length) {
12
+ const c = text[i], n = text[i + 1];
13
+ if (inStr) { out += c; if (c === '\\') { out += n ?? ''; i += 2; continue; } if (c === '"') inStr = false; i++; continue; }
14
+ if (c === '"') { inStr = true; out += c; i++; continue; }
15
+ if (c === '/' && n === '/') { while (i < text.length && text[i] !== '\n') i++; continue; }
16
+ if (c === '/' && n === '*') { i += 2; while (i < text.length && !(text[i] === '*' && text[i + 1] === '/')) i++; i += 2; continue; }
17
+ out += c; i++;
18
+ }
19
+ out = out.replace(/,(\s*[}\]])/g, '$1');
20
+ try { return JSON.parse(out); } catch { return null; }
21
+ }
22
+
23
+ const readMaybe = async abs => { try { return await fs.readFile(abs, 'utf8'); } catch { return null; } };
24
+
25
+ /** Package manager and monorepo tool from lockfiles and config at the root. */
26
+ export function workspaceTooling(rels, rootPkg = {}) {
27
+ const has = f => rels.includes(f);
28
+ const manager = rootPkg.packageManager?.split('@')[0]
29
+ ?? (has('bun.lock') || has('bun.lockb') ? 'bun' : has('pnpm-lock.yaml') ? 'pnpm' : has('yarn.lock') ? 'yarn' : has('package-lock.json') ? 'npm' : null);
30
+ const tools = [['turbo.json', 'Turborepo'], ['nx.json', 'Nx'], ['lerna.json', 'Lerna'], ['rush.json', 'Rush'], ['pnpm-workspace.yaml', 'pnpm workspaces']]
31
+ .filter(([f]) => has(f)).map(([, n]) => n);
32
+ return { manager, tools };
33
+ }
34
+
35
+ /**
36
+ * Workspace packages by name. `files` are walk() entries ({rel, abs}). Every package.json outside
37
+ * node_modules counts; that also covers repos that do not declare workspaces but import siblings by name.
38
+ */
39
+ export async function workspacePackages(files) {
40
+ const packages = new Map();
41
+ for (const f of files) {
42
+ if (!/(^|\/)package\.json$/.test(f.rel) || f.rel.includes('node_modules/')) continue;
43
+ const pkg = parseJsonc(await readMaybe(f.abs));
44
+ if (!pkg?.name || typeof pkg.name !== 'string') continue;
45
+ const dir = path.posix.dirname(f.rel) === '.' ? '' : path.posix.dirname(f.rel);
46
+ if (!packages.has(pkg.name) || dir.length < packages.get(pkg.name).dir.length) packages.set(pkg.name, { dir, pkg });
47
+ }
48
+ return packages;
49
+ }
50
+
51
+ /** Split `@scope/name/sub/path` or `name/sub` into package name and subpath ('' when none). */
52
+ export function splitSpecifier(spec) {
53
+ const parts = spec.split('/');
54
+ const n = spec.startsWith('@') ? 2 : 1;
55
+ return { name: parts.slice(0, n).join('/'), sub: parts.slice(n).join('/') };
56
+ }
57
+
58
+ // Pick a file-looking target out of an exports entry (string, conditions object, or array).
59
+ function exportTarget(v) {
60
+ if (typeof v === 'string') return v;
61
+ if (Array.isArray(v)) { for (const x of v) { const t = exportTarget(x); if (t) return t; } return null; }
62
+ if (v && typeof v === 'object') {
63
+ for (const k of ['source', 'bun', 'development', 'import', 'types', 'module', 'node', 'require', 'browser', 'default']) if (k in v) { const t = exportTarget(v[k]); if (t) return t; }
64
+ for (const x of Object.values(v)) { const t = exportTarget(x); if (t) return t; }
65
+ }
66
+ return null;
67
+ }
68
+
69
+ /** Candidate relative paths inside the package for a subpath, most likely first. */
70
+ export function packageCandidates({ dir, pkg }, sub) {
71
+ const base = dir ? `${dir}/` : '';
72
+ const out = [];
73
+ const key = sub ? `./${sub}` : '.';
74
+ const ex = pkg.exports;
75
+ if (ex && typeof ex === 'object' && !Array.isArray(ex) && Object.keys(ex).some(k => k.startsWith('.'))) {
76
+ if (key in ex) { const t = exportTarget(ex[key]); if (t) out.push(t); }
77
+ for (const [k, v] of Object.entries(ex)) {
78
+ const star = k.indexOf('*');
79
+ if (star < 0 || !sub) continue;
80
+ const pre = k.slice(0, star), post = k.slice(star + 1);
81
+ if (key.startsWith(pre) && key.endsWith(post) && key.length >= pre.length + post.length) {
82
+ const t = exportTarget(v);
83
+ if (t) out.push(t.replace('*', key.slice(pre.length, key.length - post.length)));
84
+ }
85
+ }
86
+ } else if (!sub && (typeof ex === 'string' || Array.isArray(ex))) { const t = exportTarget(ex); if (t) out.push(t); }
87
+ if (!sub) for (const k of ['source', 'module', 'main', 'types', 'typings']) if (typeof pkg[k] === 'string') out.push(pkg[k]);
88
+ if (sub) out.push(sub, `src/${sub}`);
89
+ else out.push('src/index', 'index', 'src/main', 'mod');
90
+ // Built output often points at dist/; the source usually sits at the same place under src/.
91
+ const extra = [];
92
+ for (const c of out) { const m = c.replace(/^\.\//, '').match(/^(dist|build|lib|out)\/(.*)$/); if (m) extra.push(`src/${m[2]}`); }
93
+ return [...out, ...extra].map(c => path.posix.normalize(base + c.replace(/^\.\//, '')));
94
+ }
95
+
96
+ /** tsconfig/jsconfig `paths` per folder, following `extends` to local files (a few levels). */
97
+ export async function tsconfigAliases(files, byRel) {
98
+ const configs = new Map();
99
+ const load = async (rel, depth = 0) => {
100
+ if (configs.has(rel)) return configs.get(rel);
101
+ const f = byRel.get(rel);
102
+ const json = f ? parseJsonc(await readMaybe(f.abs)) : null;
103
+ let result = null;
104
+ if (json) {
105
+ const dir = path.posix.dirname(rel) === '.' ? '' : path.posix.dirname(rel);
106
+ let inherited = null;
107
+ for (const ext of [json.extends].flat().filter(e => typeof e === 'string' && e.startsWith('.'))) {
108
+ if (depth > 4) break;
109
+ let target = path.posix.normalize(path.posix.join(dir || '.', ext));
110
+ if (!target.endsWith('.json')) target += '.json';
111
+ const parent = await load(target, depth + 1);
112
+ if (parent) inherited = parent;
113
+ }
114
+ const co = json.compilerOptions ?? {};
115
+ if (co.paths && typeof co.paths === 'object') {
116
+ // Paths resolve against baseUrl when set, else against the tsconfig that declares them.
117
+ const baseDir = typeof co.baseUrl === 'string' ? path.posix.normalize(path.posix.join(dir || '.', co.baseUrl)) : (dir || '.');
118
+ result = { baseDir: baseDir === '.' ? '' : baseDir, paths: co.paths };
119
+ } else if (inherited) result = inherited;
120
+ }
121
+ configs.set(rel, result);
122
+ return result;
123
+ };
124
+ for (const f of files) if (/(^|\/)(tsconfig[\w.-]*|jsconfig)\.json$/.test(f.rel) && !f.rel.includes('node_modules/')) await load(f.rel);
125
+ const byDir = new Map();
126
+ for (const [rel, cfg] of configs) {
127
+ if (!cfg || !/(^|\/)(tsconfig|jsconfig)\.json$/.test(rel)) continue;
128
+ byDir.set(path.posix.dirname(rel) === '.' ? '' : path.posix.dirname(rel), cfg);
129
+ }
130
+ /** Candidate relative paths for `spec` imported from `from`; [] when no alias matches. */
131
+ return (from, spec) => {
132
+ let dir = path.posix.dirname(from);
133
+ for (;;) {
134
+ const key = dir === '.' ? '' : dir;
135
+ const cfg = byDir.get(key);
136
+ if (cfg) {
137
+ const out = [];
138
+ for (const [pattern, targets] of Object.entries(cfg.paths)) {
139
+ const star = pattern.indexOf('*');
140
+ let wild = null;
141
+ if (star < 0) { if (spec !== pattern) continue; wild = ''; }
142
+ else {
143
+ const pre = pattern.slice(0, star), post = pattern.slice(star + 1);
144
+ if (!spec.startsWith(pre) || !spec.endsWith(post) || spec.length < pre.length + post.length) continue;
145
+ wild = spec.slice(pre.length, spec.length - post.length);
146
+ }
147
+ for (const t of [targets].flat()) if (typeof t === 'string') out.push(path.posix.normalize(path.posix.join(cfg.baseDir || '.', t.replace('*', wild))));
148
+ }
149
+ return out;
150
+ }
151
+ if (key === '') return [];
152
+ dir = path.posix.dirname(dir);
153
+ }
154
+ };
155
+ }
@@ -15,6 +15,7 @@ import { fileURLToPath } from 'node:url';
15
15
  import { tools as localToolDefs, callTool, coerceArgs, VERSION } from './tools.mjs';
16
16
  import { errorPayload, closest, portableSchema } from './agent.mjs';
17
17
  import { RPError } from './util.mjs';
18
+ import { budgetValue, budgetMcpResult } from './budget.mjs';
18
19
 
19
20
  const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
20
21
  export const SUPPORTED_PROTOCOLS = ['2025-11-25', '2025-06-18', '2025-03-26', '2024-11-05'];
@@ -366,12 +367,12 @@ export function createRelay(hooks = {}, options = {}) {
366
367
  const input = coerceArgs(schema, dropDisallowedNulls(schema, args ?? {}));
367
368
  const res = await engine.forward('tools/call', { name, arguments: input, ...(meta ? { _meta: meta } : {}) }, clientKey);
368
369
  if (!res.error && !res.result?.structuredContent?.error?.code?.startsWith?.('ENGINE_')) state.restarts = 0; // a working call resets the restart budget
369
- return res.error ? toolError(Object.assign(new Error(res.error.message), { code: 'TOOL_FAILED', details: { rpc: res.error } })) : res.result;
370
+ return res.error ? toolError(Object.assign(new Error(res.error.message), { code: 'TOOL_FAILED', details: { rpc: res.error } })) : budgetMcpResult(name, res.result);
370
371
  }
371
372
 
372
373
  async function runLocal(name, args) {
373
374
  const def = localToolDefs.find(t => t.name === name);
374
- const run = async () => { try { return toolResult(await callTool(name, args ?? {})); } catch (e) { return toolError(e); } };
375
+ const run = async () => { try { return toolResult(await budgetValue(name, await callTool(name, args ?? {}))); } catch (e) { return toolError(e); } };
375
376
  if (def?.annotations?.readOnlyHint) return run();
376
377
  const p = writerQueue.then(run);
377
378
  writerQueue = p.catch(() => {});
package/port/src/scan.mjs CHANGED
@@ -5,6 +5,7 @@ import path from 'node:path';
5
5
  import { RPError, requireDir, walk, readAt, countLines, isText, MiB } from './util.mjs';
6
6
  import { LANGUAGE_BY_EXT, CODE_LANGUAGES } from './symbols.mjs';
7
7
  import { parseGodotProject } from './scene.mjs';
8
+ import { workspaceTooling } from './jsworkspace.mjs';
8
9
 
9
10
  const CATEGORY_BY_EXT = {};
10
11
  const cat = (name, exts) => exts.split(' ').forEach(e => { CATEGORY_BY_EXT[`.${e}`] = name; });
@@ -19,6 +20,36 @@ cat('config', 'json yaml yml toml ini cfg xml plist properties godot import meta
19
20
  cat('build-binary', 'exe dll so dylib pck pak ucas utoc bundle assets ress resource apk aab ipa jar class dex wasm pdb lib a o obj node');
20
21
  cat('docs', 'md txt rst pdf html htm');
21
22
 
23
+ // npm dependency → framework name, for "what is this built with" in JS/TS projects.
24
+ const JS_FRAMEWORKS = [['three', 'three.js'], ['phaser', 'Phaser'], ['@babylonjs/core', 'Babylon.js'], ['babylonjs', 'Babylon.js'], ['pixi.js', 'PixiJS'], ['playcanvas', 'PlayCanvas'], ['excalibur', 'Excalibur'], ['kaboom', 'Kaboom'], ['kaplay', 'KAPLAY'],
25
+ ['electron', 'Electron'], ['electron-vite', 'Electron'], ['electron-builder', 'Electron'], ['@tauri-apps/api', 'Tauri'], ['@capacitor/core', 'Capacitor'], ['react-native', 'React Native'], ['expo', 'Expo'],
26
+ ['react', 'React'], ['vue', 'Vue'], ['svelte', 'Svelte'], ['solid-js', 'SolidJS'], ['preact', 'Preact'], ['@angular/core', 'Angular'], ['lit', 'Lit'],
27
+ ['next', 'Next.js'], ['nuxt', 'Nuxt'], ['@sveltejs/kit', 'SvelteKit'], ['@solidjs/start', 'SolidStart'], ['astro', 'Astro'], ['@remix-run/react', 'Remix'], ['react-router', 'React Router'],
28
+ ['vite', 'Vite'], ['webpack', 'webpack'], ['esbuild', 'esbuild'],
29
+ ['express', 'Express'], ['fastify', 'Fastify'], ['hono', 'Hono'], ['koa', 'Koa'], ['@nestjs/core', 'NestJS'], ['elysia', 'Elysia'],
30
+ ['effect', 'Effect'], ['drizzle-orm', 'Drizzle ORM'], ['@prisma/client', 'Prisma'], ['@trpc/server', 'tRPC'],
31
+ ['@modelcontextprotocol/sdk', 'MCP SDK'], ['ai', 'Vercel AI SDK'], ['@opentui/core', 'OpenTUI'], ['ink', 'Ink (terminal UI)'], ['sst', 'SST']];
32
+
33
+ /** The file a package script starts (`node x.js`, `tsx src/a.ts`, `bun run --cwd pkg src/index.ts`). A leading \0 marks a path relative to --cwd's target, already joined. */
34
+ function scriptEntry(cmd) {
35
+ if (typeof cmd !== 'string') return null;
36
+ for (const part of cmd.split(/&&|\|\||;|\|/)) {
37
+ const t = part.trim().split(/\s+/);
38
+ const i = t.findIndex(x => /^(node|tsx|ts-node|bun|deno|esno|vite-node)$/.test(x));
39
+ if (i < 0) continue;
40
+ let cwd = null;
41
+ for (let j = i + 1; j < t.length; j++) {
42
+ const x = t[j];
43
+ if (x === 'run' || x === 'x') continue;
44
+ if (x === '--cwd' && t[j + 1]) { cwd = t[++j]; continue; }
45
+ if (x.startsWith('--cwd=')) { cwd = x.slice(6); continue; }
46
+ if (x.startsWith('-')) continue;
47
+ if (/\.[cm]?[jt]sx?$/.test(x)) return cwd ? `\0${path.posix.normalize(path.posix.join(cwd, x))}` : x;
48
+ }
49
+ }
50
+ return null;
51
+ }
52
+
22
53
  function categoryOf(f) {
23
54
  const lang = LANGUAGE_BY_EXT[f.ext];
24
55
  if (lang && CODE_LANGUAGES.has(lang)) return 'code';
@@ -104,18 +135,37 @@ export async function detectEngines(root, files) {
104
135
  else if (/\bFNA\b/.test(t)) add({ id: 'fna', name: 'FNA', kind: 'project', evidence: [f.rel] });
105
136
  else if (/Godot\.NET\.Sdk/i.test(t)) { /* part of a Godot .NET project */ }
106
137
  }
107
- for (const f of find(/(^|\/)package\.json$/).filter(f => !f.rel.includes('node_modules')).slice(0, 10)) {
138
+ const pkgFiles = find(/(^|\/)package\.json$/).filter(f => !f.rel.includes('node_modules')).sort((a, b) => a.rel.split('/').length - b.rel.split('/').length);
139
+ const PKG_MAX = 60;
140
+ for (const f of pkgFiles.slice(0, PKG_MAX)) {
108
141
  let pkg = {};
109
142
  try { pkg = JSON.parse(await readText(f.abs)); } catch { continue; }
110
143
  const deps = { ...pkg.dependencies, ...pkg.devDependencies };
111
- const known = [['three', 'three.js'], ['phaser', 'Phaser'], ['@babylonjs/core', 'Babylon.js'], ['babylonjs', 'Babylon.js'], ['pixi.js', 'PixiJS'], ['playcanvas', 'PlayCanvas'], ['excalibur', 'Excalibur'], ['kaboom', 'Kaboom'], ['electron', 'Electron'], ['react', 'React'], ['vue', 'Vue'], ['svelte', 'Svelte'], ['next', 'Next.js'], ['react-native', 'React Native']];
112
- const found = known.filter(([d]) => deps[d]).map(([, n]) => n);
113
- const starts = [...(typeof pkg.bin === 'string' ? [pkg.bin] : Object.values(pkg.bin ?? {})), ...['start', 'dev', 'serve'].map(k => String(pkg.scripts?.[k] ?? '').match(/\b(?:node|tsx|ts-node|bun|deno run)\s+(?:--?\S+\s+)*([^\s&|;]+\.[cm]?[jt]s)\b/)?.[1]).filter(Boolean)];
144
+ const found = JS_FRAMEWORKS.filter(([d]) => deps[d]).map(([, n]) => n);
145
+ const pkgDir = path.posix.dirname(f.rel) === '.' ? '' : path.posix.dirname(f.rel);
146
+ const starts = [...(typeof pkg.bin === 'string' ? [pkg.bin] : Object.values(pkg.bin ?? {})), ...['start', 'dev', 'serve'].map(k => scriptEntry(pkg.scripts?.[k])).filter(Boolean)];
147
+ // `bun run --cwd packages/cli src/index.ts` at the root starts a file in another package.
148
+ for (let i = 0; i < starts.length; i++) if (typeof starts[i] === 'string' && starts[i].startsWith('\0')) starts[i] = path.posix.relative(pkgDir || '.', starts[i].slice(1)) || starts[i].slice(1);
114
149
  add({ id: 'node', name: `Node.js package${found.length ? ` (${[...new Set(found)].join(', ')})` : ''}`, kind: 'project', root: path.posix.dirname(f.rel) === '.' ? '' : path.posix.dirname(f.rel), evidence: [f.rel], details: { name: pkg.name, main: pkg.main, bin: pkg.bin, starts: [...new Set(starts)], scripts: Object.keys(pkg.scripts ?? {}), frameworks: [...new Set(found)] } });
115
150
  }
151
+ if (pkgFiles.length > 1 || rels.some(r => /^(turbo|nx|lerna|rush)\.json$|^pnpm-workspace\.yaml$/.test(r))) {
152
+ let rootPkg = {};
153
+ if (has('package.json')) { try { rootPkg = JSON.parse(await readText(path.join(root, 'package.json'))); } catch { /* damaged */ } }
154
+ const { manager, tools } = workspaceTooling(rels, rootPkg);
155
+ const declared = Array.isArray(rootPkg.workspaces) ? rootPkg.workspaces : rootPkg.workspaces?.packages ?? [];
156
+ if (declared.length || tools.length || pkgFiles.length > 2) {
157
+ add({ id: 'js-monorepo', name: `JavaScript/TypeScript monorepo (${[manager, ...tools].filter(Boolean).join(', ') || 'workspaces'})`, kind: 'project', evidence: ['package.json', ...rels.filter(r => /^(turbo|nx|lerna|rush)\.json$|^pnpm-workspace\.yaml$|^bun\.lockb?$/.test(r))].filter(r => rels.includes(r)), details: { packageManager: manager, tools, workspaces: declared, packages: pkgFiles.length, packagesListed: Math.min(pkgFiles.length, PKG_MAX) } });
158
+ }
159
+ }
116
160
  if (rels.some(r => /(^|\/)resources\/app\.asar$/i.test(r))) add({ id: 'electron-build', name: 'Electron app build', kind: 'build', evidence: rels.filter(r => /app\.asar$/i.test(r)).slice(0, 3) });
117
161
  if (find(/(^|\/)pubspec\.yaml$/).length) add({ id: 'flutter', name: 'Flutter / Dart', kind: 'project', evidence: find(/(^|\/)pubspec\.yaml$/).map(f => f.rel) });
118
- if (find(/(^|\/)AndroidManifest\.xml$/).length) add({ id: 'android', name: 'Android app', kind: 'project', evidence: find(/(^|\/)AndroidManifest\.xml$/).slice(0, 3).map(f => f.rel) });
162
+ const depthOfRel = r => r.split('/').length;
163
+ const manifests = find(/(^|\/)AndroidManifest\.xml$/).map(f => f.rel).sort((a, b) => depthOfRel(a) - depthOfRel(b));
164
+ if (manifests.length) {
165
+ // The project root is the folder above src/main (and above app/ for a Gradle app module).
166
+ const root = path.posix.dirname(manifests[0]).replace(/(^|\/)src\/main$/, '').replace(/(^|\/)app$/, '');
167
+ add({ id: 'android', name: 'Android app', kind: 'project', root: root === '.' ? '' : root, evidence: manifests.slice(0, 3) });
168
+ }
119
169
  if (find(/\.xcodeproj\//).length) add({ id: 'xcode', name: 'Xcode project (Apple)', kind: 'project', evidence: [...new Set(find(/\.xcodeproj\//).map(f => f.rel.split('.xcodeproj/')[0] + '.xcodeproj'))].slice(0, 3) });
120
170
  if (find(/\.sln$/i).length && !engines.some(e => ['unity', 'godot'].includes(e.id))) add({ id: 'dotnet', name: '.NET solution', kind: 'project', evidence: find(/\.sln$/i).map(f => f.rel).slice(0, 3) });
121
171
  for (const [file, id, name] of [['go.mod', 'go', 'Go module'], ['CMakeLists.txt', 'cmake', 'CMake (C/C++)'], ['pyproject.toml', 'python', 'Python project'], ['setup.py', 'python', 'Python project'], ['requirements.txt', 'python', 'Python project'], ['pom.xml', 'maven', 'Maven (Java)'], ['build.gradle', 'gradle', 'Gradle (JVM)'], ['build.gradle.kts', 'gradle', 'Gradle (JVM)'], ['Makefile', 'make', 'Makefile build']]) {
@@ -148,7 +198,7 @@ export async function scanProject({ path: dir, maxFiles = 50000 } = {}) {
148
198
  }
149
199
  const engines = await detectEngines(abs, files);
150
200
  const depthOf = e => (e.root ? e.root.split('/').length : 0);
151
- const primary = engines.filter(e => e.kind === 'project').sort((a, b) => depthOf(a) - depthOf(b))[0] ?? engines[0] ?? null;
201
+ const primary = engines.filter(e => e.kind === 'project').sort((a, b) => depthOf(a) - depthOf(b) || (b.id === 'js-monorepo') - (a.id === 'js-monorepo'))[0] ?? engines[0] ?? null;
152
202
  const entryPoints = [];
153
203
  for (const e of engines) {
154
204
  if (e.id === 'godot') {
@@ -0,0 +1,41 @@
1
+ // The skills that ship with this exact ReversePort version, served by the CLI.
2
+ // An agent that runs `reverseport skills get <name>` always reads the guide that matches the binary it calls,
3
+ // even when a copied SKILL.md in its own skills folder is older.
4
+ import fs from 'node:fs/promises';
5
+ import path from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { RPError } from './util.mjs';
8
+
9
+ const SKILLS_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'skills');
10
+
11
+ function frontmatter(text) {
12
+ const m = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
13
+ const meta = {};
14
+ if (m) for (const line of m[1].split(/\r?\n/)) { const kv = line.match(/^([a-zA-Z_-]+):\s*(.*)$/); if (kv) meta[kv[1]] = kv[2].replace(/^["']|["']$/g, ''); }
15
+ return { meta, body: m ? text.slice(m[0].length) : text };
16
+ }
17
+
18
+ async function load(dir) {
19
+ let folders = [];
20
+ try { folders = (await fs.readdir(dir, { withFileTypes: true })).filter(d => d.isDirectory()).map(d => d.name).sort(); } catch { return []; }
21
+ const out = [];
22
+ for (const folder of folders) {
23
+ const file = path.join(dir, folder, 'SKILL.md');
24
+ let text;
25
+ try { text = await fs.readFile(file, 'utf8'); } catch { continue; } // a folder without SKILL.md is not a skill
26
+ const { meta, body } = frontmatter(text);
27
+ out.push({ name: meta.name || folder, description: meta.description ?? '', file, body: body.trim(), text });
28
+ }
29
+ return out;
30
+ }
31
+
32
+ export async function listSkills({ dir = SKILLS_DIR } = {}) {
33
+ return (await load(dir)).map(({ name, description }) => ({ name, description }));
34
+ }
35
+
36
+ export async function getSkill(name, { dir = SKILLS_DIR } = {}) {
37
+ const all = await load(dir);
38
+ const hit = all.find(s => s.name === name);
39
+ if (!hit) throw new RPError('INVALID_ARGUMENT', `${name ? `Unknown skill "${name}".` : 'Name a skill.'} Skills: ${all.map(s => s.name).join(', ') || '(none bundled)'}.`);
40
+ return hit;
41
+ }
@@ -55,6 +55,25 @@ function lineIndex(text) {
55
55
 
56
56
  const all = (re, text) => [...text.matchAll(re)];
57
57
 
58
+ /** `a::{b, c::{self, D}, *}` → `a::b`, `a::c`, `a::c::D`, `a`. Aliases (`as X`) and leading `::` are dropped. */
59
+ export function expandRustUse(spec) {
60
+ const text = spec.replace(/\s+as\s+\w+/g, '').replace(/\s+/g, '').replace(/^::/, '');
61
+ const out = [];
62
+ const walk = (prefix, s) => {
63
+ const open = s.indexOf('{');
64
+ if (open < 0) { const p = `${prefix}${s}`.replace(/::(self|\*)$/, '').replace(/^(self|\*)$/, ''); if (p) out.push(p); return; }
65
+ let depth = 0, close = open;
66
+ for (; close < s.length; close++) { if (s[close] === '{') depth++; else if (s[close] === '}' && --depth === 0) break; }
67
+ const items = [];
68
+ let start = open + 1, level = 0;
69
+ for (let i = open + 1; i < close; i++) { if (s[i] === '{') level++; else if (s[i] === '}') level--; else if (s[i] === ',' && !level) { items.push(s.slice(start, i)); start = i + 1; } }
70
+ items.push(s.slice(start, close));
71
+ for (const item of items) if (item) walk(prefix + s.slice(0, open), item);
72
+ };
73
+ walk('', text);
74
+ return [...new Set(out)];
75
+ }
76
+
58
77
  export function extractSymbols(text, language) {
59
78
  const code = stripComments(text, language);
60
79
  const line = lineIndex(code);
@@ -150,7 +169,9 @@ export function extractSymbols(text, language) {
150
169
  for (const m of all(/\bfn\s+(\w+)/g, code)) fn(m[1], m);
151
170
  for (const m of all(/\b(struct|enum|trait|union)\s+(\w+)/g, code)) cls(m[2], m, { kind: m[1] });
152
171
  for (const m of all(/^\s*(?:pub(?:\([^)]*\))?\s+)?mod\s+(\w+)\s*;/gm, code)) dep(m[1], 'mod');
153
- for (const m of all(/^\s*(?:pub\s+)?use\s+([\w:]+)/gm, code)) dep(m[1], 'use');
172
+ // `use a::{b, c::{self, D}};` names every leaf; a `use super::…` that is indented sits in an inline
173
+ // `mod tests { … }`, whose parent is this file, so one `super` becomes `self`.
174
+ for (const m of all(/^([ \t]*)(?:pub(?:\([^)]*\))?\s+)?use\s+([^;]+);/gm, code)) for (const spec of expandRustUse(m[2])) dep(m[1] ? spec.replace(/^super(?=::|$)/, 'self') : spec, 'use');
154
175
  if (/\bfn\s+main\s*\(/.test(code)) out.entry = true;
155
176
  break;
156
177
  }
@@ -35,7 +35,7 @@ export const tools = [
35
35
  description: 'Reconnaissance of a folder: detects the engine/framework (Godot, Unity, Unreal, GameMaker, RPG Maker, Ren\'Py, LÖVE, Defold, Bevy, MonoGame, three.js/Phaser/Electron, Flutter, .NET, Go, Rust, Python…) for source projects AND exported game builds, languages with line counts, file categories, entry points, biggest files and binaries. Start here.',
36
36
  inputSchema: obj({ path: str('Folder to scan.'), maxFiles: int('Stop after this many files (default 50000).', { minimum: 100, maximum: 500000 }) }, ['path']) },
37
37
  { name: 're_graph', brief: 'Dependency graph of a project: entry points, cycles, most-used files and the port order.', title: 'Dependency graph and port order',
38
- description: 'Builds the dependency graph of a project: imports/includes/requires, Godot res:// loads and scene resources, Unity script/prefab GUIDs, and type references (C#, GDScript class_name/autoloads, Java…). Returns entry points, cycles, orphans, most-used files, external packages and the port order (dependencies first). format:"mermaid" adds a diagram.',
38
+ description: 'Builds the dependency graph of a project: imports/includes/requires, Godot res:// loads and scene resources, Unity script/prefab GUIDs, and type references (C#, GDScript class_name/autoloads, Java…). JS/TS monorepos: imports of workspace packages by name and tsconfig paths aliases resolve to their files, and a workspace section shows which package uses which. Returns entry points, cycles, orphans, most-used files, external packages and the port order (dependencies first). format:"mermaid" adds a diagram.',
39
39
  inputSchema: obj({ path: str('Project folder.'), limit: int('Files in portOrder (default 80; edges 40). Larger values return more.', { minimum: 10, maximum: 5000 }), format: str('json (default), mermaid or both.', { enum: ['json', 'mermaid', 'both'] }) }, ['path']) },
40
40
  { name: 're_symbols', brief: 'Classes, functions, signals and exported fields of a file or folder; match filters by name.', title: 'Classes, functions, signals',
41
41
  description: 'Code structure for one file or a whole folder (30+ languages: GDScript, C#, C/C++ incl. Unreal macros, Python, JS/TS, Lua, Java/Kotlin, Go, Rust, Swift, shaders, Ren\'Py…): classes with base classes, functions with lines, signals/events, exported/serialized fields, dependencies, node paths. match filters by name (substring or * wildcard).',
@@ -125,8 +125,8 @@ function validate(schema, args, where = 'arguments') {
125
125
  throw new RPError('INVALID_ARGUMENT', `Unknown argument ${hints.join(', ')}${where === 'arguments' ? '' : ` in ${where}`}. Allowed: ${allowed.join(', ')}.`);
126
126
  }
127
127
  for (const r of schema.required ?? []) if (args[r] === undefined || args[r] === null || args[r] === '') {
128
- const d = schema.properties?.[r]?.description;
129
- throw new RPError('INVALID_ARGUMENT', `Missing ${r}${d ? ` (${d.replace(/\.$/, '')})` : ''}.`);
128
+ const { description: d, enum: choices } = schema.properties?.[r] ?? {};
129
+ throw new RPError('INVALID_ARGUMENT', `Missing ${r}${d ? ` (${d.replace(/\.$/, '')})` : ''}.${choices ? ` One of: ${choices.join(', ')}.` : ''}`);
130
130
  }
131
131
  for (const [k, v] of Object.entries(args)) if (v !== undefined && v !== null && schema.properties?.[k]) validate(schema.properties[k], v, k);
132
132
  return;
@@ -181,7 +181,23 @@ function coerce(schema, value, adjusted = null, at = '') {
181
181
  }
182
182
  }
183
183
  if ((schema?.type === 'integer' && typeof value === 'string' && /^-?\d+$/.test(value.trim())) || (schema?.type === 'number' && typeof value === 'string' && value.trim() !== '' && Number.isFinite(Number(value)))) return Number(value);
184
- if (schema?.type === 'boolean' && (value === 'true' || value === 'false')) return value === 'true';
184
+ if (schema?.type === 'boolean' && typeof value === 'string') {
185
+ const b = value.trim().toLowerCase();
186
+ if (b === 'true' || b === 'false') return b === 'true';
187
+ if (adjusted && ['yes', 'no', 'on', 'off', '1', '0'].includes(b)) { adjusted[at] = `read "${value}" as ${['yes', 'on', '1'].includes(b)}`; return ['yes', 'on', '1'].includes(b); }
188
+ }
189
+ if (schema?.type === 'boolean' && adjusted && (value === 1 || value === 0)) { adjusted[at] = `read ${value} as ${value === 1}`; return value === 1; }
190
+ // "Godot" for "godot", "Mermaid " for "mermaid": only when exactly one allowed value matches.
191
+ if (schema?.type === 'string' && Array.isArray(schema.enum) && typeof value === 'string' && !schema.enum.includes(value) && adjusted) {
192
+ const norm = x => String(x).trim().toLowerCase().replace(/[\s_-]+/g, '');
193
+ const hits = schema.enum.filter(e => norm(e) === norm(value));
194
+ if (hits.length === 1) { adjusted[at] = `read "${value}" as "${hits[0]}"`; return hits[0]; }
195
+ }
196
+ if (schema?.type === 'array' && typeof value === 'string' && adjusted) {
197
+ // A JSON list sent as text, or one value where a list is expected.
198
+ if (/^\s*\[/.test(value)) { try { const parsed = JSON.parse(value); if (Array.isArray(parsed)) { adjusted[at] = 'parsed the list from text'; return parsed.map((v, i) => coerce(schema.items, v, adjusted, `${at}[${i}]`)); } } catch { /* not JSON: validation explains */ } }
199
+ else if (schema.items?.type === 'string' && value.trim() !== '') { adjusted[at] = 'wrapped the single value in a list'; return [value]; }
200
+ }
185
201
  if (schema?.type === 'array' && Array.isArray(value)) return value.map((v, i) => coerce(schema.items, v, adjusted, `${at}[${i}]`));
186
202
  return value;
187
203
  }
package/port/src/util.mjs CHANGED
@@ -15,8 +15,22 @@ export const IGNORED_DIRS = new Set([
15
15
  '.git', '.svn', '.hg', 'node_modules', '.godot', '.import', 'Library', 'Temp', 'Logs', 'UserSettings', 'obj',
16
16
  '__pycache__', '.venv', 'venv', '.gradle', '.idea', '.vs', '.vscode', 'DerivedDataCache', 'Intermediate', 'Saved',
17
17
  '.mono', '.reverseport', '.cache', '.next', '.nuxt', '.parcel-cache', '.turbo', 'Pods', '.dart_tool',
18
+ '.pytest_cache', '.mypy_cache', '.ruff_cache', '.tox', 'zig-cache', '.zig-cache', 'zig-out', 'DerivedData',
19
+ '.svelte-kit', '.angular', '.vite', '.expo', '.terraform', 'bower_components', 'jspm_packages',
18
20
  ]);
19
21
 
22
+ /**
23
+ * Folders that are build output or a package store only next to a marker file: `target/` is Rust or Maven output
24
+ * beside Cargo.toml/pom.xml but may be real source elsewhere; `vendor/` holds copied dependencies beside go.mod,
25
+ * composer.json or a Gemfile.
26
+ */
27
+ export const IGNORED_DIRS_BESIDE = {
28
+ target: ['Cargo.toml', 'pom.xml', 'build.sbt'],
29
+ vendor: ['go.mod', 'composer.json', 'Gemfile'],
30
+ '.build': ['Package.swift'],
31
+ '.gradle': [],
32
+ };
33
+
20
34
  export async function statOrNull(file) { try { return await fs.stat(file); } catch { return null; } }
21
35
 
22
36
  export async function requireFile(file, what = 'file') {
@@ -88,7 +102,7 @@ export function isText(buf) {
88
102
  export const toPosix = p => p.split(path.sep).join('/');
89
103
 
90
104
  /** Walk a folder. Returns files with posix relative paths; skips IGNORED_DIRS and symlinks. */
91
- export async function walk(root, { maxFiles = 50000, ignore = IGNORED_DIRS, maxDepth = 40 } = {}) {
105
+ export async function walk(root, { maxFiles = 50000, ignore = IGNORED_DIRS, ignoreBeside = ignore === IGNORED_DIRS ? IGNORED_DIRS_BESIDE : {}, maxDepth = 40 } = {}) {
92
106
  const files = [];
93
107
  const skipped = new Set();
94
108
  const unreadable = [];
@@ -98,12 +112,14 @@ export async function walk(root, { maxFiles = 50000, ignore = IGNORED_DIRS, maxD
98
112
  let entries;
99
113
  try { entries = await fs.readdir(dir, { withFileTypes: true }); } catch (e) { if (unreadable.length < 50) unreadable.push({ path: toPosix(path.relative(root, dir)) || '.', code: e.code ?? 'ERROR' }); return; }
100
114
  entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
115
+ const names = new Set(entries.map(e => e.name));
116
+ const buildOutput = name => ignoreBeside[name]?.some(marker => names.has(marker));
101
117
  for (const e of entries) {
102
118
  if (truncated) return;
103
119
  const abs = path.join(dir, e.name);
104
120
  if (e.isSymbolicLink()) continue;
105
121
  if (e.isDirectory()) {
106
- if (ignore.has(e.name)) { skipped.add(toPosix(path.relative(root, abs))); continue; }
122
+ if (ignore.has(e.name) || buildOutput(e.name)) { skipped.add(toPosix(path.relative(root, abs))); continue; }
107
123
  await visit(abs, depth + 1);
108
124
  } else if (e.isFile()) {
109
125
  if (files.length >= maxFiles) { truncated = true; return; }
@@ -34,7 +34,7 @@ original task after repair. Client approval, shell PATH, registration and engine
34
34
  availability have different remedies; avoid repeating setup for all of them.
35
35
 
36
36
  Prefer an existing `reverseport` CLI. When none is installed and ReversePort package acquisition
37
- is within the authorized scope, use `npx -y reverseport@1.3.0` in its place.
37
+ is within the authorized scope, use `npx -y reverseport@1.5.0` in its place.
38
38
  Configuration repair starts with a read-only setup plan; show paths, backups and
39
39
  skill replacement before applying authorized changes. Preserve existing approval
40
40
  and target-execution boundaries. ReversePort setup never installs or upgrades unrelated