parche-cli 0.5.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,15 @@
2
2
 
3
3
  Todas las versiones de `parche-cli` (npm) y de la Action `Pepo08/parche@v0`.
4
4
 
5
+ ## 0.6.0 — 2026-10-17
6
+
7
+ ### Nuevo
8
+ - **ARCA con `DocTipo` en una variable: PR parcial (consumidor final).** Antes se reportaba como "falta un dato" y no se tocaba nada. Ahora Parche aplica la regla 99 → 5 **al ejecutarse** y, con otro `DocTipo`, el pedido sale igual que hoy (la clave ni existe). En JS agrega `...(Number(docTipo) === 99 ? { CondicionIVAReceptorId: 5 } : {})`; en PHP, un `if ((int) $data['DocTipo'] === 99)` después de armar el array, nunca `null`. El PR sale borrador, con "(parcial: consumidor final)" en el título y, en "Lo que este PR NO resuelve", los casos que no cubre con los códigos válidos. No se aplica a facturas A, a expresiones con efectos (llamadas, `await`) ni a arrays de PHP dentro de la llamada. Si se corre de nuevo después del merge, no cambia nada y sigue avisando lo que falta.
9
+ - El ledger registra cuántos arreglos parciales lleva cada PR (`partial`).
10
+
11
+ ### Arreglado
12
+ - ARCA en JS: la clave nueva usa las mismas comillas que las claves del objeto. Antes copiaba las del resto del archivo y, en un repo real con claves `'...'` en un archivo con `"..."`, quedaba distinta a sus vecinas.
13
+
5
14
  ## 0.5.1 — 2026-10-17
6
15
 
7
16
  Lo que apareció en el primer PR real (lunamarie11/obraya#1).
package/README.md CHANGED
@@ -129,7 +129,7 @@ Con el tag, el workflow corre los tests, controla que la versión de `package.js
129
129
 
130
130
  | Pack | Proveedor | Tipo | Fecha límite | Arreglo |
131
131
  |---|---|---|---|---|
132
- | `arca.wsfe.condicion-iva-receptor` | ARCA | rompe | 2026-12-01 | sí, en JS (`afip.js`) y PHP (`afipsdk/afip.php` y forks): consumidor final y DNI B/C |
132
+ | `arca.wsfe.condicion-iva-receptor` | ARCA | rompe | 2026-12-01 | sí, en JS (`afip.js`) y PHP (`afipsdk/afip.php` y forks): consumidor final y DNI B/C; con `DocTipo` en una variable, consumidor final al ejecutarse (PR parcial) |
133
133
  | `mercadopago.payments-to-orders` | Mercado Pago | migración | — | sí (creación de pagos en SDK Node v2) |
134
134
  | `ai.model-retirements` | Anthropic, OpenAI | retiro | varias | solo detección |
135
135
 
@@ -157,6 +157,31 @@ Para `afipsdk/afip.php` y sus forks (`fertandil87/afip.php`, `ivanalemunioz/afip
157
157
 
158
158
  **Otros lenguajes:** en Python, C#, Java, Go y VB.NET, Parche detecta integraciones SOAP propias y clientes SOAP generados sin el campo (no los reescribe).
159
159
 
160
+ ## ARCA con `DocTipo` en una variable: PR parcial
161
+
162
+ Cuando `DocTipo` no es un número fijo (`DocTipo: docTipo`), Parche no puede saber la condición frente al IVA de cada cliente. Lo único que se sabe sin datos del negocio es que **99 (consumidor final sin identificar) va con 5**, así que aplica esa regla **al ejecutarse** y, con cualquier otro `DocTipo`, el pedido sale exactamente igual que hoy:
163
+
164
+ ```js
165
+ DocNro: docNro,
166
+ // parche: 5 = Consumidor Final solo si DocTipo es 99; con otro DocTipo falta la condición frente al IVA del cliente
167
+ ...(Number(docTipo) === 99 ? { CondicionIVAReceptorId: 5 } : {}),
168
+ ```
169
+
170
+ ```php
171
+ );
172
+ // parche: 5 = Consumidor Final solo si DocTipo es 99; con otro DocTipo falta la condición frente al IVA del cliente
173
+ if ((int) $data['DocTipo'] === 99) {
174
+ $data['CondicionIVAReceptorId'] = 5;
175
+ }
176
+ ```
177
+
178
+ - El PR sale **borrador**, con "(parcial: consumidor final)" en el título. En "Lo que este PR NO resuelve" dice qué falta: los comprobantes con otro `DocTipo` (CUIT, DNI y otros) siguen sin el campo y desde el 1/12/2026 ARCA los va a rechazar. Ahí también están los códigos válidos para la clase del comprobante.
179
+ - Con otro `DocTipo` la clave ni existe (no queda en `undefined` ni en `null`): el pedido no depende de cómo lo serialice la librería y no aparece un rechazo que hoy no existe.
180
+ - `Number(...)` y `(int)` hacen que `'99'` como texto también cuente.
181
+ - En JS solo se aplica si `DocTipo` se puede releer sin efectos (`docTipo`, `cliente.docTipo`, `this.datos['DocTipo']`), no si es una llamada o un `await`. En PHP se lee del array ya armado, así que sirve cualquier expresión, pero el array tiene que estar en una variable (`$data = [...]`): con el array dentro de la llamada no hay dónde poner el `if`.
182
+ - No se aplica a facturas A ni a otras clases conocidas que no sean B o C.
183
+ - Si se vuelve a correr después del merge, no cambia nada y sigue avisando lo que falta.
184
+
160
185
  ## Límites del MVP
161
186
 
162
187
  - JS/TS (ESM y CommonJS) y, para ARCA, PHP. Siguen Python y Java.
package/bin/parche.js CHANGED
@@ -4,7 +4,7 @@ import fs from 'node:fs';
4
4
  import path from 'node:path';
5
5
  import { run } from '../src/engine.js';
6
6
  import { loadPacks } from '../src/packs.js';
7
- import { scanReport, prBody, counts, LABEL, daysUntil } from '../src/report.js';
7
+ import { scanReport, prBody, counts, LABEL, daysUntil, partialCovers } from '../src/report.js';
8
8
  import { verify } from '../src/verify.js';
9
9
  import * as git from '../src/git.js';
10
10
  import { parseGithubRemote, openPullRequest } from '../src/github.js';
@@ -121,7 +121,10 @@ function printFindings(result) {
121
121
  if (!fs2.length) continue;
122
122
  const d = pack.deadline ? ` · vence ${pack.deadline} (${daysUntil(pack.deadline)} días)` : '';
123
123
  console.log(`\n■ ${pack.title}\n ${pack.provider} · severidad ${pack.severity}${d}`);
124
- for (const f of fs2) console.log(` ${LABEL[f.status].padEnd(16)} ${f.file}${f.line ? ':' + f.line : ''} ${f.message}`);
124
+ for (const f of fs2) {
125
+ console.log(` ${LABEL[f.status].padEnd(16)} ${f.file}${f.line ? ':' + f.line : ''} ${f.message}`);
126
+ if (f.partial) console.log(` ${''.padEnd(16)} No cubre: ${f.partial.message}`);
127
+ }
125
128
  }
126
129
  }
127
130
 
