reverseport 1.4.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -17,7 +17,7 @@ const VERSION = JSON.parse(readFileSync(path.join(REPO, 'package.json'), 'utf8')
17
17
  const PORT_COMMANDS = new Set([
18
18
  'scan', 'graph', 'symbols', 'scene', 'binary', 'bin', 'archive', 'pck', 'probe', 'extract', 'carve', 'diff',
19
19
  'compare-builds', 'report', 'lookup', 'recon', 'web', 'blueprint', 'rebuild', 'map', 'convert', 'plan', 'unit',
20
- 'write', 'port-scene', 'copy-assets', 'status', 'verify', 'case', 'proof', 'tools', 'serve', 'describe', 'feedback', 'connect',
20
+ 'write', 'port-scene', 'copy-assets', 'status', 'verify', 'case', 'proof', 'tools', 'serve', 'describe', 'feedback', 'connect', 'skills',
21
21
  ]);
22
22
  const ALWAYS_ENGINE = ['setup', 'doctor', 'update', 'uninstall', 'mcp', 'capabilities', 'providers', 'help'];
23
23
 
@@ -1,7 +1,7 @@
1
1
  /** Generated from package.json and package-lock.json; do not edit. */
2
2
  export const PACKAGE_METADATA = {
3
3
  name: "reverseport",
4
- version: "1.4.0", // x-release-please-version
4
+ version: "1.5.0", // x-release-please-version
5
5
  serverSdkVersion: "2.3.1",
6
6
  clientSdkVersion: "2.3.1",
7
7
  coreSdkVersion: "2.3.1",
@@ -1,5 +1,5 @@
1
1
  {
2
- "packageVersion": "1.4.0",
2
+ "packageVersion": "1.5.0",
3
3
  "platform": "win32",
4
4
  "architecture": "x64",
5
5
  "abiVersion": 1,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "reverseport",
3
3
  "mcpName": "io.github.laaielichter/reverseport",
4
- "version": "1.4.0",
4
+ "version": "1.5.0",
5
5
  "description": "Reverse engineer anything. Port it anywhere. One CLI and MCP server that lets coding agents take apart apps, games, binaries and websites — decompile, trace, compare builds, recon without files — and rebuild or port them to Godot, Unity, Unreal, three.js or any language.",
6
6
  "license": "MIT",
7
7
  "repository": {
@@ -301,8 +301,8 @@
301
301
  },
302
302
  "lint-staged": {
303
303
  "*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}": [
304
- "oxfmt",
305
- "oxlint --fix"
304
+ "oxfmt --no-error-on-unmatched-pattern",
305
+ "oxlint --fix --no-error-on-unmatched-pattern"
306
306
  ],
307
307
  "*.{json,md,yml,yaml}": "oxfmt --no-error-on-unmatched-pattern"
308
308
  },
package/port/cli.mjs CHANGED
@@ -118,6 +118,8 @@ Proof (evidence ledger)
118
118
  Other
119
119
  tools [--all] [--format f] list the tools (--all adds the engine's when built); formats: ${FORMATS.join(', ')}
120
120
  describe <tool> one tool's description and input schema (engine tools too, when built)
121
+ skills [list] the skills bundled with this version
122
+ skills get <name> one skill's full guide, matching this version (--json for JSON)
121
123
  feedback notes agents left with share_feedback (stays on this computer)
122
124
  connect [client] config snippet for any agent: MCP clients, HTTP/OpenAPI, OpenAI, Anthropic, Gemini, shell
123
125
  serve HTTP API on http://127.0.0.1:32147 (REVERSEPORT_PORT)
@@ -153,6 +155,20 @@ export async function main(argv = process.argv.slice(2)) {
153
155
  print({ name: t.name, title: t.title, description: t.description, inputSchema: t.inputSchema, annotations: t.annotations, cli: `reverseport ${t.name} ${(POSITIONAL[t.name] ?? []).map(p => `<${p}>`).join(' ')} [--key value] [--args '{…}']`.replace(/\s+/g, ' ').trim() });
154
156
  return 0;
155
157
  }
158
+ if (cmd === 'skills') {
159
+ const { listSkills, getSkill } = await import('./src/skills.mjs');
160
+ const [sub, name] = rest.filter(x => !x.startsWith('--'));
161
+ try {
162
+ if (!sub || sub === 'list') { print({ version: VERSION, skills: await listSkills() }); return 0; }
163
+ if (sub === 'get') {
164
+ const s = await getSkill(name);
165
+ if (argv.includes('--json')) print({ version: VERSION, name: s.name, description: s.description, body: s.body });
166
+ else process.stdout.write(`<!-- ReversePort ${VERSION} · skill ${s.name} -->\n${s.text.endsWith('\n') ? s.text : `${s.text}\n`}`);
167
+ return 0;
168
+ }
169
+ return printError(new RPError('INVALID_ARGUMENT', `Unknown skills command "${sub}". Use: reverseport skills list | reverseport skills get <name>`));
170
+ } catch (e) { return printError(e); }
171
+ }
156
172
  if (cmd === 'feedback') { print(await readFeedback()); return 0; }
157
173
  if (cmd === 'connect') {
158
174
  const { connect } = await import('./src/connect.mjs');
@@ -103,10 +103,27 @@ export async function buildGraph(dir, { maxFiles = 30000 } = {}) {
103
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; }
104
104
  return null;
105
105
  };
106
- const resolveRustMod = (from, name) => {
107
- const dir = path.posix.dirname(from), stem = path.posix.basename(from, '.rs');
108
- const base = ['mod', 'main', 'lib'].includes(stem) ? dir : path.posix.join(dir, stem);
109
- return exists(path.posix.normalize(`${base}/${name}.rs`)) ?? exists(path.posix.normalize(`${base}/${name}/mod.rs`));
106
+ // Rust: a file's child modules live in `<stem>/` (or next to mod.rs/lib.rs/main.rs); `crate::` starts at the
107
+ // crate's src/, and a workspace crate is reachable by its package name (`-` reads as `_`).
108
+ const rustModuleDir = from => { const dir = path.posix.dirname(from), stem = path.posix.basename(from, '.rs'); return ['mod', 'main', 'lib'].includes(stem) ? dir : path.posix.join(dir, stem); };
109
+ const rustModuleFile = dir => exists(`${dir}.rs`) ?? exists(`${dir}/mod.rs`) ?? exists(`${dir}/lib.rs`) ?? exists(`${dir}/main.rs`);
110
+ const resolveRustMod = (from, name) => { const base = rustModuleDir(from); return exists(path.posix.normalize(`${base}/${name}.rs`)) ?? exists(path.posix.normalize(`${base}/${name}/mod.rs`)); };
111
+ const rustCrates = new Map();
112
+ for (const f of files) if (/(^|\/)Cargo\.toml$/.test(f.rel)) {
113
+ const name = (await fs.readFile(f.abs, 'utf8').catch(() => '')).match(/^\[package\][^[]*?^\s*name\s*=\s*"([^"]+)"/ms)?.[1];
114
+ if (name) rustCrates.set(name.replace(/-/g, '_'), path.posix.join(path.posix.dirname(f.rel), 'src').replace(/^\.\//, ''));
115
+ }
116
+ const rustCrateSrc = from => { let dir = path.posix.dirname(from); while (dir && dir !== '.') { if (path.posix.basename(dir) === 'src' && exists(path.posix.join(path.posix.dirname(dir), 'Cargo.toml'))) return dir; dir = path.posix.dirname(dir); } return null; };
117
+ const resolveRustUse = (from, spec) => {
118
+ const segs = spec.split('::').filter(Boolean);
119
+ let base;
120
+ if (segs[0] === 'crate') { base = rustCrateSrc(from); segs.shift(); }
121
+ else if (segs[0] === 'self') { base = rustModuleDir(from); segs.shift(); }
122
+ else if (segs[0] === 'super') { base = rustModuleDir(from); while (segs[0] === 'super') { segs.shift(); base = path.posix.dirname(base); } }
123
+ else if (rustCrates.has(segs[0])) base = rustCrates.get(segs.shift());
124
+ if (!base || base === '.') return null;
125
+ for (let i = segs.length; i >= 1; i--) { const p = `${base}/${segs.slice(0, i).join('/')}`; const hit = exists(`${p}.rs`) ?? exists(`${p}/mod.rs`); if (hit) return hit; }
126
+ return rustModuleFile(base);
110
127
  };
111
128
 
112
129
  for (const n of nodes.values()) {
@@ -134,7 +151,7 @@ export async function buildGraph(dir, { maxFiles = 30000 } = {}) {
134
151
  case 'python': hit = resolvePy(n.file, d.spec); if (!hit && !d.spec.startsWith('.')) external(d.spec.split('.')[0]); break;
135
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;
136
153
  case 'lua': hit = resolveLua(n.file, d.spec); if (!hit) external(d.spec); break;
137
- case 'rust': if (d.kind === 'mod') hit = resolveRustMod(n.file, d.spec); else if (!/^(crate|self|super)::/.test(d.spec)) external(d.spec.split('::')[0]); break;
154
+ case 'rust': if (d.kind === 'mod') hit = resolveRustMod(n.file, d.spec); else { hit = resolveRustUse(n.file, d.spec); if (!hit && !/^(crate|self|super)(::|$)/.test(d.spec)) external(d.spec.split('::')[0]); } break;
138
155
  case 'go':
139
156
  if (goModule && d.spec.startsWith(`${goModule}/`)) {
140
157
  const pkgDir = d.spec.slice(goModule.length + 1);
@@ -239,9 +256,14 @@ export function tarjanOrder(keys, out) {
239
256
  return { sccs, order: sccs.flat() };
240
257
  }
241
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
+
242
262
  export function graphMermaid(graph, max = 60) {
243
263
  const score = k => (graph.inDegree.get(k) ?? 0) + (graph.outEdges.get(k)?.length ?? 0);
244
- const keep = [...graph.nodes.keys()].sort((a, b) => score(b) - score(a)).slice(0, max);
264
+ // Tests depend on many files, so by degree alone they would crowd out the modules a reader wants to see.
265
+ const ranked = [...graph.nodes.keys()].sort((a, b) => score(b) - score(a));
266
+ const keep = [...ranked.filter(k => !isTestFile(k)), ...ranked.filter(isTestFile)].slice(0, max);
245
267
  const id = new Map(keep.map((k, i) => [k, `f${i}`]));
246
268
  const lines = ['graph LR'];
247
269
  for (const k of keep) lines.push(` ${id.get(k)}["${path.posix.basename(k).replace(/"/g, "'")}"]`);
@@ -0,0 +1,41 @@
1
+ // The skills that ship with this exact ReversePort version, served by the CLI.
2
+ // An agent that runs `reverseport skills get <name>` always reads the guide that matches the binary it calls,
3
+ // even when a copied SKILL.md in its own skills folder is older.
4
+ import fs from 'node:fs/promises';
5
+ import path from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { RPError } from './util.mjs';
8
+
9
+ const SKILLS_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'skills');
10
+
11
+ function frontmatter(text) {
12
+ const m = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
13
+ const meta = {};
14
+ if (m) for (const line of m[1].split(/\r?\n/)) { const kv = line.match(/^([a-zA-Z_-]+):\s*(.*)$/); if (kv) meta[kv[1]] = kv[2].replace(/^["']|["']$/g, ''); }
15
+ return { meta, body: m ? text.slice(m[0].length) : text };
16
+ }
17
+
18
+ async function load(dir) {
19
+ let folders = [];
20
+ try { folders = (await fs.readdir(dir, { withFileTypes: true })).filter(d => d.isDirectory()).map(d => d.name).sort(); } catch { return []; }
21
+ const out = [];
22
+ for (const folder of folders) {
23
+ const file = path.join(dir, folder, 'SKILL.md');
24
+ let text;
25
+ try { text = await fs.readFile(file, 'utf8'); } catch { continue; } // a folder without SKILL.md is not a skill
26
+ const { meta, body } = frontmatter(text);
27
+ out.push({ name: meta.name || folder, description: meta.description ?? '', file, body: body.trim(), text });
28
+ }
29
+ return out;
30
+ }
31
+
32
+ export async function listSkills({ dir = SKILLS_DIR } = {}) {
33
+ return (await load(dir)).map(({ name, description }) => ({ name, description }));
34
+ }
35
+
36
+ export async function getSkill(name, { dir = SKILLS_DIR } = {}) {
37
+ const all = await load(dir);
38
+ const hit = all.find(s => s.name === name);
39
+ if (!hit) throw new RPError('INVALID_ARGUMENT', `${name ? `Unknown skill "${name}".` : 'Name a skill.'} Skills: ${all.map(s => s.name).join(', ') || '(none bundled)'}.`);
40
+ return hit;
41
+ }
@@ -55,6 +55,25 @@ function lineIndex(text) {
55
55
 
56
56
  const all = (re, text) => [...text.matchAll(re)];
57
57
 
58
+ /** `a::{b, c::{self, D}, *}` → `a::b`, `a::c`, `a::c::D`, `a`. Aliases (`as X`) and leading `::` are dropped. */
59
+ export function expandRustUse(spec) {
60
+ const text = spec.replace(/\s+as\s+\w+/g, '').replace(/\s+/g, '').replace(/^::/, '');
61
+ const out = [];
62
+ const walk = (prefix, s) => {
63
+ const open = s.indexOf('{');
64
+ if (open < 0) { const p = `${prefix}${s}`.replace(/::(self|\*)$/, '').replace(/^(self|\*)$/, ''); if (p) out.push(p); return; }
65
+ let depth = 0, close = open;
66
+ for (; close < s.length; close++) { if (s[close] === '{') depth++; else if (s[close] === '}' && --depth === 0) break; }
67
+ const items = [];
68
+ let start = open + 1, level = 0;
69
+ for (let i = open + 1; i < close; i++) { if (s[i] === '{') level++; else if (s[i] === '}') level--; else if (s[i] === ',' && !level) { items.push(s.slice(start, i)); start = i + 1; } }
70
+ items.push(s.slice(start, close));
71
+ for (const item of items) if (item) walk(prefix + s.slice(0, open), item);
72
+ };
73
+ walk('', text);
74
+ return [...new Set(out)];
75
+ }
76
+
58
77
  export function extractSymbols(text, language) {
59
78
  const code = stripComments(text, language);
60
79
  const line = lineIndex(code);
@@ -150,7 +169,9 @@ export function extractSymbols(text, language) {
150
169
  for (const m of all(/\bfn\s+(\w+)/g, code)) fn(m[1], m);
151
170
  for (const m of all(/\b(struct|enum|trait|union)\s+(\w+)/g, code)) cls(m[2], m, { kind: m[1] });
152
171
  for (const m of all(/^\s*(?:pub(?:\([^)]*\))?\s+)?mod\s+(\w+)\s*;/gm, code)) dep(m[1], 'mod');
153
- for (const m of all(/^\s*(?:pub\s+)?use\s+([\w:]+)/gm, code)) dep(m[1], 'use');
172
+ // `use a::{b, c::{self, D}};` names every leaf; a `use super::…` that is indented sits in an inline
173
+ // `mod tests { … }`, whose parent is this file, so one `super` becomes `self`.
174
+ for (const m of all(/^([ \t]*)(?:pub(?:\([^)]*\))?\s+)?use\s+([^;]+);/gm, code)) for (const spec of expandRustUse(m[2])) dep(m[1] ? spec.replace(/^super(?=::|$)/, 'self') : spec, 'use');
154
175
  if (/\bfn\s+main\s*\(/.test(code)) out.entry = true;
155
176
  break;
156
177
  }
@@ -125,8 +125,8 @@ function validate(schema, args, where = 'arguments') {
125
125
  throw new RPError('INVALID_ARGUMENT', `Unknown argument ${hints.join(', ')}${where === 'arguments' ? '' : ` in ${where}`}. Allowed: ${allowed.join(', ')}.`);
126
126
  }
127
127
  for (const r of schema.required ?? []) if (args[r] === undefined || args[r] === null || args[r] === '') {
128
- const d = schema.properties?.[r]?.description;
129
- throw new RPError('INVALID_ARGUMENT', `Missing ${r}${d ? ` (${d.replace(/\.$/, '')})` : ''}.`);
128
+ const { description: d, enum: choices } = schema.properties?.[r] ?? {};
129
+ throw new RPError('INVALID_ARGUMENT', `Missing ${r}${d ? ` (${d.replace(/\.$/, '')})` : ''}.${choices ? ` One of: ${choices.join(', ')}.` : ''}`);
130
130
  }
131
131
  for (const [k, v] of Object.entries(args)) if (v !== undefined && v !== null && schema.properties?.[k]) validate(schema.properties[k], v, k);
132
132
  return;
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.4.0` in its place.
37
+ is within the authorized scope, use `npx -y reverseport@1.5.0` in its place.
38
38
  Configuration repair starts with a read-only setup plan; show paths, backups and
39
39
  skill replacement before applying authorized changes. Preserve existing approval
40
40
  and target-execution boundaries. ReversePort setup never installs or upgrades unrelated