claude-flow
Version:
Ruflo - Enterprise AI agent orchestration for Claude Code. Deploy 60+ specialized agents in coordinated swarms with self-learning, fault-tolerant consensus, vector memory, and MCP integration
291 lines • 11 kB
JavaScript
/**
* Deterministic Codemod Engine (ADR-143)
*
* Truly $0, no-LLM Tier-1 code transforms. Uses the TypeScript compiler API to
* locate exact AST nodes, then applies formatting-preserving text-range edits to
* the original source string (we never re-print the whole file, so comments and
* formatting survive).
*
* Only intents that can be transformed *deterministically and safely* live here.
* Intents that need inference or judgement (add-types, add-error-handling,
* async-await) are intentionally NOT codemods — they route to a model. See
* ADR-143 for the rationale.
*
* @module ruvector/codemods/engine
*/
import ts from 'typescript';
import { buildReassignmentResolver } from './scope-analysis.js';
/** Intents this engine can apply deterministically with $0 cost. */
export const DETERMINISTIC_CODEMOD_INTENTS = [
'var-to-const',
'remove-console',
'add-logging',
];
/** Intents recognised by the router but NOT safe as deterministic codemods. */
export const MODEL_ROUTED_INTENTS = [
'add-types',
'add-error-handling',
'async-await',
];
export function isDeterministicCodemod(intent) {
return DETERMINISTIC_CODEMOD_INTENTS.includes(intent);
}
// ============================================================================
// Parsing & edit application
// ============================================================================
function scriptKind(language) {
switch (language) {
case 'typescript':
return ts.ScriptKind.TS;
case 'tsx':
return ts.ScriptKind.TSX;
case 'jsx':
return ts.ScriptKind.JSX;
default:
return ts.ScriptKind.JS;
}
}
function parse(code, language) {
return ts.createSourceFile(`codemod-input.${language}`, code, ts.ScriptTarget.Latest,
/* setParentNodes */ true, scriptKind(language));
}
function parseDiagnosticCount(sf) {
// parseDiagnostics is internal but stable; guard so a missing field never throws.
return sf.parseDiagnostics?.length ?? 0;
}
/** Apply edits to the original source, right-to-left so offsets stay valid. */
function applyEdits(code, edits) {
const sorted = [...edits].sort((a, b) => b.start - a.start);
let out = code;
for (const e of sorted) {
out = out.slice(0, e.start) + e.replacement + out.slice(e.end);
}
return out;
}
function lineStartOffset(code, pos) {
let i = pos;
while (i > 0 && code[i - 1] !== '\n')
i--;
return i;
}
function leadingIndent(code, lineStart) {
let i = lineStart;
while (i < code.length && (code[i] === ' ' || code[i] === '\t'))
i++;
return code.slice(lineStart, i);
}
// ============================================================================
// var-to-const (scope-aware reassignment analysis — see scope-analysis.ts)
// ============================================================================
function isVarList(list) {
return !(list.flags & (ts.NodeFlags.Let | ts.NodeFlags.Const));
}
function collectBindingNames(name, out) {
if (ts.isIdentifier(name)) {
out.add(name.text);
return;
}
for (const el of name.elements) {
if (ts.isBindingElement(el))
collectBindingNames(el.name, out);
}
}
function varToConstEdits(code, sf) {
const resolver = buildReassignmentResolver(sf);
const edits = [];
const visit = (node) => {
if (ts.isVariableDeclarationList(node) && isVarList(node)) {
const names = new Set();
for (const decl of node.declarations)
collectBindingNames(decl.name, names);
// `const` only when no declared binding is reassigned *within its own scope*.
const anyReassigned = [...names].some((n) => resolver.isReassigned(n, node));
const keyword = anyReassigned ? 'let' : 'const';
// The `var` keyword is the first token of the declaration list.
const start = node.getStart(sf);
// Only rewrite when the literal text really is `var` (guards against odd trivia).
if (code.slice(start, start + 3) === 'var') {
edits.push({ start, end: start + 3, replacement: keyword });
}
}
ts.forEachChild(node, visit);
};
visit(sf);
return edits;
}
// ============================================================================
// remove-console
// ============================================================================
function rootIdentifier(expr) {
let cur = expr;
while (ts.isPropertyAccessExpression(cur) || ts.isElementAccessExpression(cur)) {
cur = cur.expression;
}
return ts.isIdentifier(cur) ? cur : undefined;
}
function isConsoleCallStatement(stmt) {
if (!ts.isExpressionStatement(stmt))
return false;
let expr = stmt.expression;
// unwrap `void console.log(x)` and awaited/comma forms defensively
if (ts.isCallExpression(expr)) {
const root = rootIdentifier(expr.expression);
return !!root && root.text === 'console';
}
return false;
}
function removeConsoleEdits(code, sf) {
const edits = [];
const visit = (node) => {
if (isConsoleCallStatement(node)) {
const stmt = node;
const start = stmt.getStart(sf);
const end = stmt.getEnd();
const ls = lineStartOffset(code, start);
const beforeIsBlank = code.slice(ls, start).trim() === '';
// Is the rest of the line after the statement only whitespace?
let lineEnd = end;
while (lineEnd < code.length && code[lineEnd] !== '\n')
lineEnd++;
const afterIsBlank = code.slice(end, lineEnd).trim() === '';
if (beforeIsBlank && afterIsBlank) {
// Statement owns its line(s): drop the whole line incl. trailing newline.
const dropEnd = lineEnd < code.length ? lineEnd + 1 : lineEnd;
edits.push({ start: ls, end: dropEnd, replacement: '' });
}
else {
// Inline with other code: remove just the statement, tidy one trailing space.
let e = end;
if (code[e] === ' ')
e++;
edits.push({ start, end: e, replacement: '' });
}
}
ts.forEachChild(node, visit);
};
visit(sf);
return edits;
}
function isFunctionLikeWithBlock(node) {
return ((ts.isFunctionDeclaration(node) ||
ts.isFunctionExpression(node) ||
ts.isArrowFunction(node) ||
ts.isMethodDeclaration(node) ||
ts.isConstructorDeclaration(node) ||
ts.isGetAccessor(node) ||
ts.isSetAccessor(node)) &&
!!node.body &&
ts.isBlock(node.body));
}
function functionName(node) {
if (ts.isConstructorDeclaration(node))
return 'constructor';
if ((ts.isFunctionDeclaration(node) ||
ts.isFunctionExpression(node) ||
ts.isMethodDeclaration(node) ||
ts.isGetAccessor(node) ||
ts.isSetAccessor(node)) &&
node.name &&
(ts.isIdentifier(node.name) || ts.isStringLiteral(node.name))) {
return node.name.text;
}
// function expression / arrow assigned to a variable or property
const parent = node.parent;
if (parent && ts.isVariableDeclaration(parent) && ts.isIdentifier(parent.name))
return parent.name.text;
if (parent && ts.isPropertyAssignment(parent) && ts.isIdentifier(parent.name))
return parent.name.text;
return 'anonymous';
}
function alreadyLogsEntry(body, name) {
const first = body.statements[0];
if (!first || !isConsoleCallStatement(first))
return false;
const text = first.getText();
return text.includes(`'${name} called'`) || text.includes(`"${name} called"`);
}
function addLoggingEdits(code, sf) {
const edits = [];
const visit = (node) => {
if (isFunctionLikeWithBlock(node)) {
const body = node.body;
const name = functionName(node);
if (!alreadyLogsEntry(body, name)) {
const braceOffset = body.getStart(sf); // position of '{'
const headerLineStart = lineStartOffset(code, node.getStart(sf));
const indent = leadingIndent(code, headerLineStart);
const bodyIndent = indent + ' ';
const insertion = `\n${bodyIndent}console.log(${JSON.stringify(`${name} called`)});`;
edits.push({ start: braceOffset + 1, end: braceOffset + 1, replacement: insertion });
}
}
ts.forEachChild(node, visit);
};
visit(sf);
return edits;
}
// ============================================================================
// Public API
// ============================================================================
const TRANSFORMS = {
'var-to-const': varToConstEdits,
'remove-console': removeConsoleEdits,
'add-logging': addLoggingEdits,
};
/**
* Apply a deterministic codemod to a source string.
*
* Returns the transformed source plus metadata. Never throws on malformed input
* — it reports `success: false` with a reason instead. Guarantees the output
* does not introduce new parse errors (otherwise it returns the input unchanged).
*/
export function applyCodemod(intent, code, opts = {}) {
const language = opts.language ?? 'typescript';
if (!isDeterministicCodemod(intent)) {
return {
intent: intent,
success: false,
changed: false,
output: code,
edits: 0,
language,
reason: `"${intent}" is not a deterministic codemod — route it to a model (Tier 2/3).`,
};
}
let sf;
try {
sf = parse(code, language);
}
catch (err) {
return {
intent,
success: false,
changed: false,
output: code,
edits: 0,
language,
reason: `parse failed: ${err.message}`,
};
}
const beforeDiagnostics = parseDiagnosticCount(sf);
const edits = TRANSFORMS[intent](code, sf);
if (edits.length === 0) {
return { intent, success: true, changed: false, output: code, edits: 0, language };
}
const output = applyEdits(code, edits);
// Safety net: never hand back source that parses worse than the input.
const afterDiagnostics = parseDiagnosticCount(parse(output, language));
if (afterDiagnostics > beforeDiagnostics) {
return {
intent,
success: false,
changed: false,
output: code,
edits: 0,
language,
reason: 'transform would introduce parse errors — aborted (input returned unchanged).',
};
}
return { intent, success: true, changed: true, output, edits: edits.length, language };
}
//# sourceMappingURL=engine.js.map