parche-cli 0.1.1 → 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/README.md +83 -13
- package/bin/parche.js +144 -42
- package/data/known-sdks.json +125 -0
- package/package.json +7 -3
- package/packs/ai.model-retirements.json +67 -15
- package/packs/arca.wsfe.condicion-iva-receptor.json +7 -5
- package/src/ast.js +21 -1
- 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 +105 -66
- package/src/context.js +93 -0
- package/src/detectors/ai-model-retirements.js +34 -19
- package/src/engine.js +26 -22
- package/src/git.js +42 -6
- package/src/inventory/index.js +133 -0
- package/src/inventory/js-scan.js +235 -0
- package/src/inventory/known.js +60 -0
- package/src/packs.js +21 -5
- package/src/paths.js +37 -0
- package/src/php.js +52 -0
- package/src/report.js +70 -17
- 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 +64 -8
package/src/report.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
// Reporte del scan y cuerpo del PR, en castellano.
|
|
2
|
-
|
|
2
|
+
import { apiLabel } from './inventory/index.js';
|
|
3
|
+
|
|
4
|
+
const LABEL ={
|
|
3
5
|
auto: '✅ automático',
|
|
4
6
|
'needs-review': '👀 revisar',
|
|
5
7
|
'needs-input': '✋ falta un dato',
|
|
@@ -26,16 +28,52 @@ export function counts(findings) {
|
|
|
26
28
|
return c;
|
|
27
29
|
}
|
|
28
30
|
|
|
29
|
-
|
|
31
|
+
function upgradesSection(upgrades) {
|
|
32
|
+
const out = ['## Versiones nuevas de tus SDKs de npm', '',
|
|
33
|
+
'Comparación de la API pública (tipos `.d.ts`) entre la versión instalada y la última publicada, cruzada con lo que usa este repo. Solo avisa: no cambia código.', '',
|
|
34
|
+
'| SDK | Instalada → última | Resultado |', '|---|---|---|'];
|
|
35
|
+
const STATUS = {
|
|
36
|
+
afectado: (r) => `⚠ ${r.affected.length} operación(es) del repo cambian o desaparecen`,
|
|
37
|
+
'sin-impacto': (r) => `✓ lo que usás no cambia (${r.totalChanges} cambios en el SDK)`,
|
|
38
|
+
'al-dia': () => '✓ al día',
|
|
39
|
+
'sin-verificar': () => '? tus operaciones no aparecen en los tipos instalados',
|
|
40
|
+
};
|
|
41
|
+
for (const r of upgrades) {
|
|
42
|
+
const res = STATUS[r.status] ? STATUS[r.status](r) : `— ${r.error || 'sin versión instalada'}`;
|
|
43
|
+
out.push(`| \`${r.package}\` | ${r.installed ? `${r.installed} → ${r.latest}` : '—'} | ${res} |`);
|
|
44
|
+
}
|
|
45
|
+
out.push('');
|
|
46
|
+
for (const r of upgrades.filter((x) => x.status === 'afectado')) {
|
|
47
|
+
out.push(`### \`${r.package}\` ${r.installed} → ${r.latest}`, '');
|
|
48
|
+
for (const a of r.affected) {
|
|
49
|
+
out.push(`- \`${a.op}\` (${a.locations.map((l) => `\`${l.file}:${l.line}\``).join(', ')}): ${a.changes.map((c) => c.message).join(' ')}`);
|
|
50
|
+
}
|
|
51
|
+
out.push('');
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function scanReport({ inventory, findings, packs, changed, upgrades = null }, today = new Date()) {
|
|
30
57
|
const out = [];
|
|
31
58
|
out.push(`# Parche — reporte de \`${inventory.repo}\``, '');
|
|
32
59
|
out.push('## APIs que usa este repo', '');
|
|
33
|
-
|
|
34
|
-
if (
|
|
35
|
-
|
|
36
|
-
out.push(
|
|
60
|
+
out.push('Todas las APIs de terceros que se encontraron en el código, tengan pack de Parche o no.', '');
|
|
61
|
+
if (inventory.apis.length) {
|
|
62
|
+
const KIND = { sdk: 'SDK', http: 'HTTP directo', 'ai-model': 'Modelos de IA' };
|
|
63
|
+
out.push('| Tipo | API | Rubro | Confianza | Operaciones (usos) | Pack |', '|---|---|---|---|---|---|');
|
|
64
|
+
for (const api of inventory.apis) {
|
|
65
|
+
const ops = api.operations.map((o) => `\`${o.op}\` ×${o.count}`).join(', ') || 'declarada, sin usos encontrados';
|
|
66
|
+
out.push(`| ${KIND[api.kind]} | ${apiLabel(api)} | ${api.category || '—'} | ${api.confidence} | ${ops} | ${api.packs.length ? 'sí' : '—'} |`);
|
|
67
|
+
}
|
|
68
|
+
out.push('');
|
|
69
|
+
} else {
|
|
70
|
+
out.push('No se encontraron APIs de terceros.', '');
|
|
37
71
|
}
|
|
38
|
-
|
|
72
|
+
if (inventory.unresolved.length) {
|
|
73
|
+
out.push('**Llamadas HTTP con URL dinámica** (no se puede saber a qué host van):', '',
|
|
74
|
+
...inventory.unresolved.map((u) => `- \`${u.file}:${u.line}\` (${u.client})`), '');
|
|
75
|
+
}
|
|
76
|
+
if (upgrades) out.push(...upgradesSection(upgrades));
|
|
39
77
|
for (const pack of packs) {
|
|
40
78
|
const fs = findings.filter((f) => f.pack === pack.id);
|
|
41
79
|
if (!fs.length) continue;
|
|
@@ -61,28 +99,43 @@ export function prBody({ pack, findings, changed, verification }, today = new Da
|
|
|
61
99
|
out.push(`## ${pack.title}`, '');
|
|
62
100
|
out.push(`> **${pack.provider}** · severidad ${SEVERITY[pack.severity]} · fecha límite: ${deadlineText(pack, today)}`, '');
|
|
63
101
|
out.push('### Qué cambió', '', pack.why_es, '');
|
|
64
|
-
|
|
102
|
+
// Sin cambios de código el texto se usa como issue: los títulos no pueden hablar de "este PR".
|
|
103
|
+
const asIssue = !changed.length;
|
|
104
|
+
out.push(asIssue ? '### Qué encontró Parche' : '### Qué hace este PR', '');
|
|
65
105
|
const applied = fs.filter((f) => f.status === 'auto' || f.status === 'needs-review');
|
|
66
106
|
out.push(changed.length
|
|
67
107
|
? `Modifica ${changed.length} archivo(s): ${changed.map((x) => `\`${x.file}\``).join(', ')}.`
|
|
68
|
-
: 'No
|
|
108
|
+
: 'No hay un arreglo automático seguro: esto es lo que hay que resolver a mano.', '');
|
|
69
109
|
for (const f of applied) out.push(`- ${LABEL[f.status]} \`${f.file}:${f.line}\` — ${f.message}`);
|
|
70
110
|
const pending = fs.filter((f) => f.status === 'needs-input' || f.status === 'blocked');
|
|
71
111
|
if (pending.length) {
|
|
72
|
-
out.push('', '### Lo que este PR NO resuelve (necesita una persona)', '');
|
|
112
|
+
out.push('', asIssue ? '### Lo que hay que resolver (necesita una persona)' : '### Lo que este PR NO resuelve (necesita una persona)', '');
|
|
73
113
|
for (const f of pending) {
|
|
74
114
|
out.push(`- ${LABEL[f.status]} \`${f.file}:${f.line}\` — ${f.message}`);
|
|
75
115
|
if (f.suggestion) out.push(` - Agregá: \`${f.suggestion}\``);
|
|
76
116
|
}
|
|
77
117
|
}
|
|
78
|
-
|
|
79
|
-
if (
|
|
80
|
-
out.push(
|
|
81
|
-
if (verification
|
|
82
|
-
|
|
83
|
-
|
|
118
|
+
// Un issue no cambia código: no hay verificación que mostrar (antes decía "Sin verificar").
|
|
119
|
+
if (!asIssue) {
|
|
120
|
+
out.push('', '### Verificación', '');
|
|
121
|
+
if (verification) {
|
|
122
|
+
out.push(`- Sintaxis de los archivos modificados: ${verification.parse.ok ? '✅ OK' : '❌ ' + verification.parse.errors.join('; ')}`);
|
|
123
|
+
const t = verification.tests;
|
|
124
|
+
if (t?.notRun) {
|
|
125
|
+
// Lo que escribió a mano el frente B en el primer PR real (obraya), ahora lo dice Parche.
|
|
126
|
+
out.push(t.ci.length
|
|
127
|
+
? `- Tests del repo: Parche no los corrió; los corre el CI de este repo (${t.ci.map((w) => `\`${w}\``).join(', ')}) en este PR.`
|
|
128
|
+
: '- Tests del repo: Parche no los corrió (sin `--verify` no ejecuta código del repo). Corrélos antes de mergear.');
|
|
129
|
+
} else if (t) {
|
|
130
|
+
out.push(t.skipped
|
|
131
|
+
? '- Tests del repo: — no hay tests para correr; se validó la sintaxis de los archivos modificados.'
|
|
132
|
+
: `- Tests del repo (${t.commands.map((c) => `\`${c}\``).join(' y ')}): ${t.ok ? '✅ pasan' : '❌ fallan'}`);
|
|
133
|
+
}
|
|
134
|
+
} else {
|
|
135
|
+
out.push('- Sin verificar (corré `parche pr --verify`).');
|
|
136
|
+
}
|
|
84
137
|
}
|
|
85
|
-
out.push('', '### Checklist antes de mergear', '', ...pack.review_checklist_es.map((x) => `- [ ] ${x}`), '');
|
|
138
|
+
out.push('', asIssue ? '### Checklist' : '### Checklist antes de mergear', '', ...pack.review_checklist_es.map((x) => `- [ ] ${x}`), '');
|
|
86
139
|
out.push('### Fuentes', '', ...pack.sources.map((s) => `- [${s.title}](${s.url})`), '');
|
|
87
140
|
out.push(`<sub>Pack \`${pack.id}\` v${pack.version}, verificado el ${pack.last_verified}. Resumen: ${Object.entries(c).map(([k, v]) => `${LABEL[k]} ${v}`).join(' · ')}. ¿Este PR te sirvió? Mergealo, editalo o cerralo: Parche aprende de cada resultado.</sub>`, '');
|
|
88
141
|
return out.join('\n');
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// Comparación de dos superficies de un SDK (surface.js) y cruce con las operaciones que usa el repo.
|
|
2
|
+
// Todo lo que sale de acá es detect-only: avisa qué cambia, no reescribe código (nivel 1).
|
|
3
|
+
|
|
4
|
+
const NS = ['default', 'exports'];
|
|
5
|
+
|
|
6
|
+
const required = (sig) => sig.params.filter((p) => !p.optional).length;
|
|
7
|
+
const minRequired = (sigs) => Math.min(...sigs.map(required));
|
|
8
|
+
const maxParams = (sigs) => Math.max(...sigs.map((s) => s.params.length));
|
|
9
|
+
const hasRest = (sigs) => sigs.some((s) => s.params.some((p) => p.rest));
|
|
10
|
+
const short = (t) => (t.length > 80 ? `${t.slice(0, 77)}...` : t);
|
|
11
|
+
|
|
12
|
+
// Cambios entre la superficie vieja (a) y la nueva (b).
|
|
13
|
+
// → [{ ns, path, type: 'removed' | 'new-required' | 'param-removed' | 'type-changed' | 'deprecated', message }]
|
|
14
|
+
export function diffSurfaces(a, b) {
|
|
15
|
+
const changes = [];
|
|
16
|
+
for (const ns of NS) {
|
|
17
|
+
// Si la versión nueva no expone nada en este lado (pasa cuando cambia la forma del módulo, por ejemplo de
|
|
18
|
+
// exports con nombre a `export =`), no se puede distinguir "se borró" de "no se pudo leer": no se compara.
|
|
19
|
+
if (b[ns].size === 0 && a[ns].size > 0) continue;
|
|
20
|
+
const removedRoots = [];
|
|
21
|
+
for (const [p, ea] of a[ns]) {
|
|
22
|
+
if (removedRoots.some((r) => p.startsWith(`${r}.`))) continue; // ya se reportó el padre
|
|
23
|
+
const eb = b[ns].get(p);
|
|
24
|
+
if (!eb) {
|
|
25
|
+
removedRoots.push(p);
|
|
26
|
+
changes.push({ ns, path: p, type: 'removed', message: `\`${p}\` ya no existe.` });
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
if (ea.signatures && eb.signatures) changes.push(...compareSignatures(ns, p, ea.signatures, eb.signatures));
|
|
30
|
+
if (eb.deprecated && !ea.deprecated) {
|
|
31
|
+
changes.push({ ns, path: p, type: 'deprecated', message: `\`${p}\` pasó a estar deprecado${eb.deprecated !== 'deprecado' ? `: ${short(eb.deprecated)}` : '.'}` });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return changes;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function compareSignatures(ns, p, sa, sb) {
|
|
39
|
+
const out = [];
|
|
40
|
+
const reqA = minRequired(sa);
|
|
41
|
+
const reqB = minRequired(sb);
|
|
42
|
+
if (reqB > reqA) {
|
|
43
|
+
const best = sb.find((s) => required(s) === reqB);
|
|
44
|
+
const oldNames = new Set(sa.flatMap((s) => s.params.filter((x) => !x.optional).map((x) => x.name)));
|
|
45
|
+
const nuevos = best.params.filter((x) => !x.optional && !oldNames.has(x.name)).map((x) => x.name);
|
|
46
|
+
out.push({ ns, path: p, type: 'new-required',
|
|
47
|
+
message: `\`${p}\` pide más parámetros obligatorios (${reqA} → ${reqB}${nuevos.length ? `: ${nuevos.join(', ')}` : ''}).` });
|
|
48
|
+
}
|
|
49
|
+
if (!hasRest(sb) && maxParams(sb) < maxParams(sa)) {
|
|
50
|
+
const newNames = new Set(sb.flatMap((s) => s.params.map((x) => x.name)));
|
|
51
|
+
const quitados = sa.flatMap((s) => s.params.map((x) => x.name)).filter((n, i, all) => !newNames.has(n) && all.indexOf(n) === i);
|
|
52
|
+
out.push({ ns, path: p, type: 'param-removed',
|
|
53
|
+
message: `\`${p}\` acepta menos parámetros (${maxParams(sa)} → ${maxParams(sb)}${quitados.length ? `: se quitó ${quitados.join(', ')}` : ''}).` });
|
|
54
|
+
}
|
|
55
|
+
// Tipos: solo con una firma de cada lado (con sobrecargas, comparar por posición confunde más de lo que ayuda).
|
|
56
|
+
if (sa.length === 1 && sb.length === 1) {
|
|
57
|
+
const n = Math.min(sa[0].params.length, sb[0].params.length);
|
|
58
|
+
for (let i = 0; i < n; i++) {
|
|
59
|
+
const pa = sa[0].params[i];
|
|
60
|
+
const pb = sb[0].params[i];
|
|
61
|
+
if (pa.type !== pb.type) {
|
|
62
|
+
out.push({ ns, path: p, type: 'type-changed',
|
|
63
|
+
message: `En \`${p}\`, el parámetro \`${pb.name}\` cambió de tipo: \`${short(pa.type)}\` → \`${short(pb.type)}\`.` });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ¿En qué parte de la superficie vieja vive esta operación? La ruta exacta o, si no, su prefijo más largo.
|
|
71
|
+
function namespaceOf(op, surface) {
|
|
72
|
+
let best = null;
|
|
73
|
+
for (const ns of NS) {
|
|
74
|
+
const parts = op.split('.');
|
|
75
|
+
for (let i = parts.length; i > 0; i--) {
|
|
76
|
+
const pre = parts.slice(0, i).join('.');
|
|
77
|
+
if (surface[ns].has(pre)) {
|
|
78
|
+
if (!best || i > best.depth) best = { ns, depth: i };
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return best && best.ns;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Para mostrar y contar: el mismo cambio suele verse por varios caminos (la instancia del export por defecto,
|
|
87
|
+
// la clase exportada y la fábrica: clientes.borrar, Cliente.clientes.borrar, crearCliente.clientes.borrar).
|
|
88
|
+
// Se queda con el más corto. El cruce con el repo usa la lista completa, porque el repo puede usar cualquiera.
|
|
89
|
+
export function dedupeChanges(changes) {
|
|
90
|
+
const seen = new Set(changes.filter((c) => c.ns === 'default').map((c) => `${c.type}|${c.path}`));
|
|
91
|
+
const out = [];
|
|
92
|
+
for (const c of changes) {
|
|
93
|
+
if (c.ns === 'exports') {
|
|
94
|
+
const rest = c.path.split('.').slice(1).join('.');
|
|
95
|
+
if (rest && seen.has(`${c.type}|${rest}`)) continue;
|
|
96
|
+
const key = `${c.type}|${rest || c.path}`;
|
|
97
|
+
if (rest && seen.has(`x|${key}`)) continue;
|
|
98
|
+
if (rest) seen.add(`x|${key}`);
|
|
99
|
+
}
|
|
100
|
+
out.push(c);
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Operaciones del repo que no aparecen en los tipos de la versión instalada: sobre esas no se puede afirmar nada.
|
|
106
|
+
export function unverifiedOperations(operations, oldSurface) {
|
|
107
|
+
return operations.filter((op) => !namespaceOf(op, oldSurface));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Operaciones del repo (del inventario) que tocan un cambio: la misma ruta, o una ruta padre que desapareció.
|
|
111
|
+
// → [{ op, changes: [...] }] (solo las operaciones afectadas)
|
|
112
|
+
export function affectedOperations(changes, operations, oldSurface) {
|
|
113
|
+
const out = [];
|
|
114
|
+
for (const op of operations) {
|
|
115
|
+
const ns = namespaceOf(op, oldSurface);
|
|
116
|
+
if (!ns) continue; // la operación no está en los tipos de la versión instalada: no se puede afirmar nada
|
|
117
|
+
const hits = changes.filter((c) => c.ns === ns && (c.path === op || (c.type === 'removed' && op.startsWith(`${c.path}.`))));
|
|
118
|
+
if (hits.length) out.push({ op, changes: hits });
|
|
119
|
+
}
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// Acceso al registry de npm para comparar versiones de un SDK: metadata, tarballs (con verificación
|
|
2
|
+
// de integridad) y los archivos de tipos de cada versión. Todo queda cacheado en ~/.parche/cache:
|
|
3
|
+
// los tarballs son inmutables, así que una versión se baja una sola vez.
|
|
4
|
+
//
|
|
5
|
+
// PARCHE_CACHE cambia la carpeta del caché; PARCHE_OFFLINE=1 usa solo el caché (lo usan los tests).
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import os from 'node:os';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import crypto from 'node:crypto';
|
|
10
|
+
import { extractTypes } from './tar.js';
|
|
11
|
+
|
|
12
|
+
const REGISTRY = 'https://registry.npmjs.org';
|
|
13
|
+
const METADATA_TTL_MS = 60 * 60 * 1000;
|
|
14
|
+
|
|
15
|
+
export function defaultCacheDir() {
|
|
16
|
+
return process.env.PARCHE_CACHE || path.join(os.homedir(), '.parche', 'cache');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const safeName = (pkg) => pkg.replace('/', '__');
|
|
20
|
+
|
|
21
|
+
export function createRegistry({ cacheDir = defaultCacheDir(), fetch = globalThis.fetch, offline = process.env.PARCHE_OFFLINE === '1' } = {}) {
|
|
22
|
+
const dirFor = (pkg) => path.join(cacheDir, safeName(pkg));
|
|
23
|
+
|
|
24
|
+
async function metadata(pkg) {
|
|
25
|
+
const file = path.join(dirFor(pkg), 'metadata.json');
|
|
26
|
+
if (fs.existsSync(file)) {
|
|
27
|
+
const fresh = Date.now() - fs.statSync(file).mtimeMs < METADATA_TTL_MS;
|
|
28
|
+
if (offline || fresh) return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
29
|
+
}
|
|
30
|
+
if (offline) throw new Error(`Sin conexión y sin metadata de ${pkg} en el caché (${file}).`);
|
|
31
|
+
const res = await fetch(`${REGISTRY}/${pkg.replace('/', '%2F')}`, { headers: { Accept: 'application/json' } });
|
|
32
|
+
if (!res.ok) throw new Error(`El registry respondió ${res.status} para ${pkg}.`);
|
|
33
|
+
const json = await res.json();
|
|
34
|
+
// Solo lo que hace falta: versiones con su tarball e integridad, y los dist-tags.
|
|
35
|
+
const slim = {
|
|
36
|
+
name: json.name,
|
|
37
|
+
'dist-tags': json['dist-tags'] || {},
|
|
38
|
+
versions: Object.fromEntries(Object.entries(json.versions || {}).map(([v, m]) => [v, { dist: { tarball: m.dist?.tarball, integrity: m.dist?.integrity } }])),
|
|
39
|
+
};
|
|
40
|
+
fs.mkdirSync(dirFor(pkg), { recursive: true });
|
|
41
|
+
fs.writeFileSync(file, JSON.stringify(slim));
|
|
42
|
+
return slim;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function tarball(pkg, version) {
|
|
46
|
+
const file = path.join(dirFor(pkg), `${version}.tgz`);
|
|
47
|
+
if (fs.existsSync(file)) return fs.readFileSync(file);
|
|
48
|
+
if (offline) throw new Error(`Sin conexión y sin ${pkg}@${version} en el caché (${file}).`);
|
|
49
|
+
const meta = await metadata(pkg);
|
|
50
|
+
const dist = meta.versions[version]?.dist;
|
|
51
|
+
if (!dist?.tarball) throw new Error(`${pkg}@${version} no existe en el registry.`);
|
|
52
|
+
const res = await fetch(dist.tarball);
|
|
53
|
+
if (!res.ok) throw new Error(`No se pudo bajar ${pkg}@${version} (${res.status}).`);
|
|
54
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
55
|
+
// Integridad: el hash tiene que coincidir con el que publica el registry.
|
|
56
|
+
if (dist.integrity) {
|
|
57
|
+
const [algo, expected] = dist.integrity.split(/-(.*)/s);
|
|
58
|
+
const actual = crypto.createHash(algo).update(buf).digest('base64');
|
|
59
|
+
if (actual !== expected) throw new Error(`La integridad de ${pkg}@${version} no coincide: no se usa.`);
|
|
60
|
+
}
|
|
61
|
+
fs.mkdirSync(dirFor(pkg), { recursive: true });
|
|
62
|
+
fs.writeFileSync(file, buf);
|
|
63
|
+
return buf;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Archivos de tipos de una versión: los propios del paquete o, si no tiene, los de @types/<paquete>
|
|
67
|
+
// de la misma versión mayor. → { files: Map, entry, source: 'paquete' | '@types/...' } | null
|
|
68
|
+
async function types(pkg, version) {
|
|
69
|
+
const own = extractTypes(await tarball(pkg, version));
|
|
70
|
+
const entry = typesEntry(own);
|
|
71
|
+
if (entry) return { files: own, entry, source: pkg };
|
|
72
|
+
const typesPkg = `@types/${pkg.startsWith('@') ? pkg.slice(1).replace('/', '__') : pkg}`;
|
|
73
|
+
let meta;
|
|
74
|
+
try {
|
|
75
|
+
meta = await metadata(typesPkg);
|
|
76
|
+
} catch {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
const major = version.split('.')[0];
|
|
80
|
+
const candidates = Object.keys(meta.versions).filter((v) => v.split('.')[0] === major && !v.includes('-')).sort(compareVersions);
|
|
81
|
+
const pick = candidates[candidates.length - 1];
|
|
82
|
+
if (!pick) return null;
|
|
83
|
+
const files = extractTypes(await tarball(typesPkg, pick));
|
|
84
|
+
const typesEntryFile = typesEntry(files);
|
|
85
|
+
return typesEntryFile ? { files, entry: typesEntryFile, source: `${typesPkg}@${pick}` } : null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return { metadata, tarball, types };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Archivo de entrada de los tipos según el package.json (types, typings o exports) o index.d.ts.
|
|
92
|
+
export function typesEntry(files) {
|
|
93
|
+
let pkg = {};
|
|
94
|
+
try {
|
|
95
|
+
pkg = JSON.parse(files.get('package.json') || '{}');
|
|
96
|
+
} catch { /* package.json roto: se busca index.d.ts */ }
|
|
97
|
+
const fromExports = typesInExports(pkg.exports);
|
|
98
|
+
const candidates = [pkg.types, pkg.typings, fromExports, 'index.d.ts'].filter((x) => typeof x === 'string');
|
|
99
|
+
for (const c of candidates) {
|
|
100
|
+
const norm = c.replace(/^\.\//, '');
|
|
101
|
+
const withExt = /\.d\.(ts|mts|cts)$/.test(norm) ? norm : `${norm.replace(/\.(js|mjs|cjs)$/, '')}.d.ts`;
|
|
102
|
+
if (files.has(withExt)) return withExt;
|
|
103
|
+
}
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// `types` dentro de `exports`, que puede ser { ".": {...} } o directamente un objeto de condiciones
|
|
108
|
+
// anidadas (stripe 23: exports.default.require.types). Se prefiere lo de Node/CommonJS.
|
|
109
|
+
const CONDITION_ORDER = ['types', 'require', 'node', 'default', 'import'];
|
|
110
|
+
export function typesInExports(exp, depth = 0) {
|
|
111
|
+
if (!exp || typeof exp !== 'object' || depth > 5) return null;
|
|
112
|
+
if (Array.isArray(exp)) {
|
|
113
|
+
for (const e of exp) { const t = typesInExports(e, depth + 1); if (t) return t; }
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
if ('.' in exp) return typeof exp['.'] === 'string' ? null : typesInExports(exp['.'], depth + 1);
|
|
117
|
+
if (Object.keys(exp).some((k) => k.startsWith('./'))) return null; // solo subrutas: no hay entrada principal
|
|
118
|
+
if (typeof exp.types === 'string') return exp.types;
|
|
119
|
+
const keys = Object.keys(exp).sort((a, b) => {
|
|
120
|
+
const ia = CONDITION_ORDER.indexOf(a);
|
|
121
|
+
const ib = CONDITION_ORDER.indexOf(b);
|
|
122
|
+
return (ia === -1 ? 99 : ia) - (ib === -1 ? 99 : ib);
|
|
123
|
+
});
|
|
124
|
+
for (const k of keys) {
|
|
125
|
+
const t = typesInExports(exp[k], depth + 1);
|
|
126
|
+
if (t) return t;
|
|
127
|
+
}
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Comparación de versiones semver simples (x.y.z); las prereleases van antes que la estable.
|
|
132
|
+
export function compareVersions(a, b) {
|
|
133
|
+
const pa = a.split(/[.-]/);
|
|
134
|
+
const pb = b.split(/[.-]/);
|
|
135
|
+
for (let i = 0; i < 3; i++) {
|
|
136
|
+
const d = Number(pa[i] || 0) - Number(pb[i] || 0);
|
|
137
|
+
if (d) return d;
|
|
138
|
+
}
|
|
139
|
+
const preA = a.includes('-');
|
|
140
|
+
const preB = b.includes('-');
|
|
141
|
+
return preA === preB ? a.localeCompare(b) : preA ? -1 : 1;
|
|
142
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// Superficie pública de una versión de un SDK, leída de sus .d.ts con el compiler API de TypeScript.
|
|
2
|
+
//
|
|
3
|
+
// Devuelve dos mapas de ruta → entrada, con la misma forma que las operaciones del inventario (hito B):
|
|
4
|
+
// default: miembros de la instancia del export por defecto → "chat.completions.create"
|
|
5
|
+
// exports: exports con nombre y sus miembros → "OpenAIApi.createChatCompletion"
|
|
6
|
+
// Cada entrada: { kind, deprecated (texto o null), signatures: [{ params: [{ name, optional, rest, type }] }] }.
|
|
7
|
+
//
|
|
8
|
+
// TypeScript (24 MB) se carga recién acá, para que `parche scan` común no lo pague.
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { createRequire } from 'node:module';
|
|
12
|
+
|
|
13
|
+
const require = createRequire(import.meta.url);
|
|
14
|
+
let tsModule = null;
|
|
15
|
+
const loadTs = () => (tsModule ||= require('typescript'));
|
|
16
|
+
|
|
17
|
+
const MAX_PROPS = 400;
|
|
18
|
+
const TYPE_MAX = 400;
|
|
19
|
+
|
|
20
|
+
export function extractSurface({ files, entry, pkgName, maxDepth = 4 }) {
|
|
21
|
+
const ts = loadTs();
|
|
22
|
+
const options = {
|
|
23
|
+
noEmit: true, skipLibCheck: true, types: [], strict: false,
|
|
24
|
+
target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS, moduleResolution: ts.ModuleResolutionKind.Node10,
|
|
25
|
+
};
|
|
26
|
+
// Sistema de archivos virtual: el paquete vive en /node_modules/<pkg>/; las libs de TypeScript, en disco.
|
|
27
|
+
const base = `/node_modules/${pkgName}/`;
|
|
28
|
+
const virtual = new Map([...files].map(([p, c]) => [base + p, c]));
|
|
29
|
+
const norm = (f) => f.replace(/\\/g, '/');
|
|
30
|
+
const libDir = norm(path.dirname(ts.getDefaultLibFilePath(options)));
|
|
31
|
+
const isLib = (f) => norm(f).startsWith(libDir);
|
|
32
|
+
const dirs = new Set();
|
|
33
|
+
for (const p of virtual.keys()) for (let d = path.posix.dirname(p); d !== '/'; d = path.posix.dirname(d)) dirs.add(d);
|
|
34
|
+
const host = {
|
|
35
|
+
fileExists: (f) => virtual.has(norm(f)) || (isLib(f) && fs.existsSync(f)),
|
|
36
|
+
readFile: (f) => virtual.get(norm(f)) ?? (isLib(f) && fs.existsSync(f) ? fs.readFileSync(f, 'utf8') : undefined),
|
|
37
|
+
getSourceFile: (f, lang) => {
|
|
38
|
+
const text = host.readFile(f);
|
|
39
|
+
return text === undefined ? undefined : ts.createSourceFile(f, text, lang, true);
|
|
40
|
+
},
|
|
41
|
+
directoryExists: (d) => dirs.has(norm(d).replace(/\/$/, '')) || isLib(d),
|
|
42
|
+
getDirectories: () => [],
|
|
43
|
+
getDefaultLibFileName: (o) => ts.getDefaultLibFilePath(o),
|
|
44
|
+
getCurrentDirectory: () => '/',
|
|
45
|
+
getCanonicalFileName: (f) => f,
|
|
46
|
+
useCaseSensitiveFileNames: () => true,
|
|
47
|
+
getNewLine: () => '\n',
|
|
48
|
+
writeFile: () => {},
|
|
49
|
+
};
|
|
50
|
+
const entryPath = base + entry;
|
|
51
|
+
const program = ts.createProgram({ rootNames: [entryPath], options, host });
|
|
52
|
+
const checker = program.getTypeChecker();
|
|
53
|
+
const sf = program.getSourceFile(entryPath);
|
|
54
|
+
const surface = { default: new Map(), exports: new Map() };
|
|
55
|
+
if (!sf) return surface;
|
|
56
|
+
|
|
57
|
+
// El módulo: el archivo de entrada o, si declara `declare module 'pkg' { ... }`, ese módulo ambiental.
|
|
58
|
+
let mod = checker.getSymbolAtLocation(sf);
|
|
59
|
+
if (!mod) mod = checker.getAmbientModules().find((m) => m.name.replace(/^["']|["']$/g, '') === pkgName);
|
|
60
|
+
if (!mod) return surface;
|
|
61
|
+
|
|
62
|
+
const resolve = (sym) => (sym && sym.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(sym) : sym);
|
|
63
|
+
const declOf = (sym) => sym.valueDeclaration || (sym.declarations && sym.declarations[0]);
|
|
64
|
+
const typeStr = (type) => checker.typeToString(type, undefined, ts.TypeFormatFlags.NoTruncation).replace(/\s+/g, ' ').slice(0, TYPE_MAX);
|
|
65
|
+
const deprecatedOf = (sym) => {
|
|
66
|
+
const tag = sym.getJsDocTags(checker).find((t) => t.name === 'deprecated');
|
|
67
|
+
return tag ? (ts.displayPartsToString(tag.text) || 'deprecado').trim() : null;
|
|
68
|
+
};
|
|
69
|
+
const isHidden = (sym) => {
|
|
70
|
+
const name = sym.getName();
|
|
71
|
+
if (name.startsWith('#') || name.startsWith('_') || name.startsWith('__@')) return true;
|
|
72
|
+
const d = declOf(sym);
|
|
73
|
+
return !!(d && ts.getCombinedModifierFlags(d) & (ts.ModifierFlags.Private | ts.ModifierFlags.Protected));
|
|
74
|
+
};
|
|
75
|
+
const signaturesOf = (sigs) => sigs.map((sig) => ({
|
|
76
|
+
params: sig.getParameters().map((p) => {
|
|
77
|
+
const d = p.valueDeclaration;
|
|
78
|
+
const rest = !!(d && d.dotDotDotToken);
|
|
79
|
+
const optional = rest || !!(d && (d.questionToken || d.initializer)) || (d && ts.isParameter(d) && checker.isOptionalParameter(d));
|
|
80
|
+
return { name: p.getName(), optional: !!optional, rest, type: d ? typeStr(checker.getTypeOfSymbolAtLocation(p, d)) : 'any' };
|
|
81
|
+
}),
|
|
82
|
+
}));
|
|
83
|
+
// Tipos de la librería estándar (Promise, Array, Map...): no son parte del SDK, no se baja por ellos.
|
|
84
|
+
const isLibType = (type) => {
|
|
85
|
+
const s = type.aliasSymbol || type.getSymbol();
|
|
86
|
+
const decls = s && s.declarations;
|
|
87
|
+
return !!(decls && decls.length && decls.every((d) => isLib(d.getSourceFile().fileName)));
|
|
88
|
+
};
|
|
89
|
+
// ¿Es un "recurso"? Tiene métodos, o propiedades que a su vez tienen métodos (openai.chat → chat.completions.create).
|
|
90
|
+
const hasMethods = (type, depth = 2) => {
|
|
91
|
+
if (isLibType(type)) return false;
|
|
92
|
+
const props = checker.getPropertiesOfType(type);
|
|
93
|
+
if (props.length > MAX_PROPS) return false;
|
|
94
|
+
return props.some((p) => {
|
|
95
|
+
if (isHidden(p)) return false;
|
|
96
|
+
const d = declOf(p);
|
|
97
|
+
if (!d) return false;
|
|
98
|
+
const t = checker.getTypeOfSymbolAtLocation(p, d);
|
|
99
|
+
if (t.getCallSignatures().length > 0) return true;
|
|
100
|
+
return depth > 1 && !!(t.flags & ts.TypeFlags.Object) && hasMethods(t, depth - 1);
|
|
101
|
+
});
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
// Miembros de un tipo (recursivo en "recursos": objetos que a su vez tienen métodos).
|
|
105
|
+
function addMembers(map, prefix, type, depth, stack) {
|
|
106
|
+
if (depth > maxDepth || stack.has(type)) return;
|
|
107
|
+
const props = checker.getPropertiesOfType(type);
|
|
108
|
+
if (props.length > MAX_PROPS) return;
|
|
109
|
+
stack.add(type);
|
|
110
|
+
for (const p of props) {
|
|
111
|
+
if (isHidden(p)) continue;
|
|
112
|
+
const d = declOf(p);
|
|
113
|
+
if (!d) continue;
|
|
114
|
+
const pathName = prefix ? `${prefix}.${p.getName()}` : p.getName();
|
|
115
|
+
const pType = checker.getTypeOfSymbolAtLocation(p, d);
|
|
116
|
+
const sigs = pType.getCallSignatures();
|
|
117
|
+
if (sigs.length) map.set(pathName, { kind: 'method', deprecated: deprecatedOf(p), signatures: signaturesOf(sigs) });
|
|
118
|
+
else map.set(pathName, { kind: 'property', deprecated: deprecatedOf(p) });
|
|
119
|
+
// Recursos (también los invocables, como client.messages(sid) que además tiene messages.create).
|
|
120
|
+
if (pType.flags & ts.TypeFlags.Object && hasMethods(pType)) addMembers(map, pathName, pType, depth + 1, stack);
|
|
121
|
+
}
|
|
122
|
+
stack.delete(type);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Un símbolo exportado: clase (sus miembros de instancia), función, u objeto/namespace con miembros.
|
|
126
|
+
function addSymbol(map, prefix, rawSym) {
|
|
127
|
+
const sym = resolve(rawSym);
|
|
128
|
+
if (!sym) return;
|
|
129
|
+
const d = declOf(sym);
|
|
130
|
+
if (sym.flags & ts.SymbolFlags.Class) {
|
|
131
|
+
if (prefix) map.set(prefix, { kind: 'class', deprecated: deprecatedOf(sym) });
|
|
132
|
+
addMembers(map, prefix, checker.getDeclaredTypeOfSymbol(sym), 1, new Set());
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (!d || !(sym.flags & ts.SymbolFlags.Value)) return; // solo tipos: no se pueden llamar
|
|
136
|
+
const type = checker.getTypeOfSymbolAtLocation(sym, d);
|
|
137
|
+
const sigs = type.getCallSignatures();
|
|
138
|
+
if (sigs.length && prefix) map.set(prefix, { kind: 'function', deprecated: deprecatedOf(sym), signatures: signaturesOf(sigs) });
|
|
139
|
+
else if (prefix) map.set(prefix, { kind: 'property', deprecated: deprecatedOf(sym) });
|
|
140
|
+
if (type.flags & ts.TypeFlags.Object && hasMethods(type)) addMembers(map, prefix, type, 1, new Set());
|
|
141
|
+
// Fábricas: const client = twilio(sid, token) → los miembros del cliente que devuelve, en la misma ruta.
|
|
142
|
+
for (const sig of sigs.slice(0, 1)) {
|
|
143
|
+
const ret = checker.getReturnTypeOfSignature(sig);
|
|
144
|
+
if (ret.flags & ts.TypeFlags.Object && hasMethods(ret)) addMembers(map, prefix, ret, 1, new Set());
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const exported = checker.getExportsOfModule(mod);
|
|
149
|
+
for (const sym of exported) {
|
|
150
|
+
const name = sym.getName();
|
|
151
|
+
if (name === 'default') continue;
|
|
152
|
+
addSymbol(surface.exports, name, sym);
|
|
153
|
+
}
|
|
154
|
+
// Export por defecto: `export default X` o `export = X` (CommonJS).
|
|
155
|
+
const defaultSym = exported.find((s) => s.getName() === 'default')
|
|
156
|
+
|| (mod.exports && mod.exports.get('export=') ? checker.resolveExternalModuleSymbol(mod) : null);
|
|
157
|
+
if (defaultSym && defaultSym !== mod) addSymbol(surface.default, '', defaultSym);
|
|
158
|
+
return surface;
|
|
159
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Lector mínimo de tarballs de npm (.tgz), sin dependencias: gunzip + formato tar (ustar, pax y GNU).
|
|
2
|
+
// Solo devuelve los archivos que sirven para comparar APIs: package.json y declaraciones de tipos.
|
|
3
|
+
import zlib from 'node:zlib';
|
|
4
|
+
|
|
5
|
+
const WANTED = /(^|\/)package\.json$|\.d\.(ts|mts|cts)$/;
|
|
6
|
+
|
|
7
|
+
const str = (buf, start, len) => {
|
|
8
|
+
const s = buf.subarray(start, start + len);
|
|
9
|
+
const end = s.indexOf(0);
|
|
10
|
+
return s.subarray(0, end === -1 ? s.length : end).toString('utf8');
|
|
11
|
+
};
|
|
12
|
+
const octal = (buf, start, len) => parseInt(str(buf, start, len).trim() || '0', 8);
|
|
13
|
+
|
|
14
|
+
// Registros pax: "<largo> clave=valor\n"
|
|
15
|
+
function paxPath(data) {
|
|
16
|
+
for (const rec of data.toString('utf8').split('\n')) {
|
|
17
|
+
const m = rec.match(/^\d+ path=(.*)$/);
|
|
18
|
+
if (m) return m[1];
|
|
19
|
+
}
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// tgz → Map(ruta sin el "package/" inicial → contenido). Ignora todo lo que no sea tipos o package.json.
|
|
24
|
+
export function extractTypes(tgz) {
|
|
25
|
+
const tar = zlib.gunzipSync(tgz);
|
|
26
|
+
const files = new Map();
|
|
27
|
+
let offset = 0;
|
|
28
|
+
let nextName = null;
|
|
29
|
+
while (offset + 512 <= tar.length) {
|
|
30
|
+
const header = tar.subarray(offset, offset + 512);
|
|
31
|
+
if (header.every((b) => b === 0)) break; // fin del archivo
|
|
32
|
+
const size = octal(header, 124, 12);
|
|
33
|
+
const type = String.fromCharCode(header[156] || 48);
|
|
34
|
+
const prefix = str(header, 345, 155);
|
|
35
|
+
let name = nextName || (prefix ? `${prefix}/${str(header, 0, 100)}` : str(header, 0, 100));
|
|
36
|
+
nextName = null;
|
|
37
|
+
const dataStart = offset + 512;
|
|
38
|
+
const data = tar.subarray(dataStart, dataStart + size);
|
|
39
|
+
offset = dataStart + Math.ceil(size / 512) * 512;
|
|
40
|
+
if (type === 'x') { nextName = paxPath(data); continue; } // pax: el nombre largo es del próximo
|
|
41
|
+
if (type === 'L') { nextName = str(data, 0, data.length); continue; } // GNU: nombre largo
|
|
42
|
+
if (type !== '0' && type !== '\0') continue; // solo archivos comunes
|
|
43
|
+
name = name.replace(/^[^/]+\//, ''); // "package/index.d.ts" → "index.d.ts"
|
|
44
|
+
if (WANTED.test(name)) files.set(name, data.toString('utf8'));
|
|
45
|
+
}
|
|
46
|
+
return files;
|
|
47
|
+
}
|