import { afterEach, describe, expect, it } from 'vitest'; import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js'; import { renameSymbol } from '../../src/mcp/local/rename/rename-plan.js'; import { SCOPE_RESOLVERS } from '../../src/core/ingestion/scope-resolution/pipeline/registry.js'; import { SupportedLanguages } from 'gitnexus-shared'; import { phpRenameProvider } from '../../src/core/ingestion/languages/php/rename.js'; const roots: string[] = []; afterEach(async () => { await Promise.all(roots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true }))); }); async function fixture(files: Record, name: string, kind: string) { const root = await fs.mkdtemp(path.join(os.tmpdir(), 'rename-php-ruby-')); roots.push(root); await Promise.all( Object.entries(files).map(async ([file, content]) => { await fs.mkdir(path.dirname(path.join(root, file)), { recursive: true }); await fs.writeFile(path.join(root, file), content); }), ); const result = await runPipelineFromRepo(root, () => undefined, { workerPoolSize: 1, skipGraphPhases: true, }); const nodes = [...result.graph.iterNodes()]; const target = nodes.find((node) => node.properties.name === name && node.label === kind); expect(target, JSON.stringify(nodes.filter((node) => node.label !== 'File'))).toBeDefined(); return { root, nodes, symbol: { uid: target!.id, name: target!.properties.name, filePath: target!.properties.filePath, startLine: target!.properties.startLine! + 1, endLine: target!.properties.endLine! + 1, kind: target!.label, }, }; } async function expectRename( files: Record, expected: Record, name: string, kind: string, newName: string, ) { const { root, nodes, symbol } = await fixture(files, name, kind); const preview = await renameSymbol(root, symbol, { new_name: newName, dry_run: true }, nodes); expect(preview, JSON.stringify(preview)).toMatchObject({ status: 'success', applied: false }); for (const [file, content] of Object.entries(files)) expect(await fs.readFile(path.join(root, file), 'utf8')).toBe(content); 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, }); for (const [file, content] of Object.entries(expected)) expect(await fs.readFile(path.join(root, file), 'utf8')).toBe(content); } describe('PHP and Ruby rename through the production parsing pipeline', () => { it.each([ [ 'model.php', ' { await expectRename( { [file]: content }, { [file]: content .replace('function target()', 'function renamed()') .replace('def target', 'def renamed') .replace('\ntarget()', '\nrenamed()'), }, 'target', kind, 'renamed', ); }, 60000, ); it('refuses PHP class imports that require an autoload filename change', async () => { const files = { 'composer.json': '{"autoload":{"psr-4":{"Source\\\\":"Source/"}}}', 'Source/Target.php': ' { const files = { 'composer.json': '{"autoload":{"psr-4":{"Source\\\\":"Source/"}}}', 'Source/first.php': ` { const content = ' { await expectRename( { [file]: content }, { [file]: content .replace('target()', 'renamed()') .replace('target();', 'renamed();') .replace('def target', 'def renamed') .replace('\ntarget()', '\nrenamed()'), }, 'target', kind, 'renamed', ); }, 60000, ); it.each([ [ 'model.php', ' { await expectRename( { [file]: content }, { [file]: content.replaceAll('Target', 'Renamed') }, name, kind, 'Renamed', ); }, 60000, ); it.each([ [ 'model.php', 'target(); } }\n$value = new Worker();\n$value->target();\n', ], [ 'model.rb', 'class Worker\n def target\n end\n def call\n self.target()\n end\nend\nvalue = Worker.new\nvalue.target()\n', ], ])( 'renames members on this/self and constructed receivers in %s', async (file, content) => { await expectRename( { [file]: content }, { [file]: content.replaceAll('target', 'renamed') }, 'target', 'Method', 'renamed', ); }, 60000, ); it('keeps PHP explicit function aliases stable', async () => { const files = { 'composer.json': '{"autoload":{"psr-4":{"Source\\\\":"Source/"}}}', 'Source/functions.php': ' { const files = { 'source.rb': 'class Target\nend\n', 'caller.rb': 'require_relative "source"\nvalue = Target.new\n', }; await expectRename( files, Object.fromEntries( Object.entries(files).map(([file, content]) => [ file, content.replaceAll('Target', 'Renamed'), ]), ), 'Target', 'Class', 'Renamed', ); }, 60000); it('uses PHP case-insensitive callable names without folding variables', async () => { const content = ' { const content = 'TARGET(); $this->target; $this->Target; } }\n'; await expectRename( { 'model.php': content }, { 'model.php': content .replace('function Target()', 'function renamed()') .replace('->TARGET()', '->renamed()'), }, 'Target', 'Method', 'renamed', ); }, 60000); it('keeps PHP properties case-sensitive', async () => { const content = 'target = 1; return $this->Target + $this->target; } }\n'; await expectRename( { 'model.php': content }, { 'model.php': content.replaceAll('$target', '$renamed').replaceAll('->target', '->renamed'), }, 'target', 'Property', 'renamed', ); }, 60000); it('keeps grouped PHP aliases stable', async () => { const files = { 'composer.json': '{"autoload":{"psr-4":{"Source\\\\":"Source/"}}}', 'Source/functions.php': ' [ file, content.replace('target', 'renamed'), ]), ), 'target', 'Function', 'renamed', ); }, 60000); it.each([ ['target?', 'renamed!'], ['target!', 'renamed?'], ])( 'renames Ruby punctuation method %s', async (name, newName) => { const content = `class Worker\n def ${name}\n end\nend\nvalue = Worker.new\nvalue.${name}()\n`; await expectRename( { 'model.rb': content }, { 'model.rb': content.replaceAll(name, newName) }, name, 'Method', newName, ); }, 60000, ); it('preserves Ruby block parameter shadows and unrelated indexed reads', async () => { const content = 'def target\nend\n[1].each { |target| puts target }\ntarget()\nvalues = [1]\nvalues[0]\n'; await expectRename( { 'model.rb': content }, { 'model.rb': content.replace('def target', 'def renamed').replace('target()', 'renamed()') }, 'target', 'Method', 'renamed', ); }, 60000); it.each([ [ 'model.php', 'target(); }\n', ], [ 'model.rb', 'module Contract\n def target\n end\nend\nclass Worker\n include Contract\n def target\n end\nend\nvalue = Worker.new\nvalue.target()\n', ], ])( 'renames graph-proven method families in %s', async (file, content) => { await expectRename( { [file]: content }, { [file]: content.replaceAll('target', 'renamed') }, 'target', 'Method', 'renamed', ); }, 60000, ); it.each([ ['model.php', ' { const { root, nodes, symbol } = await fixture({ [file]: content }, 'target', kind); const result = await renameSymbol( root, symbol, { new_name: 'renamed', dry_run: false }, nodes, ); expect(result).toMatchObject({ status: 'error', applied: false, code: 'name_collision' }); expect(await fs.readFile(path.join(root, file), 'utf8')).toBe(content); }, 60000, ); it('preserves Ruby relative and load-path import distinctions', async () => { const resolver = SCOPE_RESOLVERS.get(SupportedLanguages.Ruby)!; const source = await resolver.languageProvider.rename!.parseSource({ filePath: 'caller.rb', content: 'require_relative "source"\nrequire "source"\n', languageProvider: resolver.languageProvider, scopeResolver: resolver, }); expect(source.parsed.parsedImports.map((entry) => entry.targetRaw)).toEqual([ './source', 'source', ]); }); it('renames PHP class spellings case-insensitively', async () => { const content = ' { const content = 'module Outer\n class Target\n end\nend\nmodule Other\n class Target\n end\nend\nfirst = Outer::Target.new\nsecond = Other::Target.new\n'; await expectRename( { 'model.rb': content }, { 'model.rb': content .replace('class Target', 'class Renamed') .replace('Outer::Target', 'Outer::Renamed'), }, 'Target', 'Class', 'Renamed', ); }, 60000); it('renames Ruby setters through assignment syntax', async () => { const content = 'class Worker\n def target=(value)\n end\nend\nworker = Worker.new\nworker.target = 1\n'; await expectRename( { 'model.rb': content }, { 'model.rb': content.replaceAll('target', 'renamed') }, 'target=', 'Method', 'renamed=', ); }, 60000); it('distinguishes Ruby singleton and instance methods', async () => { const content = 'class Worker\n def target\n end\n def self.target\n end\nend\nworker = Worker.new\nworker.target()\nWorker.target()\n'; await expectRename( { 'model.rb': content }, { 'model.rb': content .replace('def target', 'def renamed') .replace('worker.target()', 'worker.renamed()'), }, 'target', 'Method', 'renamed', ); }, 60000); it('renames Ruby extended module methods through singleton receivers', async () => { const content = 'module Contract\n def target\n end\nend\nclass Worker\n extend Contract\nend\nWorker.target()\n'; await expectRename( { 'model.rb': content }, { 'model.rb': content.replaceAll('target', 'renamed') }, 'target', 'Method', 'renamed', ); }, 60000); it.each([ [ 'model.php', '$name();\n', ], ['model.rb', 'class Worker\n def target\n end\nend\nvalue = Worker.new\nvalue.send(:target)\n'], ])( 'refuses dynamic member references to the selected owner in %s', async (file, content) => { const { root, nodes, symbol } = await fixture({ [file]: content }, 'target', 'Method'); const result = await renameSymbol( root, symbol, { new_name: 'renamed', dry_run: false }, nodes, ); expect(result).toMatchObject({ status: 'error', applied: false, code: 'unsupported_occurrence', }); expect(await fs.readFile(path.join(root, file), 'utf8')).toBe(content); }, 60000, ); it.each([ ['model.php', ' { const { root, nodes, symbol } = await fixture({ [file]: content }, 'target', kind); const result = await renameSymbol( root, symbol, { new_name: 'renamed', dry_run: false }, nodes, ); expect(result).toMatchObject({ status: 'error', applied: false, code: 'unsupported_occurrence', }); expect(await fs.readFile(path.join(root, file), 'utf8')).toBe(content); }, 60000, ); it.each([ [ 'model.php', 'target();\n$other = new Other();\n$name = "target";\n$other->$name();\n', ], [ 'model.rb', 'class Worker\n def target\n end\nend\nclass Other\n def target\n end\nend\nvalue = Worker.new\nvalue.target()\nother = Other.new\nother.send(:target)\n', ], ])( 'allows dynamic references proven to belong to unrelated owners in %s', async (file, content) => { await expectRename( { [file]: content }, { [file]: content .replace('function target()', 'function renamed()') .replace('def target', 'def renamed') .replace('value->target()', 'value->renamed()') .replace('value.target()', 'value.renamed()'), }, 'target', 'Method', 'renamed', ); }, 60000, ); }); describe('PHP replacement identifier validation', () => { const validateName = phpRenameProvider.validateName; if (!validateName) throw new Error('Missing PHP identifier validator'); // PHP labels accept every non-ASCII code point, so the guard targets only names that // render invisibly or reorder neighbouring source. Names are written with escapes so // this file carries no hidden characters itself. it.each([ ['a right-to-left override', 'user\u202Eadmin'], ['a left-to-right embedding', 'user\u202Aadmin'], ['a bidirectional isolate', 'user\u2066admin'], ['a left-to-right mark', 'user\u200Eadmin'], ['a zero-width space', 'a\u200Bb'], ['a zero-width non-joiner', 'a\u200Cb'], ['a zero-width joiner', 'a\u200Db'], ['a word joiner', 'a\u2060b'], ['a leading byte order mark', '\uFEFFname'], ['a trailing byte order mark', 'name\uFEFF'], ['a C1 control character', 'a\u0085b'], ['a no-break space', 'a\u00A0b'], ['a line separator', 'a\u2028b'], ['a paragraph separator', 'a\u2029b'], ['a lone surrogate', 'a\uD800'], ['a private-use code point', 'a\uE000b'], ['a noncharacter', 'a\uFFFEb'], ])('rejects %s in a replacement name', (_label, name) => { expect(validateName(name)).toMatchObject({ code: 'invalid_name' }); }); it.each([ ['a Latin letter with a diacritic', '\u00E9'], ['CJK letters', '\u65E5\u672C\u8A9E'], ['a leading underscore with a digit', '_x1'], ['a decomposed accent', 'e\u0301'], ['an astral letter', '\u{1D4B3}name'], ['mixed ASCII and non-ASCII letters', 'caf\u00E9_\u540D\u524D2'], ])('accepts %s in a replacement name', (_label, name) => { expect(validateName(name)).toBeUndefined(); }); it.each([ ['a reserved word', 'class'], ['a reserved word in another case', 'CLASS'], ['a leading digit', '1name'], ['an empty name', ''], ['a hyphen', 'a-b'], ['a leading dollar sign', '$name'], ])('keeps rejecting %s in a replacement name', (_label, name) => { expect(validateName(name)).toMatchObject({ code: 'invalid_name' }); }); it('refuses a bidirectional override before preview or apply', async () => { const content = '