import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import type { GraphNode } from 'gitnexus-shared'; import { planGraphRename } from '../../src/mcp/local/rename/graph-rename.js'; import { renameSymbol, type RenameSymbol } from '../../src/mcp/local/rename/rename-plan.js'; describe('graph and SemanticModel rename', () => { let root: string; beforeEach(async () => { root = await fs.mkdtemp(path.join(os.tmpdir(), 'graph-rename-')); }); afterEach(async () => { await fs.rm(root, { recursive: true, force: true }); }); async function fixture(file: string, source: string, extra: GraphNode[] = []) { await fs.writeFile(path.join(root, file), source); return [ { id: `File:${file}`, label: 'File', properties: { name: file, filePath: file } }, { id: `Function:${file}:target`, label: 'Function', properties: { name: 'target', filePath: file, startLine: 1, endLine: 1 }, }, ...extra, ] as GraphNode[]; } const cases = [ ['model.ts', 'function target() {}\ntarget();\n// target\nconst text = "target";\n'], ['model.js', 'function target() {}\ntarget();\n// target\nconst text = "target";\n'], ['model.py', 'def target(): pass\ntarget()\n# target\ntext = "target"\n'], ['model.rs', 'fn target() {}\nfn main() { target(); }\n// target\n'], ['model.go', 'package model\nfunc target() {}\nfunc caller() { target() }\n// target\n'], ]; async function expectBlockedPreviewAndApply( file: string, source: string, symbol: RenameSymbol, nodes: GraphNode[], code: string, newName = 'Renamed', ) { for (const dry_run of [true, false]) { const result = await renameSymbol(root, symbol, { new_name: newName, dry_run }, nodes); expect(result, JSON.stringify(result)).toMatchObject({ status: 'error', application_status: 'not_started', applied: false, total_edits: 0, files_affected: 0, changes: [], code, }); expect(result.planning_status).not.toBe('ready'); expect(await fs.readFile(path.join(root, file))).toEqual(Buffer.from(source)); } } async function expectSupportedPreviewAndApply( file: string, source: string, symbol: RenameSymbol, nodes: GraphNode[], expected: string, newName = 'Renamed', ) { const preview = await renameSymbol(root, symbol, { new_name: newName, dry_run: true }, nodes); expect(preview, JSON.stringify(preview)).toMatchObject({ status: 'success', applied: false }); expect(preview.total_edits).toBeGreaterThan(0); expect(await fs.readFile(path.join(root, file), 'utf8')).toBe(source); const applied = await renameSymbol(root, symbol, { new_name: newName, dry_run: false }, nodes); expect(applied, JSON.stringify(applied)).toMatchObject({ status: 'success', applied: true, changes: preview.changes, }); expect(await fs.readFile(path.join(root, file), 'utf8')).toBe(expected); } it.each([ [ 'escaped JS reference', 'model.js', 'function target() {}\nt\\u0061rget();\n', 'target', 'Function', 'function Renamed() {}\nRenamed();\n', ], [ 'escaped TS reference', 'model.ts', 'function target() {}\ntar\\u{67}et();\n', 'target', 'Function', 'function Renamed() {}\nRenamed();\n', ], [ 'escaped shadow binder', 'model.ts', 'function target() {}\nfunction other(t\\u0061rget) { target(); }\n', 'target', 'Function', 'function Renamed() {}\nfunction other(t\\u0061rget) { target(); }\n', ], [ 'Python normalized reference', 'model.py', 'def K(): pass\nK()\n', 'K', 'Function', 'def Renamed(): pass\nRenamed()\n', ], [ 'Python normalized shadow binder', 'model.py', 'def K(): pass\ndef other(K):\n K()\n', 'K', 'Function', 'def Renamed(): pass\ndef other(K):\n K()\n', ], ] as const)( 'renames the provider-canonical binding and preserves shadows for %s', async (_name, file, source, name, kind, expected) => { const nodes = await fixture(file, source); nodes[1] = { id: `${kind}:${file}:${name}`, label: kind, properties: { name, filePath: file, startLine: 0, endLine: 0 }, }; await expectSupportedPreviewAndApply( file, source, { uid: nodes[1].id, name, kind, filePath: file, startLine: 1, }, nodes, expected, ); }, ); it('refuses a collision with a provider-canonical escaped declaration', async () => { const file = 'model.ts'; const source = 'function target() {}\nconst Re\\u006eamed = 1;\ntarget();\n'; const nodes = await fixture(file, source); nodes[1]!.properties.startLine = 0; await expectBlockedPreviewAndApply( file, source, { uid: nodes[1]!.id, name: 'target', filePath: file, startLine: 1 }, nodes, 'name_collision', ); }); it('renames a provider-proven verbatim type reference', async () => { const file = 'model.cs'; const source = 'class Target {}\nclass Use { @Target value; }\n'; const nodes = await fixture(file, source); nodes[1] = { id: 'Class:model.cs:Target', label: 'Class', properties: { name: 'Target', filePath: file, startLine: 0, endLine: 0 }, }; await expectSupportedPreviewAndApply( file, source, { uid: nodes[1]!.id, name: 'Target', kind: 'Class', filePath: file, startLine: 1 }, nodes, 'class Renamed {}\nclass Use { @Renamed value; }\n', ); }); it.each([''])( 'blocks JSX component-to-intrinsic renames in preview and apply: %s', async (element) => { const file = 'model.tsx'; const source = `function Target() { return null; }\nconst element = ${element};\n`; const nodes = await fixture(file, source); nodes[1] = { id: `Function:${file}:Target`, label: 'Function', properties: { name: 'Target', filePath: file, startLine: 0, endLine: 0 }, }; await expectBlockedPreviewAndApply( file, source, { uid: nodes[1].id, name: 'Target', kind: 'Function', filePath: file, startLine: 1, }, nodes, 'invalid_name', 'widget', ); }, ); it('preserves JSX component roles in a successful preview and apply', async () => { const file = 'model.tsx'; const source = 'function Target() { return null; }\nconst element = ;\n'; const nodes = await fixture(file, source); nodes[1] = { id: `Function:${file}:Target`, label: 'Function', properties: { name: 'Target', filePath: file, startLine: 0, endLine: 0 }, }; const symbol = { uid: nodes[1].id, name: 'Target', kind: 'Function', filePath: file, startLine: 1, }; const preview = await renameSymbol(root, symbol, { new_name: 'Widget', dry_run: true }, nodes); expect(preview, JSON.stringify(preview)).toMatchObject({ status: 'success', total_edits: 2 }); expect(await fs.readFile(path.join(root, file), 'utf8')).toBe(source); const applied = await renameSymbol(root, symbol, { new_name: 'Widget', dry_run: false }, nodes); expect(applied).toMatchObject({ status: 'success', applied: true, changes: preview.changes }); expect(await fs.readFile(path.join(root, file), 'utf8')).toBe( 'function Widget() { return null; }\nconst element = ;\n', ); }); it.each([ ['private field', ' __secret = 42\n', 'w = Writer()\nprint(w._Writer__secret)\n'], [ 'private method', ' def __secret(self): return 42\n', 'w = Writer()\nprint(w._Writer__secret())\n', ], [ 'private local in a method', ' def read(self):\n __secret = 42\n return __secret\n', 'w = Writer()\n', ], [ 'private tuple slot', ' __slots__ = ("__secret",)\n', 'w = Writer()\nw._Writer__secret = 42\nprint(w._Writer__secret)\n', ], ['private string slot', ' __slots__ = "__secret"\n', 'w = Writer()\n'], ['unknown slot expression', ' __slots__ = make_slots()\n', 'w = Writer()\n'], [ 'loop-bound private slots', ' for __slots__ in [("__secret",)]: pass\n', 'w = Writer()\nw._Writer__secret = 42\nprint(w._Writer__secret)\n', ], [ 'named-expression private slots', ' (__slots__ := ("__secret",))\n', 'w = Writer()\nw._Writer__secret = 42\nprint(w._Writer__secret)\n', ], ])('blocks Python class renames with a mangled %s', async (_name, body, use) => { const file = 'model.py'; const source = `class Writer:\n${body}${use}`; const uid = `Class:${file}:Writer`; const nodes = await fixture(file, source, [ { id: uid, label: 'Class', properties: { name: 'Writer', filePath: file, startLine: 0 }, }, ]); await expectBlockedPreviewAndApply( file, source, { uid, name: 'Writer', kind: 'Class', filePath: file, startLine: 1, }, nodes, 'unsupported_symbol_family', ); }); it.each([ ['shadow property', 'const object = { target: () => "object" };\nwith (object) target();\n'], ['unknown object', 'with (object) target();\n'], ['deferred closure', 'with (object) { function invoke() { return target(); } }\n'], ])('blocks dynamic with bindings in preview and apply: %s', async (_name, use) => { const file = 'model.js'; const source = 'function target() { return "global"; }\n' + use; const nodes = await fixture(file, source); nodes[1]!.properties.startLine = 0; await expectBlockedPreviewAndApply( file, source, { uid: nodes[1]!.id, name: 'target', kind: 'Function', filePath: file, startLine: 1, }, nodes, 'unsupported_occurrence', ); }); it.each([ [ 'calls before and after a write', 'let writer = new Writer();\nwriter.close();\nwriter = new Other();\nwriter.close();\n', ], [ 'write before the call', 'let writer = new Writer();\nwriter = new Other();\nwriter.close();\n', ], [ 'write after the call', 'let writer = new Writer();\nwriter.close();\nwriter = new Other();\n', ], [ 'conditional write', 'let writer = new Writer();\nif (false) { writer = new Other(); }\nwriter.close();\n', ], ['unknown write', 'let writer = new Writer();\nwriter = obtain();\nwriter.close();\n'], [ 'closure write', 'let writer = new Writer();\nfunction change() { writer = new Other(); }\nwriter.close();\n', ], [ 'destructuring write', 'let writer = new Writer();\n[writer] = [new Other()];\nwriter.close();\n', ], ['structural initializer', 'const writer: Writer = new Other();\nwriter.close();\n'], [ 'structural parameter', 'function use(writer: Writer) { writer.close(); }\nuse(new Other());\n', ], [ 'conditional var redeclaration to another class', 'var writer = new Writer();\nif (false) { var writer = new Other(); }\nwriter.close();\n', 'model.js', ], [ 'conditional var redeclaration to the target class', 'var writer = new Other();\nif (false) { var writer = new Writer(); }\nwriter.close();\n', 'model.js', ], [ 'conditional var before the final declaration', 'if (false) { var writer = new Other(); }\nvar writer = new Writer();\nwriter.close();\n', 'model.js', ], [ 'conditional target var before the final declaration', 'if (false) { var writer = new Writer(); }\nvar writer = new Other();\nwriter.close();\n', 'model.js', ], [ 'function-hoisted conditional var redeclaration', 'function use() {\nvar writer = new Writer();\nif (false) { var writer = new Other(); }\nwriter.close();\n}\nuse();\n', 'model.js', ], ])( 'blocks unproven receiver ownership in preview and apply: %s', async (_name, use, file = 'model.ts') => { const source = 'class Writer {\n close() {}\n}\nclass Other {\n close() {}\n}\n' + use; const uid = `Method:${file}:Writer.close#0`; const nodes = await fixture(file, source, [ { id: `Class:${file}:Writer`, label: 'Class', properties: { name: 'Writer', filePath: file, startLine: 0, endLine: 2 }, }, { id: `Class:${file}:Other`, label: 'Class', properties: { name: 'Other', filePath: file, startLine: 3, endLine: 5 }, }, { id: uid, label: 'Method', properties: { name: 'close', filePath: file, startLine: 1, endLine: 1 }, }, { id: `Method:${file}:Other.close#0`, label: 'Method', properties: { name: 'close', filePath: file, startLine: 4, endLine: 4 }, }, ]); await expectBlockedPreviewAndApply( file, source, { uid, name: 'close', kind: 'Method', filePath: file, startLine: 2, }, nodes, 'ambiguous_reference', ); }, ); it.each([ ['constant receiver', 'const writer = new Writer();\nwriter.close();\n'], ['unwritten mutable receiver', 'let writer = new Writer();\nwriter.close();\n'], ['stable var receiver', 'var writer = new Writer();\nwriter.close();\n'], [ 'block let shadow', 'const writer = new Writer();\nif (false) { let writer = obtain(); }\nwriter.close();\n', ], [ 'block const shadow', 'const writer = new Writer();\nif (false) { const writer = obtain(); }\nwriter.close();\n', ], [ 'separate function var', 'var writer = new Writer();\nfunction other() { var writer = obtain(); }\nwriter.close();\n', ], ['matching annotated initializer', 'const writer: Writer = new Writer();\nwriter.close();\n'], [ 'unrelated shadow write', 'const writer = new Writer();\nfunction change(writer) { writer = obtain(); }\nwriter.close();\n', ], ])('preserves constructor-proven member preview and apply: %s', async (_name, use) => { const file = 'model.ts'; const source = 'class Writer {\n close() {}\n}\n' + use; const uid = `Method:${file}:Writer.close#0`; const nodes = await fixture(file, source, [ { id: `Class:${file}:Writer`, label: 'Class', properties: { name: 'Writer', filePath: file, startLine: 0, endLine: 2 }, }, { id: uid, label: 'Method', properties: { name: 'close', filePath: file, startLine: 1, endLine: 1 }, }, ]); const symbol = { uid, name: 'close', kind: 'Method', filePath: file, startLine: 2 }; const preview = await renameSymbol(root, symbol, { new_name: 'finish', dry_run: true }, nodes); expect(preview, JSON.stringify(preview)).toMatchObject({ status: 'success', total_edits: 2 }); expect(preview.changes[0]!.edits.map((edit) => edit.start)).toEqual([ source.indexOf('close'), source.lastIndexOf('close'), ]); expect(await fs.readFile(path.join(root, file), 'utf8')).toBe(source); const applied = await renameSymbol(root, symbol, { new_name: 'finish', dry_run: false }, nodes); expect(applied).toMatchObject({ status: 'success', applied: true, changes: preview.changes }); expect(await fs.readFile(path.join(root, file), 'utf8')).toBe( source.replaceAll('close', 'finish'), ); }); it.each([ [ 'prototype function expression', 'model.js', 'class Writer {\n closeWriter() {}\n}\nWriter.prototype.flush = function () { return this.closeWriter(); };\n', ], [ 'function with a this parameter', 'model.ts', 'class Writer {\n closeWriter() {}\n}\nexport function flush(this: Writer) { return this.closeWriter(); }\n', ], ])('refuses a member call on a call-site-bound this: %s', async (_name, file, source) => { const uid = `Method:${file}:Writer.closeWriter#0`; const nodes = await fixture(file, source, [ { id: `Class:${file}:Writer`, label: 'Class', properties: { name: 'Writer', filePath: file, startLine: 0, endLine: 2 }, }, { id: uid, label: 'Method', properties: { name: 'closeWriter', filePath: file, startLine: 1, endLine: 1 }, }, ]); await expectBlockedPreviewAndApply( file, source, { uid, name: 'closeWriter', kind: 'Method', filePath: file, startLine: 2 }, nodes, 'ambiguous_reference', 'shutdown', ); }); it('preserves provider-proven this receiver ownership', async () => { const file = 'model.ts'; const source = 'class Writer {\n close() {}\n use() { this.close(); }\n}\n'; const uid = `Method:${file}:Writer.close#0`; const nodes = await fixture(file, source, [ { id: `Class:${file}:Writer`, label: 'Class', properties: { name: 'Writer', filePath: file, startLine: 0, endLine: 3 }, }, { id: uid, label: 'Method', properties: { name: 'close', filePath: file, startLine: 1, endLine: 1 }, }, ]); const plan = await planGraphRename( root, { uid, name: 'close', kind: 'Method', filePath: file, startLine: 2, }, { new_name: 'finish' }, nodes, ); expect(plan.edits.map((edit) => edit.start)).toEqual([ source.indexOf('close'), source.lastIndexOf('close'), ]); }); it('preserves Python lexical with scopes', async () => { const file = 'model.py'; const source = 'def target(): pass\nwith open("file") as stream:\n target()\n'; const nodes = await fixture(file, source); nodes[1]!.properties.startLine = 0; const symbol = { uid: nodes[1]!.id, name: 'target', kind: 'Function', filePath: file, startLine: 1, }; const preview = await renameSymbol(root, symbol, { new_name: 'renamed', dry_run: true }, nodes); expect(preview, JSON.stringify(preview)).toMatchObject({ status: 'success', total_edits: 2 }); expect(await fs.readFile(path.join(root, file), 'utf8')).toBe(source); const applied = await renameSymbol( root, symbol, { new_name: 'renamed', dry_run: false }, nodes, ); expect(applied).toMatchObject({ status: 'success', applied: true, changes: preview.changes }); expect(await fs.readFile(path.join(root, file), 'utf8')).toBe( 'def renamed(): pass\nwith open("file") as stream:\n renamed()\n', ); }); it('preserves Python member renames for a single constructor assignment', async () => { const file = 'model.py'; const source = 'class Writer:\n def close(self): pass\nwriter = Writer()\nwriter.close()\n'; const uid = `Method:${file}:Writer.close#0`; const nodes = await fixture(file, source, [ { id: `Class:${file}:Writer`, label: 'Class', properties: { name: 'Writer', filePath: file, startLine: 0, endLine: 1 }, }, { id: uid, label: 'Method', properties: { name: 'close', filePath: file, startLine: 1, endLine: 1 }, }, ]); const plan = await planGraphRename( root, { uid, name: 'close', kind: 'Method', filePath: file, startLine: 2, }, { new_name: 'finish' }, nodes, ); expect(plan.edits.map((edit) => edit.start)).toEqual([ source.indexOf('close'), source.lastIndexOf('close'), ]); }); it('preserves Python class renames with ordinary dunder names', async () => { const file = 'model.py'; const source = 'class Writer:\n def __init__(self): pass\nwriter = Writer()\n'; const uid = `Class:${file}:Writer`; const nodes = await fixture(file, source, [ { id: uid, label: 'Class', properties: { name: 'Writer', filePath: file, startLine: 0, endLine: 1 }, }, ]); const plan = await planGraphRename( root, { uid, name: 'Writer', kind: 'Class', filePath: file, startLine: 1, }, { new_name: 'Renamed' }, nodes, ); expect(plan.edits.map((edit) => edit.start)).toEqual([ source.indexOf('Writer'), source.lastIndexOf('Writer'), ]); }); it.each([ ['nested class slots', ' class Nested:\n __slots__ = ("__secret",)\n'], ['ordinary string', ' label = "__slots__ __secret"\n'], ['method-local slots name', ' def use(self):\n __slots__ = ("__secret",)\n'], ['method parameter slots name', ' def use(self, __slots__): pass\n'], ['method default parameter slots name', ' def use(self, __slots__ = ("__secret",)): pass\n'], ])('preserves Python class preview and apply with an unrelated %s', async (_name, body) => { const file = 'model.py'; const source = `class Writer:\n${body}writer = Writer()\n`; const uid = `Class:${file}:Writer`; const nodes = await fixture(file, source, [ { id: uid, label: 'Class', properties: { name: 'Writer', filePath: file, startLine: 0 }, }, ]); const symbol = { uid, name: 'Writer', kind: 'Class', filePath: file, startLine: 1 }; const preview = await renameSymbol(root, symbol, { new_name: 'Renamed', dry_run: true }, nodes); expect(preview, JSON.stringify(preview)).toMatchObject({ status: 'success', total_edits: 2 }); expect(await fs.readFile(path.join(root, file), 'utf8')).toBe(source); const applied = await renameSymbol( root, symbol, { new_name: 'Renamed', dry_run: false }, nodes, ); expect(applied).toMatchObject({ status: 'success', applied: true, changes: preview.changes }); expect(await fs.readFile(path.join(root, file), 'utf8')).toBe( `class Renamed:\n${body}writer = Renamed()\n`, ); }); it('allows escaped identifier text in strings and comments', async () => { const file = 'model.ts'; const source = 'function target() {}\nconst text = "t\\u0061rget";\n// t\\u0061rget\ntarget();\n'; const nodes = await fixture(file, source); nodes[1]!.properties.startLine = 0; const plan = await planGraphRename( root, { uid: nodes[1]!.id, name: 'target', kind: 'Function', filePath: file, startLine: 1, }, { new_name: 'renamed' }, nodes, ); expect(plan.edits.map((edit) => edit.start)).toEqual([ source.indexOf('target'), source.lastIndexOf('target'), ]); }); it('renames PHP case-insensitive callable names through its provider', async () => { const file = 'model.php'; const source = ' { const uid = `Class:${file}:Writer`; const nodes = await fixture(file!, source!, [ { id: uid, label: 'Class', properties: { name: 'Writer', filePath: file!, startLine: 0 }, }, ]); await expectSupportedPreviewAndApply( file!, source!, { uid, name: 'Writer', kind: 'Class', filePath: file!, startLine: 1, }, nodes, source!.replaceAll('Writer', 'Renamed'), ); }); it.each([ [ 'Python keyword argument', 'model.py', 'def target(): pass\ndef fn(**kwargs): pass\nfn(target=1)\ntarget()\n', ], [ 'JSX intrinsic element', 'model.tsx', 'function target() {}\nconst element = ;\ntarget();\n', ], [ 'JSX attribute', 'model.tsx', 'function target() {}\nconst element = ;\ntarget();\n', ], ])('preserves the unrelated %s in preview and apply', async (_name, file, source) => { const nodes = await fixture(file!, source!); nodes[1]!.properties.startLine = 0; const symbol = { uid: nodes[1]!.id, name: 'target', kind: 'Function', filePath: file!, startLine: 1, }; const preview = await renameSymbol(root, symbol, { new_name: 'Renamed', dry_run: true }, nodes); expect(preview, JSON.stringify(preview)).toMatchObject({ status: 'success', total_edits: 2 }); expect(preview.changes[0]!.edits.map((edit) => edit.start)).toEqual([ source!.indexOf('target'), source!.lastIndexOf('target'), ]); expect(await fs.readFile(path.join(root, file!))).toEqual(Buffer.from(source!)); const applied = await renameSymbol( root, symbol, { new_name: 'Renamed', dry_run: false }, nodes, ); expect(applied).toMatchObject({ status: 'success', applied: true, changes: preview.changes }); const first = source!.indexOf('target'); const last = source!.lastIndexOf('target'); const expected = source!.slice(0, first) + 'Renamed' + source!.slice(first + 6, last) + 'Renamed' + source!.slice(last + 6); expect(await fs.readFile(path.join(root, file!))).toEqual(Buffer.from(expected)); }); it('renames the provider-captured value binding inside a JSX expression', async () => { const file = 'model.tsx'; const source = 'function target() {}\nconst element = ;\n'; const nodes = await fixture(file, source); nodes[1]!.properties.startLine = 0; await expectSupportedPreviewAndApply( file, source, { uid: nodes[1]!.id, name: 'target', kind: 'Function', filePath: file, startLine: 1, }, nodes, 'function Renamed() {}\nconst element = ;\n', ); }); it.each([ ['variable key', 'const key = "close";\nwriter[key]();\n'], ['escaped literal key', 'writer["clo\\u0073e"]();\n'], ['concatenated key', 'writer["cl" + "ose"]();\n'], ['unknown receiver', 'unknown[key]();\n'], ])('blocks preview and apply for an unresolved computed %s', async (_name, call) => { const file = 'model.ts'; const source = 'class Writer {\n close() {}\n}\nconst writer = new Writer();\n' + call; const uid = `Method:${file}:Writer.close#0`; const nodes = await fixture(file, source, [ { id: `Class:${file}:Writer`, label: 'Class', properties: { name: 'Writer', filePath: file, startLine: 0, endLine: 2 }, }, { id: uid, label: 'Method', properties: { name: 'close', filePath: file, startLine: 1, endLine: 1 }, }, ]); await expectBlockedPreviewAndApply( file, source, { uid, name: 'close', kind: 'Method', filePath: file, startLine: 2, }, nodes, 'unsupported_occurrence', ); }); it.each(cases)( 'renames bound declaration and call with the registered provider: %s', async (file, source) => { const nodes = await fixture(file!, source!); const line = file!.endsWith('.go') ? 2 : 1; nodes[1]!.properties.startLine = line - 1; nodes[1]!.properties.endLine = line - 1; const plan = await planGraphRename( root, { uid: nodes[1]!.id, name: 'target', kind: 'Function', filePath: file!, startLine: line, endLine: line, }, { new_name: 'renamed' }, nodes, ); expect(plan.edits).toHaveLength(2); expect(plan.edits.map((e) => source!.slice(e.start, e.start + e.length))).toEqual([ 'target', 'target', ]); expect(plan.coverage).toMatchObject({ name: 'gitnexus-semantic', scope: 'indexed-repository', source_file_count: 1, }); // Files added after the last analyze are skipped; the result must say so. expect(plan.coverage.limitations).toContainEqual( expect.stringContaining('added after the last analyze'), ); expect(await fs.readFile(path.join(root, file!), 'utf8')).toBe(source); }, ); it('refuses a stale graph declaration', async () => { const nodes = await fixture('model.ts', 'function other() {}\n'); await expect( planGraphRename( root, { uid: nodes[1]!.id, name: 'target', kind: 'Function', filePath: 'model.ts', startLine: 1 }, { new_name: 'renamed' }, nodes, ), ).rejects.toMatchObject({ code: 'stale_graph' }); }); it('permits an existing replacement name in an unrelated lexical scope', async () => { const file = 'model.ts'; const source = 'function target() {}\n' + 'function other() { const renamed = 1; return renamed; }\n' + 'target();\n'; const nodes = await fixture(file, source); nodes[1]!.properties.startLine = 0; nodes[1]!.properties.endLine = 0; const symbol = { uid: nodes[1]!.id, name: 'target', filePath: file, startLine: 1 }; const result = await renameSymbol(root, symbol, { new_name: 'renamed', dry_run: false }, nodes); expect(result, JSON.stringify(result)).toMatchObject({ status: 'success', applied: true, total_edits: 2, }); expect(await fs.readFile(path.join(root, file), 'utf8')).toBe( 'function renamed() {}\n' + 'function other() { const renamed = 1; return renamed; }\n' + 'renamed();\n', ); }); it('renames only the member owned by the selected graph class', async () => { const source = 'class Writer {\n close() {}\n}\nclass Other {\n close() {}\n}\nconst writer = new Writer();\nconst other = new Other();\nwriter.close();\nother.close();\nconst unrelated: Other = { close() {} };\n'; const file = 'model.ts'; const nodes = await fixture(file, source, [ { id: `Class:${file}:Writer`, label: 'Class', properties: { name: 'Writer', filePath: file, startLine: 0, endLine: 2 }, }, { id: `Class:${file}:Other`, label: 'Class', properties: { name: 'Other', filePath: file, startLine: 3, endLine: 5 }, }, { id: `Method:${file}:Writer.close#0`, label: 'Method', properties: { name: 'close', filePath: file, startLine: 1, endLine: 1 }, }, { id: `Method:${file}:Other.close#0`, label: 'Method', properties: { name: 'close', filePath: file, startLine: 4, endLine: 4 }, }, ]); const plan = await planGraphRename( root, { uid: `Method:${file}:Writer.close#0`, name: 'close', kind: 'Method', filePath: file, startLine: 2, }, { new_name: 'finish' }, nodes, ); expect(plan.edits.map((edit) => edit.start)).toEqual([ source.indexOf('close'), source.indexOf('writer.close') + 7, ]); }); it('keeps parameter shadows, object keys, comments and strings unchanged while covering interpolation and bare values', async () => { const source = 'function target() {}\nfunction wrapper(target) { return target; }\nconst literal = { target: 1 };\nconst value = target;\nconst text = `target ${target()}`;\n// target\n'; const nodes = await fixture('model.ts', source); nodes[1]!.properties.startLine = 0; const plan = await planGraphRename( root, { uid: nodes[1]!.id, name: 'target', kind: 'Function', filePath: 'model.ts', startLine: 1 }, { new_name: 'renamed' }, nodes, ); expect(plan.edits.map((edit) => edit.start)).toEqual([ source.indexOf('target'), source.indexOf('= target') + 2, source.indexOf('${target') + 2, ]); }); it('updates a named import while preserving an explicit alias and its usages', async () => { const source = 'export function target() {}\n'; const nodes = await fixture('model.ts', source); nodes[1]!.properties.startLine = 0; const consumer = 'import { target as local } from "./model";\nlocal();\n'; await fs.writeFile(path.join(root, 'consumer.ts'), consumer); nodes.push({ id: 'File:consumer.ts', label: 'File', properties: { name: 'consumer.ts', filePath: 'consumer.ts' }, }); const plan = await planGraphRename( root, { uid: nodes[1]!.id, name: 'target', kind: 'Function', filePath: 'model.ts', startLine: 1 }, { new_name: 'renamed' }, nodes, ); expect(plan.edits.map((edit) => [edit.file_path, edit.start])).toEqual([ ['consumer.ts', consumer.indexOf('target')], ['model.ts', source.indexOf('target')], ]); }); it('preserves a shorthand property key while renaming its value binding', async () => { const source = 'function target() {}\nconst object = { target };\n'; const nodes = await fixture('model.ts', source); nodes[1]!.properties.startLine = 0; const symbol = { uid: nodes[1]!.id, name: 'target', filePath: 'model.ts', startLine: 1 }; const result = await renameSymbol(root, symbol, { new_name: 'renamed', dry_run: false }, nodes); expect(result, JSON.stringify(result)).toMatchObject({ status: 'success', applied: true, total_edits: 2, }); expect(await fs.readFile(path.join(root, 'model.ts'), 'utf8')).toBe( 'function renamed() {}\nconst object = { target: renamed };\n', ); }); it('preserves an explicit import alias even when it has the exported spelling', async () => { const source = 'export function target() {}\n'; const nodes = await fixture('model.ts', source); nodes[1]!.properties.startLine = 0; const consumer = 'import { target as target } from "./model";\ntarget();\n'; await fs.writeFile(path.join(root, 'consumer.ts'), consumer); nodes.push({ id: 'File:consumer.ts', label: 'File', properties: { name: 'consumer.ts', filePath: 'consumer.ts' }, }); const symbol = { uid: nodes[1]!.id, name: 'target', filePath: 'model.ts', startLine: 1 }; const result = await renameSymbol(root, symbol, { new_name: 'renamed', dry_run: false }, nodes); expect(result, JSON.stringify(result)).toMatchObject({ status: 'success', applied: true, total_edits: 2, }); expect(await fs.readFile(path.join(root, 'consumer.ts'), 'utf8')).toBe( 'import { renamed as target } from "./model";\ntarget();\n', ); }); it('renames through a provider-approved mixed source workspace', async () => { const source = 'export function target() {}\n'; const nodes = await fixture('model.ts', source); nodes[1]!.properties.startLine = 0; const consumer = 'import { target } from "./model.js";\ntarget();\n'; await fs.writeFile(path.join(root, 'consumer.js'), consumer); nodes.push({ id: 'File:consumer.js', label: 'File', properties: { name: 'consumer.js', filePath: 'consumer.js' }, }); const symbol = { uid: nodes[1]!.id, name: 'target', filePath: 'model.ts', startLine: 1 }; const result = await renameSymbol(root, symbol, { new_name: 'renamed', dry_run: false }, nodes); expect(result, JSON.stringify(result)).toMatchObject({ status: 'success', applied: true, total_edits: 3, }); expect(await fs.readFile(path.join(root, 'consumer.js'), 'utf8')).toBe( 'import { renamed } from "./model.js";\nrenamed();\n', ); }); it('refuses capture of an existing reference that receives no source edit', async () => { const source = 'function target() {}\nfunction caller() { return renamed; }\ntarget();\n'; const nodes = await fixture('model.ts', source); nodes[1]!.properties.startLine = 0; await expectBlockedPreviewAndApply( 'model.ts', source, { uid: nodes[1]!.id, name: 'target', filePath: 'model.ts', startLine: 1 }, nodes, 'name_collision', 'renamed', ); }); it.each([ [ 'export alias', 'function target() {}\nexport { target as publicTarget };\n', 'function renamed() {}\nexport { renamed as publicTarget };\n', ], [ 'unrelated destructuring key', 'function target() {}\nconst { target: other } = object;\n', 'function renamed() {}\nconst { target: other } = object;\n', ], ])('preserves the independent source name in an %s', async (_name, source, expected) => { const nodes = await fixture('model.ts', source!); nodes[1]!.properties.startLine = 0; await expectSupportedPreviewAndApply( 'model.ts', source!, { uid: nodes[1]!.id, name: 'target', filePath: 'model.ts', startLine: 1 }, nodes, expected!, 'renamed', ); }); it('resolves a constructed member through finalized cross-file import bindings', async () => { const file = 'model.ts'; const nodes = await fixture(file, 'export class Writer {\n close() {}\n}\n', [ { id: `Class:${file}:Writer`, label: 'Class', properties: { name: 'Writer', filePath: file, startLine: 0, endLine: 2 }, }, { id: `Method:${file}:Writer.close#0`, label: 'Method', properties: { name: 'close', filePath: file, startLine: 1, endLine: 1 }, }, ]); const consumer = 'import { Writer } from "./model.js";\nconst writer = new Writer();\nwriter.close();\n'; await fs.writeFile(path.join(root, 'consumer.ts'), consumer); nodes.push({ id: 'File:consumer.ts', label: 'File', properties: { name: 'consumer.ts', filePath: 'consumer.ts' }, }); const plan = await planGraphRename( root, { uid: `Method:${file}:Writer.close#0`, name: 'close', kind: 'Method', filePath: file, startLine: 2, }, { new_name: 'finish' }, nodes, ); expect(plan.edits).toHaveLength(2); expect(plan.edits[0]).toMatchObject({ file_path: 'consumer.ts', start: consumer.indexOf('close'), }); }); it.each([ ['unknown receiver', 'function target() {}\nunknown.target();\n', 'ambiguous_reference'], [ 'parameter capture', 'function target() {}\nfunction other(renamed) { target(); }\n', 'name_collision', ], ['keyword replacement', 'function target() {}\ntarget();\n', 'invalid_name'], ['keyword declaration only', 'function target() {}\n', 'invalid_name'], ['computed member', 'function target() {}\nobject["target"]();\n', 'unsupported_occurrence'], ])('refuses %s', async (_name, source, code) => { const nodes = await fixture('model.ts', source); nodes[1]!.properties.startLine = 0; await expect( planGraphRename( root, { uid: nodes[1]!.id, name: 'target', kind: 'Function', filePath: 'model.ts', startLine: 1 }, { new_name: code === 'invalid_name' ? 'return' : 'renamed' }, nodes, ), ).rejects.toMatchObject({ code }); }); it('refuses known graph references across an unsupported language boundary', async () => { const nodes = await fixture('model.ts', 'function target() {}\n'); nodes[1]!.properties.startLine = 0; await expect( planGraphRename( root, { uid: nodes[1]!.id, name: 'target', filePath: 'model.ts', startLine: 1 }, { new_name: 'renamed' }, nodes, ['consumer.js'], ), ).rejects.toMatchObject({ code: 'unsupported_boundary' }); }); it('renames the complete provider-proven override family', async () => { const file = 'model.ts'; const source = 'class Base {\n close() {}\n}\nclass Derived extends Base {\n close() {}\n}\n'; const nodes = await fixture(file, source, [ { id: `Class:${file}:Base`, label: 'Class', properties: { name: 'Base', filePath: file, startLine: 0, endLine: 2 }, }, { id: `Class:${file}:Derived`, label: 'Class', properties: { name: 'Derived', filePath: file, startLine: 3, endLine: 5 }, }, { id: `Method:${file}:Base.close#0`, label: 'Method', properties: { name: 'close', filePath: file, startLine: 1, endLine: 1 }, }, { id: `Method:${file}:Derived.close#0`, label: 'Method', properties: { name: 'close', filePath: file, startLine: 4, endLine: 4 }, }, ]); await expectSupportedPreviewAndApply( file, source, { uid: `Method:${file}:Base.close#0`, name: 'close', kind: 'Method', filePath: file, startLine: 2, }, nodes, 'class Base {\n finish() {}\n}\nclass Derived extends Base {\n finish() {}\n}\n', 'finish', ); }); it('refuses a new inherited family collision even without call sites', async () => { const file = 'model.ts'; const source = 'class Base {\n finish() {}\n}\nclass Derived extends Base {\n close() {}\n}\n'; const nodes = await fixture(file, source, [ { id: `Class:${file}:Base`, label: 'Class', properties: { name: 'Base', filePath: file, startLine: 0, endLine: 2 }, }, { id: `Class:${file}:Derived`, label: 'Class', properties: { name: 'Derived', filePath: file, startLine: 3, endLine: 5 }, }, { id: `Method:${file}:Base.finish#0`, label: 'Method', properties: { name: 'finish', filePath: file, startLine: 1, endLine: 1 }, }, { id: `Method:${file}:Derived.close#0`, label: 'Method', properties: { name: 'close', filePath: file, startLine: 4, endLine: 4 }, }, ]); await expectBlockedPreviewAndApply( file, source, { uid: `Method:${file}:Derived.close#0`, name: 'close', kind: 'Method', filePath: file, startLine: 5, }, nodes, 'name_collision', 'finish', ); }); it('preserves a same-spelled import alias bound to a different symbol', async () => { const file = 'model.ts'; const nodes = await fixture(file, 'export function target() {}\nexport function other() {}\n', [ { id: `Function:${file}:other`, label: 'Function', properties: { name: 'other', filePath: file, startLine: 1, endLine: 1 }, }, ]); nodes[1]!.properties.startLine = 0; const consumer = 'import { other as target } from "./model";\ntarget();\n'; await fs.writeFile(path.join(root, 'consumer.ts'), consumer); nodes.push({ id: 'File:consumer.ts', label: 'File', properties: { name: 'consumer.ts', filePath: 'consumer.ts' }, }); const plan = await planGraphRename( root, { uid: nodes[1]!.id, name: 'target', filePath: file, startLine: 1 }, { new_name: 'renamed' }, nodes, ); expect(plan.edits).toHaveLength(1); expect(plan.edits[0]!.file_path).toBe(file); }); it('keeps exact UTF-16 offsets after Unicode source', async () => { const file = 'model.ts'; const source = 'const message = "😀"; function target() {}\ntarget();\n'; const nodes = await fixture(file, source); nodes[1]!.properties.startLine = 0; const plan = await planGraphRename( root, { uid: nodes[1]!.id, name: 'target', filePath: file, startLine: 1 }, { new_name: 'renamed' }, nodes, ); expect(plan.edits.map((edit) => edit.start)).toEqual([ source.indexOf('target'), source.lastIndexOf('target'), ]); }); it.each([{ kind: 'Method' }, { uid: 'Function:model.ts:unrelated' }, { endLine: 2 }])( 'refuses mismatched graph evidence %j', async (override) => { const nodes = await fixture('model.ts', 'function target() {}\ntarget();\n'); nodes[1]!.properties.startLine = 0; await expect( planGraphRename( root, { uid: nodes[1]!.id, name: 'target', kind: 'Function', filePath: 'model.ts', startLine: 1, ...override, }, { new_name: 'renamed' }, nodes, ), ).rejects.toMatchObject({ code: 'stale_graph' }); }, ); it('refuses stale ownership even when graph name and source position still match', async () => { const file = 'model.ts'; const nodes = await fixture(file, 'class Other {\n close() {}\n}\n', [ { id: `Class:${file}:Writer`, label: 'Class', properties: { name: 'Writer', filePath: file, startLine: 0, endLine: 2 }, }, { id: `Method:${file}:Writer.close#0`, label: 'Method', properties: { name: 'close', filePath: file, startLine: 1, endLine: 1 }, }, ]); await expect( planGraphRename( root, { uid: `Method:${file}:Writer.close#0`, name: 'close', kind: 'Method', filePath: file, startLine: 2, }, { new_name: 'finish' }, nodes, ), ).rejects.toMatchObject({ code: 'stale_graph' }); }); });