parche-cli 0.7.0 → 0.7.3

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
@@ -1,6 +1,38 @@
1
1
  # Cambios
2
2
 
3
- Los cambios de cada versión de `parche-cli` y de la Action `Pepo08/parche@v0`. En npm están la 0.1.1, la 0.5.1 y de la 0.6.0 en adelante. La 0.1.0 y de la 0.2.0 a la 0.5.0 no se publicaron en npm; lo que traían está incluido en la 0.5.1. La fecha de cada versión es la del cambio; la de publicación figura en npm y en los [Releases](https://github.com/Pepo08/parche/releases).
3
+ Los cambios de cada versión de `parche-cli` y de la Action `Pepo08/parche@v0`. En npm están la 0.1.1, la 0.5.1, de la 0.6.0 a la 0.7.0 y de la 0.7.2 en adelante. La 0.1.0 y de la 0.2.0 a la 0.5.0 no se publicaron en npm; lo que traían está incluido en la 0.5.1. La 0.7.1 tampoco: salió junto con la 0.7.2. La fecha de cada versión es la del cambio; la de publicación figura en npm y en los [Releases](https://github.com/Pepo08/parche/releases).
4
+
5
+ ## 0.7.3 — 2026-10-24
6
+
7
+ La foto de cada corrida de la Action. Hasta la 0.7.2, el inventario de APIs, los avisos y el registro se escribían dentro del runner y se perdían al terminar el job: de una corrida quedaban solo el resumen del job y los PRs, y el resumen no se puede leer por la API de GitHub.
8
+
9
+ ### Nuevo
10
+ - **La Action guarda la foto de la corrida** como artifact del workflow (`parche`): `scan.json`, que es la salida de `parche scan --json` (el inventario y los avisos), y `ledger.jsonl`, el registro de esa corrida (los PRs que abrió y lo que quedó sin PR). Lleva rutas, números de línea, paquetes, versiones, operaciones y los mensajes de Parche; no lleva código. Se baja con `gh run download <id> -n parche`. Inputs nuevos: `snapshot` (`'true'` por defecto; `'false'` la apaga) y `snapshot-name`, para usar la Action más de una vez en el mismo workflow. Corre aunque `parche pr` haya fallado, y si la foto falla, la corrida no falla: queda un aviso.
11
+ - **Cada aviso tiene un `fingerprint`**, un identificador que sale del pack, el archivo y el texto de la línea, sin el número de línea ni el mensaje. No cambia si se mueven las líneas, se reindenta el código o el archivo pasa a CRLF; sí cambia si cambia el código de esa línea. Sirve para comparar una corrida con la anterior: qué es nuevo, qué sigue y qué se resolvió.
12
+ - **`parche scan --json` dice qué versión de Parche lo generó** (`parcheVersion`).
13
+
14
+ ### Cambios
15
+ - `actions/upload-artifact` (v7.0.2) fijada por SHA, como las demás Actions; un test controla que `action.yml` también las fije.
16
+
17
+ ## 0.7.2 — 2026-10-24
18
+
19
+ ARCA: lo que pidió el re-escaneo de repos públicos con la 0.7.0. Un PR preparado quedaba parcial sin decirlo, y dos casos podían sacar un PR que no anda.
20
+
21
+ ### Cambios
22
+ - **Clave casi bien escrita en una asignación aparte:** `det.CondicionIVAReceptor = x`, `det['CondicionIVAReceptor'] = x` (JS y TS) y `$det['CondicionIVAReceptor'] = $x` (PHP) se renombran a `CondicionIVAReceptorId` cuando `det` es, en la misma función, un objeto o array con claves de WSFEv1. En un repo real, el informe de comprobantes con CAEA lo hacía así, y la 0.7.0 renombraba las otras tres apariciones y esta ni la nombraba: el PR quedaba parcial en silencio. Si no se puede confirmar que `det` sea el comprobante (por ejemplo, si llega como parámetro), Parche lo avisa para revisar y no lo toca.
23
+ - **El renombre ya no rompe el tipado:** si un `interface` o un `type` del repo declara la clave mal escrita (`CondicionIvaReceptorId?: number`), renombrarla solo en el objeto hacía que TypeScript no compilara, y el chequeo de sintaxis no lo veía. Ahora no se renombra, y el aviso dice en qué archivo y línea está el tipo para cambiar los dos.
24
+ - **Archivos con saltos de línea `\r` sueltos (`\r\r\n`):** en JS y TS, Parche no los edita. El parser cuenta cada `\r` como una línea y la edición caía en otra parte del archivo; en un repo real, dentro de un comentario y con "Sintaxis OK". Ahora el hallazgo pasa a "revisar", con lo que hay que cambiar a mano y la línea que muestra git.
25
+
26
+ ## 0.7.1 — 2026-10-24
27
+
28
+ La Action, lista para el GitHub Marketplace como "Parche API Changes". No cambia nada de lo que hace Parche.
29
+
30
+ ### Cambios
31
+ - **Ícono de la Action:** `git-pull-request`. El que tenía, `tool`, está en la lista de íconos que el Marketplace no acepta y el panel de publicación lo marcaba como error.
32
+ - **Descripción de la Action en inglés y en menos de 125 caracteres:** es la tarjeta del Marketplace, que ve gente de cualquier país. "Dependabot para APIs" queda como frase de presentación.
33
+ - **README:** un "GitHub Action quick start" en inglés al principio (la página de la Action en el Marketplace es el README): qué hace la plantilla, los permisos y los inputs.
34
+ - **Input `verify`:** la descripción nombra todos los tests que puede correr (`npm test`, `composer test` o `vendor/bin/phpunit`), no solo `npm test`.
35
+ - Un test controla lo que revisa el Marketplace: el nombre, el largo de la descripción, el ícono y el color.
4
36
 
5
37
  ## 0.7.0 — 2026-10-24
6
38
 
package/README.md CHANGED
@@ -6,7 +6,29 @@
6
6
 
7
7
  > **In English.** Parche reads your code and inventories every third-party API it uses: SDKs, direct HTTP calls and AI model IDs. When a provider ships a dated change that affects you, it tells you the file and the line, or opens a small PR with a deterministic fix. Every changed file is parsed before the PR, and with `--verify` your own tests run too. What can't be inferred is never made up: the PR asks for it. Today's packs cover Argentina's ARCA e-invoicing change (due December 1, 2026), Mercado Pago Payments → Orders, and OpenAI and Anthropic model and parameter deprecations.
8
8
  >
9
- > Try it with `npx parche-cli scan .` (Node 22+): it runs locally, with no account and no telemetry ([security and data](SECURITY.md)). GitHub Action: `uses: Pepo08/parche@v0`. The rest of this README is in Spanish for now · [getparche.com](https://getparche.com) · hola@getparche.com
9
+ > Try it with `npx parche-cli scan .` (Node 22+): it runs locally, with no account and no telemetry ([security and data](SECURITY.md)). GitHub Action: [quick start below](#github-action-quick-start). The rest of this README is in Spanish for now · [getparche.com](https://getparche.com) · hola@getparche.com
10
+
11
+ ### GitHub Action quick start
12
+
13
+ Copy [`templates/github-workflow.yml`](templates/github-workflow.yml) to `.github/workflows/parche.yml`. Every Monday it installs your dependencies (so your tests can run) and runs Parche, which opens one PR per API change that affects the repo, as a draft if something needs a human, and lists everything else in the job summary. The job needs `contents: write` and `pull-requests: write`. The key step:
14
+
15
+ ```yaml
16
+ - uses: Pepo08/parche@v0
17
+ with:
18
+ verify: 'true' # run your tests before opening each PR
19
+ ```
20
+
21
+ | Input | Default | What it does |
22
+ |---|---|---|
23
+ | `path` | `.` | Folder to scan. |
24
+ | `pack` | all packs | Run a single pack (list them with `npx parche-cli packs`). |
25
+ | `verify` | `'true'` | Run the repo's tests (`npm test`, `composer test` or `vendor/bin/phpunit`) before opening each PR. |
26
+ | `dry-run` | `'false'` | Build the branches and PR bodies without opening any PR. |
27
+ | `token` | `github.token` | Pushes the branches and opens the PRs. With the default `GITHUB_TOKEN`, GitHub holds the PR's workflows until someone approves them; with a GitHub App token or a personal token they run on their own. |
28
+ | `snapshot` | `'true'` | Save a snapshot of the run (API inventory, findings and the PRs it opened, as JSON; no source code) as a workflow artifact. |
29
+ | `snapshot-name` | `parche` | Artifact name. Use a different one for each step if you call the Action more than once in a workflow. |
30
+
31
+ `@v0` follows every 0.x release; to pin one, use its release commit: `Pepo08/parche@<sha> # v0.7.2`. PR bodies and the job output are in Spanish for now.
10
32
 
11
33
  Parche lee tu código y arma el inventario de **todas** las APIs de terceros que usás: SDKs (Stripe, Twilio, OpenAI, AWS, Mercado Pago, ARCA…), llamadas HTTP directas y modelos de IA. Lo cruza contra una base de **cambios de API verificados** y, cuando un proveedor cambia algo que te afecta, te avisa o abre un PR con el arreglo, explica qué cambió y por qué, y marca lo que necesita una persona. Ningún PR sale sin pasar la verificación. Las APIs de pagos y facturación de Latinoamérica son el fuerte, pero el inventario cubre cualquier API.
12
34
 
@@ -95,6 +117,8 @@ El workflow necesita `permissions: contents: write` y `pull-requests: write`. An
95
117
 
96
118
  Lo que no abre un PR también se ve en la corrida: los avisos de solo detección (modelos de IA que se retiran, por ejemplo) y lo que quedó sin PR porque falta un dato de tu negocio aparecen en el **resumen del job** y como **anotaciones** con el archivo y la línea (GitHub muestra las primeras 10; el resumen las tiene todas).
97
119
 
120
+ **La foto de la corrida:** además, la Action guarda un artifact del workflow (`parche`, salvo que le pongas otro nombre con `snapshot-name`) con dos archivos. `scan.json` es la salida de `parche scan --json`: el inventario de APIs y los avisos, cada uno con su `fingerprint`, un identificador que no cambia aunque se muevan las líneas, para comparar una semana con la anterior. `ledger.jsonl` es el registro de esa corrida: los PRs que abrió y lo que quedó sin PR. Lleva rutas, números de línea, paquetes, versiones, operaciones y los mensajes de Parche; no lleva código. Lo baja quien puede ver las corridas de Actions del repo (`gh run download <id> -n parche`), y dura lo que diga la retención de artifacts del repo. Si no lo querés: `snapshot: 'false'`. Si la foto falla, la corrida no falla: queda un aviso.
121
+
98
122
  Con el `GITHUB_TOKEN` por defecto, GitHub deja los workflows del PR que abre Parche esperando aprobación: alguien con permiso de escritura tiene que tocar **Approve workflows to run** (el cuerpo del PR lo avisa). Para que tu CI corra sola sobre esos PRs, pasale a la Action un token de una GitHub App o un token personal con permiso de escritura en `contents` y `pull-requests`:
99
123
 
100
124
  ```yaml
@@ -151,7 +175,7 @@ Parche corre en tu máquina o en tu runner. Tu código no sale de ahí, salvo el
151
175
  | **Red** | `scan`, `fix` y `packs` no usan la red. `pr` llama a `api.github.com` para abrir el PR en tu repo y pushea su rama `parche/<pack>` (con `--force`, así solo reemplaza su propio PR anterior). `diff` y `scan --upgrades` bajan metadatos y paquetes de `registry.npmjs.org`, que ve qué paquetes y versiones consultás. Nada más. |
152
176
  | **Telemetría** | Ninguna. `parche feedback` escribe en `.parche/ledger.jsonl` de tu repo y queda ahí. |
153
177
  | **Qué ejecuta** | `git` y, solo con `--verify`, los tests de tu repo (`npm test`, `composer test` o `vendor/bin/phpunit`). |
154
- | **Qué escribe** | Tu working copy (solo `fix` y `pr`), `.parche/` (se agrega a `.git/info/exclude`) y, para `diff` y `--upgrades`, un caché en `~/.parche/cache`. |
178
+ | **Qué escribe** | Tu working copy (solo `fix` y `pr`), `.parche/` (se agrega a `.git/info/exclude`) y, para `diff` y `--upgrades`, un caché en `~/.parche/cache`. En GitHub Actions, además, la foto de la corrida como artifact de tu workflow (inventario y avisos en JSON, sin código; se apaga con `snapshot: 'false'`). |
155
179
 
156
180
  Para reportar una vulnerabilidad, y el detalle de todo esto: [`SECURITY.md`](SECURITY.md). Si te llegó un PR de Parche: [por qué lo recibiste](docs/por-que-recibi-este-pr.md).
157
181
 
@@ -175,7 +199,7 @@ Para `afipsdk/afip.php` y sus forks (`fertandil87/afip.php`, `ivanalemunioz/afip
175
199
 
176
200
  **La versión de la librería importa.** Con `afip.js`/`afip.php` 0.x, `eesnaola/afip.php` y `ts-afip-ws` (un fork de afip.js 0.x; su última versión, la 1.3.2, trae el WSDL sin el campo) el pedido se arma con un WSDL de 2021 que no tiene `CondicionIVAReceptorId`: aunque el campo esté en el código, no llega a ARCA. Parche lee la versión instalada (`composer.lock`, `package-lock.json`, `node_modules`) y, en esos casos, el arreglo sale como **"revisar"** y lo que pide un dato lleva el mismo aviso: qué actualizar para que el campo viaje. También avisa si el repo tiene un `.wsdl` de WSFEv1 sin el campo.
177
201
 
178
- **Clave casi bien escrita.** Si el detalle del comprobante lleva `CondicionIVAReceptor` (sin `Id`), `CondicionIvaReceptorId` o `condicionIVAReceptorId`, ese elemento no existe en el WSDL: el dato nunca llega y el repo parece migrado. Parche renombra la clave a `CondicionIVAReceptorId` sin tocar el valor, en JS y en PHP, y el PR sale **borrador** para revisar que el valor sea un código válido. Solo lo hace dentro de objetos o arrays con al menos dos claves de WSFEv1 (`DocTipo`, `CbteDesde`, `ImpTotal`…), para no tocar modelos propios con un nombre parecido, y nunca en tests.
202
+ **Clave casi bien escrita.** Si el detalle del comprobante lleva `CondicionIVAReceptor` (sin `Id`), `CondicionIvaReceptorId` o `condicionIVAReceptorId`, ese elemento no existe en el WSDL: el dato nunca llega y el repo parece migrado. Parche renombra la clave a `CondicionIVAReceptorId` sin tocar el valor, en JS y en PHP, y el PR sale **borrador** para revisar que el valor sea un código válido. Solo lo hace dentro de objetos o arrays con al menos dos claves de WSFEv1 (`DocTipo`, `CbteDesde`, `ImpTotal`…), para no tocar modelos propios con un nombre parecido, y nunca en tests. También renombra una asignación aparte (`det.CondicionIVAReceptor = x`, `det['CondicionIVAReceptor'] = x` o `$det['CondicionIVAReceptor'] = $x`) cuando `det` es, en la misma función, un objeto o array con esas claves; si no puede confirmarlo, lo avisa sin tocar. Si un `interface` o un `type` del repo declara la clave mal escrita, no renombra, porque TypeScript dejaría de compilar, y el aviso dice dónde está ese tipo.
179
203
 
180
204
  **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).
181
205
 
@@ -207,7 +231,7 @@ Cuando `DocTipo` no es un número fijo (`DocTipo: docTipo`), Parche no puede sab
207
231
  ## Límites del MVP
208
232
 
209
233
  - JS/TS (ESM y CommonJS) y, para ARCA, PHP. Siguen Python y Java.
210
- - ARCA: cubre `@afipsdk/afip.js`, `@arcasdk/core` (`arca.electronicBillingService.createVoucher` y `createNextVoucher`) y `afipsdk/afip.php` (con sus forks). Las integraciones SOAP propias (en cualquier lenguaje) se detectan pero no se reescriben, salvo el renombre de una clave casi bien escrita.
234
+ - ARCA: cubre `@afipsdk/afip.js`, `@arcasdk/core` (`arca.electronicBillingService.createVoucher` y `createNextVoucher`) y `afipsdk/afip.php` (con sus forks). Las integraciones SOAP propias (en cualquier lenguaje) se detectan pero no se reescriben, salvo el renombre de una clave casi bien escrita. Un archivo JS o TS con saltos de línea `\r` sueltos (por ejemplo `\r\r\n`) no se edita: el aviso dice qué cambiar a mano.
211
235
  - IA, parámetros de Claude 4.7 y posteriores: avisa solo si el modelo del pedido se lee en el mismo archivo (un literal, una constante asignada una vez o el valor por defecto de una variable de entorno) y el parámetro es un número escrito en el código. Si el modelo llega de otro archivo o de la configuración, no avisa: no se puede saber si el pedido va a fallar.
212
236
  - IA, API version `2026-10-21` de Anthropic: la versión del SDK sale de `package-lock.json`, `node_modules` o el rango de `package.json`, y en Python solo de `requirements.txt` (todavía no de `pyproject.toml`, `poetry.lock` ni `uv.lock`). Un rango sin número (`latest`, `*`) cuenta como versión desconocida. Por HTTP directo avisa solo si el archivo manda `anthropic-version: 2026-10-21`. Los SDKs de Go, Java, .NET, Ruby y PHP no se leen.
213
237
  - IA, alias de Anthropic: avisa los de Claude Sonnet 4.5, Opus 4.5 y Haiku 4.5 (`claude-sonnet-4-5`, `claude-opus-4-5`, `claude-haiku-4-5`), escritos enteros entre comillas. Los alias de modelos ya retirados (`claude-3-5-sonnet-latest`, `claude-3-7-sonnet-latest` y otros) todavía no: la documentación actual de Anthropic ya no los nombra.
package/bin/parche.js CHANGED
@@ -139,12 +139,13 @@ function printFindings(result) {
139
139
  }
140
140
  }
141
141
 
142
+ const VERSION = () => JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
143
+
142
144
  async function main() {
143
145
  const args = parseArgs(process.argv.slice(2));
144
146
  const [cmd, dirArg] = args._;
145
147
  if (args.version || cmd === '-v') {
146
- const pkg = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
147
- console.log(pkg.version);
148
+ console.log(VERSION());
148
149
  return 0;
149
150
  }
150
151
  if (!cmd || args.help) { console.log(HELP); return 0; }
@@ -187,7 +188,8 @@ async function main() {
187
188
  fs.writeFileSync(path.join(root, '.parche', 'inventory.json'), JSON.stringify(result.inventory, null, 2));
188
189
  fs.writeFileSync(path.join(root, '.parche', 'report.md'), scanReport({ ...result, changed: [], upgrades }));
189
190
  record(root, { type: 'scan', findings: counts(result.findings) });
190
- if (args.json) { console.log(JSON.stringify({ inventory: result.inventory, findings: result.findings, skipped: result.skipped }, null, 2)); return 0; }
191
+ // parcheVersion: qué versión (y por lo tanto qué reglas) generó este JSON; la foto de cada corrida de la Action lo guarda.
192
+ if (args.json) { console.log(JSON.stringify({ parcheVersion: VERSION(), inventory: result.inventory, findings: result.findings, skipped: result.skipped }, null, 2)); return 0; }
191
193
  console.log(`Parche · scan de ${path.basename(root)}\n`);
192
194
  printInventory(result.inventory);
193
195
  if (upgrades) printUpgrades(upgrades);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "parche-cli",
3
- "version": "0.7.0",
3
+ "version": "0.7.3",
4
4
  "description": "Dependabot para APIs: detecta cambios de cualquier API de terceros en tu código (ARCA, Mercado Pago, OpenAI, Anthropic) y abre el PR con el arreglo.",
5
5
  "keywords": [
6
6
  "api",
package/src/ast.js CHANGED
@@ -91,6 +91,15 @@ export function lineOf(node) {
91
91
  return node && node.loc ? node.loc.start.line : null;
92
92
  }
93
93
 
94
+ // La línea del archivo (contando \n, como git y GitHub) de una línea del parser, que también cuenta cada \r suelto.
95
+ // Solo difieren en archivos con \r\r\n o \r solos (frente B, punto 22).
96
+ export function fileLine(source, line) {
97
+ const breaks = /\r\n|[\n\r]/g;
98
+ let offset = 0;
99
+ for (let i = 1; i < line && breaks.exec(source); i++) offset = breaks.lastIndex;
100
+ return source.slice(0, offset).split('\n').length;
101
+ }
102
+
94
103
  // Copia profunda de un nodo sin metadatos de posición, para poder usarlo dos veces.
95
104
  export function cloneNode(node) {
96
105
  if (Array.isArray(node)) return node.map(cloneNode);
@@ -98,6 +98,27 @@ export function nearMissBothMessage(key) {
98
98
  return `El comprobante tiene \`${key}\` y también \`${FIELD}\`. \`${key}\` no es un campo de WSFEv1: se puede borrar. Revisá que \`${FIELD}\` lleve el valor correcto.`;
99
99
  }
100
100
 
101
+ // Frente B, punto 25: una asignación aparte (`det.CondicionIVAReceptor = x`) a un objeto que no se puede confirmar
102
+ // como el detalle del comprobante. No se toca, pero se dice: un arreglo parcial nunca queda en silencio.
103
+ export function nearMissUnsureMessage(target, key) {
104
+ return `\`${target}\`: si es el detalle del comprobante, \`${key}\` no es un campo de WSFEv1 y ARCA espera \`${FIELD}\`. `
105
+ + 'No lo cambié porque no pude confirmar que sea el comprobante: revisalo a mano.';
106
+ }
107
+
108
+ // Frente B, punto 23: el tipo del repo declara la clave mal escrita. Renombrar solo el objeto rompe el tipado.
109
+ export function nearMissTypedMessage(key, decl, pack) {
110
+ return `\`${key}\` no es un campo de WSFEv1: ARCA espera \`${FIELD}\`, así que hoy el dato no llega y desde el ${dmy(pack.deadline)} ARCA rechaza el CAE. `
111
+ + `No lo renombré porque el tipo de \`${decl.file}:${decl.line}\` declara \`${key}\` y TypeScript dejaría de compilar: renombralo en los dos lugares (y en los tests que lo usen).`;
112
+ }
113
+
114
+ // Frente B, punto 22: con un \r suelto (\r\r\n) el parser cuenta una línea más que el archivo y la edición caía en
115
+ // otra línea. En esos archivos no se edita: se dice qué cambio hacer a mano.
116
+ export const LOOSE_EOL = /\r(?!\n)|[\u2028\u2029]/;
117
+ export function looseEolMessage(manual) {
118
+ return 'No edité este archivo: tiene saltos de línea `\\r` sueltos (por ejemplo `\\r\\r\\n`) y Parche no puede ubicar el cambio con certeza. '
119
+ + `Hacelo a mano: ${manual}.`;
120
+ }
121
+
101
122
  // "El repo ya usa CondicionIVAReceptorId en otro lado": prefijo para bajar a "revisar" lo que no se pudo leer
102
123
  // (en repos ya migrados, el objeto suele salir de un builder que sí pone el campo).
103
124
  export function alreadyMigratedPrefix(context) {
@@ -8,7 +8,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
10
  canApplyAtRuntime, PARTIAL_COMMENT, partialAppliedMessage, partialPending, partialLeftMessage,
11
- NEAR_HINT, isNearMissKey, isVoucherDetail, nearMissMessage, nearMissBothMessage,
11
+ NEAR_HINT, isNearMissKey, isVoucherDetail, nearMissMessage, nearMissBothMessage, nearMissUnsureMessage,
12
12
  } from './arca-rules.js';
13
13
  import { emptyContext } from '../context.js';
14
14
  import { isTestFile } from '../paths.js';
@@ -134,7 +134,7 @@ export default function transform({ source, file, pack, context = emptyContext()
134
134
 
135
135
  // Punto 13 del frente B: 'CondicionIVAReceptor' => $x (sin Id) se renombra, como en JS. Solo cambia el texto
136
136
  // entre las comillas de la clave; el array queda marcado para que la llamada no reciba un campo nuevo.
137
- let renamed = false;
137
+ let nearMiss = false; // el archivo ya tiene un aviso de la clave: el de SOAP sin el campo sobra
138
138
  if (!isTestFile(file)) {
139
139
  walkPhp(ast, (node) => {
140
140
  if (node.kind !== 'array') return;
@@ -142,15 +142,45 @@ export default function transform({ source, file, pack, context = emptyContext()
142
142
  const near = entries.find((e) => isNearMissKey(phpKey(e)));
143
143
  if (!near || !isVoucherDetail(entries.map(phpKey))) return;
144
144
  edited.add(node);
145
+ nearMiss = true;
145
146
  if (entries.some((e) => phpKey(e) === FIELD)) {
146
147
  findings.push(finding(PACK, file, near, 'needs-review', nearMissBothMessage(phpKey(near))));
147
148
  return;
148
149
  }
149
150
  const { start, end } = near.key.loc;
150
151
  edits.push({ offset: start.offset + 1, remove: end.offset - start.offset - 2, text: FIELD });
151
- renamed = true;
152
152
  findings.push(finding(PACK, file, near, 'needs-review', nearMissMessage(phpKey(near), pack) + (caveat ? ` ${caveat}` : '')));
153
153
  });
154
+
155
+ // Punto 25: $det['CondicionIVAReceptor'] = $x; en una asignación aparte. Se renombra si $det es, en la misma
156
+ // función, un array literal con claves de WSFEv1 definido una sola vez; si no se puede confirmar, se avisa.
157
+ for (const s of scopes.values()) {
158
+ for (const [name, offsets] of s.offsets) {
159
+ for (const o of offsets) {
160
+ if (!isNearMissKey(o.key) || o.node.left.offset.kind !== 'string') continue;
161
+ const defs = s.assigns.get(name) || [];
162
+ const arr = defs.length === 1 && defs[0].operator === '=' && defs[0].right.kind === 'array' ? defs[0].right : null;
163
+ const entries = arr ? arr.items.filter((it) => it && it.kind === 'entry') : [];
164
+ if (!arr || !isVoucherDetail(entries.map(phpKey))) {
165
+ // Solo con la grafía de WSFEv1 (con mayúscula): $cliente['condicionIvaReceptor'] es un campo propio.
166
+ if (ARCA_HINT.test(source) && /^Condicion/.test(o.key)) {
167
+ findings.push(finding(PACK, file, o.node, 'needs-review', nearMissUnsureMessage(`$${name}['${o.key}']`, o.key)));
168
+ nearMiss = true;
169
+ }
170
+ continue;
171
+ }
172
+ edited.add(arr);
173
+ nearMiss = true;
174
+ if (entries.some((e) => phpKey(e) === FIELD) || offsets.some((x) => x.key === FIELD)) {
175
+ findings.push(finding(PACK, file, o.node, 'needs-review', nearMissBothMessage(o.key)));
176
+ continue;
177
+ }
178
+ const { start, end } = o.node.left.offset.loc;
179
+ edits.push({ offset: start.offset + 1, remove: end.offset - start.offset - 2, text: FIELD });
180
+ findings.push(finding(PACK, file, o.node, 'needs-review', nearMissMessage(o.key, pack) + (caveat ? ` ${caveat}` : '')));
181
+ }
182
+ }
183
+ }
154
184
  }
155
185
 
156
186
  for (const { node: call, scope, method } of calls) {
@@ -199,7 +229,7 @@ export default function transform({ source, file, pack, context = emptyContext()
199
229
  findings.push(finding(PACK, file, call, status, appliedMessage(d.status) + (caveat ? ` ${caveat}` : '')));
200
230
  }
201
231
 
202
- const soap = renamed ? null : soapDetection(source, file, context); // ya manda el campo (ver el codemod de JS)
232
+ const soap = nearMiss ? null : soapDetection(source, file, context); // ya tiene su aviso (ver el codemod de JS)
203
233
  if (soap) { findings.push(soap.finding); usages.push(soap.usage); }
204
234
 
205
235
  return { output: edits.length ? applyEdits(source, edits) : null, findings, usages };
@@ -2,12 +2,13 @@
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, isQuotedKey, isPlainProp, keyName, literalValue, resolveBinding, finding,
5
+ jFor, detectQuote, eolOf, offsetAt, applyEdits, getProp, isQuotedKey, isPlainProp, keyName, literalValue, resolveBinding, finding, fileLine,
6
6
  } from '../ast.js';
7
7
  import {
8
8
  PACK, decideCondition, validCodesText, missingMessage, appliedMessage, soapDetection, ARCA_HINT, libraryCaveat, alreadyMigratedPrefix,
9
9
  canApplyAtRuntime, PARTIAL_COMMENT, partialAppliedMessage, partialPending, partialLeftMessage,
10
- NEAR_HINT, isNearMissKey, isVoucherDetail, nearMissMessage, nearMissBothMessage,
10
+ NEAR_HINT, isNearMissKey, isVoucherDetail, nearMissMessage, nearMissBothMessage, nearMissUnsureMessage, nearMissTypedMessage,
11
+ LOOSE_EOL, looseEolMessage,
11
12
  } from './arca-rules.js';
12
13
  import { emptyContext } from '../context.js';
13
14
  import { isTestFile } from '../paths.js';
@@ -152,10 +153,15 @@ export default function transform({ source, file, pack, context = emptyContext()
152
153
  // Objetos ya editados: el mismo `payload` puede ir a dos llamadas (lo vimos en un repo real). Como se edita
153
154
  // texto y el árbol no se entera, sin esto se insertaba dos veces → clave duplicada y TypeScript no compila.
154
155
  const edited = new Set();
156
+ // Cada cambio, con cómo hacerlo a mano: si el archivo tiene \r sueltos no se edita y se dice eso (punto 22).
157
+ const applied = [];
158
+ const apply = (f, manual) => { findings.push(f); applied.push({ f, manual }); };
159
+ // Punto 23: si un tipo del repo declara la clave mal escrita, renombrarla solo acá rompe el tipado.
160
+ const typedConflict = (key) => (/\.[cm]?tsx?$/.test(file) ? (context.nearMissTypes || []).find((t) => t.key === key) : null);
155
161
 
156
162
  // Punto 13 del frente B: la clave casi bien escrita se renombra; el nombre correcto lo fija el WSDL.
157
163
  // Va antes que las llamadas, así el comprobante renombrado no recibe además un CondicionIVAReceptorId nuevo.
158
- let renamed = false;
164
+ let nearMiss = false; // el archivo ya tiene un aviso de la clave: el de SOAP sin el campo sobra
159
165
  if (!isTestFile(file)) {
160
166
  root.find(j.ObjectExpression).forEach((p) => {
161
167
  const obj = p.node;
@@ -163,14 +169,57 @@ export default function transform({ source, file, pack, context = emptyContext()
163
169
  const near = props.find((pr) => isNearMissKey(keyName(pr)));
164
170
  if (!near || !isVoucherDetail(props.map(keyName))) return;
165
171
  edited.add(obj);
172
+ nearMiss = true;
166
173
  if (getProp(obj, 'CondicionIVAReceptorId')) {
167
174
  findings.push(finding(PACK, file, near, 'needs-review', nearMissBothMessage(keyName(near))));
168
175
  return;
169
176
  }
177
+ const typed = typedConflict(keyName(near));
178
+ if (typed) {
179
+ findings.push(finding(PACK, file, near, 'needs-review', nearMissTypedMessage(keyName(near), typed, pack)));
180
+ return;
181
+ }
170
182
  edits.push(renameKey(source, near));
171
- renamed = true;
172
183
  changed = true;
173
- findings.push(finding(PACK, file, near, 'needs-review', nearMissMessage(keyName(near), pack) + (caveat ? ` ${caveat}` : '')));
184
+ apply(finding(PACK, file, near, 'needs-review', nearMissMessage(keyName(near), pack) + (caveat ? ` ${caveat}` : '')),
185
+ `renombrá \`${keyName(near)}\` a \`CondicionIVAReceptorId\``);
186
+ });
187
+
188
+ // Punto 25: la misma clave en una asignación aparte (det.CondicionIVAReceptor = x, det['CondicionIVAReceptor'] = x),
189
+ // como en el informe de CAEA de un repo real. Se renombra si `det` es, en el mismo scope, un objeto literal con
190
+ // claves de WSFEv1. Si no se puede confirmar que sea el comprobante, se avisa sin tocar nada.
191
+ root.find(j.AssignmentExpression).forEach((p) => {
192
+ const left = unwrap(p.node.left);
193
+ if (!left || left.type !== 'MemberExpression') return;
194
+ const key = memberName(left);
195
+ if (!isNearMissKey(key)) return;
196
+ const target = unwrap(left.object);
197
+ const decl = target && target.type === 'Identifier' ? resolveBinding(p, target.name) : null;
198
+ const obj = unwrap(decl && decl.node.init);
199
+ if (!obj || obj.type !== 'ObjectExpression' || !isVoucherDetail(obj.properties.filter(isPlainProp).map(keyName))) {
200
+ // Solo con la grafía de WSFEv1 (con mayúscula): `cliente.condicionIvaReceptor = …` es un campo propio.
201
+ if (arcaFile && /^Condicion/.test(key)) {
202
+ const shown = target && target.type === 'Identifier' ? `${target.name}.${key}` : textOf(source, left);
203
+ findings.push(finding(PACK, file, p.node, 'needs-review', nearMissUnsureMessage(shown, key)));
204
+ nearMiss = true;
205
+ }
206
+ return;
207
+ }
208
+ edited.add(obj);
209
+ nearMiss = true;
210
+ if (getProp(obj, 'CondicionIVAReceptorId') || fieldAssignedLater(j, root, decl, target.name)) {
211
+ findings.push(finding(PACK, file, p.node, 'needs-review', nearMissBothMessage(key)));
212
+ return;
213
+ }
214
+ const typed = typedConflict(key);
215
+ if (typed) {
216
+ findings.push(finding(PACK, file, p.node, 'needs-review', nearMissTypedMessage(key, typed, pack)));
217
+ return;
218
+ }
219
+ edits.push(renameMember(source, left));
220
+ changed = true;
221
+ apply(finding(PACK, file, p.node, 'needs-review', nearMissMessage(key, pack) + (caveat ? ` ${caveat}` : '')),
222
+ `renombrá \`${target.name}.${key}\` a \`${target.name}.CondicionIVAReceptorId\``);
174
223
  });
175
224
  }
176
225
 
@@ -218,8 +267,9 @@ export default function transform({ source, file, pack, context = emptyContext()
218
267
  edits.push(...insertAfterProp(source, anchor, `...(Number(${expr}) === 99 ? { ${keyText}: 5 } : {})`, PARTIAL_COMMENT, { isLast }));
219
268
  edited.add(obj);
220
269
  changed = true;
221
- findings.push(finding(PACK, file, call, 'needs-review', partialAppliedMessage(expr) + (caveat ? ` ${caveat}` : ''),
222
- { partial: partialPending(d, pack, suggestionFor(d, pack)) }));
270
+ apply(finding(PACK, file, call, 'needs-review', partialAppliedMessage(expr) + (caveat ? ` ${caveat}` : ''),
271
+ { partial: partialPending(d, pack, suggestionFor(d, pack)) }),
272
+ 'agregá `CondicionIVAReceptorId: 5` al comprobante cuando DocTipo es 99 (consumidor final); con otro DocTipo falta la condición frente al IVA del cliente');
223
273
  return;
224
274
  }
225
275
  findings.push(finding(PACK, file, call, 'needs-input', missingMessage(d) + (caveat ? ` ${caveat}` : ''),
@@ -232,16 +282,40 @@ export default function transform({ source, file, pack, context = emptyContext()
232
282
  changed = true;
233
283
  // Con librería vieja o WSDL viejo el arreglo no alcanza solo: deja de ser "automático".
234
284
  const status = caveat && d.status === 'auto' ? 'needs-review' : d.status;
235
- findings.push(finding(PACK, file, call, status, appliedMessage(d.status) + (caveat ? ` ${caveat}` : '')));
285
+ apply(finding(PACK, file, call, status, appliedMessage(d.status) + (caveat ? ` ${caveat}` : '')),
286
+ `agregá \`CondicionIVAReceptorId: ${d.code}\` al comprobante`);
236
287
  });
237
288
 
238
- // Con la clave renombrada el archivo ya manda el campo: el aviso de SOAP sin CondicionIVAReceptorId sobra.
239
- const soap = renamed ? null : soapDetection(source, file, context);
289
+ // Punto 22: con \r sueltos (\r\r\n) las líneas del parser no son las del archivo: se informan las de git.
290
+ const looseEol = LOOSE_EOL.test(source);
291
+ if (looseEol) for (const x of [...findings, ...usages]) if (x.line) x.line = fileLine(source, x.line);
292
+
293
+ // Con la clave renombrada (o avisada) el archivo ya tiene su aviso: el de SOAP sin CondicionIVAReceptorId sobra.
294
+ const soap = nearMiss ? null : soapDetection(source, file, context);
240
295
  if (soap) { findings.push(soap.finding); usages.push(soap.usage); }
241
296
 
297
+ // Y la edición caía en otro lado (una vez, dentro de un comentario y con "Sintaxis OK"). No se edita: cada cambio
298
+ // pasa a "revisar" con lo que hay que hacer a mano.
299
+ if (changed && looseEol) {
300
+ for (const { f, manual } of applied) {
301
+ f.status = 'needs-review';
302
+ f.message = looseEolMessage(manual);
303
+ delete f.partial;
304
+ }
305
+ return { output: null, findings, usages };
306
+ }
307
+
242
308
  return { output: changed ? applyEdits(source, edits) : null, findings, usages };
243
309
  }
244
310
 
311
+ // det.CondicionIVAReceptor → det.CondicionIVAReceptorId; det['CondicionIVAReceptor'] → det['CondicionIVAReceptorId'] (mismas comillas).
312
+ function renameMember(source, member) {
313
+ const start = offsetAt(source, member.property.loc.start);
314
+ const end = offsetAt(source, member.property.loc.end);
315
+ return member.computed ? { offset: start + 1, remove: end - start - 2, text: 'CondicionIVAReceptorId' }
316
+ : { offset: start, remove: end - start, text: 'CondicionIVAReceptorId' };
317
+ }
318
+
245
319
  // `CondicionIVAReceptor: x` → `CondicionIVAReceptorId: x`, con las mismas comillas y sin tocar el valor.
246
320
  // Si era abreviada (`{ CondicionIVAReceptor }`), queda `CondicionIVAReceptorId: CondicionIVAReceptor`.
247
321
  function renameKey(source, prop) {
package/src/context.js CHANGED
@@ -6,9 +6,12 @@
6
6
  // fieldFiles: archivos que ya usan CondicionIVAReceptorId (el repo ya empezó a migrar)
7
7
  // staleWsdl: WSDL de WSFEv1 del repo que no tienen CondicionIVAReceptorId
8
8
  // arcaLibs: librerías de ARCA del repo con lo que se sabe de si el campo llega a viajar
9
+ // nearMissTypes: interfaces y types de TypeScript que declaran la clave casi bien escrita (CondicionIvaReceptorId?: number)
9
10
  import fs from 'node:fs';
10
11
  import path from 'node:path';
12
+ import { parse as babelParse } from '@babel/parser';
11
13
  import { findFiles } from './paths.js';
14
+ import { NEAR_HINT, isNearMissKey } from './codemods/arca-rules.js';
12
15
 
13
16
  const readJson = (p) => {
14
17
  try {
@@ -104,7 +107,33 @@ export function buildContext(root, files, manifests) {
104
107
  const text = fs.readFileSync(path.join(root, rel), 'utf8');
105
108
  return /FECAESolicitar|FECAEDetRequest/.test(text) && !text.includes('CondicionIVAReceptorId');
106
109
  });
107
- return { versions, fieldFiles, staleWsdl, arcaLibs: arcaLibraries(versions) };
110
+ return { versions, fieldFiles, staleWsdl, arcaLibs: arcaLibraries(versions), nearMissTypes: nearMissTypes(root, files) };
108
111
  }
109
112
 
110
- export const emptyContext = () => ({ versions: { npm: {}, composer: {}, pypi: {} }, fieldFiles: [], staleWsdl: [], arcaLibs: [] });
113
+ // Frente B, punto 23: si un tipo declara la clave mal escrita, renombrarla solo en el objeto rompe el tipado
114
+ // (TS2353) y el chequeo de sintaxis no lo ve. El codemod de JS no renombra cuando la encuentra acá.
115
+ export function nearMissTypes(root, files) {
116
+ const out = [];
117
+ for (const rel of files.filter((f) => /\.[cm]?tsx?$/.test(f))) {
118
+ let text;
119
+ try { text = fs.readFileSync(path.join(root, rel), 'utf8'); } catch { continue; }
120
+ if (!NEAR_HINT.test(text)) continue;
121
+ let ast;
122
+ try {
123
+ ast = babelParse(text, { sourceType: 'unambiguous', plugins: ['typescript', 'decorators-legacy', ...(/x$/.test(rel) ? ['jsx'] : [])], errorRecovery: true });
124
+ } catch { continue; }
125
+ const walk = (n) => {
126
+ if (!n || typeof n !== 'object') return;
127
+ if (Array.isArray(n)) { n.forEach(walk); return; }
128
+ if (n.type === 'TSPropertySignature') {
129
+ const key = n.key && (n.key.type === 'Identifier' ? n.key.name : n.key.type === 'StringLiteral' ? n.key.value : null);
130
+ if (isNearMissKey(key)) out.push({ file: rel, line: n.loc.start.line, key });
131
+ }
132
+ for (const k of Object.keys(n)) if (k !== 'loc' && k !== 'start' && k !== 'end') walk(n[k]);
133
+ };
134
+ walk(ast.program);
135
+ }
136
+ return out;
137
+ }
138
+
139
+ export const emptyContext = () => ({ versions: { npm: {}, composer: {}, pypi: {} }, fieldFiles: [], staleWsdl: [], arcaLibs: [], nearMissTypes: [] });
package/src/engine.js CHANGED
@@ -1,4 +1,5 @@
1
1
  // Motor: recorre el repo, arma el inventario de APIs y corre los packs.
2
+ import crypto from 'node:crypto';
2
3
  import fs from 'node:fs';
3
4
  import path from 'node:path';
4
5
  import { loadPacks, transformFor, languageOf } from './packs.js';
@@ -33,6 +34,18 @@ export function listFiles(dir, skipped = []) {
33
34
  return out.sort((a, b) => (toPosix(a) < toPosix(b) ? -1 : 1));
34
35
  }
35
36
 
37
+ // Identificador estable de un aviso (P5 de docs/app-vigilancia.md): el pack, el archivo y el texto de la línea, sin el
38
+ // número de línea ni el mensaje. Así una corrida reconoce el mismo aviso de la anterior aunque se muevan las líneas o
39
+ // cambie el texto del pack, y "nuevo", "sigue" y "resuelto" no dependen de la posición. Los espacios y el \r no cuentan
40
+ // (una reindentación o pasar a CRLF no es otro aviso). Si el mismo texto se repite en el archivo, cuenta el orden.
41
+ export function fingerprint(finding, source, seen) {
42
+ const text = finding.line ? (source.split('\n')[finding.line - 1] ?? '').replace(/\s+/g, ' ').trim() : '';
43
+ const key = [finding.pack, finding.file, text].join('\u0000');
44
+ const n = (seen.get(key) || 0) + 1;
45
+ seen.set(key, n);
46
+ return crypto.createHash('sha256').update(`${key}\u0000${n}`).digest('hex').slice(0, 16);
47
+ }
48
+
36
49
  export function run(dir, { write = false, packIds = null, today } = {}) {
37
50
  const root = path.resolve(dir);
38
51
  const packs = loadPacks().filter((p) => !packIds || packIds.includes(p.id));
@@ -47,6 +60,7 @@ export function run(dir, { write = false, packIds = null, today } = {}) {
47
60
  const usages = [];
48
61
  const fileScans = []; // inventario de cualquier API (nivel 1), por archivo
49
62
  const changed = new Map(); // archivo relativo → [packs]
63
+ const seen = new Map(); // para fingerprint(): cuántas veces apareció cada pack + archivo + texto
50
64
 
51
65
  files.forEach((abs, i) => {
52
66
  const rel = rels[i];
@@ -60,10 +74,12 @@ export function run(dir, { write = false, packIds = null, today } = {}) {
60
74
  try {
61
75
  res = t({ source, file: rel, pack, today, context });
62
76
  } catch (err) {
63
- findings.push({ pack: pack.id, file: rel, line: null, status: 'needs-input', message: `Parche no pudo procesar este archivo: ${err.message}` });
77
+ const f = { pack: pack.id, file: rel, line: null, status: 'needs-input', message: `Parche no pudo procesar este archivo: ${err.message}` };
78
+ findings.push({ ...f, fingerprint: fingerprint(f, source, seen) });
64
79
  continue;
65
80
  }
66
- findings.push(...res.findings);
81
+ // El texto de la línea, del archivo tal como lo leyó este pack (antes de su propio cambio).
82
+ findings.push(...res.findings.map((f) => ({ ...f, fingerprint: fingerprint(f, source, seen) })));
67
83
  usages.push(...res.usages.map((u) => ({ ...u, file: rel, pack: pack.id })));
68
84
  if (res.output !== null && res.output !== source) {
69
85
  source = res.output;