parche-cli 0.0.0-stage → 0.5.1
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/CHANGELOG.md +97 -0
- package/LICENSE +21 -0
- package/README.md +165 -2
- package/bin/parche.js +273 -0
- package/data/known-sdks.json +125 -0
- package/package.json +47 -4
- package/packs/ai.model-retirements.json +96 -0
- package/packs/arca.wsfe.condicion-iva-receptor.json +51 -0
- package/packs/mercadopago.payments-to-orders.json +57 -0
- package/src/ast.js +204 -0
- package/src/codemods/arca-rules.js +109 -0
- package/src/codemods/arca-wsfe-condicion-iva-php.js +195 -0
- package/src/codemods/arca-wsfe-condicion-iva.js +177 -0
- package/src/codemods/mercadopago-payments-to-orders.js +476 -0
- package/src/context.js +93 -0
- package/src/detectors/ai-model-retirements.js +52 -0
- package/src/engine.js +89 -0
- package/src/git.js +85 -0
- package/src/github.js +22 -0
- package/src/inventory/index.js +133 -0
- package/src/inventory/js-scan.js +235 -0
- package/src/inventory/known.js +60 -0
- package/src/ledger.js +16 -0
- package/src/packs.js +55 -0
- package/src/paths.js +37 -0
- package/src/php.js +52 -0
- package/src/report.js +144 -0
- package/src/sdk-diff/compare.js +121 -0
- package/src/sdk-diff/registry.js +142 -0
- package/src/sdk-diff/surface.js +159 -0
- package/src/sdk-diff/tar.js +47 -0
- package/src/sdk-diff/upgrades.js +107 -0
- package/src/verify.js +87 -0
- package/templates/github-workflow.yml +27 -0
package/src/engine.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Motor: recorre el repo, arma el inventario de APIs y corre los packs.
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { loadPacks, transformFor, languageOf } from './packs.js';
|
|
5
|
+
import { SKIP_DIRS, toPosix } from './paths.js';
|
|
6
|
+
import { scanJs } from './inventory/js-scan.js';
|
|
7
|
+
import { readManifests, buildInventory } from './inventory/index.js';
|
|
8
|
+
import { buildContext } from './context.js';
|
|
9
|
+
|
|
10
|
+
// JS/TS y PHP (codemods), WSDL (detección de esquemas viejos) y Python, C#, Java, Go, VB.NET (solo detección).
|
|
11
|
+
const EXT = /\.(m?js|cjs|jsx|ts|tsx|php|wsdl|py|cs|java|go|vb)$/i;
|
|
12
|
+
|
|
13
|
+
export function listFiles(dir) {
|
|
14
|
+
const out = [];
|
|
15
|
+
const walk = (d) => {
|
|
16
|
+
for (const e of fs.readdirSync(d, { withFileTypes: true })) {
|
|
17
|
+
if (e.isDirectory()) {
|
|
18
|
+
if (!SKIP_DIRS.has(e.name)) walk(path.join(d, e.name));
|
|
19
|
+
} else if (EXT.test(e.name) && !/\.d\.ts$/.test(e.name)) {
|
|
20
|
+
out.push(path.join(d, e.name));
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
walk(dir);
|
|
25
|
+
// Mismo orden en Windows y en Linux: se ordena por la ruta con "/".
|
|
26
|
+
return out.sort((a, b) => (toPosix(a) < toPosix(b) ? -1 : 1));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function run(dir, { write = false, packIds = null, today } = {}) {
|
|
30
|
+
const root = path.resolve(dir);
|
|
31
|
+
const packs = loadPacks().filter((p) => !packIds || packIds.includes(p.id));
|
|
32
|
+
const manifests = readManifests(root);
|
|
33
|
+
const deps = { ...manifests.npm, ...manifests.composer };
|
|
34
|
+
const files = listFiles(root);
|
|
35
|
+
const rels = files.map((abs) => toPosix(path.relative(root, abs)));
|
|
36
|
+
// Lo que un archivo solo no sabe (versiones instaladas, si el repo ya usa el campo, WSDL viejos).
|
|
37
|
+
const context = buildContext(root, rels, manifests);
|
|
38
|
+
const findings = [];
|
|
39
|
+
const usages = [];
|
|
40
|
+
const fileScans = []; // inventario de cualquier API (nivel 1), por archivo
|
|
41
|
+
const changed = new Map(); // archivo relativo → [packs]
|
|
42
|
+
|
|
43
|
+
files.forEach((abs, i) => {
|
|
44
|
+
const rel = rels[i];
|
|
45
|
+
let source = fs.readFileSync(abs, 'utf8');
|
|
46
|
+
// El inventario se arma sobre el archivo original, antes de que un pack lo modifique.
|
|
47
|
+
if (languageOf(rel) === 'js') fileScans.push({ file: rel, scan: scanJs(source, rel) });
|
|
48
|
+
for (const pack of packs) {
|
|
49
|
+
const t = transformFor(pack.id, rel);
|
|
50
|
+
if (!t) continue;
|
|
51
|
+
let res;
|
|
52
|
+
try {
|
|
53
|
+
res = t({ source, file: rel, pack, today, context });
|
|
54
|
+
} catch (err) {
|
|
55
|
+
findings.push({ pack: pack.id, file: rel, line: null, status: 'needs-input', message: `Parche no pudo procesar este archivo: ${err.message}` });
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
findings.push(...res.findings);
|
|
59
|
+
usages.push(...res.usages.map((u) => ({ ...u, file: rel, pack: pack.id })));
|
|
60
|
+
if (res.output !== null && res.output !== source) {
|
|
61
|
+
source = res.output;
|
|
62
|
+
changed.set(rel, [...(changed.get(rel) || []), pack.id]);
|
|
63
|
+
if (write) fs.writeFileSync(abs, source);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const providers = {};
|
|
69
|
+
for (const u of usages) {
|
|
70
|
+
const p = (providers[u.provider] ||= { callSites: 0, ops: {}, files: new Set() });
|
|
71
|
+
p.callSites++;
|
|
72
|
+
p.ops[u.op] = (p.ops[u.op] || 0) + 1;
|
|
73
|
+
p.files.add(u.file);
|
|
74
|
+
}
|
|
75
|
+
const generatedAt = new Date().toISOString();
|
|
76
|
+
const inventory = {
|
|
77
|
+
...buildInventory({ repo: path.basename(root), manifests, fileScans, packs }),
|
|
78
|
+
generatedAt,
|
|
79
|
+
// Campos anteriores a schemaVersion: lo que reportan los packs. Se mantienen por compatibilidad.
|
|
80
|
+
generated_at: generatedAt,
|
|
81
|
+
packages: Object.fromEntries(Object.entries(deps).filter(([name]) =>
|
|
82
|
+
packs.some((p) => (p.detect.packages || []).includes(name)))),
|
|
83
|
+
providers: Object.fromEntries(Object.entries(providers).map(([k, v]) => [k, { ...v, files: [...v.files] }])),
|
|
84
|
+
usages,
|
|
85
|
+
};
|
|
86
|
+
return { root, packs, context, inventory, findings, changed: [...changed.entries()].map(([file, packIds]) => ({ file, packs: packIds })) };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export const STATUS_ORDER = ['blocked', 'needs-input', 'needs-review', 'auto', 'detect-only'];
|
package/src/git.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
const git = (cwd, ...args) => execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
|
|
4
|
+
|
|
5
|
+
export function isRepo(cwd) {
|
|
6
|
+
try {
|
|
7
|
+
return git(cwd, 'rev-parse', '--is-inside-work-tree') === 'true';
|
|
8
|
+
} catch {
|
|
9
|
+
return false;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function currentBranch(cwd) {
|
|
14
|
+
return git(cwd, 'rev-parse', '--abbrev-ref', 'HEAD');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function isClean(cwd) {
|
|
18
|
+
return git(cwd, 'status', '--porcelain') === '';
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function checkoutNewBranch(cwd, name, base) {
|
|
22
|
+
try {
|
|
23
|
+
git(cwd, 'checkout', base);
|
|
24
|
+
git(cwd, 'branch', '-D', name);
|
|
25
|
+
} catch { /* la rama no existía */ }
|
|
26
|
+
git(cwd, 'checkout', '-b', name);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Quién firma los commits de Parche. Nunca una dirección de un dominio que no controlamos: antes era
|
|
30
|
+
// bot@parche.dev, y parche.dev lo tiene registrado otra persona. En orden:
|
|
31
|
+
// 1. PARCHE_GIT_NAME y PARCHE_GIT_EMAIL, si están.
|
|
32
|
+
// 2. La identidad de git de quien lo corre (en su compu, o la que configuró su workflow).
|
|
33
|
+
// 3. En GitHub Actions sin identidad: el bot de Actions, el mismo que abre el PR con el GITHUB_TOKEN.
|
|
34
|
+
// Si no hay ninguna, error: no se inventa un autor.
|
|
35
|
+
export const ACTIONS_BOT = { name: 'github-actions[bot]', email: '41898282+github-actions[bot]@users.noreply.github.com' };
|
|
36
|
+
|
|
37
|
+
export function commitIdentity(cwd, env = process.env) {
|
|
38
|
+
if (env.PARCHE_GIT_NAME && env.PARCHE_GIT_EMAIL) return { name: env.PARCHE_GIT_NAME, email: env.PARCHE_GIT_EMAIL };
|
|
39
|
+
const get = (key) => {
|
|
40
|
+
try {
|
|
41
|
+
return execFileSync('git', ['config', key], { cwd, env, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
|
|
42
|
+
} catch { return ''; }
|
|
43
|
+
};
|
|
44
|
+
const name = get('user.name');
|
|
45
|
+
const email = get('user.email');
|
|
46
|
+
if (name && email) return { name, email };
|
|
47
|
+
if (env.GITHUB_ACTIONS === 'true') return ACTIONS_BOT;
|
|
48
|
+
throw new Error('No hay una identidad de git para firmar el commit: configurá `git config user.name` y `git config user.email`, o PARCHE_GIT_NAME y PARCHE_GIT_EMAIL.');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function commitAll(cwd, message, env = process.env) {
|
|
52
|
+
const id = commitIdentity(cwd, env);
|
|
53
|
+
git(cwd, 'add', '-A');
|
|
54
|
+
git(cwd, '-c', `user.name=${id.name}`, '-c', `user.email=${id.email}`, 'commit', '-q', '-m', message);
|
|
55
|
+
return git(cwd, 'rev-parse', '--short', 'HEAD');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function checkout(cwd, branch) {
|
|
59
|
+
git(cwd, 'checkout', '-q', branch);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function remoteUrl(cwd, remote = 'origin') {
|
|
63
|
+
try {
|
|
64
|
+
return git(cwd, 'remote', 'get-url', remote);
|
|
65
|
+
} catch {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export const originUrl = (cwd) => remoteUrl(cwd, 'origin');
|
|
71
|
+
|
|
72
|
+
export function push(cwd, branch, remote = 'origin') {
|
|
73
|
+
git(cwd, 'push', '-u', '--force', remote, branch);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Descarta lo que Parche escribió en su rama (el árbol estaba limpio antes de empezar; .parche/ está excluido).
|
|
77
|
+
export function discardChanges(cwd) {
|
|
78
|
+
git(cwd, 'reset', '--hard', '-q');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function deleteBranch(cwd, name) {
|
|
82
|
+
try {
|
|
83
|
+
git(cwd, 'branch', '-D', name);
|
|
84
|
+
} catch { /* ya no existía */ }
|
|
85
|
+
}
|
package/src/github.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Apertura del PR por la API REST de GitHub (sin dependencias).
|
|
2
|
+
export function parseGithubRemote(url) {
|
|
3
|
+
if (!url) return null;
|
|
4
|
+
const m = url.match(/github\.com[:/]([^/]+)\/([^/.]+?)(\.git)?$/);
|
|
5
|
+
return m ? { owner: m[1], repo: m[2] } : null;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export async function openPullRequest({ token, owner, repo, head, base, title, body, draft }) {
|
|
9
|
+
const res = await fetch(`https://api.github.com/repos/${owner}/${repo}/pulls`, {
|
|
10
|
+
method: 'POST',
|
|
11
|
+
headers: {
|
|
12
|
+
Authorization: `Bearer ${token}`,
|
|
13
|
+
Accept: 'application/vnd.github+json',
|
|
14
|
+
'X-GitHub-Api-Version': '2022-11-28',
|
|
15
|
+
'Content-Type': 'application/json',
|
|
16
|
+
},
|
|
17
|
+
body: JSON.stringify({ title, head, base, body, draft }),
|
|
18
|
+
});
|
|
19
|
+
const data = await res.json();
|
|
20
|
+
if (!res.ok) throw new Error(`GitHub respondió ${res.status}: ${data.message || JSON.stringify(data)}`);
|
|
21
|
+
return data.html_url;
|
|
22
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// Inventario de cualquier API (nivel 1 del producto): junta las dependencias declaradas, lo que
|
|
2
|
+
// encontró js-scan en cada archivo y lo que reportaron los packs, y arma .parche/inventory.json.
|
|
3
|
+
// El esquema está documentado en docs/inventario.md; si cambia de forma incompatible, sube SCHEMA_VERSION.
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { classifyPackage, providerForHost } from './known.js';
|
|
7
|
+
import { findFiles } from '../paths.js';
|
|
8
|
+
|
|
9
|
+
export const SCHEMA_VERSION = 1;
|
|
10
|
+
const KIND_ORDER = { sdk: 0, http: 1, 'ai-model': 2 };
|
|
11
|
+
|
|
12
|
+
const readJson = (p) => {
|
|
13
|
+
try {
|
|
14
|
+
return JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
15
|
+
} catch {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
// requirements.txt: "paquete==1.2", "paquete>=1", "paquete[extra]"; sin comentarios, -r, -e ni URLs.
|
|
21
|
+
export function parseRequirements(text) {
|
|
22
|
+
const out = {};
|
|
23
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
24
|
+
const line = raw.replace(/#.*$/, '').trim();
|
|
25
|
+
if (!line || line.startsWith('-') || /^[a-z]+:\/\//i.test(line)) continue;
|
|
26
|
+
const m = line.match(/^([A-Za-z0-9][A-Za-z0-9._-]*)(\[[^\]]*\])?\s*(.*)$/);
|
|
27
|
+
if (m) out[m[1].toLowerCase()] = m[3].trim() || '*';
|
|
28
|
+
}
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const MANIFEST_NAMES = new Set(['package.json', 'composer.json', 'requirements.txt']);
|
|
33
|
+
|
|
34
|
+
// Todos los manifiestos del repo, no solo los de la raíz: los monorepos (packages/backend/package.json,
|
|
35
|
+
// ms-billing/package.json) declaran ahí sus SDKs. Primero la raíz, después por profundidad.
|
|
36
|
+
export function findManifests(root) {
|
|
37
|
+
return findFiles(root, (name) => MANIFEST_NAMES.has(name));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Dependencias declaradas por ecosistema (si un paquete aparece en varios manifiestos, gana el más cercano a la raíz).
|
|
41
|
+
export function readManifests(root) {
|
|
42
|
+
const deps = { npm: {}, composer: {}, pypi: {} };
|
|
43
|
+
const files = { npm: [], composer: [], pypi: [] };
|
|
44
|
+
const merge = (eco, entries, file) => {
|
|
45
|
+
files[eco].push(file);
|
|
46
|
+
for (const [name, version] of entries) if (!(name in deps[eco])) deps[eco][name] = version;
|
|
47
|
+
};
|
|
48
|
+
for (const rel of findManifests(root)) {
|
|
49
|
+
const abs = path.join(root, rel);
|
|
50
|
+
const name = path.posix.basename(rel);
|
|
51
|
+
if (name === 'package.json') {
|
|
52
|
+
const pkg = readJson(abs) || {};
|
|
53
|
+
merge('npm', Object.entries({ ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) }), rel);
|
|
54
|
+
} else if (name === 'composer.json') {
|
|
55
|
+
const c = readJson(abs) || {};
|
|
56
|
+
// fuera "php" y las extensiones "ext-*": no son paquetes
|
|
57
|
+
merge('composer', Object.entries({ ...(c.require || {}), ...(c['require-dev'] || {}) }).filter(([n]) => n.includes('/')), rel);
|
|
58
|
+
} else {
|
|
59
|
+
merge('pypi', Object.entries(parseRequirements(fs.readFileSync(abs, 'utf8'))), rel);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return { ...deps, files };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// fileScans: [{ file, scan }] con el resultado de scanJs; packs: los packs cargados.
|
|
66
|
+
export function buildInventory({ repo, manifests, fileScans, packs }) {
|
|
67
|
+
const apis = new Map();
|
|
68
|
+
const add = (id, base) => {
|
|
69
|
+
if (!apis.has(id)) apis.set(id, { id, ...base, operations: new Map() });
|
|
70
|
+
return apis.get(id);
|
|
71
|
+
};
|
|
72
|
+
const addOp = (api, op, file, line) => {
|
|
73
|
+
const o = api.operations.get(op) || { op, count: 0, locations: [] };
|
|
74
|
+
o.count++;
|
|
75
|
+
o.locations.push({ file, line });
|
|
76
|
+
api.operations.set(op, o);
|
|
77
|
+
};
|
|
78
|
+
const packsFor = (pkg) => packs.filter((p) => (p.detect.packages || []).includes(pkg)).map((p) => p.id);
|
|
79
|
+
|
|
80
|
+
// Dependencias declaradas que son SDKs de una API (aunque todavía no se vean usadas).
|
|
81
|
+
for (const eco of ['npm', 'composer', 'pypi']) {
|
|
82
|
+
for (const [name, version] of Object.entries(manifests[eco] || {})) {
|
|
83
|
+
const c = classifyPackage(eco, name);
|
|
84
|
+
if (!c || c.kind !== 'sdk') continue;
|
|
85
|
+
add(`${eco}:${name}`, { kind: 'sdk', provider: c.provider, category: c.category, ecosystem: eco, package: name, version,
|
|
86
|
+
confidence: c.confidence, source: c.source, packs: packsFor(name) });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const unresolved = [];
|
|
91
|
+
for (const { file, scan } of fileScans) {
|
|
92
|
+
for (const u of scan.sdk) {
|
|
93
|
+
let api = apis.get(`npm:${u.pkg}`);
|
|
94
|
+
if (!api) {
|
|
95
|
+
const c = classifyPackage('npm', u.pkg);
|
|
96
|
+
if (!c || c.kind !== 'sdk') continue; // no es el cliente de una API (lodash, react...)
|
|
97
|
+
api = add(`npm:${u.pkg}`, { kind: 'sdk', provider: c.provider, category: c.category, ecosystem: 'npm', package: u.pkg,
|
|
98
|
+
version: null, confidence: c.confidence, source: c.source, packs: packsFor(u.pkg) });
|
|
99
|
+
}
|
|
100
|
+
addOp(api, u.op, file, u.line);
|
|
101
|
+
}
|
|
102
|
+
for (const h of scan.http) {
|
|
103
|
+
const known = providerForHost(h.host);
|
|
104
|
+
const api = add(`http:${h.host}`, { kind: 'http', provider: known?.provider || null, category: known?.category || null,
|
|
105
|
+
host: h.host, confidence: known ? 'alta' : 'media', source: known ? 'curated' : 'usage', packs: [] });
|
|
106
|
+
addOp(api, `${h.method} ${h.path}`, file, h.line);
|
|
107
|
+
}
|
|
108
|
+
for (const m of scan.models) {
|
|
109
|
+
const api = add(`ai:${m.provider}`, { kind: 'ai-model', provider: m.provider, category: 'ia', confidence: 'alta',
|
|
110
|
+
source: 'usage', packs: packs.some((p) => p.id === 'ai.model-retirements') ? ['ai.model-retirements'] : [] });
|
|
111
|
+
addOp(api, m.id, file, m.line);
|
|
112
|
+
}
|
|
113
|
+
for (const r of scan.unresolved) unresolved.push({ file, ...r });
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const list = [...apis.values()].map((api) => {
|
|
117
|
+
const operations = [...api.operations.values()].sort((a, b) => b.count - a.count || a.op.localeCompare(b.op));
|
|
118
|
+
// Un SDK detectado por el nombre que además se ve usado deja de ser una corazonada.
|
|
119
|
+
const confidence = api.source === 'heuristic' && operations.length ? 'media' : api.confidence;
|
|
120
|
+
return { ...api, confidence, operations };
|
|
121
|
+
}).sort((a, b) => KIND_ORDER[a.kind] - KIND_ORDER[b.kind]
|
|
122
|
+
|| (a.provider || a.package || a.host || '').localeCompare(b.provider || b.package || b.host || ''));
|
|
123
|
+
|
|
124
|
+
const { files: manifestFiles = {}, ...dependencies } = manifests;
|
|
125
|
+
return { schemaVersion: SCHEMA_VERSION, repo, apis: list, unresolved, dependencies, manifestFiles };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Nombre corto para mostrar una API en el reporte y en la CLI.
|
|
129
|
+
export function apiLabel(api) {
|
|
130
|
+
if (api.kind === 'sdk') return api.provider ? `${api.provider} · ${api.package}` : api.package;
|
|
131
|
+
if (api.kind === 'http') return api.provider ? `${api.provider} · ${api.host}` : api.host;
|
|
132
|
+
return `${api.provider} · modelos`;
|
|
133
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
// Inventario de un archivo JS/TS: qué SDKs usa (y con qué operaciones), a qué hosts llama por HTTP
|
|
2
|
+
// y qué modelos de IA nombra. Solo lee: usa @babel/parser directo (sin recast, no hay offsets que cuidar).
|
|
3
|
+
//
|
|
4
|
+
// Límite conocido: las variables se siguen por nombre en todo el archivo, sin scopes. Alcanza para el
|
|
5
|
+
// inventario (aviso de nivel 1); un codemod que edita nunca debe apoyarse en esto.
|
|
6
|
+
import { parse } from '@babel/parser';
|
|
7
|
+
import { packageOf, isInternalHost } from './known.js';
|
|
8
|
+
|
|
9
|
+
const HTTP_METHODS = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options'];
|
|
10
|
+
const FETCH_PACKAGES = new Set(['node-fetch', 'cross-fetch', 'isomorphic-fetch', 'undici']);
|
|
11
|
+
const SKIP_KEYS = new Set(['loc', 'start', 'end', 'extra', 'range', 'comments', 'leadingComments', 'trailingComments', 'innerComments', 'tokens']);
|
|
12
|
+
|
|
13
|
+
// IDs de modelos de IA. Prefijos inequívocos; la serie o1/o3/o4 de OpenAI solo cuenta si el archivo usa OpenAI.
|
|
14
|
+
const MODEL_PATTERNS = [
|
|
15
|
+
[/^(gpt-(3\.5|4|4\.1|4o|4\.5|5)[\w.-]*|chatgpt-[\w.-]+|text-embedding-[\w-]+|dall-e-[23]|whisper-1|tts-1(-hd)?|gpt-image-[\w.-]+)$/, 'OpenAI'],
|
|
16
|
+
[/^o[1-9](-(mini|pro|preview|deep-research))?(-\d{4}-\d{2}-\d{2})?$/, 'OpenAI', { needs: 'openai' }],
|
|
17
|
+
[/^claude-[\w.-]+$/, 'Anthropic'],
|
|
18
|
+
[/^(gemini|gemma)-[\w.-]+$/, 'Google (Gemini)'],
|
|
19
|
+
[/^(mistral|mixtral|codestral|pixtral|ministral)-[\w.-]+$/, 'Mistral'],
|
|
20
|
+
[/^command-(r|a)[\w.-]*$/, 'Cohere'],
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
function parseJs(source, file) {
|
|
24
|
+
const lang = /\.tsx$/.test(file) ? ['typescript', 'jsx'] : /\.(ts|mts|cts)$/.test(file) ? ['typescript'] : ['jsx'];
|
|
25
|
+
const plugins = [...lang, 'decorators-legacy']; // NestJS, Angular, TypeORM
|
|
26
|
+
return parse(source, {
|
|
27
|
+
sourceType: 'unambiguous', plugins, errorRecovery: true,
|
|
28
|
+
allowAwaitOutsideFunction: true, allowReturnOutsideFunction: true, allowImportExportEverywhere: true,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function walk(node, visit, parent = null) {
|
|
33
|
+
if (!node || typeof node !== 'object') return;
|
|
34
|
+
if (Array.isArray(node)) { for (const n of node) walk(n, visit, parent); return; }
|
|
35
|
+
if (typeof node.type !== 'string') return;
|
|
36
|
+
visit(node, parent);
|
|
37
|
+
for (const [k, v] of Object.entries(node)) if (!SKIP_KEYS.has(k)) walk(v, visit, node);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const isRequire = (n) => n && n.type === 'CallExpression' && n.callee.type === 'Identifier' && n.callee.name === 'require'
|
|
41
|
+
&& n.arguments[0] && n.arguments[0].type === 'StringLiteral';
|
|
42
|
+
const propName = (n) => (n.type === 'Identifier' ? n.name : n.type === 'StringLiteral' ? n.value : null);
|
|
43
|
+
|
|
44
|
+
// a.b.c(...) → { root: 'a', path: ['b', 'c'] }; null si la cadena no es estática.
|
|
45
|
+
function chainOf(node) {
|
|
46
|
+
const path = [];
|
|
47
|
+
let cur = node;
|
|
48
|
+
while (cur && (cur.type === 'MemberExpression' || cur.type === 'OptionalMemberExpression')) {
|
|
49
|
+
const name = cur.computed ? (cur.property.type === 'StringLiteral' ? cur.property.value : null) : propName(cur.property);
|
|
50
|
+
if (name === null) return null;
|
|
51
|
+
path.unshift(name);
|
|
52
|
+
cur = cur.object;
|
|
53
|
+
}
|
|
54
|
+
if (cur && cur.type === 'Identifier') return { root: cur.name, path };
|
|
55
|
+
// this.afip.ElectronicBilling.createVoucher → raíz "this.afip" (cliente guardado en la clase)
|
|
56
|
+
if (cur && cur.type === 'ThisExpression' && path.length) return { root: `this.${path[0]}`, path: path.slice(1) };
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function scanJs(source, file) {
|
|
61
|
+
const result = { sdk: [], http: [], unresolved: [], models: [], imports: new Set() };
|
|
62
|
+
let ast;
|
|
63
|
+
try {
|
|
64
|
+
ast = parseJs(source, file);
|
|
65
|
+
} catch {
|
|
66
|
+
return result;
|
|
67
|
+
}
|
|
68
|
+
const bindings = new Map(); // nombre → { pkg, kind: 'module'|'named'|'client'|'http', imported?, prefix?, base? }
|
|
69
|
+
const constants = new Map(); // nombre → string resuelto (para armar URLs)
|
|
70
|
+
const bind = (name, info) => { if (name) bindings.set(name, info); };
|
|
71
|
+
|
|
72
|
+
// Texto estático de una expresión: literales, plantillas y constantes del archivo; '{}' donde no se sabe.
|
|
73
|
+
const staticText = (expr) => {
|
|
74
|
+
if (!expr) return null;
|
|
75
|
+
if (expr.type === 'StringLiteral') return expr.value;
|
|
76
|
+
if (expr.type === 'TemplateLiteral') {
|
|
77
|
+
return expr.quasis.map((q, i) => q.value.cooked + (i < expr.expressions.length ? (staticText(expr.expressions[i]) ?? '{}') : '')).join('');
|
|
78
|
+
}
|
|
79
|
+
if (expr.type === 'Identifier' && constants.has(expr.name)) return constants.get(expr.name);
|
|
80
|
+
if (expr.type === 'BinaryExpression' && expr.operator === '+') {
|
|
81
|
+
const l = staticText(expr.left);
|
|
82
|
+
const r = staticText(expr.right);
|
|
83
|
+
return (l ?? '{}') + (r ?? '{}');
|
|
84
|
+
}
|
|
85
|
+
return null;
|
|
86
|
+
};
|
|
87
|
+
const objProp = (obj, name) => obj && obj.type === 'ObjectExpression'
|
|
88
|
+
&& obj.properties.find((p) => (p.type === 'ObjectProperty' || p.type === 'Property') && !p.computed && propName(p.key) === name);
|
|
89
|
+
const objString = (obj, name) => { const p = objProp(obj, name); return p ? staticText(p.value) : null; };
|
|
90
|
+
|
|
91
|
+
// Instancias: new Stripe(key), twilio(sid, token), new Payment(client), axios.create({ baseURL }).
|
|
92
|
+
// → binding nuevo, o null si la expresión no crea un cliente de un SDK conocido en el archivo.
|
|
93
|
+
const instanceBinding = (expr) => {
|
|
94
|
+
let init = expr;
|
|
95
|
+
if (init && init.type === 'AwaitExpression') init = init.argument;
|
|
96
|
+
if (!init || (init.type !== 'NewExpression' && init.type !== 'CallExpression')) return null;
|
|
97
|
+
if (init.callee.type === 'Identifier' && bindings.has(init.callee.name)) {
|
|
98
|
+
const b = bindings.get(init.callee.name);
|
|
99
|
+
if (b.kind === 'named') return { pkg: b.pkg, kind: 'client', prefix: b.imported };
|
|
100
|
+
return { pkg: b.pkg, kind: 'client', prefix: b.prefix || '' };
|
|
101
|
+
}
|
|
102
|
+
const ch = init.callee.type === 'MemberExpression' ? chainOf(init.callee) : null;
|
|
103
|
+
if (ch && bindings.has(ch.root)) {
|
|
104
|
+
const b = bindings.get(ch.root);
|
|
105
|
+
const last = ch.path[ch.path.length - 1];
|
|
106
|
+
if ((b.pkg === 'axios' && last === 'create') || (b.pkg === 'got' && last === 'extend')) {
|
|
107
|
+
const cfg = init.arguments[0];
|
|
108
|
+
return { pkg: b.pkg, kind: 'http', base: objString(cfg, 'baseURL') ?? objString(cfg, 'prefixUrl') };
|
|
109
|
+
}
|
|
110
|
+
if (init.type === 'NewExpression') return { pkg: b.pkg, kind: 'client', prefix: [b.prefix, ...ch.path].filter(Boolean).join('.') };
|
|
111
|
+
}
|
|
112
|
+
return null;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
// 1) Imports, require, constantes e instancias (en orden de aparición).
|
|
116
|
+
walk(ast, (node) => {
|
|
117
|
+
// Cliente guardado en una clase: `private afip = new Afip(...)` o `this.afip = new Afip(...)` en el constructor.
|
|
118
|
+
if ((node.type === 'ClassProperty' || node.type === 'PropertyDefinition') && !node.computed && node.key.type === 'Identifier' && node.value) {
|
|
119
|
+
const b = instanceBinding(node.value);
|
|
120
|
+
if (b) bind(`this.${node.key.name}`, b);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (node.type === 'AssignmentExpression' && node.left.type === 'MemberExpression' && node.left.object.type === 'ThisExpression'
|
|
124
|
+
&& !node.left.computed && node.left.property.type === 'Identifier') {
|
|
125
|
+
const b = instanceBinding(node.right);
|
|
126
|
+
if (b) bind(`this.${node.left.property.name}`, b);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
if (node.type === 'ImportDeclaration' && node.importKind !== 'type') {
|
|
130
|
+
const pkg = packageOf(node.source.value);
|
|
131
|
+
if (!pkg) return;
|
|
132
|
+
result.imports.add(pkg);
|
|
133
|
+
for (const s of node.specifiers) {
|
|
134
|
+
if (s.importKind === 'type') continue;
|
|
135
|
+
if (s.type === 'ImportSpecifier') bind(s.local.name, { pkg, kind: 'named', imported: propName(s.imported) });
|
|
136
|
+
else bind(s.local.name, { pkg, kind: 'module' });
|
|
137
|
+
}
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (node.type !== 'VariableDeclarator' || !node.init) return;
|
|
141
|
+
let init = node.init;
|
|
142
|
+
if (init.type === 'AwaitExpression') init = init.argument;
|
|
143
|
+
const id = node.id;
|
|
144
|
+
// const BASE = 'https://...'
|
|
145
|
+
if (id.type === 'Identifier' && (init.type === 'StringLiteral' || init.type === 'TemplateLiteral' || init.type === 'BinaryExpression')) {
|
|
146
|
+
const t = staticText(init);
|
|
147
|
+
if (t !== null) constants.set(id.name, t);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
// require('x'), require('x').Y y require('x')(...)
|
|
151
|
+
if (isRequire(init) || (init.type === 'MemberExpression' && isRequire(init.object)) || (init.type === 'CallExpression' && isRequire(init.callee))) {
|
|
152
|
+
const req = isRequire(init) ? init : init.type === 'MemberExpression' ? init.object : init.callee;
|
|
153
|
+
const pkg = packageOf(req.arguments[0].value);
|
|
154
|
+
if (!pkg) return;
|
|
155
|
+
result.imports.add(pkg);
|
|
156
|
+
if (init.type === 'CallExpression' && init !== req) return bind(id.name, { pkg, kind: 'client', prefix: '' });
|
|
157
|
+
if (init.type === 'MemberExpression') return bind(id.name, { pkg, kind: 'named', imported: propName(init.property) });
|
|
158
|
+
if (id.type === 'Identifier') return bind(id.name, { pkg, kind: 'module' });
|
|
159
|
+
if (id.type === 'ObjectPattern') {
|
|
160
|
+
for (const p of id.properties) {
|
|
161
|
+
if (p.type === 'ObjectProperty' && p.value.type === 'Identifier') bind(p.value.name, { pkg, kind: 'named', imported: propName(p.key) });
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (id.type !== 'Identifier') return;
|
|
167
|
+
const b = instanceBinding(init);
|
|
168
|
+
if (b) bind(id.name, b);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
const usesOpenAI = result.imports.has('openai');
|
|
172
|
+
|
|
173
|
+
const recordHttp = (client, method, urlExpr, line, base = null) => {
|
|
174
|
+
let url = staticText(urlExpr);
|
|
175
|
+
if (url !== null && base && !/^https?:\/\//i.test(url)) url = base.replace(/\/+$/, '') + '/' + url.replace(/^\/+/, '');
|
|
176
|
+
if (url === null || url.startsWith('{}')) {
|
|
177
|
+
result.unresolved.push({ kind: 'http', client, line, reason: 'URL dinámica: no se puede saber a qué host va' });
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
const m = url.match(/^https?:\/\/([^/?#{}]+)(\/[^?#]*)?/i);
|
|
181
|
+
if (!m) return; // ruta relativa: es el backend propio, no una API de terceros
|
|
182
|
+
// Sin el puerto, también cuando es dinámico: http://localhost:${PORT} → "localhost:" → "localhost".
|
|
183
|
+
const host = m[1].toLowerCase().replace(/:.*$/, '');
|
|
184
|
+
if (isInternalHost(host)) return;
|
|
185
|
+
result.http.push({ client, method: (method || 'GET').toUpperCase(), host, path: (m[2] || '/').replace(/\{\}[^/]*/g, '{}'), line });
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
// 2) Llamadas.
|
|
189
|
+
walk(ast, (node) => {
|
|
190
|
+
if (node.type === 'StringLiteral' || (node.type === 'TemplateLiteral' && node.expressions.length === 0)) {
|
|
191
|
+
const value = node.type === 'StringLiteral' ? node.value : node.quasis[0].value.cooked;
|
|
192
|
+
for (const [re, provider, opts] of MODEL_PATTERNS) {
|
|
193
|
+
if (re.test(value) && (!opts || (opts.needs === 'openai' && usesOpenAI))) {
|
|
194
|
+
result.models.push({ id: value, provider, line: node.loc.start.line });
|
|
195
|
+
break;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
if (node.type !== 'CallExpression' && node.type !== 'OptionalCallExpression') return;
|
|
201
|
+
const line = node.loc.start.line;
|
|
202
|
+
const callee = node.callee;
|
|
203
|
+
const [a0, a1] = node.arguments;
|
|
204
|
+
|
|
205
|
+
// fetch global, o importado de node-fetch / undici / cross-fetch
|
|
206
|
+
if (callee.type === 'Identifier') {
|
|
207
|
+
const b = bindings.get(callee.name);
|
|
208
|
+
if ((!b && callee.name === 'fetch') || (b && FETCH_PACKAGES.has(b.pkg) && (b.kind === 'module' || b.imported === 'fetch'))) {
|
|
209
|
+
return recordHttp(b ? b.pkg : 'fetch', objString(a1, 'method'), a0, line);
|
|
210
|
+
}
|
|
211
|
+
if (b && b.pkg === 'undici' && b.imported === 'request') return recordHttp('undici', objString(a1, 'method'), a0, line);
|
|
212
|
+
if (b && (b.pkg === 'axios' || b.pkg === 'got') && (b.kind === 'module' || b.kind === 'http')) {
|
|
213
|
+
if (a0 && a0.type === 'ObjectExpression') return recordHttp(b.pkg, objString(a0, 'method'), objProp(a0, 'url')?.value, line, objString(a0, 'baseURL') ?? b.base);
|
|
214
|
+
return recordHttp(b.pkg, objString(a1, 'method'), a0, line, b.base);
|
|
215
|
+
}
|
|
216
|
+
if (b && b.kind === 'named') result.sdk.push({ pkg: b.pkg, op: b.imported, line });
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
const ch = chainOf(callee);
|
|
220
|
+
if (!ch || !bindings.has(ch.root)) return;
|
|
221
|
+
const b = bindings.get(ch.root);
|
|
222
|
+
const method = ch.path[ch.path.length - 1];
|
|
223
|
+
if (['axios', 'got', 'ky', 'superagent'].includes(b.pkg) || b.kind === 'http') {
|
|
224
|
+
if (ch.path.length === 1 && HTTP_METHODS.includes(method)) return recordHttp(b.pkg, method, a0, line, b.base);
|
|
225
|
+
if (ch.path.length === 1 && method === 'request' && a0 && a0.type === 'ObjectExpression') {
|
|
226
|
+
return recordHttp(b.pkg, objString(a0, 'method'), objProp(a0, 'url')?.value, line, objString(a0, 'baseURL') ?? b.base);
|
|
227
|
+
}
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
if (FETCH_PACKAGES.has(b.pkg)) return;
|
|
231
|
+
const op = [b.kind === 'named' ? b.imported : b.prefix, ...ch.path].filter(Boolean).join('.');
|
|
232
|
+
if (op) result.sdk.push({ pkg: b.pkg, op, line });
|
|
233
|
+
});
|
|
234
|
+
return result;
|
|
235
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Qué es cada dependencia y cada host: SDK de una API, cliente HTTP o ninguna de las dos.
|
|
2
|
+
// La fuente de verdad es data/known-sdks.json (datos, no código: se amplía sin tocar esto).
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
|
|
7
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
export const KNOWN = JSON.parse(fs.readFileSync(path.join(HERE, '..', '..', 'data', 'known-sdks.json'), 'utf8'));
|
|
9
|
+
|
|
10
|
+
// Palabras que, en el nombre de un paquete, sugieren que es el cliente de una API.
|
|
11
|
+
const NAME_HINT = /(^|[-_./@])(api|sdk|client)([-_./]|$)/i;
|
|
12
|
+
|
|
13
|
+
// Clasifica un paquete de un ecosistema (npm, composer, pypi).
|
|
14
|
+
// → { kind: 'sdk', provider, category, confidence, source } | { kind: 'http-client' } | null
|
|
15
|
+
export function classifyPackage(ecosystem, name) {
|
|
16
|
+
const curated = KNOWN[ecosystem]?.[name];
|
|
17
|
+
if (curated) return { kind: 'sdk', ...curated, confidence: 'alta', source: 'curated' };
|
|
18
|
+
// Antes que los prefijos de familias: @aws-sdk/types es de la familia de AWS pero son solo tipos.
|
|
19
|
+
if (KNOWN.notApis.prefixes.some((p) => isFamily(name, p))) return null;
|
|
20
|
+
if (ecosystem === 'npm') {
|
|
21
|
+
for (const [prefix, info] of Object.entries(KNOWN.npmPrefixes)) {
|
|
22
|
+
if (name.startsWith(prefix)) return { kind: 'sdk', ...info, confidence: 'alta', source: 'curated' };
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
if ((KNOWN.httpClients[ecosystem] || []).includes(name)) return { kind: 'http-client' };
|
|
26
|
+
// Heurística: el nombre dice api/sdk/client. Confianza baja hasta que se vea usado (el inventario la sube).
|
|
27
|
+
if (NAME_HINT.test(name)) return { kind: 'sdk', provider: null, category: null, confidence: 'baja', source: 'heuristic' };
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ¿`name` es de la familia `p`? "@types/" y "babel-" son prefijos; "pg" es "pg" o "pg-*", pero no "pgsql"
|
|
32
|
+
// (con nombres cortos como "ws", un prefijo suelto se llevaría puesto un "wsfe-sdk").
|
|
33
|
+
function isFamily(name, p) {
|
|
34
|
+
if (p.endsWith('/') || p.endsWith('-')) return name.startsWith(p);
|
|
35
|
+
return name === p || name.startsWith(`${p}-`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Paquete de un import: 'openai/resources' → 'openai'; '@aws-sdk/client-s3/dist' → '@aws-sdk/client-s3'.
|
|
39
|
+
export function packageOf(spec) {
|
|
40
|
+
if (!spec || spec.startsWith('.') || spec.startsWith('/') || spec.startsWith('node:')) return null;
|
|
41
|
+
const parts = spec.split('/');
|
|
42
|
+
return spec.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Proveedor de un host, por sufijo: 'sheets.googleapis.com' → Google APIs.
|
|
46
|
+
export function providerForHost(host) {
|
|
47
|
+
if (!host) return null;
|
|
48
|
+
const h = host.toLowerCase();
|
|
49
|
+
for (const [known, info] of Object.entries(KNOWN.hosts)) {
|
|
50
|
+
if (h === known || h.endsWith(`.${known}`)) return info;
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Hosts que no son APIs de terceros: la propia máquina o la red local.
|
|
56
|
+
export function isInternalHost(host) {
|
|
57
|
+
const h = (host || '').toLowerCase();
|
|
58
|
+
return !h || h === 'localhost' || h.endsWith('.localhost') || h.endsWith('.local') || h.endsWith('.internal')
|
|
59
|
+
|| /^127\./.test(h) || h === '0.0.0.0' || h === '[::1]' || /^10\./.test(h) || /^192\.168\./.test(h);
|
|
60
|
+
}
|
package/src/ledger.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Registro append-only de eventos. En el producto real esto se sube (anonimizado) y es lo que
|
|
2
|
+
// hace que la base de packs mejore: qué fixes se mergean, cuáles se editan y cuáles se rechazan.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
export function record(root, event) {
|
|
7
|
+
const dir = path.join(root, '.parche');
|
|
8
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
9
|
+
fs.appendFileSync(path.join(dir, 'ledger.jsonl'), JSON.stringify({ at: new Date().toISOString(), ...event }) + '\n');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function read(root) {
|
|
13
|
+
const f = path.join(root, '.parche', 'ledger.jsonl');
|
|
14
|
+
if (!fs.existsSync(f)) return [];
|
|
15
|
+
return fs.readFileSync(f, 'utf8').trim().split('\n').filter(Boolean).map((l) => JSON.parse(l));
|
|
16
|
+
}
|