fix(rename): resolve occurrences with graph and semantic data

This commit is contained in:
Gergő Magyar 2026-10-10 22:16:09 +03:00
parent b6dcf3e338
commit 69ed9cc39a
31 changed files with 1562 additions and 881 deletions

View file

@ -56,7 +56,7 @@ checkout and reports nothing changed, which reads as a verified refactor.
- [ ] list_repos {} — bind repo; explicit repo when >1 indexed, ask if ambiguous
- [ ] rename_preview({symbol_name: "oldName", new_name: "newName"}) — preview semantic edits
- [ ] Confirm the previewed file paths are in the bound repository/worktree
- [ ] Review exact semantic spans and coverage.tsconfig_path; select tsconfig_path if ambiguous
- [ ] Review exact graph-backed semantic spans, coverage, and limitations
- [ ] If satisfied: rename({..., dry_run: false}) — apply edits
- [ ] detect_changes() — verify only expected files changed
- [ ] Run tests for affected processes
@ -94,23 +94,28 @@ checkout and reports nothing changed, which reads as a verified refactor.
```
rename_preview({symbol_name: "validateUser", new_name: "authenticateUser", repo: "my-app"})
→ 12 edits across 8 files
→ semantic_edits: 12, coverage: {scope: "project", tsconfig_path: "tsconfig.json"}
→ semantic_edits: 12, coverage: {name: "gitnexus-semantic", scope: "indexed-repository", limitations: [...]}
→ changes: [{file_path, edits: [{start, length, line, old_text, new_text, confidence: "semantic"}]}]
```
Only configured TypeScript/JavaScript projects are supported. If multiple configs
include the declaration, pass a returned `tsconfig_path` candidate. Coverage is
limited to that project; project references and unsupported languages fail closed.
There is no regex fallback: `graph_edits` and `text_search_edits` are zero.
Rename uses the indexed graph and GitNexus's own semantic layer. Language
providers resolve current source through scopes and bindings; no compiler or
language service is loaded. Unsupported syntax, ambiguous bindings, stale
declarations, and incomplete occurrence coverage fail closed. Coverage describes
indexed files in the target language, excluding unindexed files and external consumers.
Unsupported constructor/inheritance families, unclassified token roles, and
computed references are refused. Any detected computed access in the analyzed
language scope currently blocks rename, including accesses outside edited files.
PHP rename is unsupported until providers cover case-insensitive symbol names.
There is no regex fallback: legacy `graph_edits` and `text_search_edits` are zero.
Comments, strings, and unrelated same-spelled symbols are not replacements.
`rename` remains available with `dry_run: true` (default) or explicit
`dry_run: false`. Both use the same planner. Results use `result_version: 2`;
`total_edits` counts occurrences, not matching lines. Import/export alias affixes
are part of `new_text`. Separate preview and apply calls recompute the plan.
`total_edits` counts occurrences, not matching lines. Exact replacements are shown in `new_text`. Separate preview and apply calls recompute the plan.
Check `planning_status`, `application_status`, and `applied`. After a write
failure, `changes` and counts describe only landed edits; inspect `failed_files`
before retrying. New-name collisions still require compiler and test verification.
before retrying. Run the project validation suite after applying.
Neither tool accepts `branch`: they inspect the current checkout.
**impact** — map all dependents first:

View file

@ -51,9 +51,7 @@ jobs:
- uses: ./.github/actions/setup-gitnexus
with:
lifecycle-scripts: 'false'
# The runtime language-service alias also publishes a tsc bin. Select the
# build compiler explicitly so npm's bin-link order cannot change CI.
- run: node node_modules/typescript/lib/tsc.js --noEmit
- run: npx tsc --noEmit
working-directory: gitnexus
- name: Typecheck tests
run: npm run typecheck:tests

View file

@ -20,7 +20,7 @@ fi
if [ -n "$CLI_CHANGED" ]; then
echo "pre-commit: typechecking gitnexus..."
cd "$ROOT/gitnexus" && node node_modules/typescript/lib/tsc.js --noEmit || exit 1
cd "$ROOT/gitnexus" && ./node_modules/.bin/tsc --noEmit || exit 1
fi
echo "pre-commit: all checks passed"

View file

