GitNexus/gitnexus/test/unit/graph-rename.test.ts

1362 lines
45 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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(['<Target />'])(
'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 = <Target />;\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 = <Widget />;\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 = '<?php\nfunction target() {}\nTARGET();\n';
const nodes = await fixture(file, source);
await expectSupportedPreviewAndApply(
file,
source,
{
uid: nodes[1]!.id,
name: 'target',
kind: 'Function',
filePath: file,
startLine: 2,
},
nodes,
'<?php\nfunction Renamed() {}\nRenamed();\n',
);
});
it.each([
[
'Java constructor',
'model.java',
'class Writer {\n Writer() {}\n static void use() { new Writer(); }\n}\n',
],
[
'C# constructor',
'model.cs',
'class Writer {\n public Writer() {}\n static void Use() { new Writer(); }\n}\n',
],
[
'C++ constructor',
'model.cpp',
'class Writer {\n public: Writer() {}\n};\nvoid use() { Writer x; }\n',
],
['C# destructor', 'model.cs', 'class Writer {\n ~Writer() {}\n}\n'],
['C++ destructor', 'model.cpp', 'class Writer {\n public: ~Writer() {}\n};\n'],
])('renames a class with a name-coupled %s', async (_name, file, 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 = <target></target>;\ntarget();\n',
],
[
'JSX attribute',
'model.tsx',
'function target() {}\nconst element = <a target="_blank" />;\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 = <a title={target} />;\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 = <a title={Renamed} />;\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' });
});
});