mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-14 04:08:05 +00:00
fix(rename): resolve occurrences with graph and semantic data
This commit is contained in:
parent
b6dcf3e338
commit
69ed9cc39a
31 changed files with 1562 additions and 881 deletions
|
|
@ -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:
|
||||
|
|
|
|||
4
.github/workflows/ci-quality.yml
vendored
4
.github/workflows/ci-quality.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
36
README.md
36
README.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
15
gitnexus/package-lock.json
generated
15
gitnexus/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
706
gitnexus/src/mcp/local/rename/graph-rename.ts
Normal file
706
gitnexus/src/mcp/local/rename/graph-rename.ts
Normal 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}`;
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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 |',
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
|
|
|
|||
589
gitnexus/test/unit/graph-rename.test.ts
Normal file
589
gitnexus/test/unit/graph-rename.test.ts
Normal 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' });
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue