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.
package/src/ast.js ADDED
@@ -0,0 +1,184 @@
1
+ // Utilidades de AST compartidas por todos los codemods.
2
+ import jscodeshift from 'jscodeshift';
3
+ import { parse as babelParse } from '@babel/parser';
4
+
5
+ export function jFor(file) {
6
+ const parser = /\.ts$/.test(file) ? 'ts' : 'tsx';
7
+ return jscodeshift.withParser(parser);
8
+ }
9
+
10
+ // Estilo de comillas predominante en el archivo, para que el código nuevo combine.
11
+ export function detectQuote(source) {
12
+ const single = (source.match(/'[^'\n]*'/g) || []).length;
13
+ const double = (source.match(/"[^"\n]*"/g) || []).length;
14
+ return double > single ? 'double' : 'single';
15
+ }
16
+
17
+ // Fin de línea predominante en el archivo. recast imprime con os.EOL por defecto (\r\n en
18
+ // Windows), así que hay que pasárselo siempre para no reescribir el archivo entero.
19
+ export function eolOf(source) {
20
+ const crlf = (source.match(/\r\n/g) || []).length;
21
+ const lf = (source.match(/\n/g) || []).length - crlf;
22
+ return crlf > lf ? '\r\n' : '\n';
23
+ }
24
+
25
+ // Offset en el texto original de una posición { line, column } de recast.
26
+ // No usar node.start/node.end para editar texto: recast parsea una copia con las líneas unidas
27
+ // por os.EOL y las tabs de la indentación expandidas, así que esos offsets no coinciden con el
28
+ // archivo del cliente (en Windows quedan corridos un carácter por línea).
29
+ export function offsetAt(source, { line, column }, tabWidth = 4) {
30
+ let k = 0;
31
+ for (let i = 1; i < line; i++) k = source.indexOf('\n', k) + 1;
32
+ // La indentación se mide como en recast: tab hasta el próximo múltiplo, \v \f y BOM no ocupan.
33
+ let width = 0;
34
+ while (width < column && /[^\S\r\n]/.test(source[k] ?? '')) {
35
+ if (source[k] === '\t') width = (Math.floor(width / tabWidth) + 1) * tabWidth;
36
+ else if (!'\v\f'.includes(source[k])) width += 1;
37
+ k++;
38
+ }
39
+ return k + (column - width);
40
+ }
41
+
42
+ export function keyName(prop) {
43
+ if (!prop || !prop.key) return null;
44
+ if (prop.computed) return null;
45
+ const k = prop.key;
46
+ if (k.type === 'Identifier') return k.name;
47
+ if (k.type === 'StringLiteral' || (k.type === 'Literal' && typeof k.value === 'string')) return k.value;
48
+ return null;
49
+ }
50
+
51
+ export function isPlainProp(prop) {
52
+ return prop && (prop.type === 'ObjectProperty' || prop.type === 'Property');
53
+ }
54
+
55
+ export function hasSpread(obj) {
56
+ return obj.properties.some((p) => p.type === 'SpreadElement' || p.type === 'SpreadProperty' || p.type === 'ObjectMethod');
57
+ }
58
+
59
+ export function getProp(obj, name) {
60
+ return obj.properties.find((p) => isPlainProp(p) && keyName(p) === name) || null;
61
+ }
62
+
63
+ export function isQuotedKey(prop) {
64
+ return prop.key && (prop.key.type === 'StringLiteral' || prop.key.type === 'Literal');
65
+ }
66
+
67
+ export function literalValue(node) {
68
+ if (!node) return undefined;
69
+ if (['StringLiteral', 'NumericLiteral', 'BooleanLiteral'].includes(node.type)) return node.value;
70
+ if (node.type === 'Literal') return node.value;
71
+ if (node.type === 'TemplateLiteral' && node.expressions.length === 0) return node.quasis[0].value.cooked;
72
+ return undefined;
73
+ }
74
+
75
+ export function isStringLit(node) {
76
+ return node && (node.type === 'StringLiteral' || (node.type === 'Literal' && typeof node.value === 'string'));
77
+ }
78
+
79
+ export function lineOf(node) {
80
+ return node && node.loc ? node.loc.start.line : null;
81
+ }
82
+
83
+ // Copia profunda de un nodo sin metadatos de posición, para poder usarlo dos veces.
84
+ export function cloneNode(node) {
85
+ if (Array.isArray(node)) return node.map(cloneNode);
86
+ if (!node || typeof node !== 'object') return node;
87
+ const out = {};
88
+ for (const [k, v] of Object.entries(node)) {
89
+ if (['loc', 'start', 'end', 'range', 'comments', 'leadingComments', 'trailingComments', 'innerComments', 'original', 'tokens'].includes(k)) continue;
90
+ out[k] = cloneNode(v);
91
+ }
92
+ return out;
93
+ }
94
+
95
+ // Cadena de propiedades de un MemberExpression no computado: result.a.b → { root: 'result', path: ['a','b'] }
96
+ export function memberChain(node) {
97
+ const path = [];
98
+ let cur = node;
99
+ while (cur && (cur.type === 'MemberExpression' || cur.type === 'OptionalMemberExpression')) {
100
+ if (cur.computed || cur.property.type !== 'Identifier') return null;
101
+ path.unshift(cur.property.name);
102
+ cur = cur.object;
103
+ }
104
+ if (!cur || cur.type !== 'Identifier') return null;
105
+ return { root: cur.name, path };
106
+ }
107
+
108
+ export function buildMember(j, rootName, parts) {
109
+ let expr = j.identifier(rootName);
110
+ for (const part of parts) {
111
+ if (typeof part === 'number') expr = j.memberExpression(expr, j.numericLiteral(part), true);
112
+ else expr = j.memberExpression(expr, j.identifier(part));
113
+ }
114
+ return expr;
115
+ }
116
+
117
+ // ¿Este Identifier es una referencia real (no una clave de objeto ni una propiedad de member)?
118
+ export function isReference(path) {
119
+ const parent = path.parent && path.parent.node;
120
+ if (!parent) return true;
121
+ if ((parent.type === 'MemberExpression' || parent.type === 'OptionalMemberExpression') && parent.property === path.node && !parent.computed) return false;
122
+ if ((parent.type === 'ObjectProperty' || parent.type === 'Property') && parent.key === path.node && !parent.computed && !parent.shorthand) return false;
123
+ if (parent.type === 'ImportSpecifier' || parent.type === 'ImportDefaultSpecifier') return false;
124
+ if (parent.type === 'VariableDeclarator' && parent.id === path.node) return false;
125
+ if (parent.type === 'TSPropertySignature' || parent.type === 'TSQualifiedName') return false;
126
+ const grand = path.parent.parent && path.parent.parent.node;
127
+ if ((parent.type === 'ObjectProperty' || parent.type === 'Property') && grand && grand.type === 'ObjectPattern') return false;
128
+ return true;
129
+ }
130
+
131
+ // Nodo armado desde texto: recast lo imprime tal cual, sin reformatear.
132
+ export function snippet(j, code, type) {
133
+ return j(code).find(j[type]).paths()[0].node;
134
+ }
135
+
136
+ // Resuelve un Identifier a la inicialización de su declaración (const x = {...}) usando el scope.
137
+ export function resolveBinding(path, name) {
138
+ const scope = path.scope && path.scope.lookup(name);
139
+ if (!scope) return null;
140
+ const bindings = scope.getBindings()[name];
141
+ if (!bindings || !bindings.length) return null;
142
+ const declPath = bindings[0].parent;
143
+ if (declPath && declPath.node.type === 'VariableDeclarator' && declPath.node.id.type === 'Identifier') return declPath;
144
+ return null;
145
+ }
146
+
147
+ // Chequeo de sintaxis del resultado, usado por verify y por los tests.
148
+ export function parseCheck(source, file) {
149
+ const plugins = ['jsx'];
150
+ if (/\.(ts|tsx)$/.test(file)) plugins.push('typescript');
151
+ if (/\.ts$/.test(file)) plugins.splice(plugins.indexOf('jsx'), 1);
152
+ babelParse(source, { sourceType: 'unambiguous', plugins, allowAwaitOutsideFunction: true });
153
+ return true;
154
+ }
155
+
156
+ export function finding(pack, file, node, status, message, extra = {}) {
157
+ return { pack, file, line: lineOf(node), status, message, ...extra };
158
+ }
159
+
160
+ // Recast agrega líneas en blanco alrededor de propiedades multilínea que reimprime.
161
+ // Esto saca SOLO las líneas en blanco nuevas (las que no estaban en el original),
162
+ // usando un diff de líneas, así el PR muestra el cambio y nada más.
163
+ export function tidy(original, output) {
164
+ const a = original.split('\n');
165
+ const b = output.split('\n');
166
+ const n = a.length;
167
+ const m = b.length;
168
+ const dp = Array.from({ length: n + 1 }, () => new Uint32Array(m + 1));
169
+ for (let i = n - 1; i >= 0; i--) {
170
+ for (let k = m - 1; k >= 0; k--) {
171
+ dp[i][k] = a[i] === b[k] ? dp[i + 1][k + 1] + 1 : Math.max(dp[i + 1][k], dp[i][k + 1]);
172
+ }
173
+ }
174
+ const keep = [];
175
+ let i = 0;
176
+ let k = 0;
177
+ while (k < m) {
178
+ if (i < n && a[i] === b[k]) { keep.push(b[k]); i++; k++; continue; }
179
+ if (i < n && dp[i + 1][k] >= dp[i][k + 1]) { i++; continue; }
180
+ if (b[k].trim() !== '') keep.push(b[k]); // línea nueva con contenido: se queda
181
+ k++;
182
+ }
183
+ return keep.join('\n');
184
+ }
@@ -0,0 +1,138 @@
1
+ // Pack arca.wsfe.condicion-iva-receptor
2
+ // Agrega CondicionIVAReceptorId a cada pedido de CAE hecho con afip.js cuando se puede inferir
3
+ // sin datos del negocio, y marca el resto para que una persona complete el dato.
4
+ import {
5
+ jFor, detectQuote, eolOf, offsetAt, getProp, hasSpread, isQuotedKey, isPlainProp, literalValue, resolveBinding, finding,
6
+ } from '../ast.js';
7
+
8
+ const PACK = 'arca.wsfe.condicion-iva-receptor';
9
+ const VOUCHER_METHODS = new Set(['createVoucher', 'createNextVoucher']);
10
+ const CLASS = {
11
+ A: new Set([1, 2, 3, 201, 202, 203]),
12
+ B: new Set([6, 7, 8, 206, 207, 208]),
13
+ C: new Set([11, 12, 13, 211, 212, 213]),
14
+ };
15
+
16
+ function classOf(cbteTipo) {
17
+ for (const [k, set] of Object.entries(CLASS)) if (set.has(cbteTipo)) return k;
18
+ return null;
19
+ }
20
+
21
+ function isVoucherCall(callee) {
22
+ return (
23
+ callee.type === 'MemberExpression' &&
24
+ !callee.computed &&
25
+ callee.property.type === 'Identifier' &&
26
+ VOUCHER_METHODS.has(callee.property.name) &&
27
+ callee.object.type === 'MemberExpression' &&
28
+ !callee.object.computed &&
29
+ callee.object.property.name === 'ElectronicBilling'
30
+ );
31
+ }
32
+
33
+ export function decide(obj, pack) {
34
+ const docTipo = literalValue(getProp(obj, 'DocTipo')?.value);
35
+ const cbteTipo = literalValue(getProp(obj, 'CbteTipo')?.value);
36
+ const clase = typeof cbteTipo === 'number' ? classOf(cbteTipo) : null;
37
+ if (docTipo === 99) {
38
+ return { status: 'auto', code: 5, comment: ' parche: 5 = Consumidor Final (DocTipo 99, sin identificar)' };
39
+ }
40
+ if (docTipo === 96 && (clase === 'B' || clase === 'C')) {
41
+ return { status: 'needs-review', code: 5, comment: ' parche: inferido Consumidor Final (DNI + comprobante ' + clase + ') — REVISAR' };
42
+ }
43
+ const valid = clase ? pack.codes_by_class[clase] : null;
44
+ return { status: 'needs-input', clase, valid };
45
+ }
46
+
47
+ export default function transform({ source, file, pack }) {
48
+ const j = jFor(file);
49
+ const findings = [];
50
+ const usages = [];
51
+ let root;
52
+ try {
53
+ root = j(source);
54
+ } catch {
55
+ return { output: null, findings, usages };
56
+ }
57
+ let changed = false;
58
+ const edits = [];
59
+
60
+ root.find(j.CallExpression).filter((p) => isVoucherCall(p.node.callee)).forEach((p) => {
61
+ const call = p.node;
62
+ usages.push({ provider: 'arca', op: `ElectronicBilling.${call.callee.property.name}`, line: call.loc?.start.line });
63
+ const arg = call.arguments[0];
64
+ let obj = null;
65
+ if (arg && arg.type === 'ObjectExpression') obj = arg;
66
+ else if (arg && arg.type === 'Identifier') {
67
+ const decl = resolveBinding(p, arg.name);
68
+ if (decl && decl.node.init && decl.node.init.type === 'ObjectExpression') obj = decl.node.init;
69
+ }
70
+ if (!obj || hasSpread(obj)) {
71
+ findings.push(finding(PACK, file, call, 'needs-input',
72
+ 'No pude leer los datos del comprobante (no es un objeto literal). Agregá CondicionIVAReceptorId a mano.'));
73
+ return;
74
+ }
75
+ if (getProp(obj, 'CondicionIVAReceptorId')) return; // ya está migrado
76
+
77
+ const d = decide(obj, pack);
78
+ if (d.status === 'needs-input') {
79
+ const lista = (d.valid || Object.keys(pack.codes).map(Number))
80
+ .map((c) => `${c} (${pack.codes[String(c)]})`).join(', ');
81
+ findings.push(finding(PACK, file, call, 'needs-input',
82
+ `Falta CondicionIVAReceptorId y no se puede inferir sin saber la condición frente al IVA del cliente${d.clase ? ` (comprobante clase ${d.clase})` : ''}.`,
83
+ { suggestion: `'CondicionIVAReceptorId': <condición del cliente>, // valores válidos: ${lista}` }));
84
+ return;
85
+ }
86
+
87
+ const props = obj.properties.filter(isPlainProp);
88
+ const quoted = props.length > 0 && props.filter(isQuotedKey).length >= props.length / 2;
89
+ const q = detectQuote(source) === 'double' ? '"' : "'";
90
+ const keyText = quoted ? `${q}CondicionIVAReceptorId${q}` : 'CondicionIVAReceptorId';
91
+ const anchor = getProp(obj, 'DocNro') || props[props.length - 1];
92
+ edits.push(...insertAfterProp(source, anchor, `${keyText}: ${d.code}`, d.comment));
93
+ changed = true;
94
+ findings.push(finding(PACK, file, call, d.status,
95
+ d.status === 'auto'
96
+ ? 'Agregado CondicionIVAReceptorId: 5 (Consumidor Final): el comprobante va a un consumidor sin identificar.'
97
+ : 'Agregado CondicionIVAReceptorId: 5 por inferencia (DNI + comprobante B/C). Confirmá que el cliente no sea exento ni no alcanzado.'));
98
+ });
99
+
100
+ // Integraciones SOAP propias: solo detección.
101
+ if (/FECAESolicitar|FECAEDetRequest/.test(source) && !/CondicionIVAReceptorId/.test(source)) {
102
+ const line = source.split('\n').findIndex((l) => /FECAESolicitar|FECAEDetRequest/.test(l)) + 1;
103
+ findings.push({ pack: PACK, file, line, status: 'needs-input',
104
+ message: 'Integración SOAP propia con WSFEv1 sin CondicionIVAReceptorId en el FECAEDetRequest. Hay que agregarlo al XML.' });
105
+ usages.push({ provider: 'arca', op: 'WSFEv1 SOAP', line });
106
+ }
107
+
108
+ return { output: changed ? applyEdits(source, edits) : null, findings, usages };
109
+ }
110
+
111
+ // Inserta una propiedad nueva después de `prop` tocando solo el texto necesario,
112
+ // para que el diff del PR sea de una línea y respete el formato del cliente.
113
+ export function insertAfterProp(source, prop, text, comment) {
114
+ const eol = eolOf(source);
115
+ const end = offsetAt(source, prop.loc.end);
116
+ let k = end;
117
+ while (k < source.length && /[ \t]/.test(source[k])) k++;
118
+ const hasComma = source[k] === ',';
119
+ const nl = source.indexOf('\n', end);
120
+ const rest = source.slice(hasComma ? k + 1 : end, nl === -1 ? source.length : nl);
121
+ if (nl !== -1 && rest.trim() === '') {
122
+ const lineStart = offsetAt(source, { line: prop.loc.start.line, column: 0 });
123
+ const indent = source.slice(lineStart).match(/^[ \t]*/)[0];
124
+ const out = [{ offset: nl + 1, text: `${indent}//${comment}${eol}${indent}${text},${eol}` }];
125
+ if (!hasComma) out.push({ offset: end, text: ',' });
126
+ return out;
127
+ }
128
+ // Objeto en una sola línea.
129
+ return [{ offset: hasComma ? k + 1 : end, text: `${hasComma ? '' : ','} ${text} /*${comment} */${hasComma ? ',' : ''}` }];
130
+ }
131
+
132
+ function applyEdits(source, edits) {
133
+ let out = source;
134
+ for (const e of [...edits].sort((a, b) => b.offset - a.offset)) {
135
+ out = out.slice(0, e.offset) + e.text + out.slice(e.offset);
136
+ }
137
+ return out;
138
+ }