@ -134,7 +134,7 @@ This project is indexed by GitNexus as **GitNexus** (248612 symbols, 565510 rela
- NEVER edit a function, class, or method before MCP/CLI impact analysis.
- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis, and never read `UNKNOWN` as an all-clear — it means the walk could not answer, which is the one verdict that requires confirming by other means.
- NEVER rename symbols with find-and-replace — use `rename` for configured TS/JS projects after reviewing `rename_preview` semantic spans and project coverage.
- NEVER rename symbols with find-and-replace — use `rename` after reviewing `rename_preview` graph-backed semantic spans and coverage.
- NEVER commit before MCP/CLI graph change analysis.
## Resources

View file

@ -81,7 +81,7 @@ This project is indexed by GitNexus as **GitNexus** (248612 symbols, 565510 rela
- NEVER edit a function, class, or method before MCP/CLI impact analysis.
- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis, and never read `UNKNOWN` as an all-clear — it means the walk could not answer, which is the one verdict that requires confirming by other means.
- NEVER rename symbols with find-and-replace — use `rename` for configured TS/JS projects after reviewing `rename_preview` semantic spans and project coverage.
- NEVER rename symbols with find-and-replace — use `rename` after reviewing `rename_preview` graph-backed semantic spans and coverage.
- NEVER commit before MCP/CLI graph change analysis.
## Resources

View file

@ -75,7 +75,7 @@ Commits within a PR may use any style — only the **merged PR title** shows up
## Before you open a PR
- [ ] Tests pass for the packages you touched (`gitnexus` and/or `gitnexus-web`).
- [ ] Typecheck passes: `node node_modules/typescript/lib/tsc.js --noEmit` in `gitnexus/` and `npx tsc -b --noEmit` in `gitnexus-web/`. Those commands use TypeScript 7; the explicit CLI package path avoids npm bin-link collisions with the pinned rename language-service alias. The web app tsconfig lists `lib` `DOM`/`DOM.Iterable` and `jsx: react-jsx` so React/JSX typecheck on 7; Vite/`vitest` keep `@vitejs/plugin-react` with the automatic JSX runtime. Build `gitnexus-shared/dist` first with a parent `lib/tsc.js` (a `gitnexus` install/`npm run build` does this). Repo ESLint stays syntax-only on a TypeScript 5.x peer until typescript-eslint supports 7.
- [ ] Typecheck passes: `npx tsc --noEmit` in `gitnexus/` and `npx tsc -b --noEmit` in `gitnexus-web/`. Those commands use TypeScript 7. The web app tsconfig lists `lib` `DOM`/`DOM.Iterable` and `jsx: react-jsx` so React/JSX typecheck on 7; Vite/`vitest` keep `@vitejs/plugin-react` with the automatic JSX runtime. Build `gitnexus-shared/dist` first with a parent `lib/tsc.js` (a `gitnexus` install/`npm run build` does this). Repo ESLint stays syntax-only on a TypeScript 5.x peer until typescript-eslint supports 7.
- [ ] No secrets, tokens, or machine-specific paths committed.
- [ ] Documentation updated if behavior or public CLI/MCP contract changes.
- [ ] Every new `GITNEXUS_*` environment variable has a row in the **Environment variables** table in [README.md](README.md) — variable, default, effect, and when to tune it.

View file

@ -16,7 +16,7 @@ Maintainer may widen scope per task.
## Non-negotiables
1. **Never commit secrets** — API keys, tokens, real `.env` values, private URLs, session cookies. Use `.env.example` with placeholders.
2. **Never rename with find-and-replace** in GitNexus-indexed projects — use `rename_preview` first for supported configured TS/JS projects, review exact semantic spans and project coverage, then apply with `rename` and `dry_run: false`. Unsupported targets fail closed; inspect and refactor them manually with language-aware tools. No separate `gitnexus rename` CLI exists.
2. **Never rename with find-and-replace** in GitNexus-indexed projects — use `rename_preview` first, review exact graph-backed semantic spans and coverage, then apply with `rename` and `dry_run: false`. Unsupported targets fail closed; inspect and refactor them manually with language-aware tools. No separate `gitnexus rename` CLI exists.
3. **Run impact analysis before editing shared symbols** — `impact` (upstream) for functions/classes/methods others call. Do not ignore HIGH/CRITICAL without maintainer sign-off.
4. **Run `detect_changes` before commit** — confirm diffs map to expected symbols/processes when the graph is available.
5. **Preserve embeddings** — plain `npx gitnexus analyze` now preserves any embeddings recorded in the index metadata (`.gitnexus/gitnexus.json`, mirrored to the legacy `meta.json`) — the previous behavior wiped them. Use `--embeddings` to also generate vectors for new/changed nodes; use `--drop-embeddings` only when an explicit wipe is intended (e.g., model swap).

View file

@ -169,7 +169,7 @@ flowchart TB
| `trace` | Shortest directed path between two symbols (call + class-member edges) |
| `detect_changes` | Git-diff impact — maps changed lines to affected processes |
| `check` | Read-only structural checks against the indexed graph |
| `rename_preview` | Read-only semantic rename preview for a configured TS/JS project |
| `rename_preview` | Read-only graph and semantic rename preview |
| `rename` | Preview or apply exact semantic rename occurrences |
| `cypher` | Raw Cypher graph queries |
| `route_map` | API route map — which components fetch which endpoints, and handlers |
@ -875,11 +875,11 @@ changed_symbols: [validateUser, AuthService, ...]
affected_processes: [LoginFlow, RegistrationFlow, ...]
```
### Rename (Configured TypeScript/JavaScript Project)
### Rename (Graph and Semantic Layer)
```js
rename_preview({symbol_name: "validateUser", new_name: "verifyUser", repo: "my-app"})
// If project selection is ambiguous, repeat with a returned tsconfig_path.
// For an ambiguous name, repeat with the symbol_uid from context().
```
```text
@ -894,21 +894,29 @@ semantic_edits: 8
graph_edits: 0
text_search_edits: 0
text_search: not_used
coverage: {name: "typescript", version: "5.9.3", scope: "project", tsconfig_path: "tsconfig.json", ...}
coverage: {name: "gitnexus-semantic", scope: "indexed-repository", languages: [...], limitations: [...], ...}
changes: [{file_path, edits: [{start, length, old_text, new_text, line, confidence: "semantic"}]}]
```
Review the exact UTF-16 spans and selected project, then call `rename` with the
same selectors and `dry_run: false` to apply. `rename` still defaults to preview.
Counts describe occurrences, including multiple edits on one line. Import/export
aliases include compiler-provided prefix/suffix text in `new_text`. Comments,
strings, and unrelated same-spelled symbols are not text-search replacements.
Review the exact UTF-16 spans and coverage, then call `rename` with the same
selectors and `dry_run: false` to apply. `rename` still defaults to preview.
Counts describe occurrences, including multiple edits on one line. The graph
selects indexed declarations and files; GitNexus's language providers rebuild
semantic scopes from the current source and resolve exact name spans.
Coverage is limited to one configured project, not the whole repository. Select
`tsconfig_path` explicitly when several configs contain the declaration. Unsupported
languages, project references, stale declarations, and unresolved targets fail
closed without regex fallback. Both tools operate on the checkout and reject
`branch`. The runtime compiler is pinned independently of the build compiler.
Rename does not load a compiler or language service. Comments, strings, and
unrelated same-spelled symbols are preserved. Unsupported syntax, ambiguous
bindings, stale declarations, and incomplete occurrence coverage block writes.
Coverage includes indexed files in the target language, excluding unindexed
files and external consumers. Constructor, destructor, inheritance, and interface
families require complete provider evidence; unsupported families, unclassified
token roles, and computed references are refused. Any detected computed access
in the analyzed language scope currently blocks rename until providers can prove
its key and receiver are unrelated. Both tools operate on the current checkout
and reject `branch`.
PHP rename is currently unsupported because exact-spelling occurrence checks do
not cover its case-insensitive symbol names.
Preview and apply recompute their plans independently. Source snapshots are
checked before writes; this is not a cross-call preview token or transactional

View file

@ -56,7 +56,7 @@ checkout and reports nothing changed, which reads as a verified refactor.
- [ ] list_repos {} — bind repo; explicit repo when >1 indexed, ask if ambiguous
- [ ] rename_preview({symbol_name: "oldName", new_name: "newName"}) — preview semantic edits
- [ ] Confirm the previewed file paths are in the bound repository/worktree
- [ ] Review exact semantic spans and coverage.tsconfig_path; select tsconfig_path if ambiguous
- [ ] Review exact graph-backed semantic spans, coverage, and limitations
- [ ] If satisfied: rename({..., dry_run: false}) — apply edits
- [ ] detect_changes() — verify only expected files changed
- [ ] Run tests for affected processes
@ -94,23 +94,28 @@ checkout and reports nothing changed, which reads as a verified refactor.
```
rename_preview({symbol_name: "validateUser", new_name: "authenticateUser", repo: "my-app"})
→ 12 edits across 8 files
→ semantic_edits: 12, coverage: {scope: "project", tsconfig_path: "tsconfig.json"}
→ semantic_edits: 12, coverage: {name: "gitnexus-semantic", scope: "indexed-repository", limitations: [...]}
→ changes: [{file_path, edits: [{start, length, line, old_text, new_text, confidence: "semantic"}]}]
```
Only configured TypeScript/JavaScript projects are supported. If multiple configs
include the declaration, pass a returned `tsconfig_path` candidate. Coverage is
limited to that project; project references and unsupported languages fail closed.
There is no regex fallback: `graph_edits` and `text_search_edits` are zero.
Rename uses the indexed graph and GitNexus's own semantic layer. Language
providers resolve current source through scopes and bindings; no compiler or
language service is loaded. Unsupported syntax, ambiguous bindings, stale
declarations, and incomplete occurrence coverage fail closed. Coverage describes
indexed files in the target language, excluding unindexed files and external consumers.
Unsupported constructor/inheritance families, unclassified token roles, and
computed references are refused. Any detected computed access in the analyzed
language scope currently blocks rename, including accesses outside edited files.
PHP rename is unsupported until providers cover case-insensitive symbol names.
There is no regex fallback: legacy `graph_edits` and `text_search_edits` are zero.
Comments, strings, and unrelated same-spelled symbols are not replacements.
`rename` remains available with `dry_run: true` (default) or explicit
`dry_run: false`. Both use the same planner. Results use `result_version: 2`;
`total_edits` counts occurrences, not matching lines. Import/export alias affixes
are part of `new_text`. Separate preview and apply calls recompute the plan.
`total_edits` counts occurrences, not matching lines. Exact replacements are shown in `new_text`. Separate preview and apply calls recompute the plan.
Check `planning_status`, `application_status`, and `applied`. After a write
failure, `changes` and counts describe only landed edits; inspect `failed_files`
before retrying. New-name collisions still require compiler and test verification.
before retrying. Run the project validation suite after applying.
Neither tool accepts `branch`: they inspect the current checkout.
**impact** — map all dependents first:

View file

@ -56,7 +56,7 @@ checkout and reports nothing changed, which reads as a verified refactor.
- [ ] list_repos {} — bind repo; explicit repo when >1 indexed, ask if ambiguous
- [ ] rename_preview({symbol_name: "oldName", new_name: "newName"}) — preview semantic edits
- [ ] Confirm the previewed file paths are in the bound repository/worktree
- [ ] Review exact semantic spans and coverage.tsconfig_path; select tsconfig_path if ambiguous
- [ ] Review exact graph-backed semantic spans, coverage, and limitations
- [ ] If satisfied: rename({..., dry_run: false}) — apply edits
- [ ] detect_changes() — verify only expected files changed
- [ ] Run tests for affected processes
@ -94,23 +94,28 @@ checkout and reports nothing changed, which reads as a verified refactor.
```
rename_preview({symbol_name: "validateUser", new_name: "authenticateUser", repo: "my-app"})
→ 12 edits across 8 files
→ semantic_edits: 12, coverage: {scope: "project", tsconfig_path: "tsconfig.json"}
→ semantic_edits: 12, coverage: {name: "gitnexus-semantic", scope: "indexed-repository", limitations: [...]}
→ changes: [{file_path, edits: [{start, length, line, old_text, new_text, confidence: "semantic"}]}]
```
Only configured TypeScript/JavaScript projects are supported. If multiple configs
include the declaration, pass a returned `tsconfig_path` candidate. Coverage is
limited to that project; project references and unsupported languages fail closed.
There is no regex fallback: `graph_edits` and `text_search_edits` are zero.
Rename uses the indexed graph and GitNexus's own semantic layer. Language
providers resolve current source through scopes and bindings; no compiler or
language service is loaded. Unsupported syntax, ambiguous bindings, stale
declarations, and incomplete occurrence coverage fail closed. Coverage describes
indexed files in the target language, excluding unindexed files and external consumers.
Unsupported constructor/inheritance families, unclassified token roles, and
computed references are refused. Any detected computed access in the analyzed
language scope currently blocks rename, including accesses outside edited files.
PHP rename is unsupported until providers cover case-insensitive symbol names.
There is no regex fallback: legacy `graph_edits` and `text_search_edits` are zero.
Comments, strings, and unrelated same-spelled symbols are not replacements.
`rename` remains available with `dry_run: true` (default) or explicit
`dry_run: false`. Both use the same planner. Results use `result_version: 2`;
`total_edits` counts occurrences, not matching lines. Import/export alias affixes
are part of `new_text`. Separate preview and apply calls recompute the plan.
`total_edits` counts occurrences, not matching lines. Exact replacements are shown in `new_text`. Separate preview and apply calls recompute the plan.
Check `planning_status`, `application_status`, and `applied`. After a write
failure, `changes` and counts describe only landed edits; inspect `failed_files`
before retrying. New-name collisions still require compiler and test verification.
before retrying. Run the project validation suite after applying.
Neither tool accepts `branch`: they inspect the current checkout.
**impact** — map all dependents first:

View file

@ -68,6 +68,8 @@ export type ReferenceKind =
export type CallForm = 'free' | 'member' | 'constructor' | 'index';
export interface ReferenceSite {
/** Exact identifier capture; atRange still describes the whole expression. */
readonly nameRange?: Range;
/** Graph caller for a synthesized invocation whose callable is looked up elsewhere.
* Never changes lexical resolution from `inScope` (for example FastAPI Depends). */
readonly callerScope?: ScopeId;

View file

@ -10,7 +10,7 @@
*/
import type { NodeLabel } from '../graph/types.js';
import type { SourcePosition } from './types.js';
import type { Range, SourcePosition } from './types.js';
export interface ParameterTypeClass {
/** Normalized base type, matching the coarse `parameterTypes` vocabulary when known. */
@ -58,6 +58,10 @@ export interface TypeParameter {
}
export interface SymbolDefinition {
/** Exact identifier capture, when the provider supplied a source name. */
readonly nameRange?: Range;
/** Full declaration capture, retained to validate persisted graph ranges. */
readonly declarationRange?: Range;
nodeId: string;
filePath: string;
type: NodeLabel;

View file

@ -62,7 +62,6 @@
"tree-sitter-ruby": "0.23.1",
"tree-sitter-rust": "0.24.0",
"tree-sitter-typescript": "0.23.2",
"typescript-language-service": "npm:typescript@5.9.3",
"uuid": "^14.0.0"
},
"bin": {
@ -5060,20 +5059,6 @@
"@typescript/typescript-win32-x64": "7.0.2"
}
},
"node_modules/typescript-language-service": {
"name": "typescript",
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/typical": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/typical/-/typical-7.3.0.tgz",

View file

@ -52,7 +52,7 @@
"serve": "tsx src/cli/index.ts serve",
"dev": "tsx watch src/cli/index.ts",
"test": "vitest run",
"typecheck:tests": "node node_modules/typescript/lib/tsc.js --noEmit -p tsconfig.test.json",
"typecheck:tests": "tsc --noEmit -p tsconfig.test.json",
"test:unit": "vitest run test/unit",
"pretest:integration": "node scripts/build.js",
"test:integration": "vitest run test/integration",
@ -106,7 +106,6 @@
"tree-sitter-ruby": "0.23.1",
"tree-sitter-rust": "0.24.0",
"tree-sitter-typescript": "0.23.2",
"typescript-language-service": "npm:typescript@5.9.3",
"uuid": "^14.0.0"
},
"gitnexusEmbeddingStack": {

View file

@ -56,7 +56,7 @@ checkout and reports nothing changed, which reads as a verified refactor.
- [ ] list_repos {} — bind repo; explicit repo when >1 indexed, ask if ambiguous
- [ ] rename_preview({symbol_name: "oldName", new_name: "newName"}) — preview semantic edits
- [ ] Confirm the previewed file paths are in the bound repository/worktree
- [ ] Review exact semantic spans and coverage.tsconfig_path; select tsconfig_path if ambiguous
- [ ] Review exact graph-backed semantic spans, coverage, and limitations
- [ ] If satisfied: rename({..., dry_run: false}) — apply edits
- [ ] detect_changes() — verify only expected files changed
- [ ] Run tests for affected processes
@ -94,23 +94,28 @@ checkout and reports nothing changed, which reads as a verified refactor.
```
rename_preview({symbol_name: "validateUser", new_name: "authenticateUser", repo: "my-app"})
→ 12 edits across 8 files
→ semantic_edits: 12, coverage: {scope: "project", tsconfig_path: "tsconfig.json"}
→ semantic_edits: 12, coverage: {name: "gitnexus-semantic", scope: "indexed-repository", limitations: [...]}
→ changes: [{file_path, edits: [{start, length, line, old_text, new_text, confidence: "semantic"}]}]
```
Only configured TypeScript/JavaScript projects are supported. If multiple configs
include the declaration, pass a returned `tsconfig_path` candidate. Coverage is
limited to that project; project references and unsupported languages fail closed.
There is no regex fallback: `graph_edits` and `text_search_edits` are zero.
Rename uses the indexed graph and GitNexus's own semantic layer. Language
providers resolve current source through scopes and bindings; no compiler or
language service is loaded. Unsupported syntax, ambiguous bindings, stale
declarations, and incomplete occurrence coverage fail closed. Coverage describes
indexed files in the target language, excluding unindexed files and external consumers.
Unsupported constructor/inheritance families, unclassified token roles, and
computed references are refused. Any detected computed access in the analyzed
language scope currently blocks rename, including accesses outside edited files.
PHP rename is unsupported until providers cover case-insensitive symbol names.
There is no regex fallback: legacy `graph_edits` and `text_search_edits` are zero.
Comments, strings, and unrelated same-spelled symbols are not replacements.
`rename` remains available with `dry_run: true` (default) or explicit
`dry_run: false`. Both use the same planner. Results use `result_version: 2`;
`total_edits` counts occurrences, not matching lines. Import/export alias affixes
are part of `new_text`. Separate preview and apply calls recompute the plan.
`total_edits` counts occurrences, not matching lines. Exact replacements are shown in `new_text`. Separate preview and apply calls recompute the plan.
Check `planning_status`, `application_status`, and `applied`. After a write
failure, `changes` and counts describe only landed edits; inspect `failed_files`
before retrying. New-name collisions still require compiler and test verification.
before retrying. Run the project validation suite after applying.
Neither tool accepts `branch`: they inspect the current checkout.
**impact** — map all dependents first:

View file

@ -250,7 +250,7 @@ This project is indexed by GitNexus as **${projectName}**${noStats ? '' : ` (${s
- NEVER edit a function, class, or method before MCP/CLI impact analysis.
- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis, and never read \`UNKNOWN\` as an all-clear — it means the walk could not answer, which is the one verdict that requires confirming by other means.
- NEVER rename symbols with find-and-replace — use \`rename\` for configured TS/JS projects after reviewing \`rename_preview\` semantic spans and project coverage.
- NEVER rename symbols with find-and-replace — use \`rename\` after reviewing \`rename_preview\` graph-backed semantic spans and coverage.
- NEVER commit before MCP/CLI graph change analysis.
## Resources

View file

@ -88,15 +88,11 @@ export interface ResolveReferencesOutput {
readonly stats: ResolveStats;
}
/**
* Resolve every `ReferenceSite` in `scopes.referenceSites` against the
* matching registry and produce a `ReferenceIndex` keyed by source scope
* + target def.
*/
export function resolveReferenceSites(input: ResolveReferencesInput): ResolveReferencesOutput {
const { scopes } = input;
const providers: RegistryProviders = input.providers ?? {};
/** Preserve every candidate for callers that must reject ambiguous edits. */
export function createReferenceLookup(
input: ResolveReferencesInput,
): (site: ReferenceSite) => readonly Resolution[] {
const scopes = input.scopes;
const ctx: RegistryContext = {
scopes: scopes.scopeTree,
defs: scopes.defs,
@ -104,13 +100,23 @@ export function resolveReferenceSites(input: ResolveReferencesInput): ResolveRef
moduleScopes: scopes.moduleScopes,
ownedMembersByOwner: input.ownedMembersByOwner,
methodDispatch: scopes.methodDispatch,
providers,
providers: input.providers ?? {},
};
const classes = buildClassRegistry(ctx);
const methods = buildMethodRegistry(ctx);
const fields = buildFieldRegistry(ctx);
const macros = buildMacroRegistry(ctx);
return (site) => lookupForSite(site, classes, methods, fields, macros, scopes);
}
const classRegistry = buildClassRegistry(ctx);
const methodRegistry = buildMethodRegistry(ctx);
const fieldRegistry = buildFieldRegistry(ctx);
const macroRegistry = buildMacroRegistry(ctx);
/**
* Resolve every `ReferenceSite` in `scopes.referenceSites` against the
* matching registry and produce a `ReferenceIndex` keyed by source scope
* + target def.
*/
export function resolveReferenceSites(input: ResolveReferencesInput): ResolveReferencesOutput {
const { scopes } = input;
const lookup = createReferenceLookup(input);
// bySourceScope is the canonical index; byTargetDef is derived from it.
const bySourceScope = new Map<ScopeId, Reference[]>();
@ -128,14 +134,7 @@ export function resolveReferenceSites(input: ResolveReferencesInput): ResolveRef
if (site.kind === 'value-ref') continue;
sitesProcessed++;
const resolutions = lookupForSite(
site,
classRegistry,
methodRegistry,
fieldRegistry,
macroRegistry,
scopes,
);
const resolutions = lookup(site);
if (resolutions.length === 0) {
unresolved++;
continue;

View file

@ -780,6 +780,8 @@ function buildDefFromDeclarationMatch(
return {
nodeId: makeDefId(filePath, anchor.range, type, nameCap.text),
nameRange: nameCap.range,
declarationRange: anchor.range,
filePath,
type,
...(graphPosition !== undefined
@ -1423,6 +1425,7 @@ function pass5CollectReferences(
const site: ReferenceSite = {
name: nameCap.text,
nameRange: nameCap.range,
atRange: anchor.range,
...(match['@reference.lookup-purpose']?.text === 'type' ||
match['@reference.lookup-purpose']?.text === 'value'

View file

@ -29,6 +29,7 @@ import type { KnowledgeGraph } from '../../../graph/types.js';
import { generateId } from '../../../../lib/utils.js';
import { lookupOwnedMembersByOwner } from '../../model/owned-members-lookup.js';
import type { MutableSemanticModel, SemanticModel } from '../../model/semantic-model.js';
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
import { reconcileOwnership, validateOwnershipParity } from './reconcile-ownership.js';
import { validateBindingsImmutability } from './validate-bindings-immutability.js';
import { extractParsedFile } from '../../scope-extractor-bridge.js';
@ -382,6 +383,11 @@ export type ScopeResolutionSubPhase =
| 'linking symbols';
interface RunScopeResolutionInput {
/** Observe the completed semantic pass without changing its graph output. */
readonly onResolved?: (result: {
readonly parsedFiles: readonly ParsedFile[];
readonly indexes: ScopeResolutionIndexes;
}) => void;
readonly graph: KnowledgeGraph;
/**
* Semantic model populated by the legacy `parse` phase. Scope-
@ -1896,6 +1902,7 @@ export function runScopeResolution(
logHeapProbe('sr-end', `lang=${provider.language} parsedFiles=${parsedFiles.length}`);
for (const outcome of resolutionOutcomes) input.recordResolutionOutcome?.(outcome);
input.onResolved?.({ parsedFiles, indexes });
return {
filesProcessed: parsedFiles.length,

View file

@ -15,6 +15,8 @@ import {
unusedAxesForImpactWalk,
getLanguageFromFilename,
type ImpactRiskResult,
type GraphNode,
type NodeLabel,
} from 'gitnexus-shared';
import {
initLbug,
@ -6762,7 +6764,7 @@ export class LocalBackend {
};
}
/** Resolve graph identity, then let the language provider find exact occurrences. */
/** Resolve graph identity and indexed scope through GitNexus's semantic layer. */
private async rename(
repo: RepoHandle,
params: {
@ -6770,7 +6772,6 @@ export class LocalBackend {
symbol_uid?: string;
new_name: string;
file_path?: string;
tsconfig_path?: string;
dry_run?: boolean;
},
): Promise<any> {
@ -6784,8 +6785,42 @@ export class LocalBackend {
file_path: params.file_path,
});
if (lookupResult.status === 'ambiguous' || lookupResult.error) return lookupResult;
// Incoming graph edges are discovery hints, not proof of source occurrences.
return renameSymbol(repo.repoPath, lookupResult.symbol, params);
// Read the indexed file and declaration inventory, without context()'s
// ranked/truncated incoming edges. Current sources rebuild semantic scopes;
// a graph edge alone never authorizes a textual replacement.
const rows = await executeQuery(
repo.lbugPath,
`MATCH (n) WHERE n.filePath IS NOT NULL AND n.filePath <> ''
RETURN n.id AS id, n.name AS name, n.filePath AS filePath,
n.startLine AS startLine, n.endLine AS endLine`,
);
const graphNodes: GraphNode[] = rows.map((row: any) => ({
id: row.id,
// Node IDs carry the actual label; labels(n)[0] is unreliable on LadybugDB.
label: row.id.split(':')[0] as NodeLabel,
properties: {
name: row.name,
filePath: row.filePath,
startLine: row.startLine ?? undefined,
endLine: row.endLine ?? undefined,
},
}));
const referenceRows = await executeParameterized(
repo.lbugPath,
`MATCH (source)-[r:CodeRelation]->(target {id: $uid})
WHERE r.type IN ['CALLS', 'USES', 'ACCESSES', 'EXTENDS', 'IMPLEMENTS']
RETURN DISTINCT source.filePath AS filePath`,
{ uid: lookupResult.symbol.uid },
);
return renameSymbol(
repo.repoPath,
lookupResult.symbol,
params,
graphNodes,
referenceRows
.map((row: any) => row.filePath)
.filter((file: unknown): file is string => typeof file === 'string' && file.length > 0),
);
}
private async trace(repo: RepoHandle, params: TraceParams): Promise<any> {

View file

@ -0,0 +1,706 @@
import fs from 'node:fs/promises';
import type Parser from 'tree-sitter';
import {
CLASS_KINDS,
SupportedLanguages,
getLanguageFromFilename,
type GraphNode,
type ParsedFile,
type Range,
type ReferenceSite,
type SymbolDefinition,
} from 'gitnexus-shared';
import { createKnowledgeGraph } from '../../../core/graph/graph.js';
import { createSemanticModel } from '../../../core/ingestion/model/semantic-model.js';
import type { ScopeResolutionIndexes } from '../../../core/ingestion/model/scope-resolution-indexes.js';
import { lookupOwnedMembersByOwner } from '../../../core/ingestion/model/owned-members-lookup.js';
import { createReferenceLookup } from '../../../core/ingestion/resolve-references.js';
import { extractParsedFile } from '../../../core/ingestion/scope-extractor-bridge.js';
import { SCOPE_RESOLVERS } from '../../../core/ingestion/scope-resolution/pipeline/registry.js';
import { runScopeResolution } from '../../../core/ingestion/scope-resolution/pipeline/run.js';
import {
buildGraphNodeLookup,
qualifiedKey,
} from '../../../core/ingestion/scope-resolution/graph-bridge/node-lookup.js';
import {
resolveDefGraphId,
simpleQualifiedName,
} from '../../../core/ingestion/scope-resolution/graph-bridge/ids.js';
import { lookupNameClaim } from '../../../core/ingestion/scope-resolution/scope/walkers.js';
import { definitionIdPosition } from '../../../core/ingestion/scope-resolution/utils/definition-id.js';
import { createParserForLanguage } from '../../../core/tree-sitter/parser-loader.js';
import {
RenameFailure,
repositoryPath,
type RenameOptions,
type RenamePlan,
type RenameSymbol,
type OccurrenceEdit,
} from './rename-plan.js';
interface SourceFile {
path: string;
content: string;
tree: Parser.Tree;
parsed: ParsedFile;
tokens: Parser.SyntaxNode[];
namedOccurrences: Parser.SyntaxNode[];
tokensByRange: Map<string, Parser.SyntaxNode>;
tokensByStart: Map<number, Parser.SyntaxNode>;
}
/** A fresh semantic pass uses indexed graph identities, never name-search edits. */
export async function planGraphRename(
repoPath: string,
symbol: RenameSymbol,
options: RenameOptions,
graphNodes: readonly GraphNode[],
referenceFiles: readonly string[] = [],
): Promise<RenamePlan> {
const language = getLanguageFromFilename(symbol.filePath);
const resolver = language === undefined ? undefined : SCOPE_RESOLVERS.get(language);
if (!language || !resolver || resolver.scopeResolutionEdgeMode === 'callable-flow-only') {
throw new RenameFailure(
'unsupported_language',
'This language has no complete semantic rename provider.',
'unsupported',
);
}
if (language === SupportedLanguages.PHP) {
throw new RenameFailure(
'unsupported_language',
'The PHP provider does not yet prove complete rename coverage for case-insensitive names.',
'unsupported',
);
}
const targetNode = graphNodes.find((node) => node.id === symbol.uid);
if (
!targetNode ||
targetNode.label === 'File' ||
targetNode.properties.filePath !== symbol.filePath ||
targetNode.properties.name !== symbol.name ||
targetNode.properties.startLine !== symbol.startLine - 1 ||
(symbol.kind !== undefined && targetNode.label !== symbol.kind) ||
(symbol.endLine !== undefined && targetNode.properties.endLine !== symbol.endLine - 1)
) {
throw new RenameFailure(
'stale_graph',
'The selected graph declaration does not match this checkout. Re-analyze first.',
);
}
if (
typeof options.new_name !== 'string' ||
!options.new_name ||
options.new_name === symbol.name
) {
throw new RenameFailure('invalid_name', 'Choose a different, nonempty identifier.');
}
const graph = createKnowledgeGraph();
for (const node of graphNodes) graph.addNode(node);
const nodeLookup = buildGraphNodeLookup(graph);
const model = createSemanticModel();
const snapshots = new Map<string, string>();
const files = new Map<string, SourceFile>();
const filePaths = [
...new Set(
graphNodes
.filter(
(node) =>
node.label === 'File' && getLanguageFromFilename(node.properties.filePath) === language,
)
.map((node) => node.properties.filePath),
),
];
if (!filePaths.includes(symbol.filePath)) {
throw new RenameFailure(
'stale_graph',
'The target source file is absent from the indexed file set.',
);
}
if (referenceFiles.some((filePath) => !filePaths.includes(filePath))) {
throw new RenameFailure(
'unsupported_boundary',
'The indexed graph contains references outside this language scope.',
'unsupported',
);
}
const root = await fs.realpath(repoPath);
for (const filePath of filePaths.sort()) {
const absolute = repositoryPath(repoPath, filePath);
repositoryPath(root, await fs.realpath(absolute));
const bytes = await fs.readFile(absolute);
const content = bytes.toString('utf8');
if (!Buffer.from(content, 'utf8').equals(bytes)) {
throw new RenameFailure(
'unsupported_encoding',
`Source is not lossless UTF-8: ${filePath}`,
'unsupported',
);
}
snapshots.set(absolute, content);
if (!content.trim()) continue;
const parser = await createParserForLanguage(language, filePath);
if (
parser
.getLanguage()
.nodeTypeInfo.some((info) => !info.named && info.type === options.new_name)
) {
throw new RenameFailure(
'invalid_name',
'The proposed name is a reserved syntax token in this language.',
);
}
const parseText = resolver.languageProvider.preprocessSource?.(content, filePath) ?? content;
if (parseText !== content) {
throw new RenameFailure(
'unsupported_source',
`Rename cannot prove offsets for transformed source: ${filePath}`,
'unsupported',
);
}
const tree = parser.parse(content);
if (!tree || tree.rootNode.hasError) {
throw new RenameFailure('parse_error', `Cannot establish semantic coverage for ${filePath}.`);
}
const parsed = extractParsedFile(resolver.languageProvider, content, filePath, undefined, tree);
if (!parsed)
throw new RenameFailure(
'incomplete_semantics',
`Scope extraction is unavailable for ${filePath}.`,
);
resolver.populateOwners(parsed);
for (const def of parsed.localDefs) {
const name = simpleQualifiedName(def);
if (name) model.symbols.add(filePath, name, def.nodeId, def.type, def);
}
const tokens = sourceTokens(tree.rootNode);
const namedOccurrences = tokens.filter((token) => token.text === symbol.name);
const tokensByRange = new Map<string, Parser.SyntaxNode>();
const tokensByStart = new Map<number, Parser.SyntaxNode>();
for (const token of namedOccurrences) {
const range = tokenRangeKey(token);
if (!tokensByRange.has(range)) tokensByRange.set(range, token);
if (!tokensByStart.has(token.startIndex)) tokensByStart.set(token.startIndex, token);
}
files.set(filePath, {
path: filePath,
content,
tree,
parsed,
tokens,
namedOccurrences,
tokensByRange,
tokensByStart,
});
}
let observation:
| { readonly parsedFiles: readonly ParsedFile[]; readonly indexes: ScopeResolutionIndexes }
| undefined;
const warnings: string[] = [];
const stats = runScopeResolution(
{
graph,
model,
files: [...files.values()],
treeCache: new Map([...files].map(([filePath, file]) => [filePath, file.tree])),
preExtractedParsedFiles: new Map(
[...files].map(([filePath, file]) => [filePath, file.parsed]),
),
onWarn: (warning) => warnings.push(warning),
onResolved: (result) => {
observation = result;
},
},
resolver,
);
if (
!observation ||
stats.filesSkipped ||
stats.scopeExtractionFailedPaths.length ||
warnings.length
) {
throw new RenameFailure(
'incomplete_semantics',
'The semantic pass could not verify every indexed source file.',
);
}
const { parsedFiles, indexes } = observation;
if (
[...files.values()].some((file) => file.tokens.some((token) => token.text === options.new_name))
) {
throw new RenameFailure(
'name_collision',
'The proposed name already occurs in the indexed language scope.',
);
}
const definitions = parsedFiles.flatMap((file) => [...file.localDefs]);
const targetDefs = definitions.filter(
(def) =>
def.filePath === symbol.filePath &&
simpleQualifiedName(def) === symbol.name &&
definitionIdPosition(def.nodeId, def.filePath)?.line === symbol.startLine &&
(symbol.endLine === undefined || def.declarationRange?.endLine === symbol.endLine) &&
def.qualifiedName !== undefined &&
nodeLookup.get(
qualifiedKey(
def.filePath,
targetNode.label,
def.namespacePrefix && !def.qualifiedName.startsWith(`${def.namespacePrefix}.`)
? `${def.namespacePrefix}.${def.qualifiedName}`
: def.qualifiedName,
),
) === targetNode.id &&
resolveDefGraphId(def.filePath, def, nodeLookup) === targetNode.id,
);
if (targetDefs.length !== 1 || targetDefs[0]!.isSynthetic) {
throw new RenameFailure(
'stale_graph',
'The graph identity is not a unique current source declaration.',
);
}
const target = targetDefs[0]!;
if (
CLASS_KINDS.includes(target.type) &&
(definitions.some(
(def) => def.ownerId === target.nodeId && simpleQualifiedName(def) === symbol.name,
) ||
[...files.values()].some((file) =>
file.namedOccurrences.some((token) => isNameCoupledDeclaration(token)),
))
) {
throw new RenameFailure(
'unsupported_symbol_family',
'Renaming a class with name-coupled constructors or destructors requires a complete symbol-family plan.',
'unsupported',
);
}
if (target.ownerId) {
const owner = definitions.find((def) => def.nodeId === target.ownerId);
if (owner && CLASS_KINDS.includes(owner.type) && simpleQualifiedName(owner) === symbol.name) {
throw new RenameFailure(
'unsupported_symbol_family',
'Renaming a name-coupled constructor or destructor requires a complete symbol-family plan.',
'unsupported',
);
}
const ownerGraphId = owner && resolveDefGraphId(owner.filePath, owner, nodeLookup);
if (
ownerGraphId &&
[...graph.iterRelationships()].some(
(edge) =>
(edge.type === 'EXTENDS' || edge.type === 'IMPLEMENTS') &&
(edge.sourceId === ownerGraphId || edge.targetId === ownerGraphId),
)
) {
throw new RenameFailure(
'unsupported_symbol_family',
'Renaming an inherited or interface member requires a complete symbol-family plan.',
'unsupported',
);
}
}
if (definitions.some((def) => simpleQualifiedName(def) === options.new_name)) {
throw new RenameFailure(
'name_collision',
'The proposed name already belongs to a definition in the indexed language scope.',
);
}
const lookup = createReferenceLookup({
scopes: indexes,
providers: { arityCompatibility: resolver.arityCompatibility },
ownedMembersByOwner: (owner, name) => lookupOwnedMembersByOwner(model, owner, name),
});
const boundMembers = (site: ReferenceSite): readonly SymbolDefinition[] | undefined => {
if (!site.explicitReceiver) return undefined;
const receiverClaim = lookupNameClaim(site.inScope, site.explicitReceiver.name, indexes, {
position: site.atRange,
purpose: 'value',
});
const type = receiverClaim.typeBinding;
if (
type?.source === 'decorator-unknown' ||
(type?.declaredSpelling && type.declaredSpelling !== type.rawName)
)
return [];
const owners = type
? lookupNameClaim(type.declaredAtScope, type.rawName, indexes, {
position: type.lookupPosition ?? site.atRange,
purpose: 'type',
}).bindings
: receiverClaim.bindings;
const classOwners = [
...new Map(
owners
.filter((binding) => CLASS_KINDS.includes(binding.def.type))
.map((binding) => [binding.def.nodeId, binding.def]),
).values(),
];
if (classOwners.length !== 1) return undefined;
return lookupOwnedMembersByOwner(model, classOwners[0]!.nodeId, site.name);
};
const edits = new Map<string, OccurrenceEdit>();
const accounted = new Set<string>();
const key = (file: string, start: number) => `${file}:${start}`;
const add = (file: SourceFile, token: Parser.SyntaxNode, rename: boolean) => {
const id = key(file.path, token.startIndex);
accounted.add(id);
if (!rename) return;
// Shorthand keys and bindings have a second semantic identity. Until a
// provider describes its rewrite, changing only the token would corrupt it.
if (token.type.includes('shorthand') || token.parent?.type.includes('shorthand')) {
throw new RenameFailure(
'unsupported_occurrence',
`Shorthand occurrence needs a provider rewrite: ${file.path}:${token.startPosition.row + 1}`,
'unsupported',
);
}
edits.set(id, {
file_path: file.path,
start: token.startIndex,
length: token.endIndex - token.startIndex,
old_text: symbol.name,
new_text: options.new_name,
});
};
const tokenAtRange = (file: SourceFile, range: Range | undefined): Parser.SyntaxNode => {
const token =
range &&
file.tokensByRange.get(
`${range.startLine}:${range.startCol}:${range.endLine}:${range.endCol}`,
);
if (!token)
throw new RenameFailure(
'unsupported_occurrence',
`The provider has no exact identifier span in ${file.path}.`,
'unsupported',
);
return token;
};
for (const def of definitions) {
if (simpleQualifiedName(def) !== symbol.name) continue;
const file = files.get(def.filePath)!;
add(file, tokenAtRange(file, def.nameRange), def.nodeId === target.nodeId);
}
// Finalized import provenance identifies the exported token. Aliased local
// names retain their spelling; their later usages are a separate binding.
for (const [scopeId, imports] of indexes.imports) {
const scope = indexes.scopeTree.getScope(scopeId);
const file = scope && files.get(scope.filePath);
if (!file) continue;
for (const edge of imports) {
if (edge.targetExportedName !== symbol.name && edge.localName !== symbol.name) continue;
if (!edge.atRange || edge.linkStatus === 'unresolved') {
throw new RenameFailure(
'incomplete_semantics',
`An import of ${symbol.name} is unresolved in ${file.path}.`,
);
}
const candidates = file.namedOccurrences.filter((token) => contains(edge.atRange!, token));
const isTarget = edge.targetDefId === target.nodeId;
if (candidates.length !== 1) {
throw new RenameFailure(
'unsupported_occurrence',
`The import has no unambiguous source token in ${file.path}.`,
'unsupported',
);
}
add(file, candidates[0]!, isTarget && edge.targetExportedName === symbol.name);
}
}
for (const site of indexes.referenceSites) {
if (site.name !== symbol.name) continue;
const scope = indexes.scopeTree.getScope(site.inScope);
const file = scope && files.get(scope.filePath);
if (!file)
throw new RenameFailure(
'incomplete_semantics',
'A reference points outside the indexed source set.',
);
const token = tokenAtRange(file, site.nameRange);
if (accounted.has(key(file.path, token.startIndex))) continue;
const claim =
site.explicitReceiver === undefined
? lookupNameClaim(site.lookupScope ?? site.inScope, site.name, indexes, {
position: site.atRange,
purpose:
site.lookupPurpose ??
(site.kind === 'type-reference' || site.kind === 'inherits' ? 'type' : 'value'),
})
: undefined;
const members = boundMembers(site);
const resolutions =
members !== undefined || claim?.bindings.length || claim?.status === 'blocked'
? []
: lookup(site);
if (
resolutions.some(
(resolution) =>
!resolution.evidence.some((evidence) => evidence.kind === 'type-binding') ||
!resolution.evidence.some((evidence) => evidence.kind === 'owner-match') ||
resolution.evidence.some(
(evidence) =>
evidence.kind === 'global-name' ||
evidence.kind === 'global-qualified' ||
evidence.kind === 'dynamic-import-unresolved',
),
)
) {
throw new RenameFailure(
'ambiguous_reference',
`Only heuristic evidence resolves ${symbol.name} at ${file.path}:${site.atRange.startLine}.`,
);
}
const candidates =
members ??
(claim?.bindings.length
? claim.bindings.map((binding) => binding.def)
: claim?.status === 'blocked'
? []
: resolutions.map((resolution) => resolution.def));
const ids = new Set(candidates.map((def) => def.nodeId));
if (ids.size > 1 || (ids.size === 0 && claim?.status !== 'blocked')) {
throw new RenameFailure(
'ambiguous_reference',
`Cannot prove the target of ${symbol.name} at ${file.path}:${site.atRange.startLine}.`,
);
}
add(file, token, ids.has(target.nodeId));
}
// Audit every source identifier with the same spelling. This catches syntax
// that a provider has not captured instead of claiming a complete rename.
for (const file of files.values()) {
if (hasComputedAccess(file.tree.rootNode)) {
throw new RenameFailure(
'unsupported_occurrence',
`A computed member access prevents complete rename coverage in ${file.path}.`,
'unsupported',
);
}
for (const token of file.tokens) {
if (token.text === symbol.name && hasCompoundBinding(token)) {
throw new RenameFailure(
'unsupported_occurrence',
`An alias or destructuring binding needs a provider rewrite in ${file.path}.`,
'unsupported',
);
}
if (token.text !== symbol.name || accounted.has(key(file.path, token.startIndex))) continue;
if (isUnrelatedLabel(token)) {
add(file, token, false);
continue;
}
// Parameters may be represented by a scope blocker rather than a symbol
// definition. That proof can preserve the binder, but must never authorize
// an edit to an uncaptured token. Only provider reference sites do that.
if (isParameterBinder(token)) {
const scope = innermostScope(file.parsed, token);
if (
scope &&
lookupNameClaim(scope, token.text, indexes, {
position: {
startLine: token.startPosition.row + 1,
startCol: token.startPosition.column,
},
purpose: 'value',
}).status === 'blocked'
) {
add(file, token, false);
continue;
}
}
throw new RenameFailure(
'incomplete_semantics',
`Uncovered occurrence in ${file.path}:${token.startPosition.row + 1}.`,
);
}
}
const ordered = [...edits.values()].sort(
(a, b) => a.file_path.localeCompare(b.file_path) || a.start - b.start,
);
const editsByFile = new Map<string, OccurrenceEdit[]>();
for (const edit of ordered) {
const group = editsByFile.get(edit.file_path) ?? [];
group.push(edit);
editsByFile.set(edit.file_path, group);
}
for (const file of files.values()) {
const fileEdits = editsByFile.get(file.path);
if (!fileEdits) continue;
const pieces: string[] = [];
let cursor = 0;
for (const edit of fileEdits) {
pieces.push(file.content.slice(cursor, edit.start), edit.new_text);
cursor = edit.start + edit.length;
}
pieces.push(file.content.slice(cursor));
const updated = pieces.join('');
const parser = await createParserForLanguage(language, file.path);
const validated = parser.parse(updated);
const updatedTokens = new Map<number, Parser.SyntaxNode>();
for (const token of validated ? sourceTokens(validated.rootNode) : []) {
if (!updatedTokens.has(token.startIndex)) updatedTokens.set(token.startIndex, token);
}
let delta = 0;
for (const edit of fileEdits) {
const original = file.tokensByStart.get(edit.start)!;
const replacement = updatedTokens.get(edit.start + delta);
if (
!validated ||
validated.rootNode.hasError ||
!replacement ||
replacement.text !== options.new_name ||
replacement.type !== original.type ||
replacement.parent?.type !== original.parent?.type
) {
throw new RenameFailure(
'invalid_name',
'The proposed name does not preserve the source identifier syntax.',
);
}
delta += edit.new_text.length - edit.length;
}
}
return {
symbol,
new_name: options.new_name,
snapshots,
edits: ordered,
coverage: {
name: 'gitnexus-semantic',
scope: 'indexed-repository',
source_file_count: filePaths.length,
languages: [language],
limitations: [
'Only indexed files in this language are covered.',
'Computed accesses, incomplete symbol families, and uncaptured identifier roles are refused.',
],
},
};
}
function contains(range: Range, node: Parser.SyntaxNode): boolean {
const startLine = node.startPosition.row + 1;
const endLine = node.endPosition.row + 1;
return (
(startLine > range.startLine ||
(startLine === range.startLine && node.startPosition.column >= range.startCol)) &&
(endLine < range.endLine ||
(endLine === range.endLine && node.endPosition.column <= range.endCol))
);
}
function sourceTokens(root: Parser.SyntaxNode): Parser.SyntaxNode[] {
const tokens: Parser.SyntaxNode[] = [];
const pending = [root];
while (pending.length) {
const node = pending.pop()!;
if (node.type.includes('comment')) continue;
if (node.namedChildCount === 0) {
if (
node.isNamed &&
!node.type.includes('string') &&
!node.type.includes('template_chars') &&
!node.type.includes('escape')
)
tokens.push(node);
} else pending.push(...node.namedChildren);
}
return tokens;
}
function innermostScope(parsed: ParsedFile, token: Parser.SyntaxNode): string | undefined {
let result: ParsedFile['scopes'][number] | undefined;
for (const scope of parsed.scopes) {
if (
contains(scope.range, token) &&
(!result ||
scope.range.startLine > result.range.startLine ||
(scope.range.startLine === result.range.startLine &&
scope.range.startCol >= result.range.startCol))
)
result = scope;
}
return result?.id;
}
function hasComputedAccess(root: Parser.SyntaxNode): boolean {
const pending = [root];
while (pending.length) {
const node = pending.pop()!;
if (node.type.includes('comment')) continue;
// Raw key spelling cannot establish identity: variables, concatenations,
// escapes, and runtime receivers may all denote the selected member. Until
// providers expose complete key/owner proofs, refuse every computed access.
if (
node.childForFieldName('index') ||
node.childForFieldName('subscript') ||
[
'subscript_expression',
'subscript',
'element_access_expression',
'index_expression',
'array_access',
'element_reference',
'computed_property_name',
].includes(node.type)
)
return true;
pending.push(...node.namedChildren);
}
return false;
}
function isNameCoupledDeclaration(token: Parser.SyntaxNode): boolean {
return (
token.parent?.type === 'destructor_name' ||
((token.parent?.type === 'constructor_declaration' ||
token.parent?.type === 'compact_constructor_declaration' ||
token.parent?.type === 'destructor_declaration') &&
token.parent.childForFieldName('name')?.id === token.id)
);
}
function isUnrelatedLabel(token: Parser.SyntaxNode): boolean {
const parent = token.parent;
if (!parent) return false;
if (parent.childForFieldName('key')?.id === token.id) return true;
if (parent.type === 'keyword_argument' && parent.childForFieldName('name')?.id === token.id)
return true;
if (parent.type === 'jsx_attribute' && parent.firstNamedChild?.id === token.id) return true;
return (
['jsx_opening_element', 'jsx_closing_element', 'jsx_self_closing_element'].includes(
parent.type,
) &&
parent.childForFieldName('name')?.id === token.id &&
/^[a-z]/.test(token.text)
);
}
function isParameterBinder(token: Parser.SyntaxNode): boolean {
const parent = token.parent;
if (!parent) return false;
return (
((parent.type === 'required_parameter' || parent.type === 'optional_parameter') &&
parent.childForFieldName('pattern')?.id === token.id) ||
parent.type === 'formal_parameters' ||
parent.type === 'parameters'
);
}
function hasCompoundBinding(token: Parser.SyntaxNode): boolean {
let parent = token.parent;
while (parent) {
if (
parent.type.includes('pattern') ||
(parent.type.includes('export') && parent.childForFieldName('alias'))
)
return true;
if (parent.type.includes('statement') || parent.type.includes('declaration')) break;
parent = parent.parent;
}
return false;
}
function tokenRangeKey(token: Parser.SyntaxNode): string {
return `${token.startPosition.row + 1}:${token.startPosition.column}:${token.endPosition.row + 1}:${token.endPosition.column}`;
}

View file

@ -1,5 +1,6 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import type { GraphNode } from 'gitnexus-shared';
export interface RenameSymbol {
uid?: string;
@ -13,16 +14,15 @@ export interface RenameSymbol {
export interface RenameOptions {
new_name: string;
tsconfig_path?: string;
dry_run?: boolean;
}
export interface RenameCoverage {
name: string;
version: string;
scope: 'project';
tsconfig_path: string;
scope: 'indexed-repository';
source_file_count: number;
languages: string[];
limitations: string[];
}
export interface OccurrenceEdit {
@ -38,7 +38,7 @@ export interface RenamePlan {
symbol: RenameSymbol;
new_name: string;
coverage: RenameCoverage;
/** Includes all source/config files read by the provider, even without edits. */
/** Includes all source files used by semantic resolution, even without edits. */
snapshots: Map<string, string>;
edits: OccurrenceEdit[];
}
@ -316,19 +316,14 @@ export async function renameSymbol(
repoPath: string,
symbol: RenameSymbol,
options: RenameOptions,
graphNodes: readonly GraphNode[],
referenceFiles: readonly string[] = [],
): Promise<RenameResult> {
try {
if (!/\.(?:[cm]?[jt]s|[jt]sx)$/i.test(symbol.filePath)) {
throw new RenameFailure(
'unsupported_language',
'Semantic rename currently supports configured TypeScript and JavaScript projects only.',
'unsupported',
);
}
if (options.dry_run !== undefined && typeof options.dry_run !== 'boolean')
throw new RenameFailure('invalid_argument', 'dry_run must be a boolean.');
const { planTypeScriptRename } = await import('./typescript-rename.js');
const plan = planTypeScriptRename(repoPath, symbol, options);
const { planGraphRename } = await import('./graph-rename.js');
const plan = await planGraphRename(repoPath, symbol, options, graphNodes, referenceFiles);
return await executeRenamePlan(repoPath, plan, options.dry_run ?? true);
} catch (error) {
return blocked(symbol, options.new_name, error);

View file

@ -1,359 +0,0 @@
import fs from 'node:fs';
import path from 'node:path';
import ts from 'typescript-language-service';
import {
RenameFailure,
repositoryPath,
type RenameOptions,
type RenamePlan,
type RenameSymbol,
} from './rename-plan.js';
function checkNewName(name: string, oldName: string): void {
if (typeof name !== 'string' || !name || name === oldName || name.includes('\\')) {
throw new RenameFailure('invalid_name', 'new_name must be a different, unescaped identifier.');
}
const scanner = ts.createScanner(
ts.ScriptTarget.Latest,
false,
ts.LanguageVariant.Standard,
name,
);
if (
scanner.scan() !== ts.SyntaxKind.Identifier ||
scanner.getTokenPos() !== 0 ||
scanner.getTextPos() !== name.length ||
scanner.scan() !== ts.SyntaxKind.EndOfFileToken
) {
throw new RenameFailure(
'invalid_name',
'new_name must be one identifier; keywords, whitespace and punctuation are unsupported.',
);
}
}
function declarationKind(node: ts.Node): string | undefined {
if (
ts.isMethodDeclaration(node) ||
ts.isMethodSignature(node) ||
ts.isGetAccessor(node) ||
ts.isSetAccessor(node)
)
return 'method';
if (ts.isFunctionDeclaration(node) || ts.isFunctionExpression(node)) return 'function';
if (ts.isClassDeclaration(node) || ts.isClassExpression(node)) return 'class';
if (ts.isInterfaceDeclaration(node)) return 'interface';
if (ts.isTypeAliasDeclaration(node)) return 'type';
if (ts.isEnumDeclaration(node)) return 'enum';
if (ts.isModuleDeclaration(node)) return 'namespace';
if (
ts.isPropertyDeclaration(node) ||
ts.isPropertySignature(node) ||
ts.isPropertyAssignment(node)
)
return 'property';
if (ts.isVariableDeclaration(node))
return node.initializer &&
(ts.isArrowFunction(node.initializer) || ts.isFunctionExpression(node.initializer))
? 'function'
: 'variable';
if (ts.isParameter(node) || ts.isBindingElement(node)) return 'variable';
return undefined;
}
function matchesKind(actual: string, expected?: string): boolean {
if (!expected) return true;
const kind = expected.toLowerCase();
if (kind === 'const' || kind === 'variable') return actual === 'variable';
if (kind === 'typealias') return actual === 'type';
return actual === kind;
}
function ownerName(node: ts.Node): string {
const owners: string[] = [];
for (let parent = node.parent; parent && !ts.isSourceFile(parent); parent = parent.parent) {
if (declarationKind(parent)) {
const name = (parent as ts.NamedDeclaration).name;
if (name && ts.isIdentifier(name)) owners.unshift(name.text);
}
}
return owners.join('.');
}
function declarationPosition(source: ts.SourceFile, symbol: RenameSymbol): number {
if (
!Number.isSafeInteger(symbol.startLine) ||
symbol.startLine < 1 ||
(symbol.endLine !== undefined &&
(!Number.isSafeInteger(symbol.endLine) || symbol.endLine < symbol.startLine))
) {
throw new RenameFailure(
'stale_symbol',
'The indexed declaration has no usable source range. Reindex and select it again.',
);
}
let expectedOwner: string | undefined;
if (symbol.uid) {
const prefix = `${symbol.kind ?? symbol.uid.split(':')[0]}:${symbol.filePath}:`;
if (!symbol.uid.startsWith(prefix))
throw new RenameFailure(
'stale_symbol',
'Symbol identity does not match its file and kind. Reindex and select it again.',
);
const qualified = symbol.uid.slice(prefix.length).replace(/#\d+.*$/, '');
if (qualified === symbol.name) expectedOwner = '';
else if (qualified.endsWith(`.${symbol.name}`))
expectedOwner = qualified.slice(0, -(symbol.name.length + 1));
else
throw new RenameFailure(
'stale_symbol',
'This indexed declaration identity is not supported. Reindex and select a named declaration.',
);
}
const candidates: ts.Identifier[] = [];
function visit(node: ts.Node): void {
const kind = declarationKind(node);
const name = kind ? (node as ts.NamedDeclaration).name : undefined;
if (
name &&
ts.isIdentifier(name) &&
name.text === symbol.name &&
matchesKind(kind!, symbol.kind)
) {
// Tree-sitter records variable statements (including export/const), while
// the compiler owns the identifier on a VariableDeclaration.
const rangeNode =
ts.isVariableDeclaration(node) &&
ts.isVariableDeclarationList(node.parent) &&
ts.isVariableStatement(node.parent.parent)
? node.parent.parent
: node;
const start = source.getLineAndCharacterOfPosition(rangeNode.getStart(source)).line + 1;
const end =
source.getLineAndCharacterOfPosition(
Math.max(rangeNode.getStart(source), rangeNode.end - 1),
).line + 1;
if (
start === symbol.startLine &&
(symbol.endLine === undefined || end === symbol.endLine) &&
(expectedOwner === undefined || ownerName(node) === expectedOwner)
)
candidates.push(name);
}
ts.forEachChild(node, visit);
}
visit(source);
if (candidates.length !== 1)
throw new RenameFailure(
'stale_symbol',
'The indexed name, kind, owner and range do not identify one current declaration. Reindex and select it again.',
);
return candidates[0].getStart(source);
}
/** Uses our pinned compiler API only; project compiler packages/plugins are never executed. */
export function planTypeScriptRename(
repoPath: string,
symbol: RenameSymbol,
options: RenameOptions,
): RenamePlan {
checkNewName(options.new_name, symbol.name);
const root = path.resolve(repoPath);
const target = repositoryPath(root, symbol.filePath);
const realRoot = fs.realpathSync(root);
repositoryPath(realRoot, fs.realpathSync(target));
const snapshots = new Map<string, string>();
const readFile = (filename: string): string | undefined => {
const absolute = path.resolve(filename);
if (snapshots.has(absolute)) return snapshots.get(absolute);
try {
// Keep the exact on-disk BOM and UTF-16 offsets; ts.sys.readFile strips BOMs.
const bytes = fs.readFileSync(absolute);
const text = bytes.toString('utf8');
if (!Buffer.from(text, 'utf8').equals(bytes))
throw new RenameFailure(
'unsupported_encoding',
`Rename requires losslessly decoded UTF-8 input: ${absolute}`,
'unsupported',
);
snapshots.set(absolute, text);
return text;
} catch (error) {
if (error instanceof RenameFailure) throw error;
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined;
throw new RenameFailure(
'read_failed',
`Cannot read rename input ${absolute}: ${error instanceof Error ? error.message : String(error)}`,
);
}
};
const canonical = (file: string) =>
ts.sys.useCaseSensitiveFileNames ? path.resolve(file) : path.resolve(file).toLowerCase();
let configs: string[] = [];
if (options.tsconfig_path !== undefined) {
if (
typeof options.tsconfig_path !== 'string' ||
!options.tsconfig_path ||
path.isAbsolute(options.tsconfig_path)
) {
throw new RenameFailure(
'invalid_config_path',
'tsconfig_path must be a repository-relative config filename.',
);
}
configs = [repositoryPath(root, options.tsconfig_path)];
} else {
for (let dir = path.dirname(target); ; dir = path.dirname(dir)) {
configs.push(
...fs
.readdirSync(dir)
.filter((name) => /^(?:tsconfig|jsconfig).*\.json$/i.test(name))
.map((name) => path.join(dir, name)),
);
if (dir === root) break;
}
}
if (!configs.length)
throw new RenameFailure(
'missing_project',
'No configured TS/JS project found. Add a config or supply tsconfig_path.',
'unsupported',
);
const candidates: { config: string; parsed: ts.ParsedCommandLine }[] = [];
for (const config of configs.sort()) {
repositoryPath(realRoot, fs.realpathSync(config));
const loaded = ts.readConfigFile(config, readFile);
const parsed = loaded.error
? undefined
: ts.parseJsonConfigFileContent(
loaded.config,
{ ...ts.sys, readFile },
path.dirname(config),
undefined,
config,
);
const errors = loaded.error ? [loaded.error] : parsed!.errors;
if (errors.length)
throw new RenameFailure(
'invalid_project',
`Cannot use ${path.relative(root, config)}: ${errors.map((e) => ts.flattenDiagnosticMessageText(e.messageText, ' ')).join('; ')}`,
'unsupported',
);
if (parsed!.fileNames.some((file) => canonical(file) === canonical(target)))
candidates.push({ config, parsed: parsed! });
}
if (!candidates.length)
throw new RenameFailure(
'missing_project',
'The selected declaration is not included in a configured project. Supply tsconfig_path for its owning project.',
'unsupported',
);
if (candidates.length > 1)
throw new RenameFailure(
'ambiguous_project',
'Several configured projects contain this declaration. Select one using tsconfig_path.',
'blocked',
candidates.map((c) => path.relative(root, c.config).split(path.sep).join('/')),
);
const { config, parsed } = candidates[0];
if (parsed.projectReferences?.length)
throw new RenameFailure(
'project_references',
'Project references require cross-project coordination and are not supported for rename. Select a self-contained project.',
'unsupported',
);
const host: ts.LanguageServiceHost = {
...ts.sys,
readFile,
getCurrentDirectory: () => root,
useCaseSensitiveFileNames: () => ts.sys.useCaseSensitiveFileNames,
getCompilationSettings: () => parsed.options,
getScriptFileNames: () => parsed.fileNames,
getScriptVersion: () => '0',
getScriptSnapshot: (filename) => {
const text = readFile(filename);
return text === undefined ? undefined : ts.ScriptSnapshot.fromString(text);
},
getDefaultLibFileName: (compilerOptions) => ts.getDefaultLibFilePath(compilerOptions),
};
const service = ts.createLanguageService(host);
try {
const program = service.getProgram();
const source = program?.getSourceFile(target);
if (!source)
throw new RenameFailure(
'missing_source',
'The compiler could not load the indexed source.',
'unsupported',
);
// Capture every source file, including unedited dependencies: a changed
// declaration elsewhere can invalidate the compiler's binding decisions.
for (const file of program.getSourceFiles()) {
if (readFile(file.fileName) !== file.text)
throw new RenameFailure(
'source_changed',
`Compiler source snapshot mismatch: ${file.fileName}`,
);
}
const position = declarationPosition(source, symbol);
const preferences: ts.UserPreferences = {
providePrefixAndSuffixTextForRename: true,
allowRenameOfImportPath: false,
};
const info = service.getRenameInfo(target, position, preferences);
if (info.canRename === false)
throw new RenameFailure('unsupported_target', info.localizedErrorMessage, 'unsupported');
if (info.fileToRename)
throw new RenameFailure(
'file_rename',
'File and import-path renames are not supported.',
'unsupported',
);
const locations = service.findRenameLocations(target, position, false, false, preferences);
if (!locations?.length)
throw new RenameFailure(
'no_locations',
'The compiler returned no rename locations.',
'unsupported',
);
return {
symbol,
new_name: options.new_name,
snapshots,
coverage: {
name: 'typescript',
version: ts.version,
scope: 'project',
tsconfig_path: path.relative(root, config).split(path.sep).join('/'),
source_file_count: program
.getSourceFiles()
.filter(
(file) =>
!program.isSourceFileDefaultLibrary(file) &&
!program.isSourceFileFromExternalLibrary(file),
).length,
},
edits: locations.map((location) => {
const absolute = repositoryPath(root, location.fileName);
const snapshot = snapshots.get(absolute);
if (snapshot === undefined)
throw new RenameFailure(
'missing_snapshot',
`The compiler returned an uncaptured source: ${location.fileName}`,
);
return {
file_path: path.relative(root, absolute).split(path.sep).join('/'),
start: location.textSpan.start,
length: location.textSpan.length,
old_text: snapshot.slice(
location.textSpan.start,
location.textSpan.start + location.textSpan.length,
),
new_text: `${location.prefixText ?? ''}${options.new_name}${location.suffixText ?? ''}`,
};
}),
};
} finally {
service.dispose();
}
}

View file

@ -415,9 +415,7 @@ async function getContextResource(backend: LocalBackend, repoName?: string): Pro
' - explain: Persisted taint findings — source→sink data flows with per-hop variables (requires analyze --pdg)',
);
lines.push(' - detect_changes: Git-diff impact analysis (what do your changes affect)');
lines.push(
' - rename_preview: Read-only semantic rename preview for a configured TS/JS project',
);
lines.push(' - rename_preview: Read-only graph and semantic rename preview');
lines.push(' - rename: Preview or apply exact semantic occurrences in the selected project');
lines.push(' - cypher: Raw graph queries');
lines.push(' - list_repos: Discover all indexed repositories');
@ -703,7 +701,7 @@ async function getSetupResource(backend: LocalBackend): Promise<string> {
'| `context` | 360-degree symbol view — categorized refs, processes it participates in |',
'| `impact` | Symbol blast radius — what breaks at depth 1/2/3 with confidence |',
'| `detect_changes` | Git-diff impact — what do your current changes affect |',
'| `rename_preview` | Read-only exact-span rename preview for a configured TS/JS project |',
'| `rename_preview` | Read-only graph and semantic exact-span rename preview |',
'| `rename` | Preview or apply semantic rename in the selected project |',
'| `cypher` | Raw graph queries |',
'| `list_repos` | Discover indexed repos |',

View file

@ -108,11 +108,6 @@ const RENAME_PROPERTIES: ToolDefinition['inputSchema']['properties'] = {
symbol_uid: { type: 'string', description: 'Direct symbol UID from prior tool results' },
new_name: { type: 'string', description: 'The new identifier for the symbol' },
file_path: { type: 'string', description: 'File path to disambiguate common names' },
tsconfig_path: {
type: 'string',
description:
'Repository-relative TypeScript/JavaScript project config; required when project selection is ambiguous',
},
};
export const GITNEXUS_TOOLS: ToolDefinition[] = [
@ -482,10 +477,10 @@ A graph too large to analyze at all returns \`{ error, truncated: true }\` with
},
{
name: 'rename_preview',
description: `Read-only semantic rename preview for one configured TypeScript/JavaScript project in the current checkout.
Uses the TypeScript language service to report exact UTF-16 spans, including import/export alias affixes. Comments, strings, and unrelated same-spelled symbols are not text-search replacements.
Pass symbol_uid from context() to select the declaration. If multiple project configs include it, select a returned candidate with tsconfig_path. Unsupported languages, project references, and unresolved declarations fail closed.
Returns result_version:2, semantic_edits, and coverage identifying the selected project; coverage is not repository-wide. This tool cannot apply edits.`,
description: `Read-only rename preview using the indexed graph and GitNexus's own semantic layer in the current checkout.
Graph identities and language-provider scope resolution select exact UTF-16 name spans. No compiler, language service, or text-search replacement is used.
Pass symbol_uid from context() to select the declaration. Unsupported syntax, ambiguous bindings, stale declarations, and incomplete occurrence coverage fail closed.
Returns result_version:2, semantic_edits, and coverage describing the indexed source scope and limitations. This tool cannot apply edits.`,
annotations: READ_ONLY_TOOL_ANNOTATIONS,
inputSchema: {
type: 'object',
@ -501,9 +496,9 @@ Returns result_version:2, semantic_edits, and coverage identifying the selected
},
{
name: 'rename',
description: `Semantic rename for one configured TypeScript/JavaScript project in the current checkout. Previews by default; dry_run:false applies exact compiler-selected occurrences after checking source snapshots.
Use rename_preview for read-only clients. Pass symbol_uid from context() to select the declaration and tsconfig_path when project selection is ambiguous. Unsupported languages, project references, and unresolved declarations fail closed; there is no text-search fallback.
Returns result_version:2 with semantic_edits (occurrence count), exact UTF-16 spans, project coverage, and explicit planning/application status. graph_edits and text_search_edits are zero. Partial writes report only landed edits and failed_files; review failures before retrying. Separate preview/apply calls recompute the plan.
description: `Rename using the indexed graph and GitNexus's own semantic layer. Previews by default; dry_run:false applies exact resolved name spans after checking source snapshots.
Use rename_preview for read-only clients. Pass symbol_uid from context() to select the declaration. Unsupported syntax, ambiguous bindings, and incomplete coverage fail closed; no compiler or text-search fallback is used.
Returns result_version:2 with semantic_edits (occurrence count), exact UTF-16 spans, indexed-source coverage, and explicit planning/application status. Legacy graph_edits and text_search_edits are zero. Partial writes report only landed edits and failed_files; review failures before retrying. Separate preview/apply calls recompute the plan.
Ambiguous symbol_name returns context()'s ranked candidates and totalCandidates verbatim; re-call with symbol_uid. After application, run tests and detect_changes().`,
annotations: DESTRUCTIVE_TOOL_ANNOTATIONS,
inputSchema: {

View file

@ -2549,7 +2549,7 @@ describe('LocalBackend.callTool', () => {
});
it('rename: a swallowed apply-edit write failure degrades to status:partial + failed_files (#2283)', async () => {
// A real configured project produces an edit; only its final write fails.
// Real graph/semantic resolution produces an edit; staging its replacement fails.
(executeParameterized as any)
.mockResolvedValueOnce([
{
@ -2572,7 +2572,16 @@ describe('LocalBackend.callTool', () => {
// Graph lines are zero-based; context presents this declaration on line 2.
mkdirSync(path.join(repoDir, 'src'));
writeFileSync(path.join(repoDir, 'src/target.ts'), '\nfunction oldName() {}\n');
writeFileSync(path.join(repoDir, 'tsconfig.json'), '{"include":["src/**/*.ts"]}');
lbugMocks.executeQuery.mockResolvedValue([
{ id: 'File:src/target.ts', name: 'target.ts', filePath: 'src/target.ts' },
{
id: 'Function:src/target.ts:oldName',
name: 'oldName',
filePath: 'src/target.ts',
startLine: 1,
endLine: 1,
},
]);
const writeSpy = vi
.spyOn(fsPromises, 'writeFile')
.mockRejectedValue(new Error('EACCES: permission denied'));

View file

@ -0,0 +1,589 @@
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,
) {
for (const dry_run of [true, false]) {
const result = await renameSymbol(root, symbol, { new_name: 'Renamed', dry_run }, nodes);
expect(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));
}
}
it('blocks PHP preview and apply until case-insensitive names have provider coverage', async () => {
const file = 'model.php';
const source = '<?php\nfunction target() {}\nTARGET();\n';
const nodes = await fixture(file, source);
await expectBlockedPreviewAndApply(
file,
source,
{
uid: nodes[1]!.id,
name: 'target',
kind: 'Function',
filePath: file,
startLine: 2,
},
nodes,
'unsupported_language',
);
});
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'],
])('blocks preview and apply for 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 expectBlockedPreviewAndApply(
file!,
source!,
{
uid,
name: 'Writer',
kind: 'Class',
filePath: file!,
startLine: 1,
},
nodes,
'unsupported_symbol_family',
);
});
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('blocks unclassified JSX expression occurrences instead of inventing a binding role', 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 expectBlockedPreviewAndApply(
file,
source,
{
uid: nodes[1]!.id,
name: 'target',
kind: 'Function',
filePath: file,
startLine: 1,
},
nodes,
'incomplete_semantics',
);
});
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,
});
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('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 = { close: 1 };\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('resolves a typed 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";\nfunction use(writer: Writer) { writer.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'],
[
'shorthand property',
'function target() {}\nconst object = { target };\n',
'unsupported_occurrence',
],
[
'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'],
[
'export alias',
'function target() {}\nexport { target as publicTarget };\n',
'unsupported_occurrence',
],
[
'destructuring',
'function target() {}\nconst { target: other } = object;\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('refuses a member with an 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 expect(
planGraphRename(
root,
{
uid: `Method:${file}:Base.close#0`,
name: 'close',
kind: 'Method',
filePath: file,
startLine: 2,
},
{ new_name: 'finish' },
nodes,
),
).rejects.toMatchObject({ code: 'unsupported_symbol_family' });
});
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' });
});
});

