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.
@@ -0,0 +1,276 @@
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
+ canApplyAtRuntime, PARTIAL_COMMENT, partialAppliedMessage, partialPending, partialLeftMessage,
11
+ } from './arca-rules.js';
12
+ import { emptyContext } from '../context.js';
13
+
14
+ // En PHP los nombres de método no distinguen mayúsculas: createVoucher también es válido.
15
+ // El inventario usa el nombre oficial, así un mismo método no aparece dos veces.
16
+ const VOUCHER_METHODS = new Map([['createvoucher', 'CreateVoucher'], ['createnextvoucher', 'CreateNextVoucher']]);
17
+ // Claves que cambian la decisión: si se tocan fuera del array literal, no se puede inferir.
18
+ const DECISION_KEYS = new Set(['DocTipo', 'CbteTipo', 'DocNro']);
19
+ const FIELD = 'CondicionIVAReceptorId';
20
+ const HINT_TAIL = 'con la condición frente al IVA del cliente.';
21
+
22
+ const isLookup = (n) => n && (n.kind === 'propertylookup' || n.kind === 'nullsafepropertylookup');
23
+ const identName = (n) => (n && n.kind === 'identifier' ? n.name : null);
24
+
25
+ // '...->ElectronicBilling->CreateVoucher(...)' → nombre oficial del método; null si no es.
26
+ function voucherMethod(call) {
27
+ const what = call.what;
28
+ if (!isLookup(what)) return null;
29
+ const method = identName(what.offset);
30
+ if (!method || !VOUCHER_METHODS.has(method.toLowerCase())) return null;
31
+ if (!isLookup(what.what) || identName(what.what.offset) !== 'ElectronicBilling') return null;
32
+ return VOUCHER_METHODS.get(method.toLowerCase());
33
+ }
34
+
35
+ // if ($data['DocTipo'] == 99) / if ((int) $data['DocTipo'] === 99): el arreglo parcial de Parche
36
+ // (o uno igual escrito a mano). Devuelve el nombre de la variable, o null.
37
+ function partialIfVar(test) {
38
+ if (!test || test.kind !== 'bin' || (test.type !== '==' && test.type !== '===')) return null;
39
+ const side = (n) => (n && n.kind === 'cast' ? n.expr : n);
40
+ const isDocTipo = (n) => n && n.kind === 'offsetlookup' && n.what?.kind === 'variable' && typeof n.what.name === 'string'
41
+ && n.offset && phpLiteral(n.offset) === 'DocTipo';
42
+ const [a, b] = [side(test.left), side(test.right)];
43
+ const lookup = isDocTipo(a) ? a : isDocTipo(b) ? b : null;
44
+ const other = lookup === a ? b : a;
45
+ return lookup && phpLiteral(other) === 99 ? lookup.what.name : null;
46
+ }
47
+
48
+ // Asignaciones por scope: $var = ... y $var['Clave'] = ...
49
+ function collect(ast) {
50
+ const scopes = new Map();
51
+ const calls = [];
52
+ const info = (scope) => {
53
+ if (!scopes.has(scope)) scopes.set(scope, { assigns: new Map(), offsets: new Map(), partialIfs: new Map() });
54
+ return scopes.get(scope);
55
+ };
56
+ const push = (map, name, value) => map.set(name, [...(map.get(name) || []), value]);
57
+ walkPhp(ast, (node, scope) => {
58
+ if (node.kind === 'if') {
59
+ const v = partialIfVar(node.test);
60
+ if (v) push(info(scope).partialIfs, v, node);
61
+ }
62
+ if (node.kind === 'assign') {
63
+ const left = node.left;
64
+ if (left.kind === 'variable' && typeof left.name === 'string') push(info(scope).assigns, left.name, node);
65
+ else if (left.kind === 'offsetlookup' && left.what && left.what.kind === 'variable' && typeof left.what.name === 'string' && left.offset) {
66
+ push(info(scope).offsets, left.what.name, { key: phpLiteral(left.offset), node });
67
+ }
68
+ }
69
+ if (node.kind === 'call') {
70
+ const method = voucherMethod(node);
71
+ if (method) calls.push({ node, scope, method });
72
+ }
73
+ });
74
+ return { scopes, calls };
75
+ }
76
+
77
+ // El array con los datos del comprobante: literal en la llamada o $var definida una sola vez,
78
+ // antes de la llamada y en el mismo scope.
79
+ function resolveData(arg, scope, call, scopes) {
80
+ if (!arg) return { reason: 'la llamada no tiene argumentos' };
81
+ if (arg.kind === 'array') return { arr: arg };
82
+ if (arg.kind !== 'variable' || typeof arg.name !== 'string') return { reason: 'no es un array literal ni una variable' };
83
+ const s = scopes.get(scope) || { assigns: new Map(), offsets: new Map(), partialIfs: new Map() };
84
+ const defs = s.assigns.get(arg.name) || [];
85
+ const v = `$${arg.name}`;
86
+ if (defs.length === 0) {
87
+ const isParam = (scope.arguments || []).some((a) => a.kind === 'parameter' && (a.name?.name ?? a.name) === arg.name);
88
+ if (isParam) return { hint: `Los datos del comprobante llegan en \`${v}\` como parámetro: agregá ${FIELD} donde se arma ese array, ${HINT_TAIL}` };
89
+ return { reason: `${v} no se define en esta función` };
90
+ }
91
+ if (defs.length > 1) return { reason: `${v} se asigna más de una vez` };
92
+ const def = defs[0];
93
+ // $data = armarComprobante(...) / $this->builder->armar(...) / Builder::armar(...): decir qué función lo arma.
94
+ if (def.right.kind === 'call') {
95
+ const what = def.right.what;
96
+ const fn = what && (what.kind === 'name' ? what.name : identName(what.offset));
97
+ if (typeof fn === 'string') return { hint: `Los datos del comprobante los arma \`${fn}()\`: agregá ${FIELD} ahí, ${HINT_TAIL}` };
98
+ }
99
+ if (def.operator !== '=' || def.right.kind !== 'array') return { reason: `${v} no es un array literal` };
100
+ if (def.loc.start.offset > call.loc.start.offset) return { reason: `${v} se define después de la llamada` };
101
+ const offsets = s.offsets.get(arg.name) || [];
102
+ // $data['CondicionIVAReceptorId'] = ... : migrado, salvo que solo pase dentro del if del arreglo parcial.
103
+ const fieldSets = offsets.filter((o) => o.key === FIELD);
104
+ const ifs = s.partialIfs.get(arg.name) || [];
105
+ const insideIf = (o) => ifs.some((i) => o.node.loc.start.offset >= i.loc.start.offset && o.node.loc.end.offset <= i.loc.end.offset);
106
+ const partial = fieldSets.length > 0 && fieldSets.every(insideIf);
107
+ if (fieldSets.length && !partial) return { migrated: true };
108
+ if (offsets.some((o) => o.key === undefined || DECISION_KEYS.has(o.key))) return { reason: `${v} se completa en varios pasos` };
109
+ return { arr: def.right, def, name: arg.name, partial };
110
+ }
111
+
112
+ export default function transform({ source, file, pack, context = emptyContext() }) {
113
+ const findings = [];
114
+ const usages = [];
115
+ if (!ARCA_HINT.test(source)) return { output: null, findings, usages }; // prefiltro (ver el codemod de JS)
116
+ const caveat = libraryCaveat(context, 'composer');
117
+ const migrated = alreadyMigratedPrefix(context);
118
+ let ast;
119
+ try {
120
+ ast = parsePhp(source, file);
121
+ } catch (err) {
122
+ if (/ElectronicBilling|FECAESolicitar|FECAEDetRequest/.test(source)) {
123
+ findings.push({ pack: PACK, file, line: err.lineNumber || null, status: 'needs-input',
124
+ message: `No pude analizar este archivo PHP (${err.message}). Revisá a mano las llamadas a ElectronicBilling.` });
125
+ }
126
+ return { output: null, findings, usages };
127
+ }
128
+
129
+ const { scopes, calls } = collect(ast);
130
+ const edits = [];
131
+ const edited = new Set(); // el mismo $data en dos llamadas: se inserta una sola vez (ver el codemod de JS)
132
+ for (const { node: call, scope, method } of calls) {
133
+ usages.push({ provider: 'arca', op: `ElectronicBilling->${method}`, line: call.loc.start.line });
134
+ const data = resolveData(call.arguments[0], scope, call, scopes);
135
+ if (data.migrated) continue;
136
+ const entries = data.arr ? data.arr.items.filter((it) => it && it.kind === 'entry') : null;
137
+ if (!entries || entries.some((e) => e.unpack || e.byRef)) {
138
+ 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}`;
139
+ findings.push(finding(PACK, file, call, migrated ? 'needs-review' : 'needs-input', (migrated || '') + msg));
140
+ continue;
141
+ }
142
+ const byKey = (k) => entries.find((e) => phpKey(e) === k);
143
+ if (byKey(FIELD) || edited.has(data.arr)) continue; // ya está migrado (o ya lo editamos)
144
+
145
+ const d = decideCondition({ docTipo: phpLiteral(byKey('DocTipo')?.value), cbteTipo: phpLiteral(byKey('CbteTipo')?.value) }, pack);
146
+ const suggestion = `'${FIELD}' => <condición del cliente>, // valores válidos: ${validCodesText(d, pack)}`;
147
+ if (data.partial) {
148
+ // Ya tiene el arreglo parcial: no se toca, pero lo que falta se sigue reportando.
149
+ const pending = partialPending(d, pack, suggestion);
150
+ findings.push(finding(PACK, file, call, 'needs-input', partialLeftMessage(pending), { suggestion }));
151
+ continue;
152
+ }
153
+ if (d.status === 'needs-input') {
154
+ // DocTipo no literal y $data en una variable: arreglo parcial con un if después de armar el array.
155
+ // Con un array dentro de la llamada no hay dónde poner el if, y `null` no sirve: mandarlo antes del 1/12
156
+ // podría generar un rechazo que hoy no existe (frente B, punto 7).
157
+ const docTipo = byKey('DocTipo');
158
+ const partialEdit = data.def && docTipo && phpLiteral(docTipo.value) === undefined
159
+ && canApplyAtRuntime(d, phpLiteral(byKey('CbteTipo')?.value)) ? insertPartialIf(source, data, entries) : null;
160
+ if (partialEdit) {
161
+ edits.push(partialEdit.edit);
162
+ edited.add(data.arr);
163
+ findings.push(finding(PACK, file, call, 'needs-review', partialAppliedMessage(partialEdit.read) + (caveat ? ` ${caveat}` : ''),
164
+ { partial: partialPending(d, pack, suggestion) }));
165
+ continue;
166
+ }
167
+ findings.push(finding(PACK, file, call, 'needs-input', missingMessage(d) + (caveat ? ` ${caveat}` : ''), { suggestion }));
168
+ continue;
169
+ }
170
+ const anchor = byKey('DocNro') || entries[entries.length - 1];
171
+ edits.push(...insertAfterEntry(source, entries, anchor, { value: d.code, comment: d.comment }));
172
+ edited.add(data.arr);
173
+ // Con afip.php 0.x (WSDL de 2021 sin el campo) el arreglo no alcanza solo: deja de ser "automático".
174
+ const status = caveat && d.status === 'auto' ? 'needs-review' : d.status;
175
+ findings.push(finding(PACK, file, call, status, appliedMessage(d.status) + (caveat ? ` ${caveat}` : '')));
176
+ }
177
+
178
+ const soap = soapDetection(source, file, context);
179
+ if (soap) { findings.push(soap.finding); usages.push(soap.usage); }
180
+
181
+ return { output: edits.length ? applyEdits(source, edits) : null, findings, usages };
182
+ }
183
+
184
+ // Comillas de las claves del array: las que use la mayoría.
185
+ function quoteOf(source, entries) {
186
+ const keyed = entries.filter((e) => e.key && e.key.kind === 'string');
187
+ const doubles = keyed.filter((e) => source[e.key.loc.start.offset] === '"').length;
188
+ return doubles > keyed.length / 2 ? '"' : "'";
189
+ }
190
+
191
+ // Arreglo parcial en PHP: después de `$data = [...];` agrega
192
+ // if ((int) $data['DocTipo'] === 99) { $data['CondicionIVAReceptorId'] = 5; }
193
+ // en tres líneas, con la indentación del archivo. Lee DocTipo del array ya armado, así no repite la
194
+ // expresión original (puede ser una llamada). (int) y === : '99' como string también cuenta, como en JS.
195
+ // null si la asignación no termina la línea (dentro de un if, varias sentencias en una línea...).
196
+ function insertPartialIf(source, data, entries) {
197
+ const eol = eolOf(source);
198
+ const { def, name } = data;
199
+ let semi = def.loc.end.offset - 1;
200
+ if (source[semi] !== ';') {
201
+ semi = def.loc.end.offset;
202
+ while (semi < source.length && /[ \t]/.test(source[semi])) semi++;
203
+ if (source[semi] !== ';') return null;
204
+ }
205
+ const nl = source.indexOf('\n', semi);
206
+ const rest = source.slice(semi + 1, nl === -1 ? source.length : nl);
207
+ if (nl === -1 || !(rest.trim() === '' || /^\s*(\/\/|#)/.test(rest))) return null;
208
+ const lineStart = source.lastIndexOf('\n', def.loc.start.offset - 1) + 1;
209
+ const indent = source.slice(lineStart).match(/^[ \t]*/)[0];
210
+ // Unidad de indentación: la de las claves del array respecto de la sentencia; si no, la del archivo.
211
+ const first = entries.find((e) => e.loc.start.line > def.loc.start.line);
212
+ const entryIndent = first ? source.slice(source.lastIndexOf('\n', first.loc.start.offset - 1) + 1).match(/^[ \t]*/)[0] : '';
213
+ const unit = entryIndent.startsWith(indent) && entryIndent.length > indent.length ? entryIndent.slice(indent.length)
214
+ : indent.includes('\t') ? '\t' : ' ';
215
+ const q = quoteOf(source, entries);
216
+ const read = `$${name}[${q}DocTipo${q}]`;
217
+ const text = `${indent}//${PARTIAL_COMMENT}${eol}`
218
+ + `${indent}if ((int) ${read} === 99) {${eol}`
219
+ + `${indent}${unit}$${name}[${q}${FIELD}${q}] = 5;${eol}`
220
+ + `${indent}}${eol}`;
221
+ return { edit: { offset: nl + 1, text }, read };
222
+ }
223
+
224
+ // Inserta 'CondicionIVAReceptorId' => N después de `anchor`, copiando el estilo del array:
225
+ // comillas de las claves, indentación, alineación de los =>, coma final y fin de línea.
226
+ export function insertAfterEntry(source, entries, anchor, { value, comment }) {
227
+ const eol = eolOf(source);
228
+ const keyed = entries.filter((e) => e.key && e.key.kind === 'string');
229
+ const q = quoteOf(source, entries);
230
+ const key = `${q}${FIELD}${q}`;
231
+
232
+ const end = anchor.loc.end.offset;
233
+ let k = end;
234
+ while (k < source.length && /[ \t]/.test(source[k])) k++;
235
+ const hasComma = source[k] === ',';
236
+ const nl = source.indexOf('\n', end);
237
+ const rest = source.slice(hasComma ? k + 1 : end, nl === -1 ? source.length : nl);
238
+ const endsLine = nl !== -1 && (rest.trim() === '' || /^\s*(\/\/|#)/.test(rest));
239
+
240
+ if (!endsLine) {
241
+ // Array en una sola línea.
242
+ return [{ offset: hasComma ? k + 1 : end, text: `${hasComma ? '' : ','} ${key} => ${value} /*${comment} */${hasComma ? ',' : ''}` }];
243
+ }
244
+
245
+ const lineStart = source.lastIndexOf('\n', anchor.loc.start.offset - 1) + 1;
246
+ const indent = source.slice(lineStart).match(/^[ \t]*/)[0];
247
+ const { before, after } = arrowSpacing(source, keyed, anchor, key.length);
248
+ const isLast = anchor === entries[entries.length - 1];
249
+ const trailing = hasComma || !isLast ? ',' : '';
250
+ const out = [{ offset: nl + 1, text: `${indent}//${comment}${eol}${indent}${key}${before}=>${after}${value}${trailing}${eol}` }];
251
+ if (!hasComma) out.push({ offset: end, text: ',' });
252
+ return out;
253
+ }
254
+
255
+ // Espacios antes y después del => para la línea nueva. Si las claves del array están alineadas
256
+ // con espacios ('DocTipo' => 99), se alinea igual; si no, se copia el espaciado del ancla.
257
+ function arrowSpacing(source, keyed, anchor, newKeyLength) {
258
+ const parts = (e) => {
259
+ const keyEnd = e.key.loc.end.offset;
260
+ const arrow = source.indexOf('=>', keyEnd);
261
+ return {
262
+ width: arrow - e.key.loc.start.offset,
263
+ before: source.slice(keyEnd, arrow),
264
+ after: source.slice(arrow + 2, e.value.loc.start.offset),
265
+ line: e.loc.start.line,
266
+ };
267
+ };
268
+ const all = keyed.map(parts);
269
+ const own = anchor.key && anchor.key.kind === 'string' ? parts(anchor) : { before: ' ', after: ' ' };
270
+ const after = /^[ \t]*$/.test(own.after) && own.after ? own.after : ' ';
271
+ const multiline = new Set(all.map((p) => p.line)).size === all.length;
272
+ const aligned = multiline && all.length > 1 && all.every((p) => p.width === all[0].width && /^ +$/.test(p.before))
273
+ && all.some((p) => p.before.length > 1);
274
+ if (aligned) return { before: ' '.repeat(Math.max(1, all[0].width - newKeyLength)), after };
275
+ return { before: /^[ \t]+$/.test(own.before) ? own.before : ' ', after };
276
+ }
@@ -1,65 +1,120 @@
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, isQuotedKey, isPlainProp, literalValue, resolveBinding, finding,
6
6
  } from '../ast.js';
7
+ import {
8
+ PACK, decideCondition, validCodesText, missingMessage, appliedMessage, soapDetection, ARCA_HINT, libraryCaveat, alreadyMigratedPrefix,
9
+ canApplyAtRuntime, PARTIAL_COMMENT, partialAppliedMessage, partialPending, partialLeftMessage,
10
+ } from './arca-rules.js';
11
+ import { emptyContext } from '../context.js';
7
12
 
8
- const PACK = 'arca.wsfe.condicion-iva-receptor';
9
13
  const VOUCHER_METHODS = new Set(['createVoucher', 'createNextVoucher']);
10
- const CLASS = {
11
- A: new Set([1, 2, 3, 201, 202, 203]),
12
- B: new Set([6, 7, 8, 206, 207, 208]),
13
- C: new Set([11, 12, 13, 211, 212, 213]),
14
+ const WRAPPERS = new Set(['TSAsExpression', 'TSNonNullExpression', 'TSTypeAssertion', 'TSSatisfiesExpression', 'ParenthesizedExpression']);
15
+
16
+ // (afip.ElectronicBilling as any) → afip.ElectronicBilling
17
+ const unwrap = (n) => {
18
+ while (n && WRAPPERS.has(n.type)) n = n.expression;
19
+ return n;
14
20
  };
21
+ const isEBMember = (n) => !!(n && n.type === 'MemberExpression' && !n.computed && n.property.type === 'Identifier' && n.property.name === 'ElectronicBilling');
15
22
 
16
- function classOf(cbteTipo) {
17
- for (const [k, set] of Object.entries(CLASS)) if (set.has(cbteTipo)) return k;
18
- return null;
23
+ // ¿`node` es `<algo>.ElectronicBilling`? También con casts de TS y con un alias en el mismo scope
24
+ // (`const eb = afip.ElectronicBilling; eb.createVoucher(...)`): los dos aparecieron en repos reales.
25
+ function isElectronicBilling(node, p) {
26
+ const n = unwrap(node);
27
+ if (isEBMember(n)) return true;
28
+ if (n && n.type === 'Identifier') {
29
+ const decl = resolveBinding(p, n.name);
30
+ return isEBMember(unwrap(decl && decl.node.init));
31
+ }
32
+ return false;
19
33
  }
20
34
 
21
- function isVoucherCall(callee) {
22
- return (
23
- callee.type === 'MemberExpression' &&
24
- !callee.computed &&
25
- callee.property.type === 'Identifier' &&
26
- VOUCHER_METHODS.has(callee.property.name) &&
27
- callee.object.type === 'MemberExpression' &&
28
- !callee.object.computed &&
29
- callee.object.property.name === 'ElectronicBilling'
30
- );
35
+ function voucherCallee(p) {
36
+ const callee = unwrap(p.node.callee);
37
+ if (!callee || callee.type !== 'MemberExpression' || callee.computed || callee.property.type !== 'Identifier') return null;
38
+ if (!VOUCHER_METHODS.has(callee.property.name) || !isElectronicBilling(callee.object, p)) return null;
39
+ return callee;
31
40
  }
32
41
 
33
42
  export function decide(obj, pack) {
34
- const docTipo = literalValue(getProp(obj, 'DocTipo')?.value);
35
- const cbteTipo = literalValue(getProp(obj, 'CbteTipo')?.value);
36
- const clase = typeof cbteTipo === 'number' ? classOf(cbteTipo) : null;
37
- if (docTipo === 99) {
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' };
43
+ return decideCondition({
44
+ docTipo: literalValue(getProp(obj, 'DocTipo')?.value),
45
+ cbteTipo: literalValue(getProp(obj, 'CbteTipo')?.value),
46
+ }, pack);
47
+ }
48
+
49
+ // Un valor que se puede volver a leer sin efectos: docTipo, cliente.docTipo, this.doc?.tipo, datos['DocTipo'].
50
+ // El arreglo parcial repite esa expresión; una llamada o un await no se repiten.
51
+ function isPureRead(node, top = true) {
52
+ const n = unwrap(node);
53
+ if (!n) return false;
54
+ if (n.type === 'Identifier') return n.name !== 'undefined';
55
+ if (n.type === 'ThisExpression') return !top;
56
+ if (n.type === 'MemberExpression' || n.type === 'OptionalMemberExpression') {
57
+ if (n.computed && !['StringLiteral', 'NumericLiteral', 'Literal'].includes(n.property.type)) return false;
58
+ return isPureRead(n.object, false);
42
59
  }
43
- const valid = clase ? pack.codes_by_class[clase] : null;
44
- return { status: 'needs-input', clase, valid };
60
+ return false;
45
61
  }
46
62
 
47
- export default function transform({ source, file, pack }) {
48
- const j = jFor(file);
63
+ // ...(cond ? { CondicionIVAReceptorId: N } : {}): 'partial' si para el otro caso no hay valor (el arreglo
64
+ // parcial de Parche, o uno igual escrito a mano), 'full' si los dos lados ponen el campo.
65
+ function conditionSpread(prop) {
66
+ if (prop.type !== 'SpreadElement' && prop.type !== 'SpreadProperty') return null;
67
+ const c = unwrap(prop.argument);
68
+ if (!c || c.type !== 'ConditionalExpression') return null;
69
+ const sets = (o) => { const x = unwrap(o); return !!(x && x.type === 'ObjectExpression' && getProp(x, 'CondicionIVAReceptorId')); };
70
+ if (!sets(c.consequent) && !sets(c.alternate)) return null;
71
+ return sets(c.consequent) && sets(c.alternate) ? 'full' : 'partial';
72
+ }
73
+ const otherSpread = (obj) => obj.properties.some((p) => (p.type === 'SpreadElement' || p.type === 'SpreadProperty' || p.type === 'ObjectMethod') && !conditionSpread(p));
74
+
75
+ const textOf = (source, node) => source.slice(offsetAt(source, node.loc.start), offsetAt(source, node.loc.end));
76
+
77
+ // La clave nueva copia el estilo del objeto: con comillas si la mayoría de las claves las tiene, y las
78
+ // mismas comillas que esas claves. Las del resto del archivo solo desempatan: en un repo real las claves
79
+ // iban con ' y el archivo usaba " en el resto, y la clave nueva salía distinta a sus vecinas.
80
+ function keyTextFor(source, obj) {
81
+ const props = obj.properties.filter(isPlainProp);
82
+ const quotedKeys = props.filter(isQuotedKey);
83
+ if (!props.length || quotedKeys.length < props.length / 2) return 'CondicionIVAReceptorId';
84
+ const raw = (k) => (k.extra?.raw ?? k.raw ?? source[offsetAt(source, k.loc.start)])[0];
85
+ const doubles = quotedKeys.filter((p) => raw(p.key) === '"').length;
86
+ const singles = quotedKeys.length - doubles;
87
+ const q = doubles === singles ? (detectQuote(source) === 'double' ? '"' : "'") : doubles > singles ? '"' : "'";
88
+ return `${q}CondicionIVAReceptorId${q}`;
89
+ }
90
+
91
+ const suggestionFor = (d, pack) => `'CondicionIVAReceptorId': <condición del cliente>, // valores válidos: ${validCodesText(d, pack)}`;
92
+
93
+ export default function transform({ source, file, pack, context = emptyContext() }) {
49
94
  const findings = [];
50
95
  const usages = [];
96
+ if (!ARCA_HINT.test(source)) return { output: null, findings, usages };
97
+ const caveat = libraryCaveat(context, 'npm');
98
+ const migrated = alreadyMigratedPrefix(context);
99
+ const j = jFor(file);
51
100
  let root;
52
101
  try {
53
102
  root = j(source);
54
- } catch {
103
+ } catch (err) {
104
+ // Antes se descartaba en silencio: un archivo de ARCA que no parsea es justo el que hay que mirar.
105
+ findings.push({ pack: PACK, file, line: err.loc?.line || null, status: 'needs-input',
106
+ message: `No pude analizar este archivo (${err.message.split('\n')[0]}). Revisá a mano las llamadas a ElectronicBilling.` });
55
107
  return { output: null, findings, usages };
56
108
  }
57
109
  let changed = false;
58
110
  const edits = [];
111
+ // Objetos ya editados: el mismo `payload` puede ir a dos llamadas (lo vimos en un repo real). Como se edita
112
+ // texto y el árbol no se entera, sin esto se insertaba dos veces → clave duplicada y TypeScript no compila.
113
+ const edited = new Set();
59
114
 
60
- root.find(j.CallExpression).filter((p) => isVoucherCall(p.node.callee)).forEach((p) => {
115
+ root.find(j.CallExpression).filter((p) => voucherCallee(p) !== null).forEach((p) => {
61
116
  const call = p.node;
62
- usages.push({ provider: 'arca', op: `ElectronicBilling.${call.callee.property.name}`, line: call.loc?.start.line });
117
+ usages.push({ provider: 'arca', op: `ElectronicBilling.${voucherCallee(p).property.name}`, line: call.loc?.start.line });
63
118
  const arg = call.arguments[0];
64
119
  let obj = null;
65
120
  if (arg && arg.type === 'ObjectExpression') obj = arg;
@@ -67,50 +122,104 @@ export default function transform({ source, file, pack }) {
67
122
  const decl = resolveBinding(p, arg.name);
68
123
  if (decl && decl.node.init && decl.node.init.type === 'ObjectExpression') obj = decl.node.init;
69
124
  }
70
- if (!obj || hasSpread(obj)) {
71
- findings.push(finding(PACK, file, call, 'needs-input',
72
- 'No pude leer los datos del comprobante (no es un objeto literal). Agregá CondicionIVAReceptorId a mano.'));
125
+ if (!obj || otherSpread(obj)) {
126
+ // Si el repo ya usa el campo en otro lado, lo más probable es que el builder ya lo ponga: "revisar".
127
+ findings.push(finding(PACK, file, call, migrated ? 'needs-review' : 'needs-input', (migrated || '') + unreadableMessage(j, root, p, arg, obj)));
73
128
  return;
74
129
  }
75
- if (getProp(obj, 'CondicionIVAReceptorId')) return; // ya está migrado
130
+ if (getProp(obj, 'CondicionIVAReceptorId') || edited.has(obj)) return; // ya está migrado (o ya lo editamos)
131
+ const spreads = obj.properties.map(conditionSpread);
132
+ if (spreads.includes('full')) return;
76
133
 
77
134
  const d = decide(obj, pack);
135
+ const props = obj.properties.filter(isPlainProp);
136
+ const keyText = keyTextFor(source, obj);
137
+ const anchor = getProp(obj, 'DocNro') || props[props.length - 1];
138
+ const isLast = anchor === obj.properties[obj.properties.length - 1];
139
+ if (spreads.includes('partial')) {
140
+ // Ya tiene el arreglo parcial: no se toca, pero lo que falta se sigue reportando.
141
+ const pending = partialPending(d, pack, suggestionFor(d, pack));
142
+ findings.push(finding(PACK, file, call, 'needs-input', partialLeftMessage(pending), { suggestion: pending.suggestion }));
143
+ return;
144
+ }
78
145
  if (d.status === 'needs-input') {
79
- const lista = (d.valid || Object.keys(pack.codes).map(Number))
80
- .map((c) => `${c} (${pack.codes[String(c)]})`).join(', ');
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}` }));
146
+ const docTipo = getProp(obj, 'DocTipo')?.value;
147
+ const expr = docTipo && anchor && literalValue(docTipo) === undefined && isPureRead(docTipo) ? textOf(source, docTipo) : null;
148
+ if (expr && !expr.includes('\n') && canApplyAtRuntime(d, literalValue(getProp(obj, 'CbteTipo')?.value))) {
149
+ // Spread y no `CondicionIVAReceptorId: x === 99 ? 5 : undefined`: con otro DocTipo la clave ni existe,
150
+ // así el pedido queda idéntico al de hoy sin depender de cómo serialice la librería (decisión D52).
151
+ edits.push(...insertAfterProp(source, anchor, `...(Number(${expr}) === 99 ? { ${keyText}: 5 } : {})`, PARTIAL_COMMENT, { isLast }));
152
+ edited.add(obj);
153
+ changed = true;
154
+ findings.push(finding(PACK, file, call, 'needs-review', partialAppliedMessage(expr) + (caveat ? ` ${caveat}` : ''),
155
+ { partial: partialPending(d, pack, suggestionFor(d, pack)) }));
156
+ return;
157
+ }
158
+ findings.push(finding(PACK, file, call, 'needs-input', missingMessage(d) + (caveat ? ` ${caveat}` : ''),
159
+ { suggestion: suggestionFor(d, pack) }));
84
160
  return;
85
161
  }
86
162
 
87
- const props = obj.properties.filter(isPlainProp);
88
- const quoted = props.length > 0 && props.filter(isQuotedKey).length >= props.length / 2;
89
- const q = detectQuote(source) === 'double' ? '"' : "'";
90
- const keyText = quoted ? `${q}CondicionIVAReceptorId${q}` : 'CondicionIVAReceptorId';
91
- const anchor = getProp(obj, 'DocNro') || props[props.length - 1];
92
- edits.push(...insertAfterProp(source, anchor, `${keyText}: ${d.code}`, d.comment));
163
+ edits.push(...insertAfterProp(source, anchor, `${keyText}: ${d.code}`, d.comment, { isLast }));
164
+ edited.add(obj);
93
165
  changed = true;
94
- findings.push(finding(PACK, file, call, d.status,
95
- d.status === 'auto'
96
- ? 'Agregado CondicionIVAReceptorId: 5 (Consumidor Final): el comprobante va a un consumidor sin identificar.'
97
- : 'Agregado CondicionIVAReceptorId: 5 por inferencia (DNI + comprobante B/C). Confirmá que el cliente no sea exento ni no alcanzado.'));
166
+ // Con librería vieja o WSDL viejo el arreglo no alcanza solo: deja de ser "automático".
167
+ const status = caveat && d.status === 'auto' ? 'needs-review' : d.status;
168
+ findings.push(finding(PACK, file, call, status, appliedMessage(d.status) + (caveat ? ` ${caveat}` : '')));
98
169
  });
99
170
 
100
- // Integraciones SOAP propias: solo detección.
101
- if (/FECAESolicitar|FECAEDetRequest/.test(source) && !/CondicionIVAReceptorId/.test(source)) {
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
- }
171
+ const soap = soapDetection(source, file, context);
172
+ if (soap) { findings.push(soap.finding); usages.push(soap.usage); }
107
173
 
108
174
  return { output: changed ? applyEdits(source, edits) : null, findings, usages };
109
175
  }
110
176
 
177
+ // Cuando no se puede leer el comprobante, decir DÓNDE hay que agregar el dato: la función que lo arma
178
+ // (y de qué módulo viene) o que llega como parámetro. Lo encontrado en repos reales: payload = buildVoucherPayload(...).
179
+ function unreadableMessage(j, root, p, arg, obj) {
180
+ const tail = 'con la condición frente al IVA del cliente.';
181
+ if (obj) return `No pude leer los datos del comprobante (el objeto usa ...spread). Agregá CondicionIVAReceptorId a mano, ${tail}`;
182
+ if (arg && arg.type === 'Identifier') {
183
+ const decl = resolveBinding(p, arg.name);
184
+ let init = decl && decl.node.init;
185
+ if (init && init.type === 'AwaitExpression') init = init.argument;
186
+ if (init && init.type === 'CallExpression') {
187
+ const fn = init.callee.type === 'Identifier' ? init.callee.name
188
+ : init.callee.type === 'MemberExpression' && !init.callee.computed && init.callee.property.type === 'Identifier' ? init.callee.property.name : null;
189
+ if (fn) {
190
+ const from = importSourceOf(j, root, fn);
191
+ return `Los datos del comprobante los arma \`${fn}()\`${from ? ` (de \`${from}\`)` : ''}: agregá CondicionIVAReceptorId ahí, ${tail}`;
192
+ }
193
+ }
194
+ 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}`;
195
+ }
196
+ return `No pude leer los datos del comprobante (no es un objeto literal). Agregá CondicionIVAReceptorId a mano, ${tail}`;
197
+ }
198
+
199
+ // De qué módulo viene `name`: import estático, require o import() dinámico con desestructuración.
200
+ function importSourceOf(j, root, name) {
201
+ let src = null;
202
+ root.find(j.ImportDeclaration).forEach((ip) => {
203
+ if (ip.node.specifiers.some((s) => s.local && s.local.name === name)) src = ip.node.source.value;
204
+ });
205
+ if (src) return src;
206
+ root.find(j.VariableDeclarator).forEach((vp) => {
207
+ const { id } = vp.node;
208
+ let init = vp.node.init;
209
+ if (!init || id.type !== 'ObjectPattern') return;
210
+ if (init.type === 'AwaitExpression') init = init.argument;
211
+ const isImportCall = init.type === 'CallExpression' && (init.callee.type === 'Import' || (init.callee.type === 'Identifier' && init.callee.name === 'require'));
212
+ const source = init.type === 'ImportExpression' ? init.source : isImportCall ? init.arguments[0] : null;
213
+ if (!source || typeof source.value !== 'string') return;
214
+ if (id.properties.some((pr) => pr.value && pr.value.type === 'Identifier' && pr.value.name === name)) src = source.value;
215
+ });
216
+ return src;
217
+ }
218
+
111
219
  // Inserta una propiedad nueva después de `prop` tocando solo el texto necesario,
112
220
  // para que el diff del PR sea de una línea y respete el formato del cliente.
113
- export function insertAfterProp(source, prop, text, comment) {
221
+ // Si `prop` es la última y no tenía coma final, la nueva tampoco la lleva.
222
+ export function insertAfterProp(source, prop, text, comment, { isLast = false } = {}) {
114
223
  const eol = eolOf(source);
115
224
  const end = offsetAt(source, prop.loc.end);
116
225
  let k = end;
@@ -121,18 +230,11 @@ export function insertAfterProp(source, prop, text, comment) {
121
230
  if (nl !== -1 && rest.trim() === '') {
122
231
  const lineStart = offsetAt(source, { line: prop.loc.start.line, column: 0 });
123
232
  const indent = source.slice(lineStart).match(/^[ \t]*/)[0];
124
- const out = [{ offset: nl + 1, text: `${indent}//${comment}${eol}${indent}${text},${eol}` }];
233
+ const trailing = hasComma || !isLast ? ',' : '';
234
+ const out = [{ offset: nl + 1, text: `${indent}//${comment}${eol}${indent}${text}${trailing}${eol}` }];
125
235
  if (!hasComma) out.push({ offset: end, text: ',' });
126
236
  return out;
127
237
  }
128
238
  // Objeto en una sola línea.
129
239
  return [{ offset: hasComma ? k + 1 : end, text: `${hasComma ? '' : ','} ${text} /*${comment} */${hasComma ? ',' : ''}` }];
130
240
  }
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
- }