parche-cli 0.0.0-stage → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,109 @@
1
+ // Reglas del pack arca.wsfe.condicion-iva-receptor, compartidas por los codemods de JS y de PHP:
2
+ // qué se puede inferir sin datos del negocio y con qué mensajes se reporta.
3
+
4
+ import { isTestFile } from '../paths.js';
5
+
6
+ export const PACK = 'arca.wsfe.condicion-iva-receptor';
7
+
8
+ // Prefiltro de los codemods: sin estas palabras no hay nada de ARCA en el archivo y no se parsea
9
+ // (en repos con bundles grandes, parsear todo tardaba minutos y 1,5 GB de RAM).
10
+ export const ARCA_HINT = /electronicbilling|fecaesolicitar|fecaedetrequest/i;
11
+
12
+ const CLASS = {
13
+ A: new Set([1, 2, 3, 201, 202, 203]),
14
+ B: new Set([6, 7, 8, 206, 207, 208]),
15
+ C: new Set([11, 12, 13, 211, 212, 213]),
16
+ };
17
+
18
+ export function classOf(cbteTipo) {
19
+ for (const [k, set] of Object.entries(CLASS)) if (set.has(cbteTipo)) return k;
20
+ return null;
21
+ }
22
+
23
+ // docTipo y cbteTipo: números si el valor es un literal, undefined si no se puede saber.
24
+ export function decideCondition({ docTipo, cbteTipo }, pack) {
25
+ const clase = typeof cbteTipo === 'number' ? classOf(cbteTipo) : null;
26
+ if (docTipo === 99) {
27
+ return { status: 'auto', code: 5, comment: ' parche: 5 = Consumidor Final (DocTipo 99, sin identificar)' };
28
+ }
29
+ if (docTipo === 96 && (clase === 'B' || clase === 'C')) {
30
+ return { status: 'needs-review', code: 5, comment: ' parche: inferido Consumidor Final (DNI + comprobante ' + clase + ') — REVISAR' };
31
+ }
32
+ const valid = clase ? pack.codes_by_class[clase] : null;
33
+ return { status: 'needs-input', clase, valid };
34
+ }
35
+
36
+ export function validCodesText(decision, pack) {
37
+ return (decision.valid || Object.keys(pack.codes).map(Number))
38
+ .map((c) => `${c} (${pack.codes[String(c)]})`).join(', ');
39
+ }
40
+
41
+ export function missingMessage(decision) {
42
+ return `Falta CondicionIVAReceptorId y no se puede inferir sin saber la condición frente al IVA del cliente${decision.clase ? ` (comprobante clase ${decision.clase})` : ''}.`;
43
+ }
44
+
45
+ export function appliedMessage(status) {
46
+ return status === 'auto'
47
+ ? 'Agregado CondicionIVAReceptorId: 5 (Consumidor Final): el comprobante va a un consumidor sin identificar.'
48
+ : 'Agregado CondicionIVAReceptorId: 5 por inferencia (DNI + comprobante B/C). Confirmá que el cliente no sea exento ni no alcanzado.';
49
+ }
50
+
51
+ // "El repo ya usa CondicionIVAReceptorId en otro lado": prefijo para bajar a "revisar" lo que no se pudo leer
52
+ // (en repos ya migrados, el objeto suele salir de un builder que sí pone el campo).
53
+ export function alreadyMigratedPrefix(context) {
54
+ const f = context?.fieldFiles?.[0];
55
+ return f ? `El repo ya usa CondicionIVAReceptorId (en \`${f}\`): verificá que este comprobante también lo lleve. ` : null;
56
+ }
57
+
58
+ // Si el campo puede no llegar a ARCA aunque esté en el código: librería 0.x (WSDL de 2021) o un WSDL viejo
59
+ // en el repo. Verificado en el código de afip.js/afip.php el 16/10/2026 (ver src/context.js).
60
+ export function libraryCaveat(context, ecosystem) {
61
+ const lib = (context?.arcaLibs || []).find((l) => l.ecosystem === ecosystem && l.travels !== 'si');
62
+ if (lib) {
63
+ return `Ojo: con ${lib.name} ${lib.version} el pedido se arma con un WSDL de 2021 que no tiene este campo`
64
+ + `${lib.travels === 'no' ? '' : ' (en JS falta confirmarlo)'}: para que llegue a ARCA hay que actualizar la librería a 1.x.`;
65
+ }
66
+ const wsdl = context?.staleWsdl?.[0];
67
+ if (wsdl) return `Ojo: el repo tiene un WSDL de WSFEv1 sin este campo (\`${wsdl}\`): si el cliente SOAP lo usa, el campo no viaja hasta actualizarlo.`;
68
+ return null;
69
+ }
70
+
71
+ // Integraciones SOAP propias (cualquier lenguaje): solo detección.
72
+ // Con menos ruido que la primera versión (frente B, 16/10): no marca tests, mocks, tipos, copias de la librería
73
+ // ni archivos que solo nombran el método (constantes, reexports); solo los que arman el detalle del comprobante.
74
+ export function soapDetection(source, file, context) {
75
+ if (!/FECAESolicitar|FECAEDetRequest/.test(source) || /CondicionIVAReceptorId/.test(source)) return null;
76
+ if (isTestFile(file) || /\.d\.[cm]?ts$/.test(file)) return null;
77
+ if (/class\s+ElectronicBilling\b/.test(source)) return null; // copia de afip.php/afip.js: la cubren la versión y el WSDL
78
+ const generated = /(^|\/)Reference\.cs$|Stub\.java$/i.test(file) || /class\s+FECAEDetRequest\b/.test(source);
79
+ if (!generated && !/\b(DocTipo|CbteDesde|ImpTotal)\b/.test(source)) return null;
80
+ const line = source.split('\n').findIndex((l) => /FECAESolicitar|FECAEDetRequest/.test(l)) + 1;
81
+ const base = generated
82
+ ? 'Cliente SOAP de WSFEv1 generado sin CondicionIVAReceptorId: hay que regenerarlo con el WSDL vigente (y después completar el campo).'
83
+ : 'Integración SOAP propia con WSFEv1 sin CondicionIVAReceptorId en el FECAEDetRequest. Hay que agregarlo al XML, con la condición frente al IVA del cliente.';
84
+ const prefix = alreadyMigratedPrefix(context);
85
+ return {
86
+ finding: { pack: PACK, file, line, status: prefix ? 'needs-review' : 'needs-input', message: (prefix || '') + base },
87
+ usage: { provider: 'arca', op: generated ? 'WSFEv1 SOAP (cliente generado)' : 'WSFEv1 SOAP', line },
88
+ };
89
+ }
90
+
91
+ // Archivos .wsdl de WSFEv1 sin el campo: si el cliente SOAP arma el pedido con ese esquema
92
+ // (SoapClient en modo WSDL, la librería soap de Node), el campo no viaja aunque esté en el código.
93
+ export function wsdlDetection({ source, file }) {
94
+ const findings = [];
95
+ const usages = [];
96
+ if (/FECAESolicitar|FECAEDetRequest/.test(source) && !source.includes('CondicionIVAReceptorId')) {
97
+ const line = source.split('\n').findIndex((l) => /FECAEDetRequest/.test(l)) + 1 || 1;
98
+ findings.push({ pack: PACK, file, line, status: 'needs-input',
99
+ message: 'WSDL de WSFEv1 sin CondicionIVAReceptorId: si tu cliente SOAP arma el pedido con este archivo, el campo no viaja aunque lo agregues al código. Reemplazalo por el WSDL vigente de WSFEv1.' });
100
+ usages.push({ provider: 'arca', op: 'WSFEv1 WSDL', line });
101
+ }
102
+ return { output: null, findings, usages };
103
+ }
104
+
105
+ // Lenguajes sin codemod (Python, C#, Java, Go, VB.NET): solo la detección de SOAP propio o cliente generado.
106
+ export function soapOnly({ source, file, context }) {
107
+ const soap = soapDetection(source, file, context);
108
+ return { output: null, findings: soap ? [soap.finding] : [], usages: soap ? [soap.usage] : [] };
109
+ }
@@ -0,0 +1,195 @@
1
+ // Pack arca.wsfe.condicion-iva-receptor (PHP: afipsdk/afip.php y sus forks)
2
+ // Mismas reglas que el codemod de JS: agrega CondicionIVAReceptorId a cada
3
+ // $afip->ElectronicBilling->CreateVoucher($data) / CreateNextVoucher($data) cuando se puede inferir
4
+ // sin datos del negocio, y marca el resto para que una persona complete el dato.
5
+ // Edita con inserciones de texto mínimas sobre los offsets exactos de php-parser (ver src/php.js).
6
+ import { eolOf, applyEdits, finding } from '../ast.js';
7
+ import { parsePhp, walkPhp, phpLiteral, phpKey } from '../php.js';
8
+ import {
9
+ PACK, decideCondition, validCodesText, missingMessage, appliedMessage, soapDetection, ARCA_HINT, libraryCaveat, alreadyMigratedPrefix,
10
+ } from './arca-rules.js';
11
+ import { emptyContext } from '../context.js';
12
+
13
+ // En PHP los nombres de método no distinguen mayúsculas: createVoucher también es válido.
14
+ // El inventario usa el nombre oficial, así un mismo método no aparece dos veces.
15
+ const VOUCHER_METHODS = new Map([['createvoucher', 'CreateVoucher'], ['createnextvoucher', 'CreateNextVoucher']]);
16
+ // Claves que cambian la decisión: si se tocan fuera del array literal, no se puede inferir.
17
+ const DECISION_KEYS = new Set(['DocTipo', 'CbteTipo', 'DocNro']);
18
+ const FIELD = 'CondicionIVAReceptorId';
19
+ const HINT_TAIL = 'con la condición frente al IVA del cliente.';
20
+
21
+ const isLookup = (n) => n && (n.kind === 'propertylookup' || n.kind === 'nullsafepropertylookup');
22
+ const identName = (n) => (n && n.kind === 'identifier' ? n.name : null);
23
+
24
+ // '...->ElectronicBilling->CreateVoucher(...)' → nombre oficial del método; null si no es.
25
+ function voucherMethod(call) {
26
+ const what = call.what;
27
+ if (!isLookup(what)) return null;
28
+ const method = identName(what.offset);
29
+ if (!method || !VOUCHER_METHODS.has(method.toLowerCase())) return null;
30
+ if (!isLookup(what.what) || identName(what.what.offset) !== 'ElectronicBilling') return null;
31
+ return VOUCHER_METHODS.get(method.toLowerCase());
32
+ }
33
+
34
+ // Asignaciones por scope: $var = ... y $var['Clave'] = ...
35
+ function collect(ast) {
36
+ const scopes = new Map();
37
+ const calls = [];
38
+ const info = (scope) => {
39
+ if (!scopes.has(scope)) scopes.set(scope, { assigns: new Map(), offsets: new Map() });
40
+ return scopes.get(scope);
41
+ };
42
+ const push = (map, name, value) => map.set(name, [...(map.get(name) || []), value]);
43
+ walkPhp(ast, (node, scope) => {
44
+ if (node.kind === 'assign') {
45
+ const left = node.left;
46
+ if (left.kind === 'variable' && typeof left.name === 'string') push(info(scope).assigns, left.name, node);
47
+ else if (left.kind === 'offsetlookup' && left.what && left.what.kind === 'variable' && typeof left.what.name === 'string' && left.offset) {
48
+ push(info(scope).offsets, left.what.name, { key: phpLiteral(left.offset), node });
49
+ }
50
+ }
51
+ if (node.kind === 'call') {
52
+ const method = voucherMethod(node);
53
+ if (method) calls.push({ node, scope, method });
54
+ }
55
+ });
56
+ return { scopes, calls };
57
+ }
58
+
59
+ // El array con los datos del comprobante: literal en la llamada o $var definida una sola vez,
60
+ // antes de la llamada y en el mismo scope.
61
+ function resolveData(arg, scope, call, scopes) {
62
+ if (!arg) return { reason: 'la llamada no tiene argumentos' };
63
+ if (arg.kind === 'array') return { arr: arg };
64
+ if (arg.kind !== 'variable' || typeof arg.name !== 'string') return { reason: 'no es un array literal ni una variable' };
65
+ const s = scopes.get(scope) || { assigns: new Map(), offsets: new Map() };
66
+ const defs = s.assigns.get(arg.name) || [];
67
+ const v = `$${arg.name}`;
68
+ if (defs.length === 0) {
69
+ const isParam = (scope.arguments || []).some((a) => a.kind === 'parameter' && (a.name?.name ?? a.name) === arg.name);
70
+ if (isParam) return { hint: `Los datos del comprobante llegan en \`${v}\` como parámetro: agregá ${FIELD} donde se arma ese array, ${HINT_TAIL}` };
71
+ return { reason: `${v} no se define en esta función` };
72
+ }
73
+ if (defs.length > 1) return { reason: `${v} se asigna más de una vez` };
74
+ const def = defs[0];
75
+ // $data = armarComprobante(...) / $this->builder->armar(...) / Builder::armar(...): decir qué función lo arma.
76
+ if (def.right.kind === 'call') {
77
+ const what = def.right.what;
78
+ const fn = what && (what.kind === 'name' ? what.name : identName(what.offset));
79
+ if (typeof fn === 'string') return { hint: `Los datos del comprobante los arma \`${fn}()\`: agregá ${FIELD} ahí, ${HINT_TAIL}` };
80
+ }
81
+ if (def.operator !== '=' || def.right.kind !== 'array') return { reason: `${v} no es un array literal` };
82
+ if (def.loc.start.offset > call.loc.start.offset) return { reason: `${v} se define después de la llamada` };
83
+ const offsets = s.offsets.get(arg.name) || [];
84
+ if (offsets.some((o) => o.key === FIELD)) return { migrated: true };
85
+ if (offsets.some((o) => o.key === undefined || DECISION_KEYS.has(o.key))) return { reason: `${v} se completa en varios pasos` };
86
+ return { arr: def.right };
87
+ }
88
+
89
+ export default function transform({ source, file, pack, context = emptyContext() }) {
90
+ const findings = [];
91
+ const usages = [];
92
+ if (!ARCA_HINT.test(source)) return { output: null, findings, usages }; // prefiltro (ver el codemod de JS)
93
+ const caveat = libraryCaveat(context, 'composer');
94
+ const migrated = alreadyMigratedPrefix(context);
95
+ let ast;
96
+ try {
97
+ ast = parsePhp(source, file);
98
+ } catch (err) {
99
+ if (/ElectronicBilling|FECAESolicitar|FECAEDetRequest/.test(source)) {
100
+ findings.push({ pack: PACK, file, line: err.lineNumber || null, status: 'needs-input',
101
+ message: `No pude analizar este archivo PHP (${err.message}). Revisá a mano las llamadas a ElectronicBilling.` });
102
+ }
103
+ return { output: null, findings, usages };
104
+ }
105
+
106
+ const { scopes, calls } = collect(ast);
107
+ const edits = [];
108
+ const edited = new Set(); // el mismo $data en dos llamadas: se inserta una sola vez (ver el codemod de JS)
109
+ for (const { node: call, scope, method } of calls) {
110
+ usages.push({ provider: 'arca', op: `ElectronicBilling->${method}`, line: call.loc.start.line });
111
+ const data = resolveData(call.arguments[0], scope, call, scopes);
112
+ if (data.migrated) continue;
113
+ const entries = data.arr ? data.arr.items.filter((it) => it && it.kind === 'entry') : null;
114
+ if (!entries || entries.some((e) => e.unpack || e.byRef)) {
115
+ const msg = data.hint || `No pude leer los datos del comprobante (${data.reason || 'el array usa ...$otro o referencias'}). Agregá ${FIELD} a mano, ${HINT_TAIL}`;
116
+ findings.push(finding(PACK, file, call, migrated ? 'needs-review' : 'needs-input', (migrated || '') + msg));
117
+ continue;
118
+ }
119
+ const byKey = (k) => entries.find((e) => phpKey(e) === k);
120
+ if (byKey(FIELD) || edited.has(data.arr)) continue; // ya está migrado (o ya lo editamos)
121
+
122
+ const d = decideCondition({ docTipo: phpLiteral(byKey('DocTipo')?.value), cbteTipo: phpLiteral(byKey('CbteTipo')?.value) }, pack);
123
+ if (d.status === 'needs-input') {
124
+ findings.push(finding(PACK, file, call, 'needs-input', missingMessage(d) + (caveat ? ` ${caveat}` : ''),
125
+ { suggestion: `'${FIELD}' => <condición del cliente>, // valores válidos: ${validCodesText(d, pack)}` }));
126
+ continue;
127
+ }
128
+ const anchor = byKey('DocNro') || entries[entries.length - 1];
129
+ edits.push(...insertAfterEntry(source, entries, anchor, { value: d.code, comment: d.comment }));
130
+ edited.add(data.arr);
131
+ // Con afip.php 0.x (WSDL de 2021 sin el campo) el arreglo no alcanza solo: deja de ser "automático".
132
+ const status = caveat && d.status === 'auto' ? 'needs-review' : d.status;
133
+ findings.push(finding(PACK, file, call, status, appliedMessage(d.status) + (caveat ? ` ${caveat}` : '')));
134
+ }
135
+
136
+ const soap = soapDetection(source, file, context);
137
+ if (soap) { findings.push(soap.finding); usages.push(soap.usage); }
138
+
139
+ return { output: edits.length ? applyEdits(source, edits) : null, findings, usages };
140
+ }
141
+
142
+ // Inserta 'CondicionIVAReceptorId' => N después de `anchor`, copiando el estilo del array:
143
+ // comillas de las claves, indentación, alineación de los =>, coma final y fin de línea.
144
+ export function insertAfterEntry(source, entries, anchor, { value, comment }) {
145
+ const eol = eolOf(source);
146
+ const keyed = entries.filter((e) => e.key && e.key.kind === 'string');
147
+ const doubles = keyed.filter((e) => source[e.key.loc.start.offset] === '"').length;
148
+ const q = doubles > keyed.length / 2 ? '"' : "'";
149
+ const key = `${q}${FIELD}${q}`;
150
+
151
+ const end = anchor.loc.end.offset;
152
+ let k = end;
153
+ while (k < source.length && /[ \t]/.test(source[k])) k++;
154
+ const hasComma = source[k] === ',';
155
+ const nl = source.indexOf('\n', end);
156
+ const rest = source.slice(hasComma ? k + 1 : end, nl === -1 ? source.length : nl);
157
+ const endsLine = nl !== -1 && (rest.trim() === '' || /^\s*(\/\/|#)/.test(rest));
158
+
159
+ if (!endsLine) {
160
+ // Array en una sola línea.
161
+ return [{ offset: hasComma ? k + 1 : end, text: `${hasComma ? '' : ','} ${key} => ${value} /*${comment} */${hasComma ? ',' : ''}` }];
162
+ }
163
+
164
+ const lineStart = source.lastIndexOf('\n', anchor.loc.start.offset - 1) + 1;
165
+ const indent = source.slice(lineStart).match(/^[ \t]*/)[0];
166
+ const { before, after } = arrowSpacing(source, keyed, anchor, key.length);
167
+ const isLast = anchor === entries[entries.length - 1];
168
+ const trailing = hasComma || !isLast ? ',' : '';
169
+ const out = [{ offset: nl + 1, text: `${indent}//${comment}${eol}${indent}${key}${before}=>${after}${value}${trailing}${eol}` }];
170
+ if (!hasComma) out.push({ offset: end, text: ',' });
171
+ return out;
172
+ }
173
+
174
+ // Espacios antes y después del => para la línea nueva. Si las claves del array están alineadas
175
+ // con espacios ('DocTipo' => 99), se alinea igual; si no, se copia el espaciado del ancla.
176
+ function arrowSpacing(source, keyed, anchor, newKeyLength) {
177
+ const parts = (e) => {
178
+ const keyEnd = e.key.loc.end.offset;
179
+ const arrow = source.indexOf('=>', keyEnd);
180
+ return {
181
+ width: arrow - e.key.loc.start.offset,
182
+ before: source.slice(keyEnd, arrow),
183
+ after: source.slice(arrow + 2, e.value.loc.start.offset),
184
+ line: e.loc.start.line,
185
+ };
186
+ };
187
+ const all = keyed.map(parts);
188
+ const own = anchor.key && anchor.key.kind === 'string' ? parts(anchor) : { before: ' ', after: ' ' };
189
+ const after = /^[ \t]*$/.test(own.after) && own.after ? own.after : ' ';
190
+ const multiline = new Set(all.map((p) => p.line)).size === all.length;
191
+ const aligned = multiline && all.length > 1 && all.every((p) => p.width === all[0].width && /^ +$/.test(p.before))
192
+ && all.some((p) => p.before.length > 1);
193
+ if (aligned) return { before: ' '.repeat(Math.max(1, all[0].width - newKeyLength)), after };
194
+ return { before: /^[ \t]+$/.test(own.before) ? own.before : ' ', after };
195
+ }
@@ -0,0 +1,177 @@
1
+ // Pack arca.wsfe.condicion-iva-receptor (JavaScript/TypeScript, afip.js)
2
+ // Agrega CondicionIVAReceptorId a cada pedido de CAE hecho con afip.js cuando se puede inferir
3
+ // sin datos del negocio, y marca el resto para que una persona complete el dato.
4
+ import {
5
+ jFor, detectQuote, eolOf, offsetAt, applyEdits, getProp, hasSpread, isQuotedKey, isPlainProp, literalValue, resolveBinding, finding,
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';
11
+
12
+ const VOUCHER_METHODS = new Set(['createVoucher', 'createNextVoucher']);
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;
19
+ };
20
+ const isEBMember = (n) => !!(n && n.type === 'MemberExpression' && !n.computed && n.property.type === 'Identifier' && n.property.name === 'ElectronicBilling');
21
+
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;
32
+ }
33
+
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;
39
+ }
40
+
41
+ export function decide(obj, pack) {
42
+ return decideCondition({
43
+ docTipo: literalValue(getProp(obj, 'DocTipo')?.value),
44
+ cbteTipo: literalValue(getProp(obj, 'CbteTipo')?.value),
45
+ }, pack);
46
+ }
47
+
48
+ export default function transform({ source, file, pack, context = emptyContext() }) {
49
+ const findings = [];
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);
55
+ let root;
56
+ try {
57
+ root = j(source);
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.` });
62
+ return { output: null, findings, usages };
63
+ }
64
+ let changed = false;
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();
69
+
70
+ root.find(j.CallExpression).filter((p) => voucherCallee(p) !== null).forEach((p) => {
71
+ const call = p.node;
72
+ usages.push({ provider: 'arca', op: `ElectronicBilling.${voucherCallee(p).property.name}`, line: call.loc?.start.line });
73
+ const arg = call.arguments[0];
74
+ let obj = null;
75
+ if (arg && arg.type === 'ObjectExpression') obj = arg;
76
+ else if (arg && arg.type === 'Identifier') {
77
+ const decl = resolveBinding(p, arg.name);
78
+ if (decl && decl.node.init && decl.node.init.type === 'ObjectExpression') obj = decl.node.init;
79
+ }
80
+ if (!obj || hasSpread(obj)) {
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)));
83
+ return;
84
+ }
85
+ if (getProp(obj, 'CondicionIVAReceptorId') || edited.has(obj)) return; // ya está migrado (o ya lo editamos)
86
+
87
+ const d = decide(obj, pack);
88
+ if (d.status === 'needs-input') {
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)}` }));
91
+ return;
92
+ }
93
+
94
+ const props = obj.properties.filter(isPlainProp);
95
+ const quoted = props.length > 0 && props.filter(isQuotedKey).length >= props.length / 2;
96
+ const q = detectQuote(source) === 'double' ? '"' : "'";
97
+ const keyText = quoted ? `${q}CondicionIVAReceptorId${q}` : 'CondicionIVAReceptorId';
98
+ const anchor = getProp(obj, 'DocNro') || props[props.length - 1];
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);
102
+ changed = true;
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}` : '')));
106
+ });
107
+
108
+ const soap = soapDetection(source, file, context);
109
+ if (soap) { findings.push(soap.finding); usages.push(soap.usage); }
110
+
111
+ return { output: changed ? applyEdits(source, edits) : null, findings, usages };
112
+ }
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
+
156
+ // Inserta una propiedad nueva después de `prop` tocando solo el texto necesario,
157
+ // para que el diff del PR sea de una línea y respete el formato del cliente.
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 } = {}) {
160
+ const eol = eolOf(source);
161
+ const end = offsetAt(source, prop.loc.end);
162
+ let k = end;
163
+ while (k < source.length && /[ \t]/.test(source[k])) k++;
164
+ const hasComma = source[k] === ',';
165
+ const nl = source.indexOf('\n', end);
166
+ const rest = source.slice(hasComma ? k + 1 : end, nl === -1 ? source.length : nl);
167
+ if (nl !== -1 && rest.trim() === '') {
168
+ const lineStart = offsetAt(source, { line: prop.loc.start.line, column: 0 });
169
+ const indent = source.slice(lineStart).match(/^[ \t]*/)[0];
170
+ const trailing = hasComma || !isLast ? ',' : '';
171
+ const out = [{ offset: nl + 1, text: `${indent}//${comment}${eol}${indent}${text}${trailing}${eol}` }];
172
+ if (!hasComma) out.push({ offset: end, text: ',' });
173
+ return out;
174
+ }
175
+ // Objeto en una sola línea.
176
+ return [{ offset: hasComma ? k + 1 : end, text: `${hasComma ? '' : ','} ${text} /*${comment} */${hasComma ? ',' : ''}` }];
177
+ }