parche-cli 0.0.0-stage → 0.1.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
+ }
@@ -0,0 +1,37 @@
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
+ const PACK = 'ai.model-retirements';
4
+
5
+ export default function detect({ source, file, pack, today = new Date().toISOString().slice(0, 10) }) {
6
+ const findings = [];
7
+ const usages = [];
8
+ const { retired_models: retired, retirement_floors: floors, retired_endpoints: endpoints } = pack.detect;
9
+ const lines = source.split('\n');
10
+ lines.forEach((l, i) => {
11
+ for (const [model, date] of Object.entries(retired)) {
12
+ if (l.includes(`'${model}'`) || l.includes(`"${model}"`) || l.includes('`' + model + '`')) {
13
+ const past = date <= today;
14
+ findings.push({ pack: PACK, file, line: i + 1, status: 'detect-only',
15
+ message: past
16
+ ? `El modelo \`${model}\` fue retirado el ${date}: las llamadas ya fallan. Elegí un reemplazo en la página de deprecaciones y corré tus pruebas de prompts.`
17
+ : `El modelo \`${model}\` se retira el ${date}.` });
18
+ usages.push({ provider: 'ia', op: `modelo ${model}`, line: i + 1 });
19
+ }
20
+ }
21
+ for (const [model, date] of Object.entries(floors)) {
22
+ if (l.includes(model)) {
23
+ findings.push({ pack: PACK, file, line: i + 1, status: 'detect-only',
24
+ message: `El modelo \`${model}\` puede retirarse a partir del ${date} (fecha mínima publicada; hay 60 días de aviso). Planificá el reemplazo.` });
25
+ usages.push({ provider: 'ia', op: `modelo ${model}`, line: i + 1 });
26
+ }
27
+ }
28
+ for (const ep of endpoints) {
29
+ if (l.includes(ep.pattern)) {
30
+ findings.push({ pack: PACK, file, line: i + 1, status: 'detect-only',
31
+ message: `\`${ep.pattern}\` pertenece a la Assistants API de OpenAI, cerrada el ${ep.retired}. Reemplazo: ${ep.replacement_es}.` });
32
+ usages.push({ provider: 'ia', op: 'OpenAI Assistants API', line: i + 1 });
33
+ }
34
+ }
35
+ });
36
+ return { output: null, findings, usages };
37
+ }
package/src/engine.js ADDED
@@ -0,0 +1,85 @@
1
+ // Motor: recorre el repo, arma el inventario de APIs y corre los packs.
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { loadPacks, transformFor } from './packs.js';
5
+
6
+ const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', 'coverage', '.next', '.parche']);
7
+ const EXT = /\.(m?js|cjs|jsx|ts|tsx)$/;
8
+
9
+ export function listFiles(dir) {
10
+ const out = [];
11
+ const walk = (d) => {
12
+ for (const e of fs.readdirSync(d, { withFileTypes: true })) {
13
+ if (e.isDirectory()) {
14
+ if (!SKIP_DIRS.has(e.name)) walk(path.join(d, e.name));
15
+ } else if (EXT.test(e.name) && !/\.d\.ts$/.test(e.name)) {
16
+ out.push(path.join(d, e.name));
17
+ }
18
+ }
19
+ };
20
+ walk(dir);
21
+ // Mismo orden en Windows y en Linux: se ordena por la ruta con "/".
22
+ return out.sort((a, b) => (toPosix(a) < toPosix(b) ? -1 : 1));
23
+ }
24
+
25
+ // Rutas siempre con "/": van al reporte, al cuerpo del PR y al inventario.
26
+ const toPosix = (p) => p.split(path.sep).join('/');
27
+
28
+ function readDeps(dir) {
29
+ const pkgPath = path.join(dir, 'package.json');
30
+ if (!fs.existsSync(pkgPath)) return {};
31
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
32
+ return { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
33
+ }
34
+
35
+ export function run(dir, { write = false, packIds = null, today } = {}) {
36
+ const root = path.resolve(dir);
37
+ const packs = loadPacks().filter((p) => !packIds || packIds.includes(p.id));
38
+ const deps = readDeps(root);
39
+ const files = listFiles(root);
40
+ const findings = [];
41
+ const usages = [];
42
+ const changed = new Map(); // archivo relativo → [packs]
43
+
44
+ for (const abs of files) {
45
+ const rel = toPosix(path.relative(root, abs));
46
+ let source = fs.readFileSync(abs, 'utf8');
47
+ for (const pack of packs) {
48
+ const t = transformFor(pack.id);
49
+ if (!t) continue;
50
+ let res;
51
+ try {
52
+ res = t({ source, file: rel, pack, today });
53
+ } catch (err) {
54
+ findings.push({ pack: pack.id, file: rel, line: null, status: 'needs-input', message: `Parche no pudo procesar este archivo: ${err.message}` });
55
+ continue;
56
+ }
57
+ findings.push(...res.findings);
58
+ usages.push(...res.usages.map((u) => ({ ...u, file: rel, pack: pack.id })));
59
+ if (res.output !== null && res.output !== source) {
60
+ source = res.output;
61
+ changed.set(rel, [...(changed.get(rel) || []), pack.id]);
62
+ if (write) fs.writeFileSync(abs, source);
63
+ }
64
+ }
65
+ }
66
+
67
+ const providers = {};
68
+ for (const u of usages) {
69
+ const p = (providers[u.provider] ||= { callSites: 0, ops: {}, files: new Set() });
70
+ p.callSites++;
71
+ p.ops[u.op] = (p.ops[u.op] || 0) + 1;
72
+ p.files.add(u.file);
73
+ }
74
+ const inventory = {
75
+ generated_at: new Date().toISOString(),
76
+ repo: path.basename(root),
77
+ packages: Object.fromEntries(Object.entries(deps).filter(([name]) =>
78
+ packs.some((p) => (p.detect.packages || []).includes(name)))),
79
+ providers: Object.fromEntries(Object.entries(providers).map(([k, v]) => [k, { ...v, files: [...v.files] }])),
80
+ usages,
81
+ };
82
+ return { root, packs, inventory, findings, changed: [...changed.entries()].map(([file, packIds]) => ({ file, packs: packIds })) };
83
+ }
84
+
85
+ export const STATUS_ORDER = ['blocked', 'needs-input', 'needs-review', 'auto', 'detect-only'];
package/src/git.js ADDED
@@ -0,0 +1,49 @@
1
+ import { execFileSync } from 'node:child_process';
2
+
3
+ const git = (cwd, ...args) => execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
4
+
5
+ export function isRepo(cwd) {
6
+ try {
7
+ return git(cwd, 'rev-parse', '--is-inside-work-tree') === 'true';
8
+ } catch {
9
+ return false;
10
+ }
11
+ }
12
+
13
+ export function currentBranch(cwd) {
14
+ return git(cwd, 'rev-parse', '--abbrev-ref', 'HEAD');
15
+ }
16
+
17
+ export function isClean(cwd) {
18
+ return git(cwd, 'status', '--porcelain') === '';
19
+ }
20
+
21
+ export function checkoutNewBranch(cwd, name, base) {
22
+ try {
23
+ git(cwd, 'checkout', base);
24
+ git(cwd, 'branch', '-D', name);
25
+ } catch { /* la rama no existía */ }
26
+ git(cwd, 'checkout', '-b', name);
27
+ }
28
+
29
+ export function commitAll(cwd, message) {
30
+ git(cwd, 'add', '-A');
31
+ git(cwd, '-c', 'user.name=Parche', '-c', 'user.email=bot@parche.dev', 'commit', '-q', '-m', message);
32
+ return git(cwd, 'rev-parse', '--short', 'HEAD');
33
+ }
34
+
35
+ export function checkout(cwd, branch) {
36
+ git(cwd, 'checkout', '-q', branch);
37
+ }
38
+
39
+ export function originUrl(cwd) {
40
+ try {
41
+ return git(cwd, 'remote', 'get-url', 'origin');
42
+ } catch {
43
+ return null;
44
+ }
45
+ }
46
+
47
+ export function push(cwd, branch) {
48
+ git(cwd, 'push', '-u', '--force', 'origin', branch);
49
+ }
package/src/github.js ADDED
@@ -0,0 +1,22 @@
1
+ // Apertura del PR por la API REST de GitHub (sin dependencias).
2
+ export function parseGithubRemote(url) {
3
+ if (!url) return null;
4
+ const m = url.match(/github\.com[:/]([^/]+)\/([^/.]+?)(\.git)?$/);
5
+ return m ? { owner: m[1], repo: m[2] } : null;
6
+ }
7
+
8
+ export async function openPullRequest({ token, owner, repo, head, base, title, body, draft }) {
9
+ const res = await fetch(`https://api.github.com/repos/${owner}/${repo}/pulls`, {
10
+ method: 'POST',
11
+ headers: {
12
+ Authorization: `Bearer ${token}`,
13
+ Accept: 'application/vnd.github+json',
14
+ 'X-GitHub-Api-Version': '2022-11-28',
15
+ 'Content-Type': 'application/json',
16
+ },
17
+ body: JSON.stringify({ title, head, base, body, draft }),
18
+ });
19
+ const data = await res.json();
20
+ if (!res.ok) throw new Error(`GitHub respondió ${res.status}: ${data.message || JSON.stringify(data)}`);
21
+ return data.html_url;
22
+ }