parche-cli 0.0.0-stage → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,476 @@
1
+ // Pack mercadopago.payments-to-orders
2
+ // Migra la CREACIÓN de pagos del SDK Node (Payment.create) a Orders API (Order.create) y
3
+ // adapta el manejo de la respuesta. Consultas, capturas, cancelaciones y reembolsos de pagos
4
+ // existentes NO se migran: según la guía oficial, siguen en la API con la que se crearon.
5
+ import {
6
+ jFor, detectQuote, eolOf, getProp, hasSpread, keyName, literalValue, isStringLit, resolveBinding,
7
+ memberChain, buildMember, isReference, cloneNode, finding, tidy, snippet,
8
+ } from '../ast.js';
9
+
10
+ const PACK = 'mercadopago.payments-to-orders';
11
+ const DEBIT = new Set(['debvisa', 'debmaster', 'debcabal', 'maestro']);
12
+ const CREDIT = new Set(['visa', 'master', 'amex', 'naranja', 'cabal', 'tarshop', 'cencosud', 'diners', 'argencard', 'cmr']);
13
+ const TICKET = new Set(['rapipago', 'pagofacil']);
14
+ const PASS_THROUGH = new Set(['description', 'external_reference', 'payer', 'items']);
15
+ const PM_ORDER = ['id', 'type', 'token', 'installments', 'statement_descriptor'];
16
+ const PM_SOURCE = { payment_method_id: 'id', payment_type_id: 'type', token: 'token', installments: 'installments', statement_descriptor: 'statement_descriptor' };
17
+ const P0 = ['transactions', 'payments', 0, 'payment_method'];
18
+ const RESPONSE_RENAMES = {
19
+ date_created: ['created_date'],
20
+ date_last_updated: ['last_updated_date'],
21
+ transaction_amount: ['total_amount'],
22
+ payment_method_id: [...P0, 'id'],
23
+ payment_type_id: [...P0, 'type'],
24
+ 'transaction_details.external_resource_url': [...P0, 'ticket_url'],
25
+ 'point_of_interaction.transaction_data.qr_code': [...P0, 'qr_code'],
26
+ 'point_of_interaction.transaction_data.qr_code_base64': [...P0, 'qr_code_base64'],
27
+ 'point_of_interaction.transaction_data.ticket_url': [...P0, 'ticket_url'],
28
+ };
29
+ const READ_METHODS = new Set(['get', 'search', 'cancel', 'capture', 'update']);
30
+
31
+ export default function transform({ source, file, pack }) {
32
+ const findings = [];
33
+ const usages = [];
34
+ if (!/mercadopago/.test(source)) return { output: null, findings, usages };
35
+ const j = jFor(file);
36
+ let root;
37
+ try {
38
+ root = j(source);
39
+ } catch {
40
+ return { output: null, findings, usages };
41
+ }
42
+ const id = (n) => j.identifier(n);
43
+ const prop = (k, v) => {
44
+ const node = j.objectProperty(id(k), typeof v === 'string' ? j.stringLiteral(v) : v);
45
+ if (v && v.type === 'Identifier' && v.name === k) node.shorthand = true;
46
+ return node;
47
+ };
48
+ const review = (node, text) => {
49
+ node.comments = [...(node.comments || []), j.commentBlock(` parche: REVISAR — ${text} `, true, false)];
50
+ return node;
51
+ };
52
+ let changed = false;
53
+ let needUUID = false;
54
+
55
+ textDetections(source, file, findings, usages);
56
+
57
+ const mp = findMpImport(j, root);
58
+ if (!mp) return finish();
59
+ if (mp.local('PaymentRefund')) {
60
+ findings.push({ pack: PACK, file, line: mp.line, status: 'needs-review',
61
+ message: 'Usás PaymentRefund. Los reembolsos de pagos creados con Payments API siguen por Payments API; los de órdenes nuevas van por POST /v1/orders/{id}/refund (parciales: con el id de la transacción).' });
62
+ }
63
+ const paymentLocal = mp.local('Payment');
64
+ if (!paymentLocal) return finish();
65
+
66
+ // Instancias: const payment = new Payment(client)
67
+ const instances = new Map();
68
+ root.find(j.NewExpression, { callee: { type: 'Identifier', name: paymentLocal } }).forEach((p) => {
69
+ const parent = p.parent.node;
70
+ if (parent.type === 'VariableDeclarator' && parent.id.type === 'Identifier') {
71
+ instances.set(parent.id.name, { declPath: p.parent, clientExpr: p.node.arguments[0], orderName: null });
72
+ }
73
+ });
74
+
75
+ const orderLocal = () => {
76
+ if (!mp.local('Order')) mp.addSpecifier('Order');
77
+ return mp.local('Order');
78
+ };
79
+
80
+ root.find(j.CallExpression).forEach((p) => {
81
+ const callee = p.node.callee;
82
+ if (callee.type !== 'MemberExpression' || callee.computed || callee.property.type !== 'Identifier') return;
83
+ const method = callee.property.name;
84
+ let inst = null;
85
+ let inlineNew = null;
86
+ if (callee.object.type === 'Identifier' && instances.has(callee.object.name)) inst = callee.object.name;
87
+ else if (callee.object.type === 'NewExpression' && callee.object.callee.name === paymentLocal) inlineNew = callee.object;
88
+ else return;
89
+ usages.push({ provider: 'mercadopago', op: `Payment.${method}`, line: p.node.loc?.start.line });
90
+
91
+ if (READ_METHODS.has(method)) {
92
+ findings.push(finding(PACK, file, p.node, 'needs-review',
93
+ `\`${inst || 'payment'}.${method}()\` opera sobre pagos de Payments API: se deja como está (los pagos existentes se siguen consultando por la API con la que se crearon). Para órdenes nuevas usá \`Order.${method === 'search' ? 'search' : method}\`.`));
94
+ return;
95
+ }
96
+ if (method !== 'create') return;
97
+ if (migrateCreate(p)) {
98
+ changed = true;
99
+ if (inst) {
100
+ const info = instances.get(inst);
101
+ if (!info.orderName) {
102
+ info.orderName = uniqueName(j, root, 'order');
103
+ const decl = j.variableDeclaration('const', [
104
+ j.variableDeclarator(id(info.orderName), j.newExpression(id(orderLocal()), info.clientExpr ? [cloneNode(info.clientExpr)] : [])),
105
+ ]);
106
+ j(info.declPath.parent).insertAfter(decl);
107
+ }
108
+ p.node.callee.object = id(info.orderName);
109
+ } else {
110
+ inlineNew.callee = id(orderLocal());
111
+ }
112
+ }
113
+ });
114
+
115
+ if (changed) {
116
+ // Si la instancia de Payment quedó sin usos, se borra (y el import si corresponde).
117
+ for (const [name, info] of instances) {
118
+ if (!info.orderName) continue;
119
+ const refs = root.find(j.Identifier, { name }).filter(isReference).size();
120
+ if (refs === 0 && info.declPath.parent.node.declarations.length === 1) j(info.declPath.parent).remove();
121
+ }
122
+ const paymentRefs = root.find(j.Identifier, { name: paymentLocal }).filter(isReference).size();
123
+ if (paymentRefs === 0) mp.removeSpecifier('Payment');
124
+ if (needUUID) ensureRandomUUID(j, root, mp, detectQuote(source) === 'double' ? '"' : "'");
125
+ }
126
+
127
+ webhookDetections(j, root, file, findings);
128
+ return finish();
129
+
130
+ function finish() {
131
+ if (!changed) return { output: null, findings: dedupe(findings), usages };
132
+ const trailingComma = /,\s*\n\s*[}\]]/.test(source);
133
+ const out = tidy(source, root.toSource({ quote: detectQuote(source), trailingComma, lineTerminator: eolOf(source) }));
134
+ return { output: out, findings: dedupe(findings), usages };
135
+ }
136
+
137
+ function migrateCreate(p) {
138
+ const call = p.node;
139
+ const arg = call.arguments[0];
140
+ if (!arg || arg.type !== 'ObjectExpression' || hasSpread(arg)) {
141
+ findings.push(finding(PACK, file, call, 'needs-input', 'No pude leer los argumentos de `create()` (no es un objeto literal). Migrar a mano.'));
142
+ return false;
143
+ }
144
+ const bodyProp = getProp(arg, 'body');
145
+ if (!bodyProp) return false;
146
+ let body = bodyProp.value;
147
+ let setBody = (n) => { bodyProp.value = n; if (bodyProp.shorthand) bodyProp.shorthand = false; };
148
+ if (body.type === 'Identifier') {
149
+ const decl = resolveBinding(p, body.name);
150
+ if (decl && decl.node.init && decl.node.init.type === 'ObjectExpression') {
151
+ body = decl.node.init;
152
+ setBody = (n) => { decl.node.init = n; };
153
+ }
154
+ }
155
+ if (body.type !== 'ObjectExpression' || hasSpread(body)) {
156
+ findings.push(finding(PACK, file, call, 'needs-input', 'El body del pago no es un objeto literal (o usa spread). Migrar a mano siguiendo el checklist.'));
157
+ return false;
158
+ }
159
+ if (getProp(body, 'transactions') || getProp(body, 'total_amount')) return false; // ya tiene forma de orden
160
+ if (getProp(body, 'application_fee')) {
161
+ findings.push(finding(PACK, file, call, 'blocked',
162
+ 'Usa `application_fee` (Marketplace / Split 1:1). Orders API todavía no tiene un equivalente documentado: NO migrar este flujo hasta que Mercado Pago lo soporte.'));
163
+ return false;
164
+ }
165
+ const amount = getProp(body, 'transaction_amount');
166
+ if (!amount) {
167
+ findings.push(finding(PACK, file, call, 'needs-input', 'El pago no tiene `transaction_amount` literal en el body. Migrar a mano.'));
168
+ return false;
169
+ }
170
+
171
+ const pm = {};
172
+ const passthrough = [];
173
+ let captureMode = null;
174
+ let config = null;
175
+ let integration = null;
176
+ for (const pr of body.properties) {
177
+ const k = keyName(pr);
178
+ const v = pr.value;
179
+ if (k === 'transaction_amount') continue;
180
+ if (k in PM_SOURCE) { pm[PM_SOURCE[k]] = v; continue; }
181
+ if (k === 'notification_url') {
182
+ findings.push(finding(PACK, file, pr, 'needs-review',
183
+ 'Se quitó `notification_url`: en Orders las notificaciones se configuran solo en Tus integraciones. Activá el tópico `orders` (y mantené `payment` en paralelo durante la transición).'));
184
+ continue;
185
+ }
186
+ if (k === 'issuer_id') {
187
+ findings.push(finding(PACK, file, pr, 'auto', 'Se quitó `issuer_id`: Orders no tiene ese campo, el emisor se resuelve por el medio de pago.'));
188
+ continue;
189
+ }
190
+ if (k === 'capture') {
191
+ const lit = literalValue(v);
192
+ if (lit === false) captureMode = j.stringLiteral('manual');
193
+ else if (lit === true) captureMode = j.stringLiteral('automatic');
194
+ else captureMode = j.conditionalExpression(v, j.stringLiteral('automatic'), j.stringLiteral('manual'));
195
+ if (lit !== true) {
196
+ findings.push(finding(PACK, file, pr, 'needs-review',
197
+ 'Captura manual: en Orders se captura con POST /v1/orders/{id}/capture, solo captura total y dentro de los 5 días (si no, la orden se cancela sola).'));
198
+ }
199
+ continue;
200
+ }
201
+ if (k === 'three_d_secure_mode') {
202
+ const lit = literalValue(v);
203
+ if (lit === 'optional') {
204
+ config = prop('config', j.objectExpression([prop('online', j.objectExpression([
205
+ prop('transaction_security', j.objectExpression([prop('validation', 'on_fraud_risk'), prop('liability_shift', 'required')])),
206
+ ]))]));
207
+ } else if (lit !== 'not_supported') {
208
+ passthrough.push(review(pr, '`three_d_secure_mode` pasa a `config.online.transaction_security`'));
209
+ }
210
+ continue;
211
+ }
212
+ if (k === 'sponsor_id') {
213
+ integration = prop('integration_data', j.objectExpression([prop('sponsor', j.objectExpression([prop('id', v)]))]));
214
+ continue;
215
+ }
216
+ if (k === 'payer' && v.type === 'ObjectExpression') {
217
+ const t = getProp(v, 'type');
218
+ const pid = getProp(v, 'id');
219
+ if (t && literalValue(t.value) === 'customer' && pid) {
220
+ v.properties = v.properties.filter((x) => x !== t);
221
+ v.properties[v.properties.indexOf(pid)] = prop('customer_id', pid.value);
222
+ }
223
+ }
224
+ if (k === 'items' && v.type === 'ArrayExpression') {
225
+ for (const it of v.elements) {
226
+ if (it && it.type === 'ObjectExpression') {
227
+ const iid = getProp(it, 'id');
228
+ if (iid) it.properties[it.properties.indexOf(iid)] = prop('external_code', iid.value);
229
+ }
230
+ }
231
+ }
232
+ if (PASS_THROUGH.has(k)) { passthrough.push(pr); continue; }
233
+ passthrough.push(review(pr, `\`${k}\` no tiene equivalente directo en Orders API`));
234
+ findings.push(finding(PACK, file, pr, 'needs-review', `\`${k}\` no tiene equivalente directo documentado en Orders API: quedó marcado en el código para revisar.`));
235
+ }
236
+
237
+ // Tipo de medio de pago (obligatorio en Orders).
238
+ if (!pm.type) {
239
+ const pmId = literalValue(pm.id);
240
+ if (DEBIT.has(pmId)) pm.type = j.stringLiteral('debit_card');
241
+ else if (CREDIT.has(pmId)) pm.type = j.stringLiteral('credit_card');
242
+ else if (TICKET.has(pmId)) pm.type = j.stringLiteral('ticket');
243
+ else {
244
+ pm.type = j.stringLiteral('credit_card');
245
+ pm.typeReview = true;
246
+ findings.push(finding(PACK, file, call, 'needs-review',
247
+ 'Orders exige `payment_method.type` (credit_card | debit_card | ticket). Se puso `credit_card` por defecto: pasá el tipo real (el Card Payment Brick lo devuelve como payment_type_id).'));
248
+ }
249
+ }
250
+ const pmProps = PM_ORDER.filter((k) => pm[k]).map((k) => {
251
+ const node = prop(k, pm[k]);
252
+ if (k === 'type' && pm.typeReview) review(node, 'confirmar credit_card o debit_card');
253
+ return node;
254
+ });
255
+
256
+ const props = [prop('type', 'online'), prop('processing_mode', 'automatic')];
257
+ if (captureMode) props.push(prop('capture_mode', captureMode));
258
+ props.push(prop('total_amount', toAmount(j, amount.value)));
259
+ props.push(...passthrough);
260
+ if (config) props.push(config);
261
+ if (integration) props.push(integration);
262
+ props.push(prop('transactions', j.objectExpression([
263
+ prop('payments', j.arrayExpression([j.objectExpression([
264
+ prop('amount', toAmount(j, cloneNode(amount.value))),
265
+ prop('payment_method', j.objectExpression(pmProps)),
266
+ ])])),
267
+ ])));
268
+ setBody(j.objectExpression(props));
269
+
270
+ if (!getProp(body, 'external_reference')) {
271
+ findings.push(finding(PACK, file, call, 'needs-review', 'No hay `external_reference`: Orders lo exige para algunos medios de pago y sirve para conciliar.'));
272
+ }
273
+
274
+ // Idempotencia obligatoria en todas las escrituras de Orders.
275
+ const ro = getProp(arg, 'requestOptions');
276
+ const idem = prop('idempotencyKey', j.callExpression(id('randomUUID'), []));
277
+ if (!ro) {
278
+ arg.properties.push(prop('requestOptions', snippet(j, 'x = { idempotencyKey: randomUUID() };', 'ObjectExpression')));
279
+ needUUID = true;
280
+ findings.push(finding(PACK, file, call, 'auto', 'Se agregó `idempotencyKey` (X-Idempotency-Key es obligatorio en Orders).'));
281
+ } else if (ro.value.type === 'ObjectExpression' && !getProp(ro.value, 'idempotencyKey')) {
282
+ ro.value.properties.push(idem);
283
+ needUUID = true;
284
+ }
285
+
286
+ rewriteResponse(p);
287
+ findings.push(finding(PACK, file, call, 'auto', 'Creación migrada de `Payment.create` a `Order.create` con el body en formato Orders.'));
288
+ return true;
289
+ }
290
+
291
+ function rewriteResponse(p) {
292
+ let resultName = null;
293
+ let scope = null;
294
+ let cur = p.parent;
295
+ if (cur.node.type === 'AwaitExpression') cur = cur.parent;
296
+ if (cur.node.type === 'VariableDeclarator' && cur.node.id.type === 'Identifier') {
297
+ resultName = cur.node.id.name;
298
+ const fn = j(cur).closest(j.Function);
299
+ scope = fn.size() ? fn : root;
300
+ } else if (p.parent.node.type === 'MemberExpression' && p.parent.node.property.name === 'then') {
301
+ const fn = p.parent.parent.node.arguments[0];
302
+ if (fn && fn.params && fn.params[0] && fn.params[0].type === 'Identifier') {
303
+ resultName = fn.params[0].name;
304
+ scope = j(fn);
305
+ }
306
+ }
307
+ if (!resultName) return;
308
+ const sm = pack.status_map;
309
+ const dm = pack.status_detail_map;
310
+ const mapLit = (lit, map, field) => {
311
+ const old = lit.value;
312
+ const nu = map[old];
313
+ if (!nu || nu === old) return;
314
+ lit.value = nu;
315
+ delete lit.extra;
316
+ if (field === 'status' && (old === 'authorized' || old === 'in_mediation')) {
317
+ findings.push(finding(PACK, file, lit, 'needs-review',
318
+ `'${old}' pasa a '${nu}' pero se distingue por status_detail (${old === 'authorized' ? "'waiting_capture'" : "'in_process'"}). Revisá la condición.`));
319
+ }
320
+ };
321
+ scope.find(j.BinaryExpression).forEach((bp) => {
322
+ const { left, right, operator } = bp.node;
323
+ if (!['===', '==', '!==', '!='].includes(operator)) return;
324
+ for (const [mem, lit] of [[left, right], [right, left]]) {
325
+ const ch = memberChain(mem);
326
+ if (!ch || ch.root !== resultName || !isStringLit(lit)) continue;
327
+ const field = ch.path.join('.');
328
+ if (field === 'status') mapLit(lit, sm, field);
329
+ if (field === 'status_detail') mapLit(lit, dm, field);
330
+ }
331
+ });
332
+ scope.find(j.SwitchStatement).forEach((sp) => {
333
+ const ch = memberChain(sp.node.discriminant);
334
+ if (!ch || ch.root !== resultName) return;
335
+ const field = ch.path.join('.');
336
+ const map = field === 'status' ? sm : field === 'status_detail' ? dm : null;
337
+ if (!map) return;
338
+ for (const c of sp.node.cases) if (c.test && isStringLit(c.test)) mapLit(c.test, map, field);
339
+ });
340
+ let idFlagged = false;
341
+ scope.find(j.MemberExpression).forEach((mp2) => {
342
+ const ch = memberChain(mp2.node);
343
+ if (!ch || ch.root !== resultName) return;
344
+ const key = ch.path.join('.');
345
+ if (RESPONSE_RENAMES[key]) {
346
+ if (key === 'transaction_amount') {
347
+ findings.push(finding(PACK, file, mp2.node, 'needs-review', '`transaction_amount` pasa a `total_amount` y ahora es un string ("50.00").'));
348
+ }
349
+ j(mp2).replaceWith(buildMember(j, resultName, RESPONSE_RENAMES[key]));
350
+ } else if (key === 'id') {
351
+ const parent = mp2.parent.node;
352
+ const numeric = parent.type === 'CallExpression' && parent.callee.type === 'Identifier' && ['Number', 'parseInt', 'parseFloat'].includes(parent.callee.name);
353
+ if (numeric) {
354
+ findings.push(finding(PACK, file, mp2.node, 'needs-review',
355
+ `\`${parent.callee.name}(${resultName}.id)\`: el id de una orden es alfanumérico (ej. "ORD01JS2V…"). Esto va a dar NaN: guardalo como string y revisá la columna en la base.`));
356
+ idFlagged = true;
357
+ } else if (!idFlagged) {
358
+ findings.push(finding(PACK, file, mp2.node, 'needs-review', 'El id pasa de numérico a alfanumérico con prefijo ORD. Revisá dónde lo guardás.'));
359
+ idFlagged = true;
360
+ }
361
+ }
362
+ });
363
+ }
364
+ }
365
+
366
+ function toAmount(j, expr) {
367
+ const v = literalValue(expr);
368
+ if (typeof v === 'number') return j.stringLiteral(v.toFixed(2));
369
+ const toFixed = (e) => j.callExpression(j.memberExpression(e, j.identifier('toFixed')), [j.numericLiteral(2)]);
370
+ if (expr.type === 'CallExpression' && expr.callee.type === 'Identifier' && expr.callee.name === 'Number') return toFixed(expr);
371
+ return toFixed(j.callExpression(j.identifier('Number'), [expr]));
372
+ }
373
+
374
+ function uniqueName(j, root, base) {
375
+ const taken = new Set(root.find(j.Identifier).nodes().map((n) => n.name));
376
+ if (!taken.has(base)) return base;
377
+ for (let i = 2; ; i++) if (!taken.has(`${base}${i}`)) return `${base}${i}`;
378
+ }
379
+
380
+ function findMpImport(j, root) {
381
+ const esm = root.find(j.ImportDeclaration, { source: { value: 'mercadopago' } });
382
+ if (esm.size()) {
383
+ const path = esm.paths()[0];
384
+ const node = path.node;
385
+ return {
386
+ line: node.loc?.start.line,
387
+ kind: 'esm',
388
+ local: (name) => node.specifiers.find((s) => s.type === 'ImportSpecifier' && s.imported.name === name)?.imported.name && (node.specifiers.find((s) => s.type === 'ImportSpecifier' && s.imported.name === name).local || node.specifiers.find((s) => s.type === 'ImportSpecifier' && s.imported.name === name).imported).name || null,
389
+ addSpecifier: (name) => node.specifiers.push(j.importSpecifier(j.identifier(name))),
390
+ removeSpecifier: (name) => { node.specifiers = node.specifiers.filter((s) => !(s.type === 'ImportSpecifier' && s.imported.name === name)); },
391
+ statement: path,
392
+ };
393
+ }
394
+ const cjs = root.find(j.VariableDeclarator, {
395
+ init: { type: 'CallExpression', callee: { name: 'require' }, arguments: [{ value: 'mercadopago' }] },
396
+ id: { type: 'ObjectPattern' },
397
+ });
398
+ if (cjs.size()) {
399
+ const path = cjs.paths()[0];
400
+ const keyOf = (pr) => pr.key && (pr.key.name || pr.key.value);
401
+ let names = path.node.id.properties.map((pr) => [keyOf(pr), pr.value.name]);
402
+ const rebuild = () => {
403
+ const txt = names.map(([k, v]) => (k === v ? k : `${k}: ${v}`)).join(', ');
404
+ path.node.id = snippet(j, `const { ${txt} } = x;`, 'VariableDeclarator').id;
405
+ };
406
+ return {
407
+ line: path.node.loc?.start.line,
408
+ kind: 'cjs',
409
+ local: (name) => names.find(([k]) => k === name)?.[1] || null,
410
+ addSpecifier: (name) => { names.push([name, name]); rebuild(); },
411
+ removeSpecifier: (name) => { names = names.filter(([k]) => k !== name); rebuild(); },
412
+ statement: path.parent,
413
+ };
414
+ }
415
+ return null;
416
+ }
417
+
418
+ function ensureRandomUUID(j, root, mp, quoteChar) {
419
+ const already = root.find(j.Identifier, { name: 'randomUUID' }).filter((p) => {
420
+ const parent = p.parent.node;
421
+ return parent.type === 'ImportSpecifier' || parent.type === 'ObjectProperty' || parent.type === 'Property';
422
+ }).size();
423
+ if (already) return;
424
+ const q = quoteChar;
425
+ if (mp.kind === 'esm') {
426
+ const decl = snippet(j, `import { randomUUID } from ${q}node:crypto${q};`, 'ImportDeclaration');
427
+ const imports = root.find(j.ImportDeclaration);
428
+ j(imports.paths()[imports.size() - 1]).insertAfter(decl);
429
+ } else {
430
+ const decl = snippet(j, `const { randomUUID } = require(${q}node:crypto${q});`, 'VariableDeclaration');
431
+ j(mp.statement).insertAfter(decl);
432
+ }
433
+ }
434
+
435
+ function textDetections(source, file, findings, usages) {
436
+ const lines = source.split('\n');
437
+ lines.forEach((l, i) => {
438
+ if (/api\.mercadopago\.com\/v1\/payments/.test(l)) {
439
+ findings.push({ pack: PACK, file, line: i + 1, status: 'needs-review',
440
+ message: 'Llamada REST directa a /v1/payments. El equivalente para pagos nuevos es POST /v1/orders (ver mapeo de campos en la guía).' });
441
+ usages.push({ provider: 'mercadopago', op: 'REST /v1/payments', line: i + 1 });
442
+ }
443
+ if (/mercadopago\.(configure|payment\.(create|save))\(/.test(l)) {
444
+ findings.push({ pack: PACK, file, line: i + 1, status: 'needs-input',
445
+ message: 'SDK v1 de Mercado Pago (deprecado desde la v2). Primero hay que pasar a la v2 del SDK; Parche no migra v1 automáticamente.' });
446
+ usages.push({ provider: 'mercadopago', op: 'SDK v1', line: i + 1 });
447
+ }
448
+ });
449
+ }
450
+
451
+ function webhookDetections(j, root, file, findings) {
452
+ root.find(j.BinaryExpression).forEach((bp) => {
453
+ const { left, right, operator } = bp.node;
454
+ if (!['===', '=='].includes(operator)) return;
455
+ for (const [mem, lit] of [[left, right], [right, left]]) {
456
+ const ch = memberChain(mem);
457
+ if (!ch || !isStringLit(lit) || lit.value !== 'payment') continue;
458
+ const last = ch.path[ch.path.length - 1];
459
+ if (last === 'topic' || last === 'type') {
460
+ findings.push(finding(PACK, file, bp.node, 'needs-review',
461
+ 'El webhook solo procesa el tópico `payment`. Las órdenes nuevas notifican por el tópico `orders` (se consulta con GET /v1/orders/{id}): sumá ese handler y mantené ambos durante la transición.'));
462
+ return;
463
+ }
464
+ }
465
+ });
466
+ }
467
+
468
+ function dedupe(findings) {
469
+ const seen = new Set();
470
+ return findings.filter((f) => {
471
+ const k = `${f.file}:${f.line}:${f.status}:${f.message}`;
472
+ if (seen.has(k)) return false;
473
+ seen.add(k);
474
+ return true;
475
+ });
476
+ }
package/src/context.js ADDED
@@ -0,0 +1,93 @@
1
+ // Contexto del repo para los packs: lo que un archivo solo no puede saber.
2
+ // Lo arma el motor una vez, antes de correr los packs, y cada transform lo recibe como `context`.
3
+ //
4
+ // versions: versión instalada de cada dependencia (de los lockfiles, de node_modules o del rango declarado)
5
+ // fieldFiles: archivos que ya usan CondicionIVAReceptorId (el repo ya empezó a migrar)
6
+ // staleWsdl: WSDL de WSFEv1 del repo que no tienen CondicionIVAReceptorId
7
+ // arcaLibs: librerías de ARCA del repo con lo que se sabe de si el campo llega a viajar
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ import { findFiles } from './paths.js';
11
+
12
+ const readJson = (p) => {
13
+ try {
14
+ return JSON.parse(fs.readFileSync(p, 'utf8'));
15
+ } catch {
16
+ return null;
17
+ }
18
+ };
19
+ const clean = (v) => String(v || '').replace(/^v/, '');
20
+
21
+ // Versiones instaladas por ecosistema: lockfiles (en cualquier carpeta) → node_modules de la raíz → rango declarado.
22
+ export function readVersions(root, manifests) {
23
+ const out = { npm: {}, composer: {} };
24
+ const set = (eco, name, version, source) => { if (name && version && !out[eco][name]) out[eco][name] = { version: clean(version), source }; };
25
+ for (const rel of findFiles(root, (n) => n === 'package-lock.json' || n === 'composer.lock')) {
26
+ const lock = readJson(path.join(root, rel));
27
+ if (!lock) continue;
28
+ if (rel.endsWith('composer.lock')) {
29
+ for (const p of [...(lock.packages || []), ...(lock['packages-dev'] || [])]) set('composer', p.name, p.version, rel);
30
+ } else {
31
+ for (const [k, v] of Object.entries(lock.packages || {})) {
32
+ const m = k.match(/^node_modules\/((?:@[^/]+\/)?[^/]+)$/); // solo las de primer nivel
33
+ if (m) set('npm', m[1], v.version, rel);
34
+ }
35
+ for (const [name, v] of Object.entries(lock.dependencies || {})) set('npm', name, v.version, rel); // lockfile v1
36
+ }
37
+ }
38
+ for (const name of Object.keys(manifests?.npm || {})) {
39
+ const pkg = readJson(path.join(root, 'node_modules', name, 'package.json'));
40
+ if (pkg?.version) set('npm', name, pkg.version, 'node_modules');
41
+ }
42
+ for (const eco of ['npm', 'composer']) {
43
+ for (const [name, range] of Object.entries(manifests?.[eco] || {})) {
44
+ const m = String(range).match(/(\d+)(?:\.(\d+))?(?:\.(\d+))?/);
45
+ if (m) set(eco, name, `${m[1]}.${m[2] || 0}.${m[3] || 0}`, 'declarada');
46
+ }
47
+ }
48
+ return out;
49
+ }
50
+
51
+ // Qué se sabe, por librería, de si CondicionIVAReceptorId llega a ARCA (verificado en su código el 16/10/2026):
52
+ // afip.js / afip.php ≥ 1.0: mandan JSON a la API de Afip SDK → sí.
53
+ // 0.x: arman el SOAP con un WSDL de 2021 incluido en la librería, sin el campo → no (PHP) o sin confirmar (JS).
54
+ // fertandil87/afip.php: actualizó su WSDL (lo incluye) → sí. eesnaola/afip.php: WSDL de 2021 → igual que 0.x.
55
+ const ARCA_LIBS = {
56
+ npm: { '@afipsdk/afip.js': (v) => (major(v) >= 1 ? 'si' : 'sin-confirmar') },
57
+ composer: {
58
+ 'afipsdk/afip.php': (v) => (major(v) >= 1 ? 'si' : 'no'),
59
+ 'ivanalemunioz/afip.php': (v) => (major(v) >= 1 ? 'si' : 'no'),
60
+ 'fertandil87/afip.php': () => 'si',
61
+ 'eesnaola/afip.php': () => 'no',
62
+ },
63
+ };
64
+ const major = (v) => Number(String(v).split('.')[0]);
65
+
66
+ export function arcaLibraries(versions) {
67
+ const out = [];
68
+ for (const [eco, libs] of Object.entries(ARCA_LIBS)) {
69
+ for (const [name, rule] of Object.entries(libs)) {
70
+ const v = versions[eco]?.[name];
71
+ if (v) out.push({ ecosystem: eco, name, version: v.version, source: v.source, travels: rule(v.version) });
72
+ }
73
+ }
74
+ return out;
75
+ }
76
+
77
+ // files: rutas relativas (con "/") de los archivos que el motor va a recorrer.
78
+ export function buildContext(root, files, manifests) {
79
+ const versions = readVersions(root, manifests);
80
+ const fieldFiles = [];
81
+ for (const rel of files) {
82
+ try {
83
+ if (fs.readFileSync(path.join(root, rel), 'utf8').includes('CondicionIVAReceptorId')) fieldFiles.push(rel);
84
+ } catch { /* archivo ilegible: lo reporta el pack que lo lea */ }
85
+ }
86
+ const staleWsdl = findFiles(root, (n) => /\.wsdl$/i.test(n)).filter((rel) => {
87
+ const text = fs.readFileSync(path.join(root, rel), 'utf8');
88
+ return /FECAESolicitar|FECAEDetRequest/.test(text) && !text.includes('CondicionIVAReceptorId');
89
+ });
90
+ return { versions, fieldFiles, staleWsdl, arcaLibs: arcaLibraries(versions) };
91
+ }
92
+
93
+ export const emptyContext = () => ({ versions: { npm: {}, composer: {} }, fieldFiles: [], staleWsdl: [], arcaLibs: [] });
@@ -0,0 +1,52 @@
1
+ // Pack ai.model-retirements — solo detección.
2
+ // Cambiar de modelo cambia el comportamiento del producto, así que Parche avisa y no reescribe.
3
+ //
4
+ // Menos ruido (frente B, 16/10): un ID que aparece en el contexto de una llamada (model: '...', DEFAULT_MODEL)
5
+ // es un uso; si aparece en una lista o un catálogo, se avisa como mención. Y en código de Azure OpenAI no se
6
+ // da por cerrada la Assistants API: la fecha oficial es la de la API de OpenAI.
7
+ const PACK = 'ai.model-retirements';
8
+
9
+ const MODEL_CONTEXT = /\bmodel(?:Id|Name)?\b\s*[:=(]|["']model["']\s*[:=]|\b(?:DEFAULT|default)_?(?:MODEL|Model)\b|\bmodel\s*\(/i;
10
+ const AZURE = /azure|AzureOpenAI|openai\.azure\.com/i;
11
+
12
+ // Acepta el formato nuevo ({ date, replacement }) y el viejo (solo la fecha).
13
+ const entry = (v) => (typeof v === 'string' ? { date: v, replacement: null } : v);
14
+ const quoted = (line, id) => line.includes(`'${id}'`) || line.includes(`"${id}"`) || line.includes(`\`${id}\``);
15
+
16
+ export default function detect({ source, file, pack, today = new Date().toISOString().slice(0, 10) }) {
17
+ const findings = [];
18
+ const usages = [];
19
+ const { retired_models: retired, retirement_floors: floors, retired_endpoints: endpoints } = pack.detect;
20
+ const azure = AZURE.test(source);
21
+ const lines = source.split('\n');
22
+ lines.forEach((l, i) => {
23
+ const line = i + 1;
24
+ const inCall = MODEL_CONTEXT.test(l);
25
+ for (const [model, raw] of Object.entries(retired)) {
26
+ if (!quoted(l, model)) continue;
27
+ const { date, replacement } = entry(raw);
28
+ const past = date <= today;
29
+ const repl = replacement ? ` Reemplazo recomendado: \`${replacement}\`.` : '';
30
+ const when = past ? `fue retirado el ${date}` : `se retira el ${date}`;
31
+ const message = inCall
32
+ ? `El modelo \`${model}\` ${when}${past ? ': las llamadas ya fallan' : ''}.${repl} Corré tus pruebas de prompts antes de cambiarlo.`
33
+ : `Aparece \`${model}\` (${when}) fuera de una llamada: si es una lista o un catálogo de modelos, actualizalo.${repl}`;
34
+ findings.push({ pack: PACK, file, line, status: 'detect-only', message });
35
+ usages.push({ provider: 'ia', op: `modelo ${model}`, line });
36
+ }
37
+ for (const [model, date] of Object.entries(floors)) {
38
+ if (!l.includes(model)) continue;
39
+ findings.push({ pack: PACK, file, line, status: 'detect-only',
40
+ message: `El modelo \`${model}\` puede retirarse a partir del ${date} (fecha mínima publicada; hay 60 días de aviso). Planificá el reemplazo.` });
41
+ usages.push({ provider: 'ia', op: `modelo ${model}`, line });
42
+ }
43
+ for (const ep of endpoints) {
44
+ if (!l.includes(ep.pattern)) continue;
45
+ findings.push({ pack: PACK, file, line, status: 'detect-only', message: azure && ep.provider === 'openai'
46
+ ? `\`${ep.pattern}\` es la Assistants API. En Azure OpenAI sigue su propio calendario (el cierre del ${ep.retired} es el de la API de OpenAI): revisá el de Azure.`
47
+ : `\`${ep.pattern}\` pertenece a la Assistants API de OpenAI, cerrada el ${ep.retired}. Reemplazo: ${ep.replacement_es}.` });
48
+ usages.push({ provider: 'ia', op: 'OpenAI Assistants API', line });
49
+ }
50
+ });
51
+ return { output: null, findings, usages };
52
+ }