+610
| #!/usr/bin/env node | ||
| // mendapi astlite — zero-dependency AST-lite engine for syntax-aware code | ||
| // transforms. This is the foundation of the AST-tier fixer track: instead of | ||
| // blind regex replacement (which corrupts string literals, comments, and | ||
| // template literals containing the target pattern), astlite tokenizes the | ||
| // source just enough to know what is *code* and what is not, locates call | ||
| // sites by method chain, splits arguments with full nesting awareness, and | ||
| // rewrites them structurally. | ||
| // | ||
| // Scope (deliberate): JavaScript / TypeScript surface syntax. Not a full | ||
| // parser — a lexical scanner that classifies every character as code, | ||
| // string, template, comment, or regex literal, plus a call-site walker on | ||
| // top of the code regions. That is sufficient for the migration-pack use | ||
| // cases (method renames, positional-to-named argument moves, argument | ||
| // reordering, option-object injection) while staying zero-dependency. | ||
| // | ||
| // Usage: | ||
| // node app/astlite.js --self-test run built-in unit tests | ||
| // | ||
| // Library API (used by fixer packs): | ||
| // maskNonCode(text) -> string with non-code chars blanked | ||
| // findCallSites(text, chainRegex) -> [{ start, calleeEnd, argsStart, argsEnd, args }] | ||
| // splitArgs(text, argsStart, argsEnd) -> [{ start, end, text }] | ||
| // replaceCalls(text, chainRegex, fn) -> transformed text | ||
| // | ||
| // No network primitives. No child processes. Pure string analysis. | ||
| // ---------- lexical region scanner ---------- | ||
| // Region kinds (kindMask): 1 = code, 2 = string literal, 3 = template literal | ||
| // text (delimiters + text parts; ${ } interpolations are code), 4 = comment, | ||
| // 5 = regex literal. | ||
| const KIND_CODE = 1; | ||
| const KIND_STRING = 2; | ||
| const KIND_TEMPLATE = 3; | ||
| const KIND_COMMENT = 4; | ||
| const KIND_REGEX = 5; | ||
| // Returns a Uint8Array the same length as text with a region kind per char. | ||
| function kindMask(text) { | ||
| const n = text.length; | ||
| const mask = new Uint8Array(n).fill(KIND_CODE); | ||
| // template nesting stack: tracks brace depth inside ${ } per template level | ||
| const tplStack = []; | ||
| let i = 0; | ||
| let prevSig = ''; // last significant code char (for regex-vs-division) | ||
| let curKind = 0; // kind used by markNon for the current region | ||
| const markNon = (from, to) => { for (let k = from; k < to; k++) mask[k] = curKind; }; | ||
| while (i < n) { | ||
| const c = text[i]; | ||
| const c2 = i + 1 < n ? text[i + 1] : ''; | ||
| // line comment | ||
| if (c === '/' && c2 === '/') { | ||
| curKind = KIND_COMMENT; | ||
| let j = i; | ||
| while (j < n && text[j] !== '\n') j++; | ||
| markNon(i, j); | ||
| i = j; | ||
| continue; | ||
| } | ||
| // block comment | ||
| if (c === '/' && c2 === '*') { | ||
| curKind = KIND_COMMENT; | ||
| let j = i + 2; | ||
| while (j < n && !(text[j] === '*' && text[j + 1] === '/')) j++; | ||
| j = Math.min(n, j + 2); | ||
| markNon(i, j); | ||
| i = j; | ||
| continue; | ||
| } | ||
| // string literals | ||
| if (c === '"' || c === "'") { | ||
| curKind = KIND_STRING; | ||
| let j = i + 1; | ||
| while (j < n && text[j] !== c) { | ||
| if (text[j] === '\\') j++; | ||
| j++; | ||
| } | ||
| j = Math.min(n, j + 1); | ||
| markNon(i, j); | ||
| prevSig = c; // a string is a value: following / is division | ||
| i = j; | ||
| continue; | ||
| } | ||
| // template literal open | ||
| if (c === '`') { | ||
| curKind = KIND_TEMPLATE; | ||
| let j = i + 1; | ||
| markNon(i, i + 1); | ||
| while (j < n) { | ||
| if (text[j] === '\\') { markNon(j, j + 2); j += 2; continue; } | ||
| if (text[j] === '`') { markNon(j, j + 1); j++; break; } | ||
| if (text[j] === '$' && text[j + 1] === '{') { | ||
| // interpolation: keep ${ } contents as code | ||
| markNon(j, j + 2); // the ${ itself is delimiter | ||
| tplStack.push(0); | ||
| j += 2; | ||
| // recursively scan until matching } — delegate to outer loop by | ||
| // rewinding: simplest correct approach is manual brace tracking here | ||
| let depth = 1; | ||
| while (j < n && depth > 0) { | ||
| const d = text[j]; | ||
| const d2 = j + 1 < n ? text[j + 1] : ''; | ||
| if (d === '/' && d2 === '/') { let k = j; while (k < n && text[k] !== '\n') k++; curKind = KIND_COMMENT; markNon(j, k); curKind = KIND_TEMPLATE; j = k; continue; } | ||
| if (d === '/' && d2 === '*') { let k = j + 2; while (k < n && !(text[k] === '*' && text[k + 1] === '/')) k++; k = Math.min(n, k + 2); curKind = KIND_COMMENT; markNon(j, k); curKind = KIND_TEMPLATE; j = k; continue; } | ||
| if (d === '"' || d === "'") { let k = j + 1; while (k < n && text[k] !== d) { if (text[k] === '\\') k++; k++; } k = Math.min(n, k + 1); curKind = KIND_STRING; markNon(j, k); curKind = KIND_TEMPLATE; j = k; continue; } | ||
| if (d === '`') { | ||
| // nested template: mark whole nested template non-code except | ||
| // its own interpolations — for fixer purposes treating nested | ||
| // template interpolations as non-code is acceptable (conservative: | ||
| // we never rewrite inside them). | ||
| let k = j + 1; | ||
| while (k < n) { | ||
| if (text[k] === '\\') { k += 2; continue; } | ||
| if (text[k] === '`') { k++; break; } | ||
| k++; | ||
| } | ||
| markNon(j, k); | ||
| j = k; | ||
| continue; | ||
| } | ||
| if (d === '{') depth++; | ||
| if (d === '}') { depth--; if (depth === 0) { markNon(j, j + 1); j++; break; } } | ||
| j++; | ||
| } | ||
| tplStack.pop(); | ||
| continue; | ||
| } | ||
| markNon(j, j + 1); | ||
| j++; | ||
| } | ||
| prevSig = '`'; | ||
| i = j; | ||
| continue; | ||
| } | ||
| // regex literal vs division: / starts a regex when previous significant | ||
| // char is not a value terminator | ||
| if (c === '/') { | ||
| const valueEnd = /[\w$)\]`'"]/.test(prevSig); | ||
| if (!valueEnd) { | ||
| curKind = KIND_REGEX; | ||
| let j = i + 1; | ||
| let inClass = false; | ||
| while (j < n) { | ||
| const d = text[j]; | ||
| if (d === '\\') { j += 2; continue; } | ||
| if (d === '[') inClass = true; | ||
| else if (d === ']') inClass = false; | ||
| else if (d === '/' && !inClass) { j++; break; } | ||
| else if (d === '\n') break; // unterminated — bail | ||
| j++; | ||
| } | ||
| // trailing flags | ||
| while (j < n && /[a-z]/i.test(text[j])) j++; | ||
| markNon(i, j); | ||
| prevSig = '/'; | ||
| i = j; | ||
| continue; | ||
| } | ||
| } | ||
| if (!/\s/.test(c)) prevSig = c; | ||
| i++; | ||
| } | ||
| return mask; | ||
| } | ||
| // Back-compat: 1 = code, 0 = non-code (any literal/comment region). | ||
| function codeMask(text) { | ||
| const km = kindMask(text); | ||
| const mask = new Uint8Array(km.length); | ||
| for (let i = 0; i < km.length; i++) mask[i] = km[i] === KIND_CODE ? 1 : 0; | ||
| return mask; | ||
| } | ||
| // Return text with every non-code character replaced by a space (delimiters | ||
| // too), preserving offsets — regexes run on the masked text can only match | ||
| // real code. | ||
| function maskNonCode(text) { | ||
| const mask = codeMask(text); | ||
| let out = ''; | ||
| for (let i = 0; i < text.length; i++) out += mask[i] ? text[i] : (text[i] === '\n' ? '\n' : ' '); | ||
| return out; | ||
| } | ||
| // ---------- argument splitting ---------- | ||
| // Given the offsets of the ( and ) of a call, split top-level arguments. | ||
| function splitArgs(text, argsStart, argsEnd) { | ||
| const mask = codeMask(text); | ||
| const args = []; | ||
| let depth = 0; | ||
| let cur = argsStart + 1; | ||
| for (let i = argsStart + 1; i < argsEnd; i++) { | ||
| if (!mask[i]) continue; | ||
| const c = text[i]; | ||
| if (c === '(' || c === '[' || c === '{') depth++; | ||
| else if (c === ')' || c === ']' || c === '}') depth--; | ||
| else if (c === ',' && depth === 0) { | ||
| args.push({ start: cur, end: i, text: text.slice(cur, i).trim() }); | ||
| cur = i + 1; | ||
| } | ||
| } | ||
| if (argsEnd > cur && text.slice(cur, argsEnd).trim() !== '') { | ||
| args.push({ start: cur, end: argsEnd, text: text.slice(cur, argsEnd).trim() }); | ||
| } | ||
| return args; | ||
| } | ||
| // Find the matching ) for the ( at openIdx, honoring nesting and non-code. | ||
| function matchParen(text, mask, openIdx) { | ||
| let depth = 0; | ||
| for (let i = openIdx; i < text.length; i++) { | ||
| if (!mask[i]) continue; | ||
| if (text[i] === '(') depth++; | ||
| else if (text[i] === ')') { depth--; if (depth === 0) return i; } | ||
| } | ||
| return -1; | ||
| } | ||
| // ---------- call-site location ---------- | ||
| // chainRegex matches the callee expression ending right before the opening | ||
| // paren, e.g. /\.createChatCompletion$/ applied per candidate. For practical | ||
| // pack use, pass a regex WITHOUT anchors that matches the chain, e.g. | ||
| // /\bopenai\.chat\.completions\.create/ or /\.createChatCompletion\b/. | ||
| function findCallSites(text, chainRegex) { | ||
| const masked = maskNonCode(text); | ||
| const mask = codeMask(text); | ||
| const sites = []; | ||
| const re = new RegExp(chainRegex.source, chainRegex.flags.includes('g') ? chainRegex.flags : chainRegex.flags + 'g'); | ||
| let m; | ||
| while ((m = re.exec(masked)) !== null) { | ||
| const calleeEnd = m.index + m[0].length; | ||
| // skip whitespace to find the ( | ||
| let p = calleeEnd; | ||
| while (p < text.length && /\s/.test(text[p])) p++; | ||
| if (text[p] !== '(') continue; // property access, not a call | ||
| const close = matchParen(text, mask, p); | ||
| if (close === -1) continue; | ||
| sites.push({ | ||
| start: m.index, | ||
| calleeEnd, | ||
| callee: text.slice(m.index, calleeEnd), | ||
| argsStart: p, | ||
| argsEnd: close, | ||
| args: splitArgs(text, p, close), | ||
| }); | ||
| re.lastIndex = close; | ||
| } | ||
| return sites; | ||
| } | ||
| // ---------- structural rewrite ---------- | ||
| // fn(site, text) returns either null (leave untouched) or the full | ||
| // replacement string for text[site.start .. site.argsEnd+1). | ||
| function replaceCalls(text, chainRegex, fn) { | ||
| const sites = findCallSites(text, chainRegex); | ||
| let out = text; | ||
| // apply from last to first so offsets stay valid | ||
| for (let i = sites.length - 1; i >= 0; i--) { | ||
| const s = sites[i]; | ||
| const rep = fn(s, out); | ||
| if (rep == null) continue; | ||
| out = out.slice(0, s.start) + rep + out.slice(s.argsEnd + 1); | ||
| } | ||
| return out; | ||
| } | ||
| // Convenience: rename a callee (method chain) without touching args. | ||
| function renameCall(text, chainRegex, newCallee) { | ||
| return replaceCalls(text, chainRegex, (s, cur) => | ||
| newCallee + cur.slice(s.calleeEnd, s.argsEnd + 1)); | ||
| } | ||
| // ---------- identifier rename ---------- | ||
| // Rename a bare identifier (type name, class name, imported symbol) in CODE | ||
| // regions only. Unlike renameCall this does not require a call site: it | ||
| // covers type annotations, import specifiers, extends clauses, generics. | ||
| // Matches whole identifiers (\b anchored) and never fires when the match is | ||
| // preceded by '.' (member access on another object) or followed/preceded by | ||
| // identifier characters. By default comments are treated as non-code (JSDoc | ||
| // type references in comments stay untouched unless opts.includeComments). | ||
| // opts.includeJsdoc allows matches inside /** ... */ doc comments only — | ||
| // JSDoc type annotations are live type information in JS codebases, while | ||
| // plain // and /* */ prose comments stay protected. | ||
| function renameIdentifier(text, oldName, newName, opts = {}) { | ||
| if (!/^[A-Za-z_$][\w$]*$/.test(oldName)) throw new Error(`renameIdentifier: invalid identifier ${JSON.stringify(oldName)}`); | ||
| const km = kindMask(text); | ||
| const allowed = new Set([KIND_CODE]); | ||
| if (opts.includeComments) allowed.add(KIND_COMMENT); | ||
| const isJsdocAt = (idx) => { | ||
| // walk back to the start of the enclosing comment region | ||
| let s = idx; | ||
| while (s > 0 && km[s - 1] === KIND_COMMENT) s--; | ||
| return text.startsWith('/**', s); | ||
| }; | ||
| const re = new RegExp(`(^|[^\\w$])(${oldName.replace(/\$/g, '\\$')})(?![\\w$])`, 'g'); | ||
| let out = ''; | ||
| let last = 0; | ||
| let m; | ||
| while ((m = re.exec(text)) !== null) { | ||
| const idStart = m.index + m[1].length; | ||
| // '.'-preceded = member access on another object: skip in code, but | ||
| // allow inside JSDoc where import('pkg').Type is a qualified type ref. | ||
| const dotPreceded = m[1] === '.'; | ||
| if (dotPreceded && !(opts.includeJsdoc && km[idStart] === KIND_COMMENT && isJsdocAt(idStart))) continue; | ||
| // every char of the identifier must sit in an allowed region | ||
| let ok = true; | ||
| for (let k = idStart; k < idStart + oldName.length; k++) { | ||
| if (allowed.has(km[k])) continue; | ||
| if (opts.includeJsdoc && km[k] === KIND_COMMENT && isJsdocAt(k)) continue; | ||
| ok = false; break; | ||
| } | ||
| if (!ok) continue; | ||
| out += text.slice(last, idStart) + newName; | ||
| last = idStart + oldName.length; | ||
| } | ||
| out += text.slice(last); | ||
| return out; | ||
| } | ||
| // ---------- destructuring pattern cleanup ---------- | ||
| // Remove one property from flat object destructuring declarations, e.g. | ||
| // const { iin, brand } = customer.default_source; -> const { brand } = customer.default_source; | ||
| // This is the AST-tier counterpart of the conservative line-level packs that | ||
| // deliberately leave destructuring patterns alone (a dropped binding would | ||
| // turn downstream reads into ReferenceErrors). Safety rules, all honest-skip: | ||
| // * only flat patterns (no nested braces) are touched | ||
| // * entries with defaults (`iin = x`) or rest (`...r`) leave the site alone | ||
| // * the bound identifier must have ZERO other references in code regions | ||
| // (string literals, comments, template text never count; `obj.iin` | ||
| // member access never counts; any ambiguous hit counts as a reference, | ||
| // the conservative direction) | ||
| // * when the pattern empties: the declaration is dropped only if the RHS | ||
| // is a pure member/identifier chain (no call parens = no side effects); | ||
| // otherwise it is kept as `const {} = rhs;` (valid JS, side effects kept) | ||
| function removeDestructuredProperty(text, propName) { | ||
| if (!/^[A-Za-z_$][\w$]*$/.test(propName)) throw new Error(`removeDestructuredProperty: invalid identifier ${JSON.stringify(propName)}`); | ||
| const masked = maskNonCode(text); | ||
| const km = kindMask(text); | ||
| const declRe = /\b(const|let|var)\s*\{([^{}]*)\}\s*=/g; | ||
| const edits = []; // { start, end, replacement } | ||
| let m; | ||
| while ((m = declRe.exec(masked)) !== null) { | ||
| const braceOpen = masked.indexOf('{', m.index); | ||
| const braceClose = masked.indexOf('}', braceOpen); | ||
| const inner = text.slice(braceOpen + 1, braceClose); | ||
| // parse flat entries from the real text (offsets equal the masked text) | ||
| const rawEntries = inner.split(',').map((e) => e.trim()).filter((e) => e !== ''); | ||
| let hitIdx = -1; | ||
| let bound = null; | ||
| for (let i = 0; i < rawEntries.length; i++) { | ||
| const e = rawEntries[i]; | ||
| if (e === propName) { hitIdx = i; bound = propName; break; } | ||
| const am = e.match(/^([A-Za-z_$][\w$]*)\s*:\s*([A-Za-z_$][\w$]*)$/); | ||
| if (am && am[1] === propName) { hitIdx = i; bound = am[2]; break; } | ||
| } | ||
| if (hitIdx === -1) continue; // property not in this pattern | ||
| // honest skips: defaults or rest anywhere in the pattern make comma | ||
| // surgery risky; leave the whole site to a human / full parser | ||
| if (rawEntries.some((e) => e.includes('=') || e.startsWith('...'))) continue; | ||
| // reference counting: the bound identifier must appear nowhere else in | ||
| // code regions (member access `.bound` excluded; everything else counts) | ||
| const refRe = new RegExp(`(^|[^\\w$])(${bound.replace(/\$/g, '\\$')})(?![\\w$])`, 'g'); | ||
| let referenced = false; | ||
| let rm; | ||
| while ((rm = refRe.exec(text)) !== null) { | ||
| const idStart = rm.index + rm[1].length; | ||
| if (idStart > braceOpen && idStart < braceClose) continue; // the pattern itself | ||
| if (km[idStart] !== KIND_CODE) continue; // strings/comments/templates never count | ||
| if (rm[1] === '.') continue; // member access on another object | ||
| referenced = true; break; | ||
| } | ||
| if (referenced) continue; | ||
| const kept = rawEntries.filter((_, i) => i !== hitIdx); | ||
| if (kept.length > 0) { | ||
| edits.push({ start: braceOpen, end: braceClose + 1, replacement: `{ ${kept.join(', ')} }` }); | ||
| continue; | ||
| } | ||
| // pattern emptied: locate the statement end (first code-region `;` or | ||
| // newline after the `=`) and decide whether the RHS is side-effect free | ||
| const eq = masked.indexOf('=', braceClose); | ||
| let stmtEnd = -1; | ||
| for (let i = eq + 1; i < text.length; i++) { | ||
| if (km[i] === KIND_CODE && text[i] === ';') { stmtEnd = i + 1; break; } | ||
| if (text[i] === '\n' && km[i] !== KIND_STRING && km[i] !== KIND_TEMPLATE) { stmtEnd = i; break; } | ||
| } | ||
| if (stmtEnd === -1) stmtEnd = text.length; | ||
| const rhs = text.slice(eq + 1, stmtEnd).replace(/;\s*$/, '').trim(); | ||
| if (/^[\w$?.\[\]\s]+$/.test(rhs) && !rhs.includes('(')) { | ||
| // pure member/identifier chain: drop the whole declaration; also eat | ||
| // the leading indentation and the trailing newline so no blank line | ||
| // is left behind | ||
| let lineStart = m.index; | ||
| while (lineStart > 0 && text[lineStart - 1] !== '\n') lineStart--; | ||
| const before = text.slice(lineStart, m.index); | ||
| const start = /^[ \t]*$/.test(before) ? lineStart : m.index; | ||
| let end = stmtEnd; | ||
| if (start === lineStart && text[end] === '\n') end++; | ||
| edits.push({ start, end, replacement: '' }); | ||
| } else { | ||
| edits.push({ start: braceOpen, end: braceClose + 1, replacement: '{}' }); | ||
| } | ||
| } | ||
| let out = text; | ||
| for (let i = edits.length - 1; i >= 0; i--) { | ||
| const e = edits[i]; | ||
| out = out.slice(0, e.start) + e.replacement + out.slice(e.end); | ||
| } | ||
| return out; | ||
| } | ||
| export { codeMask, kindMask, maskNonCode, splitArgs, findCallSites, replaceCalls, renameCall, renameIdentifier, removeDestructuredProperty }; | ||
| // ---------- self-test ---------- | ||
| function selfTest() { | ||
| const results = []; | ||
| const t = (name, cond) => results.push({ name, pass: !!cond }); | ||
| // 1. regex-vs-astlite discriminator: pattern inside a string literal must | ||
| // NOT be rewritten (this is the exact failure mode of blind regex packs) | ||
| { | ||
| const src = `const msg = "call openai.createChatCompletion( here";\nawait client.createChatCompletion({ model: "gpt-4" });\n`; | ||
| const out = renameCall(src, /\bclient\.createChatCompletion\b/, 'client.chat.completions.create'); | ||
| t('rename rewrites real call', out.includes('client.chat.completions.create({ model: "gpt-4" })')); | ||
| t('rename leaves string literal intact', out.includes('"call openai.createChatCompletion( here"')); | ||
| // control: prove the naive regex WOULD have corrupted the string | ||
| const naive = src.replace(/createChatCompletion/g, 'chat.completions.create'); | ||
| t('control: naive regex corrupts string literal', naive.includes('"call openai.chat.completions.create( here"')); | ||
| } | ||
| // 2. pattern inside comments must not be rewritten | ||
| { | ||
| const src = `// migrate client.oldCall() later\n/* client.oldCall(1) */\nclient.oldCall(1);\n`; | ||
| const out = renameCall(src, /\bclient\.oldCall\b/, 'client.newCall'); | ||
| t('comments untouched', out.includes('// migrate client.oldCall() later') && out.includes('/* client.oldCall(1) */')); | ||
| t('code rewritten once', (out.match(/client\.newCall\(1\)/g) || []).length === 1); | ||
| } | ||
| // 3. template literal: text part non-code, interpolation IS code | ||
| { | ||
| const src = 'const s = `use client.oldCall() like ${client.oldCall(x)} ok`;\n'; | ||
| const out = renameCall(src, /\bclient\.oldCall\b/, 'client.newCall'); | ||
| t('template text part untouched', out.includes('use client.oldCall() like')); | ||
| t('template interpolation rewritten', out.includes('${client.newCall(x)}')); | ||
| } | ||
| // 4. nested-paren argument splitting | ||
| { | ||
| const src = 'api.doThing(fn(a, b), { x: [1, 2], y: g(h(3), 4) }, "lit,eral");'; | ||
| const sites = findCallSites(src, /\bapi\.doThing\b/); | ||
| t('one call site found', sites.length === 1); | ||
| const args = sites[0] ? sites[0].args.map(a => a.text) : []; | ||
| t('three top-level args', args.length === 3); | ||
| t('nested call arg intact', args[0] === 'fn(a, b)'); | ||
| t('object arg intact', args[1] === '{ x: [1, 2], y: g(h(3), 4) }'); | ||
| t('string arg with comma intact', args[2] === '"lit,eral"'); | ||
| } | ||
| // 5. positional-to-named transform (cloudflare v7 named-path-params shape) | ||
| { | ||
| const src = 'await cf.zones.dns.get(zoneId, recordId, { timeout: 5 });'; | ||
| const out = replaceCalls(src, /\bcf\.zones\.dns\.get\b/, (s) => { | ||
| if (s.args.length < 2) return null; | ||
| const [zone, rec, ...rest] = s.args.map(a => a.text); | ||
| const opts = rest.length ? `, ${rest.join(', ')}` : ''; | ||
| return `cf.zones.dns.get(${rec}, { zone_id: ${zone} }${opts})`; | ||
| }); | ||
| t('positional-to-named rewrite', out === 'await cf.zones.dns.get(recordId, { zone_id: zoneId }, { timeout: 5 });'); | ||
| } | ||
| // 6. multiple call sites in one file, rewritten independently | ||
| { | ||
| const src = 'a.old(1);\nconst r = /a\\.old\\(/g;\na.old(2);\n'; | ||
| const out = renameCall(src, /\ba\.old\b/, 'a.neo'); | ||
| t('regex literal untouched', out.includes('/a\\.old\\(/g')); | ||
| t('both real calls rewritten', out.includes('a.neo(1)') && out.includes('a.neo(2)')); | ||
| } | ||
| // 7. property access without call is not treated as a call site | ||
| { | ||
| const src = 'const ref = client.oldCall;\nclient.oldCall(9);\n'; | ||
| const sites = findCallSites(src, /\bclient\.oldCall\b/); | ||
| t('bare property access skipped', sites.length === 1 && sites[0].args[0].text === '9'); | ||
| } | ||
| // 8. division is not misread as regex (mask sanity) | ||
| { | ||
| const src = 'const x = a / b; client.oldCall(x / 2);\n'; | ||
| const out = renameCall(src, /\bclient\.oldCall\b/, 'client.newCall'); | ||
| t('division-safe', out.includes('client.newCall(x / 2)')); | ||
| } | ||
| // 9. renameIdentifier: type/class names in code rewritten, strings and | ||
| // prose comments untouched, member access on other objects untouched | ||
| { | ||
| const src = [ | ||
| "import { DEXTestGetResponse } from 'cloudflare';", | ||
| 'const note = "DEXTestGetResponse is gone in v7";', | ||
| '// prose: DEXTestGetResponse mentioned in a comment', | ||
| 'let r = other.DEXTestGetResponse;', | ||
| '/** @returns {Promise<DEXTestGetResponse>} */', | ||
| 'function f() { return /** */ null; }', | ||
| 'const x = new DEXTestGetResponse();', | ||
| 'const tpl = `mentions DEXTestGetResponse in text ${DEXTestGetResponse.kind}`;', | ||
| ].join('\n'); | ||
| const out = renameIdentifier(src, 'DEXTestGetResponse', 'SchemaHTTP', { includeJsdoc: true }); | ||
| t('identifier: import specifier rewritten', out.includes("import { SchemaHTTP } from 'cloudflare';")); | ||
| t('identifier: string literal untouched', out.includes('"DEXTestGetResponse is gone in v7"')); | ||
| t('identifier: prose comment untouched', out.includes('// prose: DEXTestGetResponse mentioned')); | ||
| t('identifier: member access on other object untouched', out.includes('other.DEXTestGetResponse')); | ||
| t('identifier: jsdoc type rewritten with includeJsdoc', out.includes('{Promise<SchemaHTTP>}')); | ||
| t('identifier: new expression rewritten', out.includes('new SchemaHTTP()')); | ||
| t('identifier: template text untouched, interpolation rewritten', out.includes('mentions DEXTestGetResponse in text ${SchemaHTTP.kind}')); | ||
| // control: naive regex corrupts string + prose comment + member access | ||
| const naive = src.replace(/\bDEXTestGetResponse\b/g, 'SchemaHTTP'); | ||
| t('control: naive regex corrupts string literal', naive.includes('"SchemaHTTP is gone in v7"')); | ||
| t('control: naive regex corrupts member access', naive.includes('other.SchemaHTTP')); | ||
| } | ||
| // 10. renameIdentifier: partial-name and adjacency safety | ||
| { | ||
| const src = 'const DEXTestGetResponseList = 1; call(DEXTestGetResponse);\n'; | ||
| const out = renameIdentifier(src, 'DEXTestGetResponse', 'SchemaHTTP'); | ||
| t('identifier: longer identifier not clipped', out.includes('DEXTestGetResponseList = 1')); | ||
| t('identifier: exact match rewritten', out.includes('call(SchemaHTTP)')); | ||
| t('identifier: idempotent', renameIdentifier(out, 'DEXTestGetResponse', 'SchemaHTTP') === out); | ||
| } | ||
| // 11. removeDestructuredProperty: the AST-track destructuring cleanup | ||
| { | ||
| // unreferenced binding is removed, siblings kept | ||
| const src = 'const { iin, brand } = customer.default_source;\nreturn brand;\n'; | ||
| const out = removeDestructuredProperty(src, 'iin'); | ||
| t('destructure: unreferenced binding removed', out.includes('const { brand } = customer.default_source;')); | ||
| t('destructure: sibling reference intact', out.includes('return brand;')); | ||
| } | ||
| { | ||
| // referenced binding => honest skip (conservative direction) | ||
| const src = 'const { iin, brand } = customer.default_source;\nreturn { iin, brand };\n'; | ||
| const out = removeDestructuredProperty(src, 'iin'); | ||
| t('destructure: referenced binding untouched', out === src); | ||
| } | ||
| { | ||
| // renamed entry ({ iin: bin }): reference check follows the LOCAL name | ||
| const used = 'const { iin: bin, brand } = card;\nconsole.log(bin);\n'; | ||
| t('destructure: renamed binding referenced -> untouched', removeDestructuredProperty(used, 'iin') === used); | ||
| const unused = 'const { iin: bin, brand } = card;\nconsole.log(brand);\n'; | ||
| t('destructure: renamed binding unreferenced -> removed', removeDestructuredProperty(unused, 'iin').includes('const { brand } = card;')); | ||
| } | ||
| { | ||
| // string/comment/member-access mentions never count as references | ||
| const src = 'const { iin, brand } = card;\n// iin was the BIN\nconst s = "iin";\nconst other = record.iin;\nreturn brand;\n'; | ||
| const out = removeDestructuredProperty(src, 'iin'); | ||
| t('destructure: string/comment/member mentions do not block removal', out.includes('const { brand } = card;')); | ||
| t('destructure: comment and string survive verbatim', out.includes('// iin was the BIN') && out.includes('"iin"') && out.includes('record.iin')); | ||
| } | ||
| { | ||
| // defaults and rest elements => honest skip | ||
| const d = 'const { iin = null, brand } = card;\nreturn brand;\n'; | ||
| t('destructure: default value pattern untouched', removeDestructuredProperty(d, 'iin') === d); | ||
| const r = 'const { iin, ...rest } = card;\nreturn rest;\n'; | ||
| t('destructure: rest pattern untouched', removeDestructuredProperty(r, 'iin') === r); | ||
| } | ||
| { | ||
| // nested patterns => honest skip (flat-only rule) | ||
| const src = 'const { card: { iin } } = charge;\nreturn 1;\n'; | ||
| t('destructure: nested pattern untouched', removeDestructuredProperty(src, 'iin') === src); | ||
| } | ||
| { | ||
| // pattern empties: pure member RHS => whole declaration dropped | ||
| const src = 'function f(card) {\n const { iin } = card.details;\n return card.brand;\n}\n'; | ||
| const out = removeDestructuredProperty(src, 'iin'); | ||
| t('destructure: emptied pattern with pure RHS drops declaration', !out.includes('iin') && !out.includes('{}')); | ||
| t('destructure: no blank line left behind', out === 'function f(card) {\n return card.brand;\n}\n'); | ||
| } | ||
| { | ||
| // pattern empties: call RHS has side effects => kept as `const {} = rhs` | ||
| const src = 'const { iin } = await fetchCard(id);\nreturn 1;\n'; | ||
| const out = removeDestructuredProperty(src, 'iin'); | ||
| t('destructure: emptied pattern with call RHS keeps side effect', out.includes('const {} = await fetchCard(id);')); | ||
| } | ||
| { | ||
| // patterns inside strings/comments are never rewritten | ||
| const src = 'const s = "const { iin } = card;";\n// const { iin } = card;\nconst { iin, brand } = card;\nuse(brand);\n'; | ||
| const out = removeDestructuredProperty(src, 'iin'); | ||
| t('destructure: string literal pattern untouched', out.includes('"const { iin } = card;"')); | ||
| t('destructure: comment pattern untouched', out.includes('// const { iin } = card;')); | ||
| t('destructure: real code pattern rewritten', out.includes('const { brand } = card;')); | ||
| // control: prove a naive regex WOULD have hit the string literal too | ||
| const naive = src.replace(/\biin\s*,\s*/g, '').replace(/\{\s*iin\s*\}/g, '{}'); | ||
| t('control: naive regex corrupts string pattern', naive.includes('"const {} = card;"')); | ||
| } | ||
| { | ||
| // idempotent | ||
| const src = 'const { iin, brand } = card;\nuse(brand);\n'; | ||
| const once = removeDestructuredProperty(src, 'iin'); | ||
| t('destructure: idempotent', removeDestructuredProperty(once, 'iin') === once); | ||
| } | ||
| const fails = results.filter(r => !r.pass); | ||
| for (const r of results) console.log(`${r.pass ? 'PASS' : 'FAIL'}: ${r.name}`); | ||
| console.log(`astlite self-test: ${results.length - fails.length}/${results.length} passed`); | ||
| process.exit(fails.length ? 1 : 0); | ||
| } | ||
| if (process.argv[1] && process.argv[1].endsWith('astlite.js')) { | ||
| if (process.argv.includes('--self-test')) selfTest(); | ||
| else { | ||
| console.log('Usage: node app/astlite.js --self-test'); | ||
| process.exit(1); | ||
| } | ||
| } |
+2
-1
| { | ||
| "name": "mendapi", | ||
| "version": "0.5.2", | ||
| "version": "0.5.3", | ||
| "license": "AGPL-3.0-only", | ||
@@ -28,2 +28,3 @@ "type": "module", | ||
| "fixer.js", | ||
| "astlite.js", | ||
| "pr.js", | ||
@@ -30,0 +31,0 @@ "review.js", |
+1
-1
@@ -163,3 +163,3 @@ # mendapi | ||
| Published on npm as [`mendapi`](https://www.npmjs.com/package/mendapi) (v0.5.1). Early release — the change database and migration pack registry grow daily; interfaces may still shift before 1.0. | ||
| Published on npm as [`mendapi`](https://www.npmjs.com/package/mendapi) (v0.5.3). Early release — the change database and migration pack registry grow daily; interfaces may still shift before 1.0. | ||
@@ -166,0 +166,0 @@ ## License |
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
923250
3.17%18
5.88%14935
4.07%