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
|
@@ -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
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Verificación: ningún PR sale sin que el código modificado parsee y, si se pide, sin que pasen los tests.
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { spawnSync } from 'node:child_process';
|
|
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
|
+
}
|
|
55
|
+
|
|
56
|
+
export function verify(root, changedFiles, { runTests = false } = {}) {
|
|
57
|
+
const errors = [];
|
|
58
|
+
for (const rel of changedFiles) {
|
|
59
|
+
try {
|
|
60
|
+
parseCheck(fs.readFileSync(path.join(root, rel), 'utf8'), rel);
|
|
61
|
+
} catch (e) {
|
|
62
|
+
errors.push(`${rel}: ${e.message}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const result = { parse: { ok: errors.length === 0, errors } };
|
|
66
|
+
if (runTests) {
|
|
67
|
+
const cmds = testCommands(root, changedFiles);
|
|
68
|
+
if (!cmds.length) {
|
|
69
|
+
result.tests = { ok: true, skipped: true, commands: [] };
|
|
70
|
+
} else {
|
|
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) };
|
|
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) };
|
|
84
|
+
}
|
|
85
|
+
result.ok = result.parse.ok && (!result.tests || result.tests.ok);
|
|
86
|
+
return result;
|
|
87
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Copiá este archivo a .github/workflows/parche.yml en tu repo.
|
|
2
|
+
# Cada lunes Parche revisa tu código contra la base de cambios de APIs
|
|
3
|
+
# y abre un PR por cada cambio que te afecta (en borrador si hay algo para revisar).
|
|
4
|
+
name: Parche
|
|
5
|
+
|
|
6
|
+
on:
|
|
7
|
+
schedule:
|
|
8
|
+
- cron: "17 11 * * 1" # lunes 8:17 (hora de Argentina)
|
|
9
|
+
workflow_dispatch:
|
|
10
|
+
|
|
11
|
+
permissions:
|
|
12
|
+
contents: write
|
|
13
|
+
pull-requests: write
|
|
14
|
+
|
|
15
|
+
jobs:
|
|
16
|
+
parche:
|
|
17
|
+
runs-on: ubuntu-latest
|
|
18
|
+
steps:
|
|
19
|
+
- uses: actions/checkout@v7
|
|
20
|
+
with:
|
|
21
|
+
fetch-depth: 0
|
|
22
|
+
- uses: actions/setup-node@v7
|
|
23
|
+
with:
|
|
24
|
+
node-version: 24
|
|
25
|
+
- name: Instalar dependencias del proyecto (para correr sus tests antes de cada PR)
|
|
26
|
+
run: npm ci
|
|
27
|
+
- uses: Pepo08/parche@v0
|