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.
- package/CHANGELOG.md +97 -0
- package/README.md +83 -13
- package/bin/parche.js +144 -42
- 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 +109 -0
- package/src/codemods/arca-wsfe-condicion-iva-php.js +195 -0
- package/src/codemods/arca-wsfe-condicion-iva.js +105 -66
- 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 +70 -17
- 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
|
@@ -1,65 +1,75 @@
|
|
|
1
|
-
// Pack arca.wsfe.condicion-iva-receptor
|
|
1
|
+
// Pack arca.wsfe.condicion-iva-receptor (JavaScript/TypeScript, afip.js)
|
|
2
2
|
// Agrega CondicionIVAReceptorId a cada pedido de CAE hecho con afip.js cuando se puede inferir
|
|
3
3
|
// sin datos del negocio, y marca el resto para que una persona complete el dato.
|
|
4
4
|
import {
|
|
5
|
-
jFor, detectQuote, eolOf, offsetAt, getProp, hasSpread, isQuotedKey, isPlainProp, literalValue, resolveBinding, finding,
|
|
5
|
+
jFor, detectQuote, eolOf, offsetAt, applyEdits, getProp, hasSpread, isQuotedKey, isPlainProp, literalValue, resolveBinding, finding,
|
|
6
6
|
} from '../ast.js';
|
|
7
|
+
import {
|
|
8
|
+
PACK, decideCondition, validCodesText, missingMessage, appliedMessage, soapDetection, ARCA_HINT, libraryCaveat, alreadyMigratedPrefix,
|
|
9
|
+
} from './arca-rules.js';
|
|
10
|
+
import { emptyContext } from '../context.js';
|
|
7
11
|
|
|
8
|
-
const PACK = 'arca.wsfe.condicion-iva-receptor';
|
|
9
12
|
const VOUCHER_METHODS = new Set(['createVoucher', 'createNextVoucher']);
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
13
|
+
const WRAPPERS = new Set(['TSAsExpression', 'TSNonNullExpression', 'TSTypeAssertion', 'TSSatisfiesExpression', 'ParenthesizedExpression']);
|
|
14
|
+
|
|
15
|
+
// (afip.ElectronicBilling as any) → afip.ElectronicBilling
|
|
16
|
+
const unwrap = (n) => {
|
|
17
|
+
while (n && WRAPPERS.has(n.type)) n = n.expression;
|
|
18
|
+
return n;
|
|
14
19
|
};
|
|
20
|
+
const isEBMember = (n) => !!(n && n.type === 'MemberExpression' && !n.computed && n.property.type === 'Identifier' && n.property.name === 'ElectronicBilling');
|
|
15
21
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
22
|
+
// ¿`node` es `<algo>.ElectronicBilling`? También con casts de TS y con un alias en el mismo scope
|
|
23
|
+
// (`const eb = afip.ElectronicBilling; eb.createVoucher(...)`): los dos aparecieron en repos reales.
|
|
24
|
+
function isElectronicBilling(node, p) {
|
|
25
|
+
const n = unwrap(node);
|
|
26
|
+
if (isEBMember(n)) return true;
|
|
27
|
+
if (n && n.type === 'Identifier') {
|
|
28
|
+
const decl = resolveBinding(p, n.name);
|
|
29
|
+
return isEBMember(unwrap(decl && decl.node.init));
|
|
30
|
+
}
|
|
31
|
+
return false;
|
|
19
32
|
}
|
|
20
33
|
|
|
21
|
-
function
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
VOUCHER_METHODS.has(callee.property.name) &&
|
|
27
|
-
callee.object.type === 'MemberExpression' &&
|
|
28
|
-
!callee.object.computed &&
|
|
29
|
-
callee.object.property.name === 'ElectronicBilling'
|
|
30
|
-
);
|
|
34
|
+
function voucherCallee(p) {
|
|
35
|
+
const callee = unwrap(p.node.callee);
|
|
36
|
+
if (!callee || callee.type !== 'MemberExpression' || callee.computed || callee.property.type !== 'Identifier') return null;
|
|
37
|
+
if (!VOUCHER_METHODS.has(callee.property.name) || !isElectronicBilling(callee.object, p)) return null;
|
|
38
|
+
return callee;
|
|
31
39
|
}
|
|
32
40
|
|
|
33
41
|
export function decide(obj, pack) {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
return { status: 'auto', code: 5, comment: ' parche: 5 = Consumidor Final (DocTipo 99, sin identificar)' };
|
|
39
|
-
}
|
|
40
|
-
if (docTipo === 96 && (clase === 'B' || clase === 'C')) {
|
|
41
|
-
return { status: 'needs-review', code: 5, comment: ' parche: inferido Consumidor Final (DNI + comprobante ' + clase + ') — REVISAR' };
|
|
42
|
-
}
|
|
43
|
-
const valid = clase ? pack.codes_by_class[clase] : null;
|
|
44
|
-
return { status: 'needs-input', clase, valid };
|
|
42
|
+
return decideCondition({
|
|
43
|
+
docTipo: literalValue(getProp(obj, 'DocTipo')?.value),
|
|
44
|
+
cbteTipo: literalValue(getProp(obj, 'CbteTipo')?.value),
|
|
45
|
+
}, pack);
|
|
45
46
|
}
|
|
46
47
|
|
|
47
|
-
export default function transform({ source, file, pack }) {
|
|
48
|
-
const j = jFor(file);
|
|
48
|
+
export default function transform({ source, file, pack, context = emptyContext() }) {
|
|
49
49
|
const findings = [];
|
|
50
50
|
const usages = [];
|
|
51
|
+
if (!ARCA_HINT.test(source)) return { output: null, findings, usages };
|
|
52
|
+
const caveat = libraryCaveat(context, 'npm');
|
|
53
|
+
const migrated = alreadyMigratedPrefix(context);
|
|
54
|
+
const j = jFor(file);
|
|
51
55
|
let root;
|
|
52
56
|
try {
|
|
53
57
|
root = j(source);
|
|
54
|
-
} catch {
|
|
58
|
+
} catch (err) {
|
|
59
|
+
// Antes se descartaba en silencio: un archivo de ARCA que no parsea es justo el que hay que mirar.
|
|
60
|
+
findings.push({ pack: PACK, file, line: err.loc?.line || null, status: 'needs-input',
|
|
61
|
+
message: `No pude analizar este archivo (${err.message.split('\n')[0]}). Revisá a mano las llamadas a ElectronicBilling.` });
|
|
55
62
|
return { output: null, findings, usages };
|
|
56
63
|
}
|
|
57
64
|
let changed = false;
|
|
58
65
|
const edits = [];
|
|
66
|
+
// Objetos ya editados: el mismo `payload` puede ir a dos llamadas (lo vimos en un repo real). Como se edita
|
|
67
|
+
// texto y el árbol no se entera, sin esto se insertaba dos veces → clave duplicada y TypeScript no compila.
|
|
68
|
+
const edited = new Set();
|
|
59
69
|
|
|
60
|
-
root.find(j.CallExpression).filter((p) =>
|
|
70
|
+
root.find(j.CallExpression).filter((p) => voucherCallee(p) !== null).forEach((p) => {
|
|
61
71
|
const call = p.node;
|
|
62
|
-
usages.push({ provider: 'arca', op: `ElectronicBilling.${
|
|
72
|
+
usages.push({ provider: 'arca', op: `ElectronicBilling.${voucherCallee(p).property.name}`, line: call.loc?.start.line });
|
|
63
73
|
const arg = call.arguments[0];
|
|
64
74
|
let obj = null;
|
|
65
75
|
if (arg && arg.type === 'ObjectExpression') obj = arg;
|
|
@@ -68,19 +78,16 @@ export default function transform({ source, file, pack }) {
|
|
|
68
78
|
if (decl && decl.node.init && decl.node.init.type === 'ObjectExpression') obj = decl.node.init;
|
|
69
79
|
}
|
|
70
80
|
if (!obj || hasSpread(obj)) {
|
|
71
|
-
|
|
72
|
-
|
|
81
|
+
// Si el repo ya usa el campo en otro lado, lo más probable es que el builder ya lo ponga: "revisar".
|
|
82
|
+
findings.push(finding(PACK, file, call, migrated ? 'needs-review' : 'needs-input', (migrated || '') + unreadableMessage(j, root, p, arg, obj)));
|
|
73
83
|
return;
|
|
74
84
|
}
|
|
75
|
-
if (getProp(obj, 'CondicionIVAReceptorId')) return; // ya está migrado
|
|
85
|
+
if (getProp(obj, 'CondicionIVAReceptorId') || edited.has(obj)) return; // ya está migrado (o ya lo editamos)
|
|
76
86
|
|
|
77
87
|
const d = decide(obj, pack);
|
|
78
88
|
if (d.status === 'needs-input') {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
findings.push(finding(PACK, file, call, 'needs-input',
|
|
82
|
-
`Falta CondicionIVAReceptorId y no se puede inferir sin saber la condición frente al IVA del cliente${d.clase ? ` (comprobante clase ${d.clase})` : ''}.`,
|
|
83
|
-
{ suggestion: `'CondicionIVAReceptorId': <condición del cliente>, // valores válidos: ${lista}` }));
|
|
89
|
+
findings.push(finding(PACK, file, call, 'needs-input', missingMessage(d) + (caveat ? ` ${caveat}` : ''),
|
|
90
|
+
{ suggestion: `'CondicionIVAReceptorId': <condición del cliente>, // valores válidos: ${validCodesText(d, pack)}` }));
|
|
84
91
|
return;
|
|
85
92
|
}
|
|
86
93
|
|
|
@@ -89,28 +96,67 @@ export default function transform({ source, file, pack }) {
|
|
|
89
96
|
const q = detectQuote(source) === 'double' ? '"' : "'";
|
|
90
97
|
const keyText = quoted ? `${q}CondicionIVAReceptorId${q}` : 'CondicionIVAReceptorId';
|
|
91
98
|
const anchor = getProp(obj, 'DocNro') || props[props.length - 1];
|
|
92
|
-
|
|
99
|
+
const isLast = anchor === obj.properties[obj.properties.length - 1];
|
|
100
|
+
edits.push(...insertAfterProp(source, anchor, `${keyText}: ${d.code}`, d.comment, { isLast }));
|
|
101
|
+
edited.add(obj);
|
|
93
102
|
changed = true;
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
: 'Agregado CondicionIVAReceptorId: 5 por inferencia (DNI + comprobante B/C). Confirmá que el cliente no sea exento ni no alcanzado.'));
|
|
103
|
+
// Con librería vieja o WSDL viejo el arreglo no alcanza solo: deja de ser "automático".
|
|
104
|
+
const status = caveat && d.status === 'auto' ? 'needs-review' : d.status;
|
|
105
|
+
findings.push(finding(PACK, file, call, status, appliedMessage(d.status) + (caveat ? ` ${caveat}` : '')));
|
|
98
106
|
});
|
|
99
107
|
|
|
100
|
-
|
|
101
|
-
if (
|
|
102
|
-
const line = source.split('\n').findIndex((l) => /FECAESolicitar|FECAEDetRequest/.test(l)) + 1;
|
|
103
|
-
findings.push({ pack: PACK, file, line, status: 'needs-input',
|
|
104
|
-
message: 'Integración SOAP propia con WSFEv1 sin CondicionIVAReceptorId en el FECAEDetRequest. Hay que agregarlo al XML.' });
|
|
105
|
-
usages.push({ provider: 'arca', op: 'WSFEv1 SOAP', line });
|
|
106
|
-
}
|
|
108
|
+
const soap = soapDetection(source, file, context);
|
|
109
|
+
if (soap) { findings.push(soap.finding); usages.push(soap.usage); }
|
|
107
110
|
|
|
108
111
|
return { output: changed ? applyEdits(source, edits) : null, findings, usages };
|
|
109
112
|
}
|
|
110
113
|
|
|
114
|
+
// Cuando no se puede leer el comprobante, decir DÓNDE hay que agregar el dato: la función que lo arma
|
|
115
|
+
// (y de qué módulo viene) o que llega como parámetro. Lo encontrado en repos reales: payload = buildVoucherPayload(...).
|
|
116
|
+
function unreadableMessage(j, root, p, arg, obj) {
|
|
117
|
+
const tail = 'con la condición frente al IVA del cliente.';
|
|
118
|
+
if (obj) return `No pude leer los datos del comprobante (el objeto usa ...spread). Agregá CondicionIVAReceptorId a mano, ${tail}`;
|
|
119
|
+
if (arg && arg.type === 'Identifier') {
|
|
120
|
+
const decl = resolveBinding(p, arg.name);
|
|
121
|
+
let init = decl && decl.node.init;
|
|
122
|
+
if (init && init.type === 'AwaitExpression') init = init.argument;
|
|
123
|
+
if (init && init.type === 'CallExpression') {
|
|
124
|
+
const fn = init.callee.type === 'Identifier' ? init.callee.name
|
|
125
|
+
: init.callee.type === 'MemberExpression' && !init.callee.computed && init.callee.property.type === 'Identifier' ? init.callee.property.name : null;
|
|
126
|
+
if (fn) {
|
|
127
|
+
const from = importSourceOf(j, root, fn);
|
|
128
|
+
return `Los datos del comprobante los arma \`${fn}()\`${from ? ` (de \`${from}\`)` : ''}: agregá CondicionIVAReceptorId ahí, ${tail}`;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if (!decl) return `Los datos del comprobante llegan en \`${arg.name}\` desde afuera de esta función: agregá CondicionIVAReceptorId donde se arma ese objeto, ${tail}`;
|
|
132
|
+
}
|
|
133
|
+
return `No pude leer los datos del comprobante (no es un objeto literal). Agregá CondicionIVAReceptorId a mano, ${tail}`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// De qué módulo viene `name`: import estático, require o import() dinámico con desestructuración.
|
|
137
|
+
function importSourceOf(j, root, name) {
|
|
138
|
+
let src = null;
|
|
139
|
+
root.find(j.ImportDeclaration).forEach((ip) => {
|
|
140
|
+
if (ip.node.specifiers.some((s) => s.local && s.local.name === name)) src = ip.node.source.value;
|
|
141
|
+
});
|
|
142
|
+
if (src) return src;
|
|
143
|
+
root.find(j.VariableDeclarator).forEach((vp) => {
|
|
144
|
+
const { id } = vp.node;
|
|
145
|
+
let init = vp.node.init;
|
|
146
|
+
if (!init || id.type !== 'ObjectPattern') return;
|
|
147
|
+
if (init.type === 'AwaitExpression') init = init.argument;
|
|
148
|
+
const isImportCall = init.type === 'CallExpression' && (init.callee.type === 'Import' || (init.callee.type === 'Identifier' && init.callee.name === 'require'));
|
|
149
|
+
const source = init.type === 'ImportExpression' ? init.source : isImportCall ? init.arguments[0] : null;
|
|
150
|
+
if (!source || typeof source.value !== 'string') return;
|
|
151
|
+
if (id.properties.some((pr) => pr.value && pr.value.type === 'Identifier' && pr.value.name === name)) src = source.value;
|
|
152
|
+
});
|
|
153
|
+
return src;
|
|
154
|
+
}
|
|
155
|
+
|
|
111
156
|
// Inserta una propiedad nueva después de `prop` tocando solo el texto necesario,
|
|
112
157
|
// para que el diff del PR sea de una línea y respete el formato del cliente.
|
|
113
|
-
|
|
158
|
+
// Si `prop` es la última y no tenía coma final, la nueva tampoco la lleva.
|
|
159
|
+
export function insertAfterProp(source, prop, text, comment, { isLast = false } = {}) {
|
|
114
160
|
const eol = eolOf(source);
|
|
115
161
|
const end = offsetAt(source, prop.loc.end);
|
|
116
162
|
let k = end;
|
|
@@ -121,18 +167,11 @@ export function insertAfterProp(source, prop, text, comment) {
|
|
|
121
167
|
if (nl !== -1 && rest.trim() === '') {
|
|
122
168
|
const lineStart = offsetAt(source, { line: prop.loc.start.line, column: 0 });
|
|
123
169
|
const indent = source.slice(lineStart).match(/^[ \t]*/)[0];
|
|
124
|
-
const
|
|
170
|
+
const trailing = hasComma || !isLast ? ',' : '';
|
|
171
|
+
const out = [{ offset: nl + 1, text: `${indent}//${comment}${eol}${indent}${text}${trailing}${eol}` }];
|
|
125
172
|
if (!hasComma) out.push({ offset: end, text: ',' });
|
|
126
173
|
return out;
|
|
127
174
|
}
|
|
128
175
|
// Objeto en una sola línea.
|
|
129
176
|
return [{ offset: hasComma ? k + 1 : end, text: `${hasComma ? '' : ','} ${text} /*${comment} */${hasComma ? ',' : ''}` }];
|
|
130
177
|
}
|
|
131
|
-
|
|
132
|
-
function applyEdits(source, edits) {
|
|
133
|
-
let out = source;
|
|
134
|
-
for (const e of [...edits].sort((a, b) => b.offset - a.offset)) {
|
|
135
|
-
out = out.slice(0, e.offset) + e.text + out.slice(e.offset);
|
|
136
|
-
}
|
|
137
|
-
return out;
|
|
138
|
-
}
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
45
|
-
const rel =
|
|
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
|
-
|
|
76
|
-
|
|
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
|
-
|
|
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',
|
|
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
|
|
62
|
+
export function remoteUrl(cwd, remote = 'origin') {
|
|
40
63
|
try {
|
|
41
|
-
return git(cwd, 'remote', 'get-url',
|
|
64
|
+
return git(cwd, 'remote', 'get-url', remote);
|
|
42
65
|
} catch {
|
|
43
66
|
return null;
|
|
44
67
|
}
|
|
45
68
|
}
|
|
46
69
|
|
|
47
|
-
export
|
|
48
|
-
|
|
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
|
}
|