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/src/context.js ADDED
@@ -0,0 +1,93 @@
1
+ // Contexto del repo para los packs: lo que un archivo solo no puede saber.
2
+ // Lo arma el motor una vez, antes de correr los packs, y cada transform lo recibe como `context`.
3
+ //
4
+ // versions: versión instalada de cada dependencia (de los lockfiles, de node_modules o del rango declarado)
5
+ // fieldFiles: archivos que ya usan CondicionIVAReceptorId (el repo ya empezó a migrar)
6
+ // staleWsdl: WSDL de WSFEv1 del repo que no tienen CondicionIVAReceptorId
7
+ // arcaLibs: librerías de ARCA del repo con lo que se sabe de si el campo llega a viajar
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ import { findFiles } from './paths.js';
11
+
12
+ const readJson = (p) => {
13
+ try {
14
+ return JSON.parse(fs.readFileSync(p, 'utf8'));
15
+ } catch {
16
+ return null;
17
+ }
18
+ };
19
+ const clean = (v) => String(v || '').replace(/^v/, '');
20
+
21
+ // Versiones instaladas por ecosistema: lockfiles (en cualquier carpeta) → node_modules de la raíz → rango declarado.
22
+ export function readVersions(root, manifests) {
23
+ const out = { npm: {}, composer: {} };
24
+ const set = (eco, name, version, source) => { if (name && version && !out[eco][name]) out[eco][name] = { version: clean(version), source }; };
25
+ for (const rel of findFiles(root, (n) => n === 'package-lock.json' || n === 'composer.lock')) {
26
+ const lock = readJson(path.join(root, rel));
27
+ if (!lock) continue;
28
+ if (rel.endsWith('composer.lock')) {
29
+ for (const p of [...(lock.packages || []), ...(lock['packages-dev'] || [])]) set('composer', p.name, p.version, rel);
30
+ } else {
31
+ for (const [k, v] of Object.entries(lock.packages || {})) {
32
+ const m = k.match(/^node_modules\/((?:@[^/]+\/)?[^/]+)$/); // solo las de primer nivel
33
+ if (m) set('npm', m[1], v.version, rel);
34
+ }
35
+ for (const [name, v] of Object.entries(lock.dependencies || {})) set('npm', name, v.version, rel); // lockfile v1
36
+ }
37
+ }
38
+ for (const name of Object.keys(manifests?.npm || {})) {
39
+ const pkg = readJson(path.join(root, 'node_modules', name, 'package.json'));
40
+ if (pkg?.version) set('npm', name, pkg.version, 'node_modules');
41
+ }
42
+ for (const eco of ['npm', 'composer']) {
43
+ for (const [name, range] of Object.entries(manifests?.[eco] || {})) {
44
+ const m = String(range).match(/(\d+)(?:\.(\d+))?(?:\.(\d+))?/);
45
+ if (m) set(eco, name, `${m[1]}.${m[2] || 0}.${m[3] || 0}`, 'declarada');
46
+ }
47
+ }
48
+ return out;
49
+ }
50
+
51
+ // Qué se sabe, por librería, de si CondicionIVAReceptorId llega a ARCA (verificado en su código el 16/10/2026):
52
+ // afip.js / afip.php ≥ 1.0: mandan JSON a la API de Afip SDK → sí.
53
+ // 0.x: arman el SOAP con un WSDL de 2021 incluido en la librería, sin el campo → no (PHP) o sin confirmar (JS).
54
+ // fertandil87/afip.php: actualizó su WSDL (lo incluye) → sí. eesnaola/afip.php: WSDL de 2021 → igual que 0.x.
55
+ const ARCA_LIBS = {
56
+ npm: { '@afipsdk/afip.js': (v) => (major(v) >= 1 ? 'si' : 'sin-confirmar') },
57
+ composer: {
58
+ 'afipsdk/afip.php': (v) => (major(v) >= 1 ? 'si' : 'no'),
59
+ 'ivanalemunioz/afip.php': (v) => (major(v) >= 1 ? 'si' : 'no'),
60
+ 'fertandil87/afip.php': () => 'si',
61
+ 'eesnaola/afip.php': () => 'no',
62
+ },
63
+ };
64
+ const major = (v) => Number(String(v).split('.')[0]);
65
+
66
+ export function arcaLibraries(versions) {
67
+ const out = [];
68
+ for (const [eco, libs] of Object.entries(ARCA_LIBS)) {
69
+ for (const [name, rule] of Object.entries(libs)) {
70
+ const v = versions[eco]?.[name];
71
+ if (v) out.push({ ecosystem: eco, name, version: v.version, source: v.source, travels: rule(v.version) });
72
+ }
73
+ }
74
+ return out;
75
+ }
76
+
77
+ // files: rutas relativas (con "/") de los archivos que el motor va a recorrer.
78
+ export function buildContext(root, files, manifests) {
79
+ const versions = readVersions(root, manifests);
80
+ const fieldFiles = [];
81
+ for (const rel of files) {
82
+ try {
83
+ if (fs.readFileSync(path.join(root, rel), 'utf8').includes('CondicionIVAReceptorId')) fieldFiles.push(rel);
84
+ } catch { /* archivo ilegible: lo reporta el pack que lo lea */ }
85
+ }
86
+ const staleWsdl = findFiles(root, (n) => /\.wsdl$/i.test(n)).filter((rel) => {
87
+ const text = fs.readFileSync(path.join(root, rel), 'utf8');
88
+ return /FECAESolicitar|FECAEDetRequest/.test(text) && !text.includes('CondicionIVAReceptorId');
89
+ });
90
+ return { versions, fieldFiles, staleWsdl, arcaLibs: arcaLibraries(versions) };
91
+ }
92
+
93
+ export const emptyContext = () => ({ versions: { npm: {}, composer: {} }, fieldFiles: [], staleWsdl: [], arcaLibs: [] });
@@ -1,36 +1,51 @@
1
1
  // Pack ai.model-retirements — solo detección.