View file

@ -26,6 +26,13 @@ vi.mock('child_process', async (importActual) => ({
import { LocalBackend } from '../../src/mcp/local/local-backend.js';
const { graphRows } = vi.hoisted(() => ({ graphRows: [] as Record<string, unknown>[] }));
vi.mock('../../src/core/lbug/pool-adapter.js', async (importActual) => ({
...(await importActual<typeof import('../../src/core/lbug/pool-adapter.js')>()),
executeQuery: vi.fn(async () => graphRows),
executeParameterized: vi.fn(async () => [{ filePath: 'src/caller.ts' }]),
}));
const SOURCE = `export class Writer {
close() {}
}
@ -77,15 +84,59 @@ describe('semantic rename reports exact applied occurrences (#3486, #2605)', ()
beforeEach(async () => {
execFileSyncMock.mockClear();
graphRows.splice(
0,
graphRows.length,
...['src/writer.ts', 'src/caller.ts'].map((filePath) => ({
id: `File:${filePath}`,
name: filePath,
filePath,
})),
{
id: 'Class:src/writer.ts:Writer',
name: 'Writer',
filePath: 'src/writer.ts',
startLine: 0,
endLine: 2,
},
{
id: 'Class:src/writer.ts:Other',
name: 'Other',
filePath: 'src/writer.ts',
startLine: 3,
endLine: 5,
},
{
id: 'Method:src/writer.ts:Writer.close#0',
name: 'close',
filePath: 'src/writer.ts',
startLine: 1,
endLine: 1,
},
{
id: 'Method:src/writer.ts:Other.close#0',
name: 'close',
filePath: 'src/writer.ts',
startLine: 4,
endLine: 4,
},
{
id: 'Function:src/writer.ts:run',
name: 'run',
filePath: 'src/writer.ts',
startLine: 6,
endLine: 11,
},
{
id: 'Function:src/caller.ts:caller',
name: 'caller',
filePath: 'src/caller.ts',
startLine: 1,
endLine: 1,
},
);
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-3486-backend-'));
await fs.mkdir(path.join(tmpDir, 'src'));
await fs.writeFile(
path.join(tmpDir, 'tsconfig.json'),
JSON.stringify({
compilerOptions: { target: 'ES2022', module: 'NodeNext', moduleResolution: 'NodeNext' },
include: ['src/**/*.ts'],
}),
);
await fs.writeFile(path.join(tmpDir, 'src/writer.ts'), SOURCE);
await fs.writeFile(path.join(tmpDir, 'src/caller.ts'), CALLER);
});
@ -97,7 +148,7 @@ describe('semantic rename reports exact applied occurrences (#3486, #2605)', ()
it('previews bound occurrences, including callers absent from graph incoming files', async () => {
const result = await callRename(stubbedBackend(), tmpDir);
expect(result).toMatchObject({
expect(result, JSON.stringify(result)).toMatchObject({
status: 'success',
applied: false,
result_version: 2,
@ -107,6 +158,7 @@ describe('semantic rename reports exact applied occurrences (#3486, #2605)', ()
text_search_edits: 0,
text_search: 'not_used',
files_affected: 2,
coverage: { name: 'gitnexus-semantic', scope: 'indexed-repository' },
});
const edits = result.changes.flatMap((change: any) => change.edits);
expect(edits.every((edit: any) => edit.confidence === 'semantic')).toBe(true);
@ -122,7 +174,11 @@ describe('semantic rename reports exact applied occurrences (#3486, #2605)', ()
const backend = stubbedBackend();
const preview = await callRename(backend, tmpDir);
const result = await callRename(backend, tmpDir, { dry_run: false });
expect(result).toMatchObject({ status: 'success', applied: true, total_edits: 4 });
expect(result, JSON.stringify(result)).toMatchObject({
status: 'success',
applied: true,
total_edits: 4,
});
expect(result.changes).toEqual(preview.changes);
expect(await fs.readFile(path.join(tmpDir, 'src/writer.ts'), 'utf8')).toBe(
SOURCE.replace(' close() {}', ' closeWriter() {}').replaceAll(
@ -135,7 +191,7 @@ describe('semantic rename reports exact applied occurrences (#3486, #2605)', ()
);
});
it('refuses unsupported languages without changing the target', async () => {
it('refuses targets missing from the indexed graph', async () => {
const rust = 'fn close() {}\nfn use_it() { close(); }\n';
await fs.writeFile(path.join(tmpDir, 'src/lib.rs'), rust);
const backend = stubbedBackend({
@ -146,7 +202,7 @@ describe('semantic rename reports exact applied occurrences (#3486, #2605)', ()
endLine: 1,
});
const result = await callRename(backend, tmpDir, { dry_run: false });
expect(result).toMatchObject({ applied: false, planning_status: 'unsupported' });
expect(result, JSON.stringify(result)).toMatchObject({ applied: false, status: 'error' });
expect(await fs.readFile(path.join(tmpDir, 'src/lib.rs'), 'utf8')).toBe(rust);
});
@ -164,7 +220,7 @@ describe('semantic rename reports exact applied occurrences (#3486, #2605)', ()
return originalRename(source, destination);
});
const result = await callRename(stubbedBackend(), tmpDir, { dry_run: false });
expect(result).toMatchObject({
expect(result, JSON.stringify(result)).toMatchObject({
status: 'partial',
applied: true,
total_edits: 1,

View file

@ -21,10 +21,10 @@ describe('exact rename plan application', () => {
symbol: { name: 'close', filePath: 'a.ts', startLine: 1 },
new_name: 'finish',
coverage: {
name: 'typescript',
version: '5.9.3',
scope: 'project',
tsconfig_path: 'tsconfig.json',
name: 'gitnexus-semantic',
scope: 'indexed-repository',
languages: ['typescript'],
limitations: [],
source_file_count: Object.keys(files).length,
},
snapshots: new Map(

View file

@ -311,11 +311,12 @@ describe('intended standard-skill improvements stay in every applicable copy', (
},
);
it('teaches semantic project-scoped preview in every refactoring copy', () => {
it('teaches graph-backed semantic preview in every refactoring copy', () => {
for (const file of standardSkillCopies('gitnexus-refactoring')) {
const content = fs.readFileSync(file, 'utf-8');
expect(content).toContain('rename_preview');
expect(content).toContain('tsconfig_path');
expect(content).toContain('gitnexus-semantic');
expect(content).not.toContain('tsconfig_path');
expect(content).toContain('confidence: "semantic"');
expect(content).not.toContain('graph (safe)');
expect(content).not.toContain('ast_search');

View file

@ -1,369 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import ts from 'typescript-language-service';
import { renameSymbol, type RenameSymbol } from '../../src/mcp/local/rename/rename-plan.js';
describe('configured TypeScript semantic rename', () => {
let root: string;
beforeEach(async () => {
root = await fs.mkdtemp(path.join(os.tmpdir(), 'ts-rename-'));
});
afterEach(async () => {
vi.unstubAllEnvs();
await fs.rm(root, { recursive: true, force: true });
});
async function fixture(files: Record<string, string>, config = 'tsconfig.json') {
await fs.writeFile(
path.join(root, config),
JSON.stringify({
compilerOptions: {
target: 'ES2022',
module: 'CommonJS',
allowJs: true,
checkJs: true,
jsx: 'preserve',
skipLibCheck: true,
},
include: ['**/*'],
}),
);
for (const [file, text] of Object.entries(files)) {
await fs.mkdir(path.dirname(path.join(root, file)), { recursive: true });
await fs.writeFile(path.join(root, file), text);
}
}
const method = (name: string, startLine: number, endLine = startLine): RenameSymbol => ({
name,
kind: 'Method',
filePath: 'model.ts',
startLine,
endLine,
});
// Independent direct compiler oracle: compare every span and replacement,
// not just counts or text matches. It does not use the production planner.
function oracle(file: string, position: number, newName: string, config = 'tsconfig.json') {
const cfg = ts.readConfigFile(path.join(root, config), ts.sys.readFile);
const parsed = ts.parseJsonConfigFileContent(cfg.config, ts.sys, root);
const host: ts.LanguageServiceHost = {
...ts.sys,
getCurrentDirectory: () => root,
getCompilationSettings: () => parsed.options,
useCaseSensitiveFileNames: () => ts.sys.useCaseSensitiveFileNames,
getScriptFileNames: () => parsed.fileNames,
getScriptVersion: () => '0',
getScriptSnapshot: (filename) => {
const text = ts.sys.readFile(filename);
return text === undefined ? undefined : ts.ScriptSnapshot.fromString(text);
},
getDefaultLibFileName: (options) => ts.getDefaultLibFilePath(options),
};
const service = ts.createLanguageService(host);
try {
return service
.findRenameLocations(path.join(root, file), position, false, false, {
providePrefixAndSuffixTextForRename: true,
})!
.map((location) => ({
file_path: path.relative(root, location.fileName).split(path.sep).join('/'),
start: location.textSpan.start,
length: location.textSpan.length,
new_text: `${location.prefixText ?? ''}${newName}${location.suffixText ?? ''}`,
}))
.sort((a, b) => a.file_path.localeCompare(b.file_path) || a.start - b.start);
} finally {
service.dispose();
}
}
function diagnostics(config = 'tsconfig.json') {
const cfg = ts.readConfigFile(path.join(root, config), ts.sys.readFile);
const parsed = ts.parseJsonConfigFileContent(cfg.config, ts.sys, root);
const program = ts.createProgram(parsed.fileNames, parsed.options);
return [...program.getSyntacticDiagnostics(), ...program.getSemanticDiagnostics()]
.map((d) => `${d.code}:${ts.flattenDiagnosticMessageText(d.messageText, ' ')}`)
.sort();
}
async function assertOracle(symbol: RenameSymbol, newName: string, config?: string) {
const source = await fs.readFile(path.join(root, symbol.filePath), 'utf8');
const offset =
source
.split('\n')
.slice(0, symbol.startLine - 1)
.join('\n').length + (symbol.startLine > 1 ? 1 : 0);
const position = source.indexOf(symbol.name, offset);
const expected = oracle(symbol.filePath, position, newName, config);
const baseline = diagnostics(config);
const preview = await renameSymbol(root, symbol, { new_name: newName, tsconfig_path: config });
expect(preview.status, JSON.stringify(preview)).toBe('success');
expect(preview.coverage).toMatchObject({
name: 'typescript',
version: '5.9.3',
scope: 'project',
tsconfig_path: config ?? 'tsconfig.json',
});
expect(
preview.changes.flatMap((file) =>
file.edits.map((edit) => ({
file_path: file.file_path,
start: edit.start,
length: edit.length,
new_text: edit.new_text,
})),
),
).toEqual(expected);
expect(preview).toMatchObject({
graph_edits: 0,
text_search_edits: 0,
text_search: 'not_used',
semantic_edits: expected.length,
});
const applied = await renameSymbol(root, symbol, {
new_name: newName,
tsconfig_path: config,
dry_run: false,
});
expect(applied).toMatchObject({
status: 'success',
applied: true,
total_edits: expected.length,
});
expect(diagnostics(config)).toEqual(baseline);
return preview;
}
it('finds map iteration callers while preserving an unrelated close method and prose without rg', async () => {
await fixture({
'model.ts':
'export class SyncCsvWriter {\n close() {}\n}\nexport class PdgEmitSink { close() {} }\n',
'caller.ts':
'import { SyncCsvWriter, PdgEmitSink } from "./model";\nconst writers = new Map<string, SyncCsvWriter>();\nfor (const w of writers.values()) { w.close(); w.close(); }\nnew PdgEmitSink().close(); // close\nconst title = "close";\n',
});
vi.stubEnv('PATH', '');
const result = await assertOracle(method('close', 2), 'finish');
expect(result.total_edits).toBe(3);
expect(await fs.readFile(path.join(root, 'caller.ts'), 'utf8')).toContain(
'new PdgEmitSink().close(); // close\nconst title = "close";',
);
});
it('keeps a hook key beside this.resolve and follows base and derived overrides', async () => {
await fixture({
'model.ts':
'export class ScopeTreeHarvester {\n resolve(n: number) { return n; }\n hooks = { resolve: (n: number) => this.resolve(n) };\n}\nexport class Derived extends ScopeTreeHarvester {\n override resolve(n: number) { return super.resolve(n); }\n}\nnew Derived().resolve(1); // resolve\n',
});
await assertOracle(method('resolve', 2), 'lookup');
const text = await fs.readFile(path.join(root, 'model.ts'), 'utf8');
expect(text).toContain('hooks = { resolve: (n: number) => this.lookup(n) }');
expect(text).toContain('override lookup');
});
it('preserves compiler affixes for barrels, shorthand and destructuring', async () => {
await fixture({
'model.ts':
'export const target = 1;\nconst obj = { target };\nconst { target: local } = obj;\n',
'barrel.ts': 'export { target } from "./model";\n',
'caller.ts': 'import { target } from "./barrel";\nconst result = target;\n',
});
const result = await assertOracle(
{ name: 'target', kind: 'Const', filePath: 'model.ts', startLine: 1, endLine: 1 },
'renamed',
);
expect(
result.changes.flatMap((c) => c.edits).some((e) => e.new_text.includes(' as target')),
).toBe(true);
expect(await fs.readFile(path.join(root, 'model.ts'), 'utf8')).toContain('{ target: renamed }');
expect(await fs.readFile(path.join(root, 'caller.ts'), 'utf8')).toContain(
'const result = target;',
);
});
it('requires a config choice and allows an explicit nonstandard config', async () => {
await fixture({
'model.ts': 'export function target() {}',
'test.ts': 'import { target } from "./model"; target();',
});
await fs.writeFile(
path.join(root, 'tsconfig.test.json'),
JSON.stringify({ extends: './tsconfig.json' }),
);
const symbol = { name: 'target', kind: 'Function', filePath: 'model.ts', startLine: 1 };
const ambiguous = await renameSymbol(root, symbol, { new_name: 'changed', dry_run: false });
expect(ambiguous).toMatchObject({
planning_status: 'blocked',
applied: false,
code: 'ambiguous_project',
candidates: ['tsconfig.json', 'tsconfig.test.json'],
});
await fs.rename(path.join(root, 'tsconfig.test.json'), path.join(root, 'custom-project.json'));
await assertOracle(symbol, 'changed', 'custom-project.json');
});
it.each([
['main.js', 'jsconfig.json', '$target', 'δelta'],
['main.jsx', 'jsconfig.json', 'δelta', '$target'],
['main.tsx', 'tsconfig.json', '$target', 'δelta'],
])('supports %s and scanner-validated identifiers', async (file, config, name, next) => {
await fixture({ [file]: `export function ${name}() { return 1; }\n${name}();` }, config);
await assertOracle({ name, kind: 'Function', filePath: file, startLine: 1 }, next, config);
});
it.each(['class', 'a-b', ' foo', 'foo ', 'close', 'foo/*x*/', '\\u0061'])(
'rejects unsafe or unchanged new name %s',
async (new_name) => {
await fixture({ 'model.ts': 'class A {\nclose() {}\n}' });
expect(
await renameSymbol(root, method('close', 2), { new_name, dry_run: false }),
).toMatchObject({ planning_status: 'blocked', applied: false });
expect(await fs.readFile(path.join(root, 'model.ts'), 'utf8')).toContain('close()');
},
);
it.each([
'missing config',
'malformed config',
'not member',
'references',
'unsupported language',
] as const)('refuses %s without writes', async (reason) => {
await fixture({ 'model.ts': 'class A {\nclose() {}\n}' });
let symbol = method('close', 2);
if (reason === 'missing config') await fs.unlink(path.join(root, 'tsconfig.json'));
if (reason === 'malformed config')
await fs.writeFile(path.join(root, 'tsconfig.json'), '{ "compilerOptions":');
if (reason === 'not member')
await fs.writeFile(path.join(root, 'tsconfig.json'), '{ "files": [] }');
if (reason === 'references')
await fs.writeFile(
path.join(root, 'tsconfig.json'),
'{ "references": [{ "path": "./other" }], "include": ["model.ts"] }',
);
if (reason === 'unsupported language') symbol = { ...symbol, filePath: 'model.rs' };
expect(await renameSymbol(root, symbol, { new_name: 'finish', dry_run: false })).toMatchObject({
planning_status: 'unsupported',
applied: false,
total_edits: 0,
});
expect(await fs.readFile(path.join(root, 'model.ts'), 'utf8')).toContain('close()');
});
it.each([
{ name: 'close', kind: 'Method', filePath: 'model.ts', startLine: 2, endLine: 3 },
{ name: 'close', kind: 'Function', filePath: 'model.ts', startLine: 2, endLine: 2 },
{ name: 'close', kind: 'Method', filePath: 'model.ts', startLine: 1, endLine: 3 },
{
uid: 'Method:model.ts:Other.close#0',
name: 'close',
kind: 'Method',
filePath: 'model.ts',
startLine: 2,
endLine: 2,
},
])('rejects stale or mismatched declaration identity: %j', async (symbol) => {
await fixture({ 'model.ts': 'class A {\nclose() {}\n}' });
expect(await renameSymbol(root, symbol, { new_name: 'finish', dry_run: false })).toMatchObject({
planning_status: 'blocked',
code: 'stale_symbol',
total_edits: 0,
});
});
it('rejects ambiguous same-line declarations rather than choosing the first', async () => {
await fixture({ 'model.ts': 'class A { close() {} } class B { close() {} }' });
expect(await renameSymbol(root, method('close', 1), { new_name: 'finish' })).toMatchObject({
planning_status: 'blocked',
code: 'stale_symbol',
});
});
it('uses the indexed owner to distinguish same-line method declarations', async () => {
await fixture({
'model.ts':
'class A { close() {} } class B { close() {} }\nnew A().close(); new B().close();',
});
const result = await renameSymbol(
root,
{ ...method('close', 1), uid: 'Method:model.ts:A.close#0' },
{ new_name: 'finish', dry_run: false },
);
expect(result).toMatchObject({ status: 'success', total_edits: 2 });
expect(await fs.readFile(path.join(root, 'model.ts'), 'utf8')).toBe(
'class A { finish() {} } class B { close() {} }\nnew A().finish(); new B().close();',
);
});
it('preserves a UTF-8 BOM and astral characters preceding compiler spans', async () => {
const source = '\ufeff// 🐈\r\nexport function target() {}\r\ntarget();\r\n';
await fixture({ 'model.ts': source });
const result = await renameSymbol(
root,
{ name: 'target', kind: 'Function', filePath: 'model.ts', startLine: 2 },
{ new_name: 'changed', dry_run: false },
);
expect(result).toMatchObject({ status: 'success', total_edits: 2 });
expect(await fs.readFile(path.join(root, 'model.ts'), 'utf8')).toBe(
'\ufeff// 🐈\r\nexport function changed() {}\r\nchanged();\r\n',
);
});
it('refuses bytes that cannot round-trip through UTF-8', async () => {
await fixture({ 'model.ts': 'export function target() {}\n' });
const original = Buffer.concat([
Buffer.from('export function target() {}\n// '),
Buffer.from([0xff]),
]);
await fs.writeFile(path.join(root, 'model.ts'), original);
expect(
await renameSymbol(
root,
{ name: 'target', kind: 'Function', filePath: 'model.ts', startLine: 1 },
{ new_name: 'changed', dry_run: false },
),
).toMatchObject({
planning_status: 'unsupported',
code: 'unsupported_encoding',
applied: false,
});
expect(await fs.readFile(path.join(root, 'model.ts'))).toEqual(original);
});
it('refuses unsupported computed declaration syntax', async () => {
await fixture({ 'model.ts': 'class A { ["close"]() {} }' });
expect(
await renameSymbol(root, method('close', 1), { new_name: 'finish', dry_run: false }),
).toMatchObject({ planning_status: 'blocked', code: 'stale_symbol', total_edits: 0 });
expect(await fs.readFile(path.join(root, 'model.ts'), 'utf8')).toBe(
'class A { ["close"]() {} }',
);
});
it('rejects a semantic location outside the repository', async () => {
const outside = await fs.mkdtemp(path.join(os.tmpdir(), 'rename-dependency-'));
try {
const external = path.join(outside, 'outside.ts');
await fs.writeFile(
external,
'import { target } from ' + JSON.stringify(path.join(root, 'model')) + '; target();',
);
await fixture({ 'model.ts': 'export function target() {}' });
await fs.writeFile(
path.join(root, 'tsconfig.json'),
JSON.stringify({ files: ['model.ts', external] }),
);
expect(
await renameSymbol(
root,
{ name: 'target', kind: 'Function', filePath: 'model.ts', startLine: 1 },
{ new_name: 'changed', dry_run: false },
),
).toMatchObject({ planning_status: 'blocked', applied: false });
expect(await fs.readFile(path.join(root, 'model.ts'), 'utf8')).toBe(
'export function target() {}',
);
} finally {
await fs.rm(outside, { recursive: true, force: true });
}
});
});