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.
- package/CHANGELOG.md +106 -0
- package/README.md +108 -13
- package/bin/parche.js +152 -44
- package/data/known-sdks.json +125 -0
- package/package.json +7 -3
- package/packs/ai.model-retirements.json +67 -15
- package/packs/arca.wsfe.condicion-iva-receptor.json +7 -5
- package/src/ast.js +21 -1
- package/src/codemods/arca-rules.js +140 -0
- package/src/codemods/arca-wsfe-condicion-iva-php.js +276 -0
- package/src/codemods/arca-wsfe-condicion-iva.js +173 -71
- package/src/context.js +93 -0
- package/src/detectors/ai-model-retirements.js +34 -19
- package/src/engine.js +26 -22
- package/src/git.js +42 -6
- package/src/inventory/index.js +133 -0
- package/src/inventory/js-scan.js +235 -0
- package/src/inventory/known.js +60 -0
- package/src/packs.js +21 -5
- package/src/paths.js +37 -0
- package/src/php.js +52 -0
- package/src/report.js +85 -18
- package/src/sdk-diff/compare.js +121 -0
- package/src/sdk-diff/registry.js +142 -0
- package/src/sdk-diff/surface.js +159 -0
- package/src/sdk-diff/tar.js +47 -0
- package/src/sdk-diff/upgrades.js +107 -0
- package/src/verify.js +64 -8
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
// Inventario de un archivo JS/TS: qué SDKs usa (y con qué operaciones), a qué hosts llama por HTTP
|
|
2
|
+
// y qué modelos de IA nombra. Solo lee: usa @babel/parser directo (sin recast, no hay offsets que cuidar).
|
|
3
|
+
//
|
|
4
|
+
// Límite conocido: las variables se siguen por nombre en todo el archivo, sin scopes. Alcanza para el
|
|
5
|
+
// inventario (aviso de nivel 1); un codemod que edita nunca debe apoyarse en esto.
|
|
6
|
+
import { parse } from '@babel/parser';
|
|
7
|
+
import { packageOf, isInternalHost } from './known.js';
|
|
8
|
+
|
|
9
|
+
const HTTP_METHODS = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options'];
|
|
10
|
+
const FETCH_PACKAGES = new Set(['node-fetch', 'cross-fetch', 'isomorphic-fetch', 'undici']);
|
|
11
|
+
const SKIP_KEYS = new Set(['loc', 'start', 'end', 'extra', 'range', 'comments', 'leadingComments', 'trailingComments', 'innerComments', 'tokens']);
|
|
12
|
+
|
|
13
|
+
// IDs de modelos de IA. Prefijos inequívocos; la serie o1/o3/o4 de OpenAI solo cuenta si el archivo usa OpenAI.
|
|
14
|
+
const MODEL_PATTERNS = [
|
|
15
|
+
[/^(gpt-(3\.5|4|4\.1|4o|4\.5|5)[\w.-]*|chatgpt-[\w.-]+|text-embedding-[\w-]+|dall-e-[23]|whisper-1|tts-1(-hd)?|gpt-image-[\w.-]+)$/, 'OpenAI'],
|
|
16
|
+
[/^o[1-9](-(mini|pro|preview|deep-research))?(-\d{4}-\d{2}-\d{2})?$/, 'OpenAI', { needs: 'openai' }],
|
|
17
|
+
[/^claude-[\w.-]+$/, 'Anthropic'],
|
|
18
|
+
[/^(gemini|gemma)-[\w.-]+$/, 'Google (Gemini)'],
|
|
19
|
+
[/^(mistral|mixtral|codestral|pixtral|ministral)-[\w.-]+$/, 'Mistral'],
|
|
20
|
+
[/^command-(r|a)[\w.-]*$/, 'Cohere'],
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
function parseJs(source, file) {
|
|
24
|
+
const lang = /\.tsx$/.test(file) ? ['typescript', 'jsx'] : /\.(ts|mts|cts)$/.test(file) ? ['typescript'] : ['jsx'];
|
|
25
|
+
const plugins = [...lang, 'decorators-legacy']; // NestJS, Angular, TypeORM
|
|
26
|
+
return parse(source, {
|
|
27
|
+
sourceType: 'unambiguous', plugins, errorRecovery: true,
|
|
28
|
+
allowAwaitOutsideFunction: true, allowReturnOutsideFunction: true, allowImportExportEverywhere: true,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function walk(node, visit, parent = null) {
|
|
33
|
+
if (!node || typeof node !== 'object') return;
|
|
34
|
+
if (Array.isArray(node)) { for (const n of node) walk(n, visit, parent); return; }
|
|
35
|
+
if (typeof node.type !== 'string') return;
|
|
36
|
+
visit(node, parent);
|
|
37
|
+
for (const [k, v] of Object.entries(node)) if (!SKIP_KEYS.has(k)) walk(v, visit, node);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const isRequire = (n) => n && n.type === 'CallExpression' && n.callee.type === 'Identifier' && n.callee.name === 'require'
|
|
41
|
+
&& n.arguments[0] && n.arguments[0].type === 'StringLiteral';
|
|
42
|
+
const propName = (n) => (n.type === 'Identifier' ? n.name : n.type === 'StringLiteral' ? n.value : null);
|
|
43
|
+
|
|
44
|
+
// a.b.c(...) → { root: 'a', path: ['b', 'c'] }; null si la cadena no es estática.
|
|
45
|
+
function chainOf(node) {
|
|
46
|
+
const path = [];
|
|
47
|
+
let cur = node;
|
|
48
|
+
while (cur && (cur.type === 'MemberExpression' || cur.type === 'OptionalMemberExpression')) {
|
|
49
|
+
const name = cur.computed ? (cur.property.type === 'StringLiteral' ? cur.property.value : null) : propName(cur.property);
|
|
50
|
+
if (name === null) return null;
|
|
51
|
+
path.unshift(name);
|
|
52
|
+
cur = cur.object;
|
|
53
|
+
}
|
|
54
|
+
if (cur && cur.type === 'Identifier') return { root: cur.name, path };
|
|
55
|
+
// this.afip.ElectronicBilling.createVoucher → raíz "this.afip" (cliente guardado en la clase)
|
|
56
|
+
if (cur && cur.type === 'ThisExpression' && path.length) return { root: `this.${path[0]}`, path: path.slice(1) };
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function scanJs(source, file) {
|
|
61
|
+
const result = { sdk: [], http: [], unresolved: [], models: [], imports: new Set() };
|
|
62
|
+
let ast;
|
|
63
|
+
try {
|
|
64
|
+
ast = parseJs(source, file);
|
|
65
|
+
} catch {
|
|
66
|
+
return result;
|
|
67
|
+
}
|
|
68
|
+
const bindings = new Map(); // nombre → { pkg, kind: 'module'|'named'|'client'|'http', imported?, prefix?, base? }
|
|
69
|
+
const constants = new Map(); // nombre → string resuelto (para armar URLs)
|
|
70
|
+
const bind = (name, info) => { if (name) bindings.set(name, info); };
|
|
71
|
+
|
|
72
|
+
// Texto estático de una expresión: literales, plantillas y constantes del archivo; '{}' donde no se sabe.
|
|
73
|
+
const staticText = (expr) => {
|
|
74
|
+
if (!expr) return null;
|
|
75
|
+
if (expr.type === 'StringLiteral') return expr.value;
|
|
76
|
+
if (expr.type === 'TemplateLiteral') {
|
|
77
|
+
return expr.quasis.map((q, i) => q.value.cooked + (i < expr.expressions.length ? (staticText(expr.expressions[i]) ?? '{}') : '')).join('');
|
|
78
|
+
}
|
|
79
|
+
if (expr.type === 'Identifier' && constants.has(expr.name)) return constants.get(expr.name);
|
|
80
|
+
if (expr.type === 'BinaryExpression' && expr.operator === '+') {
|
|
81
|
+
const l = staticText(expr.left);
|
|
82
|
+
const r = staticText(expr.right);
|
|
83
|
+
return (l ?? '{}') + (r ?? '{}');
|
|
84
|
+
}
|
|
85
|
+
return null;
|
|
86
|
+
};
|
|
87
|
+
const objProp = (obj, name) => obj && obj.type === 'ObjectExpression'
|
|
88
|
+
&& obj.properties.find((p) => (p.type === 'ObjectProperty' || p.type === 'Property') && !p.computed && propName(p.key) === name);
|
|
89
|
+
const objString = (obj, name) => { const p = objProp(obj, name); return p ? staticText(p.value) : null; };
|
|
90
|
+
|
|
91
|
+
// Instancias: new Stripe(key), twilio(sid, token), new Payment(client), axios.create({ baseURL }).
|
|
92
|
+
// → binding nuevo, o null si la expresión no crea un cliente de un SDK conocido en el archivo.
|
|
93
|
+
const instanceBinding = (expr) => {
|
|
94
|
+
let init = expr;
|
|
95
|
+
if (init && init.type === 'AwaitExpression') init = init.argument;
|
|
96
|
+
if (!init || (init.type !== 'NewExpression' && init.type !== 'CallExpression')) return null;
|
|
97
|
+
if (init.callee.type === 'Identifier' && bindings.has(init.callee.name)) {
|
|
98
|
+
const b = bindings.get(init.callee.name);
|
|
99
|
+
if (b.kind === 'named') return { pkg: b.pkg, kind: 'client', prefix: b.imported };
|
|
100
|
+
return { pkg: b.pkg, kind: 'client', prefix: b.prefix || '' };
|
|
101
|
+
}
|
|
102
|
+
const ch = init.callee.type === 'MemberExpression' ? chainOf(init.callee) : null;
|
|
103
|
+
if (ch && bindings.has(ch.root)) {
|
|
104
|
+
const b = bindings.get(ch.root);
|
|
105
|
+
const last = ch.path[ch.path.length - 1];
|
|
106
|
+
if ((b.pkg === 'axios' && last === 'create') || (b.pkg === 'got' && last === 'extend')) {
|
|
107
|
+
const cfg = init.arguments[0];
|
|
108
|
+
return { pkg: b.pkg, kind: 'http', base: objString(cfg, 'baseURL') ?? objString(cfg, 'prefixUrl') };
|
|
109
|
+
}
|
|
110
|
+
if (init.type === 'NewExpression') return { pkg: b.pkg, kind: 'client', prefix: [b.prefix, ...ch.path].filter(Boolean).join('.') };
|
|
111
|
+
}
|
|
112
|
+
return null;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
// 1) Imports, require, constantes e instancias (en orden de aparición).
|
|
116
|
+
walk(ast, (node) => {
|
|
117
|
+
// Cliente guardado en una clase: `private afip = new Afip(...)` o `this.afip = new Afip(...)` en el constructor.
|
|
118
|
+
if ((node.type === 'ClassProperty' || node.type === 'PropertyDefinition') && !node.computed && node.key.type === 'Identifier' && node.value) {
|
|
119
|
+
const b = instanceBinding(node.value);
|
|
120
|
+
if (b) bind(`this.${node.key.name}`, b);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (node.type === 'AssignmentExpression' && node.left.type === 'MemberExpression' && node.left.object.type === 'ThisExpression'
|
|
124
|
+
&& !node.left.computed && node.left.property.type === 'Identifier') {
|
|
125
|
+
const b = instanceBinding(node.right);
|
|
126
|
+
if (b) bind(`this.${node.left.property.name}`, b);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
if (node.type === 'ImportDeclaration' && node.importKind !== 'type') {
|
|
130
|
+
const pkg = packageOf(node.source.value);
|
|
131
|
+
if (!pkg) return;
|
|
132
|
+
result.imports.add(pkg);
|
|
133
|
+
for (const s of node.specifiers) {
|
|
134
|
+
if (s.importKind === 'type') continue;
|
|
135
|
+
if (s.type === 'ImportSpecifier') bind(s.local.name, { pkg, kind: 'named', imported: propName(s.imported) });
|
|
136
|
+
else bind(s.local.name, { pkg, kind: 'module' });
|
|
137
|
+
}
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (node.type !== 'VariableDeclarator' || !node.init) return;
|
|
141
|
+
let init = node.init;
|
|
142
|
+
if (init.type === 'AwaitExpression') init = init.argument;
|
|
143
|
+
const id = node.id;
|
|
144
|
+
// const BASE = 'https://...'
|
|
145
|
+
if (id.type === 'Identifier' && (init.type === 'StringLiteral' || init.type === 'TemplateLiteral' || init.type === 'BinaryExpression')) {
|
|
146
|
+
const t = staticText(init);
|
|
147
|
+
if (t !== null) constants.set(id.name, t);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
// require('x'), require('x').Y y require('x')(...)
|
|
151
|
+
if (isRequire(init) || (init.type === 'MemberExpression' && isRequire(init.object)) || (init.type === 'CallExpression' && isRequire(init.callee))) {
|
|
152
|
+
const req = isRequire(init) ? init : init.type === 'MemberExpression' ? init.object : init.callee;
|
|
153
|
+
const pkg = packageOf(req.arguments[0].value);
|
|
154
|
+
if (!pkg) return;
|
|
155
|
+
result.imports.add(pkg);
|
|
156
|
+
if (init.type === 'CallExpression' && init !== req) return bind(id.name, { pkg, kind: 'client', prefix: '' });
|
|
157
|
+
if (init.type === 'MemberExpression') return bind(id.name, { pkg, kind: 'named', imported: propName(init.property) });
|
|
158
|
+
if (id.type === 'Identifier') return bind(id.name, { pkg, kind: 'module' });
|
|
159
|
+
if (id.type === 'ObjectPattern') {
|
|
160
|
+
for (const p of id.properties) {
|
|
161
|
+
if (p.type === 'ObjectProperty' && p.value.type === 'Identifier') bind(p.value.name, { pkg, kind: 'named', imported: propName(p.key) });
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (id.type !== 'Identifier') return;
|
|
167
|
+
const b = instanceBinding(init);
|
|
168
|
+
if (b) bind(id.name, b);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
const usesOpenAI = result.imports.has('openai');
|
|
172
|
+
|
|
173
|
+
const recordHttp = (client, method, urlExpr, line, base = null) => {
|
|
174
|
+
let url = staticText(urlExpr);
|
|
175
|
+
if (url !== null && base && !/^https?:\/\//i.test(url)) url = base.replace(/\/+$/, '') + '/' + url.replace(/^\/+/, '');
|
|
176
|
+
if (url === null || url.startsWith('{}')) {
|
|
177
|
+
result.unresolved.push({ kind: 'http', client, line, reason: 'URL dinámica: no se puede saber a qué host va' });
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
const m = url.match(/^https?:\/\/([^/?#{}]+)(\/[^?#]*)?/i);
|
|
181
|
+
if (!m) return; // ruta relativa: es el backend propio, no una API de terceros
|
|
182
|
+
// Sin el puerto, también cuando es dinámico: http://localhost:${PORT} → "localhost:" → "localhost".
|
|
183
|
+
const host = m[1].toLowerCase().replace(/:.*$/, '');
|
|
184
|
+
if (isInternalHost(host)) return;
|
|
185
|
+
result.http.push({ client, method: (method || 'GET').toUpperCase(), host, path: (m[2] || '/').replace(/\{\}[^/]*/g, '{}'), line });
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
// 2) Llamadas.
|
|
189
|
+
walk(ast, (node) => {
|
|
190
|
+
if (node.type === 'StringLiteral' || (node.type === 'TemplateLiteral' && node.expressions.length === 0)) {
|
|
191
|
+
const value = node.type === 'StringLiteral' ? node.value : node.quasis[0].value.cooked;
|
|
192
|
+
for (const [re, provider, opts] of MODEL_PATTERNS) {
|
|
193
|
+
if (re.test(value) && (!opts || (opts.needs === 'openai' && usesOpenAI))) {
|
|
194
|
+
result.models.push({ id: value, provider, line: node.loc.start.line });
|
|
195
|
+
break;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
if (node.type !== 'CallExpression' && node.type !== 'OptionalCallExpression') return;
|
|
201
|
+
const line = node.loc.start.line;
|
|
202
|
+
const callee = node.callee;
|
|
203
|
+
const [a0, a1] = node.arguments;
|
|
204
|
+
|
|
205
|
+
// fetch global, o importado de node-fetch / undici / cross-fetch
|
|
206
|
+
if (callee.type === 'Identifier') {
|
|
207
|
+
const b = bindings.get(callee.name);
|
|
208
|
+
if ((!b && callee.name === 'fetch') || (b && FETCH_PACKAGES.has(b.pkg) && (b.kind === 'module' || b.imported === 'fetch'))) {
|
|
209
|
+
return recordHttp(b ? b.pkg : 'fetch', objString(a1, 'method'), a0, line);
|
|
210
|
+
}
|
|
211
|
+
if (b && b.pkg === 'undici' && b.imported === 'request') return recordHttp('undici', objString(a1, 'method'), a0, line);
|
|
212
|
+
if (b && (b.pkg === 'axios' || b.pkg === 'got') && (b.kind === 'module' || b.kind === 'http')) {
|
|
213
|
+
if (a0 && a0.type === 'ObjectExpression') return recordHttp(b.pkg, objString(a0, 'method'), objProp(a0, 'url')?.value, line, objString(a0, 'baseURL') ?? b.base);
|
|
214
|
+
return recordHttp(b.pkg, objString(a1, 'method'), a0, line, b.base);
|
|
215
|
+
}
|
|
216
|
+
if (b && b.kind === 'named') result.sdk.push({ pkg: b.pkg, op: b.imported, line });
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
const ch = chainOf(callee);
|
|
220
|
+
if (!ch || !bindings.has(ch.root)) return;
|
|
221
|
+
const b = bindings.get(ch.root);
|
|
222
|
+
const method = ch.path[ch.path.length - 1];
|
|
223
|
+
if (['axios', 'got', 'ky', 'superagent'].includes(b.pkg) || b.kind === 'http') {
|
|
224
|
+
if (ch.path.length === 1 && HTTP_METHODS.includes(method)) return recordHttp(b.pkg, method, a0, line, b.base);
|
|
225
|
+
if (ch.path.length === 1 && method === 'request' && a0 && a0.type === 'ObjectExpression') {
|
|
226
|
+
return recordHttp(b.pkg, objString(a0, 'method'), objProp(a0, 'url')?.value, line, objString(a0, 'baseURL') ?? b.base);
|
|
227
|
+
}
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
if (FETCH_PACKAGES.has(b.pkg)) return;
|
|
231
|
+
const op = [b.kind === 'named' ? b.imported : b.prefix, ...ch.path].filter(Boolean).join('.');
|
|
232
|
+
if (op) result.sdk.push({ pkg: b.pkg, op, line });
|
|
233
|
+
});
|
|
234
|
+
return result;
|
|
235
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Qué es cada dependencia y cada host: SDK de una API, cliente HTTP o ninguna de las dos.
|
|
2
|
+
// La fuente de verdad es data/known-sdks.json (datos, no código: se amplía sin tocar esto).
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
|
|
7
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
export const KNOWN = JSON.parse(fs.readFileSync(path.join(HERE, '..', '..', 'data', 'known-sdks.json'), 'utf8'));
|
|
9
|
+
|
|
10
|
+
// Palabras que, en el nombre de un paquete, sugieren que es el cliente de una API.
|
|
11
|
+
const NAME_HINT = /(^|[-_./@])(api|sdk|client)([-_./]|$)/i;
|
|
12
|
+
|
|
13
|
+
// Clasifica un paquete de un ecosistema (npm, composer, pypi).
|
|
14
|
+
// → { kind: 'sdk', provider, category, confidence, source } | { kind: 'http-client' } | null
|
|
15
|
+
export function classifyPackage(ecosystem, name) {
|
|
16
|
+
const curated = KNOWN[ecosystem]?.[name];
|
|
17
|
+
if (curated) return { kind: 'sdk', ...curated, confidence: 'alta', source: 'curated' };
|
|
18
|
+
// Antes que los prefijos de familias: @aws-sdk/types es de la familia de AWS pero son solo tipos.
|
|
19
|
+
if (KNOWN.notApis.prefixes.some((p) => isFamily(name, p))) return null;
|
|
20
|
+
if (ecosystem === 'npm') {
|
|
21
|
+
for (const [prefix, info] of Object.entries(KNOWN.npmPrefixes)) {
|
|
22
|
+
if (name.startsWith(prefix)) return { kind: 'sdk', ...info, confidence: 'alta', source: 'curated' };
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
if ((KNOWN.httpClients[ecosystem] || []).includes(name)) return { kind: 'http-client' };
|
|
26
|
+
// Heurística: el nombre dice api/sdk/client. Confianza baja hasta que se vea usado (el inventario la sube).
|
|
27
|
+
if (NAME_HINT.test(name)) return { kind: 'sdk', provider: null, category: null, confidence: 'baja', source: 'heuristic' };
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ¿`name` es de la familia `p`? "@types/" y "babel-" son prefijos; "pg" es "pg" o "pg-*", pero no "pgsql"
|
|
32
|
+
// (con nombres cortos como "ws", un prefijo suelto se llevaría puesto un "wsfe-sdk").
|
|
33
|
+
function isFamily(name, p) {
|
|
34
|
+
if (p.endsWith('/') || p.endsWith('-')) return name.startsWith(p);
|
|
35
|
+
return name === p || name.startsWith(`${p}-`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Paquete de un import: 'openai/resources' → 'openai'; '@aws-sdk/client-s3/dist' → '@aws-sdk/client-s3'.
|
|
39
|
+
export function packageOf(spec) {
|
|
40
|
+
if (!spec || spec.startsWith('.') || spec.startsWith('/') || spec.startsWith('node:')) return null;
|
|
41
|
+
const parts = spec.split('/');
|
|
42
|
+
return spec.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Proveedor de un host, por sufijo: 'sheets.googleapis.com' → Google APIs.
|
|
46
|
+
export function providerForHost(host) {
|
|
47
|
+
if (!host) return null;
|
|
48
|
+
const h = host.toLowerCase();
|
|
49
|
+
for (const [known, info] of Object.entries(KNOWN.hosts)) {
|
|
50
|
+
if (h === known || h.endsWith(`.${known}`)) return info;
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Hosts que no son APIs de terceros: la propia máquina o la red local.
|
|
56
|
+
export function isInternalHost(host) {
|
|
57
|
+
const h = (host || '').toLowerCase();
|
|
58
|
+
return !h || h === 'localhost' || h.endsWith('.localhost') || h.endsWith('.local') || h.endsWith('.internal')
|
|
59
|
+
|| /^127\./.test(h) || h === '0.0.0.0' || h === '[::1]' || /^10\./.test(h) || /^192\.168\./.test(h);
|
|
60
|
+
}
|
package/src/packs.js
CHANGED
|
@@ -3,18 +3,33 @@ import fs from 'node:fs';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
import arca from './codemods/arca-wsfe-condicion-iva.js';
|
|
6
|
+
import arcaPhp from './codemods/arca-wsfe-condicion-iva-php.js';
|
|
6
7
|
import mercadopago from './codemods/mercadopago-payments-to-orders.js';
|
|
7
8
|
import aiModels from './detectors/ai-model-retirements.js';
|
|
9
|
+
import { wsdlDetection, soapOnly } from './codemods/arca-rules.js';
|
|
10
|
+
import { isPhp } from './php.js';
|
|
8
11
|
|
|
9
12
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
10
13
|
export const PACKS_DIR = path.join(HERE, '..', 'packs');
|
|
11
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.
|
|
12
17
|
const TRANSFORMS = {
|
|
13
|
-
|
|
14
|
-
'
|
|
15
|
-
'
|
|
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
|
|
16
22
|
};
|
|
17
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
|
+
|
|
18
33
|
export const REQUIRED_FIELDS = ['id', 'version', 'provider', 'title', 'kind', 'severity', 'deadline', 'last_verified',
|
|
19
34
|
'summary_es', 'why_es', 'sources', 'detect', 'codemod', 'review_checklist_es'];
|
|
20
35
|
|
|
@@ -34,6 +49,7 @@ export function loadPacks(dir = PACKS_DIR) {
|
|
|
34
49
|
.sort((a, b) => (a.deadline || '9999').localeCompare(b.deadline || '9999'));
|
|
35
50
|
}
|
|
36
51
|
|
|
37
|
-
export function transformFor(packId) {
|
|
38
|
-
|
|
52
|
+
export function transformFor(packId, file = 'x.js') {
|
|
53
|
+
const byLang = TRANSFORMS[packId];
|
|
54
|
+
return (byLang && byLang[languageOf(file)]) || null;
|
|
39
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
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
// Reporte del scan y cuerpo del PR, en castellano.
|
|
2
|
-
|
|
2
|
+
import { apiLabel } from './inventory/index.js';
|
|
3
|
+
|
|
4
|
+
const LABEL ={
|
|
3
5
|
auto: '✅ automático',
|
|
4
6
|
'needs-review': '👀 revisar',
|
|
5
7
|
'needs-input': '✋ falta un dato',
|
|
@@ -26,16 +28,52 @@ export function counts(findings) {
|
|
|
26
28
|
return c;
|
|
27
29
|
}
|
|
28
30
|
|
|
29
|
-
|
|
31
|
+
function upgradesSection(upgrades) {
|
|
32
|
+
const out = ['## Versiones nuevas de tus SDKs de npm', '',
|
|
33
|
+
'Comparación de la API pública (tipos `.d.ts`) entre la versión instalada y la última publicada, cruzada con lo que usa este repo. Solo avisa: no cambia código.', '',
|
|
34
|
+
'| SDK | Instalada → última | Resultado |', '|---|---|---|'];
|
|
35
|
+
const STATUS = {
|
|
36
|
+
afectado: (r) => `⚠ ${r.affected.length} operación(es) del repo cambian o desaparecen`,
|
|
37
|
+
'sin-impacto': (r) => `✓ lo que usás no cambia (${r.totalChanges} cambios en el SDK)`,
|
|
38
|
+
'al-dia': () => '✓ al día',
|
|
39
|
+
'sin-verificar': () => '? tus operaciones no aparecen en los tipos instalados',
|
|
40
|
+
};
|
|
41
|
+
for (const r of upgrades) {
|
|
42
|
+
const res = STATUS[r.status] ? STATUS[r.status](r) : `— ${r.error || 'sin versión instalada'}`;
|
|
43
|
+
out.push(`| \`${r.package}\` | ${r.installed ? `${r.installed} → ${r.latest}` : '—'} | ${res} |`);
|
|
44
|
+
}
|
|
45
|
+
out.push('');
|
|
46
|
+
for (const r of upgrades.filter((x) => x.status === 'afectado')) {
|
|
47
|
+
out.push(`### \`${r.package}\` ${r.installed} → ${r.latest}`, '');
|
|
48
|
+
for (const a of r.affected) {
|
|
49
|
+
out.push(`- \`${a.op}\` (${a.locations.map((l) => `\`${l.file}:${l.line}\``).join(', ')}): ${a.changes.map((c) => c.message).join(' ')}`);
|
|
50
|
+
}
|
|
51
|
+
out.push('');
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function scanReport({ inventory, findings, packs, changed, upgrades = null }, today = new Date()) {
|
|
30
57
|
const out = [];
|
|
31
58
|
out.push(`# Parche — reporte de \`${inventory.repo}\``, '');
|
|
32
59
|
out.push('## APIs que usa este repo', '');
|
|
33
|
-
|
|
34
|
-
if (
|
|
35
|
-
|
|
36
|
-
out.push(
|
|
60
|
+
out.push('Todas las APIs de terceros que se encontraron en el código, tengan pack de Parche o no.', '');
|
|
61
|
+
if (inventory.apis.length) {
|
|
62
|
+
const KIND = { sdk: 'SDK', http: 'HTTP directo', 'ai-model': 'Modelos de IA' };
|
|
63
|
+
out.push('| Tipo | API | Rubro | Confianza | Operaciones (usos) | Pack |', '|---|---|---|---|---|---|');
|
|
64
|
+
for (const api of inventory.apis) {
|
|
65
|
+
const ops = api.operations.map((o) => `\`${o.op}\` ×${o.count}`).join(', ') || 'declarada, sin usos encontrados';
|
|
66
|
+
out.push(`| ${KIND[api.kind]} | ${apiLabel(api)} | ${api.category || '—'} | ${api.confidence} | ${ops} | ${api.packs.length ? 'sí' : '—'} |`);
|
|
67
|
+
}
|
|
68
|
+
out.push('');
|
|
69
|
+
} else {
|
|
70
|
+
out.push('No se encontraron APIs de terceros.', '');
|
|
37
71
|
}
|
|
38
|
-
|
|
72
|
+
if (inventory.unresolved.length) {
|
|
73
|
+
out.push('**Llamadas HTTP con URL dinámica** (no se puede saber a qué host van):', '',
|
|
74
|
+
...inventory.unresolved.map((u) => `- \`${u.file}:${u.line}\` (${u.client})`), '');
|
|
75
|
+
}
|
|
76
|
+
if (upgrades) out.push(...upgradesSection(upgrades));
|
|
39
77
|
for (const pack of packs) {
|
|
40
78
|
const fs = findings.filter((f) => f.pack === pack.id);
|
|
41
79
|
if (!fs.length) continue;
|
|
@@ -45,6 +83,7 @@ export function scanReport({ inventory, findings, packs, changed }, today = new
|
|
|
45
83
|
for (const f of fs) {
|
|
46
84
|
out.push(`- ${LABEL[f.status]} \`${f.file}${f.line ? `:${f.line}` : ''}\` — ${f.message}`);
|
|
47
85
|
if (f.suggestion) out.push(` - Sugerido: \`${f.suggestion}\``);
|
|
86
|
+
if (f.partial) out.push(` - ${LABEL['needs-input']} ${f.partial.message}`, ` - Cuando tengas el dato: \`${f.partial.suggestion}\``);
|
|
48
87
|
}
|
|
49
88
|
out.push('');
|
|
50
89
|
}
|
|
@@ -54,6 +93,11 @@ export function scanReport({ inventory, findings, packs, changed }, today = new
|
|
|
54
93
|
return out.join('\n');
|
|
55
94
|
}
|
|
56
95
|
|
|
96
|
+
// Si algún arreglo es parcial, qué casos cubre ("consumidor final"): va en el título y arriba del cuerpo del PR.
|
|
97
|
+
export function partialCovers(findings) {
|
|
98
|
+
return [...new Set(findings.filter((f) => f.partial).map((f) => f.partial.covers))];
|
|
99
|
+
}
|
|
100
|
+
|
|
57
101
|
export function prBody({ pack, findings, changed, verification }, today = new Date()) {
|
|
58
102
|
const fs = findings.filter((f) => f.pack === pack.id);
|
|
59
103
|
const c = counts(fs);
|
|
@@ -61,28 +105,51 @@ export function prBody({ pack, findings, changed, verification }, today = new Da
|
|
|
61
105
|
out.push(`## ${pack.title}`, '');
|
|
62
106
|
out.push(`> **${pack.provider}** · severidad ${SEVERITY[pack.severity]} · fecha límite: ${deadlineText(pack, today)}`, '');
|
|
63
107
|
out.push('### Qué cambió', '', pack.why_es, '');
|
|
64
|
-
|
|
108
|
+
// Sin cambios de código el texto se usa como issue: los títulos no pueden hablar de "este PR".
|
|
109
|
+
const asIssue = !changed.length;
|
|
110
|
+
out.push(asIssue ? '### Qué encontró Parche' : '### Qué hace este PR', '');
|
|
111
|
+
const covers = partialCovers(fs);
|
|
112
|
+
if (covers.length && !asIssue) {
|
|
113
|
+
out.push(`> **Este PR es parcial (${covers.join(', ')}).** Arregla solo esos casos, sin inventar datos: el resto del pedido sale igual que hoy. Lo que no cubre está en "Lo que este PR NO resuelve" y hay que completarlo antes de la fecha límite.`, '');
|
|
114
|
+
}
|
|
65
115
|
const applied = fs.filter((f) => f.status === 'auto' || f.status === 'needs-review');
|
|
66
116
|
out.push(changed.length
|
|
67
117
|
? `Modifica ${changed.length} archivo(s): ${changed.map((x) => `\`${x.file}\``).join(', ')}.`
|
|
68
|
-
: 'No
|
|
118
|
+
: 'No hay un arreglo automático seguro: esto es lo que hay que resolver a mano.', '');
|
|
69
119
|
for (const f of applied) out.push(`- ${LABEL[f.status]} \`${f.file}:${f.line}\` — ${f.message}`);
|
|
70
|
-
|
|
120
|
+
// Lo que falta: los hallazgos sin arreglo y, de cada arreglo parcial, la parte que no cubre.
|
|
121
|
+
const pending = [
|
|
122
|
+
...fs.filter((f) => f.status === 'needs-input' || f.status === 'blocked'),
|
|
123
|
+
...fs.filter((f) => f.partial).map((f) => ({ ...f, status: 'needs-input', message: f.partial.message, suggestion: f.partial.suggestion })),
|
|
124
|
+
];
|
|
71
125
|
if (pending.length) {
|
|
72
|
-
out.push('', '### Lo que este PR NO resuelve (necesita una persona)', '');
|
|
126
|
+
out.push('', asIssue ? '### Lo que hay que resolver (necesita una persona)' : '### Lo que este PR NO resuelve (necesita una persona)', '');
|
|
73
127
|
for (const f of pending) {
|
|
74
128
|
out.push(`- ${LABEL[f.status]} \`${f.file}:${f.line}\` — ${f.message}`);
|
|
75
129
|
if (f.suggestion) out.push(` - Agregá: \`${f.suggestion}\``);
|
|
76
130
|
}
|
|
77
131
|
}
|
|
78
|
-
|
|
79
|
-
if (
|
|
80
|
-
out.push(
|
|
81
|
-
if (verification
|
|
82
|
-
|
|
83
|
-
|
|
132
|
+
// Un issue no cambia código: no hay verificación que mostrar (antes decía "Sin verificar").
|
|
133
|
+
if (!asIssue) {
|
|
134
|
+
out.push('', '### Verificación', '');
|
|
135
|
+
if (verification) {
|
|
136
|
+
out.push(`- Sintaxis de los archivos modificados: ${verification.parse.ok ? '✅ OK' : '❌ ' + verification.parse.errors.join('; ')}`);
|
|
137
|
+
const t = verification.tests;
|
|
138
|
+
if (t?.notRun) {
|
|
139
|
+
// Lo que escribió a mano el frente B en el primer PR real (obraya), ahora lo dice Parche.
|
|
140
|
+
out.push(t.ci.length
|
|
141
|
+
? `- Tests del repo: Parche no los corrió; los corre el CI de este repo (${t.ci.map((w) => `\`${w}\``).join(', ')}) en este PR.`
|
|
142
|
+
: '- Tests del repo: Parche no los corrió (sin `--verify` no ejecuta código del repo). Corrélos antes de mergear.');
|
|
143
|
+
} else if (t) {
|
|
144
|
+
out.push(t.skipped
|
|
145
|
+
? '- Tests del repo: — no hay tests para correr; se validó la sintaxis de los archivos modificados.'
|
|
146
|
+
: `- Tests del repo (${t.commands.map((c) => `\`${c}\``).join(' y ')}): ${t.ok ? '✅ pasan' : '❌ fallan'}`);
|
|
147
|
+
}
|
|
148
|
+
} else {
|
|
149
|
+
out.push('- Sin verificar (corré `parche pr --verify`).');
|
|
150
|
+
}
|
|
84
151
|
}
|
|
85
|
-
out.push('', '### Checklist antes de mergear', '', ...pack.review_checklist_es.map((x) => `- [ ] ${x}`), '');
|
|
152
|
+
out.push('', asIssue ? '### Checklist' : '### Checklist antes de mergear', '', ...pack.review_checklist_es.map((x) => `- [ ] ${x}`), '');
|
|
86
153
|
out.push('### Fuentes', '', ...pack.sources.map((s) => `- [${s.title}](${s.url})`), '');
|
|
87
154
|
out.push(`<sub>Pack \`${pack.id}\` v${pack.version}, verificado el ${pack.last_verified}. Resumen: ${Object.entries(c).map(([k, v]) => `${LABEL[k]} ${v}`).join(' · ')}. ¿Este PR te sirvió? Mergealo, editalo o cerralo: Parche aprende de cada resultado.</sub>`, '');
|
|
88
155
|
return out.join('\n');
|