@@ -204,7 +207,9 @@ async function main() {
204
207
  const preview = run(root, { write: false, packIds: [pack.id] });
205
208
  if (!preview.findings.length) continue;
206
209
  const branch = `parche/${pack.id}`;
207
- const title = `Parche: ${pack.title}`;
210
+ // Un arreglo parcial lo dice en el título: "(parcial: consumidor final)".
211
+ const covers = partialCovers(preview.findings);
212
+ const title = `Parche: ${pack.title}${covers.length ? ` (parcial: ${covers.join(', ')})` : ''}`;
208
213
  git.checkoutNewBranch(root, branch, base);
209
214
  let keepBranch = false;
210
215
  try {
@@ -246,7 +251,8 @@ async function main() {
246
251
  } else {
247
252
  console.log(` Modo prueba: no se abrió el PR${draft ? ' (iría como borrador por tener ítems para revisar)' : ''}.`);
248
253
  }
249
- record(root, { type: 'pr', pack: pack.id, packVersion: pack.version, branch, sha, url, draft, files: changedFiles, findings: c });
254
+ record(root, { type: 'pr', pack: pack.id, packVersion: pack.version, branch, sha, url, draft, files: changedFiles, findings: c,
255
+ partial: result.findings.filter((f) => f.partial).length });
250
256
  } finally {
251
257
  // Pase lo que pase, se vuelve a la rama base; la rama de Parche solo queda si tiene un commit.
252
258
  git.checkout(root, base);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "parche-cli",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
4
4
  "description": "Dependabot para APIs: detecta cambios de APIs de terceros en tu código y abre el PR con el arreglo.",
5
5
  "keywords": [
6
6
  "api",
@@ -48,6 +48,37 @@ export function appliedMessage(status) {
48
48
  : 'Agregado CondicionIVAReceptorId: 5 por inferencia (DNI + comprobante B/C). Confirmá que el cliente no sea exento ni no alcanzado.';
49
49
  }
50
50
 
51
+ // Arreglo parcial "en tiempo de ejecución" (punto 7 del frente B, 16/10): si DocTipo no es un literal
52
+ // (DocTipo: docTipo), la regla 99 → 5 se aplica al ejecutarse y, con otro DocTipo, el pedido sale igual que hoy.
53
+ // No inventa nada: Consumidor Final es el único caso que no depende de datos del negocio. No se aplica si el
54
+ // comprobante es clase A o de otra clase conocida (99 con factura A no existe).
55
+ export const PARTIAL = 'consumidor final';
56
+ export const PARTIAL_COMMENT = ' parche: 5 = Consumidor Final solo si DocTipo es 99; con otro DocTipo falta la condición frente al IVA del cliente';
57
+
58
+ export function canApplyAtRuntime(decision, cbteTipo) {
59
+ return decision.status === 'needs-input' && (cbteTipo === undefined || decision.clase === 'B' || decision.clase === 'C');
60
+ }
61
+
62
+ const dmy = (iso) => iso.split('-').map(Number).reverse().join('/');
63
+
64
+ export function partialAppliedMessage(docTipoText) {
65
+ return `Arreglo parcial (${PARTIAL}): CondicionIVAReceptorId: 5 solo cuando \`${docTipoText}\` vale 99 al ejecutarse. Con otro DocTipo el pedido sale igual que hoy, sin el campo.`;
66
+ }
67
+
68
+ // Lo que el arreglo parcial NO cubre: va en el cuerpo del PR como "lo que este PR no resuelve".
69
+ export function partialPending(decision, pack, suggestion) {
70
+ return {
71
+ covers: PARTIAL,
72
+ message: `No cubre los comprobantes con DocTipo distinto de 99 (CUIT, DNI y otros): siguen sin CondicionIVAReceptorId y desde el ${dmy(pack.deadline)} ARCA los va a rechazar. Falta la condición frente al IVA de cada cliente${decision.clase ? ` (comprobante clase ${decision.clase})` : ''}.`,
73
+ suggestion,
74
+ };
75
+ }
76
+
77
+ // En un scan posterior (el PR parcial ya se mergeó): lo que falta se sigue reportando.
78
+ export function partialLeftMessage(pending) {
79
+ return `Tiene el arreglo parcial de Parche (solo DocTipo 99 → 5). ${pending.message}`;
80
+ }
81
+
51
82
  // "El repo ya usa CondicionIVAReceptorId en otro lado": prefijo para bajar a "revisar" lo que no se pudo leer
52
83
  // (en repos ya migrados, el objeto suele salir de un builder que sí pone el campo).
53
84
  export function alreadyMigratedPrefix(context) {
@@ -7,6 +7,7 @@ import { eolOf, applyEdits, finding } from '../ast.js';
7
7
  import { parsePhp, walkPhp, phpLiteral, phpKey } from '../php.js';
8
8
  import {
9
9
  PACK, decideCondition, validCodesText, missingMessage, appliedMessage, soapDetection, ARCA_HINT, libraryCaveat, alreadyMigratedPrefix,
10
+ canApplyAtRuntime, PARTIAL_COMMENT, partialAppliedMessage, partialPending, partialLeftMessage,
10
11
  } from './arca-rules.js';
11
12
  import { emptyContext } from '../context.js';
12
13
 
@@ -31,16 +32,33 @@ function voucherMethod(call) {
31
32
  return VOUCHER_METHODS.get(method.toLowerCase());
32
33
  }
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
+
34
48
  // Asignaciones por scope: $var = ... y $var['Clave'] = ...
35
49
  function collect(ast) {
36
50
  const scopes = new Map();
37
51
  const calls = [];
38
52
  const info = (scope) => {
39
- if (!scopes.has(scope)) scopes.set(scope, { assigns: new Map(), offsets: new Map() });
53
+ if (!scopes.has(scope)) scopes.set(scope, { assigns: new Map(), offsets: new Map(), partialIfs: new Map() });
40
54
  return scopes.get(scope);
41
55
  };
42
56
  const push = (map, name, value) => map.set(name, [...(map.get(name) || []), value]);
43
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
+ }
44
62
  if (node.kind === 'assign') {
45
63
  const left = node.left;
46
64
  if (left.kind === 'variable' && typeof left.name === 'string') push(info(scope).assigns, left.name, node);
@@ -62,7 +80,7 @@ function resolveData(arg, scope, call, scopes) {
62
80
  if (!arg) return { reason: 'la llamada no tiene argumentos' };
63
81
  if (arg.kind === 'array') return { arr: arg };
64
82
  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() };
83
+ const s = scopes.get(scope) || { assigns: new Map(), offsets: new Map(), partialIfs: new Map() };
66
84
  const defs = s.assigns.get(arg.name) || [];
67
85
  const v = `$${arg.name}`;
68
86
  if (defs.length === 0) {
@@ -81,9 +99,14 @@ function resolveData(arg, scope, call, scopes) {
81
99
  if (def.operator !== '=' || def.right.kind !== 'array') return { reason: `${v} no es un array literal` };
82
100
  if (def.loc.start.offset > call.loc.start.offset) return { reason: `${v} se define después de la llamada` };
83
101
  const offsets = s.offsets.get(arg.name) || [];
84
- if (offsets.some((o) => o.key === FIELD)) return { migrated: true };
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 };
85
108
  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 };
109
+ return { arr: def.right, def, name: arg.name, partial };
87
110
  }
88
111
 
89
112
  export default function transform({ source, file, pack, context = emptyContext() }) {
@@ -120,9 +143,28 @@ export default function transform({ source, file, pack, context = emptyContext()
120
143
  if (byKey(FIELD) || edited.has(data.arr)) continue; // ya está migrado (o ya lo editamos)
121
144
 
122
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
+ }
123
153
  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)}` }));
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 }));
126
168
  continue;
127
169
  }
128
170
  const anchor = byKey('DocNro') || entries[entries.length - 1];
@@ -139,13 +181,52 @@ export default function transform({ source, file, pack, context = emptyContext()
139
181
  return { output: edits.length ? applyEdits(source, edits) : null, findings, usages };
140
182
  }
141
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
+
142
224
  // Inserta 'CondicionIVAReceptorId' => N después de `anchor`, copiando el estilo del array:
143
225
  // comillas de las claves, indentación, alineación de los =>, coma final y fin de línea.
144
226
  export function insertAfterEntry(source, entries, anchor, { value, comment }) {
145
227
  const eol = eolOf(source);
146
228
  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 ? '"' : "'";
229
+ const q = quoteOf(source, entries);
149
230
  const key = `${q}${FIELD}${q}`;
150
231
 
151
232
  const end = anchor.loc.end.offset;
@@ -2,10 +2,11 @@
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, applyEdits, 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
7
  import {
8
8
  PACK, decideCondition, validCodesText, missingMessage, appliedMessage, soapDetection, ARCA_HINT, libraryCaveat, alreadyMigratedPrefix,
9
+ canApplyAtRuntime, PARTIAL_COMMENT, partialAppliedMessage, partialPending, partialLeftMessage,
9
10
  } from './arca-rules.js';
10
11
  import { emptyContext } from '../context.js';
11
12
 
@@ -45,6 +46,50 @@ export function decide(obj, pack) {
45
46
  }, pack);
46
47
  }
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);
59
+ }
60
+ return false;
61
+ }
62
+
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
+
48
93
  export default function transform({ source, file, pack, context = emptyContext() }) {
49
94
  const findings = [];
50
95
  const usages = [];
@@ -77,26 +122,44 @@ export default function transform({ source, file, pack, context = emptyContext()
77
122
  const decl = resolveBinding(p, arg.name);
78
123
  if (decl && decl.node.init && decl.node.init.type === 'ObjectExpression') obj = decl.node.init;
79
124
  }
80
- if (!obj || hasSpread(obj)) {
125
+ if (!obj || otherSpread(obj)) {
81
126
  // Si el repo ya usa el campo en otro lado, lo más probable es que el builder ya lo ponga: "revisar".
82
127
  findings.push(finding(PACK, file, call, migrated ? 'needs-review' : 'needs-input', (migrated || '') + unreadableMessage(j, root, p, arg, obj)));
83
128
  return;
84
129
  }
85
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;
86
133
 
87
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
+ }
88
145
  if (d.status === 'needs-input') {
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
+ }
89
158
  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)}` }));
159
+ { suggestion: suggestionFor(d, pack) }));
91
160
  return;
