reverseport 1.2.0 → 1.4.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 +4 -4
- package/dist/generatedPackageMetadata.js +1 -1
- package/native/windows/build/manifest.json +1 -1
- package/package.json +1 -1
- package/port/src/budget.mjs +135 -0
- package/port/src/graph.mjs +39 -5
- package/port/src/jsworkspace.mjs +155 -0
- package/port/src/relay.mjs +3 -2
- package/port/src/scan.mjs +56 -6
- package/port/src/tools.mjs +18 -2
- package/skills/reverse-engineer-anything/SKILL.md +1 -1
package/README.md
CHANGED
|
@@ -242,10 +242,10 @@ command and its JSON error. Every error names the next step, so include that lin
|
|
|
242
242
|
Two small tools from the same author that work well next to ReversePort. Both are local, open source and have their
|
|
243
243
|
own MCP server, and both install from the ReversePort marketplace:
|
|
244
244
|
|
|
245
|
-
| Tool
|
|
246
|
-
|
|
247
|
-
| [**Stashpad**](https://github.com/laaielichter/stashpad)
|
|
248
|
-
| [**agentdisk**](https://github.com/laaielichter/agentdisk) | The disk report for coding agents: the worktrees, checkpoints, `node_modules`, `.venv` and `.godot/` folders agents leave behind (porting work makes plenty), and a cleanup you can undo. Needs the `agentdisk` binary ([install](https://github.com/laaielichter/agentdisk#install)).
|
|
245
|
+
| Tool | What it does | Claude Code |
|
|
246
|
+
| ---------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- |
|
|
247
|
+
| [**Stashpad**](https://github.com/laaielichter/stashpad) | Memory for agents that you can read, edit and trust: plain Markdown files with a source and dates, a review queue for agent writes, and a handoff note for the next session. `re_case remember` sends a proof case's findings there ([how](docs/proof-and-memory.md#memory-across-sessions-stashpad-optional)). | `/plugin install stashpad@reverseport` |
|
|
248
|
+
| [**agentdisk**](https://github.com/laaielichter/agentdisk) | The disk report for coding agents: the worktrees, checkpoints, `node_modules`, `.venv` and `.godot/` folders agents leave behind (porting work makes plenty), and a cleanup you can undo. Needs the `agentdisk` binary ([install](https://github.com/laaielichter/agentdisk#install)). | `/plugin install agentdisk@reverseport` |
|
|
249
249
|
|
|
250
250
|
Add the marketplace first with `/plugin marketplace add laaielichter/reverseport`. Other agents: see each project's
|
|
251
251
|
README.
|
|
@@ -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.
|
|
4
|
+
version: "1.4.0", // x-release-please-version
|
|
5
5
|
serverSdkVersion: "2.3.1",
|
|
6
6
|
clientSdkVersion: "2.3.1",
|
|
7
7
|
coreSdkVersion: "2.3.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.
|
|
4
|
+
"version": "1.4.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": {
|
|
@@ -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
|
+
}
|
package/port/src/graph.mjs
CHANGED
|
@@ -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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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, '/');
|
|
@@ -115,7 +130,7 @@ export async function buildGraph(dir, { maxFiles = 30000 } = {}) {
|
|
|
115
130
|
let hit = null;
|
|
116
131
|
switch (n.language) {
|
|
117
132
|
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;
|
|
133
|
+
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
134
|
case 'python': hit = resolvePy(n.file, d.spec); if (!hit && !d.spec.startsWith('.')) external(d.spec.split('.')[0]); break;
|
|
120
135
|
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
136
|
case 'lua': hit = resolveLua(n.file, d.spec); if (!hit) external(d.spec); break;
|
|
@@ -166,7 +181,25 @@ export async function buildGraph(dir, { maxFiles = 30000 } = {}) {
|
|
|
166
181
|
const { sccs, order } = tarjanOrder([...nodes.keys()], out);
|
|
167
182
|
const cycles = sccs.filter(c => c.length > 1).sort((a, b) => b.length - a.length);
|
|
168
183
|
const orphans = [...nodes.keys()].filter(k => !inc.get(k) && !entry.has(k) && nodes.get(k).kind !== 'scene' && !(out.get(k).length));
|
|
169
|
-
|
|
184
|
+
let workspace = null;
|
|
185
|
+
if (wsPackages.size > 1 || aliasEdges) {
|
|
186
|
+
// Package-level view: which workspace package uses which (counts of file imports across packages).
|
|
187
|
+
const dirs = [...wsPackages.entries()].map(([name, w]) => ({ name, dir: w.dir })).sort((a, b) => b.dir.length - a.dir.length);
|
|
188
|
+
const pkgOf = rel => dirs.find(d => !d.dir || rel === d.dir || rel.startsWith(`${d.dir}/`))?.name ?? null;
|
|
189
|
+
const files = new Map(), links = new Map();
|
|
190
|
+
for (const k of nodes.keys()) { const p = pkgOf(k); if (p) files.set(p, (files.get(p) ?? 0) + 1); }
|
|
191
|
+
for (const e of edges) {
|
|
192
|
+
const a = pkgOf(e.from), b = pkgOf(e.to);
|
|
193
|
+
if (!a || !b || a === b) continue;
|
|
194
|
+
const key = `${a}\0${b}`; links.set(key, (links.get(key) ?? 0) + 1);
|
|
195
|
+
}
|
|
196
|
+
workspace = {
|
|
197
|
+
packages: wsPackages.size, workspaceEdges, aliasEdges,
|
|
198
|
+
packageList: dirs.map(d => ({ name: d.name, dir: d.dir || '.', files: files.get(d.name) ?? 0 })).sort((a, b) => b.files - a.files),
|
|
199
|
+
packageEdges: [...links.entries()].map(([k, uses]) => { const [from, to] = k.split('\0'); return { from, to, uses }; }).sort((a, b) => b.uses - a.uses),
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
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
203
|
}
|
|
171
204
|
|
|
172
205
|
function commonPrefix(a, b) { let i = 0; while (i < a.length && i < b.length && a[i] === b[i]) i++; return i; }
|
|
@@ -232,6 +265,7 @@ export async function graphTool({ path: dir, limit, format = 'json' } = {}) {
|
|
|
232
265
|
return {
|
|
233
266
|
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
267
|
entryPoints: g.entryPoints, cycles: g.cycles.slice(0, 20).map(c => c.slice(0, 30)), orphans: g.orphans.slice(0, 100),
|
|
268
|
+
...(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
269
|
mostUsed: files.slice(0, 25), externals: ext,
|
|
236
270
|
portOrder: g.order.slice(0, max), portOrderTruncated: g.order.length > max,
|
|
237
271
|
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
|
+
}
|
package/port/src/relay.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
112
|
-
const
|
|
113
|
-
const starts = [...(typeof pkg.bin === 'string' ? [pkg.bin] : Object.values(pkg.bin ?? {})), ...['start', 'dev', 'serve'].map(k =>
|
|
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
|
-
|
|
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') {
|
package/port/src/tools.mjs
CHANGED
|
@@ -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).',
|
|
@@ -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' &&
|
|
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
|
}
|
|
@@ -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.
|
|
37
|
+
is within the authorized scope, use `npx -y reverseport@1.4.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
|