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.
- package/bin/reverseport.mjs +1 -1
- package/dist/generatedPackageMetadata.js +1 -1
- package/native/windows/build/manifest.json +1 -1
- package/package.json +3 -3
- package/port/cli.mjs +16 -0
- package/port/src/budget.mjs +135 -0
- package/port/src/graph.mjs +67 -11
- package/port/src/jsworkspace.mjs +155 -0
- package/port/src/relay.mjs +3 -2
- package/port/src/scan.mjs +56 -6
- package/port/src/skills.mjs +41 -0
- package/port/src/symbols.mjs +22 -1
- package/port/src/tools.mjs +20 -4
- package/port/src/util.mjs +18 -2
- package/skills/reverse-engineer-anything/SKILL.md +1 -1
package/bin/reverseport.mjs
CHANGED
|
@@ -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.
|
|
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",
|
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.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
|
+
}
|
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, '/');
|
|
@@ -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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
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') {
|
|
@@ -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
|
+
}
|
package/port/src/symbols.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
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).',
|
|
@@ -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]
|
|
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' &&
|
|
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.
|
|
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
|