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/packs.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Carga de packs (la base de cambios) y el registro de transformaciones.
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import arca from './codemods/arca-wsfe-condicion-iva.js';
|
|
6
|
+
import arcaPhp from './codemods/arca-wsfe-condicion-iva-php.js';
|
|
7
|
+
import mercadopago from './codemods/mercadopago-payments-to-orders.js';
|
|
8
|
+
import aiModels from './detectors/ai-model-retirements.js';
|
|
9
|
+
import { wsdlDetection, soapOnly } from './codemods/arca-rules.js';
|
|
10
|
+
import { isPhp } from './php.js';
|
|
11
|
+
|
|
12
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
export const PACKS_DIR = path.join(HERE, '..', 'packs');
|
|
14
|
+
|
|
15
|
+
// Una transformación por pack y por lenguaje. Si un pack no tiene versión para un lenguaje,
|
|
16
|
+
// ese pack no corre sobre esos archivos.
|
|
17
|
+
const TRANSFORMS = {
|
|
18
|
+
// js y php: codemod; wsdl: WSDL de WSFEv1 sin el campo; text (Python, C#, Java, Go, VB.NET): solo SOAP propio.
|
|
19
|
+
'arca.wsfe.condicion-iva-receptor': { js: arca, php: arcaPhp, wsdl: wsdlDetection, text: soapOnly },
|
|
20
|
+
'mercadopago.payments-to-orders': { js: mercadopago },
|
|
21
|
+
'ai.model-retirements': { js: aiModels, php: aiModels, text: aiModels }, // detector de texto: sirve para cualquier lenguaje
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const TEXT_ONLY = /\.(py|cs|java|go|vb)$/i;
|
|
25
|
+
|
|
26
|
+
export function languageOf(file) {
|
|
27
|
+
if (isPhp(file)) return 'php';
|
|
28
|
+
if (/\.wsdl$/i.test(file)) return 'wsdl';
|
|
29
|
+
if (TEXT_ONLY.test(file)) return 'text';
|
|
30
|
+
return 'js';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const REQUIRED_FIELDS = ['id', 'version', 'provider', 'title', 'kind', 'severity', 'deadline', 'last_verified',
|
|
34
|
+
'summary_es', 'why_es', 'sources', 'detect', 'codemod', 'review_checklist_es'];
|
|
35
|
+
|
|
36
|
+
export function validatePack(pack) {
|
|
37
|
+
const missing = REQUIRED_FIELDS.filter((f) => !(f in pack));
|
|
38
|
+
if (missing.length) throw new Error(`Pack ${pack.id || '?'}: faltan campos ${missing.join(', ')}`);
|
|
39
|
+
if (!Array.isArray(pack.sources) || pack.sources.some((s) => !s.url)) throw new Error(`Pack ${pack.id}: sources inválidas`);
|
|
40
|
+
if (!['critical', 'high', 'medium', 'low'].includes(pack.severity)) throw new Error(`Pack ${pack.id}: severity inválida`);
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function loadPacks(dir = PACKS_DIR) {
|
|
45
|
+
return fs.readdirSync(dir)
|
|
46
|
+
.filter((f) => f.endsWith('.json'))
|
|
47
|
+
.map((f) => JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8')))
|
|
48
|
+
.map((p) => (validatePack(p), p))
|
|
49
|
+
.sort((a, b) => (a.deadline || '9999').localeCompare(b.deadline || '9999'));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function transformFor(packId, file = 'x.js') {
|
|
53
|
+
const byLang = TRANSFORMS[packId];
|
|
54
|
+
return (byLang && byLang[languageOf(file)]) || null;
|
|
55
|
+
}
|
package/src/paths.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Rutas y carpetas compartidas por el motor y el inventario.
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
// Carpetas que no son código del cliente: dependencias instaladas (vendor/ es donde composer instala
|
|
6
|
+
// las de PHP, incluida la propia librería de ARCA), builds y lo que escribe Parche.
|
|
7
|
+
export const SKIP_DIRS = new Set(['node_modules', 'vendor', '.git', 'dist', 'build', 'coverage', '.next', '.parche']);
|
|
8
|
+
|
|
9
|
+
// Rutas siempre con "/": van al reporte, al cuerpo del PR y al inventario.
|
|
10
|
+
export const toPosix = (p) => p.split(path.sep).join('/');
|
|
11
|
+
|
|
12
|
+
// Tests, mocks, fixtures y ejemplos: un PR que solo toca eso no le sirve al mantenedor, y la detección
|
|
13
|
+
// por texto en esos archivos es ruido (frente B, 16/10).
|
|
14
|
+
export function isTestFile(file) {
|
|
15
|
+
return /(^|\/)(__tests__|__mocks__|tests?|specs?|mocks?|fixtures?|examples?)\//i.test(file)
|
|
16
|
+
|| /\.(test|spec|mock)\.[cm]?[jt]sx?$/i.test(file) || /Test\.php$/.test(file);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Recorre el repo (salteando SKIP_DIRS) y devuelve las rutas relativas, con "/", de los archivos que
|
|
20
|
+
// cumplen `match(nombre)`.
|
|
21
|
+
export function findFiles(root, match) {
|
|
22
|
+
const out = [];
|
|
23
|
+
const walk = (dir) => {
|
|
24
|
+
let entries;
|
|
25
|
+
try {
|
|
26
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
27
|
+
} catch {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
for (const e of entries) {
|
|
31
|
+
if (e.isDirectory()) { if (!SKIP_DIRS.has(e.name)) walk(path.join(dir, e.name)); }
|
|
32
|
+
else if (match(e.name)) out.push(toPosix(path.relative(root, path.join(dir, e.name))));
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
walk(root);
|
|
36
|
+
return out.sort((a, b) => a.split('/').length - b.split('/').length || a.localeCompare(b));
|
|
37
|
+
}
|
package/src/php.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Lectura de PHP con php-parser (glayzzle).
|
|
2
|
+
// A diferencia de recast, php-parser lee el texto original: loc.start.offset y loc.end.offset son
|
|
3
|
+
// posiciones exactas en el archivo del cliente, también con CRLF, tabs y acentos (lo cubren los tests).
|
|
4
|
+
// Por eso acá se edita con esos offsets y no con offsetAt(), que corrige las particularidades de recast.
|
|
5
|
+
import PhpParser from 'php-parser';
|
|
6
|
+
|
|
7
|
+
const engine = new PhpParser.Engine({
|
|
8
|
+
parser: { php8: true, suppressErrors: false, extractDoc: false },
|
|
9
|
+
ast: { withPositions: true, withSource: false },
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
export function parsePhp(source, file = 'archivo.php') {
|
|
13
|
+
return engine.parseCode(source, file);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const SCOPE_KINDS = new Set(['function', 'method', 'closure', 'arrowfunc']);
|
|
17
|
+
|
|
18
|
+
// Recorre el AST y llama visit(node, scope), donde scope es la función, el método, la closure
|
|
19
|
+
// o el programa que contiene al nodo (en PHP las variables no cruzan esos límites).
|
|
20
|
+
export function walkPhp(node, visit, scope = node) {
|
|
21
|
+
if (!node || typeof node !== 'object') return;
|
|
22
|
+
if (Array.isArray(node)) {
|
|
23
|
+
for (const n of node) walkPhp(n, visit, scope);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
if (!node.kind) return;
|
|
27
|
+
visit(node, scope);
|
|
28
|
+
const inner = SCOPE_KINDS.has(node.kind) ? node : scope;
|
|
29
|
+
for (const [k, v] of Object.entries(node)) {
|
|
30
|
+
if (k === 'loc' || k === 'leadingComments' || k === 'trailingComments') continue;
|
|
31
|
+
walkPhp(v, visit, inner);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Valor de un literal: número (también '99' entre comillas, que PHP convierte), string o booleano.
|
|
36
|
+
// undefined si no es un literal (constante, variable, llamada...).
|
|
37
|
+
export function phpLiteral(node) {
|
|
38
|
+
if (!node) return undefined;
|
|
39
|
+
if (node.kind === 'number') return Number(node.value);
|
|
40
|
+
if (node.kind === 'string') return /^-?\d+$/.test(node.value) ? Number(node.value) : node.value;
|
|
41
|
+
if (node.kind === 'boolean') return node.value;
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Clave de un elemento de array si es un string literal ('DocTipo' => ...); null si no.
|
|
46
|
+
export function phpKey(entry) {
|
|
47
|
+
return entry && entry.key && entry.key.kind === 'string' ? entry.key.value : null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function isPhp(file) {
|
|
51
|
+
return /\.php$/i.test(file);
|
|
52
|
+
}
|
package/src/report.js
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// Reporte del scan y cuerpo del PR, en castellano.
|
|
2
|
+
import { apiLabel } from './inventory/index.js';
|
|
3
|
+
|
|
4
|
+
const LABEL ={
|
|
5
|
+
auto: '✅ automático',
|
|
6
|
+
'needs-review': '👀 revisar',
|
|
7
|
+
'needs-input': '✋ falta un dato',
|
|
8
|
+
blocked: '⛔ bloqueado',
|
|
9
|
+
'detect-only': '🔎 detectado',
|
|
10
|
+
};
|
|
11
|
+
const SEVERITY = { critical: 'crítica', high: 'alta', medium: 'media', low: 'baja' };
|
|
12
|
+
|
|
13
|
+
export function daysUntil(date, today = new Date()) {
|
|
14
|
+
if (!date) return null;
|
|
15
|
+
const ms = new Date(date + 'T00:00:00-03:00') - today;
|
|
16
|
+
return Math.ceil(ms / 86400000);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function deadlineText(pack, today) {
|
|
20
|
+
if (!pack.deadline) return 'sin fecha límite';
|
|
21
|
+
const d = daysUntil(pack.deadline, today);
|
|
22
|
+
return d >= 0 ? `${pack.deadline} (faltan ${d} días)` : `${pack.deadline} (vencida hace ${-d} días)`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function counts(findings) {
|
|
26
|
+
const c = {};
|
|
27
|
+
for (const f of findings) c[f.status] = (c[f.status] || 0) + 1;
|
|
28
|
+
return c;
|
|
29
|
+
}
|
|
30
|
+
|
|
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()) {
|
|
57
|
+
const out = [];
|
|
58
|
+
out.push(`# Parche — reporte de \`${inventory.repo}\``, '');
|
|
59
|
+
out.push('## APIs que usa este repo', '');
|
|
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.', '');
|
|
71
|
+
}
|
|
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));
|
|
77
|
+
for (const pack of packs) {
|
|
78
|
+
const fs = findings.filter((f) => f.pack === pack.id);
|
|
79
|
+
if (!fs.length) continue;
|
|
80
|
+
out.push(`## ${pack.title}`, '');
|
|
81
|
+
out.push(`**Proveedor:** ${pack.provider} · **Severidad:** ${SEVERITY[pack.severity]} · **Fecha límite:** ${deadlineText(pack, today)}`, '');
|
|
82
|
+
out.push(pack.summary_es, '');
|
|
83
|
+
for (const f of fs) {
|
|
84
|
+
out.push(`- ${LABEL[f.status]} \`${f.file}${f.line ? `:${f.line}` : ''}\` — ${f.message}`);
|
|
85
|
+
if (f.suggestion) out.push(` - Sugerido: \`${f.suggestion}\``);
|
|
86
|
+
}
|
|
87
|
+
out.push('');
|
|
88
|
+
}
|
|
89
|
+
if (changed && changed.length) {
|
|
90
|
+
out.push('## Archivos modificados', '', ...changed.map((c) => `- \`${c.file}\` (${c.packs.join(', ')})`), '');
|
|
91
|
+
}
|
|
92
|
+
return out.join('\n');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function prBody({ pack, findings, changed, verification }, today = new Date()) {
|
|
96
|
+
const fs = findings.filter((f) => f.pack === pack.id);
|
|
97
|
+
const c = counts(fs);
|
|
98
|
+
const out = [];
|
|
99
|
+
out.push(`## ${pack.title}`, '');
|
|
100
|
+
out.push(`> **${pack.provider}** · severidad ${SEVERITY[pack.severity]} · fecha límite: ${deadlineText(pack, today)}`, '');
|
|
101
|
+
out.push('### Qué cambió', '', pack.why_es, '');
|
|
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', '');
|
|
105
|
+
const applied = fs.filter((f) => f.status === 'auto' || f.status === 'needs-review');
|
|
106
|
+
out.push(changed.length
|
|
107
|
+
? `Modifica ${changed.length} archivo(s): ${changed.map((x) => `\`${x.file}\``).join(', ')}.`
|
|
108
|
+
: 'No hay un arreglo automático seguro: esto es lo que hay que resolver a mano.', '');
|
|
109
|
+
for (const f of applied) out.push(`- ${LABEL[f.status]} \`${f.file}:${f.line}\` — ${f.message}`);
|
|
110
|
+
const pending = fs.filter((f) => f.status === 'needs-input' || f.status === 'blocked');
|
|
111
|
+
if (pending.length) {
|
|
112
|
+
out.push('', asIssue ? '### Lo que hay que resolver (necesita una persona)' : '### Lo que este PR NO resuelve (necesita una persona)', '');
|
|
113
|
+
for (const f of pending) {
|
|
114
|
+
out.push(`- ${LABEL[f.status]} \`${f.file}:${f.line}\` — ${f.message}`);
|
|
115
|
+
if (f.suggestion) out.push(` - Agregá: \`${f.suggestion}\``);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
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
|
+
}
|
|
137
|
+
}
|
|
138
|
+
out.push('', asIssue ? '### Checklist' : '### Checklist antes de mergear', '', ...pack.review_checklist_es.map((x) => `- [ ] ${x}`), '');
|
|
139
|
+
out.push('### Fuentes', '', ...pack.sources.map((s) => `- [${s.title}](${s.url})`), '');
|
|
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>`, '');
|
|
141
|
+
return out.join('\n');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export { LABEL };
|
|
@@ -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
|
+
}
|