2
2
  // Cambiar de modelo cambia el comportamiento del producto, así que Parche avisa y no reescribe.
3
+ //
4
+ // Menos ruido (frente B, 16/10): un ID que aparece en el contexto de una llamada (model: '...', DEFAULT_MODEL)
5
+ // es un uso; si aparece en una lista o un catálogo, se avisa como mención. Y en código de Azure OpenAI no se
6
+ // da por cerrada la Assistants API: la fecha oficial es la de la API de OpenAI.
3
7
  const PACK = 'ai.model-retirements';
4
8
 
9
+ const MODEL_CONTEXT = /\bmodel(?:Id|Name)?\b\s*[:=(]|["']model["']\s*[:=]|\b(?:DEFAULT|default)_?(?:MODEL|Model)\b|\bmodel\s*\(/i;
10
+ const AZURE = /azure|AzureOpenAI|openai\.azure\.com/i;
11
+
12
+ // Acepta el formato nuevo ({ date, replacement }) y el viejo (solo la fecha).
13
+ const entry = (v) => (typeof v === 'string' ? { date: v, replacement: null } : v);
14
+ const quoted = (line, id) => line.includes(`'${id}'`) || line.includes(`"${id}"`) || line.includes(`\`${id}\``);
15
+
5
16
  export default function detect({ source, file, pack, today = new Date().toISOString().slice(0, 10) }) {
6
17
  const findings = [];
7
18
  const usages = [];
8
19
  const { retired_models: retired, retirement_floors: floors, retired_endpoints: endpoints } = pack.detect;
20
+ const azure = AZURE.test(source);
9
21
  const lines = source.split('\n');
10
22
  lines.forEach((l, i) => {
11
- for (const [model, date] of Object.entries(retired)) {
12
- if (l.includes(`'${model}'`) || l.includes(`"${model}"`) || l.includes('`' + model + '`')) {
13
- const past = date <= today;
14
- findings.push({ pack: PACK, file, line: i + 1, status: 'detect-only',
15
- message: past
16
- ? `El modelo \`${model}\` fue retirado el ${date}: las llamadas ya fallan. Elegí un reemplazo en la página de deprecaciones y corré tus pruebas de prompts.`
17
- : `El modelo \`${model}\` se retira el ${date}.` });
18
- usages.push({ provider: 'ia', op: `modelo ${model}`, line: i + 1 });
19
- }
23
+ const line = i + 1;
24
+ const inCall = MODEL_CONTEXT.test(l);
25
+ for (const [model, raw] of Object.entries(retired)) {
26
+ if (!quoted(l, model)) continue;
27
+ const { date, replacement } = entry(raw);
28
+ const past = date <= today;
29
+ const repl = replacement ? ` Reemplazo recomendado: \`${replacement}\`.` : '';
30
+ const when = past ? `fue retirado el ${date}` : `se retira el ${date}`;
31
+ const message = inCall
32
+ ? `El modelo \`${model}\` ${when}${past ? ': las llamadas ya fallan' : ''}.${repl} Corré tus pruebas de prompts antes de cambiarlo.`
33
+ : `Aparece \`${model}\` (${when}) fuera de una llamada: si es una lista o un catálogo de modelos, actualizalo.${repl}`;
34
+ findings.push({ pack: PACK, file, line, status: 'detect-only', message });
35
+ usages.push({ provider: 'ia', op: `modelo ${model}`, line });
20
36
  }
21
37
  for (const [model, date] of Object.entries(floors)) {
22
- if (l.includes(model)) {
23
- findings.push({ pack: PACK, file, line: i + 1, status: 'detect-only',
24
- message: `El modelo \`${model}\` puede retirarse a partir del ${date} (fecha mínima publicada; hay 60 días de aviso). Planificá el reemplazo.` });
25
- usages.push({ provider: 'ia', op: `modelo ${model}`, line: i + 1 });
26
- }
38
+ if (!l.includes(model)) continue;
39
+ findings.push({ pack: PACK, file, line, status: 'detect-only',
40
+ message: `El modelo \`${model}\` puede retirarse a partir del ${date} (fecha mínima publicada; hay 60 días de aviso). Planificá el reemplazo.` });
41
+ usages.push({ provider: 'ia', op: `modelo ${model}`, line });
27
42
  }
28
43
  for (const ep of endpoints) {
29
- if (l.includes(ep.pattern)) {
30
- findings.push({ pack: PACK, file, line: i + 1, status: 'detect-only',
31
- message: `\`${ep.pattern}\` pertenece a la Assistants API de OpenAI, cerrada el ${ep.retired}. Reemplazo: ${ep.replacement_es}.` });
32
- usages.push({ provider: 'ia', op: 'OpenAI Assistants API', line: i + 1 });
33
- }
44
+ if (!l.includes(ep.pattern)) continue;
45
+ findings.push({ pack: PACK, file, line, status: 'detect-only', message: azure && ep.provider === 'openai'
46
+ ? `\`${ep.pattern}\` es la Assistants API. En Azure OpenAI sigue su propio calendario (el cierre del ${ep.retired} es el de la API de OpenAI): revisá el de Azure.`
47
+ : `\`${ep.pattern}\` pertenece a la Assistants API de OpenAI, cerrada el ${ep.retired}. Reemplazo: ${ep.replacement_es}.` });
48
+ usages.push({ provider: 'ia', op: 'OpenAI Assistants API', line });
34
49
  }
35
50
  });
36
51
  return { output: null, findings, usages };
package/src/engine.js CHANGED
@@ -1,10 +1,14 @@
1
1
  // Motor: recorre el repo, arma el inventario de APIs y corre los packs.
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
- import { loadPacks, transformFor } from './packs.js';
4
+ import { loadPacks, transformFor, languageOf } from './packs.js';
5
+ import { SKIP_DIRS, toPosix } from './paths.js';
6
+ import { scanJs } from './inventory/js-scan.js';
7
+ import { readManifests, buildInventory } from './inventory/index.js';
8
+ import { buildContext } from './context.js';
5
9
 
6
- const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', 'coverage', '.next', '.parche']);
7
- const EXT = /\.(m?js|cjs|jsx|ts|tsx)$/;
10
+ // JS/TS y PHP (codemods), WSDL (detección de esquemas viejos) y Python, C#, Java, Go, VB.NET (solo detección).
11
+ const EXT = /\.(m?js|cjs|jsx|ts|tsx|php|wsdl|py|cs|java|go|vb)$/i;
8
12
 
9
13
  export function listFiles(dir) {
10
14
  const out = [];
@@ -22,34 +26,31 @@ export function listFiles(dir) {
22
26
  return out.sort((a, b) => (toPosix(a) < toPosix(b) ? -1 : 1));
23
27
  }
24
28
 
25
- // Rutas siempre con "/": van al reporte, al cuerpo del PR y al inventario.
26
- const toPosix = (p) => p.split(path.sep).join('/');
27
-
28
- function readDeps(dir) {
29
- const pkgPath = path.join(dir, 'package.json');
30
- if (!fs.existsSync(pkgPath)) return {};
31
- const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
32
- return { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
33
- }
34
-
35
29
  export function run(dir, { write = false, packIds = null, today } = {}) {
36
30
  const root = path.resolve(dir);
37
31
  const packs = loadPacks().filter((p) => !packIds || packIds.includes(p.id));
38
- const deps = readDeps(root);
32
+ const manifests = readManifests(root);
33
+ const deps = { ...manifests.npm, ...manifests.composer };
39
34
  const files = listFiles(root);
35
+ const rels = files.map((abs) => toPosix(path.relative(root, abs)));
36
+ // Lo que un archivo solo no sabe (versiones instaladas, si el repo ya usa el campo, WSDL viejos).
37
+ const context = buildContext(root, rels, manifests);
40
38
  const findings = [];
41
39
  const usages = [];
40
+ const fileScans = []; // inventario de cualquier API (nivel 1), por archivo
42
41
  const changed = new Map(); // archivo relativo → [packs]
43
42
 
44
- for (const abs of files) {
45
- const rel = toPosix(path.relative(root, abs));
43
+ files.forEach((abs, i) => {
44
+ const rel = rels[i];
46
45
  let source = fs.readFileSync(abs, 'utf8');
46
+ // El inventario se arma sobre el archivo original, antes de que un pack lo modifique.
47
+ if (languageOf(rel) === 'js') fileScans.push({ file: rel, scan: scanJs(source, rel) });
47
48
  for (const pack of packs) {
48
- const t = transformFor(pack.id);
49
+ const t = transformFor(pack.id, rel);
49
50
  if (!t) continue;
50
51
  let res;
51
52
  try {
52
- res = t({ source, file: rel, pack, today });
53
+ res = t({ source, file: rel, pack, today, context });
53
54
  } catch (err) {
54
55
  findings.push({ pack: pack.id, file: rel, line: null, status: 'needs-input', message: `Parche no pudo procesar este archivo: ${err.message}` });
55
56
  continue;
@@ -62,7 +63,7 @@ export function run(dir, { write = false, packIds = null, today } = {}) {
62
63
  if (write) fs.writeFileSync(abs, source);
63
64
  }
64
65
  }
65
- }
66
+ });
66
67
 
67
68
  const providers = {};
68
69
  for (const u of usages) {
@@ -71,15 +72,18 @@ export function run(dir, { write = false, packIds = null, today } = {}) {
71
72
  p.ops[u.op] = (p.ops[u.op] || 0) + 1;
72
73
  p.files.add(u.file);
73
74
  }
75
+ const generatedAt = new Date().toISOString();
74
76
  const inventory = {
75
- generated_at: new Date().toISOString(),
76
- repo: path.basename(root),
77
+ ...buildInventory({ repo: path.basename(root), manifests, fileScans, packs }),
78
+ generatedAt,
79
+ // Campos anteriores a schemaVersion: lo que reportan los packs. Se mantienen por compatibilidad.
80
+ generated_at: generatedAt,
77
81
  packages: Object.fromEntries(Object.entries(deps).filter(([name]) =>
78
82
  packs.some((p) => (p.detect.packages || []).includes(name)))),
79
83
  providers: Object.fromEntries(Object.entries(providers).map(([k, v]) => [k, { ...v, files: [...v.files] }])),
80
84
  usages,
81
85
  };
82
- return { root, packs, inventory, findings, changed: [...changed.entries()].map(([file, packIds]) => ({ file, packs: packIds })) };
86
+ return { root, packs, context, inventory, findings, changed: [...changed.entries()].map(([file, packIds]) => ({ file, packs: packIds })) };
83
87
  }
84
88
 
85
89
  export const STATUS_ORDER = ['blocked', 'needs-input', 'needs-review', 'auto', 'detect-only'];
package/src/git.js CHANGED
@@ -26,9 +26,32 @@ export function checkoutNewBranch(cwd, name, base) {
26
26
  git(cwd, 'checkout', '-b', name);
27
27
  }
28
28
 
29
- export function commitAll(cwd, message) {
29
+ // Quién firma los commits de Parche. Nunca una dirección de un dominio que no controlamos: antes era
30
+ // bot@parche.dev, y parche.dev lo tiene registrado otra persona. En orden:
31
+ // 1. PARCHE_GIT_NAME y PARCHE_GIT_EMAIL, si están.
32
+ // 2. La identidad de git de quien lo corre (en su compu, o la que configuró su workflow).
33
+ // 3. En GitHub Actions sin identidad: el bot de Actions, el mismo que abre el PR con el GITHUB_TOKEN.
34
+ // Si no hay ninguna, error: no se inventa un autor.
35
+ export const ACTIONS_BOT = { name: 'github-actions[bot]', email: '41898282+github-actions[bot]@users.noreply.github.com' };
36
+
37
+ export function commitIdentity(cwd, env = process.env) {
38
+ if (env.PARCHE_GIT_NAME && env.PARCHE_GIT_EMAIL) return { name: env.PARCHE_GIT_NAME, email: env.PARCHE_GIT_EMAIL };
39
+ const get = (key) => {
40
+ try {
41
+ return execFileSync('git', ['config', key], { cwd, env, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
42
+ } catch { return ''; }
43
+ };
44
+ const name = get('user.name');
45
+ const email = get('user.email');
46
+ if (name && email) return { name, email };
47
+ if (env.GITHUB_ACTIONS === 'true') return ACTIONS_BOT;
48
+ throw new Error('No hay una identidad de git para firmar el commit: configurá `git config user.name` y `git config user.email`, o PARCHE_GIT_NAME y PARCHE_GIT_EMAIL.');
49
+ }
50
+
51
+ export function commitAll(cwd, message, env = process.env) {
52
+ const id = commitIdentity(cwd, env);
30
53
  git(cwd, 'add', '-A');
31
- git(cwd, '-c', 'user.name=Parche', '-c', 'user.email=bot@parche.dev', 'commit', '-q', '-m', message);
54
+ git(cwd, '-c', `user.name=${id.name}`, '-c', `user.email=${id.email}`, 'commit', '-q', '-m', message);
32
55
  return git(cwd, 'rev-parse', '--short', 'HEAD');
33
56
  }
34
57
 
@@ -36,14 +59,27 @@ export function checkout(cwd, branch) {
36
59
  git(cwd, 'checkout', '-q', branch);
37
60
  }
38
61
 
39
- export function originUrl(cwd) {
62
+ export function remoteUrl(cwd, remote = 'origin') {
40
63
  try {
41
- return git(cwd, 'remote', 'get-url', 'origin');
64
+ return git(cwd, 'remote', 'get-url', remote);
42
65
  } catch {
43
66
  return null;
44
67
  }
45
68
  }
46
69
 
47
- export function push(cwd, branch) {
48
- git(cwd, 'push', '-u', '--force', 'origin', branch);
70
+ export const originUrl = (cwd) => remoteUrl(cwd, 'origin');
71
+
72
+ export function push(cwd, branch, remote = 'origin') {
73
+ git(cwd, 'push', '-u', '--force', remote, branch);
74
+ }
75
+
76
+ // Descarta lo que Parche escribió en su rama (el árbol estaba limpio antes de empezar; .parche/ está excluido).
77
+ export function discardChanges(cwd) {
78
+ git(cwd, 'reset', '--hard', '-q');
79
+ }
80
+
81
+ export function deleteBranch(cwd, name) {
82
+ try {
83
+ git(cwd, 'branch', '-D', name);
84
+ } catch { /* ya no existía */ }
49
85
  }
@@ -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
+ }