parche-cli 0.1.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
+ }
@@ -0,0 +1,107 @@
1
+ // `parche scan --upgrades` y `parche diff`: para cada SDK de npm que el repo usa, compara la versión
2
+ // instalada con la última publicada y dice qué operaciones del repo cambian o desaparecen.
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { extractSurface } from './surface.js';
6
+ import { diffSurfaces, affectedOperations, unverifiedOperations, dedupeChanges } from './compare.js';
7
+ import { compareVersions } from './registry.js';
8
+
9
+ const readJson = (p) => {
10
+ try {
11
+ return JSON.parse(fs.readFileSync(p, 'utf8'));
12
+ } catch {
13
+ return null;
14
+ }
15
+ };
16
+
17
+ // Versión instalada: node_modules → package-lock.json → la base del rango declarado ("^3.2.1" → 3.2.1).
18
+ // Si la base no existe en el registry, la más alta de la misma versión mayor. → { version, from } | null
19
+ export function installedVersion(root, pkg, declared, meta) {
20
+ const nm = readJson(path.join(root, 'node_modules', pkg, 'package.json'));
21
+ if (nm?.version) return { version: nm.version, from: 'node_modules' };
22
+ const lock = readJson(path.join(root, 'package-lock.json'));
23
+ const locked = lock?.packages?.[`node_modules/${pkg}`]?.version || lock?.dependencies?.[pkg]?.version;
24
+ if (locked) return { version: locked, from: 'package-lock.json' };
25
+ const m = String(declared || '').match(/(\d+)(?:\.(\d+))?(?:\.(\d+))?/);
26
+ if (!m) return null;
27
+ const base = `${m[1]}.${m[2] || 0}.${m[3] || 0}`;
28
+ if (!meta || meta.versions[base]) return { version: base, from: 'package.json' };
29
+ const sameMajor = Object.keys(meta.versions).filter((v) => v.split('.')[0] === m[1] && !v.includes('-')).sort(compareVersions);
30
+ return sameMajor.length ? { version: sameMajor[sameMajor.length - 1], from: 'package.json' } : null;
31
+ }
32
+
33
+ // "latest", "4" o "4.1" → la versión exacta más alta que corresponde, según el registry.
34
+ export function resolveSpec(meta, spec) {
35
+ if (spec === 'latest') return meta['dist-tags']?.latest || null;
36
+ if (meta.versions[spec]) return spec;
37
+ const matches = Object.keys(meta.versions).filter((v) => !v.includes('-') && v.startsWith(`${spec}.`)).sort(compareVersions);
38
+ return matches.length ? matches[matches.length - 1] : null;
39
+ }
40
+
41
+ // Diff completo entre dos versiones de un paquete (acepta "latest" y versiones parciales).
42
+ export async function diffVersions(registry, pkg, fromSpec, toSpec) {
43
+ const meta = await registry.metadata(pkg);
44
+ const from = resolveSpec(meta, fromSpec);
45
+ const to = resolveSpec(meta, toSpec);
46
+ if (!from || !to) return { pkg, from: fromSpec, to: toSpec, error: `${pkg}: no existe la versión ${!from ? fromSpec : toSpec} en el registry.` };
47
+ const [ta, tb] = await Promise.all([registry.types(pkg, from), registry.types(pkg, to)]);
48
+ if (!ta || !tb) return { pkg, from, to, error: `No hay tipos (.d.ts) para ${!ta ? from : to}: no se puede comparar.` };
49
+ const sa = extractSurface({ files: ta.files, entry: ta.entry, pkgName: pkg });
50
+ const sb = extractSurface({ files: tb.files, entry: tb.entry, pkgName: pkg });
51
+ const all = diffSurfaces(sa, sb);
52
+ // changes: para mostrar (sin repetidos); allChanges: para cruzar con lo que usa el repo.
53
+ return { pkg, from, to, typesFrom: ta.source, typesTo: tb.source, before: sa, changes: dedupeChanges(all), allChanges: all };
54
+ }
55
+
56
+ // Para cada SDK de npm del inventario que tiene operaciones: versión instalada vs última.
57
+ export async function checkUpgrades(inventory, root, registry) {
58
+ const results = [];
59
+ const sdks = inventory.apis.filter((a) => a.kind === 'sdk' && a.ecosystem === 'npm' && a.operations.length);
60
+ for (const api of sdks) {
61
+ const base = { package: api.package, provider: api.provider };
62
+ try {
63
+ const meta = await registry.metadata(api.package);
64
+ const latest = meta['dist-tags']?.latest;
65
+ const installed = installedVersion(root, api.package, api.version, meta);
66
+ if (!latest || !installed) {
67
+ results.push({ ...base, status: 'sin-version', installed: installed?.version || null, latest: latest || null });
68
+ continue;
69
+ }
70
+ if (compareVersions(installed.version, latest) >= 0) {
71
+ results.push({ ...base, status: 'al-dia', installed: installed.version, latest });
72
+ continue;
73
+ }
74
+ const d = await diffVersions(registry, api.package, installed.version, latest);
75
+ if (d.error) {
76
+ results.push({ ...base, status: 'sin-tipos', installed: installed.version, latest, error: d.error });
77
+ continue;
78
+ }
79
+ const ops = api.operations.map((o) => o.op);
80
+ const affected = affectedOperations(d.allChanges, ops, d.before).map((a) => ({
81
+ ...a, locations: api.operations.find((o) => o.op === a.op).locations,
82
+ }));
83
+ const unverified = unverifiedOperations(ops, d.before);
84
+ const status = affected.length ? 'afectado' : unverified.length === ops.length ? 'sin-verificar' : 'sin-impacto';
85
+ results.push({ ...base, status, installed: installed.version, installedFrom: installed.from, latest,
86
+ totalChanges: d.changes.length, affected, unverified });
87
+ } catch (err) {
88
+ const notPublished = /respondió 404/.test(err.message);
89
+ results.push({ ...base, status: notPublished ? 'no-publicado' : 'error',
90
+ error: notPublished ? `${api.package} no está publicado en el registry de npm (¿paquete privado o interno?).` : err.message });
91
+ }
92
+ }
93
+ return results;
94
+ }
95
+
96
+ // Hallazgos detect-only (en castellano) para el reporte.
97
+ export function upgradeFindings(results) {
98
+ const findings = [];
99
+ for (const r of results.filter((x) => x.status === 'afectado')) {
100
+ for (const a of r.affected) {
101
+ const where = a.locations[0];
102
+ findings.push({ pack: 'sdk.upgrades', file: where.file, line: where.line, status: 'detect-only',
103
+ message: `${r.package} ${r.installed} → ${r.latest}: ${a.changes.map((c) => c.message).join(' ')}` });
104
+ }
105
+ }
106
+ return findings;
107
+ }
package/src/verify.js CHANGED
@@ -3,6 +3,55 @@ import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { spawnSync } from 'node:child_process';
5
5
  import { parseCheck } from './ast.js';
