From 69ed9cc39a89749f3fd3e98366c7dc67df746732 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Sat, 10 Oct 2026 22:16:09 +0300 Subject: [PATCH] fix(rename): resolve occurrences with graph and semantic data --- .claude/skills/gitnexus-refactoring/SKILL.md | 23 +- .github/workflows/ci-quality.yml | 4 +- .husky/pre-commit | 2 +- AGENTS.md | 2 +- CLAUDE.md | 2 +- CONTRIBUTING.md | 2 +- GUARDRAILS.md | 2 +- README.md | 36 +- .../skills/gitnexus-refactoring/SKILL.md | 23 +- .../skills/gitnexus-refactoring/SKILL.md | 23 +- .../src/scope-resolution/reference-site.ts | 2 + .../src/scope-resolution/symbol-definition.ts | 6 +- gitnexus/package-lock.json | 15 - gitnexus/package.json | 3 +- gitnexus/skills/gitnexus-refactoring.md | 23 +- gitnexus/src/cli/ai-context.ts | 2 +- .../src/core/ingestion/resolve-references.ts | 43 +- .../src/core/ingestion/scope-extractor.ts | 3 + .../scope-resolution/pipeline/run.ts | 7 + gitnexus/src/mcp/local/local-backend.ts | 43 +- gitnexus/src/mcp/local/rename/graph-rename.ts | 706 ++++++++++++++++++ gitnexus/src/mcp/local/rename/rename-plan.ts | 23 +- .../src/mcp/local/rename/typescript-rename.ts | 359 --------- gitnexus/src/mcp/resources.ts | 6 +- gitnexus/src/mcp/tools.ts | 19 +- gitnexus/test/unit/calltool-dispatch.test.ts | 13 +- gitnexus/test/unit/graph-rename.test.ts | 589 +++++++++++++++ gitnexus/test/unit/rename-edit-report.test.ts | 80 +- gitnexus/test/unit/rename-plan.test.ts | 8 +- .../test/unit/shipped-skills-sync.test.ts | 5 +- gitnexus/test/unit/typescript-rename.test.ts | 369 --------- 31 files changed, 1562 insertions(+), 881 deletions(-) create mode 100644 gitnexus/src/mcp/local/rename/graph-rename.ts delete mode 100644 gitnexus/src/mcp/local/rename/typescript-rename.ts create mode 100644 gitnexus/test/unit/graph-rename.test.ts delete mode 100644 gitnexus/test/unit/typescript-rename.test.ts diff --git a/.claude/skills/gitnexus-refactoring/SKILL.md b/.claude/skills/gitnexus-refactoring/SKILL.md index c8ca26147..3702f3055 100644 --- a/.claude/skills/gitnexus-refactoring/SKILL.md +++ b/.claude/skills/gitnexus-refactoring/SKILL.md @@ -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: diff --git a/.github/workflows/ci-quality.yml b/.github/workflows/ci-quality.yml index bd51aa880..dedf5edf6 100644 --- a/.github/workflows/ci-quality.yml +++ b/.github/workflows/ci-quality.yml @@ -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 diff --git a/.husky/pre-commit b/.husky/pre-commit index 180a184b4..2a04a77f0 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -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" diff --git a/AGENTS.md b/AGENTS.md index 8dd91d20e..88092705e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index f37848080..35869d251 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b2fa39487..dc9cb884d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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. diff --git a/GUARDRAILS.md b/GUARDRAILS.md index 4ef7f27ff..9214b36de 100644 --- a/GUARDRAILS.md +++ b/GUARDRAILS.md @@ -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). diff --git a/README.md b/README.md index bfcf2c3ac..17d5c7f67 100644 --- a/README.md +++ b/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 diff --git a/gitnexus-claude-plugin/skills/gitnexus-refactoring/SKILL.md b/gitnexus-claude-plugin/skills/gitnexus-refactoring/SKILL.md index c8ca26147..3702f3055 100644 --- a/gitnexus-claude-plugin/skills/gitnexus-refactoring/SKILL.md +++ b/gitnexus-claude-plugin/skills/gitnexus-refactoring/SKILL.md @@ -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: diff --git a/gitnexus-cursor-integration/skills/gitnexus-refactoring/SKILL.md b/gitnexus-cursor-integration/skills/gitnexus-refactoring/SKILL.md index c8ca26147..3702f3055 100644 --- a/gitnexus-cursor-integration/skills/gitnexus-refactoring/SKILL.md +++ b/gitnexus-cursor-integration/skills/gitnexus-refactoring/SKILL.md @@ -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: diff --git a/gitnexus-shared/src/scope-resolution/reference-site.ts b/gitnexus-shared/src/scope-resolution/reference-site.ts index 05191dc84..0d73d0034 100644 --- a/gitnexus-shared/src/scope-resolution/reference-site.ts +++ b/gitnexus-shared/src/scope-resolution/reference-site.ts @@ -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; diff --git a/gitnexus-shared/src/scope-resolution/symbol-definition.ts b/gitnexus-shared/src/scope-resolution/symbol-definition.ts index 8d59e475d..98544c9f7 100644 --- a/gitnexus-shared/src/scope-resolution/symbol-definition.ts +++ b/gitnexus-shared/src/scope-resolution/symbol-definition.ts @@ -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; diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index d91f186ee..b9a2b9dd6 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -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", diff --git a/gitnexus/package.json b/gitnexus/package.json index 4aaae0216..8b9ccc5bc 100644 --- a/gitnexus/package.json +++ b/gitnexus/package.json @@ -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": { diff --git a/gitnexus/skills/gitnexus-refactoring.md b/gitnexus/skills/gitnexus-refactoring.md index c8ca26147..3702f3055 100644 --- a/gitnexus/skills/gitnexus-refactoring.md +++ b/gitnexus/skills/gitnexus-refactoring.md @@ -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: diff --git a/gitnexus/src/cli/ai-context.ts b/gitnexus/src/cli/ai-context.ts index 762d9b536..e68f5475c 100644 --- a/gitnexus/src/cli/ai-context.ts +++ b/gitnexus/src/cli/ai-context.ts @@ -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 diff --git a/gitnexus/src/core/ingestion/resolve-references.ts b/gitnexus/src/core/ingestion/resolve-references.ts index 604eeb1d1..7a1eaedd2 100644 --- a/gitnexus/src/core/ingestion/resolve-references.ts +++ b/gitnexus/src/core/ingestion/resolve-references.ts @@ -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(); @@ -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; diff --git a/gitnexus/src/core/ingestion/scope-extractor.ts b/gitnexus/src/core/ingestion/scope-extractor.ts index 7e6bf671b..e02a297bf 100644 --- a/gitnexus/src/core/ingestion/scope-extractor.ts +++ b/gitnexus/src/core/ingestion/scope-extractor.ts @@ -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' diff --git a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts index ef5ad246d..21aec73bc 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts @@ -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, diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts index c6c3e0ea4..155b2d26f 100644 --- a/gitnexus/src/mcp/local/local-backend.ts +++ b/gitnexus/src/mcp/local/local-backend.ts @@ -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 { @@ -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 { diff --git a/gitnexus/src/mcp/local/rename/graph-rename.ts b/gitnexus/src/mcp/local/rename/graph-rename.ts new file mode 100644 index 000000000..7d330923a --- /dev/null +++ b/gitnexus/src/mcp/local/rename/graph-rename.ts @@ -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; + tokensByStart: Map; +} + +/** 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 { + 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(); + const files = new Map(); + 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(); + const tokensByStart = new Map(); + 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(); + const accounted = new Set(); + 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(); + 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(); + 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}`; +} diff --git a/gitnexus/src/mcp/local/rename/rename-plan.ts b/gitnexus/src/mcp/local/rename/rename-plan.ts index db1943731..68ce6737e 100644 --- a/gitnexus/src/mcp/local/rename/rename-plan.ts +++ b/gitnexus/src/mcp/local/rename/rename-plan.ts @@ -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; edits: OccurrenceEdit[]; } @@ -316,19 +316,14 @@ export async function renameSymbol( repoPath: string, symbol: RenameSymbol, options: RenameOptions, + graphNodes: readonly GraphNode[], + referenceFiles: readonly string[] = [], ): Promise { 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); diff --git a/gitnexus/src/mcp/local/rename/typescript-rename.ts b/gitnexus/src/mcp/local/rename/typescript-rename.ts deleted file mode 100644 index 6fe582eff..000000000 --- a/gitnexus/src/mcp/local/rename/typescript-rename.ts +++ /dev/null @@ -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(); - 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(); - } -} diff --git a/gitnexus/src/mcp/resources.ts b/gitnexus/src/mcp/resources.ts index 1e2d4c3f8..b34b33eb3 100644 --- a/gitnexus/src/mcp/resources.ts +++ b/gitnexus/src/mcp/resources.ts @@ -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 { '| `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 |', diff --git a/gitnexus/src/mcp/tools.ts b/gitnexus/src/mcp/tools.ts index 948a44539..fc2f7241a 100644 --- a/gitnexus/src/mcp/tools.ts +++ b/gitnexus/src/mcp/tools.ts @@ -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: { diff --git a/gitnexus/test/unit/calltool-dispatch.test.ts b/gitnexus/test/unit/calltool-dispatch.test.ts index 111186e71..840200c32 100644 --- a/gitnexus/test/unit/calltool-dispatch.test.ts +++ b/gitnexus/test/unit/calltool-dispatch.test.ts @@ -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')); diff --git a/gitnexus/test/unit/graph-rename.test.ts b/gitnexus/test/unit/graph-rename.test.ts new file mode 100644 index 000000000..d3cecb27c --- /dev/null +++ b/gitnexus/test/unit/graph-rename.test.ts @@ -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 = ' { + 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 = ;\ntarget();\n', + ], + [ + 'JSX attribute', + 'model.tsx', + 'function target() {}\nconst element = ;\ntarget();\n', + ], + ])('preserves the unrelated %s in preview and apply', async (_name, file, source) => { + const nodes = await fixture(file!, source!); + nodes[1]!.properties.startLine = 0; + const symbol = { + uid: nodes[1]!.id, + name: 'target', + kind: 'Function', + filePath: file!, + startLine: 1, + }; + const preview = await renameSymbol(root, symbol, { new_name: 'Renamed', dry_run: true }, nodes); + expect(preview, JSON.stringify(preview)).toMatchObject({ status: 'success', total_edits: 2 }); + expect(preview.changes[0]!.edits.map((edit) => edit.start)).toEqual([ + source!.indexOf('target'), + source!.lastIndexOf('target'), + ]); + expect(await fs.readFile(path.join(root, file!))).toEqual(Buffer.from(source!)); + const applied = await renameSymbol( + root, + symbol, + { new_name: 'Renamed', dry_run: false }, + nodes, + ); + expect(applied).toMatchObject({ status: 'success', applied: true, changes: preview.changes }); + const first = source!.indexOf('target'); + const last = source!.lastIndexOf('target'); + const expected = + source!.slice(0, first) + + 'Renamed' + + source!.slice(first + 6, last) + + 'Renamed' + + source!.slice(last + 6); + expect(await fs.readFile(path.join(root, file!))).toEqual(Buffer.from(expected)); + }); + + it('blocks unclassified JSX expression occurrences instead of inventing a binding role', async () => { + const file = 'model.tsx'; + const source = 'function target() {}\nconst element = ;\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' }); + }); +}); diff --git a/gitnexus/test/unit/rename-edit-report.test.ts b/gitnexus/test/unit/rename-edit-report.test.ts index ab394e263..46ea03373 100644 --- a/gitnexus/test/unit/rename-edit-report.test.ts +++ b/gitnexus/test/unit/rename-edit-report.test.ts @@ -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[] })); +vi.mock('../../src/core/lbug/pool-adapter.js', async (importActual) => ({ + ...(await importActual()), + 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, diff --git a/gitnexus/test/unit/rename-plan.test.ts b/gitnexus/test/unit/rename-plan.test.ts index 7123daf1c..f8802fd9d 100644 --- a/gitnexus/test/unit/rename-plan.test.ts +++ b/gitnexus/test/unit/rename-plan.test.ts @@ -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( diff --git a/gitnexus/test/unit/shipped-skills-sync.test.ts b/gitnexus/test/unit/shipped-skills-sync.test.ts index f61527067..13b74a773 100644 --- a/gitnexus/test/unit/shipped-skills-sync.test.ts +++ b/gitnexus/test/unit/shipped-skills-sync.test.ts @@ -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'); diff --git a/gitnexus/test/unit/typescript-rename.test.ts b/gitnexus/test/unit/typescript-rename.test.ts deleted file mode 100644 index 96b13a76f..000000000 --- a/gitnexus/test/unit/typescript-rename.test.ts +++ /dev/null @@ -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, 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();\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 }); - } - }); -});