parche-cli 0.5.1 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +21 -0
- package/README.md +29 -2
- package/bin/parche.js +35 -11
- package/package.json +1 -1
- package/src/codemods/arca-rules.js +31 -0
- package/src/codemods/arca-wsfe-condicion-iva-php.js +89 -8
- package/src/codemods/arca-wsfe-condicion-iva.js +101 -10
- package/src/engine.js +14 -6
- package/src/git.js +15 -1
- package/src/paths.js +22 -5
- package/src/report.js +20 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,27 @@
|
|
|
2
2
|
|
|
3
3
|
Todas las versiones de `parche-cli` (npm) y de la Action `Pepo08/parche@v0`.
|
|
4
4
|
|
|
5
|
+
## 0.6.1 — 2026-10-18
|
|
6
|
+
|
|
7
|
+
Lo que encontró el frente de tracción al preparar los PRs con la 0.6.0.
|
|
8
|
+
|
|
9
|
+
### Arreglado
|
|
10
|
+
- **ARCA en JS: si el objeto recibe el campo después, cuenta como migrado**, igual que en PHP: `voucher.CondicionIVAReceptorId = …`, `voucher['CondicionIVAReceptorId'] = …`, con cast de TS u `Object.assign(voucher, { CondicionIVAReceptorId: … })`. Antes se agregaba el arreglo parcial igual, y era redundante. Una variable con el mismo nombre en otra función no cuenta.
|
|
11
|
+
- **Copias de respaldo:** no se leen ni se editan las carpetas `BAK/`, `backup(s)/`, `old/`, `_old/` y `deprecated/`, ni los archivos `* copy.*`, `* - Copy.*`, `* copia.*`, `*.bak.*`, `*-old.*`, `*_old.*` y `*.old.*`. Tampoco cuentan sus manifiestos ni sus WSDL. El scan y `report.md` dicen qué se saltó.
|
|
12
|
+
- **Windows:** los comandos de git corren con `core.longpaths`. Sin eso, un clon recién hecho con rutas largas daba "Hay cambios sin commitear".
|
|
13
|
+
- **`parche pr --author "Nombre <email>"`** firma con esa identidad sin tocar la configuración de git. `pr` muestra quién firma y, antes de pushear, avisa si el email no es un noreply de GitHub (queda público en el PR).
|
|
14
|
+
- Si solo cambiarían tests o mocks, el texto queda como issue: ya no empieza con "Modifica 1 archivo".
|
|
15
|
+
- La consola ya no repite "No cubre: No cubre…" en los arreglos parciales.
|
|
16
|
+
|
|
17
|
+
## 0.6.0 — 2026-10-17
|
|
18
|
+
|
|
19
|
+
### Nuevo
|
|
20
|
+
- **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.
|
|
21
|
+
- El ledger registra cuántos arreglos parciales lleva cada PR (`partial`).
|
|
22
|
+
|
|
23
|
+
### Arreglado
|
|
24
|
+
- 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.
|
|
25
|
+
|
|
5
26
|
## 0.5.1 — 2026-10-17
|
|
6
27
|
|
|
7
28
|
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
|
|
|
@@ -137,7 +137,9 @@ Con el tag, el workflow corre los tests, controla que la versión de `package.js
|
|
|
137
137
|
|
|
138
138
|
Antes de commitear, Parche parsea **siempre** cada archivo modificado (JS/TS o PHP): eso no ejecuta nada del repo. Con `--verify`, además corre los tests del repo que correspondan a lo que cambió: `npm test` para JS/TS; para PHP, `composer test` o `vendor/bin/phpunit`. Si no hay tests, queda el chequeo de sintaxis y el PR lo dice. Sin `--verify` (por ejemplo, en un PR a un repo de terceros, donde no se ejecuta su código), el PR dice que Parche no corrió los tests y nombra los workflows del repo que los corren en cada pull request. Si algo falla, no hay PR. El commit lleva la versión del pack que lo generó, para poder rastrear qué regla produjo cada cambio.
|
|
139
139
|
|
|
140
|
-
**Quién firma el commit:** `PARCHE_GIT_NAME` y `PARCHE_GIT_EMAIL` si están; si no, tu identidad de git (`user.name` y `user.email`, también la que configure tu workflow); en GitHub Actions sin identidad, `github-actions[bot]`, el mismo que abre el PR. Si no hay ninguna, `parche pr` frena antes de tocar el repo.
|
|
140
|
+
**Quién firma el commit:** `--author "Nombre <email>"` si lo pasás; si no, `PARCHE_GIT_NAME` y `PARCHE_GIT_EMAIL` si están; si no, tu identidad de git (`user.name` y `user.email`, también la que configure tu workflow); en GitHub Actions sin identidad, `github-actions[bot]`, el mismo que abre el PR. Si no hay ninguna, `parche pr` frena antes de tocar el repo. Antes de pushear, si el email no es un noreply de GitHub, avisa: va a quedar público en el commit del PR.
|
|
141
|
+
|
|
142
|
+
**Copias de respaldo:** Parche no lee ni edita carpetas `BAK/`, `backup/`, `old/`, `_old/` y `deprecated/`, ni archivos como `factura copy.php`, `factura - Copy.js`, `factura.bak.php` o `factura-old.php`. El scan dice cuáles saltó, por si alguno es código que corre.
|
|
141
143
|
|
|
142
144
|
## ARCA en PHP
|
|
143
145
|
|
|
@@ -157,6 +159,31 @@ Para `afipsdk/afip.php` y sus forks (`fertandil87/afip.php`, `ivanalemunioz/afip
|
|
|
157
159
|
|
|
158
160
|
**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
161
|
|
|
162
|
+
## ARCA con `DocTipo` en una variable: PR parcial
|
|
163
|
+
|
|
164
|
+
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:
|
|
165
|
+
|
|
166
|
+
```js
|
|
167
|
+
DocNro: docNro,
|
|
168
|
+
// parche: 5 = Consumidor Final solo si DocTipo es 99; con otro DocTipo falta la condición frente al IVA del cliente
|
|
169
|
+
...(Number(docTipo) === 99 ? { CondicionIVAReceptorId: 5 } : {}),
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
```php
|
|
173
|
+
);
|
|
174
|
+
// parche: 5 = Consumidor Final solo si DocTipo es 99; con otro DocTipo falta la condición frente al IVA del cliente
|
|
175
|
+
if ((int) $data['DocTipo'] === 99) {
|
|
176
|
+
$data['CondicionIVAReceptorId'] = 5;
|
|
177
|
+
}
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
- 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.
|
|
181
|
+
- 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.
|
|
182
|
+
- `Number(...)` y `(int)` hacen que `'99'` como texto también cuente.
|
|
183
|
+
- 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`.
|
|
184
|
+
- No se aplica a facturas A ni a otras clases conocidas que no sean B o C.
|
|
185
|
+
- Si se vuelve a correr después del merge, no cambia nada y sigue avisando lo que falta.
|
|
186
|
+
|
|
160
187
|
## Límites del MVP
|
|
161
188
|
|
|
162
189
|
- 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';
|
|
@@ -22,9 +22,10 @@ Uso:
|
|
|
22
22
|
parche diff <paquete> --from <v> --to <v> [--json] [--all]
|
|
23
23
|
Qué cambia en la API de un paquete de npm entre dos versiones
|
|
24
24
|
parche fix <dir> [--pack <id>] Aplica los arreglos en el código
|
|
25
|
-
parche pr <dir> [--pack <id>] [--dry-run] [--verify] [--push-remote <remote>]
|
|
25
|
+
parche pr <dir> [--pack <id>] [--dry-run] [--verify] [--push-remote <remote>] [--author "Nombre <email>"]
|
|
26
26
|
Rama + commit + PR por cada cambio. Con --push-remote,
|
|
27
|
-
pushea a ese remote (tu fork) y abre el PR contra origin
|
|
27
|
+
pushea a ese remote (tu fork) y abre el PR contra origin.
|
|
28
|
+
Con --author, firma con esa identidad y no con la de git
|
|
28
29
|
parche feedback <dir> --pack <id> --outcome merged|edited|rejected [--note "..."]
|
|
29
30
|
`;
|
|
30
31
|
|
|
@@ -115,13 +116,24 @@ function printDiff(d, { all = false } = {}) {
|
|
|
115
116
|
}
|
|
116
117
|
}
|
|
117
118
|
|
|
119
|
+
// Lo que no se revisó por ser copia de respaldo: se dice, no se oculta.
|
|
120
|
+
function printSkipped(skipped = []) {
|
|
121
|
+
if (!skipped.length) return;
|
|
122
|
+
const shown = skipped.slice(0, 8).join(', ');
|
|
123
|
+
console.log(`\nNo revisé ${skipped.length} copia(s) de respaldo: ${shown}${skipped.length > 8 ? ` y ${skipped.length - 8} más` : ''}. Si alguna es código que corre, renombrala o movela.`);
|
|
124
|
+
}
|
|
125
|
+
|
|
118
126
|
function printFindings(result) {
|
|
119
127
|
for (const pack of result.packs) {
|
|
120
128
|
const fs2 = result.findings.filter((f) => f.pack === pack.id);
|
|
121
129
|
if (!fs2.length) continue;
|
|
122
130
|
const d = pack.deadline ? ` · vence ${pack.deadline} (${daysUntil(pack.deadline)} días)` : '';
|
|
123
131
|
console.log(`\n■ ${pack.title}\n ${pack.provider} · severidad ${pack.severity}${d}`);
|
|
124
|
-
for (const f of fs2)
|
|
132
|
+
for (const f of fs2) {
|
|
133
|
+
console.log(` ${LABEL[f.status].padEnd(16)} ${f.file}${f.line ? ':' + f.line : ''} ${f.message}`);
|
|
134
|
+
// partial.message ya empieza con "No cubre…": sin prefijo (antes salía "No cubre: No cubre").
|
|
135
|
+
if (f.partial) console.log(` ${''.padEnd(16)} ${f.partial.message}`);
|
|
136
|
+
}
|
|
125
137
|
}
|
|
126
138
|
}
|
|
127
139
|
|
|
@@ -168,11 +180,12 @@ async function main() {
|
|
|
168
180
|
fs.writeFileSync(path.join(root, '.parche', 'inventory.json'), JSON.stringify(result.inventory, null, 2));
|
|
169
181
|
fs.writeFileSync(path.join(root, '.parche', 'report.md'), scanReport({ ...result, changed: [], upgrades }));
|
|
170
182
|
record(root, { type: 'scan', findings: counts(result.findings) });
|
|
171
|
-
if (args.json) { console.log(JSON.stringify({ inventory: result.inventory, findings: result.findings }, null, 2)); return 0; }
|
|
183
|
+
if (args.json) { console.log(JSON.stringify({ inventory: result.inventory, findings: result.findings, skipped: result.skipped }, null, 2)); return 0; }
|
|
172
184
|
console.log(`Parche · scan de ${path.basename(root)}\n`);
|
|
173
185
|
printInventory(result.inventory);
|
|
174
186
|
if (upgrades) printUpgrades(upgrades);
|
|
175
187
|
printFindings(result);
|
|
188
|
+
printSkipped(result.skipped);
|
|
176
189
|
const s = summary(result.findings);
|
|
177
190
|
console.log(s ? `\n${s}` : '\nNinguno de los packs verificados tiene cambios que te afecten hoy.');
|
|
178
191
|
console.log(`Reporte: ${path.join(root, '.parche', 'report.md')}`);
|
|
@@ -194,7 +207,13 @@ async function main() {
|
|
|
194
207
|
if (!git.isRepo(root)) throw new Error('`parche pr` necesita un repositorio git.');
|
|
195
208
|
if (!git.isClean(root)) throw new Error('Hay cambios sin commitear. Commiteá o guardá (stash) antes de correr `parche pr`.');
|
|
196
209
|
// Quién firma el commit se resuelve antes de escribir nada: si falla al commitear, el repo quedaría sucio.
|
|
197
|
-
git.
|
|
210
|
+
const env = args.author !== undefined ? { ...process.env, ...git.parseAuthor(args.author) } : process.env;
|
|
211
|
+
const identity = git.commitIdentity(root, env);
|
|
212
|
+
console.log(`Firma los commits: ${identity.name} <${identity.email}>`);
|
|
213
|
+
// El email queda público en el PR: si no es un noreply de GitHub, se avisa antes de pushear (frente B, punto 20).
|
|
214
|
+
if (!args['dry-run'] && process.env.GITHUB_TOKEN && process.env.GITHUB_ACTIONS !== 'true' && !git.isNoreply(identity.email)) {
|
|
215
|
+
console.warn(`Ojo: ${identity.email} va a quedar público en el commit del PR. Si preferís no mostrarlo, usá --author "Nombre <usuario@users.noreply.github.com>".`);
|
|
216
|
+
}
|
|
198
217
|
ensureLocalDir(root);
|
|
199
218
|
const base = git.currentBranch(root);
|
|
200
219
|
const packs = loadPacks().filter((p) => p.codemod && (!packIds || packIds.includes(p.id)));
|
|
@@ -204,7 +223,9 @@ async function main() {
|
|
|
204
223
|
const preview = run(root, { write: false, packIds: [pack.id] });
|
|
205
224
|
if (!preview.findings.length) continue;
|
|
206
225
|
const branch = `parche/${pack.id}`;
|
|
207
|
-
|
|
226
|
+
// Un arreglo parcial lo dice en el título: "(parcial: consumidor final)".
|
|
227
|
+
const covers = partialCovers(preview.findings);
|
|
228
|
+
const title = `Parche: ${pack.title}${covers.length ? ` (parcial: ${covers.join(', ')})` : ''}`;
|
|
208
229
|
git.checkoutNewBranch(root, branch, base);
|
|
209
230
|
let keepBranch = false;
|
|
210
231
|
try {
|
|
@@ -220,8 +241,10 @@ async function main() {
|
|
|
220
241
|
git.discardChanges(root);
|
|
221
242
|
throw new Error(`La verificación falló para ${pack.id}; no se abre el PR y el repo queda como estaba.\n${JSON.stringify(verification, null, 2)}`);
|
|
222
243
|
}
|
|
223
|
-
|
|
224
|
-
|
|
244
|
+
// Si solo cambiarían tests o mocks no hay PR: el texto queda como issue, sin "Modifica 1 archivo" (frente B, punto 20).
|
|
245
|
+
const reportOnly = !changedFiles.length || changedFiles.every(isTestFile);
|
|
246
|
+
fs.writeFileSync(bodyPath, prBody({ pack, findings: result.findings, changed: reportOnly ? [] : result.changed, verification: reportOnly ? null : verification }));
|
|
247
|
+
if (reportOnly) {
|
|
225
248
|
const why = changedFiles.length ? 'solo cambiaría tests o mocks: no se abre un PR' : 'sin cambios de código';
|
|
226
249
|
git.discardChanges(root);
|
|
227
250
|
console.log(` ${summary(result.findings)}`);
|
|
@@ -230,7 +253,7 @@ async function main() {
|
|
|
230
253
|
continue;
|
|
231
254
|
}
|
|
232
255
|
const draft = !!(c['needs-review'] || c['needs-input'] || c.blocked);
|
|
233
|
-
const sha = git.commitAll(root, `${title}\n\n${pack.summary_es}\nPack ${pack.id} v${pack.version}
|
|
256
|
+
const sha = git.commitAll(root, `${title}\n\n${pack.summary_es}\nPack ${pack.id} v${pack.version}.`, env);
|
|
234
257
|
keepBranch = true;
|
|
235
258
|
console.log(` Rama ${branch} · commit ${sha} · ${changedFiles.length} archivo(s)${verification ? ` · verificación ${verification.ok ? 'OK' : 'FALLÓ'}` : ''}`);
|
|
236
259
|
console.log(` ${summary(result.findings)}`);
|
|
@@ -246,7 +269,8 @@ async function main() {
|
|
|
246
269
|
} else {
|
|
247
270
|
console.log(` Modo prueba: no se abrió el PR${draft ? ' (iría como borrador por tener ítems para revisar)' : ''}.`);
|
|
248
271
|
}
|
|
249
|
-
record(root, { type: 'pr', pack: pack.id, packVersion: pack.version, branch, sha, url, draft, files: changedFiles, findings: c
|
|
272
|
+
record(root, { type: 'pr', pack: pack.id, packVersion: pack.version, branch, sha, url, draft, files: changedFiles, findings: c,
|
|
273
|
+
partial: result.findings.filter((f) => f.partial).length });
|
|
250
274
|
} finally {
|
|
251
275
|
// Pase lo que pase, se vuelve a la rama base; la rama de Parche solo queda si tiene un commit.
|
|
252
276
|
git.checkout(root, base);
|
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
125
|
-
|
|
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
|
|
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,
|
|
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,76 @@ 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
|
+
// Nombre de la propiedad de un acceso: voucher.Campo o voucher['Campo'].
|
|
78
|
+
const memberName = (m) => (!m.computed && m.property.type === 'Identifier' ? m.property.name
|
|
79
|
+
: m.computed && (m.property.type === 'StringLiteral' || m.property.type === 'Literal') ? m.property.value : null);
|
|
80
|
+
|
|
81
|
+
// ¿El objeto de la variable `name` (declarada en `decl`) recibe el campo después de armarse?
|
|
82
|
+
// voucher.CondicionIVAReceptorId = …, voucher['CondicionIVAReceptorId'] = …, (voucher as any).CondicionIVAReceptorId = …
|
|
83
|
+
// u Object.assign(voucher, { CondicionIVAReceptorId: … }). Cuenta como migrado, igual que en PHP
|
|
84
|
+
// (frente B, punto 18: tometh04/vibook-services lo asigna en un if y Parche agregaba el arreglo parcial igual).
|
|
85
|
+
// Solo cuenta si es la misma variable: otra con el mismo nombre en otra función no.
|
|
86
|
+
function fieldAssignedLater(j, root, decl, name) {
|
|
87
|
+
const same = (p) => resolveBinding(p, name)?.node === decl.node;
|
|
88
|
+
const isVar = (n) => { const x = unwrap(n); return !!(x && x.type === 'Identifier' && x.name === name); };
|
|
89
|
+
const assigned = root.find(j.AssignmentExpression).filter((p) => {
|
|
90
|
+
const left = unwrap(p.node.left);
|
|
91
|
+
return left && left.type === 'MemberExpression' && isVar(left.object) && memberName(left) === 'CondicionIVAReceptorId' && same(p);
|
|
92
|
+
}).size() > 0;
|
|
93
|
+
if (assigned) return true;
|
|
94
|
+
return root.find(j.CallExpression).filter((p) => {
|
|
95
|
+
const c = unwrap(p.node.callee);
|
|
96
|
+
const isAssign = c && c.type === 'MemberExpression' && !c.computed && c.object.type === 'Identifier' && c.object.name === 'Object'
|
|
97
|
+
&& c.property.type === 'Identifier' && c.property.name === 'assign';
|
|
98
|
+
if (!isAssign || !isVar(p.node.arguments[0]) || !same(p)) return false;
|
|
99
|
+
return p.node.arguments.slice(1).some((a) => { const o = unwrap(a); return o && o.type === 'ObjectExpression' && !!getProp(o, 'CondicionIVAReceptorId'); });
|
|
100
|
+
}).size() > 0;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// La clave nueva copia el estilo del objeto: con comillas si la mayoría de las claves las tiene, y las
|
|
104
|
+
// mismas comillas que esas claves. Las del resto del archivo solo desempatan: en un repo real las claves
|
|
105
|
+
// iban con ' y el archivo usaba " en el resto, y la clave nueva salía distinta a sus vecinas.
|
|
106
|
+
function keyTextFor(source, obj) {
|
|
107
|
+
const props = obj.properties.filter(isPlainProp);
|
|
108
|
+
const quotedKeys = props.filter(isQuotedKey);
|
|
109
|
+
if (!props.length || quotedKeys.length < props.length / 2) return 'CondicionIVAReceptorId';
|
|
110
|
+
const raw = (k) => (k.extra?.raw ?? k.raw ?? source[offsetAt(source, k.loc.start)])[0];
|
|
111
|
+
const doubles = quotedKeys.filter((p) => raw(p.key) === '"').length;
|
|
112
|
+
const singles = quotedKeys.length - doubles;
|
|
113
|
+
const q = doubles === singles ? (detectQuote(source) === 'double' ? '"' : "'") : doubles > singles ? '"' : "'";
|
|
114
|
+
return `${q}CondicionIVAReceptorId${q}`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const suggestionFor = (d, pack) => `'CondicionIVAReceptorId': <condición del cliente>, // valores válidos: ${validCodesText(d, pack)}`;
|
|
118
|
+
|
|
48
119
|
export default function transform({ source, file, pack, context = emptyContext() }) {
|
|
49
120
|
const findings = [];
|
|
50
121
|
const usages = [];
|
|
@@ -72,31 +143,51 @@ export default function transform({ source, file, pack, context = emptyContext()
|
|
|
72
143
|
usages.push({ provider: 'arca', op: `ElectronicBilling.${voucherCallee(p).property.name}`, line: call.loc?.start.line });
|
|
73
144
|
const arg = call.arguments[0];
|
|
74
145
|
let obj = null;
|
|
146
|
+
let binding = null;
|
|
75
147
|
if (arg && arg.type === 'ObjectExpression') obj = arg;
|
|
76
148
|
else if (arg && arg.type === 'Identifier') {
|
|
77
149
|
const decl = resolveBinding(p, arg.name);
|
|
78
|
-
if (decl && decl.node.init && decl.node.init.type === 'ObjectExpression') obj = decl.node.init;
|
|
150
|
+
if (decl && decl.node.init && decl.node.init.type === 'ObjectExpression') { obj = decl.node.init; binding = decl; }
|
|
79
151
|
}
|
|
80
|
-
if (!obj ||
|
|
152
|
+
if (!obj || otherSpread(obj)) {
|
|
81
153
|
// Si el repo ya usa el campo en otro lado, lo más probable es que el builder ya lo ponga: "revisar".
|
|
82
154
|
findings.push(finding(PACK, file, call, migrated ? 'needs-review' : 'needs-input', (migrated || '') + unreadableMessage(j, root, p, arg, obj)));
|
|
83
155
|
return;
|
|
84
156
|
}
|
|
85
157
|
if (getProp(obj, 'CondicionIVAReceptorId') || edited.has(obj)) return; // ya está migrado (o ya lo editamos)
|
|
158
|
+
if (binding && fieldAssignedLater(j, root, binding, arg.name)) return; // lo completa después: también migrado
|
|
159
|
+
const spreads = obj.properties.map(conditionSpread);
|
|
160
|
+
if (spreads.includes('full')) return;
|
|
86
161
|
|
|
87
162
|
const d = decide(obj, pack);
|
|
163
|
+
const props = obj.properties.filter(isPlainProp);
|
|
164
|
+
const keyText = keyTextFor(source, obj);
|
|
165
|
+
const anchor = getProp(obj, 'DocNro') || props[props.length - 1];
|
|
166
|
+
const isLast = anchor === obj.properties[obj.properties.length - 1];
|
|
167
|
+
if (spreads.includes('partial')) {
|
|
168
|
+
// Ya tiene el arreglo parcial: no se toca, pero lo que falta se sigue reportando.
|
|
169
|
+
const pending = partialPending(d, pack, suggestionFor(d, pack));
|
|
170
|
+
findings.push(finding(PACK, file, call, 'needs-input', partialLeftMessage(pending), { suggestion: pending.suggestion }));
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
88
173
|
if (d.status === 'needs-input') {
|
|
174
|
+
const docTipo = getProp(obj, 'DocTipo')?.value;
|
|
175
|
+
const expr = docTipo && anchor && literalValue(docTipo) === undefined && isPureRead(docTipo) ? textOf(source, docTipo) : null;
|
|
176
|
+
if (expr && !expr.includes('\n') && canApplyAtRuntime(d, literalValue(getProp(obj, 'CbteTipo')?.value))) {
|
|
177
|
+
// Spread y no `CondicionIVAReceptorId: x === 99 ? 5 : undefined`: con otro DocTipo la clave ni existe,
|
|
178
|
+
// así el pedido queda idéntico al de hoy sin depender de cómo serialice la librería (decisión D52).
|
|
179
|
+
edits.push(...insertAfterProp(source, anchor, `...(Number(${expr}) === 99 ? { ${keyText}: 5 } : {})`, PARTIAL_COMMENT, { isLast }));
|
|
180
|
+
edited.add(obj);
|
|
181
|
+
changed = true;
|
|
182
|
+
findings.push(finding(PACK, file, call, 'needs-review', partialAppliedMessage(expr) + (caveat ? ` ${caveat}` : ''),
|
|
183
|
+
{ partial: partialPending(d, pack, suggestionFor(d, pack)) }));
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
89
186
|
findings.push(finding(PACK, file, call, 'needs-input', missingMessage(d) + (caveat ? ` ${caveat}` : ''),
|
|
90
|
-
{ suggestion:
|
|
187
|
+
{ suggestion: suggestionFor(d, pack) }));
|
|
91
188
|
return;
|
|
92
189
|
}
|
|
93
190
|
|
|
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
191
|
edits.push(...insertAfterProp(source, anchor, `${keyText}: ${d.code}`, d.comment, { isLast }));
|
|
101
192
|
edited.add(obj);
|
|
102
193
|
changed = true;
|
package/src/engine.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { loadPacks, transformFor, languageOf } from './packs.js';
|
|
5
|
-
import { SKIP_DIRS, toPosix } from './paths.js';
|
|
5
|
+
import { SKIP_DIRS, toPosix, isBackupDir, isBackupFile } from './paths.js';
|
|
6
6
|
import { scanJs } from './inventory/js-scan.js';
|
|
7
7
|
import { readManifests, buildInventory } from './inventory/index.js';
|
|
8
8
|
import { buildContext } from './context.js';
|
|
@@ -10,18 +10,25 @@ import { buildContext } from './context.js';
|
|
|
10
10
|
// JS/TS y PHP (codemods), WSDL (detección de esquemas viejos) y Python, C#, Java, Go, VB.NET (solo detección).
|
|
11
11
|
const EXT = /\.(m?js|cjs|jsx|ts|tsx|php|wsdl|py|cs|java|go|vb)$/i;
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
// Archivos que el motor lee. Las copias de respaldo (BAK/, "x copy.php"…) no se leen ni se editan:
|
|
14
|
+
// van a `skipped`, con "/", para decir en el reporte qué se ignoró.
|
|
15
|
+
export function listFiles(dir, skipped = []) {
|
|
14
16
|
const out = [];
|
|
15
17
|
const walk = (d) => {
|
|
16
18
|
for (const e of fs.readdirSync(d, { withFileTypes: true })) {
|
|
19
|
+
const rel = () => toPosix(path.relative(dir, path.join(d, e.name)));
|
|
17
20
|
if (e.isDirectory()) {
|
|
18
|
-
if (
|
|
21
|
+
if (SKIP_DIRS.has(e.name)) continue;
|
|
22
|
+
if (isBackupDir(e.name)) { skipped.push(`${rel()}/`); continue; }
|
|
23
|
+
walk(path.join(d, e.name));
|
|
19
24
|
} else if (EXT.test(e.name) && !/\.d\.ts$/.test(e.name)) {
|
|
20
|
-
|
|
25
|
+
if (isBackupFile(e.name)) skipped.push(rel());
|
|
26
|
+
else out.push(path.join(d, e.name));
|
|
21
27
|
}
|
|
22
28
|
}
|
|
23
29
|
};
|
|
24
30
|
walk(dir);
|
|
31
|
+
skipped.sort();
|
|
25
32
|
// Mismo orden en Windows y en Linux: se ordena por la ruta con "/".
|
|
26
33
|
return out.sort((a, b) => (toPosix(a) < toPosix(b) ? -1 : 1));
|
|
27
34
|
}
|
|
@@ -31,7 +38,8 @@ export function run(dir, { write = false, packIds = null, today } = {}) {
|
|
|
31
38
|
const packs = loadPacks().filter((p) => !packIds || packIds.includes(p.id));
|
|
32
39
|
const manifests = readManifests(root);
|
|
33
40
|
const deps = { ...manifests.npm, ...manifests.composer };
|
|
34
|
-
const
|
|
41
|
+
const skipped = [];
|
|
42
|
+
const files = listFiles(root, skipped);
|
|
35
43
|
const rels = files.map((abs) => toPosix(path.relative(root, abs)));
|
|
36
44
|
// Lo que un archivo solo no sabe (versiones instaladas, si el repo ya usa el campo, WSDL viejos).
|
|
37
45
|
const context = buildContext(root, rels, manifests);
|
|
@@ -83,7 +91,7 @@ export function run(dir, { write = false, packIds = null, today } = {}) {
|
|
|
83
91
|
providers: Object.fromEntries(Object.entries(providers).map(([k, v]) => [k, { ...v, files: [...v.files] }])),
|
|
84
92
|
usages,
|
|
85
93
|
};
|
|
86
|
-
return { root, packs, context, inventory, findings, changed: [...changed.entries()].map(([file, packIds]) => ({ file, packs: packIds })) };
|
|
94
|
+
return { root, packs, context, inventory, findings, skipped, changed: [...changed.entries()].map(([file, packIds]) => ({ file, packs: packIds })) };
|
|
87
95
|
}
|
|
88
96
|
|
|
89
97
|
export const STATUS_ORDER = ['blocked', 'needs-input', 'needs-review', 'auto', 'detect-only'];
|
package/src/git.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { execFileSync } from 'node:child_process';
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
// En Windows, sin core.longpaths, git ve modificado un archivo con ruta de más de 260 caracteres, y `parche pr`
|
|
4
|
+
// frenaba con "Hay cambios sin commitear" en un clon recién hecho (frente B, punto 20).
|
|
5
|
+
export const gitArgs = (args, platform = process.platform) => (platform === 'win32' ? ['-c', 'core.longpaths=true', ...args] : args);
|
|
6
|
+
const git = (cwd, ...args) => execFileSync('git', gitArgs(args), { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
|
|
4
7
|
|
|
5
8
|
export function isRepo(cwd) {
|
|
6
9
|
try {
|
|
@@ -48,6 +51,17 @@ export function commitIdentity(cwd, env = process.env) {
|
|
|
48
51
|
throw new Error('No hay una identidad de git para firmar el commit: configurá `git config user.name` y `git config user.email`, o PARCHE_GIT_NAME y PARCHE_GIT_EMAIL.');
|
|
49
52
|
}
|
|
50
53
|
|
|
54
|
+
// --author "Nombre <email>": quién firma, sin tocar la configuración de git de la máquina (frente B, punto 20:
|
|
55
|
+
// la identidad global de la compu era otra persona con un mail personal).
|
|
56
|
+
export function parseAuthor(text) {
|
|
57
|
+
const m = /^\s*(.+?)\s*<([^<>\s]+@[^<>\s]+)>\s*$/.exec(typeof text === 'string' ? text : '');
|
|
58
|
+
if (!m) throw new Error('--author tiene que ser "Nombre <email>", por ejemplo --author "Ana Pérez <ana@users.noreply.github.com>".');
|
|
59
|
+
return { PARCHE_GIT_NAME: m[1], PARCHE_GIT_EMAIL: m[2] };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// El email del commit queda público en el PR: si no es un noreply de GitHub, `parche pr` avisa antes de pushear.
|
|
63
|
+
export const isNoreply = (email) => /@users\.noreply\.github\.com$/i.test(email || '');
|
|
64
|
+
|
|
51
65
|
export function commitAll(cwd, message, env = process.env) {
|
|
52
66
|
const id = commitIdentity(cwd, env);
|
|
53
67
|
git(cwd, 'add', '-A');
|
package/src/paths.js
CHANGED
|
@@ -16,9 +16,19 @@ export function isTestFile(file) {
|
|
|
16
16
|
|| /\.(test|spec|mock)\.[cm]?[jt]sx?$/i.test(file) || /Test\.php$/.test(file);
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
|
|
19
|
+
// Copias de respaldo: no es el código que corre. Un PR sobre BAK/ o sobre "factura copy.php" no le sirve al
|
|
20
|
+
// mantenedor (frente B, 17/10: axel-tecnosoul/facturador ya usaba el campo en los archivos vigentes).
|
|
21
|
+
// Carpetas BAK, backup(s), old, _old y deprecated; archivos "x copy.php", "x - Copy.js", "x copia.php",
|
|
22
|
+
// "x.bak.php", "x-old.js", "x_old.php" y "x.old.php". No cuentan "deep_copy.js" ni "gold.js".
|
|
23
|
+
const BACKUP_DIR = /^(bak|backups?|old|_old|deprecated)$/i;
|
|
24
|
+
export const isBackupDir = (name) => BACKUP_DIR.test(name);
|
|
25
|
+
export function isBackupFile(name) {
|
|
26
|
+
return /\s(-\s)?(copy|copia)(\s\(?\d+\)?)?\.[^.]+$/i.test(name) || /\.bak\.[^.]+$/i.test(name) || /[-_.]old\.[^.]+$/i.test(name);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Recorre el repo (salteando SKIP_DIRS y las copias de respaldo) y devuelve las rutas relativas, con "/",
|
|
30
|
+
// de los archivos que cumplen `match(nombre)`. Lo salteado por ser respaldo va a `skipped`, si se pasa.
|
|
31
|
+
export function findFiles(root, match, skipped = null) {
|
|
22
32
|
const out = [];
|
|
23
33
|
const walk = (dir) => {
|
|
24
34
|
let entries;
|
|
@@ -28,8 +38,15 @@ export function findFiles(root, match) {
|
|
|
28
38
|
return;
|
|
29
39
|
}
|
|
30
40
|
for (const e of entries) {
|
|
31
|
-
|
|
32
|
-
|
|
41
|
+
const rel = () => toPosix(path.relative(root, path.join(dir, e.name)));
|
|
42
|
+
if (e.isDirectory()) {
|
|
43
|
+
if (SKIP_DIRS.has(e.name)) continue;
|
|
44
|
+
if (isBackupDir(e.name)) { skipped?.push(`${rel()}/`); continue; }
|
|
45
|
+
walk(path.join(dir, e.name));
|
|
46
|
+
} else if (match(e.name)) {
|
|
47
|
+
if (isBackupFile(e.name)) skipped?.push(rel());
|
|
48
|
+
else out.push(rel());
|
|
49
|
+
}
|
|
33
50
|
}
|
|
34
51
|
};
|
|
35
52
|
walk(root);
|
package/src/report.js
CHANGED
|
@@ -53,7 +53,7 @@ function upgradesSection(upgrades) {
|
|
|
53
53
|
return out;
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
export function scanReport({ inventory, findings, packs, changed, upgrades = null }, today = new Date()) {
|
|
56
|
+
export function scanReport({ inventory, findings, packs, changed, upgrades = null, skipped = [] }, today = new Date()) {
|
|
57
57
|
const out = [];
|
|
58
58
|
out.push(`# Parche — reporte de \`${inventory.repo}\``, '');
|
|
59
59
|
out.push('## APIs que usa este repo', '');
|
|
@@ -73,6 +73,10 @@ export function scanReport({ inventory, findings, packs, changed, upgrades = nul
|
|
|
73
73
|
out.push('**Llamadas HTTP con URL dinámica** (no se puede saber a qué host van):', '',
|
|
74
74
|
...inventory.unresolved.map((u) => `- \`${u.file}:${u.line}\` (${u.client})`), '');
|
|
75
75
|
}
|
|
76
|
+
if (skipped.length) {
|
|
77
|
+
out.push('**Copias de respaldo que no se revisaron** (si alguna es código que corre, renombrala o movela):', '',
|
|
78
|
+
...skipped.map((s) => `- \`${s}\``), '');
|
|
79
|
+
}
|
|
76
80
|
if (upgrades) out.push(...upgradesSection(upgrades));
|
|
77
81
|
for (const pack of packs) {
|
|
78
82
|
const fs = findings.filter((f) => f.pack === pack.id);
|
|
@@ -83,6 +87,7 @@ export function scanReport({ inventory, findings, packs, changed, upgrades = nul
|
|
|
83
87
|
for (const f of fs) {
|
|
84
88
|
out.push(`- ${LABEL[f.status]} \`${f.file}${f.line ? `:${f.line}` : ''}\` — ${f.message}`);
|
|
85
89
|
if (f.suggestion) out.push(` - Sugerido: \`${f.suggestion}\``);
|
|
90
|
+
if (f.partial) out.push(` - ${LABEL['needs-input']} ${f.partial.message}`, ` - Cuando tengas el dato: \`${f.partial.suggestion}\``);
|
|
86
91
|
}
|
|
87
92
|
out.push('');
|
|
88
93
|
}
|
|
@@ -92,6 +97,11 @@ export function scanReport({ inventory, findings, packs, changed, upgrades = nul
|
|
|
92
97
|
return out.join('\n');
|
|
93
98
|
}
|
|
94
99
|
|
|
100
|
+
// Si algún arreglo es parcial, qué casos cubre ("consumidor final"): va en el título y arriba del cuerpo del PR.
|
|
101
|
+
export function partialCovers(findings) {
|
|
102
|
+
return [...new Set(findings.filter((f) => f.partial).map((f) => f.partial.covers))];
|
|
103
|
+
}
|
|
104
|
+
|
|
95
105
|
export function prBody({ pack, findings, changed, verification }, today = new Date()) {
|
|
96
106
|
const fs = findings.filter((f) => f.pack === pack.id);
|
|
97
107
|
const c = counts(fs);
|
|
@@ -102,12 +112,20 @@ export function prBody({ pack, findings, changed, verification }, today = new Da
|
|
|
102
112
|
// Sin cambios de código el texto se usa como issue: los títulos no pueden hablar de "este PR".
|
|
103
113
|
const asIssue = !changed.length;
|
|
104
114
|
out.push(asIssue ? '### Qué encontró Parche' : '### Qué hace este PR', '');
|
|
115
|
+
const covers = partialCovers(fs);
|
|
116
|
+
if (covers.length && !asIssue) {
|
|
117
|
+
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.`, '');
|
|
118
|
+
}
|
|
105
119
|
const applied = fs.filter((f) => f.status === 'auto' || f.status === 'needs-review');
|
|
106
120
|
out.push(changed.length
|
|
107
121
|
? `Modifica ${changed.length} archivo(s): ${changed.map((x) => `\`${x.file}\``).join(', ')}.`
|
|
108
122
|
: 'No hay un arreglo automático seguro: esto es lo que hay que resolver a mano.', '');
|
|
109
123
|
for (const f of applied) out.push(`- ${LABEL[f.status]} \`${f.file}:${f.line}\` — ${f.message}`);
|
|
110
|
-
|
|
124
|
+
// Lo que falta: los hallazgos sin arreglo y, de cada arreglo parcial, la parte que no cubre.
|
|
125
|
+
const pending = [
|
|
126
|
+
...fs.filter((f) => f.status === 'needs-input' || f.status === 'blocked'),
|
|
127
|
+
...fs.filter((f) => f.partial).map((f) => ({ ...f, status: 'needs-input', message: f.partial.message, suggestion: f.partial.suggestion })),
|
|
128
|
+
];
|
|
111
129
|
if (pending.length) {
|
|
112
130
|
out.push('', asIssue ? '### Lo que hay que resolver (necesita una persona)' : '### Lo que este PR NO resuelve (necesita una persona)', '');
|
|
113
131
|
for (const f of pending) {
|