92
161
  }
93
162
 
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
163
  edits.push(...insertAfterProp(source, anchor, `${keyText}: ${d.code}`, d.comment, { isLast }));
101
164
  edited.add(obj);
102
165
  changed = true;
package/src/report.js CHANGED
@@ -83,6 +83,7 @@ export function scanReport({ inventory, findings, packs, changed, upgrades = nul
83
83
  for (const f of fs) {
84
84
  out.push(`- ${LABEL[f.status]} \`${f.file}${f.line ? `:${f.line}` : ''}\` — ${f.message}`);
85
85
  if (f.suggestion) out.push(` - Sugerido: \`${f.suggestion}\``);
86
+ if (f.partial) out.push(` - ${LABEL['needs-input']} ${f.partial.message}`, ` - Cuando tengas el dato: \`${f.partial.suggestion}\``);
86
87
  }
87
88
  out.push('');
88
89
  }
@@ -92,6 +93,11 @@ export function scanReport({ inventory, findings, packs, changed, upgrades = nul
92
93
  return out.join('\n');
93
94
  }
94
95
 
96
+ // Si algún arreglo es parcial, qué casos cubre ("consumidor final"): va en el título y arriba del cuerpo del PR.
97
+ export function partialCovers(findings) {
98
+ return [...new Set(findings.filter((f) => f.partial).map((f) => f.partial.covers))];
99
+ }
100
+
95
101
  export function prBody({ pack, findings, changed, verification }, today = new Date()) {
96
102
  const fs = findings.filter((f) => f.pack === pack.id);
97
103
  const c = counts(fs);
@@ -102,12 +108,20 @@ export function prBody({ pack, findings, changed, verification }, today = new Da
102
108
  // Sin cambios de código el texto se usa como issue: los títulos no pueden hablar de "este PR".
103
109
  const asIssue = !changed.length;
104
110
  out.push(asIssue ? '### Qué encontró Parche' : '### Qué hace este PR', '');
111
+ const covers = partialCovers(fs);
112
+ if (covers.length && !asIssue) {
113
+ out.push(`> **Este PR es parcial (${covers.join(', ')}).** Arregla solo esos casos, sin inventar datos: el resto del pedido sale igual que hoy. Lo que no cubre está en "Lo que este PR NO resuelve" y hay que completarlo antes de la fecha límite.`, '');
114
+ }
105
115
  const applied = fs.filter((f) => f.status === 'auto' || f.status === 'needs-review');
106
116
  out.push(changed.length
107
117
  ? `Modifica ${changed.length} archivo(s): ${changed.map((x) => `\`${x.file}\``).join(', ')}.`
108
118
  : 'No hay un arreglo automático seguro: esto es lo que hay que resolver a mano.', '');
109
119
  for (const f of applied) out.push(`- ${LABEL[f.status]} \`${f.file}:${f.line}\` — ${f.message}`);
110
- const pending = fs.filter((f) => f.status === 'needs-input' || f.status === 'blocked');
120
+ // Lo que falta: los hallazgos sin arreglo y, de cada arreglo parcial, la parte que no cubre.
121
+ const pending = [
122
+ ...fs.filter((f) => f.status === 'needs-input' || f.status === 'blocked'),
123
+ ...fs.filter((f) => f.partial).map((f) => ({ ...f, status: 'needs-input', message: f.partial.message, suggestion: f.partial.suggestion })),
124
+ ];
111
125
  if (pending.length) {
112
126
  out.push('', asIssue ? '### Lo que hay que resolver (necesita una persona)' : '### Lo que este PR NO resuelve (necesita una persona)', '');
113
127
  for (const f of pending) {