parche-cli 0.1.1 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,133 @@
1
+ // Inventario de cualquier API (nivel 1 del producto): junta las dependencias declaradas, lo que
2
+ // encontró js-scan en cada archivo y lo que reportaron los packs, y arma .parche/inventory.json.
3
+ // El esquema está documentado en docs/inventario.md; si cambia de forma incompatible, sube SCHEMA_VERSION.
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { classifyPackage, providerForHost } from './known.js';
7
+ import { findFiles } from '../paths.js';
8
+
9
+ export const SCHEMA_VERSION = 1;
10
+ const KIND_ORDER = { sdk: 0, http: 1, 'ai-model': 2 };
11
+
12
+ const readJson = (p) => {
13
+ try {
14
+ return JSON.parse(fs.readFileSync(p, 'utf8'));
15
+ } catch {
16
+ return null;
17
+ }
18
+ };
19
+
20
+ // requirements.txt: "paquete==1.2", "paquete>=1", "paquete[extra]"; sin comentarios, -r, -e ni URLs.
21
+ export function parseRequirements(text) {
22
+ const out = {};
23
+ for (const raw of text.split(/\r?\n/)) {
24
+ const line = raw.replace(/#.*$/, '').trim();
25
+ if (!line || line.startsWith('-') || /^[a-z]+:\/\//i.test(line)) continue;
26
+ const m = line.match(/^([A-Za-z0-9][A-Za-z0-9._-]*)(\[[^\]]*\])?\s*(.*)$/);
27
+ if (m) out[m[1].toLowerCase()] = m[3].trim() || '*';
28
+ }
29
+ return out;
30
+ }
31
+
32
+ const MANIFEST_NAMES = new Set(['package.json', 'composer.json', 'requirements.txt']);
33
+
34
+ // Todos los manifiestos del repo, no solo los de la raíz: los monorepos (packages/backend/package.json,
35
+ // ms-billing/package.json) declaran ahí sus SDKs. Primero la raíz, después por profundidad.
36
+ export function findManifests(root) {
37
+ return findFiles(root, (name) => MANIFEST_NAMES.has(name));
38
+ }
39
+
40
+ // Dependencias declaradas por ecosistema (si un paquete aparece en varios manifiestos, gana el más cercano a la raíz).
41
+ export function readManifests(root) {
42
+ const deps = { npm: {}, composer: {}, pypi: {} };
43
+ const files = { npm: [], composer: [], pypi: [] };
44
+ const merge = (eco, entries, file) => {
45
+ files[eco].push(file);
46
+ for (const [name, version] of entries) if (!(name in deps[eco])) deps[eco][name] = version;
47
+ };
48
+ for (const rel of findManifests(root)) {
49
+ const abs = path.join(root, rel);
50
+ const name = path.posix.basename(rel);
51
+ if (name === 'package.json') {
52
+ const pkg = readJson(abs) || {};
53
+ merge('npm', Object.entries({ ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) }), rel);
54
+ } else if (name === 'composer.json') {
55
+ const c = readJson(abs) || {};
56
+ // fuera "php" y las extensiones "ext-*": no son paquetes
57
+ merge('composer', Object.entries({ ...(c.require || {}), ...(c['require-dev'] || {}) }).filter(([n]) => n.includes('/')), rel);
58
+ } else {
59
+ merge('pypi', Object.entries(parseRequirements(fs.readFileSync(abs, 'utf8'))), rel);
60
+ }
61
+ }
62
+ return { ...deps, files };
63
+ }
64
+
65
+ // fileScans: [{ file, scan }] con el resultado de scanJs; packs: los packs cargados.
66
+ export function buildInventory({ repo, manifests, fileScans, packs }) {
67
+ const apis = new Map();
68
+ const add = (id, base) => {
69
+ if (!apis.has(id)) apis.set(id, { id, ...base, operations: new Map() });
70
+ return apis.get(id);
71
+ };
72
+ const addOp = (api, op, file, line) => {
73
+ const o = api.operations.get(op) || { op, count: 0, locations: [] };
74
+ o.count++;
75
+ o.locations.push({ file, line });
76
+ api.operations.set(op, o);
77
+ };
78
+ const packsFor = (pkg) => packs.filter((p) => (p.detect.packages || []).includes(pkg)).map((p) => p.id);
79
+
80
+ // Dependencias declaradas que son SDKs de una API (aunque todavía no se vean usadas).
81
+ for (const eco of ['npm', 'composer', 'pypi']) {
82
+ for (const [name, version] of Object.entries(manifests[eco] || {})) {
83
+ const c = classifyPackage(eco, name);
84
+ if (!c || c.kind !== 'sdk') continue;
85
+ add(`${eco}:${name}`, { kind: 'sdk', provider: c.provider, category: c.category, ecosystem: eco, package: name, version,
86
+ confidence: c.confidence, source: c.source, packs: packsFor(name) });
87
+ }
88
+ }
89
+
90
+ const unresolved = [];
91
+ for (const { file, scan } of fileScans) {
92
+ for (const u of scan.sdk) {
93
+ let api = apis.get(`npm:${u.pkg}`);
94
+ if (!api) {
95
+ const c = classifyPackage('npm', u.pkg);
96
+ if (!c || c.kind !== 'sdk') continue; // no es el cliente de una API (lodash, react...)
97
+ api = add(`npm:${u.pkg}`, { kind: 'sdk', provider: c.provider, category: c.category, ecosystem: 'npm', package: u.pkg,
98
+ version: null, confidence: c.confidence, source: c.source, packs: packsFor(u.pkg) });
99
+ }
100
+ addOp(api, u.op, file, u.line);
101
+ }
102
+ for (const h of scan.http) {
103
+ const known = providerForHost(h.host);
104
+ const api = add(`http:${h.host}`, { kind: 'http', provider: known?.provider || null, category: known?.category || null,
105
+ host: h.host, confidence: known ? 'alta' : 'media', source: known ? 'curated' : 'usage', packs: [] });
106
+ addOp(api, `${h.method} ${h.path}`, file, h.line);
107
+ }
108
+ for (const m of scan.models) {
109
+ const api = add(`ai:${m.provider}`, { kind: 'ai-model', provider: m.provider, category: 'ia', confidence: 'alta',
110
+ source: 'usage', packs: packs.some((p) => p.id === 'ai.model-retirements') ? ['ai.model-retirements'] : [] });
111
+ addOp(api, m.id, file, m.line);
112
+ }
113
+ for (const r of scan.unresolved) unresolved.push({ file, ...r });
114
+ }
115
+
116
+ const list = [...apis.values()].map((api) => {
117
+ const operations = [...api.operations.values()].sort((a, b) => b.count - a.count || a.op.localeCompare(b.op));
118
+ // Un SDK detectado por el nombre que además se ve usado deja de ser una corazonada.
119
+ const confidence = api.source === 'heuristic' && operations.length ? 'media' : api.confidence;
120
+ return { ...api, confidence, operations };
121
+ }).sort((a, b) => KIND_ORDER[a.kind] - KIND_ORDER[b.kind]
122
+ || (a.provider || a.package || a.host || '').localeCompare(b.provider || b.package || b.host || ''));
123
+
124
+ const { files: manifestFiles = {}, ...dependencies } = manifests;
125
+ return { schemaVersion: SCHEMA_VERSION, repo, apis: list, unresolved, dependencies, manifestFiles };
126
+ }
127
+
128
+ // Nombre corto para mostrar una API en el reporte y en la CLI.
129
+ export function apiLabel(api) {
130
+ if (api.kind === 'sdk') return api.provider ? `${api.provider} · ${api.package}` : api.package;
131
+ if (api.kind === 'http') return api.provider ? `${api.provider} · ${api.host}` : api.host;
132
+ return `${api.provider} · modelos`;
133
+ }
@@ -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
- 'arca.wsfe.condicion-iva-receptor': arca,
14
- 'mercadopago.payments-to-orders': mercadopago,
15
- 'ai.model-retirements': aiModels,
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
- return TRANSFORMS[packId] || null;
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
+ }