6
+ import { isPhp } from './php.js';
7
+
8
+ const readJson = (root, name) => {
9
+ try {
10
+ return JSON.parse(fs.readFileSync(path.join(root, name), 'utf8'));
11
+ } catch {
12
+ return null;
13
+ }
14
+ };
15
+
16
+ // Tests que corresponden a los archivos modificados: `npm test` para JS/TS; para PHP,
17
+ // `composer test` o, si no hay script, vendor/bin/phpunit. Si no hay ninguno, queda el chequeo de sintaxis.
18
+ export function testCommands(root, changedFiles) {
19
+ const cmds = [];
20
+ const pkg = readJson(root, 'package.json');
21
+ if (changedFiles.some((f) => !isPhp(f)) && pkg?.scripts?.test) {
22
+ cmds.push({ label: 'npm test', cmd: 'npm', args: ['test', '--silent'] });
23
+ }
24
+ if (changedFiles.some(isPhp)) {
25
+ const composer = readJson(root, 'composer.json');
26
+ if (composer?.scripts?.test) cmds.push({ label: 'composer test', cmd: 'composer', args: ['test'] });
27
+ else if (fs.existsSync(path.join(root, 'vendor', 'bin', 'phpunit'))) {
28
+ cmds.push({ label: 'vendor/bin/phpunit', cmd: path.join('vendor', 'bin', 'phpunit'), args: [] });
29
+ }
30
+ }
31
+ return cmds;
32
+ }
33
+
34
+ // Workflows del repo que corren en cada pull request: si Parche no corrió los tests, el PR dice dónde se corren.
35
+ // `on: [push, pull_request]`, `on:` + `pull_request:` o `- pull_request`. No cuenta pull_request_target:
36
+ // corre el código de la rama base, no el del PR.
37
+ const ON_PULL_REQUEST = /^\s*(['"]?on['"]?\s*:.*\bpull_request\b|-?\s*pull_request\b\s*(:|#|$))/m;
38
+
39
+ export function prWorkflows(root) {
40
+ const dir = path.join(root, '.github', 'workflows');
41
+ let names = [];
42
+ try {
43
+ names = fs.readdirSync(dir).filter((f) => /\.ya?ml$/i.test(f)).sort();
44
+ } catch {
45
+ return [];
46
+ }
47
+ return names.filter((f) => {
48
+ try {
49
+ return ON_PULL_REQUEST.test(fs.readFileSync(path.join(dir, f), 'utf8'));
50
+ } catch {
51
+ return false;
52
+ }
53
+ }).map((f) => `.github/workflows/${f}`);
54
+ }
6
55
 
7
56
  export function verify(root, changedFiles, { runTests = false } = {}) {
8
57
  const errors = [];
@@ -15,16 +64,23 @@ export function verify(root, changedFiles, { runTests = false } = {}) {
15
64
  }
16
65
  const result = { parse: { ok: errors.length === 0, errors } };
17
66
  if (runTests) {
18
- const pkgPath = path.join(root, 'package.json');
19
- const pkg = fs.existsSync(pkgPath) ? JSON.parse(fs.readFileSync(pkgPath, 'utf8')) : {};
20
- if (!pkg.scripts || !pkg.scripts.test) {
21
- result.tests = { ok: true, skipped: true };
67
+ const cmds = testCommands(root, changedFiles);
68
+ if (!cmds.length) {
69
+ result.tests = { ok: true, skipped: true, commands: [] };
22
70
  } else {
23
- // En Windows npm es npm.cmd y Node solo lo ejecuta a través de un shell.
24
- const r = spawnSync('npm', ['test', '--silent'], { cwd: root, encoding: 'utf8', timeout: 300000, shell: process.platform === 'win32' });
25
- const output = (r.stdout || '') + (r.stderr || '') + (r.error ? `\n${r.error.message}` : '');
26
- result.tests = { ok: r.status === 0, output: output.slice(-2000) };
71
+ let ok = true;
72
+ let output = '';
73
+ for (const c of cmds) {
74
+ // En Windows npm, composer y phpunit son .cmd/.bat y Node solo los ejecuta a través de un shell.
75
+ const r = spawnSync(c.cmd, c.args, { cwd: root, encoding: 'utf8', timeout: 300000, shell: process.platform === 'win32' });
76
+ output += `$ ${c.label}\n${r.stdout || ''}${r.stderr || ''}${r.error ? `\n${r.error.message}` : ''}\n`;
77
+ if (r.status !== 0) { ok = false; break; }
78
+ }
79
+ result.tests = { ok, commands: cmds.map((c) => c.label), output: output.slice(-2000) };
27
80
  }
81
+ } else {
82
+ // Sin --verify, Parche no ejecuta código del repo (por ejemplo, en un repo de terceros): se dice, no se oculta.
83
+ result.tests = { ok: true, notRun: true, ci: prWorkflows(root) };
28
84
  }
29
85
  result.ok = result.parse.ok && (!result.tests || result.tests.ok);
30
86
  return result;