From 8fa8e643886feee66ca622a21013fa33a101a9fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Tue, 13 Oct 2026 08:38:17 +0300 Subject: [PATCH] fix(rename): extend safe refactoring and flow coverage across languages (#3557) --- .claude/skills/gitnexus-guide/SKILL.md | 2 +- .claude/skills/gitnexus-refactoring/SKILL.md | 63 +- .github/workflows/ci-tests.yml | 11 +- .github/workflows/docker.yml | 5 - AGENTS.md | 2 +- ARCHITECTURE.md | 14 +- CLAUDE.md | 2 +- GUARDRAILS.md | 2 +- README.md | 60 +- .../skills/gitnexus-guide/SKILL.md | 2 +- .../skills/gitnexus-refactoring/SKILL.md | 63 +- .../skills/gitnexus-refactoring/SKILL.md | 63 +- gitnexus-shared/src/index.ts | 12 +- gitnexus-shared/src/rename/edits.ts | 57 + gitnexus-shared/src/rename/family.ts | 125 ++ gitnexus-shared/src/rename/positions.ts | 48 + gitnexus-shared/src/rename/results.ts | 41 + gitnexus-shared/src/rename/types.ts | 111 ++ .../scope-resolution/finalize-algorithm.ts | 4 +- .../src/scope-resolution/reference-site.ts | 2 + .../scope-resolution/registries/context.ts | 12 +- .../registries/lookup-core.ts | 56 +- .../src/scope-resolution/symbol-definition.ts | 8 +- gitnexus/README.md | 6 +- .../emit-persistence/baselines-streaming.json | 5 +- .../bench/emit-persistence/baselines.json | 5 +- gitnexus/bench/import-target/baselines.json | 14 +- gitnexus/bench/import-target/measure.mjs | 64 +- gitnexus/bench/mcp-tools-list/baselines.json | 2 +- .../bench/receiver-resolution/BASELINE.md | 25 + .../bench/receiver-resolution/baseline.json | 13 +- gitnexus/bench/rename/README.md | 47 + gitnexus/bench/rename/measure.mjs | 179 +++ gitnexus/bench/scope-capture/baselines.json | 75 +- gitnexus/bench/tool-accuracy/README.md | 14 +- gitnexus/bench/tool-accuracy/expectations.ts | 28 +- .../tool-accuracy/fixtures/rename/caller.ts | 3 +- .../fixtures/rename/typed-writer.ts | 6 + .../tool-accuracy/fixtures/rename/writer.ts | 3 +- gitnexus/bench/tool-accuracy/known-gaps.json | 18 - gitnexus/bench/tool-accuracy/run.ts | 129 +- gitnexus/hooks/claude/session-start.sh | 2 +- gitnexus/scripts/cross-platform-shard.ts | 21 + gitnexus/scripts/cross-platform-tests.ts | 20 + gitnexus/skills/gitnexus-guide.md | 2 +- gitnexus/skills/gitnexus-refactoring.md | 63 +- gitnexus/src/cli/ai-context.ts | 2 +- gitnexus/src/core/analyzer-identity.ts | 8 +- gitnexus/src/core/ingestion/cfg/emit.ts | 2 + gitnexus/src/core/ingestion/cfg/types.ts | 3 + .../ingestion/cfg/visitors/c-cpp-harvest.ts | 37 +- .../src/core/ingestion/cfg/visitors/c-cpp.ts | 20 +- .../ingestion/cfg/visitors/objective-c-ast.ts | 35 + .../cfg/visitors/objective-c-harvest.ts | 161 ++ .../ingestion/cfg/visitors/objective-c.ts | 319 ++++ .../core/ingestion/cfg/visitors/zig-ast.ts | 93 ++ .../ingestion/cfg/visitors/zig-harvest.ts | 347 +++++ .../src/core/ingestion/cfg/visitors/zig.ts | 566 +++++++ .../src/core/ingestion/cobol-processor.ts | 9 +- .../src/core/ingestion/language-provider.ts | 21 +- .../src/core/ingestion/languages/c-cpp.ts | 12 +- .../ingestion/languages/c/arity-metadata.ts | 12 +- .../core/ingestion/languages/c/captures.ts | 12 +- .../src/core/ingestion/languages/c/query.ts | 22 +- .../src/core/ingestion/languages/c/rename.ts | 220 +++ .../src/core/ingestion/languages/cobol.ts | 21 +- .../ingestion/languages/cobol/captures.ts | 276 +--- .../ingestion/languages/cobol/flow-harvest.ts | 289 ++++ .../ingestion/languages/cobol/flow-source.ts | 248 +++ .../core/ingestion/languages/cobol/flow.ts | 499 ++++++ .../core/ingestion/languages/cobol/rename.ts | 211 +++ .../languages/cobol/scope-resolver.ts | 69 +- .../ingestion/languages/cobol/semantic.ts | 471 ++++++ .../languages/cpp/callable-bindings.ts | 53 + .../core/ingestion/languages/cpp/captures.ts | 10 +- .../core/ingestion/languages/cpp/rename.ts | 256 ++++ .../ingestion/languages/cpp/scope-resolver.ts | 10 +- .../src/core/ingestion/languages/csharp.ts | 9 +- .../ingestion/languages/csharp/identifiers.ts | 11 + .../core/ingestion/languages/csharp/query.ts | 9 +- .../core/ingestion/languages/csharp/rename.ts | 156 ++ .../languages/csharp/scope-resolver.ts | 43 +- gitnexus/src/core/ingestion/languages/dart.ts | 2 + .../core/ingestion/languages/dart/captures.ts | 27 +- .../ingestion/languages/dart/interpret.ts | 10 + .../core/ingestion/languages/dart/query.ts | 3 +- .../core/ingestion/languages/dart/rename.ts | 166 ++ .../languages/dart/scope-resolver.ts | 2 + gitnexus/src/core/ingestion/languages/go.ts | 2 + .../languages/go/name-fallback-visibility.ts | 10 +- .../go/namespace-member-visibility.ts | 29 + .../src/core/ingestion/languages/go/rename.ts | 239 +++ .../ingestion/languages/go/scope-resolver.ts | 2 + gitnexus/src/core/ingestion/languages/java.ts | 2 + .../core/ingestion/languages/java/captures.ts | 7 +- .../core/ingestion/languages/java/query.ts | 7 +- .../core/ingestion/languages/java/rename.ts | 212 +++ .../languages/javascript/scope-resolver.ts | 7 + .../languages/jvm/accessor-synthesis.ts | 6 +- .../languages/jvm/package-siblings.ts | 4 +- .../src/core/ingestion/languages/kotlin.ts | 2 + .../ingestion/languages/kotlin/captures.ts | 59 +- .../ingestion/languages/kotlin/identifiers.ts | 3 + .../languages/kotlin/module-resolution.ts | 10 +- .../core/ingestion/languages/kotlin/rename.ts | 307 ++++ .../languages/kotlin/scope-resolver.ts | 8 + .../core/ingestion/languages/objective-c.ts | 4 + .../ingestion/languages/objective-c/facts.ts | 373 ++++- .../ingestion/languages/objective-c/rename.ts | 744 +++++++++ .../languages/objective-c/scope-resolver.ts | 511 ++++++- gitnexus/src/core/ingestion/languages/php.ts | 2 + .../ingestion/languages/php/import-target.ts | 85 +- .../src/core/ingestion/languages/php/query.ts | 3 + .../core/ingestion/languages/php/rename.ts | 326 ++++ .../ingestion/languages/php/scope-resolver.ts | 31 +- .../src/core/ingestion/languages/python.ts | 2 + .../core/ingestion/languages/python/rename.ts | 474 ++++++ .../languages/python/string-literal.ts | 69 + .../core/ingestion/languages/rename-family.ts | 54 + .../ingestion/languages/rename-tree-sitter.ts | 162 ++ gitnexus/src/core/ingestion/languages/ruby.ts | 9 +- .../core/ingestion/languages/ruby/captures.ts | 13 + .../languages/ruby/member-dispatch.ts | 187 +++ .../core/ingestion/languages/ruby/query.ts | 4 +- .../core/ingestion/languages/ruby/rename.ts | 234 +++ .../languages/ruby/scope-resolver.ts | 18 +- gitnexus/src/core/ingestion/languages/rust.ts | 2 + .../ingestion/languages/rust/interpret.ts | 10 +- .../core/ingestion/languages/rust/rename.ts | 126 ++ .../src/core/ingestion/languages/swift.ts | 2 + .../ingestion/languages/swift/captures.ts | 20 + .../ingestion/languages/swift/identifiers.ts | 3 + .../core/ingestion/languages/swift/query.ts | 8 + .../core/ingestion/languages/swift/rename.ts | 273 ++++ .../languages/swift/scope-resolver.ts | 12 + .../core/ingestion/languages/typescript.ts | 3 + .../languages/typescript/identifiers.ts | 9 + .../languages/typescript/interpret.ts | 19 +- .../languages/typescript/module-exports.ts | 17 + .../ingestion/languages/typescript/rename.ts | 1001 ++++++++++++ .../languages/typescript/scope-resolver.ts | 6 + gitnexus/src/core/ingestion/languages/vue.ts | 2 + .../core/ingestion/languages/vue/rename.ts | 606 ++++++++ .../ingestion/languages/vue/scope-resolver.ts | 6 + gitnexus/src/core/ingestion/languages/zig.ts | 4 + .../core/ingestion/languages/zig/captures.ts | 12 + .../src/core/ingestion/languages/zig/query.ts | 6 + .../core/ingestion/languages/zig/rename.ts | 166 ++ .../src/core/ingestion/model/symbol-table.ts | 2 +- .../src/core/ingestion/rename-provider.ts | 136 ++ .../src/core/ingestion/resolve-references.ts | 236 ++- .../src/core/ingestion/scope-extractor.ts | 3 + .../contract/scope-resolver.ts | 40 +- .../graph-bridge/node-lookup.ts | 12 +- .../passes/callable-value-flow.ts | 43 + .../passes/compound-receiver.ts | 85 +- .../passes/free-call-fallback.ts | 167 +- .../passes/imported-value-refs.ts | 41 +- .../passes/property-dispatch.ts | 46 +- .../passes/receiver-bound-calls.ts | 233 ++- .../scope-resolution/pipeline/phase.ts | 4 +- .../scope-resolution/pipeline/run.ts | 85 +- .../scope/canonicalize-bindings.ts | 160 ++ .../scope-resolution/scope/walkers.ts | 59 +- .../source-binding-resolution.ts | 61 + .../utils/receiver-chain-captures.ts | 28 +- .../core/ingestion/workers/parse-worker.ts | 16 +- .../core/ingestion/workers/result-merge.ts | 9 + gitnexus/src/core/lbug/csv-generator.ts | 3 +- gitnexus/src/core/lbug/lbug-adapter.ts | 6 +- gitnexus/src/core/lbug/schema.ts | 1 + gitnexus/src/mcp/local/local-backend.ts | 311 +--- gitnexus/src/mcp/local/pdg-impact.ts | 168 +- gitnexus/src/mcp/local/rename/graph-rename.ts | 1134 ++++++++++++++ gitnexus/src/mcp/local/rename/rename-plan.ts | 214 +++ gitnexus/src/mcp/read-only-policy.ts | 51 +- gitnexus/src/mcp/resources.ts | 6 +- gitnexus/src/mcp/server.ts | 30 +- gitnexus/src/mcp/tools.ts | 45 +- gitnexus/src/storage/parse-cache.ts | 8 +- .../expected-captures.json | 180 +-- .../go-captures-golden/expected-captures.json | 28 +- gitnexus/test/fixtures/index-lock-child.mjs | 7 +- .../ruby-accessor-dispatch/app.rb | 25 + .../ruby-accessor-dispatch/model.rb | 32 + .../fixtures/pdg-explicit-owner/program.cbl | 33 + .../expected-captures.json | 4 +- .../mini-repo/expected-graph.json | 6 +- .../fixtures/rename-integration/README.md | 93 ++ .../c/ffi-unrelated/after/Other.swift | 4 + .../c/ffi-unrelated/after/api.c | 3 + .../c/ffi-unrelated/after/api.h | 1 + .../c/ffi-unrelated/after/other.go | 6 + .../c/ffi-unrelated/after/other.rs | 4 + .../c/ffi-unrelated/after/other.zig | 4 + .../c/ffi-unrelated/before/Other.swift | 4 + .../c/ffi-unrelated/before/api.c | 3 + .../c/ffi-unrelated/before/api.h | 1 + .../c/ffi-unrelated/before/other.go | 6 + .../c/ffi-unrelated/before/other.rs | 4 + .../c/ffi-unrelated/before/other.zig | 4 + .../c/ffi-unrelated/case.json | 19 + .../after/callback.c | 9 + .../before/callback.c | 9 + .../c/function-pointer-call-chain/case.json | 43 + .../c/go-cgo-struct-tag-unrelated/after/api.c | 3 + .../go-cgo-struct-tag-unrelated/after/main.go | 10 + .../go-cgo-struct-tag-unrelated/before/api.c | 3 + .../before/main.go | 10 + .../c/go-cgo-struct-tag-unrelated/case.json | 19 + .../c/go-cgo-struct-tag/after/api.c | 3 + .../c/go-cgo-struct-tag/after/main.go | 8 + .../c/go-cgo-struct-tag/before/api.c | 3 + .../c/go-cgo-struct-tag/before/main.go | 8 + .../c/go-cgo-struct-tag/case.json | 21 + .../rename-integration/c/go-cgo/after/api.c | 3 + .../rename-integration/c/go-cgo/after/api.h | 1 + .../rename-integration/c/go-cgo/after/main.go | 10 + .../rename-integration/c/go-cgo/before/api.c | 3 + .../rename-integration/c/go-cgo/before/api.h | 1 + .../c/go-cgo/before/main.go | 10 + .../rename-integration/c/go-cgo/case.json | 21 + .../c/interop-cpp-call/after/api.c | 1 + .../c/interop-cpp-call/after/caller.cpp | 1 + .../c/interop-cpp-call/before/api.c | 1 + .../c/interop-cpp-call/before/caller.cpp | 1 + .../c/interop-cpp-call/case.json | 20 + .../c/interop-objective-c-call/after/api.c | 1 + .../c/interop-objective-c-call/after/caller.m | 1 + .../c/interop-objective-c-call/before/api.c | 1 + .../interop-objective-c-call/before/caller.m | 1 + .../c/interop-objective-c-call/case.json | 20 + .../c/interop-objective-c-macro/after/api.c | 1 + .../interop-objective-c-macro/after/caller.m | 2 + .../c/interop-objective-c-macro/before/api.c | 1 + .../interop-objective-c-macro/before/caller.m | 2 + .../c/interop-objective-c-macro/case.json | 20 + .../interop-objective-c-unrelated/after/api.c | 2 + .../after/other.m | 3 + .../before/api.c | 2 + .../before/other.m | 3 + .../c/interop-objective-c-unrelated/case.json | 35 + .../c/prototype-call-chain/after/api.c | 2 + .../c/prototype-call-chain/after/api.h | 1 + .../c/prototype-call-chain/after/main.c | 6 + .../c/prototype-call-chain/before/api.c | 2 + .../c/prototype-call-chain/before/api.h | 1 + .../c/prototype-call-chain/before/main.c | 6 + .../c/prototype-call-chain/case.json | 43 + .../public-field-pointer-chain/after/model.c | 12 + .../public-field-pointer-chain/before/model.c | 12 + .../c/public-field-pointer-chain/case.json | 48 + .../rename-integration/c/rust-ffi/after/api.c | 3 + .../rename-integration/c/rust-ffi/after/api.h | 1 + .../c/rust-ffi/after/ffi.rs | 7 + .../c/rust-ffi/before/api.c | 3 + .../c/rust-ffi/before/api.h | 1 + .../c/rust-ffi/before/ffi.rs | 7 + .../rename-integration/c/rust-ffi/case.json | 21 + .../c/swift-consumer/after/App.swift | 3 + .../c/swift-consumer/after/api.c | 3 + .../c/swift-consumer/after/api.h | 1 + .../c/swift-consumer/before/App.swift | 3 + .../c/swift-consumer/before/api.c | 3 + .../c/swift-consumer/before/api.h | 1 + .../c/swift-consumer/case.json | 21 + .../c/zig-cimport/after/api.c | 3 + .../c/zig-cimport/after/api.h | 1 + .../c/zig-cimport/after/main.zig | 7 + .../c/zig-cimport/before/api.c | 3 + .../c/zig-cimport/before/api.h | 1 + .../c/zig-cimport/before/main.zig | 7 + .../c/zig-cimport/case.json | 21 + .../cobol/cross-program-call/after/driver.cbl | 9 + .../cobol/cross-program-call/after/worker.cbl | 7 + .../cross-program-call/before/driver.cbl | 9 + .../cross-program-call/before/worker.cbl | 7 + .../cobol/cross-program-call/case.json | 32 + .../nested-qualified-data/after/demo.cbl | 16 + .../nested-qualified-data/before/demo.cbl | 16 + .../cobol/nested-qualified-data/case.json | 16 + .../cobol/paragraph-call-chain/after/demo.cbl | 14 + .../paragraph-call-chain/after/other.cbl | 9 + .../paragraph-call-chain/before/demo.cbl | 14 + .../paragraph-call-chain/before/other.cbl | 9 + .../cobol/paragraph-call-chain/case.json | 77 + .../cpp/external-base-family/after/model.cpp | 6 + .../cpp/external-base-family/after/notes.txt | 1 + .../cpp/external-base-family/before/model.cpp | 6 + .../cpp/external-base-family/before/notes.txt | 1 + .../cpp/external-base-family/case.json | 22 + .../cpp/go-cgo-consumer/after/api.cpp | 1 + .../cpp/go-cgo-consumer/after/main.go | 7 + .../cpp/go-cgo-consumer/before/api.cpp | 1 + .../cpp/go-cgo-consumer/before/main.go | 7 + .../cpp/go-cgo-consumer/case.json | 21 + .../cpp/go-cgo-unrelated/after/api.cpp | 2 + .../cpp/go-cgo-unrelated/after/main.go | 4 + .../cpp/go-cgo-unrelated/before/api.cpp | 2 + .../cpp/go-cgo-unrelated/before/main.go | 4 + .../cpp/go-cgo-unrelated/case.json | 19 + .../interop-objective-cpp-call/after/api.cpp | 1 + .../after/caller.mm | 1 + .../interop-objective-cpp-call/before/api.cpp | 1 + .../before/caller.mm | 1 + .../cpp/interop-objective-cpp-call/case.json | 20 + .../overload-header-call-chain/after/api.cpp | 3 + .../overload-header-call-chain/after/api.h | 2 + .../overload-header-call-chain/after/main.cpp | 8 + .../overload-header-call-chain/before/api.cpp | 3 + .../overload-header-call-chain/before/api.h | 2 + .../before/main.cpp | 8 + .../cpp/overload-header-call-chain/case.json | 80 + .../after/model.cpp | 12 + .../before/model.cpp | 12 + .../cpp/public-field-pointer-chain/case.json | 48 + .../after/model.cpp | 4 + .../before/model.cpp | 4 + .../case.json | 21 + .../after/model.cpp | 2 + .../before/model.cpp | 2 + .../reserved-global-type-underscore/case.json | 20 + .../after/model.cpp | 2 + .../before/model.cpp | 2 + .../cpp/reserved-global-underscore/case.json | 20 + .../after/main.cpp | 4 + .../before/main.cpp | 4 + .../case.json | 31 + .../restrict-type-identifier/after/model.cpp | 4 + .../restrict-type-identifier/before/model.cpp | 4 + .../cpp/restrict-type-identifier/case.json | 30 + .../cpp/virtual-method-family/after/model.cpp | 9 + .../virtual-method-family/before/model.cpp | 9 + .../cpp/virtual-method-family/case.json | 62 + .../escaped-class-import/after/App/Main.cs | 2 + .../after/Models/Worker.cs | 1 + .../escaped-class-import/before/App/Main.cs | 2 + .../before/Models/Worker.cs | 1 + .../csharp/escaped-class-import/case.json | 49 + .../escaped-member-identity/after/Worker.cs | 2 + .../escaped-member-identity/before/Worker.cs | 2 + .../csharp/escaped-member-identity/case.json | 49 + .../after/Encoder.cs | 17 + .../before/Encoder.cs | 17 + .../escaped-overload-call-graph/case.json | 102 ++ .../escaped-receiver-identity/after/Worker.cs | 9 + .../before/Worker.cs | 9 + .../escaped-receiver-identity/case.json | 37 + .../external-base-family/after/Worker.cs | 5 + .../external-base-family/after/notes.txt | 1 + .../external-base-family/before/Worker.cs | 5 + .../external-base-family/before/notes.txt | 1 + .../csharp/external-base-family/case.json | 22 + .../after/Worker.cs | 3 + .../after/notes.txt | 1 + .../before/Worker.cs | 3 + .../before/notes.txt | 1 + .../implicit-external-base-family/case.json | 23 + .../after/Counter.cs | 10 + .../before/Counter.cs | 10 + .../named-argument-binds-parameter/case.json | 19 + .../polymorphic-delivery/after/Delivery.cs | 21 + .../polymorphic-delivery/before/Delivery.cs | 21 + .../csharp/polymorphic-delivery/case.json | 68 + .../after/Pair.cs | 9 + .../before/Pair.cs | 9 + .../case.json | 22 + .../public-field-chain/after/Profile.cs | 19 + .../public-field-chain/before/Profile.cs | 19 + .../csharp/public-field-chain/case.json | 53 + .../after/payload.dart | 6 + .../before/payload.dart | 6 + .../dart/builtin-constructor-import/case.json | 21 + .../builtin-field-import/after/report.dart | 6 + .../builtin-field-import/before/report.dart | 6 + .../dart/builtin-field-import/case.json | 19 + .../after/encoder.dart | 6 + .../before/encoder.dart | 6 + .../dart/builtin-method-abstract/case.json | 19 + .../builtin-method-import/after/encoder.dart | 6 + .../builtin-method-import/before/encoder.dart | 6 + .../dart/builtin-method-import/case.json | 19 + .../builtin-type-abstract/after/payload.dart | 6 + .../builtin-type-abstract/before/payload.dart | 6 + .../dart/builtin-type-abstract/case.json | 20 + .../builtin-type-import/after/payload.dart | 6 + .../builtin-type-import/before/payload.dart | 6 + .../dart/builtin-type-import/case.json | 20 + .../after/model.dart | 2 + .../before/model.dart | 2 + .../contextual-await-async-star/case.json | 20 + .../contextual-await-async/after/model.dart | 2 + .../contextual-await-async/before/model.dart | 2 + .../dart/contextual-await-async/case.json | 20 + .../after/model.dart | 2 + .../before/model.dart | 2 + .../dart/contextual-await-sync-star/case.json | 20 + .../after/model.dart | 2 + .../before/model.dart | 2 + .../contextual-yield-async-star/case.json | 20 + .../contextual-yield-async/after/model.dart | 2 + .../contextual-yield-async/before/model.dart | 2 + .../dart/contextual-yield-async/case.json | 20 + .../after/model.dart | 2 + .../before/model.dart | 2 + .../dart/contextual-yield-sync-star/case.json | 20 + .../external-base-family/after/model.dart | 6 + .../dart/external-base-family/after/notes.txt | 1 + .../external-base-family/before/model.dart | 6 + .../external-base-family/before/notes.txt | 1 + .../dart/external-base-family/case.json | 22 + .../after/point.dart | 8 + .../before/point.dart | 8 + .../field-initializing-formal-label/case.json | 22 + .../after/model.dart | 4 + .../after/notes.txt | 1 + .../before/model.dart | 4 + .../before/notes.txt | 1 + .../implicit-external-base-family/case.json | 23 + .../polymorphic-delivery/after/delivery.dart | 20 + .../polymorphic-delivery/before/delivery.dart | 20 + .../dart/polymorphic-delivery/case.json | 83 + .../prefixed-call-graph/after/client.dart | 6 + .../prefixed-call-graph/after/encoder.dart | 8 + .../dart/prefixed-call-graph/after/other.dart | 4 + .../prefixed-call-graph/before/client.dart | 6 + .../prefixed-call-graph/before/encoder.dart | 8 + .../prefixed-call-graph/before/other.dart | 4 + .../dart/prefixed-call-graph/case.json | 39 + .../public-field-chain/after/profile.dart | 17 + .../public-field-chain/before/profile.dart | 17 + .../dart/public-field-chain/case.json | 53 + .../unrelated-named-label/after/point.dart | 10 + .../unrelated-named-label/before/point.dart | 10 + .../dart/unrelated-named-label/case.json | 19 + .../go/c-consumer/after/caller.c | 3 + .../go/c-consumer/after/lib.go | 10 + .../go/c-consumer/before/caller.c | 3 + .../go/c-consumer/before/lib.go | 10 + .../go/c-consumer/case.json | 21 + .../after/api/api.go | 5 + .../after/go.mod | 3 + .../after/main.go | 7 + .../before/api/api.go | 5 + .../before/go.mod | 3 + .../before/main.go | 7 + .../case.json | 30 + .../exported-field-pointer-chain/after/go.mod | 3 + .../after/model.go | 14 + .../before/go.mod | 3 + .../before/model.go | 14 + .../go/exported-field-pointer-chain/case.json | 48 + .../go/generic-type-collision/after/go.mod | 3 + .../go/generic-type-collision/after/model.go | 4 + .../go/generic-type-collision/before/go.mod | 3 + .../go/generic-type-collision/before/model.go | 4 + .../go/generic-type-collision/case.json | 16 + .../go/generic-type-shadow/after/go.mod | 3 + .../go/generic-type-shadow/after/model.go | 9 + .../go/generic-type-shadow/before/go.mod | 3 + .../go/generic-type-shadow/before/model.go | 9 + .../go/generic-type-shadow/case.json | 30 + .../package-alias-call-chain/after/api/api.go | 3 + .../go/package-alias-call-chain/after/go.mod | 3 + .../go/package-alias-call-chain/after/main.go | 9 + .../before/api/api.go | 3 + .../go/package-alias-call-chain/before/go.mod | 3 + .../package-alias-call-chain/before/main.go | 9 + .../go/package-alias-call-chain/case.json | 43 + .../structural-interface-family/after/go.mod | 3 + .../after/model.go | 12 + .../structural-interface-family/before/go.mod | 3 + .../before/model.go | 12 + .../go/structural-interface-family/case.json | 47 + .../after/api/config.go | 5 + .../unexport-cross-package-field/after/go.mod | 3 + .../after/main.go | 10 + .../before/api/config.go | 5 + .../before/go.mod | 3 + .../before/main.go | 10 + .../go/unexport-cross-package-field/case.json | 16 + .../after/api/api.go | 3 + .../after/go.mod | 3 + .../after/main.go | 7 + .../before/api/api.go | 3 + .../before/go.mod | 3 + .../before/main.go | 7 + .../unexport-cross-package-function/case.json | 16 + .../after/api/service.go | 7 + .../after/go.mod | 3 + .../after/main.go | 10 + .../before/api/service.go | 7 + .../before/go.mod | 3 + .../before/main.go | 10 + .../unexport-cross-package-method/case.json | 16 + .../after/api/api.go | 5 + .../after/go.mod | 3 + .../after/main.go | 9 + .../before/api/api.go | 5 + .../before/go.mod | 3 + .../before/main.go | 9 + .../case.json | 16 + .../after/api/api.go | 3 + .../after/go.mod | 3 + .../after/main.go | 7 + .../before/api/api.go | 3 + .../before/go.mod | 3 + .../before/main.go | 7 + .../unexport-cross-package-unicode/case.json | 16 + .../after/api/api.go | 5 + .../after/api/helper.go | 3 + .../after/go.mod | 3 + .../after/main.go | 7 + .../before/api/api.go | 5 + .../before/api/helper.go | 3 + .../before/go.mod | 3 + .../before/main.go | 7 + .../unexport-same-package-function/case.json | 30 + .../external-base-family/after/Worker.java | 5 + .../java/external-base-family/after/notes.txt | 1 + .../external-base-family/before/Worker.java | 5 + .../external-base-family/before/notes.txt | 1 + .../java/external-base-family/case.json | 22 + .../after/Worker.java | 3 + .../after/notes.txt | 1 + .../before/Worker.java | 3 + .../before/notes.txt | 1 + .../implicit-external-base-family/case.json | 23 + .../after/Worker.java | 3 + .../after/notes.txt | 1 + .../before/Worker.java | 3 + .../before/notes.txt | 1 + .../case.json | 24 + .../after/Worker.java | 3 + .../after/notes.txt | 1 + .../before/Worker.java | 3 + .../before/notes.txt | 1 + .../case.json | 24 + .../java/interop-kotlin-call/after/Caller.kt | 1 + .../interop-kotlin-call/after/Writer.java | 3 + .../java/interop-kotlin-call/before/Caller.kt | 1 + .../interop-kotlin-call/before/Writer.java | 3 + .../java/interop-kotlin-call/case.json | 20 + .../interop-kotlin-property/after/Caller.kt | 1 + .../interop-kotlin-property/after/Writer.java | 3 + .../interop-kotlin-property/before/Caller.kt | 1 + .../before/Writer.java | 3 + .../java/interop-kotlin-property/case.json | 20 + .../overload-call-graph/after/Encoder.java | 12 + .../overload-call-graph/before/Encoder.java | 12 + .../java/overload-call-graph/case.json | 84 + .../polymorphic-delivery/after/Delivery.java | 21 + .../polymorphic-delivery/before/Delivery.java | 21 + .../java/polymorphic-delivery/case.json | 68 + .../public-field-chain/after/Profile.java | 19 + .../public-field-chain/before/Profile.java | 19 + .../java/public-field-chain/case.json | 53 + .../after/Metric.java | 6 + .../before/Metric.java | 6 + .../reserved-escaped-underscore/case.json | 20 + .../after/Metric.java | 6 + .../before/Metric.java | 6 + .../java/reserved-single-underscore/case.json | 20 + .../after/Metric.java | 6 + .../before/Metric.java | 6 + .../java/underscore-prefix-method/case.json | 19 + .../escaped-alias-shorthand/after/entry.js | 6 + .../escaped-alias-shorthand/after/middle.js | 2 + .../escaped-alias-shorthand/after/source.js | 2 + .../escaped-alias-shorthand/before/entry.js | 6 + .../escaped-alias-shorthand/before/middle.js | 2 + .../escaped-alias-shorthand/before/source.js | 2 + .../escaped-alias-shorthand/case.json | 37 + .../escaped-callable-flow/after/callbacks.js | 4 + .../escaped-callable-flow/before/callbacks.js | 4 + .../escaped-callable-flow/case.json | 62 + .../after/entry.js | 2 + .../after/source.js | 1 + .../before/entry.js | 2 + .../before/source.js | 1 + .../escaped-import-local-spelling/case.json | 35 + .../escaped-import-spellings/after/barrel.js | 1 + .../after/different.js | 2 + .../escaped-import-spellings/after/entry.js | 2 + .../escaped-import-spellings/after/plain.js | 2 + .../escaped-import-spellings/after/same.js | 2 + .../escaped-import-spellings/after/source.js | 1 + .../escaped-import-spellings/before/barrel.js | 1 + .../before/different.js | 2 + .../escaped-import-spellings/before/entry.js | 2 + .../escaped-import-spellings/before/plain.js | 2 + .../escaped-import-spellings/before/same.js | 2 + .../escaped-import-spellings/before/source.js | 1 + .../escaped-import-spellings/case.json | 74 + .../external-base-family/after/model.js | 5 + .../external-base-family/after/notes.txt | 1 + .../external-base-family/before/model.js | 5 + .../external-base-family/before/notes.txt | 1 + .../javascript/external-base-family/case.json | 22 + .../inherited-method-family/after/model.js | 16 + .../inherited-method-family/before/model.js | 16 + .../inherited-method-family/case.json | 51 + .../member-destructure-refusal/after/model.js | 8 + .../before/model.js | 8 + .../member-destructure-refusal/case.json | 38 + .../member-object-key-refusal/after/model.js | 8 + .../member-object-key-refusal/before/model.js | 8 + .../member-object-key-refusal/case.json | 38 + .../after/model.js | 4 + .../before/model.js | 4 + .../prototype-function-this-refusal/case.json | 17 + .../public-field-chain/after/model.js | 18 + .../public-field-chain/before/model.js | 18 + .../javascript/public-field-chain/case.json | 21 + .../data-class-copy-label/after/Model.kt | 3 + .../data-class-copy-label/before/Model.kt | 3 + .../kotlin/data-class-copy-label/case.json | 22 + .../escaped-class-import/after/app/Main.kt | 3 + .../after/models/Models.kt | 4 + .../escaped-class-import/before/app/Main.kt | 3 + .../before/models/Models.kt | 4 + .../kotlin/escaped-class-import/case.json | 36 + .../escaped-member-identity/after/worker.kt | 4 + .../escaped-member-identity/before/worker.kt | 4 + .../kotlin/escaped-member-identity/case.json | 36 + .../after/encoder.kt | 17 + .../before/encoder.kt | 17 + .../escaped-overload-call-graph/case.json | 101 ++ .../escaped-receiver-identity/after/worker.kt | 7 + .../before/worker.kt | 7 + .../escaped-receiver-identity/case.json | 37 + .../external-base-family/after/model.kt | 5 + .../external-base-family/after/notes.txt | 1 + .../external-base-family/before/model.kt | 5 + .../external-base-family/before/notes.txt | 1 + .../kotlin/external-base-family/case.json | 22 + .../after/model.kt | 3 + .../after/notes.txt | 1 + .../before/model.kt | 3 + .../before/notes.txt | 1 + .../implicit-external-base-family/case.json | 23 + .../interop-java-accessor/after/Caller.java | 3 + .../interop-java-accessor/after/Writer.kt | 3 + .../interop-java-accessor/before/Caller.java | 3 + .../interop-java-accessor/before/Writer.kt | 3 + .../kotlin/interop-java-accessor/case.json | 20 + .../interop-java-call/after/Caller.java | 3 + .../kotlin/interop-java-call/after/Writer.kt | 3 + .../interop-java-call/before/Caller.java | 3 + .../kotlin/interop-java-call/before/Writer.kt | 3 + .../kotlin/interop-java-call/case.json | 20 + .../after/Caller.java | 3 + .../interop-java-destination/after/Writer.kt | 3 + .../before/Caller.java | 3 + .../interop-java-destination/before/Writer.kt | 3 + .../kotlin/interop-java-destination/case.json | 20 + .../interop-java-unrelated/after/Other.java | 3 + .../interop-java-unrelated/after/Writer.kt | 4 + .../interop-java-unrelated/before/Other.java | 3 + .../interop-java-unrelated/before/Writer.kt | 4 + .../kotlin/interop-java-unrelated/case.json | 35 + .../polymorphic-delivery/after/delivery.kt | 19 + .../polymorphic-delivery/before/delivery.kt | 19 + .../kotlin/polymorphic-delivery/case.json | 68 + .../after/Model.kt | 3 + .../before/Model.kt | 3 + .../primary-property-named-argument/case.json | 22 + .../public-property-chain/after/profile.kt | 17 + .../public-property-chain/before/profile.kt | 17 + .../kotlin/public-property-chain/case.json | 53 + .../unrelated-named-argument/after/Model.kt | 12 + .../unrelated-named-argument/before/Model.kt | 12 + .../kotlin/unrelated-named-argument/case.json | 19 + .../c-helper-call-chain/after/Helpers.m | 7 + .../c-helper-call-chain/before/Helpers.m | 7 + .../objective-c/c-helper-call-chain/case.json | 62 + .../objective-c/interop-c-call/after/api.m | 1 + .../objective-c/interop-c-call/after/caller.c | 1 + .../objective-c/interop-c-call/before/api.m | 1 + .../interop-c-call/before/caller.c | 1 + .../objective-c/interop-c-call/case.json | 20 + .../ivar-and-c-function/after/Worker.m | 13 + .../ivar-and-c-function/before/Worker.m | 13 + .../objective-c/ivar-and-c-function/case.json | 44 + .../ivar-and-c-global/after/Worker.m | 13 + .../ivar-and-c-global/before/Worker.m | 13 + .../objective-c/ivar-and-c-global/case.json | 16 + .../after/Model.m | 18 + .../before/Model.m | 18 + .../property-accessors-deep-chain/case.json | 33 + .../protocol-selector-family/after/Worker.m | 20 + .../protocol-selector-family/before/Worker.m | 20 + .../protocol-selector-family/case.json | 108 ++ .../swift-consumer/after/App.swift | 3 + .../objective-c/swift-consumer/after/Foo.h | 3 + .../objective-c/swift-consumer/after/Foo.m | 4 + .../swift-consumer/before/App.swift | 3 + .../objective-c/swift-consumer/before/Foo.h | 3 + .../objective-c/swift-consumer/before/Foo.m | 4 + .../objective-c/swift-consumer/case.json | 21 + .../swift-init-label-consumer/after/App.swift | 3 + .../swift-init-label-consumer/after/Foo.h | 3 + .../swift-init-label-consumer/after/Foo.m | 4 + .../before/App.swift | 3 + .../swift-init-label-consumer/before/Foo.h | 3 + .../swift-init-label-consumer/before/Foo.m | 4 + .../swift-init-label-consumer/case.json | 21 + .../swift-multipart-consumer/after/App.swift | 3 + .../swift-multipart-consumer/after/Foo.h | 3 + .../swift-multipart-consumer/after/Foo.m | 4 + .../swift-multipart-consumer/before/App.swift | 3 + .../swift-multipart-consumer/before/Foo.h | 3 + .../swift-multipart-consumer/before/Foo.m | 4 + .../swift-multipart-consumer/case.json | 21 + .../swift-name-attribute-embedded/after/Foo.h | 4 + .../swift-name-attribute-embedded/after/Foo.m | 5 + .../before/Foo.h | 4 + .../before/Foo.m | 5 + .../swift-name-attribute-embedded/case.json | 21 + .../after/App.swift | 3 + .../after/Foo.h | 3 + .../after/Foo.m | 4 + .../before/App.swift | 3 + .../before/Foo.h | 3 + .../before/Foo.m | 4 + .../case.json | 18 + .../swift-refined-consumer/after/App.swift | 3 + .../swift-refined-consumer/after/Foo.h | 3 + .../swift-refined-consumer/after/Foo.m | 4 + .../swift-refined-consumer/before/App.swift | 3 + .../swift-refined-consumer/before/Foo.h | 3 + .../swift-refined-consumer/before/Foo.m | 4 + .../swift-refined-consumer/case.json | 21 + .../objective-c/swift-unrelated/after/Foo.h | 3 + .../objective-c/swift-unrelated/after/Foo.m | 4 + .../swift-unrelated/after/Other.swift | 3 + .../objective-c/swift-unrelated/before/Foo.h | 3 + .../objective-c/swift-unrelated/before/Foo.m | 4 + .../swift-unrelated/before/Other.swift | 3 + .../objective-c/swift-unrelated/case.json | 19 + .../php/external-base-family/after/model.php | 4 + .../php/external-base-family/after/notes.txt | 1 + .../php/external-base-family/before/model.php | 4 + .../php/external-base-family/before/notes.txt | 1 + .../php/external-base-family/case.json | 22 + .../after/Source/functions.php | 4 + .../after/composer.json | 1 + .../grouped-function-aliases/after/entry.php | 5 + .../grouped-function-aliases/after/middle.php | 3 + .../before/Source/functions.php | 4 + .../before/composer.json | 1 + .../grouped-function-aliases/before/entry.php | 5 + .../before/middle.php | 3 + .../php/grouped-function-aliases/case.json | 49 + .../php/interface-family-case/after/model.php | 18 + .../interface-family-case/before/model.php | 18 + .../php/interface-family-case/case.json | 52 + .../php/public-property-chain/after/model.php | 18 + .../public-property-chain/before/model.php | 18 + .../php/public-property-chain/case.json | 36 + .../dataclass-keyword-label/after/model.py | 7 + .../dataclass-keyword-label/before/model.py | 7 + .../python/dataclass-keyword-label/case.json | 22 + .../after/model.py | 8 + .../before/model.py | 8 + .../destination-dynamic-attribute/case.json | 39 + .../destination-name-mangling/after/model.py | 6 + .../destination-name-mangling/before/model.py | 6 + .../destination-name-mangling/case.json | 22 + .../external-base-family/after/model.py | 5 + .../external-base-family/after/notes.txt | 1 + .../external-base-family/before/model.py | 5 + .../external-base-family/before/notes.txt | 1 + .../python/external-base-family/case.json | 22 + .../after/model.py | 8 + .../before/model.py | 8 + .../inherited-dynamic-attribute/case.json | 22 + .../inherited-method-family/after/model.py | 14 + .../inherited-method-family/before/model.py | 14 + .../python/inherited-method-family/case.json | 50 + .../matching-dynamic-attribute/after/model.py | 9 + .../before/model.py | 9 + .../matching-dynamic-attribute/case.json | 21 + .../module-export-destination/after/model.py | 3 + .../module-export-destination/before/model.py | 3 + .../module-export-destination/case.json | 37 + .../module-export-dynamic/after/model.py | 3 + .../module-export-dynamic/before/model.py | 3 + .../python/module-export-dynamic/case.json | 37 + .../python/module-export-list/after/model.py | 3 + .../python/module-export-list/before/model.py | 3 + .../python/module-export-list/case.json | 37 + .../module-export-mutation/after/model.py | 4 + .../module-export-mutation/before/model.py | 4 + .../python/module-export-mutation/case.json | 37 + .../module-export-reexport/after/model.py | 2 + .../module-export-reexport/after/public.py | 2 + .../module-export-reexport/before/model.py | 2 + .../module-export-reexport/before/public.py | 2 + .../python/module-export-reexport/case.json | 37 + .../normalized-import-alias/after/entry.py | 8 + .../normalized-import-alias/after/middle.py | 4 + .../normalized-import-alias/after/source.py | 3 + .../normalized-import-alias/before/entry.py | 8 + .../normalized-import-alias/before/middle.py | 4 + .../normalized-import-alias/before/source.py | 3 + .../python/normalized-import-alias/case.json | 36 + .../private-mangled-member/after/model.py | 8 + .../private-mangled-member/before/model.py | 8 + .../python/private-mangled-member/case.json | 21 + .../python/public-field-chain/after/model.py | 15 + .../python/public-field-chain/before/model.py | 15 + .../python/public-field-chain/case.json | 36 + .../special-method-protocol/after/model.py | 4 + .../special-method-protocol/before/model.py | 4 + .../python/special-method-protocol/case.json | 22 + .../unknown-dynamic-attribute/after/model.py | 10 + .../unknown-dynamic-attribute/before/model.py | 10 + .../unknown-dynamic-attribute/case.json | 21 + .../unrelated-export-literals/after/model.py | 9 + .../unrelated-export-literals/after/other.py | 2 + .../unrelated-export-literals/before/model.py | 9 + .../unrelated-export-literals/before/other.py | 2 + .../unrelated-export-literals/case.json | 36 + .../unrelated-keyword-label/after/model.py | 20 + .../unrelated-keyword-label/before/model.py | 20 + .../python/unrelated-keyword-label/case.json | 19 + .../after/model.py | 16 + .../before/model.py | 16 + .../unrelated-literal-attribute/case.json | 20 + .../after/model.py | 15 + .../before/model.py | 15 + .../case.json | 20 + .../ruby/external-base-family/after/model.rb | 5 + .../ruby/external-base-family/after/notes.txt | 1 + .../ruby/external-base-family/before/model.rb | 5 + .../external-base-family/before/notes.txt | 1 + .../ruby/external-base-family/case.json | 22 + .../mixin-predicate-family/after/model.rb | 23 + .../mixin-predicate-family/before/model.rb | 23 + .../ruby/mixin-predicate-family/case.json | 39 + .../after/entry.rb | 8 + .../after/middle.rb | 7 + .../after/source.rb | 10 + .../before/entry.rb | 8 + .../before/middle.rb | 7 + .../before/source.rb | 10 + .../required-qualified-constant/case.json | 50 + .../ruby/setter-receiver-chain/after/model.rb | 26 + .../setter-receiver-chain/before/model.rb | 26 + .../ruby/setter-receiver-chain/case.json | 22 + .../rust/c-consumer/after/caller.c | 3 + .../rust/c-consumer/after/lib.rs | 4 + .../rust/c-consumer/before/caller.c | 3 + .../rust/c-consumer/before/lib.rs | 4 + .../rust/c-consumer/case.json | 21 + .../rust/module-alias-call-chain/after/lib.rs | 8 + .../after/operations.rs | 1 + .../module-alias-call-chain/before/lib.rs | 8 + .../before/operations.rs | 1 + .../rust/module-alias-call-chain/case.json | 43 + .../public-field-borrow-chain/after/model.rs | 12 + .../public-field-borrow-chain/before/model.rs | 12 + .../rust/public-field-borrow-chain/case.json | 48 + .../rust/trait-method-family/after/model.rs | 10 + .../rust/trait-method-family/before/model.rs | 10 + .../rust/trait-method-family/case.json | 47 + .../after/worker.swift | 4 + .../before/worker.swift | 4 + .../swift/escaped-member-identity/case.json | 49 + .../after/encoder.swift | 13 + .../before/encoder.swift | 13 + .../escaped-overload-call-graph/case.json | 87 ++ .../after/worker.swift | 7 + .../before/worker.swift | 7 + .../swift/escaped-receiver-identity/case.json | 37 + .../after/Package.swift | 3 + .../after/Sources/App/Main.swift | 2 + .../after/Sources/Vendor/Tools.swift | 1 + .../before/Package.swift | 3 + .../before/Sources/App/Main.swift | 2 + .../before/Sources/Vendor/Tools.swift | 1 + .../swift/escaped-selective-import/case.json | 37 + .../explicit-init-label/after/Shapes.swift | 15 + .../explicit-init-label/before/Shapes.swift | 15 + .../swift/explicit-init-label/case.json | 19 + .../external-base-family/after/model.swift | 5 + .../external-base-family/after/notes.txt | 1 + .../external-base-family/before/model.swift | 5 + .../external-base-family/before/notes.txt | 1 + .../swift/external-base-family/case.json | 22 + .../memberwise-init-label/after/Point.swift | 6 + .../memberwise-init-label/before/Point.swift | 6 + .../swift/memberwise-init-label/case.json | 22 + .../after/Greeter.swift | 3 + .../objc-argument-consumer/after/caller.m | 3 + .../before/Greeter.swift | 3 + .../objc-argument-consumer/before/caller.m | 3 + .../swift/objc-argument-consumer/case.json | 21 + .../swift/objc-consumer/after/Greeter.swift | 3 + .../swift/objc-consumer/after/caller.m | 3 + .../swift/objc-consumer/before/Greeter.swift | 3 + .../swift/objc-consumer/before/caller.m | 3 + .../swift/objc-consumer/case.json | 21 + .../after/Greeter.swift | 3 + .../after/caller.m | 3 + .../before/Greeter.swift | 3 + .../before/caller.m | 3 + .../objc-init-constructor-refused/case.json | 21 + .../after/Foo.h | 3 + .../after/Impl.swift | 3 + .../before/Foo.h | 3 + .../before/Impl.swift | 3 + .../case.json | 21 + .../after/Greeter.swift | 5 + .../objc-unrelated-selector/after/Other.m | 5 + .../before/Greeter.swift | 5 + .../objc-unrelated-selector/before/Other.m | 5 + .../swift/objc-unrelated-selector/case.json | 19 + .../after/Greeter.swift | 3 + .../objc-with-label-consumer/after/caller.m | 3 + .../before/Greeter.swift | 3 + .../objc-with-label-consumer/before/caller.m | 3 + .../swift/objc-with-label-consumer/case.json | 21 + .../polymorphic-delivery/after/delivery.swift | 19 + .../before/delivery.swift | 19 + .../swift/polymorphic-delivery/case.json | 68 + .../public-property-chain/after/profile.swift | 17 + .../before/profile.swift | 17 + .../swift/public-property-chain/case.json | 53 + .../after/owners.swift | 12 + .../before/owners.swift | 12 + .../unqualified-owner-visibility/case.json | 69 + .../after/entry.ts | 6 + .../after/middle.ts | 2 + .../after/source.ts | 2 + .../before/entry.ts | 6 + .../before/middle.ts | 2 + .../before/source.ts | 2 + .../aliases-shorthand-call-chain/case.json | 49 + .../after/caller.ts | 10 + .../after/model.ts | 12 + .../before/caller.ts | 10 + .../before/model.ts | 12 + .../deep-chain-intermediate-write/case.json | 40 + .../after/entry.ts | 5 + .../after/model.ts | 6 + .../before/entry.ts | 5 + .../before/model.ts | 6 + .../case.json | 24 + .../after/caller.ts | 9 + .../after/model.ts | 12 + .../before/caller.ts | 9 + .../before/model.ts | 12 + .../case.json | 40 + .../escaped-callable-flow/after/callbacks.ts | 4 + .../escaped-callable-flow/before/callbacks.ts | 4 + .../escaped-callable-flow/case.json | 62 + .../escaped-import-destination/after/entry.ts | 2 + .../after/source.ts | 1 + .../before/entry.ts | 2 + .../before/source.ts | 1 + .../escaped-import-destination/case.json | 35 + .../after/entry.ts | 2 + .../after/source.ts | 1 + .../before/entry.ts | 2 + .../before/source.ts | 1 + .../escaped-import-local-spelling/case.json | 35 + .../after/entry.ts | 2 + .../after/model.ts | 16 + .../before/entry.ts | 2 + .../before/model.ts | 16 + .../exported-constructor-binding/case.json | 38 + .../external-base-family/after/model.ts | 5 + .../external-base-family/after/notes.txt | 1 + .../external-base-family/before/model.ts | 5 + .../external-base-family/before/notes.txt | 1 + .../typescript/external-base-family/case.json | 22 + .../after/model.ts | 6 + .../after/notes.txt | 1 + .../before/model.ts | 6 + .../before/notes.txt | 1 + .../external-base-private-member/case.json | 36 + .../after/entry.ts | 3 + .../after/model.ts | 16 + .../before/entry.ts | 3 + .../before/model.ts | 16 + .../imported-receiver-alias-write/case.json | 23 + .../after/entry.ts | 2 + .../after/model.ts | 16 + .../after/mutator.ts | 2 + .../before/entry.ts | 2 + .../before/model.ts | 16 + .../before/mutator.ts | 2 + .../case.json | 23 + .../after/model.ts | 7 + .../before/model.ts | 7 + .../case.json | 8 + .../after/model.ts | 8 + .../before/model.ts | 8 + .../case.json | 64 + .../after/model.ts | 8 + .../before/model.ts | 8 + .../case.json | 38 + .../after/model.ts | 8 + .../before/model.ts | 8 + .../case.json | 38 + .../after/model.ts | 8 + .../before/model.ts | 8 + .../case.json | 38 + .../after/model.ts | 14 + .../before/model.ts | 14 + .../member-keys-unrelated-owner/case.json | 37 + .../after/model.ts | 8 + .../before/model.ts | 8 + .../member-object-method-refusal/case.json | 38 + .../member-object-pair-refusal/after/model.ts | 8 + .../before/model.ts | 8 + .../member-object-pair-refusal/case.json | 38 + .../polymorphic-family/after/model.ts | 20 + .../polymorphic-family/before/model.ts | 20 + .../typescript/polymorphic-family/case.json | 52 + .../public-field-chain/after/caller.ts | 9 + .../public-field-chain/after/model.ts | 12 + .../public-field-chain/before/caller.ts | 9 + .../public-field-chain/before/model.ts | 12 + .../typescript/public-field-chain/case.json | 38 + .../after/model.ts | 16 + .../before/model.ts | 16 + .../structural-constructor-mismatch/case.json | 23 + .../after/caller.ts | 7 + .../after/model.ts | 12 + .../before/caller.ts | 7 + .../before/model.ts | 12 + .../case.json | 41 + .../after/model.ts | 16 + .../before/model.ts | 16 + .../case.json | 55 + .../after/model.ts | 4 + .../before/model.ts | 4 + .../this-parameter-function-refusal/case.json | 17 + .../after/model.ts | 6 + .../before/model.ts | 6 + .../transitive-external-base-family/case.json | 38 + .../component-tags-and-alias/after/App.vue | 5 + .../component-tags-and-alias/after/Other.vue | 4 + .../after/components.ts | 1 + .../component-tags-and-alias/before/App.vue | 5 + .../component-tags-and-alias/before/Other.vue | 4 + .../before/components.ts | 1 + .../vue/component-tags-and-alias/case.json | 22 + .../vue/escaped-import-source/after/App.vue | 5 + .../vue/escaped-import-source/after/source.ts | 1 + .../vue/escaped-import-source/before/App.vue | 5 + .../escaped-import-source/before/source.ts | 1 + .../vue/escaped-import-source/case.json | 35 + .../vue/external-base-family/after/App.vue | 7 + .../vue/external-base-family/after/notes.txt | 1 + .../vue/external-base-family/before/App.vue | 7 + .../vue/external-base-family/before/notes.txt | 1 + .../vue/external-base-family/case.json | 22 + .../vue/import-alias-call-chain/after/App.vue | 7 + .../vue/import-alias-call-chain/after/api.ts | 1 + .../import-alias-call-chain/after/middle.ts | 2 + .../import-alias-call-chain/before/App.vue | 7 + .../vue/import-alias-call-chain/before/api.ts | 1 + .../import-alias-call-chain/before/middle.ts | 2 + .../vue/import-alias-call-chain/case.json | 49 + .../member-destructure-refusal/after/App.vue | 9 + .../member-destructure-refusal/after/model.ts | 3 + .../member-destructure-refusal/before/App.vue | 9 + .../before/model.ts | 3 + .../vue/member-destructure-refusal/case.json | 38 + .../member-object-key-refusal/after/App.vue | 9 + .../member-object-key-refusal/after/model.ts | 3 + .../member-object-key-refusal/before/App.vue | 9 + .../member-object-key-refusal/before/model.ts | 3 + .../vue/member-object-key-refusal/case.json | 38 + .../raw-block-close-boundaries/after/App.vue | 8 + .../raw-block-close-boundaries/before/App.vue | 8 + .../vue/raw-block-close-boundaries/case.json | 20 + .../setup-template-css-shadows/after/App.vue | 14 + .../setup-template-css-shadows/before/App.vue | 14 + .../vue/setup-template-css-shadows/case.json | 23 + .../zig/c-consumer/after/caller.c | 3 + .../zig/c-consumer/after/lib.zig | 3 + .../zig/c-consumer/before/caller.c | 3 + .../zig/c-consumer/before/lib.zig | 3 + .../zig/c-consumer/case.json | 21 + .../import-alias-call-chain/after/main.zig | 3 + .../import-alias-call-chain/after/service.zig | 4 + .../import-alias-call-chain/before/main.zig | 3 + .../before/service.zig | 4 + .../zig/import-alias-call-chain/case.json | 77 + .../after/model.zig | 7 + .../before/model.zig | 7 + .../zig/reflected-hasdecl-new-name/case.json | 40 + .../after/model.zig | 7 + .../before/model.zig | 7 + .../zig/reflected-hasdecl-old-name/case.json | 34 + .../after/model.zig | 11 + .../before/model.zig | 11 + .../case.json | 33 + .../after/model.zig | 5 + .../before/model.zig | 5 + .../zig/reflected-hasfield-new-name/case.json | 40 + .../after/model.zig | 5 + .../before/model.zig | 5 + .../zig/reflected-hasfield-old-name/case.json | 34 + .../after/model.zig | 7 + .../before/model.zig | 7 + .../case.json | 33 + .../after/model.zig | 7 + .../before/model.zig | 7 + .../zig/typed-container-deep-field/case.json | 93 ++ .../typed-container-method/after/model.zig | 6 + .../typed-container-method/before/model.zig | 6 + .../zig/typed-container-method/case.json | 78 + .../fixtures/rename-planning-safety/model.ts | 7 + .../expected-captures.json | 54 +- .../expected-captures.json | 10 +- .../test/helpers/grammar-introspection.ts | 1 + gitnexus/test/helpers/literal-collectors.ts | 9 +- .../test/helpers/rename-integration-cases.ts | 82 + .../helpers/rename-integration-runner.mjs | 238 +++ .../helpers/rename-planning-safety-runner.mjs | 110 ++ .../test/helpers/rename-provider-fixture.ts | 71 + .../analyze-index-lock-concurrency.test.ts | 38 + .../integration/basicblock-roundtrip.test.ts | 114 +- .../__snapshots__/pipeline-pdg.test.ts.snap | 8 +- .../cfg/new-provider-pipeline.test.ts | 159 ++ .../integration/cfg/worker-roundtrip.test.ts | 19 +- .../grammar-literal-validation.test.ts | 26 + .../local-backend-calltool.test.ts | 108 +- .../integration/pdg-explicit-owner.test.ts | 203 +++ .../rename-planning-safety.test.ts | 57 + .../integration/rename-provider-c.test.ts | 3 + .../integration/rename-provider-cobol.test.ts | 3 + .../integration/rename-provider-cpp.test.ts | 3 + .../rename-provider-csharp.test.ts | 3 + .../integration/rename-provider-dart.test.ts | 3 + .../integration/rename-provider-go.test.ts | 3 + .../integration/rename-provider-java.test.ts | 3 + .../rename-provider-javascript.test.ts | 3 + .../rename-provider-kotlin.test.ts | 3 + .../rename-provider-matrix.test.ts | 32 + .../rename-provider-objective-c.test.ts | 3 + .../integration/rename-provider-php.test.ts | 3 + .../rename-provider-python.test.ts | 3 + .../integration/rename-provider-ruby.test.ts | 3 + .../integration/rename-provider-rust.test.ts | 3 + .../integration/rename-provider-swift.test.ts | 3 + .../rename-provider-typescript.test.ts | 3 + .../integration/rename-provider-vue.test.ts | 3 + .../integration/rename-provider-zig.test.ts | 3 + .../test/integration/resolvers/cpp.test.ts | 6 +- .../go-namespace-member-visibility.test.ts | 167 ++ .../integration/resolvers/java-lombok.test.ts | 37 + .../resolvers/kotlin-jvm-accessors.test.ts | 34 + .../resolvers/ruby-accessor-dispatch.test.ts | 53 + .../integration/resolvers/typescript.test.ts | 27 +- gitnexus/test/unit/ai-context.test.ts | 16 + gitnexus/test/unit/analyzer-identity.test.ts | 31 + .../unit/basicblock-callee-ids-schema.test.ts | 28 +- gitnexus/test/unit/calltool-dispatch.test.ts | 174 ++- .../canonicalize-workspace-bindings.test.ts | 147 ++ gitnexus/test/unit/cfg/cobol-visitor.test.ts | 331 ++++ gitnexus/test/unit/cfg/explicit-owner.test.ts | 36 + .../test/unit/cfg/objective-c-visitor.test.ts | 401 +++++ .../test/unit/cfg/source-provider.test.ts | 77 + gitnexus/test/unit/cfg/zig-visitor.test.ts | 809 ++++++++++ .../test/unit/dart-import-resolver.test.ts | 23 + gitnexus/test/unit/graph-rename.test.ts | 1362 +++++++++++++++++ .../unit/impact-context-integrity.test.ts | 3 +- .../test/unit/incremental-parse-cache.test.ts | 9 +- .../unit/kotlin-lombok-synthesizer.test.ts | 9 +- .../test/unit/kotlin-scope-captures.test.ts | 42 + gitnexus/test/unit/lombok-synthesizer.test.ts | 18 +- gitnexus/test/unit/mcp-read-only.test.ts | 212 ++- .../test/unit/objective-c-provider.test.ts | 65 +- gitnexus/test/unit/pdg-impact-engine.test.ts | 115 ++ .../test/unit/release-docker-gate.test.ts | 11 +- .../test/unit/rename-cobol-providers.test.ts | 512 +++++++ .../unit/rename-dynamic-providers.test.ts | 555 +++++++ gitnexus/test/unit/rename-edit-report.test.ts | 391 ++--- gitnexus/test/unit/rename-family.test.ts | 96 ++ .../test/unit/rename-go-reresolution.test.ts | 201 +++ .../test/unit/rename-go-visibility.test.ts | 248 +++ .../unit/rename-managed-identifiers.test.ts | 35 + .../unit/rename-managed-providers.test.ts | 688 +++++++++ .../test/unit/rename-native-providers.test.ts | 1113 ++++++++++++++ gitnexus/test/unit/rename-objective-c.test.ts | 434 ++++++ .../unit/rename-php-ruby-providers.test.ts | 646 ++++++++ gitnexus/test/unit/rename-plan.test.ts | 359 +++++ .../test/unit/rename-planning-errors.test.ts | 80 + .../test/unit/rename-python-exports.test.ts | 84 + .../test/unit/rename-python-members.test.ts | 379 +++++ gitnexus/test/unit/rename-retention.test.ts | 172 +++ .../rename-typescript-member-keys.test.ts | 98 ++ .../test/unit/rename-vue-providers.test.ts | 477 ++++++ .../test/unit/rename-zig-reflection.test.ts | 203 +++ gitnexus/test/unit/result-merge.test.ts | 27 + .../scope-resolution/c/c-captures.test.ts | 6 +- .../scope-resolution/cpp/cpp-captures.test.ts | 16 + .../csharp/csharp-captures.test.ts | 16 + .../free-call-fallback-guess-taint.test.ts | 25 +- .../java/java-captures.test.ts | 35 + .../namespace-member-visibility.test.ts | 166 ++ .../node-lookup-determinism.test.ts | 30 + .../php/php-import-target.test.ts | 34 + .../resolve-references.test.ts | 548 +++++++ .../scope-resolution/run-progress.test.ts | 69 +- .../scope-source-content-policy.test.ts | 1 + .../typescript-identifier-imports.test.ts | 72 + gitnexus/test/unit/server.test.ts | 48 + .../test/unit/shipped-skills-sync.test.ts | 91 +- .../test/unit/swift-scope-captures.test.ts | 42 + .../test/unit/tool-accuracy-score.test.ts | 4 +- gitnexus/test/unit/tools.test.ts | 14 +- .../unit/ts-receiver-chain-capture.test.ts | 26 +- gitnexus/test/unit/zig-extractors.test.ts | 22 +- gitnexus/vitest.config.ts | 4 + 1222 files changed, 40339 insertions(+), 1525 deletions(-) create mode 100644 gitnexus-shared/src/rename/edits.ts create mode 100644 gitnexus-shared/src/rename/family.ts create mode 100644 gitnexus-shared/src/rename/positions.ts create mode 100644 gitnexus-shared/src/rename/results.ts create mode 100644 gitnexus-shared/src/rename/types.ts create mode 100644 gitnexus/bench/rename/README.md create mode 100644 gitnexus/bench/rename/measure.mjs create mode 100644 gitnexus/bench/tool-accuracy/fixtures/rename/typed-writer.ts create mode 100644 gitnexus/src/core/ingestion/cfg/visitors/objective-c-ast.ts create mode 100644 gitnexus/src/core/ingestion/cfg/visitors/objective-c-harvest.ts create mode 100644 gitnexus/src/core/ingestion/cfg/visitors/objective-c.ts create mode 100644 gitnexus/src/core/ingestion/cfg/visitors/zig-ast.ts create mode 100644 gitnexus/src/core/ingestion/cfg/visitors/zig-harvest.ts create mode 100644 gitnexus/src/core/ingestion/cfg/visitors/zig.ts create mode 100644 gitnexus/src/core/ingestion/languages/c/rename.ts create mode 100644 gitnexus/src/core/ingestion/languages/cobol/flow-harvest.ts create mode 100644 gitnexus/src/core/ingestion/languages/cobol/flow-source.ts create mode 100644 gitnexus/src/core/ingestion/languages/cobol/flow.ts create mode 100644 gitnexus/src/core/ingestion/languages/cobol/rename.ts create mode 100644 gitnexus/src/core/ingestion/languages/cobol/semantic.ts create mode 100644 gitnexus/src/core/ingestion/languages/cpp/rename.ts create mode 100644 gitnexus/src/core/ingestion/languages/csharp/identifiers.ts create mode 100644 gitnexus/src/core/ingestion/languages/csharp/rename.ts create mode 100644 gitnexus/src/core/ingestion/languages/dart/rename.ts create mode 100644 gitnexus/src/core/ingestion/languages/go/namespace-member-visibility.ts create mode 100644 gitnexus/src/core/ingestion/languages/go/rename.ts create mode 100644 gitnexus/src/core/ingestion/languages/java/rename.ts create mode 100644 gitnexus/src/core/ingestion/languages/kotlin/identifiers.ts create mode 100644 gitnexus/src/core/ingestion/languages/kotlin/rename.ts create mode 100644 gitnexus/src/core/ingestion/languages/objective-c/rename.ts create mode 100644 gitnexus/src/core/ingestion/languages/php/rename.ts create mode 100644 gitnexus/src/core/ingestion/languages/python/rename.ts create mode 100644 gitnexus/src/core/ingestion/languages/python/string-literal.ts create mode 100644 gitnexus/src/core/ingestion/languages/rename-family.ts create mode 100644 gitnexus/src/core/ingestion/languages/rename-tree-sitter.ts create mode 100644 gitnexus/src/core/ingestion/languages/ruby/member-dispatch.ts create mode 100644 gitnexus/src/core/ingestion/languages/ruby/rename.ts create mode 100644 gitnexus/src/core/ingestion/languages/rust/rename.ts create mode 100644 gitnexus/src/core/ingestion/languages/swift/identifiers.ts create mode 100644 gitnexus/src/core/ingestion/languages/swift/rename.ts create mode 100644 gitnexus/src/core/ingestion/languages/typescript/identifiers.ts create mode 100644 gitnexus/src/core/ingestion/languages/typescript/module-exports.ts create mode 100644 gitnexus/src/core/ingestion/languages/typescript/rename.ts create mode 100644 gitnexus/src/core/ingestion/languages/vue/rename.ts create mode 100644 gitnexus/src/core/ingestion/languages/zig/rename.ts create mode 100644 gitnexus/src/core/ingestion/rename-provider.ts create mode 100644 gitnexus/src/core/ingestion/scope-resolution/scope/canonicalize-bindings.ts create mode 100644 gitnexus/src/core/ingestion/scope-resolution/source-binding-resolution.ts create mode 100644 gitnexus/src/mcp/local/rename/graph-rename.ts create mode 100644 gitnexus/src/mcp/local/rename/rename-plan.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/ruby-accessor-dispatch/app.rb create mode 100644 gitnexus/test/fixtures/lang-resolution/ruby-accessor-dispatch/model.rb create mode 100644 gitnexus/test/fixtures/pdg-explicit-owner/program.cbl create mode 100644 gitnexus/test/fixtures/rename-integration/README.md create mode 100644 gitnexus/test/fixtures/rename-integration/c/ffi-unrelated/after/Other.swift create mode 100644 gitnexus/test/fixtures/rename-integration/c/ffi-unrelated/after/api.c create mode 100644 gitnexus/test/fixtures/rename-integration/c/ffi-unrelated/after/api.h create mode 100644 gitnexus/test/fixtures/rename-integration/c/ffi-unrelated/after/other.go create mode 100644 gitnexus/test/fixtures/rename-integration/c/ffi-unrelated/after/other.rs create mode 100644 gitnexus/test/fixtures/rename-integration/c/ffi-unrelated/after/other.zig create mode 100644 gitnexus/test/fixtures/rename-integration/c/ffi-unrelated/before/Other.swift create mode 100644 gitnexus/test/fixtures/rename-integration/c/ffi-unrelated/before/api.c create mode 100644 gitnexus/test/fixtures/rename-integration/c/ffi-unrelated/before/api.h create mode 100644 gitnexus/test/fixtures/rename-integration/c/ffi-unrelated/before/other.go create mode 100644 gitnexus/test/fixtures/rename-integration/c/ffi-unrelated/before/other.rs create mode 100644 gitnexus/test/fixtures/rename-integration/c/ffi-unrelated/before/other.zig create mode 100644 gitnexus/test/fixtures/rename-integration/c/ffi-unrelated/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/c/function-pointer-call-chain/after/callback.c create mode 100644 gitnexus/test/fixtures/rename-integration/c/function-pointer-call-chain/before/callback.c create mode 100644 gitnexus/test/fixtures/rename-integration/c/function-pointer-call-chain/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/c/go-cgo-struct-tag-unrelated/after/api.c create mode 100644 gitnexus/test/fixtures/rename-integration/c/go-cgo-struct-tag-unrelated/after/main.go create mode 100644 gitnexus/test/fixtures/rename-integration/c/go-cgo-struct-tag-unrelated/before/api.c create mode 100644 gitnexus/test/fixtures/rename-integration/c/go-cgo-struct-tag-unrelated/before/main.go create mode 100644 gitnexus/test/fixtures/rename-integration/c/go-cgo-struct-tag-unrelated/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/c/go-cgo-struct-tag/after/api.c create mode 100644 gitnexus/test/fixtures/rename-integration/c/go-cgo-struct-tag/after/main.go create mode 100644 gitnexus/test/fixtures/rename-integration/c/go-cgo-struct-tag/before/api.c create mode 100644 gitnexus/test/fixtures/rename-integration/c/go-cgo-struct-tag/before/main.go create mode 100644 gitnexus/test/fixtures/rename-integration/c/go-cgo-struct-tag/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/c/go-cgo/after/api.c create mode 100644 gitnexus/test/fixtures/rename-integration/c/go-cgo/after/api.h create mode 100644 gitnexus/test/fixtures/rename-integration/c/go-cgo/after/main.go create mode 100644 gitnexus/test/fixtures/rename-integration/c/go-cgo/before/api.c create mode 100644 gitnexus/test/fixtures/rename-integration/c/go-cgo/before/api.h create mode 100644 gitnexus/test/fixtures/rename-integration/c/go-cgo/before/main.go create mode 100644 gitnexus/test/fixtures/rename-integration/c/go-cgo/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/c/interop-cpp-call/after/api.c create mode 100644 gitnexus/test/fixtures/rename-integration/c/interop-cpp-call/after/caller.cpp create mode 100644 gitnexus/test/fixtures/rename-integration/c/interop-cpp-call/before/api.c create mode 100644 gitnexus/test/fixtures/rename-integration/c/interop-cpp-call/before/caller.cpp create mode 100644 gitnexus/test/fixtures/rename-integration/c/interop-cpp-call/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/c/interop-objective-c-call/after/api.c create mode 100644 gitnexus/test/fixtures/rename-integration/c/interop-objective-c-call/after/caller.m create mode 100644 gitnexus/test/fixtures/rename-integration/c/interop-objective-c-call/before/api.c create mode 100644 gitnexus/test/fixtures/rename-integration/c/interop-objective-c-call/before/caller.m create mode 100644 gitnexus/test/fixtures/rename-integration/c/interop-objective-c-call/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/c/interop-objective-c-macro/after/api.c create mode 100644 gitnexus/test/fixtures/rename-integration/c/interop-objective-c-macro/after/caller.m create mode 100644 gitnexus/test/fixtures/rename-integration/c/interop-objective-c-macro/before/api.c create mode 100644 gitnexus/test/fixtures/rename-integration/c/interop-objective-c-macro/before/caller.m create mode 100644 gitnexus/test/fixtures/rename-integration/c/interop-objective-c-macro/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/c/interop-objective-c-unrelated/after/api.c create mode 100644 gitnexus/test/fixtures/rename-integration/c/interop-objective-c-unrelated/after/other.m create mode 100644 gitnexus/test/fixtures/rename-integration/c/interop-objective-c-unrelated/before/api.c create mode 100644 gitnexus/test/fixtures/rename-integration/c/interop-objective-c-unrelated/before/other.m create mode 100644 gitnexus/test/fixtures/rename-integration/c/interop-objective-c-unrelated/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/c/prototype-call-chain/after/api.c create mode 100644 gitnexus/test/fixtures/rename-integration/c/prototype-call-chain/after/api.h create mode 100644 gitnexus/test/fixtures/rename-integration/c/prototype-call-chain/after/main.c create mode 100644 gitnexus/test/fixtures/rename-integration/c/prototype-call-chain/before/api.c create mode 100644 gitnexus/test/fixtures/rename-integration/c/prototype-call-chain/before/api.h create mode 100644 gitnexus/test/fixtures/rename-integration/c/prototype-call-chain/before/main.c create mode 100644 gitnexus/test/fixtures/rename-integration/c/prototype-call-chain/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/c/public-field-pointer-chain/after/model.c create mode 100644 gitnexus/test/fixtures/rename-integration/c/public-field-pointer-chain/before/model.c create mode 100644 gitnexus/test/fixtures/rename-integration/c/public-field-pointer-chain/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/c/rust-ffi/after/api.c create mode 100644 gitnexus/test/fixtures/rename-integration/c/rust-ffi/after/api.h create mode 100644 gitnexus/test/fixtures/rename-integration/c/rust-ffi/after/ffi.rs create mode 100644 gitnexus/test/fixtures/rename-integration/c/rust-ffi/before/api.c create mode 100644 gitnexus/test/fixtures/rename-integration/c/rust-ffi/before/api.h create mode 100644 gitnexus/test/fixtures/rename-integration/c/rust-ffi/before/ffi.rs create mode 100644 gitnexus/test/fixtures/rename-integration/c/rust-ffi/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/c/swift-consumer/after/App.swift create mode 100644 gitnexus/test/fixtures/rename-integration/c/swift-consumer/after/api.c create mode 100644 gitnexus/test/fixtures/rename-integration/c/swift-consumer/after/api.h create mode 100644 gitnexus/test/fixtures/rename-integration/c/swift-consumer/before/App.swift create mode 100644 gitnexus/test/fixtures/rename-integration/c/swift-consumer/before/api.c create mode 100644 gitnexus/test/fixtures/rename-integration/c/swift-consumer/before/api.h create mode 100644 gitnexus/test/fixtures/rename-integration/c/swift-consumer/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/c/zig-cimport/after/api.c create mode 100644 gitnexus/test/fixtures/rename-integration/c/zig-cimport/after/api.h create mode 100644 gitnexus/test/fixtures/rename-integration/c/zig-cimport/after/main.zig create mode 100644 gitnexus/test/fixtures/rename-integration/c/zig-cimport/before/api.c create mode 100644 gitnexus/test/fixtures/rename-integration/c/zig-cimport/before/api.h create mode 100644 gitnexus/test/fixtures/rename-integration/c/zig-cimport/before/main.zig create mode 100644 gitnexus/test/fixtures/rename-integration/c/zig-cimport/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/cobol/cross-program-call/after/driver.cbl create mode 100644 gitnexus/test/fixtures/rename-integration/cobol/cross-program-call/after/worker.cbl create mode 100644 gitnexus/test/fixtures/rename-integration/cobol/cross-program-call/before/driver.cbl create mode 100644 gitnexus/test/fixtures/rename-integration/cobol/cross-program-call/before/worker.cbl create mode 100644 gitnexus/test/fixtures/rename-integration/cobol/cross-program-call/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/cobol/nested-qualified-data/after/demo.cbl create mode 100644 gitnexus/test/fixtures/rename-integration/cobol/nested-qualified-data/before/demo.cbl create mode 100644 gitnexus/test/fixtures/rename-integration/cobol/nested-qualified-data/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/cobol/paragraph-call-chain/after/demo.cbl create mode 100644 gitnexus/test/fixtures/rename-integration/cobol/paragraph-call-chain/after/other.cbl create mode 100644 gitnexus/test/fixtures/rename-integration/cobol/paragraph-call-chain/before/demo.cbl create mode 100644 gitnexus/test/fixtures/rename-integration/cobol/paragraph-call-chain/before/other.cbl create mode 100644 gitnexus/test/fixtures/rename-integration/cobol/paragraph-call-chain/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/cpp/external-base-family/after/model.cpp create mode 100644 gitnexus/test/fixtures/rename-integration/cpp/external-base-family/after/notes.txt create mode 100644 gitnexus/test/fixtures/rename-integration/cpp/external-base-family/before/model.cpp create mode 100644 gitnexus/test/fixtures/rename-integration/cpp/external-base-family/before/notes.txt create mode 100644 gitnexus/test/fixtures/rename-integration/cpp/external-base-family/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/cpp/go-cgo-consumer/after/api.cpp create mode 100644 gitnexus/test/fixtures/rename-integration/cpp/go-cgo-consumer/after/main.go create mode 100644 gitnexus/test/fixtures/rename-integration/cpp/go-cgo-consumer/before/api.cpp create mode 100644 gitnexus/test/fixtures/rename-integration/cpp/go-cgo-consumer/before/main.go create mode 100644 gitnexus/test/fixtures/rename-integration/cpp/go-cgo-consumer/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/cpp/go-cgo-unrelated/after/api.cpp create mode 100644 gitnexus/test/fixtures/rename-integration/cpp/go-cgo-unrelated/after/main.go create mode 100644 gitnexus/test/fixtures/rename-integration/cpp/go-cgo-unrelated/before/api.cpp create mode 100644 gitnexus/test/fixtures/rename-integration/cpp/go-cgo-unrelated/before/main.go create mode 100644 gitnexus/test/fixtures/rename-integration/cpp/go-cgo-unrelated/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/cpp/interop-objective-cpp-call/after/api.cpp create mode 100644 gitnexus/test/fixtures/rename-integration/cpp/interop-objective-cpp-call/after/caller.mm create mode 100644 gitnexus/test/fixtures/rename-integration/cpp/interop-objective-cpp-call/before/api.cpp create mode 100644 gitnexus/test/fixtures/rename-integration/cpp/interop-objective-cpp-call/before/caller.mm create mode 100644 gitnexus/test/fixtures/rename-integration/cpp/interop-objective-cpp-call/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/cpp/overload-header-call-chain/after/api.cpp create mode 100644 gitnexus/test/fixtures/rename-integration/cpp/overload-header-call-chain/after/api.h create mode 100644 gitnexus/test/fixtures/rename-integration/cpp/overload-header-call-chain/after/main.cpp create mode 100644 gitnexus/test/fixtures/rename-integration/cpp/overload-header-call-chain/before/api.cpp create mode 100644 gitnexus/test/fixtures/rename-integration/cpp/overload-header-call-chain/before/api.h create mode 100644 gitnexus/test/fixtures/rename-integration/cpp/overload-header-call-chain/before/main.cpp create mode 100644 gitnexus/test/fixtures/rename-integration/cpp/overload-header-call-chain/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/cpp/public-field-pointer-chain/after/model.cpp create mode 100644 gitnexus/test/fixtures/rename-integration/cpp/public-field-pointer-chain/before/model.cpp create mode 100644 gitnexus/test/fixtures/rename-integration/cpp/public-field-pointer-chain/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/cpp/reserved-constructor-type-underscore/after/model.cpp create mode 100644 gitnexus/test/fixtures/rename-integration/cpp/reserved-constructor-type-underscore/before/model.cpp create mode 100644 gitnexus/test/fixtures/rename-integration/cpp/reserved-constructor-type-underscore/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/cpp/reserved-global-type-underscore/after/model.cpp create mode 100644 gitnexus/test/fixtures/rename-integration/cpp/reserved-global-type-underscore/before/model.cpp create mode 100644 gitnexus/test/fixtures/rename-integration/cpp/reserved-global-type-underscore/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/cpp/reserved-global-underscore/after/model.cpp create mode 100644 gitnexus/test/fixtures/rename-integration/cpp/reserved-global-underscore/before/model.cpp create mode 100644 gitnexus/test/fixtures/rename-integration/cpp/reserved-global-underscore/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/cpp/restrict-function-grammar-refusal/after/main.cpp create mode 100644 gitnexus/test/fixtures/rename-integration/cpp/restrict-function-grammar-refusal/before/main.cpp create mode 100644 gitnexus/test/fixtures/rename-integration/cpp/restrict-function-grammar-refusal/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/cpp/restrict-type-identifier/after/model.cpp create mode 100644 gitnexus/test/fixtures/rename-integration/cpp/restrict-type-identifier/before/model.cpp create mode 100644 gitnexus/test/fixtures/rename-integration/cpp/restrict-type-identifier/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/cpp/virtual-method-family/after/model.cpp create mode 100644 gitnexus/test/fixtures/rename-integration/cpp/virtual-method-family/before/model.cpp create mode 100644 gitnexus/test/fixtures/rename-integration/cpp/virtual-method-family/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/csharp/escaped-class-import/after/App/Main.cs create mode 100644 gitnexus/test/fixtures/rename-integration/csharp/escaped-class-import/after/Models/Worker.cs create mode 100644 gitnexus/test/fixtures/rename-integration/csharp/escaped-class-import/before/App/Main.cs create mode 100644 gitnexus/test/fixtures/rename-integration/csharp/escaped-class-import/before/Models/Worker.cs create mode 100644 gitnexus/test/fixtures/rename-integration/csharp/escaped-class-import/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/csharp/escaped-member-identity/after/Worker.cs create mode 100644 gitnexus/test/fixtures/rename-integration/csharp/escaped-member-identity/before/Worker.cs create mode 100644 gitnexus/test/fixtures/rename-integration/csharp/escaped-member-identity/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/csharp/escaped-overload-call-graph/after/Encoder.cs create mode 100644 gitnexus/test/fixtures/rename-integration/csharp/escaped-overload-call-graph/before/Encoder.cs create mode 100644 gitnexus/test/fixtures/rename-integration/csharp/escaped-overload-call-graph/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/csharp/escaped-receiver-identity/after/Worker.cs create mode 100644 gitnexus/test/fixtures/rename-integration/csharp/escaped-receiver-identity/before/Worker.cs create mode 100644 gitnexus/test/fixtures/rename-integration/csharp/escaped-receiver-identity/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/csharp/external-base-family/after/Worker.cs create mode 100644 gitnexus/test/fixtures/rename-integration/csharp/external-base-family/after/notes.txt create mode 100644 gitnexus/test/fixtures/rename-integration/csharp/external-base-family/before/Worker.cs create mode 100644 gitnexus/test/fixtures/rename-integration/csharp/external-base-family/before/notes.txt create mode 100644 gitnexus/test/fixtures/rename-integration/csharp/external-base-family/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/csharp/implicit-external-base-family/after/Worker.cs create mode 100644 gitnexus/test/fixtures/rename-integration/csharp/implicit-external-base-family/after/notes.txt create mode 100644 gitnexus/test/fixtures/rename-integration/csharp/implicit-external-base-family/before/Worker.cs create mode 100644 gitnexus/test/fixtures/rename-integration/csharp/implicit-external-base-family/before/notes.txt create mode 100644 gitnexus/test/fixtures/rename-integration/csharp/implicit-external-base-family/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/csharp/named-argument-binds-parameter/after/Counter.cs create mode 100644 gitnexus/test/fixtures/rename-integration/csharp/named-argument-binds-parameter/before/Counter.cs create mode 100644 gitnexus/test/fixtures/rename-integration/csharp/named-argument-binds-parameter/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/csharp/polymorphic-delivery/after/Delivery.cs create mode 100644 gitnexus/test/fixtures/rename-integration/csharp/polymorphic-delivery/before/Delivery.cs create mode 100644 gitnexus/test/fixtures/rename-integration/csharp/polymorphic-delivery/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/csharp/positional-record-property-label/after/Pair.cs create mode 100644 gitnexus/test/fixtures/rename-integration/csharp/positional-record-property-label/before/Pair.cs create mode 100644 gitnexus/test/fixtures/rename-integration/csharp/positional-record-property-label/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/csharp/public-field-chain/after/Profile.cs create mode 100644 gitnexus/test/fixtures/rename-integration/csharp/public-field-chain/before/Profile.cs create mode 100644 gitnexus/test/fixtures/rename-integration/csharp/public-field-chain/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/dart/builtin-constructor-import/after/payload.dart create mode 100644 gitnexus/test/fixtures/rename-integration/dart/builtin-constructor-import/before/payload.dart create mode 100644 gitnexus/test/fixtures/rename-integration/dart/builtin-constructor-import/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/dart/builtin-field-import/after/report.dart create mode 100644 gitnexus/test/fixtures/rename-integration/dart/builtin-field-import/before/report.dart create mode 100644 gitnexus/test/fixtures/rename-integration/dart/builtin-field-import/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/dart/builtin-method-abstract/after/encoder.dart create mode 100644 gitnexus/test/fixtures/rename-integration/dart/builtin-method-abstract/before/encoder.dart create mode 100644 gitnexus/test/fixtures/rename-integration/dart/builtin-method-abstract/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/dart/builtin-method-import/after/encoder.dart create mode 100644 gitnexus/test/fixtures/rename-integration/dart/builtin-method-import/before/encoder.dart create mode 100644 gitnexus/test/fixtures/rename-integration/dart/builtin-method-import/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/dart/builtin-type-abstract/after/payload.dart create mode 100644 gitnexus/test/fixtures/rename-integration/dart/builtin-type-abstract/before/payload.dart create mode 100644 gitnexus/test/fixtures/rename-integration/dart/builtin-type-abstract/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/dart/builtin-type-import/after/payload.dart create mode 100644 gitnexus/test/fixtures/rename-integration/dart/builtin-type-import/before/payload.dart create mode 100644 gitnexus/test/fixtures/rename-integration/dart/builtin-type-import/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/dart/contextual-await-async-star/after/model.dart create mode 100644 gitnexus/test/fixtures/rename-integration/dart/contextual-await-async-star/before/model.dart create mode 100644 gitnexus/test/fixtures/rename-integration/dart/contextual-await-async-star/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/dart/contextual-await-async/after/model.dart create mode 100644 gitnexus/test/fixtures/rename-integration/dart/contextual-await-async/before/model.dart create mode 100644 gitnexus/test/fixtures/rename-integration/dart/contextual-await-async/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/dart/contextual-await-sync-star/after/model.dart create mode 100644 gitnexus/test/fixtures/rename-integration/dart/contextual-await-sync-star/before/model.dart create mode 100644 gitnexus/test/fixtures/rename-integration/dart/contextual-await-sync-star/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/dart/contextual-yield-async-star/after/model.dart create mode 100644 gitnexus/test/fixtures/rename-integration/dart/contextual-yield-async-star/before/model.dart create mode 100644 gitnexus/test/fixtures/rename-integration/dart/contextual-yield-async-star/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/dart/contextual-yield-async/after/model.dart create mode 100644 gitnexus/test/fixtures/rename-integration/dart/contextual-yield-async/before/model.dart create mode 100644 gitnexus/test/fixtures/rename-integration/dart/contextual-yield-async/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/dart/contextual-yield-sync-star/after/model.dart create mode 100644 gitnexus/test/fixtures/rename-integration/dart/contextual-yield-sync-star/before/model.dart create mode 100644 gitnexus/test/fixtures/rename-integration/dart/contextual-yield-sync-star/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/dart/external-base-family/after/model.dart create mode 100644 gitnexus/test/fixtures/rename-integration/dart/external-base-family/after/notes.txt create mode 100644 gitnexus/test/fixtures/rename-integration/dart/external-base-family/before/model.dart create mode 100644 gitnexus/test/fixtures/rename-integration/dart/external-base-family/before/notes.txt create mode 100644 gitnexus/test/fixtures/rename-integration/dart/external-base-family/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/dart/field-initializing-formal-label/after/point.dart create mode 100644 gitnexus/test/fixtures/rename-integration/dart/field-initializing-formal-label/before/point.dart create mode 100644 gitnexus/test/fixtures/rename-integration/dart/field-initializing-formal-label/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/dart/implicit-external-base-family/after/model.dart create mode 100644 gitnexus/test/fixtures/rename-integration/dart/implicit-external-base-family/after/notes.txt create mode 100644 gitnexus/test/fixtures/rename-integration/dart/implicit-external-base-family/before/model.dart create mode 100644 gitnexus/test/fixtures/rename-integration/dart/implicit-external-base-family/before/notes.txt create mode 100644 gitnexus/test/fixtures/rename-integration/dart/implicit-external-base-family/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/dart/polymorphic-delivery/after/delivery.dart create mode 100644 gitnexus/test/fixtures/rename-integration/dart/polymorphic-delivery/before/delivery.dart create mode 100644 gitnexus/test/fixtures/rename-integration/dart/polymorphic-delivery/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/dart/prefixed-call-graph/after/client.dart create mode 100644 gitnexus/test/fixtures/rename-integration/dart/prefixed-call-graph/after/encoder.dart create mode 100644 gitnexus/test/fixtures/rename-integration/dart/prefixed-call-graph/after/other.dart create mode 100644 gitnexus/test/fixtures/rename-integration/dart/prefixed-call-graph/before/client.dart create mode 100644 gitnexus/test/fixtures/rename-integration/dart/prefixed-call-graph/before/encoder.dart create mode 100644 gitnexus/test/fixtures/rename-integration/dart/prefixed-call-graph/before/other.dart create mode 100644 gitnexus/test/fixtures/rename-integration/dart/prefixed-call-graph/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/dart/public-field-chain/after/profile.dart create mode 100644 gitnexus/test/fixtures/rename-integration/dart/public-field-chain/before/profile.dart create mode 100644 gitnexus/test/fixtures/rename-integration/dart/public-field-chain/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/dart/unrelated-named-label/after/point.dart create mode 100644 gitnexus/test/fixtures/rename-integration/dart/unrelated-named-label/before/point.dart create mode 100644 gitnexus/test/fixtures/rename-integration/dart/unrelated-named-label/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/go/c-consumer/after/caller.c create mode 100644 gitnexus/test/fixtures/rename-integration/go/c-consumer/after/lib.go create mode 100644 gitnexus/test/fixtures/rename-integration/go/c-consumer/before/caller.c create mode 100644 gitnexus/test/fixtures/rename-integration/go/c-consumer/before/lib.go create mode 100644 gitnexus/test/fixtures/rename-integration/go/c-consumer/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/go/export-uncased-unexported-function/after/api/api.go create mode 100644 gitnexus/test/fixtures/rename-integration/go/export-uncased-unexported-function/after/go.mod create mode 100644 gitnexus/test/fixtures/rename-integration/go/export-uncased-unexported-function/after/main.go create mode 100644 gitnexus/test/fixtures/rename-integration/go/export-uncased-unexported-function/before/api/api.go create mode 100644 gitnexus/test/fixtures/rename-integration/go/export-uncased-unexported-function/before/go.mod create mode 100644 gitnexus/test/fixtures/rename-integration/go/export-uncased-unexported-function/before/main.go create mode 100644 gitnexus/test/fixtures/rename-integration/go/export-uncased-unexported-function/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/go/exported-field-pointer-chain/after/go.mod create mode 100644 gitnexus/test/fixtures/rename-integration/go/exported-field-pointer-chain/after/model.go create mode 100644 gitnexus/test/fixtures/rename-integration/go/exported-field-pointer-chain/before/go.mod create mode 100644 gitnexus/test/fixtures/rename-integration/go/exported-field-pointer-chain/before/model.go create mode 100644 gitnexus/test/fixtures/rename-integration/go/exported-field-pointer-chain/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/go/generic-type-collision/after/go.mod create mode 100644 gitnexus/test/fixtures/rename-integration/go/generic-type-collision/after/model.go create mode 100644 gitnexus/test/fixtures/rename-integration/go/generic-type-collision/before/go.mod create mode 100644 gitnexus/test/fixtures/rename-integration/go/generic-type-collision/before/model.go create mode 100644 gitnexus/test/fixtures/rename-integration/go/generic-type-collision/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/go/generic-type-shadow/after/go.mod create mode 100644 gitnexus/test/fixtures/rename-integration/go/generic-type-shadow/after/model.go create mode 100644 gitnexus/test/fixtures/rename-integration/go/generic-type-shadow/before/go.mod create mode 100644 gitnexus/test/fixtures/rename-integration/go/generic-type-shadow/before/model.go create mode 100644 gitnexus/test/fixtures/rename-integration/go/generic-type-shadow/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/go/package-alias-call-chain/after/api/api.go create mode 100644 gitnexus/test/fixtures/rename-integration/go/package-alias-call-chain/after/go.mod create mode 100644 gitnexus/test/fixtures/rename-integration/go/package-alias-call-chain/after/main.go create mode 100644 gitnexus/test/fixtures/rename-integration/go/package-alias-call-chain/before/api/api.go create mode 100644 gitnexus/test/fixtures/rename-integration/go/package-alias-call-chain/before/go.mod create mode 100644 gitnexus/test/fixtures/rename-integration/go/package-alias-call-chain/before/main.go create mode 100644 gitnexus/test/fixtures/rename-integration/go/package-alias-call-chain/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/go/structural-interface-family/after/go.mod create mode 100644 gitnexus/test/fixtures/rename-integration/go/structural-interface-family/after/model.go create mode 100644 gitnexus/test/fixtures/rename-integration/go/structural-interface-family/before/go.mod create mode 100644 gitnexus/test/fixtures/rename-integration/go/structural-interface-family/before/model.go create mode 100644 gitnexus/test/fixtures/rename-integration/go/structural-interface-family/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-field/after/api/config.go create mode 100644 gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-field/after/go.mod create mode 100644 gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-field/after/main.go create mode 100644 gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-field/before/api/config.go create mode 100644 gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-field/before/go.mod create mode 100644 gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-field/before/main.go create mode 100644 gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-field/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-function/after/api/api.go create mode 100644 gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-function/after/go.mod create mode 100644 gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-function/after/main.go create mode 100644 gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-function/before/api/api.go create mode 100644 gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-function/before/go.mod create mode 100644 gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-function/before/main.go create mode 100644 gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-function/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-method/after/api/service.go create mode 100644 gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-method/after/go.mod create mode 100644 gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-method/after/main.go create mode 100644 gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-method/before/api/service.go create mode 100644 gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-method/before/go.mod create mode 100644 gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-method/before/main.go create mode 100644 gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-method/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-promoted-method/after/api/api.go create mode 100644 gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-promoted-method/after/go.mod create mode 100644 gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-promoted-method/after/main.go create mode 100644 gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-promoted-method/before/api/api.go create mode 100644 gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-promoted-method/before/go.mod create mode 100644 gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-promoted-method/before/main.go create mode 100644 gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-promoted-method/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-unicode/after/api/api.go create mode 100644 gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-unicode/after/go.mod create mode 100644 gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-unicode/after/main.go create mode 100644 gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-unicode/before/api/api.go create mode 100644 gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-unicode/before/go.mod create mode 100644 gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-unicode/before/main.go create mode 100644 gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-unicode/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/go/unexport-same-package-function/after/api/api.go create mode 100644 gitnexus/test/fixtures/rename-integration/go/unexport-same-package-function/after/api/helper.go create mode 100644 gitnexus/test/fixtures/rename-integration/go/unexport-same-package-function/after/go.mod create mode 100644 gitnexus/test/fixtures/rename-integration/go/unexport-same-package-function/after/main.go create mode 100644 gitnexus/test/fixtures/rename-integration/go/unexport-same-package-function/before/api/api.go create mode 100644 gitnexus/test/fixtures/rename-integration/go/unexport-same-package-function/before/api/helper.go create mode 100644 gitnexus/test/fixtures/rename-integration/go/unexport-same-package-function/before/go.mod create mode 100644 gitnexus/test/fixtures/rename-integration/go/unexport-same-package-function/before/main.go create mode 100644 gitnexus/test/fixtures/rename-integration/go/unexport-same-package-function/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/java/external-base-family/after/Worker.java create mode 100644 gitnexus/test/fixtures/rename-integration/java/external-base-family/after/notes.txt create mode 100644 gitnexus/test/fixtures/rename-integration/java/external-base-family/before/Worker.java create mode 100644 gitnexus/test/fixtures/rename-integration/java/external-base-family/before/notes.txt create mode 100644 gitnexus/test/fixtures/rename-integration/java/external-base-family/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/java/implicit-external-base-family/after/Worker.java create mode 100644 gitnexus/test/fixtures/rename-integration/java/implicit-external-base-family/after/notes.txt create mode 100644 gitnexus/test/fixtures/rename-integration/java/implicit-external-base-family/before/Worker.java create mode 100644 gitnexus/test/fixtures/rename-integration/java/implicit-external-base-family/before/notes.txt create mode 100644 gitnexus/test/fixtures/rename-integration/java/implicit-external-base-family/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/java/implicit-external-base-parenthesized-override/after/Worker.java create mode 100644 gitnexus/test/fixtures/rename-integration/java/implicit-external-base-parenthesized-override/after/notes.txt create mode 100644 gitnexus/test/fixtures/rename-integration/java/implicit-external-base-parenthesized-override/before/Worker.java create mode 100644 gitnexus/test/fixtures/rename-integration/java/implicit-external-base-parenthesized-override/before/notes.txt create mode 100644 gitnexus/test/fixtures/rename-integration/java/implicit-external-base-parenthesized-override/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/java/implicit-external-base-qualified-parenthesized-override/after/Worker.java create mode 100644 gitnexus/test/fixtures/rename-integration/java/implicit-external-base-qualified-parenthesized-override/after/notes.txt create mode 100644 gitnexus/test/fixtures/rename-integration/java/implicit-external-base-qualified-parenthesized-override/before/Worker.java create mode 100644 gitnexus/test/fixtures/rename-integration/java/implicit-external-base-qualified-parenthesized-override/before/notes.txt create mode 100644 gitnexus/test/fixtures/rename-integration/java/implicit-external-base-qualified-parenthesized-override/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/java/interop-kotlin-call/after/Caller.kt create mode 100644 gitnexus/test/fixtures/rename-integration/java/interop-kotlin-call/after/Writer.java create mode 100644 gitnexus/test/fixtures/rename-integration/java/interop-kotlin-call/before/Caller.kt create mode 100644 gitnexus/test/fixtures/rename-integration/java/interop-kotlin-call/before/Writer.java create mode 100644 gitnexus/test/fixtures/rename-integration/java/interop-kotlin-call/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/java/interop-kotlin-property/after/Caller.kt create mode 100644 gitnexus/test/fixtures/rename-integration/java/interop-kotlin-property/after/Writer.java create mode 100644 gitnexus/test/fixtures/rename-integration/java/interop-kotlin-property/before/Caller.kt create mode 100644 gitnexus/test/fixtures/rename-integration/java/interop-kotlin-property/before/Writer.java create mode 100644 gitnexus/test/fixtures/rename-integration/java/interop-kotlin-property/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/java/overload-call-graph/after/Encoder.java create mode 100644 gitnexus/test/fixtures/rename-integration/java/overload-call-graph/before/Encoder.java create mode 100644 gitnexus/test/fixtures/rename-integration/java/overload-call-graph/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/java/polymorphic-delivery/after/Delivery.java create mode 100644 gitnexus/test/fixtures/rename-integration/java/polymorphic-delivery/before/Delivery.java create mode 100644 gitnexus/test/fixtures/rename-integration/java/polymorphic-delivery/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/java/public-field-chain/after/Profile.java create mode 100644 gitnexus/test/fixtures/rename-integration/java/public-field-chain/before/Profile.java create mode 100644 gitnexus/test/fixtures/rename-integration/java/public-field-chain/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/java/reserved-escaped-underscore/after/Metric.java create mode 100644 gitnexus/test/fixtures/rename-integration/java/reserved-escaped-underscore/before/Metric.java create mode 100644 gitnexus/test/fixtures/rename-integration/java/reserved-escaped-underscore/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/java/reserved-single-underscore/after/Metric.java create mode 100644 gitnexus/test/fixtures/rename-integration/java/reserved-single-underscore/before/Metric.java create mode 100644 gitnexus/test/fixtures/rename-integration/java/reserved-single-underscore/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/java/underscore-prefix-method/after/Metric.java create mode 100644 gitnexus/test/fixtures/rename-integration/java/underscore-prefix-method/before/Metric.java create mode 100644 gitnexus/test/fixtures/rename-integration/java/underscore-prefix-method/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/javascript/escaped-alias-shorthand/after/entry.js create mode 100644 gitnexus/test/fixtures/rename-integration/javascript/escaped-alias-shorthand/after/middle.js create mode 100644 gitnexus/test/fixtures/rename-integration/javascript/escaped-alias-shorthand/after/source.js create mode 100644 gitnexus/test/fixtures/rename-integration/javascript/escaped-alias-shorthand/before/entry.js create mode 100644 gitnexus/test/fixtures/rename-integration/javascript/escaped-alias-shorthand/before/middle.js create mode 100644 gitnexus/test/fixtures/rename-integration/javascript/escaped-alias-shorthand/before/source.js create mode 100644 gitnexus/test/fixtures/rename-integration/javascript/escaped-alias-shorthand/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/javascript/escaped-callable-flow/after/callbacks.js create mode 100644 gitnexus/test/fixtures/rename-integration/javascript/escaped-callable-flow/before/callbacks.js create mode 100644 gitnexus/test/fixtures/rename-integration/javascript/escaped-callable-flow/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/javascript/escaped-import-local-spelling/after/entry.js create mode 100644 gitnexus/test/fixtures/rename-integration/javascript/escaped-import-local-spelling/after/source.js create mode 100644 gitnexus/test/fixtures/rename-integration/javascript/escaped-import-local-spelling/before/entry.js create mode 100644 gitnexus/test/fixtures/rename-integration/javascript/escaped-import-local-spelling/before/source.js create mode 100644 gitnexus/test/fixtures/rename-integration/javascript/escaped-import-local-spelling/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/javascript/escaped-import-spellings/after/barrel.js create mode 100644 gitnexus/test/fixtures/rename-integration/javascript/escaped-import-spellings/after/different.js create mode 100644 gitnexus/test/fixtures/rename-integration/javascript/escaped-import-spellings/after/entry.js create mode 100644 gitnexus/test/fixtures/rename-integration/javascript/escaped-import-spellings/after/plain.js create mode 100644 gitnexus/test/fixtures/rename-integration/javascript/escaped-import-spellings/after/same.js create mode 100644 gitnexus/test/fixtures/rename-integration/javascript/escaped-import-spellings/after/source.js create mode 100644 gitnexus/test/fixtures/rename-integration/javascript/escaped-import-spellings/before/barrel.js create mode 100644 gitnexus/test/fixtures/rename-integration/javascript/escaped-import-spellings/before/different.js create mode 100644 gitnexus/test/fixtures/rename-integration/javascript/escaped-import-spellings/before/entry.js create mode 100644 gitnexus/test/fixtures/rename-integration/javascript/escaped-import-spellings/before/plain.js create mode 100644 gitnexus/test/fixtures/rename-integration/javascript/escaped-import-spellings/before/same.js create mode 100644 gitnexus/test/fixtures/rename-integration/javascript/escaped-import-spellings/before/source.js create mode 100644 gitnexus/test/fixtures/rename-integration/javascript/escaped-import-spellings/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/javascript/external-base-family/after/model.js create mode 100644 gitnexus/test/fixtures/rename-integration/javascript/external-base-family/after/notes.txt create mode 100644 gitnexus/test/fixtures/rename-integration/javascript/external-base-family/before/model.js create mode 100644 gitnexus/test/fixtures/rename-integration/javascript/external-base-family/before/notes.txt create mode 100644 gitnexus/test/fixtures/rename-integration/javascript/external-base-family/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/javascript/inherited-method-family/after/model.js create mode 100644 gitnexus/test/fixtures/rename-integration/javascript/inherited-method-family/before/model.js create mode 100644 gitnexus/test/fixtures/rename-integration/javascript/inherited-method-family/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/javascript/member-destructure-refusal/after/model.js create mode 100644 gitnexus/test/fixtures/rename-integration/javascript/member-destructure-refusal/before/model.js create mode 100644 gitnexus/test/fixtures/rename-integration/javascript/member-destructure-refusal/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/javascript/member-object-key-refusal/after/model.js create mode 100644 gitnexus/test/fixtures/rename-integration/javascript/member-object-key-refusal/before/model.js create mode 100644 gitnexus/test/fixtures/rename-integration/javascript/member-object-key-refusal/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/javascript/prototype-function-this-refusal/after/model.js create mode 100644 gitnexus/test/fixtures/rename-integration/javascript/prototype-function-this-refusal/before/model.js create mode 100644 gitnexus/test/fixtures/rename-integration/javascript/prototype-function-this-refusal/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/javascript/public-field-chain/after/model.js create mode 100644 gitnexus/test/fixtures/rename-integration/javascript/public-field-chain/before/model.js create mode 100644 gitnexus/test/fixtures/rename-integration/javascript/public-field-chain/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/kotlin/data-class-copy-label/after/Model.kt create mode 100644 gitnexus/test/fixtures/rename-integration/kotlin/data-class-copy-label/before/Model.kt create mode 100644 gitnexus/test/fixtures/rename-integration/kotlin/data-class-copy-label/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/kotlin/escaped-class-import/after/app/Main.kt create mode 100644 gitnexus/test/fixtures/rename-integration/kotlin/escaped-class-import/after/models/Models.kt create mode 100644 gitnexus/test/fixtures/rename-integration/kotlin/escaped-class-import/before/app/Main.kt create mode 100644 gitnexus/test/fixtures/rename-integration/kotlin/escaped-class-import/before/models/Models.kt create mode 100644 gitnexus/test/fixtures/rename-integration/kotlin/escaped-class-import/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/kotlin/escaped-member-identity/after/worker.kt create mode 100644 gitnexus/test/fixtures/rename-integration/kotlin/escaped-member-identity/before/worker.kt create mode 100644 gitnexus/test/fixtures/rename-integration/kotlin/escaped-member-identity/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/kotlin/escaped-overload-call-graph/after/encoder.kt create mode 100644 gitnexus/test/fixtures/rename-integration/kotlin/escaped-overload-call-graph/before/encoder.kt create mode 100644 gitnexus/test/fixtures/rename-integration/kotlin/escaped-overload-call-graph/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/kotlin/escaped-receiver-identity/after/worker.kt create mode 100644 gitnexus/test/fixtures/rename-integration/kotlin/escaped-receiver-identity/before/worker.kt create mode 100644 gitnexus/test/fixtures/rename-integration/kotlin/escaped-receiver-identity/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/kotlin/external-base-family/after/model.kt create mode 100644 gitnexus/test/fixtures/rename-integration/kotlin/external-base-family/after/notes.txt create mode 100644 gitnexus/test/fixtures/rename-integration/kotlin/external-base-family/before/model.kt create mode 100644 gitnexus/test/fixtures/rename-integration/kotlin/external-base-family/before/notes.txt create mode 100644 gitnexus/test/fixtures/rename-integration/kotlin/external-base-family/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/kotlin/implicit-external-base-family/after/model.kt create mode 100644 gitnexus/test/fixtures/rename-integration/kotlin/implicit-external-base-family/after/notes.txt create mode 100644 gitnexus/test/fixtures/rename-integration/kotlin/implicit-external-base-family/before/model.kt create mode 100644 gitnexus/test/fixtures/rename-integration/kotlin/implicit-external-base-family/before/notes.txt create mode 100644 gitnexus/test/fixtures/rename-integration/kotlin/implicit-external-base-family/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/kotlin/interop-java-accessor/after/Caller.java create mode 100644 gitnexus/test/fixtures/rename-integration/kotlin/interop-java-accessor/after/Writer.kt create mode 100644 gitnexus/test/fixtures/rename-integration/kotlin/interop-java-accessor/before/Caller.java create mode 100644 gitnexus/test/fixtures/rename-integration/kotlin/interop-java-accessor/before/Writer.kt create mode 100644 gitnexus/test/fixtures/rename-integration/kotlin/interop-java-accessor/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/kotlin/interop-java-call/after/Caller.java create mode 100644 gitnexus/test/fixtures/rename-integration/kotlin/interop-java-call/after/Writer.kt create mode 100644 gitnexus/test/fixtures/rename-integration/kotlin/interop-java-call/before/Caller.java create mode 100644 gitnexus/test/fixtures/rename-integration/kotlin/interop-java-call/before/Writer.kt create mode 100644 gitnexus/test/fixtures/rename-integration/kotlin/interop-java-call/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/kotlin/interop-java-destination/after/Caller.java create mode 100644 gitnexus/test/fixtures/rename-integration/kotlin/interop-java-destination/after/Writer.kt create mode 100644 gitnexus/test/fixtures/rename-integration/kotlin/interop-java-destination/before/Caller.java create mode 100644 gitnexus/test/fixtures/rename-integration/kotlin/interop-java-destination/before/Writer.kt create mode 100644 gitnexus/test/fixtures/rename-integration/kotlin/interop-java-destination/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/kotlin/interop-java-unrelated/after/Other.java create mode 100644 gitnexus/test/fixtures/rename-integration/kotlin/interop-java-unrelated/after/Writer.kt create mode 100644 gitnexus/test/fixtures/rename-integration/kotlin/interop-java-unrelated/before/Other.java create mode 100644 gitnexus/test/fixtures/rename-integration/kotlin/interop-java-unrelated/before/Writer.kt create mode 100644 gitnexus/test/fixtures/rename-integration/kotlin/interop-java-unrelated/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/kotlin/polymorphic-delivery/after/delivery.kt create mode 100644 gitnexus/test/fixtures/rename-integration/kotlin/polymorphic-delivery/before/delivery.kt create mode 100644 gitnexus/test/fixtures/rename-integration/kotlin/polymorphic-delivery/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/kotlin/primary-property-named-argument/after/Model.kt create mode 100644 gitnexus/test/fixtures/rename-integration/kotlin/primary-property-named-argument/before/Model.kt create mode 100644 gitnexus/test/fixtures/rename-integration/kotlin/primary-property-named-argument/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/kotlin/public-property-chain/after/profile.kt create mode 100644 gitnexus/test/fixtures/rename-integration/kotlin/public-property-chain/before/profile.kt create mode 100644 gitnexus/test/fixtures/rename-integration/kotlin/public-property-chain/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/kotlin/unrelated-named-argument/after/Model.kt create mode 100644 gitnexus/test/fixtures/rename-integration/kotlin/unrelated-named-argument/before/Model.kt create mode 100644 gitnexus/test/fixtures/rename-integration/kotlin/unrelated-named-argument/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/objective-c/c-helper-call-chain/after/Helpers.m create mode 100644 gitnexus/test/fixtures/rename-integration/objective-c/c-helper-call-chain/before/Helpers.m create mode 100644 gitnexus/test/fixtures/rename-integration/objective-c/c-helper-call-chain/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/objective-c/interop-c-call/after/api.m create mode 100644 gitnexus/test/fixtures/rename-integration/objective-c/interop-c-call/after/caller.c create mode 100644 gitnexus/test/fixtures/rename-integration/objective-c/interop-c-call/before/api.m create mode 100644 gitnexus/test/fixtures/rename-integration/objective-c/interop-c-call/before/caller.c create mode 100644 gitnexus/test/fixtures/rename-integration/objective-c/interop-c-call/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/objective-c/ivar-and-c-function/after/Worker.m create mode 100644 gitnexus/test/fixtures/rename-integration/objective-c/ivar-and-c-function/before/Worker.m create mode 100644 gitnexus/test/fixtures/rename-integration/objective-c/ivar-and-c-function/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/objective-c/ivar-and-c-global/after/Worker.m create mode 100644 gitnexus/test/fixtures/rename-integration/objective-c/ivar-and-c-global/before/Worker.m create mode 100644 gitnexus/test/fixtures/rename-integration/objective-c/ivar-and-c-global/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/objective-c/property-accessors-deep-chain/after/Model.m create mode 100644 gitnexus/test/fixtures/rename-integration/objective-c/property-accessors-deep-chain/before/Model.m create mode 100644 gitnexus/test/fixtures/rename-integration/objective-c/property-accessors-deep-chain/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/objective-c/protocol-selector-family/after/Worker.m create mode 100644 gitnexus/test/fixtures/rename-integration/objective-c/protocol-selector-family/before/Worker.m create mode 100644 gitnexus/test/fixtures/rename-integration/objective-c/protocol-selector-family/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/objective-c/swift-consumer/after/App.swift create mode 100644 gitnexus/test/fixtures/rename-integration/objective-c/swift-consumer/after/Foo.h create mode 100644 gitnexus/test/fixtures/rename-integration/objective-c/swift-consumer/after/Foo.m create mode 100644 gitnexus/test/fixtures/rename-integration/objective-c/swift-consumer/before/App.swift create mode 100644 gitnexus/test/fixtures/rename-integration/objective-c/swift-consumer/before/Foo.h create mode 100644 gitnexus/test/fixtures/rename-integration/objective-c/swift-consumer/before/Foo.m create mode 100644 gitnexus/test/fixtures/rename-integration/objective-c/swift-consumer/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/objective-c/swift-init-label-consumer/after/App.swift create mode 100644 gitnexus/test/fixtures/rename-integration/objective-c/swift-init-label-consumer/after/Foo.h create mode 100644 gitnexus/test/fixtures/rename-integration/objective-c/swift-init-label-consumer/after/Foo.m create mode 100644 gitnexus/test/fixtures/rename-integration/objective-c/swift-init-label-consumer/before/App.swift create mode 100644 gitnexus/test/fixtures/rename-integration/objective-c/swift-init-label-consumer/before/Foo.h create mode 100644 gitnexus/test/fixtures/rename-integration/objective-c/swift-init-label-consumer/before/Foo.m create mode 100644 gitnexus/test/fixtures/rename-integration/objective-c/swift-init-label-consumer/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/objective-c/swift-multipart-consumer/after/App.swift create mode 100644 gitnexus/test/fixtures/rename-integration/objective-c/swift-multipart-consumer/after/Foo.h create mode 100644 gitnexus/test/fixtures/rename-integration/objective-c/swift-multipart-consumer/after/Foo.m create mode 100644 gitnexus/test/fixtures/rename-integration/objective-c/swift-multipart-consumer/before/App.swift create mode 100644 gitnexus/test/fixtures/rename-integration/objective-c/swift-multipart-consumer/before/Foo.h create mode 100644 gitnexus/test/fixtures/rename-integration/objective-c/swift-multipart-consumer/before/Foo.m create mode 100644 gitnexus/test/fixtures/rename-integration/objective-c/swift-multipart-consumer/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/objective-c/swift-name-attribute-embedded/after/Foo.h create mode 100644 gitnexus/test/fixtures/rename-integration/objective-c/swift-name-attribute-embedded/after/Foo.m create mode 100644 gitnexus/test/fixtures/rename-integration/objective-c/swift-name-attribute-embedded/before/Foo.h create mode 100644 gitnexus/test/fixtures/rename-integration/objective-c/swift-name-attribute-embedded/before/Foo.m create mode 100644 gitnexus/test/fixtures/rename-integration/objective-c/swift-name-attribute-embedded/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/objective-c/swift-name-attribute-independent/after/App.swift create mode 100644 gitnexus/test/fixtures/rename-integration/objective-c/swift-name-attribute-independent/after/Foo.h create mode 100644 gitnexus/test/fixtures/rename-integration/objective-c/swift-name-attribute-independent/after/Foo.m create mode 100644 gitnexus/test/fixtures/rename-integration/objective-c/swift-name-attribute-independent/before/App.swift create mode 100644 gitnexus/test/fixtures/rename-integration/objective-c/swift-name-attribute-independent/before/Foo.h create mode 100644 gitnexus/test/fixtures/rename-integration/objective-c/swift-name-attribute-independent/before/Foo.m create mode 100644 gitnexus/test/fixtures/rename-integration/objective-c/swift-name-attribute-independent/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/objective-c/swift-refined-consumer/after/App.swift create mode 100644 gitnexus/test/fixtures/rename-integration/objective-c/swift-refined-consumer/after/Foo.h create mode 100644 gitnexus/test/fixtures/rename-integration/objective-c/swift-refined-consumer/after/Foo.m create mode 100644 gitnexus/test/fixtures/rename-integration/objective-c/swift-refined-consumer/before/App.swift create mode 100644 gitnexus/test/fixtures/rename-integration/objective-c/swift-refined-consumer/before/Foo.h create mode 100644 gitnexus/test/fixtures/rename-integration/objective-c/swift-refined-consumer/before/Foo.m create mode 100644 gitnexus/test/fixtures/rename-integration/objective-c/swift-refined-consumer/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/objective-c/swift-unrelated/after/Foo.h create mode 100644 gitnexus/test/fixtures/rename-integration/objective-c/swift-unrelated/after/Foo.m create mode 100644 gitnexus/test/fixtures/rename-integration/objective-c/swift-unrelated/after/Other.swift create mode 100644 gitnexus/test/fixtures/rename-integration/objective-c/swift-unrelated/before/Foo.h create mode 100644 gitnexus/test/fixtures/rename-integration/objective-c/swift-unrelated/before/Foo.m create mode 100644 gitnexus/test/fixtures/rename-integration/objective-c/swift-unrelated/before/Other.swift create mode 100644 gitnexus/test/fixtures/rename-integration/objective-c/swift-unrelated/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/php/external-base-family/after/model.php create mode 100644 gitnexus/test/fixtures/rename-integration/php/external-base-family/after/notes.txt create mode 100644 gitnexus/test/fixtures/rename-integration/php/external-base-family/before/model.php create mode 100644 gitnexus/test/fixtures/rename-integration/php/external-base-family/before/notes.txt create mode 100644 gitnexus/test/fixtures/rename-integration/php/external-base-family/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/php/grouped-function-aliases/after/Source/functions.php create mode 100644 gitnexus/test/fixtures/rename-integration/php/grouped-function-aliases/after/composer.json create mode 100644 gitnexus/test/fixtures/rename-integration/php/grouped-function-aliases/after/entry.php create mode 100644 gitnexus/test/fixtures/rename-integration/php/grouped-function-aliases/after/middle.php create mode 100644 gitnexus/test/fixtures/rename-integration/php/grouped-function-aliases/before/Source/functions.php create mode 100644 gitnexus/test/fixtures/rename-integration/php/grouped-function-aliases/before/composer.json create mode 100644 gitnexus/test/fixtures/rename-integration/php/grouped-function-aliases/before/entry.php create mode 100644 gitnexus/test/fixtures/rename-integration/php/grouped-function-aliases/before/middle.php create mode 100644 gitnexus/test/fixtures/rename-integration/php/grouped-function-aliases/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/php/interface-family-case/after/model.php create mode 100644 gitnexus/test/fixtures/rename-integration/php/interface-family-case/before/model.php create mode 100644 gitnexus/test/fixtures/rename-integration/php/interface-family-case/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/php/public-property-chain/after/model.php create mode 100644 gitnexus/test/fixtures/rename-integration/php/public-property-chain/before/model.php create mode 100644 gitnexus/test/fixtures/rename-integration/php/public-property-chain/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/python/dataclass-keyword-label/after/model.py create mode 100644 gitnexus/test/fixtures/rename-integration/python/dataclass-keyword-label/before/model.py create mode 100644 gitnexus/test/fixtures/rename-integration/python/dataclass-keyword-label/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/python/destination-dynamic-attribute/after/model.py create mode 100644 gitnexus/test/fixtures/rename-integration/python/destination-dynamic-attribute/before/model.py create mode 100644 gitnexus/test/fixtures/rename-integration/python/destination-dynamic-attribute/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/python/destination-name-mangling/after/model.py create mode 100644 gitnexus/test/fixtures/rename-integration/python/destination-name-mangling/before/model.py create mode 100644 gitnexus/test/fixtures/rename-integration/python/destination-name-mangling/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/python/external-base-family/after/model.py create mode 100644 gitnexus/test/fixtures/rename-integration/python/external-base-family/after/notes.txt create mode 100644 gitnexus/test/fixtures/rename-integration/python/external-base-family/before/model.py create mode 100644 gitnexus/test/fixtures/rename-integration/python/external-base-family/before/notes.txt create mode 100644 gitnexus/test/fixtures/rename-integration/python/external-base-family/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/python/inherited-dynamic-attribute/after/model.py create mode 100644 gitnexus/test/fixtures/rename-integration/python/inherited-dynamic-attribute/before/model.py create mode 100644 gitnexus/test/fixtures/rename-integration/python/inherited-dynamic-attribute/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/python/inherited-method-family/after/model.py create mode 100644 gitnexus/test/fixtures/rename-integration/python/inherited-method-family/before/model.py create mode 100644 gitnexus/test/fixtures/rename-integration/python/inherited-method-family/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/python/matching-dynamic-attribute/after/model.py create mode 100644 gitnexus/test/fixtures/rename-integration/python/matching-dynamic-attribute/before/model.py create mode 100644 gitnexus/test/fixtures/rename-integration/python/matching-dynamic-attribute/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/python/module-export-destination/after/model.py create mode 100644 gitnexus/test/fixtures/rename-integration/python/module-export-destination/before/model.py create mode 100644 gitnexus/test/fixtures/rename-integration/python/module-export-destination/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/python/module-export-dynamic/after/model.py create mode 100644 gitnexus/test/fixtures/rename-integration/python/module-export-dynamic/before/model.py create mode 100644 gitnexus/test/fixtures/rename-integration/python/module-export-dynamic/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/python/module-export-list/after/model.py create mode 100644 gitnexus/test/fixtures/rename-integration/python/module-export-list/before/model.py create mode 100644 gitnexus/test/fixtures/rename-integration/python/module-export-list/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/python/module-export-mutation/after/model.py create mode 100644 gitnexus/test/fixtures/rename-integration/python/module-export-mutation/before/model.py create mode 100644 gitnexus/test/fixtures/rename-integration/python/module-export-mutation/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/python/module-export-reexport/after/model.py create mode 100644 gitnexus/test/fixtures/rename-integration/python/module-export-reexport/after/public.py create mode 100644 gitnexus/test/fixtures/rename-integration/python/module-export-reexport/before/model.py create mode 100644 gitnexus/test/fixtures/rename-integration/python/module-export-reexport/before/public.py create mode 100644 gitnexus/test/fixtures/rename-integration/python/module-export-reexport/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/python/normalized-import-alias/after/entry.py create mode 100644 gitnexus/test/fixtures/rename-integration/python/normalized-import-alias/after/middle.py create mode 100644 gitnexus/test/fixtures/rename-integration/python/normalized-import-alias/after/source.py create mode 100644 gitnexus/test/fixtures/rename-integration/python/normalized-import-alias/before/entry.py create mode 100644 gitnexus/test/fixtures/rename-integration/python/normalized-import-alias/before/middle.py create mode 100644 gitnexus/test/fixtures/rename-integration/python/normalized-import-alias/before/source.py create mode 100644 gitnexus/test/fixtures/rename-integration/python/normalized-import-alias/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/python/private-mangled-member/after/model.py create mode 100644 gitnexus/test/fixtures/rename-integration/python/private-mangled-member/before/model.py create mode 100644 gitnexus/test/fixtures/rename-integration/python/private-mangled-member/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/python/public-field-chain/after/model.py create mode 100644 gitnexus/test/fixtures/rename-integration/python/public-field-chain/before/model.py create mode 100644 gitnexus/test/fixtures/rename-integration/python/public-field-chain/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/python/special-method-protocol/after/model.py create mode 100644 gitnexus/test/fixtures/rename-integration/python/special-method-protocol/before/model.py create mode 100644 gitnexus/test/fixtures/rename-integration/python/special-method-protocol/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/python/unknown-dynamic-attribute/after/model.py create mode 100644 gitnexus/test/fixtures/rename-integration/python/unknown-dynamic-attribute/before/model.py create mode 100644 gitnexus/test/fixtures/rename-integration/python/unknown-dynamic-attribute/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/python/unrelated-export-literals/after/model.py create mode 100644 gitnexus/test/fixtures/rename-integration/python/unrelated-export-literals/after/other.py create mode 100644 gitnexus/test/fixtures/rename-integration/python/unrelated-export-literals/before/model.py create mode 100644 gitnexus/test/fixtures/rename-integration/python/unrelated-export-literals/before/other.py create mode 100644 gitnexus/test/fixtures/rename-integration/python/unrelated-export-literals/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/python/unrelated-keyword-label/after/model.py create mode 100644 gitnexus/test/fixtures/rename-integration/python/unrelated-keyword-label/before/model.py create mode 100644 gitnexus/test/fixtures/rename-integration/python/unrelated-keyword-label/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/python/unrelated-literal-attribute/after/model.py create mode 100644 gitnexus/test/fixtures/rename-integration/python/unrelated-literal-attribute/before/model.py create mode 100644 gitnexus/test/fixtures/rename-integration/python/unrelated-literal-attribute/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/python/unrelated-owner-dynamic-attribute/after/model.py create mode 100644 gitnexus/test/fixtures/rename-integration/python/unrelated-owner-dynamic-attribute/before/model.py create mode 100644 gitnexus/test/fixtures/rename-integration/python/unrelated-owner-dynamic-attribute/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/ruby/external-base-family/after/model.rb create mode 100644 gitnexus/test/fixtures/rename-integration/ruby/external-base-family/after/notes.txt create mode 100644 gitnexus/test/fixtures/rename-integration/ruby/external-base-family/before/model.rb create mode 100644 gitnexus/test/fixtures/rename-integration/ruby/external-base-family/before/notes.txt create mode 100644 gitnexus/test/fixtures/rename-integration/ruby/external-base-family/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/ruby/mixin-predicate-family/after/model.rb create mode 100644 gitnexus/test/fixtures/rename-integration/ruby/mixin-predicate-family/before/model.rb create mode 100644 gitnexus/test/fixtures/rename-integration/ruby/mixin-predicate-family/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/ruby/required-qualified-constant/after/entry.rb create mode 100644 gitnexus/test/fixtures/rename-integration/ruby/required-qualified-constant/after/middle.rb create mode 100644 gitnexus/test/fixtures/rename-integration/ruby/required-qualified-constant/after/source.rb create mode 100644 gitnexus/test/fixtures/rename-integration/ruby/required-qualified-constant/before/entry.rb create mode 100644 gitnexus/test/fixtures/rename-integration/ruby/required-qualified-constant/before/middle.rb create mode 100644 gitnexus/test/fixtures/rename-integration/ruby/required-qualified-constant/before/source.rb create mode 100644 gitnexus/test/fixtures/rename-integration/ruby/required-qualified-constant/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/ruby/setter-receiver-chain/after/model.rb create mode 100644 gitnexus/test/fixtures/rename-integration/ruby/setter-receiver-chain/before/model.rb create mode 100644 gitnexus/test/fixtures/rename-integration/ruby/setter-receiver-chain/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/rust/c-consumer/after/caller.c create mode 100644 gitnexus/test/fixtures/rename-integration/rust/c-consumer/after/lib.rs create mode 100644 gitnexus/test/fixtures/rename-integration/rust/c-consumer/before/caller.c create mode 100644 gitnexus/test/fixtures/rename-integration/rust/c-consumer/before/lib.rs create mode 100644 gitnexus/test/fixtures/rename-integration/rust/c-consumer/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/rust/module-alias-call-chain/after/lib.rs create mode 100644 gitnexus/test/fixtures/rename-integration/rust/module-alias-call-chain/after/operations.rs create mode 100644 gitnexus/test/fixtures/rename-integration/rust/module-alias-call-chain/before/lib.rs create mode 100644 gitnexus/test/fixtures/rename-integration/rust/module-alias-call-chain/before/operations.rs create mode 100644 gitnexus/test/fixtures/rename-integration/rust/module-alias-call-chain/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/rust/public-field-borrow-chain/after/model.rs create mode 100644 gitnexus/test/fixtures/rename-integration/rust/public-field-borrow-chain/before/model.rs create mode 100644 gitnexus/test/fixtures/rename-integration/rust/public-field-borrow-chain/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/rust/trait-method-family/after/model.rs create mode 100644 gitnexus/test/fixtures/rename-integration/rust/trait-method-family/before/model.rs create mode 100644 gitnexus/test/fixtures/rename-integration/rust/trait-method-family/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/swift/escaped-member-identity/after/worker.swift create mode 100644 gitnexus/test/fixtures/rename-integration/swift/escaped-member-identity/before/worker.swift create mode 100644 gitnexus/test/fixtures/rename-integration/swift/escaped-member-identity/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/swift/escaped-overload-call-graph/after/encoder.swift create mode 100644 gitnexus/test/fixtures/rename-integration/swift/escaped-overload-call-graph/before/encoder.swift create mode 100644 gitnexus/test/fixtures/rename-integration/swift/escaped-overload-call-graph/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/swift/escaped-receiver-identity/after/worker.swift create mode 100644 gitnexus/test/fixtures/rename-integration/swift/escaped-receiver-identity/before/worker.swift create mode 100644 gitnexus/test/fixtures/rename-integration/swift/escaped-receiver-identity/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/swift/escaped-selective-import/after/Package.swift create mode 100644 gitnexus/test/fixtures/rename-integration/swift/escaped-selective-import/after/Sources/App/Main.swift create mode 100644 gitnexus/test/fixtures/rename-integration/swift/escaped-selective-import/after/Sources/Vendor/Tools.swift create mode 100644 gitnexus/test/fixtures/rename-integration/swift/escaped-selective-import/before/Package.swift create mode 100644 gitnexus/test/fixtures/rename-integration/swift/escaped-selective-import/before/Sources/App/Main.swift create mode 100644 gitnexus/test/fixtures/rename-integration/swift/escaped-selective-import/before/Sources/Vendor/Tools.swift create mode 100644 gitnexus/test/fixtures/rename-integration/swift/escaped-selective-import/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/swift/explicit-init-label/after/Shapes.swift create mode 100644 gitnexus/test/fixtures/rename-integration/swift/explicit-init-label/before/Shapes.swift create mode 100644 gitnexus/test/fixtures/rename-integration/swift/explicit-init-label/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/swift/external-base-family/after/model.swift create mode 100644 gitnexus/test/fixtures/rename-integration/swift/external-base-family/after/notes.txt create mode 100644 gitnexus/test/fixtures/rename-integration/swift/external-base-family/before/model.swift create mode 100644 gitnexus/test/fixtures/rename-integration/swift/external-base-family/before/notes.txt create mode 100644 gitnexus/test/fixtures/rename-integration/swift/external-base-family/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/swift/memberwise-init-label/after/Point.swift create mode 100644 gitnexus/test/fixtures/rename-integration/swift/memberwise-init-label/before/Point.swift create mode 100644 gitnexus/test/fixtures/rename-integration/swift/memberwise-init-label/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/swift/objc-argument-consumer/after/Greeter.swift create mode 100644 gitnexus/test/fixtures/rename-integration/swift/objc-argument-consumer/after/caller.m create mode 100644 gitnexus/test/fixtures/rename-integration/swift/objc-argument-consumer/before/Greeter.swift create mode 100644 gitnexus/test/fixtures/rename-integration/swift/objc-argument-consumer/before/caller.m create mode 100644 gitnexus/test/fixtures/rename-integration/swift/objc-argument-consumer/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/swift/objc-consumer/after/Greeter.swift create mode 100644 gitnexus/test/fixtures/rename-integration/swift/objc-consumer/after/caller.m create mode 100644 gitnexus/test/fixtures/rename-integration/swift/objc-consumer/before/Greeter.swift create mode 100644 gitnexus/test/fixtures/rename-integration/swift/objc-consumer/before/caller.m create mode 100644 gitnexus/test/fixtures/rename-integration/swift/objc-consumer/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/swift/objc-init-constructor-refused/after/Greeter.swift create mode 100644 gitnexus/test/fixtures/rename-integration/swift/objc-init-constructor-refused/after/caller.m create mode 100644 gitnexus/test/fixtures/rename-integration/swift/objc-init-constructor-refused/before/Greeter.swift create mode 100644 gitnexus/test/fixtures/rename-integration/swift/objc-init-constructor-refused/before/caller.m create mode 100644 gitnexus/test/fixtures/rename-integration/swift/objc-init-constructor-refused/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/swift/objc-swift-name-string-attribute/after/Foo.h create mode 100644 gitnexus/test/fixtures/rename-integration/swift/objc-swift-name-string-attribute/after/Impl.swift create mode 100644 gitnexus/test/fixtures/rename-integration/swift/objc-swift-name-string-attribute/before/Foo.h create mode 100644 gitnexus/test/fixtures/rename-integration/swift/objc-swift-name-string-attribute/before/Impl.swift create mode 100644 gitnexus/test/fixtures/rename-integration/swift/objc-swift-name-string-attribute/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/swift/objc-unrelated-selector/after/Greeter.swift create mode 100644 gitnexus/test/fixtures/rename-integration/swift/objc-unrelated-selector/after/Other.m create mode 100644 gitnexus/test/fixtures/rename-integration/swift/objc-unrelated-selector/before/Greeter.swift create mode 100644 gitnexus/test/fixtures/rename-integration/swift/objc-unrelated-selector/before/Other.m create mode 100644 gitnexus/test/fixtures/rename-integration/swift/objc-unrelated-selector/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/swift/objc-with-label-consumer/after/Greeter.swift create mode 100644 gitnexus/test/fixtures/rename-integration/swift/objc-with-label-consumer/after/caller.m create mode 100644 gitnexus/test/fixtures/rename-integration/swift/objc-with-label-consumer/before/Greeter.swift create mode 100644 gitnexus/test/fixtures/rename-integration/swift/objc-with-label-consumer/before/caller.m create mode 100644 gitnexus/test/fixtures/rename-integration/swift/objc-with-label-consumer/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/swift/polymorphic-delivery/after/delivery.swift create mode 100644 gitnexus/test/fixtures/rename-integration/swift/polymorphic-delivery/before/delivery.swift create mode 100644 gitnexus/test/fixtures/rename-integration/swift/polymorphic-delivery/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/swift/public-property-chain/after/profile.swift create mode 100644 gitnexus/test/fixtures/rename-integration/swift/public-property-chain/before/profile.swift create mode 100644 gitnexus/test/fixtures/rename-integration/swift/public-property-chain/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/swift/unqualified-owner-visibility/after/owners.swift create mode 100644 gitnexus/test/fixtures/rename-integration/swift/unqualified-owner-visibility/before/owners.swift create mode 100644 gitnexus/test/fixtures/rename-integration/swift/unqualified-owner-visibility/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/aliases-shorthand-call-chain/after/entry.ts create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/aliases-shorthand-call-chain/after/middle.ts create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/aliases-shorthand-call-chain/after/source.ts create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/aliases-shorthand-call-chain/before/entry.ts create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/aliases-shorthand-call-chain/before/middle.ts create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/aliases-shorthand-call-chain/before/source.ts create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/aliases-shorthand-call-chain/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/deep-chain-intermediate-write/after/caller.ts create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/deep-chain-intermediate-write/after/model.ts create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/deep-chain-intermediate-write/before/caller.ts create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/deep-chain-intermediate-write/before/model.ts create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/deep-chain-intermediate-write/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/deep-chain-structural-alias-write/after/entry.ts create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/deep-chain-structural-alias-write/after/model.ts create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/deep-chain-structural-alias-write/before/entry.ts create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/deep-chain-structural-alias-write/before/model.ts create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/deep-chain-structural-alias-write/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/deep-chain-structural-initializer/after/caller.ts create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/deep-chain-structural-initializer/after/model.ts create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/deep-chain-structural-initializer/before/caller.ts create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/deep-chain-structural-initializer/before/model.ts create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/deep-chain-structural-initializer/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/escaped-callable-flow/after/callbacks.ts create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/escaped-callable-flow/before/callbacks.ts create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/escaped-callable-flow/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/escaped-import-destination/after/entry.ts create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/escaped-import-destination/after/source.ts create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/escaped-import-destination/before/entry.ts create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/escaped-import-destination/before/source.ts create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/escaped-import-destination/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/escaped-import-local-spelling/after/entry.ts create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/escaped-import-local-spelling/after/source.ts create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/escaped-import-local-spelling/before/entry.ts create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/escaped-import-local-spelling/before/source.ts create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/escaped-import-local-spelling/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/exported-constructor-binding/after/entry.ts create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/exported-constructor-binding/after/model.ts create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/exported-constructor-binding/before/entry.ts create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/exported-constructor-binding/before/model.ts create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/exported-constructor-binding/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/external-base-family/after/model.ts create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/external-base-family/after/notes.txt create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/external-base-family/before/model.ts create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/external-base-family/before/notes.txt create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/external-base-family/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/external-base-private-member/after/model.ts create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/external-base-private-member/after/notes.txt create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/external-base-private-member/before/model.ts create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/external-base-private-member/before/notes.txt create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/external-base-private-member/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/imported-receiver-alias-write/after/entry.ts create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/imported-receiver-alias-write/after/model.ts create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/imported-receiver-alias-write/before/entry.ts create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/imported-receiver-alias-write/before/model.ts create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/imported-receiver-alias-write/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/imported-receiver-write-other-file/after/entry.ts create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/imported-receiver-write-other-file/after/model.ts create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/imported-receiver-write-other-file/after/mutator.ts create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/imported-receiver-write-other-file/before/entry.ts create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/imported-receiver-write-other-file/before/model.ts create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/imported-receiver-write-other-file/before/mutator.ts create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/imported-receiver-write-other-file/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/inherited-computed-member-refusal/after/model.ts create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/inherited-computed-member-refusal/before/model.ts create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/inherited-computed-member-refusal/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/inherited-intermediate-field-shadow/after/model.ts create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/inherited-intermediate-field-shadow/before/model.ts create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/inherited-intermediate-field-shadow/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/member-destructure-alias-refusal/after/model.ts create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/member-destructure-alias-refusal/before/model.ts create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/member-destructure-alias-refusal/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/member-destructure-destination-refusal/after/model.ts create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/member-destructure-destination-refusal/before/model.ts create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/member-destructure-destination-refusal/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/member-destructure-shorthand-refusal/after/model.ts create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/member-destructure-shorthand-refusal/before/model.ts create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/member-destructure-shorthand-refusal/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/member-keys-unrelated-owner/after/model.ts create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/member-keys-unrelated-owner/before/model.ts create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/member-keys-unrelated-owner/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/member-object-method-refusal/after/model.ts create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/member-object-method-refusal/before/model.ts create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/member-object-method-refusal/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/member-object-pair-refusal/after/model.ts create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/member-object-pair-refusal/before/model.ts create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/member-object-pair-refusal/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/polymorphic-family/after/model.ts create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/polymorphic-family/before/model.ts create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/polymorphic-family/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/public-field-chain/after/caller.ts create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/public-field-chain/after/model.ts create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/public-field-chain/before/caller.ts create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/public-field-chain/before/model.ts create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/public-field-chain/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/structural-constructor-mismatch/after/model.ts create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/structural-constructor-mismatch/before/model.ts create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/structural-constructor-mismatch/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/structurally-open-field-parameter/after/caller.ts create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/structurally-open-field-parameter/after/model.ts create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/structurally-open-field-parameter/before/caller.ts create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/structurally-open-field-parameter/before/model.ts create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/structurally-open-field-parameter/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/structurally-open-method-parameter/after/model.ts create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/structurally-open-method-parameter/before/model.ts create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/structurally-open-method-parameter/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/this-parameter-function-refusal/after/model.ts create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/this-parameter-function-refusal/before/model.ts create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/this-parameter-function-refusal/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/transitive-external-base-family/after/model.ts create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/transitive-external-base-family/before/model.ts create mode 100644 gitnexus/test/fixtures/rename-integration/typescript/transitive-external-base-family/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/vue/component-tags-and-alias/after/App.vue create mode 100644 gitnexus/test/fixtures/rename-integration/vue/component-tags-and-alias/after/Other.vue create mode 100644 gitnexus/test/fixtures/rename-integration/vue/component-tags-and-alias/after/components.ts create mode 100644 gitnexus/test/fixtures/rename-integration/vue/component-tags-and-alias/before/App.vue create mode 100644 gitnexus/test/fixtures/rename-integration/vue/component-tags-and-alias/before/Other.vue create mode 100644 gitnexus/test/fixtures/rename-integration/vue/component-tags-and-alias/before/components.ts create mode 100644 gitnexus/test/fixtures/rename-integration/vue/component-tags-and-alias/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/vue/escaped-import-source/after/App.vue create mode 100644 gitnexus/test/fixtures/rename-integration/vue/escaped-import-source/after/source.ts create mode 100644 gitnexus/test/fixtures/rename-integration/vue/escaped-import-source/before/App.vue create mode 100644 gitnexus/test/fixtures/rename-integration/vue/escaped-import-source/before/source.ts create mode 100644 gitnexus/test/fixtures/rename-integration/vue/escaped-import-source/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/vue/external-base-family/after/App.vue create mode 100644 gitnexus/test/fixtures/rename-integration/vue/external-base-family/after/notes.txt create mode 100644 gitnexus/test/fixtures/rename-integration/vue/external-base-family/before/App.vue create mode 100644 gitnexus/test/fixtures/rename-integration/vue/external-base-family/before/notes.txt create mode 100644 gitnexus/test/fixtures/rename-integration/vue/external-base-family/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/vue/import-alias-call-chain/after/App.vue create mode 100644 gitnexus/test/fixtures/rename-integration/vue/import-alias-call-chain/after/api.ts create mode 100644 gitnexus/test/fixtures/rename-integration/vue/import-alias-call-chain/after/middle.ts create mode 100644 gitnexus/test/fixtures/rename-integration/vue/import-alias-call-chain/before/App.vue create mode 100644 gitnexus/test/fixtures/rename-integration/vue/import-alias-call-chain/before/api.ts create mode 100644 gitnexus/test/fixtures/rename-integration/vue/import-alias-call-chain/before/middle.ts create mode 100644 gitnexus/test/fixtures/rename-integration/vue/import-alias-call-chain/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/vue/member-destructure-refusal/after/App.vue create mode 100644 gitnexus/test/fixtures/rename-integration/vue/member-destructure-refusal/after/model.ts create mode 100644 gitnexus/test/fixtures/rename-integration/vue/member-destructure-refusal/before/App.vue create mode 100644 gitnexus/test/fixtures/rename-integration/vue/member-destructure-refusal/before/model.ts create mode 100644 gitnexus/test/fixtures/rename-integration/vue/member-destructure-refusal/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/vue/member-object-key-refusal/after/App.vue create mode 100644 gitnexus/test/fixtures/rename-integration/vue/member-object-key-refusal/after/model.ts create mode 100644 gitnexus/test/fixtures/rename-integration/vue/member-object-key-refusal/before/App.vue create mode 100644 gitnexus/test/fixtures/rename-integration/vue/member-object-key-refusal/before/model.ts create mode 100644 gitnexus/test/fixtures/rename-integration/vue/member-object-key-refusal/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/vue/raw-block-close-boundaries/after/App.vue create mode 100644 gitnexus/test/fixtures/rename-integration/vue/raw-block-close-boundaries/before/App.vue create mode 100644 gitnexus/test/fixtures/rename-integration/vue/raw-block-close-boundaries/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/vue/setup-template-css-shadows/after/App.vue create mode 100644 gitnexus/test/fixtures/rename-integration/vue/setup-template-css-shadows/before/App.vue create mode 100644 gitnexus/test/fixtures/rename-integration/vue/setup-template-css-shadows/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/zig/c-consumer/after/caller.c create mode 100644 gitnexus/test/fixtures/rename-integration/zig/c-consumer/after/lib.zig create mode 100644 gitnexus/test/fixtures/rename-integration/zig/c-consumer/before/caller.c create mode 100644 gitnexus/test/fixtures/rename-integration/zig/c-consumer/before/lib.zig create mode 100644 gitnexus/test/fixtures/rename-integration/zig/c-consumer/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/zig/import-alias-call-chain/after/main.zig create mode 100644 gitnexus/test/fixtures/rename-integration/zig/import-alias-call-chain/after/service.zig create mode 100644 gitnexus/test/fixtures/rename-integration/zig/import-alias-call-chain/before/main.zig create mode 100644 gitnexus/test/fixtures/rename-integration/zig/import-alias-call-chain/before/service.zig create mode 100644 gitnexus/test/fixtures/rename-integration/zig/import-alias-call-chain/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/zig/reflected-hasdecl-new-name/after/model.zig create mode 100644 gitnexus/test/fixtures/rename-integration/zig/reflected-hasdecl-new-name/before/model.zig create mode 100644 gitnexus/test/fixtures/rename-integration/zig/reflected-hasdecl-new-name/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/zig/reflected-hasdecl-old-name/after/model.zig create mode 100644 gitnexus/test/fixtures/rename-integration/zig/reflected-hasdecl-old-name/before/model.zig create mode 100644 gitnexus/test/fixtures/rename-integration/zig/reflected-hasdecl-old-name/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/zig/reflected-hasdecl-unrelated-owner/after/model.zig create mode 100644 gitnexus/test/fixtures/rename-integration/zig/reflected-hasdecl-unrelated-owner/before/model.zig create mode 100644 gitnexus/test/fixtures/rename-integration/zig/reflected-hasdecl-unrelated-owner/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/zig/reflected-hasfield-new-name/after/model.zig create mode 100644 gitnexus/test/fixtures/rename-integration/zig/reflected-hasfield-new-name/before/model.zig create mode 100644 gitnexus/test/fixtures/rename-integration/zig/reflected-hasfield-new-name/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/zig/reflected-hasfield-old-name/after/model.zig create mode 100644 gitnexus/test/fixtures/rename-integration/zig/reflected-hasfield-old-name/before/model.zig create mode 100644 gitnexus/test/fixtures/rename-integration/zig/reflected-hasfield-old-name/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/zig/reflected-hasfield-unrelated-owner/after/model.zig create mode 100644 gitnexus/test/fixtures/rename-integration/zig/reflected-hasfield-unrelated-owner/before/model.zig create mode 100644 gitnexus/test/fixtures/rename-integration/zig/reflected-hasfield-unrelated-owner/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/zig/typed-container-deep-field/after/model.zig create mode 100644 gitnexus/test/fixtures/rename-integration/zig/typed-container-deep-field/before/model.zig create mode 100644 gitnexus/test/fixtures/rename-integration/zig/typed-container-deep-field/case.json create mode 100644 gitnexus/test/fixtures/rename-integration/zig/typed-container-method/after/model.zig create mode 100644 gitnexus/test/fixtures/rename-integration/zig/typed-container-method/before/model.zig create mode 100644 gitnexus/test/fixtures/rename-integration/zig/typed-container-method/case.json create mode 100644 gitnexus/test/fixtures/rename-planning-safety/model.ts create mode 100644 gitnexus/test/helpers/rename-integration-cases.ts create mode 100644 gitnexus/test/helpers/rename-integration-runner.mjs create mode 100644 gitnexus/test/helpers/rename-planning-safety-runner.mjs create mode 100644 gitnexus/test/helpers/rename-provider-fixture.ts create mode 100644 gitnexus/test/integration/cfg/new-provider-pipeline.test.ts create mode 100644 gitnexus/test/integration/pdg-explicit-owner.test.ts create mode 100644 gitnexus/test/integration/rename-planning-safety.test.ts create mode 100644 gitnexus/test/integration/rename-provider-c.test.ts create mode 100644 gitnexus/test/integration/rename-provider-cobol.test.ts create mode 100644 gitnexus/test/integration/rename-provider-cpp.test.ts create mode 100644 gitnexus/test/integration/rename-provider-csharp.test.ts create mode 100644 gitnexus/test/integration/rename-provider-dart.test.ts create mode 100644 gitnexus/test/integration/rename-provider-go.test.ts create mode 100644 gitnexus/test/integration/rename-provider-java.test.ts create mode 100644 gitnexus/test/integration/rename-provider-javascript.test.ts create mode 100644 gitnexus/test/integration/rename-provider-kotlin.test.ts create mode 100644 gitnexus/test/integration/rename-provider-matrix.test.ts create mode 100644 gitnexus/test/integration/rename-provider-objective-c.test.ts create mode 100644 gitnexus/test/integration/rename-provider-php.test.ts create mode 100644 gitnexus/test/integration/rename-provider-python.test.ts create mode 100644 gitnexus/test/integration/rename-provider-ruby.test.ts create mode 100644 gitnexus/test/integration/rename-provider-rust.test.ts create mode 100644 gitnexus/test/integration/rename-provider-swift.test.ts create mode 100644 gitnexus/test/integration/rename-provider-typescript.test.ts create mode 100644 gitnexus/test/integration/rename-provider-vue.test.ts create mode 100644 gitnexus/test/integration/rename-provider-zig.test.ts create mode 100644 gitnexus/test/integration/resolvers/go-namespace-member-visibility.test.ts create mode 100644 gitnexus/test/integration/resolvers/ruby-accessor-dispatch.test.ts create mode 100644 gitnexus/test/unit/canonicalize-workspace-bindings.test.ts create mode 100644 gitnexus/test/unit/cfg/cobol-visitor.test.ts create mode 100644 gitnexus/test/unit/cfg/explicit-owner.test.ts create mode 100644 gitnexus/test/unit/cfg/objective-c-visitor.test.ts create mode 100644 gitnexus/test/unit/cfg/source-provider.test.ts create mode 100644 gitnexus/test/unit/cfg/zig-visitor.test.ts create mode 100644 gitnexus/test/unit/graph-rename.test.ts create mode 100644 gitnexus/test/unit/rename-cobol-providers.test.ts create mode 100644 gitnexus/test/unit/rename-dynamic-providers.test.ts create mode 100644 gitnexus/test/unit/rename-family.test.ts create mode 100644 gitnexus/test/unit/rename-go-reresolution.test.ts create mode 100644 gitnexus/test/unit/rename-go-visibility.test.ts create mode 100644 gitnexus/test/unit/rename-managed-identifiers.test.ts create mode 100644 gitnexus/test/unit/rename-managed-providers.test.ts create mode 100644 gitnexus/test/unit/rename-native-providers.test.ts create mode 100644 gitnexus/test/unit/rename-objective-c.test.ts create mode 100644 gitnexus/test/unit/rename-php-ruby-providers.test.ts create mode 100644 gitnexus/test/unit/rename-plan.test.ts create mode 100644 gitnexus/test/unit/rename-planning-errors.test.ts create mode 100644 gitnexus/test/unit/rename-python-exports.test.ts create mode 100644 gitnexus/test/unit/rename-python-members.test.ts create mode 100644 gitnexus/test/unit/rename-retention.test.ts create mode 100644 gitnexus/test/unit/rename-typescript-member-keys.test.ts create mode 100644 gitnexus/test/unit/rename-vue-providers.test.ts create mode 100644 gitnexus/test/unit/rename-zig-reflection.test.ts create mode 100644 gitnexus/test/unit/scope-resolution/namespace-member-visibility.test.ts create mode 100644 gitnexus/test/unit/scope-resolution/typescript/typescript-identifier-imports.test.ts create mode 100644 gitnexus/test/unit/swift-scope-captures.test.ts diff --git a/.claude/skills/gitnexus-guide/SKILL.md b/.claude/skills/gitnexus-guide/SKILL.md index 126886fd7..50ecb5a5b 100644 --- a/.claude/skills/gitnexus-guide/SKILL.md +++ b/.claude/skills/gitnexus-guide/SKILL.md @@ -38,7 +38,7 @@ For any task involving code understanding, debugging, impact analysis, or refact | `impact` | Symbol blast radius — what breaks at depth 1/2/3 with confidence | | `trace` | Shortest path between two symbols — "how does A reach B?" in one call | | `detect_changes` | Git-diff impact — what do your current changes affect | -| `rename` | Multi-file coordinated rename with confidence-tagged edits | +| `rename` | Semantic rename: previews by default (`dry_run` omitted/true), applies with `dry_run: false`; inspect application status and failures | | `cypher` | Raw graph queries (read `gitnexus://repo/{name}/schema` first) | | `explain` | Persisted taint findings — source→sink data flows (needs `analyze --pdg`) | | `pdg_query` | Control/data dependence — what gates X (CDG) / where Y flows (REACHING_DEF); needs `analyze --pdg` | diff --git a/.claude/skills/gitnexus-refactoring/SKILL.md b/.claude/skills/gitnexus-refactoring/SKILL.md index 7bcc12711..76087a4ec 100644 --- a/.claude/skills/gitnexus-refactoring/SKILL.md +++ b/.claude/skills/gitnexus-refactoring/SKILL.md @@ -54,9 +54,9 @@ checkout and reports nothing changed, which reads as a verified refactor. ``` - [ ] list_repos {} — bind repo; explicit repo when >1 indexed, ask if ambiguous -- [ ] rename({symbol_name: "oldName", new_name: "newName", dry_run: true}) — preview all edits +- [ ] rename({symbol_name: "oldName", new_name: "newName", dry_run: true}) — preview semantic edits (omitting dry_run also previews) - [ ] Confirm the previewed file paths are in the bound repository/worktree -- [ ] Review graph edits (high confidence) and text_search edits (review carefully) +- [ ] 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 @@ -89,15 +89,62 @@ checkout and reports nothing changed, which reads as a verified refactor. ## Tools -**rename** — automated multi-file rename: +**rename** — semantic rename; preview first, then apply: ``` rename({symbol_name: "validateUser", new_name: "authenticateUser", repo: "my-app", dry_run: true}) → 12 edits across 8 files -→ 10 graph edits (high confidence), 2 text_search edits (review) -→ Changes: [{file_path, edits: [{line, old_text, new_text, confidence}]}] +→ semantic_edits: 12, coverage: {name: "gitnexus-semantic", scope: "indexed-repository", limitations: [...]} +→ changes: [{file_path, edits: [{start, length, line, old_text, new_text, confidence: "semantic"}]}] ``` +`dry_run` omitted or `true` previews and never writes; `dry_run: false` applies. +An MCP server started with `GITNEXUS_MCP_READ_ONLY=1` still lists `rename`, but +as preview-only: it rejects any `dry_run` other than omitted or `true`, so apply +from a client that is not read-only. + +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 accepted by the selected provider, including supported embedded sources, +and excludes unindexed files and external consumers. All registered source-language +providers supply rename rules. Declaration and override families require complete +semantic evidence. Dynamic or computed references block a rename when the provider +cannot resolve them or exclude them from the selected family. Provider-specific +limits, including class renames requiring a file move, remain explicit in results. +There is no regex fallback: legacy `graph_edits` and `text_search_edits` are zero. +Comments, strings, and unrelated same-spelled symbols are not replacements. + +Preview and apply use the same planner. Results use `result_version: 2`; +`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`. A completed apply +has `status: "success"`, `application_status: "applied"`, and `applied: true`. +A successful preview has not written files. `rename` does not accept `branch`: +it inspects the current checkout. + +Recover from a blocked or incomplete rename before applying again. A failed +result carries `code` and `error`; nothing was written unless the last row applies. + +| Result | Next step | +| --- | --- | +| `stale_graph` | Re-analyze the bound repository, then preview again with `rename` (`dry_run` omitted or `true`). | +| `source_changed` | Preview again against the current source and review it. If it appears during an apply, see the `partial` row. | +| `unsupported_*` | Stop automated rename and inspect the reported limitation. Use a language-aware manual refactor if needed. | +| Ambiguous target (`status: "ambiguous"` with candidates) | Inspect the returned candidates or `context`, then preview with the chosen `symbol_uid`. | +| `ambiguous_reference`, `incomplete_semantics` | The planner could not prove a reference, import, or declaration is covered. Stop automated rename; resolve the named location by hand or pick a different symbol. | +| `name_collision` | `new_name` would collide with, or change the identity of, another declaration or binding. Choose a different name and preview again. | +| `invalid_name` | `new_name` is empty, equals the current name, or is not valid in the target language. Choose a valid, different identifier. | +| `invalid_argument` | `dry_run` was sent but is not a boolean. Omit it or send `true` or `false`. | +| `no_locations` | The planner produced no edits. Do not apply; re-check the target with `context` and pass `symbol_uid`. | +| `overlapping_edits`, `invalid_edit` | The planned spans conflict or do not match the source text. The plan is rejected before any write; do not apply. Refactor manually or report the case. | +| `duplicate_file`, `missing_snapshot`, `outside_repository` | A planner safety check failed (two paths resolve to one real file, a file has no source snapshot, or a path leaves the repository). Re-analyze and preview again; if it repeats, stop and report it. | +| `source-unavailable` (`reason: "content-retention"` or `"checkout-missing"`) | The index was built without full source retention (`GITNEXUS_CONTENT_RETENTION`), or the checkout directory is missing. Re-analyze with full retention, or run against the checkout. | +| `rename_failed` | An unexpected error while planning, before any write (`application_status: "not_started"`). Re-analyze and preview once more; if it repeats, stop and report the `error` text. | +| `application_status: "partial"` or `"failed"` (`write_failed`, or `source_changed` mid-apply) | Writes are not transactional. Inspect landed `changes` (already written, with their counts), `failed_files` (the one file whose write or validation failed), and `unattempted_files` (later files never touched). Reconcile the current files, re-index, and review a fresh preview; never blindly retry the apply call. | + +Run the project validation suite after applying. + **impact** — map all dependents first: ``` @@ -147,10 +194,10 @@ RETURN caller.name, caller.filePath ORDER BY caller.filePath → total: 2 (my-app, billing-api) — both define validateUser, so bind explicitly 1. rename({symbol_name: "validateUser", new_name: "authenticateUser", repo: "my-app", dry_run: true}) - → 12 edits: 10 graph (safe), 2 text_search (review) - → Files: validator.ts, login.ts, middleware.ts, config.json... + → 12 semantic occurrences in the indexed repository's supported provider scope + → Files: validator.ts, login.ts, middleware.ts... -2. Review text_search edits (config.json: dynamic reference!) +2. Review semantic spans and provider coverage; inspect dynamic references separately 3. rename({symbol_name: "validateUser", new_name: "authenticateUser", repo: "my-app", dry_run: false}) → Applied 12 edits across 8 files diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index 52bf8972e..d45c5bcc9 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -18,10 +18,6 @@ jobs: - uses: ./.github/actions/setup-gitnexus with: build: 'true' - - name: Install ripgrep for rename text search - run: | - sudo apt-get -o DPkg::Lock::Timeout=600 update - sudo apt-get -o DPkg::Lock::Timeout=600 install --yes --no-install-recommends ripgrep - name: Check fixed answers against real tools working-directory: gitnexus run: node --import tsx bench/tool-accuracy/run.ts --check --out "$RUNNER_TEMP/tool-accuracy" @@ -199,7 +195,7 @@ jobs: steps: - id: gen run: | - TOTAL=4 # cross-platform (windows/macOS) shards per OS + TOTAL=8 # cross-platform (windows/macOS) shards per OS COV_TOTAL=4 # ubuntu coverage shards (merged before thresholds) if [ "$TOTAL" -lt 1 ] || [ "$COV_TOTAL" -lt 1 ]; then echo "shard totals must be >= 1" >&2; exit 1 @@ -625,6 +621,11 @@ jobs: run: node --import tsx bench/scope-capture/measure.mjs --check working-directory: gitnexus + - name: Rename output construction scaling guard + if: ${{ !cancelled() }} + run: node --import tsx bench/rename/measure.mjs --check + working-directory: gitnexus + - name: Callable-value-flow target-index guards (#2693) if: ${{ !cancelled() }} # Build-free: asserts buildGraphTargetIndex resolves an unchanged target diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 634f4a78b..ea792087a 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -116,11 +116,6 @@ jobs: if: steps.version.outputs.publish == 'true' with: build: 'true' - - name: Install ripgrep for rename text search - if: steps.version.outputs.publish == 'true' - run: | - sudo apt-get -o DPkg::Lock::Timeout=600 update - sudo apt-get -o DPkg::Lock::Timeout=600 install --yes --no-install-recommends ripgrep - name: Check fixed answers before Docker publication if: steps.version.outputs.publish == 'true' working-directory: gitnexus diff --git a/AGENTS.md b/AGENTS.md index 2172825f5..42a9340b7 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` which understands the call graph. +- NEVER rename symbols with find-and-replace — use `rename` with `dry_run: true` first, review the graph-backed semantic spans and coverage, then apply with `dry_run: false`. - NEVER commit before MCP/CLI graph change analysis. ## Resources diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 8a13abd94..f546ba411 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -36,7 +36,7 @@ Monorepo: **CLI/MCP** (`gitnexus/`) + **browser UI** (`gitnexus-web/`). | `context` | Callers, callees, processes for one symbol | | `impact` | Blast radius (upstream/downstream) with risk summary | | `detect_changes` | Map git diffs to affected symbols and processes | -| `rename` | Graph-assisted multi-file rename with `dry_run` preview | +| `rename` | Semantic rename: previews by default (`dry_run` omitted/true), applies with `dry_run: false` | | `api_impact` | Pre-change impact report for an API route handler | | `trace` | Shortest directed path between two symbols (call + class-member edges); group-aware (`repo: "@"`) for cross-repo traces | | `route_map` | API route → handler → consumer mappings | @@ -276,6 +276,14 @@ Language-agnostic scope-resolution resolver. This is the resolution path for eve Orchestrator: `runScopeResolution(input, provider)` in `scope-resolution/pipeline/run.ts`. Pipeline phase: `scopeResolutionPhase` in `scope-resolution/pipeline/phase.ts` — iterates the registered `SCOPE_RESOLVERS` over the worker-serialized `ParsedFile`s. (Per-language `emitScopeCaptures` hooks may reuse a cached Tree via the orchestrator's `treeCache`, but in worker-pool runs that cache is empty — Trees can't cross MessageChannels — so they consume the pre-extracted `ParsedFile` instead; § Performance notes.) +### Semantic rename + +`rename` plans through the graph-backed planner in `mcp/local/rename/graph-rename.ts`; `dry_run` (omitted or `true`) stops after planning, `dry_run: false` also applies. In MCP read-only mode the same tool is listed preview-only and any other `dry_run` is rejected before backend dispatch (`mcp/read-only-policy.ts`). The selected graph declaration must match the current source. Each language's `LanguageProvider.rename` supplies original-source name spans, spelling rules, declaration families, and syntax-specific rewrites. Embedded and standalone providers retain their own parsing strategy; shared planning does not classify language syntax or invoke compiler/language-service APIs. + +The planner runs the existing scope-resolution pipeline before and after the proposed edits. An opt-in source-binding observer records the declaration selected for a written name, separately from runtime dispatch expansion. Provider capture metadata travels with each parsed file, so both passes retain ordinary import and ownership evidence. Provider-proven declaration families use the existing inheritance, implementation, and declaration relationships. Re-resolution checks edited references, untouched bindings, collisions, and changes in those families. + +Application validates all captured source snapshots before each per-file replacement, then rechecks that destination immediately before installing it. Validation and replacement are separate filesystem operations; the operation is neither an atomic compare-and-swap nor a multi-file transaction. Unsupported or ambiguous source constructs produce an explicit blocked result. Coverage is limited to the indexed workspace and the provider's available semantic evidence; dynamic names and external consumers are not silently treated as resolved. + ### Callable-value flow First-class callable values use a language-neutral inclusion analysis in `passes/callable-value-flow.ts`. Providers recognize their own syntax and emit JSON-safe `CallableFlowSite` facts (`seed`, `copy`, `alias`, `address`, `load`, `store`, `formal`, `argument`, and `invoke`) into `ParsedFile`; shared ingestion never branches on a language name. These always-on facts cross workers and the durable parse store, whose schema is bumped whenever their semantic shape changes. @@ -304,7 +312,9 @@ Receivers the resolver could not type are not silently dropped. Each records a ` ### Optional CFG/PDG emission (`--pdg`, #2081–#2086) -On a `--pdg` run the parse worker builds a per-function control-flow graph from the tree-sitter AST (`LanguageProvider.cfgVisitor`; TypeScript/JavaScript today) and serializes it onto `ParsedFile.cfgSideChannel` as plain data. Scope-resolution then emits the program-dependence layers from that side-channel **inside Phase 4 of `runScopeResolution`, while the disk-backed ParsedFile store is still live** — the only window where the worker-built CFGs are loaded (the store is cleared right after the phase returns). A standalone post-`mro` phase would read an empty store, so the emit deliberately lives in-phase, mirroring the `applyCaptureSideChannel` pattern. The opt-in is off by default (graph byte-identical), folded into the parse-cache key (a pdg-off warm cache is never reused on a `--pdg` run), and each layer is bounded by a per-function edge cap that logs any dropped edges. All layers are `BasicBlock → BasicBlock` edges in the single `CodeRelation` table, keyed by `type`; there is **no** `Function → BasicBlock` edge — the symbol↔block join is reconstructed from the BasicBlock id prefix + line span. The layers build on each other: +On a `--pdg` run providers build control-flow graphs through `LanguageProvider.cfgVisitor` (Tree-sitter ASTs) or `collectSourceCfgs` (standalone source processing). Every supported language supplies one of these hooks; Vue covers executable scripts. AST visitors run in the parse worker, while source-backed providers run during fresh scope extraction. Both produce plain `ParsedFile.cfgSideChannel` data. Scope-resolution emits the program-dependence layers **inside Phase 4 of `runScopeResolution`, while the disk-backed ParsedFile store is still live**. The store is cleared after that phase, so a later standalone phase cannot consume it. + +The opt-in is off by default, folded into the parse-cache key, and bounded by executable-region and edge caps. Providers report unsupported regions and cap drops instead of fabricating complete flow. All dependence edges remain `BasicBlock → BasicBlock` in the existing `CodeRelation` table. Ordinary function ownership uses the BasicBlock id prefix and source span. A provider may supply an exact graph `ownerId` for a larger executable region; COBOL uses its existing program Module identity so paragraph fallthrough and PERFORM return continuations remain inside one region. This identity is persisted on BasicBlocks and used by PDG symbol projection. No new function-to-block relation is introduced. The layers build on each other: - **M1 — CFG** (#2081): `BasicBlock` nodes + `CFG` edges. Edge _kind_ (`seq`/`cond-true`/`loop-back`/…) rides the `reason` column (CFG is one `CodeRelation` type, not one per kind). - **M2 — REACHING_DEF** (#2082): GEN/KILL def→use data dependence from a pure fixpoint solver; the variable name rides `reason`. diff --git a/CLAUDE.md b/CLAUDE.md index 0d418c9b6..da45384e4 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` which understands the call graph. +- NEVER rename symbols with find-and-replace — use `rename` with `dry_run: true` first, review the graph-backed semantic spans and coverage, then apply with `dry_run: false`. - NEVER commit before MCP/CLI graph change analysis. ## Resources diff --git a/GUARDRAILS.md b/GUARDRAILS.md index 185015e9e..c4e0e92fc 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` MCP tool with `dry_run: true` first, review `graph` vs `text_search` edits. No separate `gitnexus rename` CLI exists. +2. **Never rename with find-and-replace** in GitNexus-indexed projects — use `rename` with `dry_run: true` first (omitting `dry_run` also previews), 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 d89a7ef05..00b0aa634 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` | Multi-file coordinated rename with graph + text search | +| `rename` | Semantic rename — previews by default, `dry_run: false` applies | | `cypher` | Raw Cypher graph queries | | `route_map` | API route map — which components fetch which endpoints, and handlers | | `tool_map` | MCP/RPC tool definitions — where they're defined and handled | @@ -372,7 +372,7 @@ codex plugin marketplace add abhigyanpatwari/GitNexus
MCP read-only mode -Set `GITNEXUS_MCP_READ_ONLY=1` before starting the MCP server to expose only the proven single-repository read surface. Raw `cypher`, rename and group tools, group routing, and group resources are omitted from discovery and rejected before backend dispatch. Tool descriptions and generated setup/context resources are scrubbed so they do not recommend unavailable routes. +Set `GITNEXUS_MCP_READ_ONLY=1` before starting the MCP server to expose only the proven single-repository read surface. `rename` stays available as a preview-only tool: its `dry_run` must be omitted or `true`, and any other value is rejected before backend dispatch. Raw `cypher`, group tools, group routing, and group resources are omitted from discovery and rejected before backend dispatch. Tool descriptions and generated setup/context resources are scrubbed so they do not recommend unavailable routes. The default is unchanged when the variable is unset or `0`. Any other value fails server startup rather than silently weakening the policy. @@ -725,7 +725,7 @@ GitNexus builds a complete knowledge graph of your codebase through a multi-phas **Imports** — cross-file import resolution · **Named Bindings** — `import { X as Y }` / re-export tracking · **Exports** — public/exported symbol detection · **Heritage** — class inheritance, interfaces, mixins · **Type Annotations** — explicit type extraction for receiver resolution · **Constructor Inference** — infer receiver type from constructor calls (`self`/`this` resolution included for all languages) · **Config** — language toolchain config parsing (tsconfig, go.mod, etc.) · **Frameworks** — AST-based framework pattern detection · **Entry Points** — entry point scoring heuristics -**Control flow (CFG, opt-in `--pdg`)** — per-function control-flow graphs (`BasicBlock` nodes + `CFG` edges) feeding the PDG/taint substrate, currently **TypeScript & JavaScript** (#2081 M1); other languages planned. Off by default. +**Control flow (CFG, opt-in `--pdg`)** — provider-owned control-flow graphs (`BasicBlock` nodes + `CFG` edges) feed control dependence and reaching definitions across the registered source languages. AST providers describe callable regions; COBOL uses source-backed program regions. Unsupported constructs and region limits produce explicit skips. Off by default. ### Multi-Repo Architecture @@ -874,19 +874,63 @@ changed_symbols: [validateUser, AuthService, ...] affected_processes: [LoginFlow, RegistrationFlow, ...] ``` -### Rename (Multi-File) +### Rename (Graph and Semantic Layer) +```js +rename({symbol_name: "validateUser", new_name: "verifyUser", repo: "my-app", dry_run: true}) +// Omitting dry_run also previews. For an ambiguous name, repeat with the symbol_uid from context(). ``` -rename({symbol_name: "validateUser", new_name: "verifyUser", dry_run: true}) +```text +result_version: 2 status: success +planning_status: ready +application_status: not_requested +applied: false files_affected: 5 total_edits: 8 -graph_edits: 6 (high confidence) -text_search_edits: 2 (review carefully) -changes: [...] +semantic_edits: 8 +graph_edits: 0 +text_search_edits: 0 +text_search: not_used +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 coverage, then call `rename` with the same +selectors and `dry_run: false` to apply. Omitting `dry_run` also previews. +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. + +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 accepted by the selected language provider, +including its supported embedded-language sources, and excludes unindexed files +and external consumers. Each registered source-language provider supplies rename +rules. Constructor, destructor, inheritance, and interface families require +complete provider evidence. Dynamic or computed references block a rename when +the provider cannot prove their binding or exclude them from the selected family. +`rename` operates on the current checkout and rejects `branch`. + +Language-specific limits remain explicit in the result. For example, PHP handles +case-insensitive function/class names separately from case-sensitive constants +and properties, but refuses class renames that require moving a PSR-4 file. +Java also refuses public top-level type renames that require a source-file move. +Rename edits source spans; it does not move files or rewrite dynamic string names. + +Preview and apply recompute their plans independently. Source snapshots are +checked before writes; this is not a cross-call preview token or transactional +filesystem operation. The final destination check and replacement are separate +operations, so another process can still change a file between them. Avoid +concurrent source writes during application. On a write failure, `status: partial` and `failed_files` +report the failed file and `unattempted_files` lists the later files that were never touched; +`applied`, counts, and `changes` describe only landed edits. +Inspect failures before retrying, and run type checking, tests, and +`detect_changes` after applying. A valid identifier is not a guarantee against +new-name collisions. + ### Cypher Queries ```cypher diff --git a/gitnexus-claude-plugin/skills/gitnexus-guide/SKILL.md b/gitnexus-claude-plugin/skills/gitnexus-guide/SKILL.md index 126886fd7..50ecb5a5b 100644 --- a/gitnexus-claude-plugin/skills/gitnexus-guide/SKILL.md +++ b/gitnexus-claude-plugin/skills/gitnexus-guide/SKILL.md @@ -38,7 +38,7 @@ For any task involving code understanding, debugging, impact analysis, or refact | `impact` | Symbol blast radius — what breaks at depth 1/2/3 with confidence | | `trace` | Shortest path between two symbols — "how does A reach B?" in one call | | `detect_changes` | Git-diff impact — what do your current changes affect | -| `rename` | Multi-file coordinated rename with confidence-tagged edits | +| `rename` | Semantic rename: previews by default (`dry_run` omitted/true), applies with `dry_run: false`; inspect application status and failures | | `cypher` | Raw graph queries (read `gitnexus://repo/{name}/schema` first) | | `explain` | Persisted taint findings — source→sink data flows (needs `analyze --pdg`) | | `pdg_query` | Control/data dependence — what gates X (CDG) / where Y flows (REACHING_DEF); needs `analyze --pdg` | diff --git a/gitnexus-claude-plugin/skills/gitnexus-refactoring/SKILL.md b/gitnexus-claude-plugin/skills/gitnexus-refactoring/SKILL.md index 7bcc12711..76087a4ec 100644 --- a/gitnexus-claude-plugin/skills/gitnexus-refactoring/SKILL.md +++ b/gitnexus-claude-plugin/skills/gitnexus-refactoring/SKILL.md @@ -54,9 +54,9 @@ checkout and reports nothing changed, which reads as a verified refactor. ``` - [ ] list_repos {} — bind repo; explicit repo when >1 indexed, ask if ambiguous -- [ ] rename({symbol_name: "oldName", new_name: "newName", dry_run: true}) — preview all edits +- [ ] rename({symbol_name: "oldName", new_name: "newName", dry_run: true}) — preview semantic edits (omitting dry_run also previews) - [ ] Confirm the previewed file paths are in the bound repository/worktree -- [ ] Review graph edits (high confidence) and text_search edits (review carefully) +- [ ] 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 @@ -89,15 +89,62 @@ checkout and reports nothing changed, which reads as a verified refactor. ## Tools -**rename** — automated multi-file rename: +**rename** — semantic rename; preview first, then apply: ``` rename({symbol_name: "validateUser", new_name: "authenticateUser", repo: "my-app", dry_run: true}) → 12 edits across 8 files -→ 10 graph edits (high confidence), 2 text_search edits (review) -→ Changes: [{file_path, edits: [{line, old_text, new_text, confidence}]}] +→ semantic_edits: 12, coverage: {name: "gitnexus-semantic", scope: "indexed-repository", limitations: [...]} +→ changes: [{file_path, edits: [{start, length, line, old_text, new_text, confidence: "semantic"}]}] ``` +`dry_run` omitted or `true` previews and never writes; `dry_run: false` applies. +An MCP server started with `GITNEXUS_MCP_READ_ONLY=1` still lists `rename`, but +as preview-only: it rejects any `dry_run` other than omitted or `true`, so apply +from a client that is not read-only. + +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 accepted by the selected provider, including supported embedded sources, +and excludes unindexed files and external consumers. All registered source-language +providers supply rename rules. Declaration and override families require complete +semantic evidence. Dynamic or computed references block a rename when the provider +cannot resolve them or exclude them from the selected family. Provider-specific +limits, including class renames requiring a file move, remain explicit in results. +There is no regex fallback: legacy `graph_edits` and `text_search_edits` are zero. +Comments, strings, and unrelated same-spelled symbols are not replacements. + +Preview and apply use the same planner. Results use `result_version: 2`; +`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`. A completed apply +has `status: "success"`, `application_status: "applied"`, and `applied: true`. +A successful preview has not written files. `rename` does not accept `branch`: +it inspects the current checkout. + +Recover from a blocked or incomplete rename before applying again. A failed +result carries `code` and `error`; nothing was written unless the last row applies. + +| Result | Next step | +| --- | --- | +| `stale_graph` | Re-analyze the bound repository, then preview again with `rename` (`dry_run` omitted or `true`). | +| `source_changed` | Preview again against the current source and review it. If it appears during an apply, see the `partial` row. | +| `unsupported_*` | Stop automated rename and inspect the reported limitation. Use a language-aware manual refactor if needed. | +| Ambiguous target (`status: "ambiguous"` with candidates) | Inspect the returned candidates or `context`, then preview with the chosen `symbol_uid`. | +| `ambiguous_reference`, `incomplete_semantics` | The planner could not prove a reference, import, or declaration is covered. Stop automated rename; resolve the named location by hand or pick a different symbol. | +| `name_collision` | `new_name` would collide with, or change the identity of, another declaration or binding. Choose a different name and preview again. | +| `invalid_name` | `new_name` is empty, equals the current name, or is not valid in the target language. Choose a valid, different identifier. | +| `invalid_argument` | `dry_run` was sent but is not a boolean. Omit it or send `true` or `false`. | +| `no_locations` | The planner produced no edits. Do not apply; re-check the target with `context` and pass `symbol_uid`. | +| `overlapping_edits`, `invalid_edit` | The planned spans conflict or do not match the source text. The plan is rejected before any write; do not apply. Refactor manually or report the case. | +| `duplicate_file`, `missing_snapshot`, `outside_repository` | A planner safety check failed (two paths resolve to one real file, a file has no source snapshot, or a path leaves the repository). Re-analyze and preview again; if it repeats, stop and report it. | +| `source-unavailable` (`reason: "content-retention"` or `"checkout-missing"`) | The index was built without full source retention (`GITNEXUS_CONTENT_RETENTION`), or the checkout directory is missing. Re-analyze with full retention, or run against the checkout. | +| `rename_failed` | An unexpected error while planning, before any write (`application_status: "not_started"`). Re-analyze and preview once more; if it repeats, stop and report the `error` text. | +| `application_status: "partial"` or `"failed"` (`write_failed`, or `source_changed` mid-apply) | Writes are not transactional. Inspect landed `changes` (already written, with their counts), `failed_files` (the one file whose write or validation failed), and `unattempted_files` (later files never touched). Reconcile the current files, re-index, and review a fresh preview; never blindly retry the apply call. | + +Run the project validation suite after applying. + **impact** — map all dependents first: ``` @@ -147,10 +194,10 @@ RETURN caller.name, caller.filePath ORDER BY caller.filePath → total: 2 (my-app, billing-api) — both define validateUser, so bind explicitly 1. rename({symbol_name: "validateUser", new_name: "authenticateUser", repo: "my-app", dry_run: true}) - → 12 edits: 10 graph (safe), 2 text_search (review) - → Files: validator.ts, login.ts, middleware.ts, config.json... + → 12 semantic occurrences in the indexed repository's supported provider scope + → Files: validator.ts, login.ts, middleware.ts... -2. Review text_search edits (config.json: dynamic reference!) +2. Review semantic spans and provider coverage; inspect dynamic references separately 3. rename({symbol_name: "validateUser", new_name: "authenticateUser", repo: "my-app", dry_run: false}) → Applied 12 edits across 8 files diff --git a/gitnexus-cursor-integration/skills/gitnexus-refactoring/SKILL.md b/gitnexus-cursor-integration/skills/gitnexus-refactoring/SKILL.md index 7bcc12711..76087a4ec 100644 --- a/gitnexus-cursor-integration/skills/gitnexus-refactoring/SKILL.md +++ b/gitnexus-cursor-integration/skills/gitnexus-refactoring/SKILL.md @@ -54,9 +54,9 @@ checkout and reports nothing changed, which reads as a verified refactor. ``` - [ ] list_repos {} — bind repo; explicit repo when >1 indexed, ask if ambiguous -- [ ] rename({symbol_name: "oldName", new_name: "newName", dry_run: true}) — preview all edits +- [ ] rename({symbol_name: "oldName", new_name: "newName", dry_run: true}) — preview semantic edits (omitting dry_run also previews) - [ ] Confirm the previewed file paths are in the bound repository/worktree -- [ ] Review graph edits (high confidence) and text_search edits (review carefully) +- [ ] 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 @@ -89,15 +89,62 @@ checkout and reports nothing changed, which reads as a verified refactor. ## Tools -**rename** — automated multi-file rename: +**rename** — semantic rename; preview first, then apply: ``` rename({symbol_name: "validateUser", new_name: "authenticateUser", repo: "my-app", dry_run: true}) → 12 edits across 8 files -→ 10 graph edits (high confidence), 2 text_search edits (review) -→ Changes: [{file_path, edits: [{line, old_text, new_text, confidence}]}] +→ semantic_edits: 12, coverage: {name: "gitnexus-semantic", scope: "indexed-repository", limitations: [...]} +→ changes: [{file_path, edits: [{start, length, line, old_text, new_text, confidence: "semantic"}]}] ``` +`dry_run` omitted or `true` previews and never writes; `dry_run: false` applies. +An MCP server started with `GITNEXUS_MCP_READ_ONLY=1` still lists `rename`, but +as preview-only: it rejects any `dry_run` other than omitted or `true`, so apply +from a client that is not read-only. + +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 accepted by the selected provider, including supported embedded sources, +and excludes unindexed files and external consumers. All registered source-language +providers supply rename rules. Declaration and override families require complete +semantic evidence. Dynamic or computed references block a rename when the provider +cannot resolve them or exclude them from the selected family. Provider-specific +limits, including class renames requiring a file move, remain explicit in results. +There is no regex fallback: legacy `graph_edits` and `text_search_edits` are zero. +Comments, strings, and unrelated same-spelled symbols are not replacements. + +Preview and apply use the same planner. Results use `result_version: 2`; +`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`. A completed apply +has `status: "success"`, `application_status: "applied"`, and `applied: true`. +A successful preview has not written files. `rename` does not accept `branch`: +it inspects the current checkout. + +Recover from a blocked or incomplete rename before applying again. A failed +result carries `code` and `error`; nothing was written unless the last row applies. + +| Result | Next step | +| --- | --- | +| `stale_graph` | Re-analyze the bound repository, then preview again with `rename` (`dry_run` omitted or `true`). | +| `source_changed` | Preview again against the current source and review it. If it appears during an apply, see the `partial` row. | +| `unsupported_*` | Stop automated rename and inspect the reported limitation. Use a language-aware manual refactor if needed. | +| Ambiguous target (`status: "ambiguous"` with candidates) | Inspect the returned candidates or `context`, then preview with the chosen `symbol_uid`. | +| `ambiguous_reference`, `incomplete_semantics` | The planner could not prove a reference, import, or declaration is covered. Stop automated rename; resolve the named location by hand or pick a different symbol. | +| `name_collision` | `new_name` would collide with, or change the identity of, another declaration or binding. Choose a different name and preview again. | +| `invalid_name` | `new_name` is empty, equals the current name, or is not valid in the target language. Choose a valid, different identifier. | +| `invalid_argument` | `dry_run` was sent but is not a boolean. Omit it or send `true` or `false`. | +| `no_locations` | The planner produced no edits. Do not apply; re-check the target with `context` and pass `symbol_uid`. | +| `overlapping_edits`, `invalid_edit` | The planned spans conflict or do not match the source text. The plan is rejected before any write; do not apply. Refactor manually or report the case. | +| `duplicate_file`, `missing_snapshot`, `outside_repository` | A planner safety check failed (two paths resolve to one real file, a file has no source snapshot, or a path leaves the repository). Re-analyze and preview again; if it repeats, stop and report it. | +| `source-unavailable` (`reason: "content-retention"` or `"checkout-missing"`) | The index was built without full source retention (`GITNEXUS_CONTENT_RETENTION`), or the checkout directory is missing. Re-analyze with full retention, or run against the checkout. | +| `rename_failed` | An unexpected error while planning, before any write (`application_status: "not_started"`). Re-analyze and preview once more; if it repeats, stop and report the `error` text. | +| `application_status: "partial"` or `"failed"` (`write_failed`, or `source_changed` mid-apply) | Writes are not transactional. Inspect landed `changes` (already written, with their counts), `failed_files` (the one file whose write or validation failed), and `unattempted_files` (later files never touched). Reconcile the current files, re-index, and review a fresh preview; never blindly retry the apply call. | + +Run the project validation suite after applying. + **impact** — map all dependents first: ``` @@ -147,10 +194,10 @@ RETURN caller.name, caller.filePath ORDER BY caller.filePath → total: 2 (my-app, billing-api) — both define validateUser, so bind explicitly 1. rename({symbol_name: "validateUser", new_name: "authenticateUser", repo: "my-app", dry_run: true}) - → 12 edits: 10 graph (safe), 2 text_search (review) - → Files: validator.ts, login.ts, middleware.ts, config.json... + → 12 semantic occurrences in the indexed repository's supported provider scope + → Files: validator.ts, login.ts, middleware.ts... -2. Review text_search edits (config.json: dynamic reference!) +2. Review semantic spans and provider coverage; inspect dynamic references separately 3. rename({symbol_name: "validateUser", new_name: "authenticateUser", repo: "my-app", dry_run: false}) → Applied 12 edits across 8 files diff --git a/gitnexus-shared/src/index.ts b/gitnexus-shared/src/index.ts index 10bf7c0ad..0dddf3b52 100644 --- a/gitnexus-shared/src/index.ts +++ b/gitnexus-shared/src/index.ts @@ -131,7 +131,11 @@ export type { } from './scope-resolution/method-dispatch-index.js'; // SCC-aware cross-file finalize (RFC §3.2 Phase 2; Ring 2 SHARED #915) -export { finalize } from './scope-resolution/finalize-algorithm.js'; +export { + finalize, + indexExportsByName, + indexTopLevelExportsByName, +} from './scope-resolution/finalize-algorithm.js'; export type { FinalizeInput, FinalizeFile, @@ -242,3 +246,9 @@ export type { NameClaimSources, ScopeClaimSelection, } from './scope-resolution/name-claims.js'; + +export * from './rename/types.js'; +export * from './rename/results.js'; +export * from './rename/edits.js'; +export * from './rename/positions.js'; +export * from './rename/family.js'; diff --git a/gitnexus-shared/src/rename/edits.ts b/gitnexus-shared/src/rename/edits.ts new file mode 100644 index 000000000..941431f75 --- /dev/null +++ b/gitnexus-shared/src/rename/edits.ts @@ -0,0 +1,57 @@ +import { RenameFailure, type OccurrenceEdit, type ReportedEdit } from './types.js'; + +/** Validate original-source spans before constructing output or reporting edits. */ +export function prepareRenameEdits( + snapshot: string, + unsorted: readonly OccurrenceEdit[], + file: string, +): { edits: OccurrenceEdit[]; reports: ReportedEdit[] } { + const edits: OccurrenceEdit[] = []; + for (const edit of [...unsorted].sort((a, b) => a.start - b.start || a.length - b.length)) { + if ( + !Number.isSafeInteger(edit.start) || + !Number.isSafeInteger(edit.length) || + edit.start < 0 || + edit.length <= 0 || + edit.start + edit.length > snapshot.length || + snapshot.slice(edit.start, edit.start + edit.length) !== edit.old_text + ) + throw new RenameFailure('invalid_edit', `Invalid rename span or expected text in ${file}.`); + const previous = edits.at(-1); + if (previous && previous.start + previous.length > edit.start) { + if ( + previous.start === edit.start && + previous.length === edit.length && + previous.old_text === edit.old_text && + previous.new_text === edit.new_text + ) + continue; + throw new RenameFailure('overlapping_edits', `Conflicting rename spans in ${file}.`); + } + edits.push(edit); + } + // Sorted offsets allow one forward walk through the source instead of + // counting every preceding line again for every occurrence. + let line = 1; + let lineStart = 0; + let nextLine = snapshot.indexOf('\n'); + const reports = edits.map(({ file_path: _file, ...edit }): ReportedEdit => { + while (nextLine >= 0 && nextLine < edit.start) { + line++; + lineStart = nextLine + 1; + nextLine = snapshot.indexOf('\n', lineStart); + } + const lineEnd = nextLine < 0 ? snapshot.length : nextLine; + return { + ...edit, + confidence: 'semantic', + line, + before: snapshot.slice(lineStart, lineEnd), + after: + snapshot.slice(lineStart, edit.start) + + edit.new_text + + snapshot.slice(edit.start + edit.length, lineEnd), + }; + }); + return { edits, reports }; +} diff --git a/gitnexus-shared/src/rename/family.ts b/gitnexus-shared/src/rename/family.ts new file mode 100644 index 000000000..19ba139b3 --- /dev/null +++ b/gitnexus-shared/src/rename/family.ts @@ -0,0 +1,125 @@ +import type { SymbolDefinition } from '../scope-resolution/symbol-definition.js'; +import type { RenameIssue } from './types.js'; + +export interface RenameFamilyPolicy { + /** A possible language-level coupling; absence of proof for this pair refuses. */ + readonly matches: (left: SymbolDefinition, right: SymbolDefinition) => boolean; + /** Validate provider syntax (visibility, staticness, signature) for a graph pair. */ + readonly canCouple?: (left: SymbolDefinition, right: SymbolDefinition) => boolean; + /** The provider's syntax explicitly requires this declaration to override a base member. */ + readonly requiresBaseMember?: (member: SymbolDefinition) => boolean; + /** Multiple declarations of one symbol, established by the provider. */ + readonly sameDeclarationGroup?: (left: SymbolDefinition, right: SymbolDefinition) => boolean; +} + +export interface RenameFamilyContext { + readonly target: SymbolDefinition; + readonly definitions: readonly SymbolDefinition[]; + readonly graphDefs: ReadonlyMap; + readonly relationships: Iterable<{ type: string; sourceId: string; targetId: string }>; + readonly incompleteOwners: ReadonlySet; + readonly methodDispatch: { + mroFor(owner: string): readonly string[]; + implementorsOf(owner: string): readonly string[]; + }; +} + +/** Follow explicit identity edges; an owner hierarchy alone never proves an override. */ +export function graphRenameFamily( + context: RenameFamilyContext, + policy: RenameFamilyPolicy, +): readonly SymbolDefinition[] | RenameIssue { + const { target, definitions, graphDefs, relationships, incompleteOwners, methodDispatch } = + context; + const pairs: Array = []; + const uncertain = new Set(); + for (const edge of relationships) { + if (edge.type !== 'METHOD_OVERRIDES' && edge.type !== 'METHOD_IMPLEMENTS') continue; + const targets = graphDefs.get(edge.targetId) ?? []; + const sources = graphDefs.get(edge.sourceId) ?? []; + for (const destination of targets) { + const direct = sources.filter((source) => policy.matches(source, destination)); + // Override edges can originate at an owning class. Resolve that endpoint + // to exactly one written declaration, never to the first same-name method. + const candidates = direct.length + ? direct + : definitions.filter( + (def) => + sources.some((owner) => def.ownerId === owner.nodeId) && + policy.matches(def, destination), + ); + if ( + candidates.length !== 1 || + (policy.canCouple && !policy.canCouple(candidates[0]!, destination)) + ) { + uncertain.add(destination.nodeId); + for (const candidate of candidates) uncertain.add(candidate.nodeId); + continue; + } + pairs.push([candidates[0]!, destination]); + } + } + const selected = new Map([[target.nodeId, target]]); + let changed = true; + while (changed) { + changed = false; + for (const [left, right] of pairs) { + if (selected.has(left.nodeId) === selected.has(right.nodeId)) continue; + selected.set(left.nodeId, left); + selected.set(right.nodeId, right); + changed = true; + } + if (policy.sameDeclarationGroup) { + for (const def of definitions) { + if (selected.has(def.nodeId)) continue; + if ([...selected.values()].some((member) => policy.sameDeclarationGroup!(member, def))) { + selected.set(def.nodeId, def); + changed = true; + } + } + } + } + const relatedOwners = (left: string, right: string) => + left === right || + methodDispatch.mroFor(left).includes(right) || + methodDispatch.mroFor(right).includes(left) || + methodDispatch.implementorsOf(left).includes(right) || + methodDispatch.implementorsOf(right).includes(left); + const overridingMembers = new Set( + pairs + .filter(([member, base]) => member.ownerId !== base.ownerId) + .map(([member]) => member.nodeId), + ); + for (const member of selected.values()) { + if (uncertain.has(member.nodeId)) return incompleteFamily(); + if (policy.requiresBaseMember?.(member) && !overridingMembers.has(member.nodeId)) + return incompleteFamily(); + if (!member.ownerId) continue; + if ( + policy.matches(member, member) && + (!policy.canCouple || policy.canCouple(member, member)) && + [member.ownerId, ...methodDispatch.mroFor(member.ownerId)].some((ownerId) => + incompleteOwners.has(ownerId), + ) + ) + return incompleteFamily(); + for (const candidate of definitions) { + if ( + selected.has(candidate.nodeId) || + !candidate.ownerId || + !policy.matches(member, candidate) + ) + continue; + if (relatedOwners(member.ownerId, candidate.ownerId)) return incompleteFamily(); + } + } + return [...selected.values()]; +} + +function incompleteFamily(): RenameIssue { + return { + code: 'unsupported_symbol_family', + status: 'unsupported', + message: 'The semantic graph does not prove a complete declaration and override family.', + }; +} diff --git a/gitnexus-shared/src/rename/positions.ts b/gitnexus-shared/src/rename/positions.ts new file mode 100644 index 000000000..06306242f --- /dev/null +++ b/gitnexus-shared/src/rename/positions.ts @@ -0,0 +1,48 @@ +import type { OccurrenceEdit } from './types.js'; + +export function applyEdits(content: string, edits: readonly OccurrenceEdit[]): string { + let cursor = 0; + const pieces: string[] = []; + for (const edit of edits) { + pieces.push(content.slice(cursor, edit.start), edit.new_text); + cursor = edit.start + edit.length; + } + pieces.push(content.slice(cursor)); + return pieces.join(''); +} + +export function createOffsetLookup(): ( + source: { readonly content: string }, + line: number, + column: number, +) => number { + const lineStarts = new WeakMap<{ readonly content: string }, readonly number[]>(); + return (source, line, column) => { + let starts = lineStarts.get(source); + if (starts === undefined) { + const entries = [0]; + for ( + let next = source.content.indexOf('\n'); + next !== -1; + next = source.content.indexOf('\n', next + 1) + ) { + entries.push(next + 1); + } + starts = entries; + lineStarts.set(source, starts); + } + const offset = starts[line <= 1 ? 0 : line - 1]; + return offset === undefined ? source.content.length + 1 : offset + column; + }; +} + +export function relocated(offset: number, edits: readonly OccurrenceEdit[], end = false): number { + let delta = 0; + for (const edit of edits) { + if (offset < edit.start || (offset === edit.start && !end)) break; + if (offset < edit.start + edit.length) + return edit.start + delta + (end ? edit.new_text.length : 0); + delta += edit.new_text.length - edit.length; + } + return offset + delta; +} diff --git a/gitnexus-shared/src/rename/results.ts b/gitnexus-shared/src/rename/results.ts new file mode 100644 index 000000000..3b48b1d27 --- /dev/null +++ b/gitnexus-shared/src/rename/results.ts @@ -0,0 +1,41 @@ +import { RenameFailure, type RenameSymbol, type RenameResult, type FileChange } from './types.js'; + +export function renameResult( + symbol: RenameSymbol, + newName: string, + changes: FileChange[] = [], +): RenameResult { + const count = changes.reduce((sum, file) => sum + file.edits.length, 0); + return { + result_version: 2, + status: 'success', + planning_status: 'ready', + application_status: 'not_requested', + applied: false, + old_name: symbol.name, + new_name: newName, + files_affected: changes.length, + total_edits: count, + semantic_edits: count, + graph_edits: 0, + text_search_edits: 0, + text_search: 'not_used', + changes, + }; +} + +export function blockedRenameResult( + symbol: RenameSymbol, + newName: string, + error: unknown, +): RenameResult { + return { + ...renameResult(symbol, newName), + status: 'error', + application_status: 'not_started', + planning_status: error instanceof RenameFailure ? error.planningStatus : 'blocked', + code: error instanceof RenameFailure ? error.code : 'rename_failed', + error: error instanceof Error ? error.message : String(error), + ...(error instanceof RenameFailure && error.candidates ? { candidates: error.candidates } : {}), + }; +} diff --git a/gitnexus-shared/src/rename/types.ts b/gitnexus-shared/src/rename/types.ts new file mode 100644 index 000000000..910e66cc5 --- /dev/null +++ b/gitnexus-shared/src/rename/types.ts @@ -0,0 +1,111 @@ +export interface RenameSymbol { + uid?: string; + name: string; + kind?: string; + filePath: string; + /** Graph display ranges are one-based, inclusive. */ + startLine: number; + endLine?: number; +} + +export interface RenameOptions { + new_name: string; + dry_run?: boolean; +} + +export interface RenameCoverage { + name: string; + scope: 'indexed-repository'; + source_file_count: number; + languages: string[]; + limitations: string[]; +} + +export interface OccurrenceEdit { + file_path: string; + /** Offsets into the unchanged source, in UTF-16 code units. */ + start: number; + length: number; + old_text: string; + new_text: string; +} + +export interface RenamePlan { + symbol: RenameSymbol; + new_name: string; + coverage: RenameCoverage; + /** Includes all source files used by semantic resolution, even without edits. */ + snapshots: Map; + edits: OccurrenceEdit[]; +} + +export interface ReportedEdit extends Omit { + line: number; + confidence: 'semantic'; + before: string; + after: string; +} + +export interface FileChange { + file_path: string; + edits: ReportedEdit[]; +} + +export interface RenameResult { + result_version: 2; + status: 'success' | 'error' | 'partial'; + planning_status: 'ready' | 'blocked' | 'unsupported'; + application_status: 'not_requested' | 'not_started' | 'applied' | 'partial' | 'failed'; + applied: boolean; + old_name: string; + new_name: string; + files_affected: number; + total_edits: number; + semantic_edits: number; + graph_edits: 0; + text_search_edits: 0; + text_search: 'not_used'; + changes: FileChange[]; + coverage?: RenameCoverage; + error?: string; + code?: string; + candidates?: string[]; + /** + * Apply failures only: the single file whose validation or write failed. Files + * that already landed are reported in `changes`. + */ + failed_files?: string[]; + /** + * Apply failures only: later files in the plan that were never touched by this + * apply. Empty when the failing file was the last one. + */ + unattempted_files?: string[]; +} + +export class RenameFailure extends Error { + constructor( + public readonly code: string, + message: string, + public readonly planningStatus: 'blocked' | 'unsupported' = 'blocked', + public readonly candidates?: string[], + ) { + super(message); + } +} + +/** A source span uses JavaScript string offsets in the ORIGINAL source. */ +export interface RenameSpan { + readonly start: number; + readonly length: number; +} + +export interface RenameIssue { + readonly code: string; + readonly message: string; + readonly status?: 'unsupported' | 'error'; +} + +export interface RenameReplacement extends RenameSpan { + readonly oldText: string; + readonly newText: string; +} diff --git a/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts b/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts index c31605780..1106d8619 100644 --- a/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts +++ b/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts @@ -1497,7 +1497,7 @@ const EXPORTS_BY_NAME = new WeakMap< ReadonlyMap >(); -function indexExportsByName( +export function indexExportsByName( defs: readonly SymbolDefinition[], ): ReadonlyMap { const cached = EXPORTS_BY_NAME.get(defs); @@ -1536,7 +1536,7 @@ const TOP_LEVEL_EXPORTS_BY_NAME = new WeakMap< ReadonlyMap >(); -function indexTopLevelExportsByName( +export function indexTopLevelExportsByName( defs: readonly SymbolDefinition[], ): ReadonlyMap { const cached = TOP_LEVEL_EXPORTS_BY_NAME.get(defs); 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/registries/context.ts b/gitnexus-shared/src/scope-resolution/registries/context.ts index 3d74bd392..c884ef5c8 100644 --- a/gitnexus-shared/src/scope-resolution/registries/context.ts +++ b/gitnexus-shared/src/scope-resolution/registries/context.ts @@ -14,12 +14,13 @@ import type { NodeLabel } from '../../graph/types.js'; import type { ParameterTypeClass, SymbolDefinition } from '../symbol-definition.js'; -import type { Callsite, DefId } from '../types.js'; +import type { Callsite, DefId, NameLookupOptions, ScopeId } from '../types.js'; import type { DefIndex } from '../def-index.js'; import type { QualifiedNameIndex } from '../qualified-name-index.js'; import type { ModuleScopeIndex } from '../module-scope-index.js'; import type { ScopeTree } from '../scope-tree.js'; import type { MethodDispatchIndex } from '../method-dispatch-index.js'; +import type { NameClaimResult } from '../name-claims.js'; // ─── Provider hooks consumed by the registries ───────────────────────────── @@ -116,6 +117,15 @@ export type OwnedMembersByOwnerLookup = ( // ─── Top-level context threaded through every lookup ─────────────────────── export interface RegistryContext { + /** Finalized lexical bindings, when they live outside the immutable scope tree. */ + readonly lexicalLookup?: ( + scope: ScopeId, + name: string, + options: NameLookupOptions, + ) => NameClaimResult; + /** Opt-in observation of the lexical decision before kind filtering. This + * carries source-name ownership even when no graph target can be emitted. */ + readonly onLexicalClaim?: (name: string, claim: NameClaimResult) => void; readonly scopes: ScopeTree; readonly defs: DefIndex; readonly qualifiedNames: QualifiedNameIndex; diff --git a/gitnexus-shared/src/scope-resolution/registries/lookup-core.ts b/gitnexus-shared/src/scope-resolution/registries/lookup-core.ts index 699b5185f..97204fd90 100644 --- a/gitnexus-shared/src/scope-resolution/registries/lookup-core.ts +++ b/gitnexus-shared/src/scope-resolution/registries/lookup-core.ts @@ -70,7 +70,15 @@ import { lookupLexicalName } from '../name-claims.js'; import type { NodeLabel } from '../../graph/types.js'; import type { SymbolDefinition } from '../symbol-definition.js'; -import type { BindingRef, Callsite, DefId, LookupParams, Resolution, ScopeId } from '../types.js'; +import type { + BindingRef, + Callsite, + DefId, + LookupParams, + NameLookupOptions, + Resolution, + ScopeId, +} from '../types.js'; import type { OriginForTieBreak } from '../origin-priority.js'; import { composeEvidence, confidenceFromEvidence, type RawSignals } from './evidence.js'; import { compareByConfidenceWithTiebreaks, type TieBreakKey } from './tie-breaks.js'; @@ -248,7 +256,8 @@ function walkLexicalChain( return id !== null; } const options = { position: params.lookupPosition, purpose: params.lookupPurpose }; - const claim = lookupLexicalName(startScope, name, { scopes: ctx.scopes }, options); + const claim = lookupName(ctx, startScope, name, options); + ctx.onLexicalClaim?.(name, claim); if (claim.status !== 'absent') { let depth = 0; let owner = ctx.scopes.getScope(startScope); @@ -265,10 +274,7 @@ function walkLexicalChain( return true; } const rootName = name.split('.', 1)[0]!; - return ( - rootName !== name && - lookupLexicalName(startScope, rootName, { scopes: ctx.scopes }, options).status !== 'absent' - ); + return rootName !== name && lookupName(ctx, startScope, rootName, options).status !== 'absent'; } function recordLexicalHit( @@ -370,32 +376,34 @@ function resolveReceiverOwner( */ const IMPLICIT_RECEIVERS: readonly string[] = Object.freeze(['self', 'this', '$this']); +function lookupName( + ctx: RegistryContext, + scope: ScopeId, + name: string, + options: NameLookupOptions, +) { + return ( + ctx.lexicalLookup?.(scope, name, options) ?? + lookupLexicalName(scope, name, { scopes: ctx.scopes }, options) + ); +} + function lookupReceiverType( startScope: ScopeId, receiverName: string, ctx: RegistryContext, params: CoreLookupParams, ): DefId | undefined { - const claim = lookupLexicalName( - startScope, - receiverName, - { scopes: ctx.scopes }, - { - position: params.lookupPosition, - purpose: 'value', - }, - ); + const claim = lookupName(ctx, startScope, receiverName, { + position: params.lookupPosition, + purpose: 'value', + }); const typeRef = claim.typeBinding; if (typeRef === undefined) return undefined; - const typeClaim = lookupLexicalName( - typeRef.declaredAtScope, - typeRef.rawName, - { scopes: ctx.scopes }, - { - position: typeRef.lookupPosition, - purpose: typeRef.lookupPurpose ?? 'type', - }, - ); + const typeClaim = lookupName(ctx, typeRef.declaredAtScope, typeRef.rawName, { + position: typeRef.lookupPosition, + purpose: typeRef.lookupPurpose ?? 'type', + }); if (typeClaim.status !== 'absent') { const candidates = new Map( typeClaim.bindings diff --git a/gitnexus-shared/src/scope-resolution/symbol-definition.ts b/gitnexus-shared/src/scope-resolution/symbol-definition.ts index 8d59e475d..49bb0e459 100644 --- a/gitnexus-shared/src/scope-resolution/symbol-definition.ts +++ b/gitnexus-shared/src/scope-resolution/symbol-definition.ts @@ -6,11 +6,11 @@ * scope-resolution types that reference it can live in the shared package * alongside their consumers (`gitnexus/` and `gitnexus-web/`). * - * Shape is unchanged from the prior local definition. + * Providers add optional source and semantic metadata to this shared contract. */ 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/README.md b/gitnexus/README.md index 82ad8cb04..7c7bc60c1 100644 --- a/gitnexus/README.md +++ b/gitnexus/README.md @@ -212,7 +212,7 @@ Note that the bundled Graphology path is no longer the slow option it once was: ## MCP Tools -Your AI agent gets **19 tools** (17 per-repo + 2 group) automatically: +Your AI agent gets **19 tools** (including 2 group tools) automatically: | Tool | What It Does | | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -223,7 +223,7 @@ Your AI agent gets **19 tools** (17 per-repo + 2 group) automatically: | `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` | Multi-file coordinated rename with graph + text search | +| `rename` | Semantic rename: previews by default (`dry_run` omitted/true), applies with `dry_run: false`; unsupported or ambiguous occurrences block the rename | | `cypher` | Raw Cypher graph queries | | `route_map` | API route map — which components fetch which endpoints, and handlers | | `tool_map` | MCP/RPC tool definitions — where they're defined and handled | @@ -236,7 +236,7 @@ Your AI agent gets **19 tools** (17 per-repo + 2 group) automatically: | `group_list` | List configured repository groups | | `group_sync` | Rebuild a group's Contract Registry and cross-repo links | -> Read-only tools can omit `repo` when one repo is indexed, an MCP default is configured, or the GitNexus process cwd is inside a registered path without crossing into an unindexed nested Git checkout. Otherwise—and for mutating tools with multiple indexed repos and no MCP default—specify it explicitly: `query({search_query: "auth", repo: "my-app"})`. Per-repo tools also take an optional `branch` for indexes pinned with `gitnexus analyze --branch`, except `read_file` and `grep`, which read the checkout and do not accept `branch`. Omitting `branch` queries the workspace index, which follows your checked-out working tree. `explain` and `pdg_query` need an index built with `gitnexus analyze --pdg`. +> Read-only tools can omit `repo` when one repo is indexed, an MCP default is configured, or the GitNexus process cwd is inside a registered path without crossing into an unindexed nested Git checkout. Otherwise—and for mutating tools with multiple indexed repos and no MCP default—specify it explicitly: `query({search_query: "auth", repo: "my-app"})`. Per-repo tools also take an optional `branch` for indexes pinned with `gitnexus analyze --branch`, except `read_file`, `grep`, and `rename`, which use the current checkout and do not accept `branch`. A `rename` preview (`dry_run` omitted or `true`) may also omit `repo` under the same cwd rule; `rename` with `dry_run: false` always needs one repo indexed, an MCP default, or an explicit `repo`. Omitting `branch` queries the workspace index, which follows your checked-out working tree. `explain` and `pdg_query` need an index built with `gitnexus analyze --pdg`. ## MCP Resources diff --git a/gitnexus/bench/emit-persistence/baselines-streaming.json b/gitnexus/bench/emit-persistence/baselines-streaming.json index 837d78d1f..0480d376c 100644 --- a/gitnexus/bench/emit-persistence/baselines-streaming.json +++ b/gitnexus/bench/emit-persistence/baselines-streaming.json @@ -1,6 +1,7 @@ { - "fingerprint": "9408aad13feb6a65c6ce031a3264b72d1afdb1fdd1e984666fe30c3ecc24d66f", + "fingerprint": "cf92db8744f965add20d80c2ebfba5ecb04eb216c4e2932ec7902b519fe90836", "_rebaselined_3161_static_gated_column": "Same change as baselines.json's `_rebaselined_3161_static_gated_column`: every relationship row now ends in a `staticGated` cell (`0` here, no PDG edge is a gated call), so each of the 36,000 `rel_BasicBlock_BasicBlock.csv` data rows grew by 2 bytes (`...,\"T\",0` -> `...,\"T\",0,0`) while the 7,200 BasicBlock rows are unchanged. Evidence is the inverse operation: stripping the trailing `,0` from the rel rows and re-hashing the sorted bb + rel rows gives EXACTLY the prior baseline 381de8dede253140953775c290bf9a25f82ebf3cc8ecb5250ae06a63f648b089. byte_identical_nodes / byte_identical_edges stayed true and resident_basic_blocks stayed 0 while the fingerprint gate was red, so the streamed sink still matches the whole-graph emit byte for byte and the RSS bound holds. Prior 381de8dede253140953775c290bf9a25f82ebf3cc8ecb5250ae06a63f648b089 -> dadb6eb19a751c3d315b7c53effc6605f89e0a766b01cd5a2ae611b3720d0d55.", "_note": "Byte-identity + bounded-retention gate for streaming/chunked PDG emit (#2202). fingerprint = sha256 of the sorted, header-stripped BasicBlock + PDG-edge data rows of the canonical synthetic set. --check also asserts the streamed PdgEmitSink output is byte-identical to the whole-graph streamAllCSVsToDisk emit (byte_identical_nodes/edges) and that the in-memory graph retains 0 BasicBlocks (resident_basic_blocks === 0, the O(chunk) RSS bound). Regenerate via `node --import tsx bench/emit-persistence/measure-streaming.mjs`.", - "_rebaselined_3442_ladybug_null_cells": "Same NULL serialization change as baselines.json (#3442). Compared the whole-graph output with the parent writer: file set, row order and all other bytes match. The fingerprinted BasicBlock and PDG-edge files contain exactly 38,400 quoted empty cells changed to bare cells (14,400 BasicBlock cells and 24,000 edge cells); restoring their quotes reproduces the exact prior fingerprint dadb6eb19a751c3d315b7c53effc6605f89e0a766b01cd5a2ae611b3720d0d55. The new fingerprint is 9408aad13feb6a65c6ce031a3264b72d1afdb1fdd1e984666fe30c3ecc24d66f. Both streamed/whole byte-identity flags remain true and resident_basic_blocks remains 0." + "_rebaselined_3442_ladybug_null_cells": "Same NULL serialization change as baselines.json (#3442). Compared the whole-graph output with the parent writer: file set, row order and all other bytes match. The fingerprinted BasicBlock and PDG-edge files contain exactly 38,400 quoted empty cells changed to bare cells (14,400 BasicBlock cells and 24,000 edge cells); restoring their quotes reproduces the exact prior fingerprint dadb6eb19a751c3d315b7c53effc6605f89e0a766b01cd5a2ae611b3720d0d55. The new fingerprint is 9408aad13feb6a65c6ce031a3264b72d1afdb1fdd1e984666fe30c3ecc24d66f. Both streamed/whole byte-identity flags remain true and resident_basic_blocks remains 0.", + "_rebaselined_3486_explicit_owner": "The same trailing BasicBlock ownerId schema cell (#3486) changes each of the 7,200 fingerprinted BasicBlock rows by one empty trailing comma; all 36,000 PDG-edge rows are byte-identical. Actual origin/main and branch whole-graph emitters produce the same 36 CSV filenames; stripping exactly the ownerId header and empty BasicBlock cells restores every prior byte and fingerprint 9408aad13feb6a65c6ce031a3264b72d1afdb1fdd1e984666fe30c3ecc24d66f. New fingerprint cf92db8744f965add20d80c2ebfba5ecb04eb216c4e2932ec7902b519fe90836. Streamed and whole-graph node/edge identity remain true; resident_basic_blocks remains 0." } diff --git a/gitnexus/bench/emit-persistence/baselines.json b/gitnexus/bench/emit-persistence/baselines.json index 374ae2067..abe9a87f4 100644 --- a/gitnexus/bench/emit-persistence/baselines.json +++ b/gitnexus/bench/emit-persistence/baselines.json @@ -1,5 +1,5 @@ { - "fingerprint": "2f5c46506cb8dfb79a65a276646ace766cc7645fabab8b383c89f5f0270cc30b", + "fingerprint": "7410210a69b4ba0d5843270d9c80dd7bad6a8f02d62ec5f72dd257200ad8c168", "_rebaselined_objective_c_node_tables": "Objective-C support adds Protocol and Category node tables to csv-generator.ts's MULTI_LANG_TYPES, so this deterministic 2,400-entity synthetic emit now creates 38 CSV files rather than 36. The two additions, category.csv and protocol.csv, are both expected 55-byte header-only files because the synthetic graph contains no Objective-C nodes. Re-emitting the graph and recomputing the fingerprint after excluding exactly those two files yields the exact prior baseline 011485e5180c005be9dfec7ac8dc4e3bb0fcfc4a680a16dcae2cf2d8af5ef7e2, proving all 36 pre-existing files retain their bytes, routing, and within-file order. Prior 011485e5180c005be9dfec7ac8dc4e3bb0fcfc4a680a16dcae2cf2d8af5ef7e2 -> d3c3a53ead47663816344369dc0e501f31d60c462039de190ab6f5361037bd79. The timing guard remains within budget: local scaling_ratio 0.965 against 1.8 and elapsed_ms_large 69.24ms against 1000ms.", "_rebaselined_3161_static_gated_column": "Every relationship row gained a trailing `staticGated` BOOLEAN column (RELATION_SCHEMA in src/core/lbug/schema.ts, REL_CSV_HEADER + buildRelRow in csv-generator.ts, the fallback CREATE in lbug-adapter.ts), written as `0` for every edge that does not carry the flag and `1` for a Zig call site inside a comptime-false branch (PR #3161). Unlike the earlier header-only rebaselines this one touches ROWS, so the evidence is the inverse operation rather than a file-set diff: re-emitting this bench's 2,400-entity graph on the branch produces the same 36 CSV files; exactly 3 of them differ from a copy with the new column stripped (`rel_File_Class.csv` 156228 -> 151416 bytes, `rel_File_Function.csv` 334008 -> 324396, `rel_Function_Function.csv` 185028 -> 180216: one header field plus `,0` per row, 4,800 / 9,600 / 4,800 rows, 2 bytes each), the other 33 files are byte-identical, and the per-file fingerprint over the stripped copies is EXACTLY the prior baseline 72096279092d4f118de7e179333705c19c9aff2664f77d71a7da48cd9f73fb5a. So no row moved between pair files and no row reordered; the only bytes that changed are the appended column. Prior 72096279092d4f118de7e179333705c19c9aff2664f77d71a7da48cd9f73fb5a -> 011485e5180c005be9dfec7ac8dc4e3bb0fcfc4a680a16dcae2cf2d8af5ef7e2. Timing gates passed while the guard was red: scaling_ratio 0.82 against the 1.8 budget, elapsed_ms_large 185.54ms against the 1000ms backstop, so no throughput claim is being rebaselined away.", "scaling_budget": 1.8, @@ -10,5 +10,6 @@ "_rebaselined_3040_convex_endpoint_factory": "Const and Function gained a trailing convexEndpointFactory column. A deterministic 2,400-entity emit produced the same 35 CSV files and fingerprint c4d799c5336d616955b3530ba051b7dca300d1a0e412a66741cf2f27e04c533e. Removing the new Const and Function header fields plus the new trailing empty Function cell from each of 4,800 Function rows restored the exact prior fingerprint 4ee15e742a9839671a900df4f57c1c91196c64256c8cab2ac445bec605a092d5. No file or row moved or reordered. The measured scaling ratio remained 0.826 against the 1.8 budget and elapsed_ms_large was 307.75ms against the 1000ms backstop.", "_rebaselined_3107_route_runtime_evidence": "Route gained trailing runtimeConfirmed BOOLEAN, runtimeSource STRING, and runtimeStatus STRING columns in its schema, CSV header/rows, COPY statement, and graph API projection. The deterministic emit still produces the same 35 CSV files; the synthetic benchmark graph has no Route rows, so the only byte drift is the Route CSV header and no row moved or reordered. Prior c4d799c5336d616955b3530ba051b7dca300d1a0e412a66741cf2f27e04c533e -> 7b2ec01a110dcbc66868fba2c97714aaece8c3864c2eba00df68b5c027f034d2. While the guard was red, scaling_ratio was 1.044 against the 1.8 budget and elapsed_ms_large was 121.08ms against the 1000ms backstop.", "_note": "fingerprint = sha256 over per-file digests (filename + sha256(file bytes)), entry list sorted \u2014 binds each emitted line to its file so a row routed to the WRONG pair file changes the hash, AND catches within-file row reordering (file bytes hashed as-written). Byte-identity gate for #2203 U2/U3. NOTE: a future change that legitimately reorders emit (without changing the node/edge SET) will trip --check; regenerate then, and record WHY in a `_rebaselined_` key alongside \u2014 bench/scope-capture/baselines.json sets that convention and it is what makes a regenerated hash reviewable. scaling_budget bounds (t_large/t_small)/(LARGE/SMALL): observed ~0.95-1.05 (linear); 1.8 tolerates disk-I/O timing noise on CI while still catching an O(n^2) re-regression (~4x). max_ms_large=1000ms is a coarse absolute backstop (observed ~200ms) that catches a gross uniform slowdown the ratio gate misses; generous so CI host noise won't flake it. Regenerate via `node --import tsx bench/emit-persistence/measure.mjs`.", - "_rebaselined_3442_ladybug_null_cells": "LadybugDB 0.21.1 preserves quoted empty strings, so escapeCSVField now emits bare empty cells to retain the SQL NULL meaning of the 0.18.3 writer (#3442). Compared the canonical emit with the parent writer: all 38 file names and every row order match; exactly 31,200 quoted empty cells became bare cells in six files, with no other byte changes. Restoring quotes around those cells reproduces the exact prior fingerprint d3c3a53ead47663816344369dc0e501f31d60c462039de190ab6f5361037bd79. The new fingerprint is 2f5c46506cb8dfb79a65a276646ace766cc7645fabab8b383c89f5f0270cc30b. Timing budgets remain unchanged." + "_rebaselined_3442_ladybug_null_cells": "LadybugDB 0.21.1 preserves quoted empty strings, so escapeCSVField now emits bare empty cells to retain the SQL NULL meaning of the 0.18.3 writer (#3442). Compared the canonical emit with the parent writer: all 38 file names and every row order match; exactly 31,200 quoted empty cells became bare cells in six files, with no other byte changes. Restoring quotes around those cells reproduces the exact prior fingerprint d3c3a53ead47663816344369dc0e501f31d60c462039de190ab6f5361037bd79. The new fingerprint is 2f5c46506cb8dfb79a65a276646ace766cc7645fabab8b383c89f5f0270cc30b. Timing budgets remain unchanged.", + "_rebaselined_3486_explicit_owner": "The BasicBlock CSV schema now includes trailing ownerId (#3486). Compared actual origin/main (191a954b) and branch emitters on the same 2,400-entity graph: all 38 CSV filenames match, 37 files are byte-identical, and only header-only basicblock.csv changes from 53 to 61 bytes. Removing exactly the trailing ,ownerId header restores every prior byte and the prior fingerprint 2f5c46506cb8dfb79a65a276646ace766cc7645fabab8b383c89f5f0270cc30b. No rows moved or reordered. New fingerprint 7410210a69b4ba0d5843270d9c80dd7bad6a8f02d62ec5f72dd257200ad8c168; scaling ratio 0.937 and large emit 451.47ms pass unchanged 1.8/1000ms limits." } diff --git a/gitnexus/bench/import-target/baselines.json b/gitnexus/bench/import-target/baselines.json index 85f84fa21..b032f8f42 100644 --- a/gitnexus/bench/import-target/baselines.json +++ b/gitnexus/bench/import-target/baselines.json @@ -240,6 +240,11 @@ "depth_ratio": 1.279, "scaling_ratio": 1.043, "small_ms": 2.45 + }, + "context": { + "target": "App.Service", + "with_context": "src/odd/Bundle.cs", + "without_context": "" } }, "csharp_csproj": { @@ -291,6 +296,11 @@ "depth_ratio": 1.394, "scaling_ratio": 1.182, "small_ms": 23.634 + }, + "context": { + "target": "App.Service", + "with_context": "src/odd/Bundle.cs", + "without_context": "" } }, "dart": { @@ -1156,5 +1166,7 @@ }, "_blind_spot": "MEASURED, so nobody has to rediscover it: a full workspace scan reintroduced on 1-in-32 imports passes EVERY arm here \u2014 dart scored 1.458 scaling and 1.736 ms against the 1.8 budget and 4 ms ceiling of an earlier revision. At 1-in-8 the scaling arm catches it (2.414). The gate that NARROWS this is not a timing gate at all: test/unit/scope-resolution/import-target-index-parity.test.ts counts iterations of the file-set Set and reads 14 instead of 1 for that same 1-in-32 mutation, deterministically and for all five languages. It does NOT close it. The counter watches the Set, and the resolvers no longer read the Set \u2014 they read materialized copies of the same file list: WorkspaceFileIndex.normalized and .all (C#, Ruby), Dart's byBasename buckets, and PackageDirIndex.filesByDir (Go, C#). A 1-in-32 scan over any of those three touches the Set zero extra times, so it passes the parity test AND passes --check. Closing it would take an iteration counter on the materialized arrays themselves. Read the two gates together; tightening these ceilings toward the noise floor to chase that case would only buy flaky CI. CONFIRMED THE HARD WAY by PR #2911: JavaScript resolution was scanning ImportPassCache.normalizedFileList on every import \u2014 a materialized array, not the Set \u2014 at 25972 us per import at 8000 files, and no instrument on the #2901-#2909 branch could see it. It took a differential parity test over 211200 old-vs-new pairs to find. The arms added here would have caught THAT one on absolute ms (85 ms budget against a 20 ms arm; the unindexed resolver costs ~83000 ms on the same corpus), which is the argument for gating every registered language rather than only the ones a PR happens to touch. THE SECOND BLIND SPOT IS CLOSED, and this records what closing it changed. This harness used to call the inner resolvers with the NO-CONTEXT shape: run.ts calls provider.resolveImportTarget with five arguments, the fifth being { parsedFiles, parsedImport }, and resolveOne supplied three. resolveOne now makes the production call, newPass mints the ParsedFile[] FIRST and derives the path set from it exactly as run.ts does, and both legs behind the argument run on every import of their arms \u2014 PHP's named/alias function-or-const leg over filesByDirectory(context.parsedFiles), whose memo defeated measures 197.0 us -> 9976.2 us per import (50.6x), and Python's from-import submodule-precedence branch, the only spelling that reads context.parsedFiles at all. Fifteen of the seventeen arms cannot observe a context (their hooks declare three or four parameters) and are handed none, so their numbers did not move; which two CAN is now reconciled against SCOPE_RESOLVERS' hook arity rather than asserted in prose. NOTHING ELSE IN THIS FILE COULD HAVE GATED IT, which is why the context arm exists: fingerprints and shape can remain unchanged while dropping context only makes timing faster. The deterministic context arm is therefore the guard for this wiring. The arm is one import per language resolved through resolveOne twice, with and without the pass's parsedFiles, whose two answers must DIFFER and must both match what is recorded. WHAT REMAINS UNMEASURED, narrowed rather than deleted: Python's parsedFileByPath memo is exercised by the five timing arms and cannot be reached by the heap arm at all, because retainedPassBytes requires a probe that MISSES while every path that builds that memo returns a non-null packageTarget \u2014 so no ceiling bounds that Map (one pointer per parsed file, O(files), no depth term) and the contract test's count gate is what holds it to one build per pass. PHP's sole arm carries a representative Composer PSR-4 map, so mapped hits and authoritative misses exercise that production branch directly. And the const tail of PHP's leg is a different ANSWER at the same cost \u2014 it runs the identical candidate gather and localDefs filter and diverges in the last two lines \u2014 so it is gated by count in test/unit/scope-resolution/import-target-index-reuse.contract.test.ts, which stays the gate to read alongside this file.", "_depth_budget_note_2953": "javascript/typescript/vue moved from 2.0-2.1 to ~2.2 in #2953 and their budgets were raised to 2.6, which is a real shift with an understood cause rather than a loosened guard. Declared resolution never walks path components, so the deep arm's uniform d0/../d15/ prefix reaches these resolvers as the tsconfig baseUrl (see tsBaseUrlFor in measure.mjs) and every candidate string carries it: resolveFile probes ~11 extensions plus their /index forms, and hashing a 60-character path costs more than hashing a 12-character one. The growth is linear in path LENGTH and independent of file COUNT, which is what the ratio exists to bound - a resolver that started walking the corpus again would move scaling_ratio, not just this. Measured over three runs on a loaded box: js 2.109/2.257/2.240, ts 2.129/2.222/2.467, vue 2.116/2.151/2.102.", - "_heap_bound_note_2953": "javascript, typescript and vue moved from heap_reading_bytes/heap_ceiling_bytes to heap_bound_bytes in #2953. They retained 26745296 B (js, ts) and 28884016 B (vue) at 32000 files for a per-pass SuffixIndex over the whole file list; they now build no per-pass structure at all and read 0-16 B, because declared resolution derives nothing from the file set. That is a real saving rather than an arm that stopped measuring - the distinction this floor exists to make - and the evidence it is real is that the resolver fingerprints did NOT move: the same corpus resolves to the same targets, once the config it always implied is passed explicitly. The 1048576 B bound is rust's, chosen the same way: far above a 16 B reading, far below the index whose return it must catch." + "_heap_bound_note_2953": "javascript, typescript and vue moved from heap_reading_bytes/heap_ceiling_bytes to heap_bound_bytes in #2953. They retained 26745296 B (js, ts) and 28884016 B (vue) at 32000 files for a per-pass SuffixIndex over the whole file list; they now build no per-pass structure at all and read 0-16 B, because declared resolution derives nothing from the file set. That is a real saving rather than an arm that stopped measuring - the distinction this floor exists to make - and the evidence it is real is that the resolver fingerprints did NOT move: the same corpus resolves to the same targets, once the config it always implied is passed explicitly. The 1048576 B bound is rust's, chosen the same way: far above a 16 B reading, far below the index whose return it must catch.", + "_context_3486_csharp_declaration_identity": "The registered C# hook gained a fifth context parameter in #3486. Both C# timing arms now call that hook with pass-stable parsedFiles and their production configuration; the context probes import App.Service declared in src/odd/Bundle.cs. With context the exact declaration resolves; without context the filename fallback returns null. All ten prior per-scale fingerprints, resolved counts and outcome counts are unchanged. Measured retained heap at 32,000 files is 31,871,096 bytes for csharp and 75,123,072 for csharp_csproj, below unchanged 44,900,000/110,600,000 ceilings, with normalized growth 0.997/0.987 below 1.25. No fingerprint or performance budget was relaxed.", + "_timing_3486_receipt": "The final exclusive-machine checks passed all fingerprints, resolved/outcome counts, context probes, hook inventory and heap gates. Timing-only failures were Go depth 1.497 > unchanged 1.4 on the first run, then Go depth 1.401 > 1.4 and COBOL scaling 1.84 > unchanged 1.8 on one bounded rerun. The Go resolver, COBOL copy-target resolver and their timing-harness branches are unchanged from origin/main; these timing checks are not reported as passing and no budget was raised." } diff --git a/gitnexus/bench/import-target/measure.mjs b/gitnexus/bench/import-target/measure.mjs index 60a73ffee..75d56ce95 100644 --- a/gitnexus/bench/import-target/measure.mjs +++ b/gitnexus/bench/import-target/measure.mjs @@ -479,7 +479,6 @@ import { SupportedLanguages } from 'gitnexus-shared'; import { resolveGoImportTarget } from '../../src/core/ingestion/languages/go/import-target.ts'; import { resolveDartImportTarget } from '../../src/core/ingestion/languages/dart/import-target.ts'; import { resolveRubyImportTarget } from '../../src/core/ingestion/languages/ruby/import-target.ts'; -import { resolveCsharpImportTarget } from '../../src/core/ingestion/languages/csharp/import-target.ts'; import { kotlinScopeResolver } from '../../src/core/ingestion/languages/kotlin/scope-resolver.ts'; import { resolvePhpImportTargetInternal } from '../../src/core/ingestion/languages/php/import-target.ts'; import { javaScopeResolver } from '../../src/core/ingestion/languages/java/scope-resolver.ts'; @@ -495,6 +494,7 @@ import { typescriptScopeResolver } from '../../src/core/ingestion/languages/type import { cScopeResolver } from '../../src/core/ingestion/languages/c/scope-resolver.ts'; import { cppScopeResolver } from '../../src/core/ingestion/languages/cpp/scope-resolver.ts'; import { objectiveCScopeResolver } from '../../src/core/ingestion/languages/objective-c/scope-resolver.ts'; +import { csharpScopeResolver } from '../../src/core/ingestion/languages/csharp/scope-resolver.ts'; // `SCOPE_RESOLVERS` is NOT imported here — see the inventory arm at the bottom, // which loads it dynamically. Statically it costs 6-10 s of module load // depending on the box (measured both ways there), because reaching the @@ -634,7 +634,18 @@ const HEAP_BUDGETED = [ * parameters cannot observe a context and are not listed, so their timed * numbers stay on the three-argument shape. */ -const CONTEXT_LANGS = ['php', 'java', 'kotlin', 'python', 'swift', 'rust', 'c', 'cpp']; +const CONTEXT_LANGS = [ + 'php', + 'java', + 'kotlin', + 'python', + 'swift', + 'rust', + 'c', + 'cpp', + 'csharp', + 'csharp_csproj', +]; /** * Needs `node --expose-gc` to force collection for a clean delta; without it @@ -1186,7 +1197,7 @@ function kotlinBenchmarkPackage(filePath) { } /** - * The `ParsedFile[]` the orchestrator threads beside the path set, for the three + * The `ParsedFile[]` the orchestrator threads beside the path set, for the * languages whose hook declares a `context` — see `CONTEXT_LANGS`. * * Two defs per file, and both are real shapes rather than padding. PHP keeps @@ -1780,7 +1791,12 @@ function newPass(lang, files, pad = 0) { restoreBenchmarkSideChannels(lang, parsedFiles); return { allFilePaths: new Set(parsedFiles.map((f) => f.filePath)), - config: lang === 'php' ? phpComposerConfigFor(pad) : undefined, + config: + lang === 'php' + ? phpComposerConfigFor(pad) + : lang === 'csharp_csproj' + ? { csharpConfigs: CSPROJ_CONFIGS } + : undefined, parsedFiles, }; } @@ -1967,15 +1983,12 @@ function resolveOne(lang, from, target, pass) { return objectiveCScopeResolver.resolveImportTarget(target, from, allFilePaths, pass.config); } if (lang === 'csharp' || lang === 'csharp_csproj') { - return resolveCsharpImportTarget( - { kind: 'namespace', localName: '_', importedName: '_', targetRaw: target }, - { - fromFile: from, - allFilePaths, - // The ONLY difference between the two C# arms. Present, the adapter - // takes the csproj branch and never falls through to the no-csproj legs. - ...(lang === 'csharp_csproj' ? { csharpConfigs: CSPROJ_CONFIGS } : {}), - }, + return csharpScopeResolver.resolveImportTarget( + target, + from, + allFilePaths, + pass.config, + contextFor(pass, { kind: 'namespace', localName: '_', importedName: '_', targetRaw: target }), ); } throw unwiredLanguage('resolveOne', lang); @@ -2310,6 +2323,24 @@ function measureHeap(lang) { * argument at all. */ const CONTEXT_PROBE = { + // Declaration identity wins even when no filename matches the using target. + // Both C# arms pass through the registered five-argument provider hook. + csharp: { + from: 'app/Main.cs', + target: 'App.Service', + parsedFiles: [ + probeFile('app/Main.cs', []), + probeFile('src/odd/Bundle.cs', [['Class', 'App.Service']]), + ], + }, + csharp_csproj: { + from: 'app/Main.cs', + target: 'App.Service', + parsedFiles: [ + probeFile('app/Main.cs', []), + probeFile('src/odd/Bundle.cs', [['Class', 'App.Service']]), + ], + }, // A declared sibling module outranks a crate-root file with the same name. // Empty module tables on the scaling corpora keep their existing target set; // this probe witnesses the lexical branch that reads the workspace index. @@ -2455,7 +2486,12 @@ const CONTEXT_PROBE = { function measureContext(lang) { const { from, target, parsedFiles } = CONTEXT_PROBE[lang]; const allFilePaths = new Set(parsedFiles.map((f) => f.filePath)); - const config = lang === 'php' ? phpComposerConfigFor(0) : undefined; + const config = + lang === 'php' + ? phpComposerConfigFor(0) + : lang === 'csharp_csproj' + ? { csharpConfigs: CSPROJ_CONFIGS } + : undefined; const answer = (files) => { restoreBenchmarkSideChannels(lang, files ?? []); const pass = { allFilePaths, config, parsedFiles: files }; diff --git a/gitnexus/bench/mcp-tools-list/baselines.json b/gitnexus/bench/mcp-tools-list/baselines.json index 2f5b405a4..d573c3265 100644 --- a/gitnexus/bench/mcp-tools-list/baselines.json +++ b/gitnexus/bench/mcp-tools-list/baselines.json @@ -9,7 +9,7 @@ "_shape_note": "THE FLOOR. Without these three, every ratio below is a ceiling over nothing. listTools_vs_listRepos_ratio only asserts something while the corpus still pays N parallel rev-list processes. Shrink it to three happy-path rows and both arms are cheap; the ratio still passes, asserting a property the corpus no longer has.", "tools_listed": 19, - "_tools_note": "Exact GITNEXUS_TOOLS roster size returned by client.listTools(). 19 after read_file and grep were added as the MCP twins of GET /api/file and GET /api/grep (was 17). GITNEXUS_TOOLS.length and listTools must move together; a schema path that throws, filters, or returns [] still looks fast on the ratio arm.", + "_tools_note": "Exact GITNEXUS_TOOLS roster size returned by client.listTools(). 19 after read_file and grep (the MCP twins of GET /api/file and GET /api/grep); a separate rename_preview tool was briefly the 20th and was folded back into rename + dry_run, which read-only mode lists as preview-only. GITNEXUS_TOOLS.length and listTools must move together; a schema path that throws, filters, or returns [] still looks fast on the ratio arm.", "schema_read_only_requires_repo": true, "schema_mutating_requires_repo": true, diff --git a/gitnexus/bench/receiver-resolution/BASELINE.md b/gitnexus/bench/receiver-resolution/BASELINE.md index badb4c316..4753cdda1 100644 --- a/gitnexus/bench/receiver-resolution/BASELINE.md +++ b/gitnexus/bench/receiver-resolution/BASELINE.md @@ -1,5 +1,30 @@ # Receiver-resolution baseline +## Semantic rename provider captures (#3486) + +A same-corpus A/B against `origin/main` (`191a954b`) reproduced the committed +129 call / 167 all-kind drops. The final provider source and rebuilt workers +measure 127 call / 189 all-kind drops, including 43 reads and 19 writes. The +original runner and a separate one-worker diagnostic agree exactly on every +gated count; all shape-arm states are unchanged. + +The two removed call drops are `user.address.save` in +`ruby-field-type-disambig/service.rb:7` and `ruby-field-types/service.rb:6`. +Preserving the `./` distinction for bare `require_relative` paths resolves the +fixture imports. Both sites now emit `CALLS` to their own fixture's +`Address.save`, with no edge to the same-named `User.save`. Ruby call drops fall +from 2 to 0, `chain-call` from 32 to 30, and `in-program` from 56 to 54; every +other call bucket is unchanged. + +The 24 added drops are newly captured Zig field reads, rather than lost call +edges. The Zig `field_expression` query now records complete receivers for +reads outside call/argument positions; its callee-position marker lets resolved +method reads avoid phantom edges. Every added suppressed site has exactly one +new `@reference.read` capture at the same written range and no corresponding +capture on `origin/main`. Existing suppressed read sites retain their ranges +and acquire receiver-chain metadata. These newly visible unresolved reads stay +in the exact all-kind gate; no coverage or threshold is relaxed. + ## Python mixin dispatch (#3390) The added mixin fixture deliberately calls an absent `missing_target` on a known diff --git a/gitnexus/bench/receiver-resolution/baseline.json b/gitnexus/bench/receiver-resolution/baseline.json index 8214c02ec..b3708d8f3 100644 --- a/gitnexus/bench/receiver-resolution/baseline.json +++ b/gitnexus/bench/receiver-resolution/baseline.json @@ -199,11 +199,11 @@ } }, "countArm": { - "callDrops": 129, - "totalDropsAllKinds": 167, + "callDrops": 127, + "totalDropsAllKinds": 189, "bySiteKind": { - "call": 129, - "read": 19, + "call": 127, + "read": 43, "write": 19 }, "callDropsByExtension": { @@ -217,20 +217,19 @@ ".php": 4, ".kt": 4, ".rs": 3, - ".rb": 2, ".js": 1, ".swift": 1 }, "callDropsByShape": { "chain-field": 56, - "chain-call": 32, + "chain-call": 30, "no-chain": 27, "<>": 11, "chain-mixed": 2, "chain-unwrap": 1 }, "callDropsByOrigin": { - "in-program": 56, + "in-program": 54, "external": 44, "unknown": 29 } diff --git a/gitnexus/bench/rename/README.md b/gitnexus/bench/rename/README.md new file mode 100644 index 000000000..bf18b96e9 --- /dev/null +++ b/gitnexus/bench/rename/README.md @@ -0,0 +1,47 @@ +# Semantic rename benchmarks + +Run from `gitnexus/`: + +```sh +node --import tsx bench/rename/measure.mjs +node --import tsx bench/rename/measure.mjs --check +``` + +The harness executes prepared semantic plans against real temporary files. It +checks exact output, edit counts, line numbers, UTF-16 offsets and CRLF reports; +previews must preserve the source. Native parsing and graph resolution are +covered by the per-language persisted integration suites, rather than included +in the construction timings below. + +A second workload runs the whole semantic planner (`planGraphRename`: parsing, +scope resolution, provider receiver proofs, rewrite validation and before/after +binding verification) over one generated TypeScript module. It renames a method +called 2,000 and then 8,000 times from receivers constructed in 20-call +functions, and checks that the produced edits rewrite exactly the declaration and +every call. The normalized planning ratio must stay below 2. This catches +per-occurrence rescans of a file's occurrences, syntax tree or edit list, which +make planning quadratic in file size while leaving small fixtures untouched. + +The preview workload grows from 1,000 to 4,000 edits. The normalized scaling +ratio must stay below 2, and the large preview must remain below a generous +one-second ceiling. These gates catch a return to repeated whole-string copying +or repeated prefix line counting. The 100-source, eight-edited-file apply case +reports median time without a filesystem timing gate. + +A local Node 22.18.0 comparison on PR #3557 measured: + +| Workload | Before | After | +| --- | ---: | ---: | +| 1,000-edit preview | 175 ms | 4.3 ms | +| 4,000-edit preview | 2,823 ms | 13.8 ms | +| Normalized preview scaling | 4.03 | 0.80 | +| Apply: 100 sources, eight edited files | 278 ms | 101 ms | + +These are local prepared-plan measurements, not end-to-end indexing timings. + +Application still reads every source snapshot before each atomic replacement. +The CLI reuses UTF-8 snapshot buffers and batches at most eight concurrent reads. +This keeps the existing source-change detection and partial-write semantics; +I/O remains proportional to source count times edited-file count. Cached buffers +add memory proportional to the source snapshot bytes and are released with the +operation. The shared package contains no filesystem or native-parser adapter. diff --git a/gitnexus/bench/rename/measure.mjs b/gitnexus/bench/rename/measure.mjs new file mode 100644 index 000000000..4db01ac46 --- /dev/null +++ b/gitnexus/bench/rename/measure.mjs @@ -0,0 +1,179 @@ +/** Rename benchmark: real files, UTF-16 offsets, and full snapshot validation. + * Run: node --import tsx bench/rename/measure.mjs [--check] + * Measures output/report construction and applying a multi-file plan independently + * of native parsing, then full semantic planning (`planGraphRename`: parse, + * resolve, provider proofs, rewrite validation and binding verification) over a + * generated single-file TypeScript source. The provider integration suites cover + * semantic correctness. + */ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { performance } from 'node:perf_hooks'; +import { executeRenamePlan } from '../../src/mcp/local/rename/rename-plan.ts'; +import { planGraphRename } from '../../src/mcp/local/rename/graph-rename.ts'; + +const root = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-rename-bench-')); +const median = (values) => [...values].sort((a, b) => a - b)[Math.floor(values.length / 2)]; +async function fixture(count, sourceCount = 1, editedCount = 1) { + const text = '// 🐈\r\n' + `writer.close(); // ${'x'.repeat(160)}\r\n`.repeat(count); + const snapshots = new Map(); + const edits = []; + for (let file = 0; file < sourceCount; file++) { + const name = `${file}.ts`; + snapshots.set(path.join(root, name), text); + await fs.writeFile(path.join(root, name), text); + if (file >= editedCount) continue; + let start = text.indexOf('close'); + while (start >= 0) { + edits.push({ file_path: name, start, length: 5, old_text: 'close', new_text: 'complete' }); + start = text.indexOf('close', start + 5); + } + } + return { + symbol: { name: 'close', filePath: '0.ts', startLine: 2 }, + new_name: 'complete', + coverage: { + name: 'benchmark', + scope: 'indexed-repository', + source_file_count: sourceCount, + languages: ['typescript'], + limitations: [], + }, + snapshots, + edits, + }; +} +async function preview(count) { + const plan = await fixture(count); + const times = []; + for (let repeat = 0; repeat < 7; repeat++) { + const started = performance.now(); + const result = await executeRenamePlan(root, plan); + times.push(performance.now() - started); + assert.equal(result.status, 'success'); + assert.equal(result.total_edits, count); + assert.equal(result.changes[0].edits.at(-1).line, count + 1); + assert.equal(result.changes[0].edits[0].before, `writer.close(); // ${'x'.repeat(160)}\r`); + assert.equal(result.changes[0].edits[0].after, `writer.complete(); // ${'x'.repeat(160)}\r`); + } + assert.equal( + await fs.readFile(path.join(root, '0.ts'), 'utf8'), + plan.snapshots.get(path.join(root, '0.ts')), + ); + return median(times.slice(1)); +} + +/** One module of `calls` member calls spread over 20-call functions that each + * construct their own receiver, so every call needs a constructor proof and + * the receiver spelling is shared by many declarations. */ +function writerSource(calls) { + const lines = ['export class Writer {', ' close(): number { return 1; }', '}']; + for (let first = 0; first < calls; first += 20) { + lines.push(`export function run${first / 20}(): number {`, ' const writer = new Writer();'); + for (let call = first; call < Math.min(calls, first + 20); call++) + lines.push(' writer.close();'); + lines.push(' return 0;', '}'); + } + return lines.join('\n') + '\n'; +} +async function semanticPlanning(calls) { + const file = 'model.ts'; + const repository = await fs.mkdtemp(path.join(root, 'semantic-')); + const text = writerSource(calls); + await fs.writeFile(path.join(repository, file), text); + const uid = `Method:${file}:Writer.close#0`; + const nodes = [ + { id: `File:${file}`, label: 'File', properties: { name: file, filePath: file } }, + { + id: `Class:${file}:Writer`, + label: 'Class', + properties: { name: 'Writer', filePath: file, startLine: 0, endLine: 2 }, + }, + { + id: uid, + label: 'Method', + properties: { name: 'close', filePath: file, startLine: 1, endLine: 1 }, + }, + ]; + const started = performance.now(); + const plan = await planGraphRename( + repository, + { uid, name: 'close', kind: 'Method', filePath: file, startLine: 2 }, + { new_name: 'finish' }, + nodes, + ); + const elapsed = performance.now() - started; + // The declaration and every call are renamed, nothing else is touched. + assert.equal(plan.edits.length, calls + 1); + let cursor = 0; + let rebuilt = ''; + for (const edit of plan.edits) { + assert.equal(edit.old_text, 'close'); + assert.equal(text.slice(edit.start, edit.start + edit.length), 'close'); + rebuilt += text.slice(cursor, edit.start) + edit.new_text; + cursor = edit.start + edit.length; + } + assert.equal(rebuilt + text.slice(cursor), text.replaceAll('close', 'finish')); + return elapsed; +} +const planning_bound = 2; +async function planningScaling() { + const small_calls = 2000; + const large_calls = 4 * small_calls; + await semanticPlanning(200); // load grammars and warm the planner before timing + const smalls = []; + for (let repeat = 0; repeat < 3; repeat++) smalls.push(await semanticPlanning(small_calls)); + const small_ms = median(smalls); + // Stop at the first large run inside the bound: a regression is quadratic + // and slow, so only a suspect run is repeated. + let large_ms = Infinity; + for (let repeat = 0; repeat < 2 && large_ms / small_ms / 4 >= planning_bound; repeat++) + large_ms = Math.min(large_ms, await semanticPlanning(large_calls)); + return { small_calls, large_calls, small_ms, large_ms, scaling_ratio: large_ms / small_ms / 4 }; +} +try { + const small_ms = await preview(1000); + const large_ms = await preview(4000); + const scaling_ratio = large_ms / small_ms / 4; + const times = []; + for (let repeat = 0; repeat < 3; repeat++) { + const plan = await fixture(300, 100, 8); + const started = performance.now(); + const result = await executeRenamePlan(root, plan, false); + times.push(performance.now() - started); + assert.equal(result.application_status, 'applied'); + assert.equal(result.total_edits, 2400); + for (const [absolute, original] of plan.snapshots) { + const expected = + Number(path.basename(absolute, '.ts')) < 8 + ? original.replaceAll('close', 'complete') + : original; + assert.equal(await fs.readFile(absolute, 'utf8'), expected); + } + } + const planning = await planningScaling(); + const measurement = { + small_ms, + large_ms, + scaling_ratio, + apply_100_sources_8_edits_ms: median(times), + planning_small_calls: planning.small_calls, + planning_large_calls: planning.large_calls, + planning_small_ms: planning.small_ms, + planning_large_ms: planning.large_ms, + planning_scaling_ratio: planning.scaling_ratio, + }; + console.log(JSON.stringify(measurement)); + if (process.argv.includes('--check')) { + assert.ok(scaling_ratio < 2, `Rename construction became superlinear: ${scaling_ratio}`); + assert.ok(large_ms < 1000, `4000-edit preview exceeded the 1s budget: ${large_ms}ms`); + assert.ok( + planning.scaling_ratio < planning_bound, + `Semantic rename planning became superlinear: ${planning.scaling_ratio}`, + ); + } +} finally { + await fs.rm(root, { recursive: true, force: true }); +} diff --git a/gitnexus/bench/scope-capture/baselines.json b/gitnexus/bench/scope-capture/baselines.json index 7a5c93921..15044f87a 100644 --- a/gitnexus/bench/scope-capture/baselines.json +++ b/gitnexus/bench/scope-capture/baselines.json @@ -1,7 +1,7 @@ { "_comment": "Per-language baselines for bench/scope-capture/measure.mjs --check. fingerprint = order-independent sha256 over the lang-resolution/-* fixture corpus + a 20-entity synthetic source (correctness gate; re-baseline intentionally on a legitimate capture change). scaling_budget = max allowed (t800/t250)/(800/250); ~1.0 is linear, ~3.2 is quadratic. The synthetic source is now HERITAGE-BEARING for every language (each Entity extends/implements/embeds/uses-trait/conforms-to a shared base) so the #1951 @reference.inherits synth is gated at scale, not just the base capture loop. All languages thread the tree-sitter captured node instead of re-deriving it with findNodeAtRange(tree.rootNode,...) per match, so all are linear (go #1915, python #1918, ruby/php/rust/csharp #1951, java #1956).", "go": { - "fingerprint": "fdc3a691e2ebbc5b0d6fe1da328864293587a75e296e106c705c7e31ee622a5b", + "fingerprint": "2cbf91984a03666ca5966fbdf0bd681f91dcda6e4c7bd9fa21e9f2daf524f9dc", "_rebaselined_3417_versioned_import_name": "#3417: splitGoImportStatement now uses the conventional package qualifier before a /vN module suffix. Compared the pre-fix 2539b08 and HEAD emitters over the SAME HEAD corpus: exactly two @import.name texts changed from v2 to bar for github.com/foo/bar/v2, in go-extern-qualified-signatures/memory/memory.go and store/store.go; every other capture tag, text, range and match order is unchanged. The capture golden already records both changes. fixture_count 122 and capture_groups_fp 2579 are unchanged, as are synthetic counts 5508/17608; scaling 0.977 < the unchanged 1.5 budget. Prior 2e3099db962f82f9d8641707a8b2bcf6875dafec1f54904f2dec7ac65ba22e9f -> fdc3a691e2ebbc5b0d6fe1da328864293587a75e296e106c705c7e31ee622a5b.", "_rebaselined_3190_ctor_qualified_name": "#3190: generic composite-literal constructors now carry @reference.qualified-name with the written pkg.Box[T] spelling so constructor fallback can tell a qualifier from a bare unique-name guess. DIGEST DRIFT ONLY: the tag is added to existing constructor matches (captureGroups unchanged on go-constructor-type-inference/cmd/main.go). Prior 990e4921a7ef0e6aa4d7da92b671de91fa2621cb27918adb27742740dc6ad902 -> 2e3099db962f82f9d8641707a8b2bcf6875dafec1f54904f2dec7ac65ba22e9f; capture_groups_fp 2579, fixture_count 122.", "_rebaselined_3190": "Corrected go-method-enrichment/app.go to share package animal with its declarations; its capture count is now 16 rather than 15. No Go emitter change. Scaling budget unchanged.", @@ -17,29 +17,32 @@ "_rebaselined_2766_callee_position_marker": "#2766 review fix: a call's callee selector is no longer DROPPED at capture. An earlier commit on this branch dropped it outright, which also deleted the genuine field read on a func-typed struct field (`h.dep.Work()` where `Work func() error`) - callback/hook/mock structs lost their only ACCESSES evidence. The match is now emitted carrying `@reference.callee-position`, and the phantom is suppressed at EMIT by the resolved target's kind instead. Go only: the other 14 languages' fingerprints are byte-identical, which is the check that this is not a cross-language capture change. Prior 7bb524a32a2eed57a15b454e3a33480e92a496c683e6856ef02179693c0e02e3 -> e47302079e17a5e73711bbed5416557b49327cb67e4932008700ec6b8fb468b3; scaling 1.001 < 1.5; fixtures 102 (unchanged), capture_groups_fp 2103.", "_rebaselined_2813_interface_field_dispatch_fixture": "#2813: added test/fixtures/lang-resolution/go-interface-field-dispatch/ (8 Go files) as the committed regression fixture for calls through an interface-typed struct field. Go fixture_count 102 -> 110. FIXTURE-CORPUS GROWTH, NOT A CAPTURE CHANGE: the accompanying fixes are a detection-time method-set change (interface-impls.ts) and a resolution-time fan-out in the shared receiver pass, neither of which emits captures; go/query.ts and go/captures.ts are untouched. Go was the ONLY language whose fingerprint drifted, and every other language matched its baseline on the same run - the same check used for the #2766 fixture growth above. Prior e47302079e17a5e73711bbed5416557b49327cb67e4932008700ec6b8fb468b3 -> cffee41cadbf350855d99bd5aee7c015b1e8b31d1c343d02f113540abe86c765; scaling 1.074 < 1.5, capture_groups_fp 2303.", "_rebaselined_2837": "#2837: Go struct/interface captures re-anchored from the type_declaration onto the type_spec (@scope.class/@declaration.struct/@declaration.interface in languages/go/query.ts, @definition.struct/@definition.interface in GO_QUERIES). A grouped `type (...)` block used to yield ONE scope and ONE node for every type in it, so each type after the first lost its field typeBindings and every field-receiver call in the file emitted nothing. Capture COUNT is unchanged; only ranges moved, plus the new go-grouped-type-decl fixture. Prior c27fb803598581fa4eb7ddf5ef6f8369b9e3a150082d11362e7aa3ec8faaa832 -> e386598526e502d131e52a17d219635b3a4196d94f1ebdd25922a2582c985d18; scaling 1.054 < 1.5.", - "_rebaselined_2873_undecided_satisfaction_fixtures": "#2873: added test/fixtures/lang-resolution/go-extern-qualified-signatures/ (5 Go files) and go-undecided-satisfaction/ (1 Go file) as the committed regression fixtures for out-of-repo package qualifiers in method signatures and for a satisfaction check that cannot be decided. Go fixture_count 116 -> 122. Prior e386598526e502d131e52a17d219635b3a4196d94f1ebdd25922a2582c985d18 -> 9c554a9d698a2b79fb419852daadca87b8aae88180cceabf9c8d82f3e3300f2e. FIXTURE-CORPUS GROWTH, NOT A CAPTURE CHANGE: the accompanying fix is resolution-time (signatureContextForFile recovers an identity for unresolvable imports) plus a tri-state verdict, neither of which runs during capture; go was the ONLY language whose fingerprint drifted and every other language matched its baseline on the same run." + "_rebaselined_2873_undecided_satisfaction_fixtures": "#2873: added test/fixtures/lang-resolution/go-extern-qualified-signatures/ (5 Go files) and go-undecided-satisfaction/ (1 Go file) as the committed regression fixtures for out-of-repo package qualifiers in method signatures and for a satisfaction check that cannot be decided. Go fixture_count 116 -> 122. Prior e386598526e502d131e52a17d219635b3a4196d94f1ebdd25922a2582c985d18 -> 9c554a9d698a2b79fb419852daadca87b8aae88180cceabf9c8d82f3e3300f2e. FIXTURE-CORPUS GROWTH, NOT A CAPTURE CHANGE: the accompanying fix is resolution-time (signatureContextForFile recovers an identity for unresolvable imports) plus a tri-state verdict, neither of which runs during capture; go was the ONLY language whose fingerprint drifted and every other language matched its baseline on the same run.", + "_rebaselined_3486_semantic_rename": "#3486: Compared actual origin/main (191a954b), committed dd3bf and frozen provider source over the same unchanged corpus. 32 existing field-read matches gain receiver-chain tags; removing only these added tags restores the complete main capture set. No group is added or removed. Fixture entries 122; changed entries 14; capture groups 2579 -> 2579. Fingerprint regenerated from the runner's canonicalized capture records. Scaling budgets unchanged." }, "cobol": { - "fingerprint": "c8c00b56a7da24e04080eb885714fbbf45e3903324f0cf9df0754f5b5a92e3aa", + "fingerprint": "d77fe8c2e08894d40bfdfeb6df8f5eceefa9f870bdfce9d4b620e6ef298870c9", "_rebaselined_2813_exact_method_sets": "#2813: Go embedded fields now emit `@reference.embedded-pointer` when spelled `*T` rather than `T`. A CAPTURE-EMISSION CHANGE, not fixture growth: fixture_count is unchanged at 110 and capture_groups_fp moves 2303 -> 2339 (+36), which is the new marker plus the WrongSigRepo/Recount rows added to two existing fixture files. The marker is required for exactness \u2014 Go gives `struct{ Base }` and `struct{ *Base }` different method sets, so structural interface satisfaction cannot be correct without knowing which was written (go.dev/ref/spec#Struct_types). Go was the ONLY language of 15 whose fingerprint moved, which is the check that this is a Go capture change and not a cross-language regression. Accompanied by SCHEMA_BUMP 39 -> 43 (skipping 40/41/42, taken by origin/main during review) so a warm cache cannot replay the pre-marker capture set. Prior cffee41cadbf350855d99bd5aee7c015b1e8b31d1c343d02f113540abe86c765 -> c27fb803598581fa4eb7ddf5ef6f8369b9e3a150082d11362e7aa3ec8faaa832; scaling 0.987 < 1.5.", "scaling_budget": 1.5, "_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: COBOL procedure-pointer callable flow facts; multi-topic extraction now consumes each grouped scope/declaration match once instead of requiring a duplicate declaration-only match. Prior 68ee0e95eb9f86f2d92ca35f730f4c2d4d83abc1b5241ae767ff3437780ec8d1 -> d45bb091b0893d0de4fae2486b31ba21719c9377bf35a0908fd3a36fa1c3bf4e; scaling 0.853 < 1.5.", "_note": "Updated for F17-F23 fixes (P2: TIMES guard, ADD GIVING, SQL AS alias). See PR #1959.", - "_rebaselined_2793_declaratives": "PR #2793: corpus-only re-baseline. `cobol-declaratives` was added to test/fixtures/lang-resolution to reproduce the `Namespace\u2192Record` analyze abort (DECLARATIVES / USE AFTER STANDARD ERROR ON ), and this bench globs `lang-resolution/cobol-*`, so the corpus grew 14 -> 15 files. Verified capture-neutral: with that one fixture moved aside the fingerprint is byte-identical to the prior d45bb091b0893d0de4fae2486b31ba21719c9377bf35a0908fd3a36fa1c3bf4e. No COBOL capture code changed in that PR. Scaling 0.677 < 1.5." + "_rebaselined_2793_declaratives": "PR #2793: corpus-only re-baseline. `cobol-declaratives` was added to test/fixtures/lang-resolution to reproduce the `Namespace\u2192Record` analyze abort (DECLARATIVES / USE AFTER STANDARD ERROR ON ), and this bench globs `lang-resolution/cobol-*`, so the corpus grew 14 -> 15 files. Verified capture-neutral: with that one fixture moved aside the fingerprint is byte-identical to the prior d45bb091b0893d0de4fae2486b31ba21719c9377bf35a0908fd3a36fa1c3bf4e. No COBOL capture code changed in that PR. Scaling 0.677 < 1.5.", + "_rebaselined_3486_semantic_rename": "#3486: Compared actual origin/main (191a954b), committed dd3bf and frozen provider source over the same unchanged corpus. Provider semantic facts replace line-only declarations: exact name spans and qualified/binding identities, full executable ownership scopes, section namespaces, data-item properties and identifier reads/calls. The 13 changed fixture entries gain 49 property declarations, 150 reads and 11 call-reference groups; sections become 5 namespaces instead of callable functions. The fixture corpus is unchanged. Fixture entries 15; changed entries 13; capture groups 110 -> 320. Fingerprint regenerated from the runner's canonicalized capture records. Scaling budgets unchanged." }, "c": { - "fingerprint": "3418cded9f7072152f68992f0a426f43ae7d9d553579a47075fc0cab185848a5", + "fingerprint": "4abf47ae5fb7ccefa78f6e4dc504fa459aebb241970c803542d666736e566846", "scaling_budget": 1.5, "_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior 57fee292147ae6d2db7062da1e07d17122cf355207c8967fa85fd2ec9ca398a4 -> 3418cded9f7072152f68992f0a426f43ae7d9d553579a47075fc0cab185848a5; scaling 1.073 < 1.5.", "_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: C function-pointer signatures plus direct-callee argument metadata and invocation-result suppression. Prior 75bcdbbf006bf9bd263c0f5857461b118f39b164e9f821cb0651ad0ec46ef6ae -> 57fee292147ae6d2db7062da1e07d17122cf355207c8967fa85fd2ec9ca398a4; scaling 1.035 < 1.5.", "_rebaselined_callable_flow": "Callable-value-flow facts for C function pointers, copies, pointer-to-pointer cells, arguments, and indirect invokes. Prior 12a196b2d6249c8d86a931b12ecebc2a0cdf8d6f47683acdd0d8e9d8bc7657f5 -> 75bcdbbf006bf9bd263c0f5857461b118f39b164e9f821cb0651ad0ec46ef6ae; measured scaling ratio 0.980 < 1.5.", "_added": "#1956: c added to the scope-capture bench (was UNBENCHED). C has no inheritance \u2014 flat scale source. Adding it exposed + fixed a pre-existing O(n^2) findNodeAtRange root-walk in c/captures.ts (threaded c.node, byte-identical over c-* fixtures); scaling 3.475 -> 0.96.", "_note": "#1983: + c-static-linkage-worker fixture (caller.c/lib.c/lib.h/local.c \u2014 worker-path static-linkage side-channel test). Pure fixture-corpus drift: no c/captures.ts or query change branch-vs-main, existing fixtures' captures byte-identical (c-captures.test.ts 45/45), scaling stays linear (~0.97). The baseline was missed when the fixture landed; regenerated here. fingerprint 0de009b->39f3a83.", - "_rebaselined": "#1919 open-language coverage: new lang-resolution fixtures + intended capture additions (F5/F9 c-cpp, F26/F28/F29 dart, F47/F48/F49/F51/F52 kotlin, F75/F79 swift). Fingerprint-only drift; scaling_ratio ~1.0 (linear, no perf regression)." + "_rebaselined": "#1919 open-language coverage: new lang-resolution fixtures + intended capture additions (F5/F9 c-cpp, F26/F28/F29 dart, F47/F48/F49/F51/F52 kotlin, F75/F79 swift). Fingerprint-only drift; scaling_ratio ~1.0 (linear, no perf regression).", + "_rebaselined_3486_semantic_rename": "#3486: Compared actual origin/main (191a954b), committed dd3bf and frozen provider source over the same unchanged corpus. 72 pointer-parameter and 43 field type bindings are added; 19 reference-only struct/union specifiers stop introducing class scopes; 53 callable groups gain C/C++-compatible parameter type classes and normalized type spelling. The existing fixture corpus is unchanged. Fixture entries 15; changed entries 9; capture groups 545 -> 641. Fingerprint regenerated from the runner's canonicalized capture records. Scaling budgets unchanged." }, "cpp": { "_lexical_ownership_3532": "#3532: lexical ownership captures and fixture updates; fixture identities are relative to lang-resolution so serialized scope IDs are independent of checkout location. Full capture content remains hashed; scaling_budget is unchanged.", - "fingerprint": "f18beeadd100e5479c2997876bc541412822cf3a4ae33b0d4a6ea6c35cae7b0d", + "fingerprint": "dfd1de80f55d8e1bacde91a99951e7eee4e76ccdc8a7477c5a8ac98d804ce05a", "scaling_budget": 1.5, "_rebaselined_2833_qualified_member_fields": "#2833 follow-up: the six per-qualifier-depth `field_declaration` type-binding rules for a QUALIFIED generic member are replaced by three depth-agnostic ones that match the outer `qualified_identifier` itself, with the qualifier reduced to its top-level tail in `interpret.ts` (`cppQualifiedTail`). This is a CAPTURE-LOGIC change and it moves the fingerprint in two places at once. (1) A qualified NON-generic member (`ns::Address addr;`, `std::string name;`) was captured by nothing at all and now binds \u2014 that is the whole +24 on the fixture corpus, every one of them a `std::string` member. (2) Qualifier depth is no longer enumerated, so `a::b::c::Repo` (depth 3+) is captured where the old rules stopped at 2. Capture-name histogram, cpp-* corpus (278 files): `@type-binding.field` 8 -> 32, `@type-binding.name` and `@type-binding.type` 401 -> 425; synthetic DAO-20: `@type-binding.field` 40 -> 60, `@type-binding.name` and `@type-binding.type` 61 -> 81 (= 20 entities x the one `std::string name;` member the DAO unit already declared). NO OTHER TAG MOVED in either set \u2014 not one `@declaration.*`, `@scope.*` or `@reference.*` count \u2014 which is the property that says three rules replaced six without widening what a field_declaration matches. Measured over the 13 cpp-* fixture repos whose sources gained a binding, the distinct CALLS edge set is byte-identical before and after (32 edges): a reduced tail that names no workspace class binds nothing. Prior bd47c82d09a83cbf0ac857f41876fa31d22304043735582e913bccde06cf2c1a -> db1156d81b3e3341faf5e938a4a34417f4fd246588b6150b4686481823262529; scaling 1.04 < 1.5.", "_rebaselined_2833_generic_member_fields": "#2833 review follow-up: the cpp DAO generator's unit gains two GENERIC member fields \u2014 `Repo repo;` (bare template_type) and `std::vector items;` (qualified_identifier wrapping a template_type) \u2014 plus the header declaring `template class Repo`. CORPUS CHANGE, NOT A CAPTURE-LOGIC CHANGE: no extractor edit accompanies it. It exists because the corpus had ZERO template-typed member fields and, across 279 cpp-* fixtures, not one qualified generic member either, so BOTH rounds of new `field_declaration` type-binding rules landed with a byte-identical cpp fingerprint \u2014 the gate was structurally blind to the exact thing being changed. Measured under the new corpus, the three states now differ: pre-#2833 query 0e7cbda71360b7ff35dd76091c77f288d6af6a5cfa9185ad85a372aae8c85191 (4521 groups) -> the three template_type field rules de07d8b5300ed867b460918e16b4d80259c7eb6efc1034d32bebe9ff7cab126d (4541) -> the six qualified rules bd47c82d09a83cbf0ac857f41876fa31d22304043735582e913bccde06cf2c1a (4561); under the OLD corpus all three were 856d02f3f9d22cb973877211100aee8e052d4bc545922f78704b1a21ce49ddcc. Capture-name histogram over the synthetic DAO-20: `@type-binding.field` 0 -> 40, `@declaration.field` 40 -> 80, `@type-binding.type`/`@type-binding.name` 20 -> 61, `@declaration.name` 104 -> 147 \u2014 40 = 20 entities x 2 fields, with the residual +1/+2/+3 attributable to the one-off header declaration; every `@reference.*` count is unchanged. Prior 856d02f3f9d22cb973877211100aee8e052d4bc545922f78704b1a21ce49ddcc -> bd47c82d09a83cbf0ac857f41876fa31d22304043735582e913bccde06cf2c1a; scaling 1.058 < 1.5. `c` is unaffected (3418cded..., unchanged).", @@ -59,11 +62,12 @@ "capture_groups_large": 16021, "capture_groups_fp": 4601, "fixture_count": 279, - "_rebaselined_1432_member_call_callee_name": "#1432 (Zig): the shared callable-flow reader no longer names a callee by simple name for a MEMBER call (`@callable-flow.direct-callee-name` requires a direct designator: `f(x)`, `ns.f(x)`), and a member call is a field-stored-callable invoke only when a MEMBER store (`o.f = handler`) or a declared callable-typed field is visible - a same-named plain binding no longer gates it. CAPTURE-EMISSION CHANGE, not fixture growth (fixture_count unchanged). Only drift: `choice.select(1)` / `choice.select(1.5)` (cpp-deleted-overload/main.cpp) no longer mint an INDIRECT `@callable-flow.invoke` (callee-kind binding) plus its synthetic `@reference.call.free` - that invoke was gated only by the same-named free `select` binding while the site is a genuine method call already captured as `@reference.call.member`, so the free-call duplicate is gone. capture_groups_fp 4605 -> 4601 (-4: 2 invokes + 2 synthetic call.free). Prior bf3587674267be1759e7c45abef143c3b81fe8629cfd17da5f8af40e83cc39ec -> 3aaee42523f02718795ba348782d78ec1418f35eff2e1b602422b192544fed96." + "_rebaselined_1432_member_call_callee_name": "#1432 (Zig): the shared callable-flow reader no longer names a callee by simple name for a MEMBER call (`@callable-flow.direct-callee-name` requires a direct designator: `f(x)`, `ns.f(x)`), and a member call is a field-stored-callable invoke only when a MEMBER store (`o.f = handler`) or a declared callable-typed field is visible - a same-named plain binding no longer gates it. CAPTURE-EMISSION CHANGE, not fixture growth (fixture_count unchanged). Only drift: `choice.select(1)` / `choice.select(1.5)` (cpp-deleted-overload/main.cpp) no longer mint an INDIRECT `@callable-flow.invoke` (callee-kind binding) plus its synthetic `@reference.call.free` - that invoke was gated only by the same-named free `select` binding while the site is a genuine method call already captured as `@reference.call.member`, so the free-call duplicate is gone. capture_groups_fp 4605 -> 4601 (-4: 2 invokes + 2 synthetic call.free). Prior bf3587674267be1759e7c45abef143c3b81fe8629cfd17da5f8af40e83cc39ec -> 3aaee42523f02718795ba348782d78ec1418f35eff2e1b602422b192544fed96.", + "_rebaselined_3486_semantic_rename": "#3486: Compared actual origin/main (191a954b), committed dd3bf and frozen provider source over the same unchanged corpus. 9 existing read matches gain receiver chains; 9 synthetic operator names now use their written operator-token range rather than the translation-unit range. Group counts are unchanged. Fixture entries 279; changed entries 10; capture groups 4507 -> 4507. Fingerprint regenerated from the runner's canonicalized capture records. Scaling budgets unchanged." }, "csharp": { "_rebaselined": "#1956 synth-widening: + csharp-qualified-base fixture; the synth now walks record_declaration + struct_declaration base_lists and handles alias_qualified_name (matching the #1940 legacy leg), so record/struct heritage now emits. csharp-record-base gains a record inherits capture. (record->record SAME-namespace EXTENDS is a separate registry resolution gap, tracked as follow-up.) Linear (~1.00). (Earlier #1956: heritage-bearing scale source.) | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged. | #1924 F16: record primary-constructor base bindings now exclude constructor arguments; capture fingerprint changes, scaling remains linear. | #2036 review follow-up: csharp-record-base now exercises primary-constructor base dispatch end to end; +2 capture groups, scaling remains linear.", - "fingerprint": "9c4d2ca55707a03185ea45e554462e81e6d83c9f8d0ea3cd63db5c362ad1bfec", + "fingerprint": "af724813b5183ad9c98408a9c6944a44897b97d02e7f474ffa4ae637ca9ac1e3", "scaling_budget": 1.5, "_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior f31544530924748f9aa37d11cec570bc10c3ddf9d9b237e6df7a17623fd2bb3a -> 75cf380209fa7d1a8a3ec873be1a9424b4e5173be0b08234c2291e8521a9b3c1; scaling 1.061 < 1.5.", "_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: C# method-group/delegate callable flow facts with invocation-result suppression. Prior 2bb5bc8c19cb8eb08c9590545ad8a1968a7152951f7e12746e2d7901d542fed9 -> f31544530924748f9aa37d11cec570bc10c3ddf9d9b237e6df7a17623fd2bb3a; scaling 1.115 < 1.5.", @@ -75,11 +79,12 @@ "capture_groups_large": 13609, "capture_groups_fp": 2657, "fixture_count": 178, - "_rebaselined_1432_member_call_callee_name": "#1432 (Zig): the shared callable-flow reader no longer names a callee by simple name for a MEMBER call (`@callable-flow.direct-callee-name` requires a direct designator: `f(x)`, `ns.f(x)`), and a member call is a field-stored-callable invoke only when a MEMBER store (`o.f = handler`) or a declared callable-typed field is visible - a same-named plain binding no longer gates it. CAPTURE-EMISSION CHANGE, not fixture growth (fixture_count unchanged). Only drift: `string.Join(\", \", args)` (csharp-variadic-resolution/Utils/Logger.cs) loses `direct-callee-name|Join`; the argument fact itself is unchanged, capture_groups_fp 2657 (unchanged). Prior 2930ef49fdce984a4c051409880bddfe8445e30e1c6bf802bd90a0a0f8f6b094 -> 9c4d2ca55707a03185ea45e554462e81e6d83c9f8d0ea3cd63db5c362ad1bfec." + "_rebaselined_1432_member_call_callee_name": "#1432 (Zig): the shared callable-flow reader no longer names a callee by simple name for a MEMBER call (`@callable-flow.direct-callee-name` requires a direct designator: `f(x)`, `ns.f(x)`), and a member call is a field-stored-callable invoke only when a MEMBER store (`o.f = handler`) or a declared callable-typed field is visible - a same-named plain binding no longer gates it. CAPTURE-EMISSION CHANGE, not fixture growth (fixture_count unchanged). Only drift: `string.Join(\", \", args)` (csharp-variadic-resolution/Utils/Logger.cs) loses `direct-callee-name|Join`; the argument fact itself is unchanged, capture_groups_fp 2657 (unchanged). Prior 2930ef49fdce984a4c051409880bddfe8445e30e1c6bf802bd90a0a0f8f6b094 -> 9c4d2ca55707a03185ea45e554462e81e6d83c9f8d0ea3cd63db5c362ad1bfec.", + "_rebaselined_3486_semantic_rename": "#3486: Compared actual origin/main (191a954b), committed dd3bf and frozen provider source over the same unchanged corpus. 89 constructor-parameter type bindings are added, 46 field declarations become addressable properties, and 3 existing member reads gain receiver-chain tags. Reversing exactly these three changes restores the complete main capture set. Fixture entries 178; changed entries 47; capture groups 2657 -> 2746. Fingerprint regenerated from the runner's canonicalized capture records. Scaling budgets unchanged." }, "rust": { "_lexical_ownership_3532": "#3532: lexical ownership captures and fixture updates; fixture identities are relative to lang-resolution so serialized scope IDs are independent of checkout location. Full capture content remains hashed; scaling_budget is unchanged.", - "fingerprint": "8c9f271349ab0a35a32e6f064303d63e191e019daa6dab8c5fc80784717a3321", + "fingerprint": "7d314a184cba1e5c47260d0a5018107146437f56a2bd183ae0670f0d77a3fbd2", "scaling_budget": 1.5, "_rebaselined_generic_instantiation_2912": "#2912: RUST_SCOPE_QUERY tags trait-impl heritage with the instantiation the impl was written with (`impl Validator for V`), so interface dispatch can prune implementors of an instantiation the receiver cannot hold. Additive capture text on existing impl matches \u2014 the same matches are minted, carrying one more field \u2014 so this is digest drift, not a capture-set change: capture_groups_fp (3556) and fixture_count (202) are both unchanged, which is the check that no match appeared or vanished. Prior 116a971fee0004f340477aff69fa110a1d92bd8ba882d7c926483c6b1e8ca2b9 -> e61653008ff2de506cfd47f905fa9eb22d82fbbfe94d2a1d8190c358211b57b7; scaling 1.018 < 1.5. Only rust and dart move; the other 13 languages are byte-identical.", "_rebaselined_mod_node_identity_2745_review": "#2745 review: added rust-2742-mod-members, rust-2742-nested-mods and rust-2742-type-vs-module under lang-resolution for the container/owner-edge fix, nested inline modules, and the imported-type-vs-module precedence. emitRustScopeCaptures is unchanged \u2014 verified by removing ONLY those three fixture dirs and re-running, which reproduces the prior fingerprint exactly, so the shift is purely corpus growth (fixture_count 196 -> 202, capture_groups_fp 3432 -> 3556). Prior 90fda086a4e13aa069a5981f63ed58ab1c71f1ed3da5e1480a080e1992b0d3e5 -> 05acbaca48427e0d9e0793bcd0ce4057712d3716b5e7868189c12e05ef8dd300; scaling 1.022 local / 1.057 CI < 1.5. NOTE for the next fixture author: a new rust-* fixture drifts BOTH this bench baseline and the rust-captures-golden snapshot. Updating only the golden is how this reached CI red.", @@ -96,10 +101,11 @@ "capture_groups_small": 5507, "capture_groups_large": 17607, "capture_groups_fp": 3556, - "fixture_count": 202 + "fixture_count": 202, + "_rebaselined_3486_semantic_rename": "#3486: Compared actual origin/main (191a954b), committed dd3bf and frozen provider source over the same unchanged corpus. 28 existing field-read matches gain receiver-chain tags; removing only these added tags restores the complete main capture set. No group is added or removed. Fixture entries 204; changed entries 6; capture groups 3568 -> 3568. Fingerprint regenerated from the runner's canonicalized capture records. Scaling budgets unchanged." }, "php": { - "fingerprint": "745700bcd75c3f7bccff2e8085205f129c9c965d38ad2e291bba42d56afcd5e6", + "fingerprint": "310d2c1cddf3b36264f988a78486f5c287a1094f8539dd165e8b58a5258febd5", "_rebaselined_3531": "The php-typed-property-dedup fixture class Mixed was renamed MixedProperties because mixed is a reserved PHP type. Only that fixture's text/ranges change; all 142 fixture files emit identical captures under the old and new grammars when given the same source. Capture groups remain 2241 and the scaling budget is unchanged.", "_rebaselined_3502_attributed_function_fixture": "#3502 extends the existing php-nested-function fixture with an attributed, comment-interposed named function and a long-whitespace closure control. This is fixture-content growth only: the capture implementation now classifies existing captures instead of scanning source text, so it cannot mint or remove matches. The fixture corpus stays at 143 files; captured groups move 2237 -> 2241, while synthetic scaling counts stay 4258/13608. Prior 0adf565ed79dc1d909c23a18a9e3a17ddaa6556fb73857081840cd7389953075 -> 6e92ed6e50c2ba4b383b97b9f9b550a99d2d647a30a2867b53ad94f24114952d.", "_rebaselined_3502_nested_function_fixture": "#3502 adds the php-nested-function fixture used by the resolver regression. The capture implementation and query are unchanged; this is fixture-corpus growth only. The unified harness moves from 141 to 143 fixture files and from 2203 to 2237 captured groups, while the synthetic scaling counts remain 4258/13608 and every other language matches its baseline. Prior 421cbd318c8857f840aa60904a675cbde32c6bb57b01e6c0f970d10db572b99e -> 0adf565ed79dc1d909c23a18a9e3a17ddaa6556fb73857081840cd7389953075.", @@ -109,10 +115,11 @@ "_rebaselined": "#1956: heritage-bearing scale source (class extends Base + use trait); both forms gated at scale; linear (~1.04). | #2481/#2482: PHP imports carry a symbol-kind capture so function/constant imports resolve by declaring file; capture shape changes, scaling remains linear (~1.04).", "_note": "PR #1931: F53 import multi-clause, F54 enum_case, F55 anonymous_class \u2014 fixture count 138\u2192140, fingerprint drift expected.", "_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged \u2014 the tag is added to existing call matches, never a new match \u2014 so this is digest drift only. Prior 4a688fa5a7016546f7f3c6d44de023608ae80c5b0e3670c16f6e61b3632608fd -> 3745662053c76b6ae0a84a29aad319626ed5ccb88f7b9376c2680d3dc6502e28.", - "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 3745662053c76b6ae0a84a29aad319626ed5ccb88f7b9376c2680d3dc6502e28 -> b213a872342da2d866b04681dede988770e4d3dfdc0d6e9f62212ec5b59cdc2c." + "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 3745662053c76b6ae0a84a29aad319626ed5ccb88f7b9376c2680d3dc6502e28 -> b213a872342da2d866b04681dede988770e4d3dfdc0d6e9f62212ec5b59cdc2c.", + "_rebaselined_3486_semantic_rename": "#3486: Compared actual origin/main (191a954b), committed dd3bf and frozen provider source over the same unchanged corpus. The existing php-use-function-const/app/Config/constants.php fixture now captures its MAX_RETRIES declaration. Its one new declaration group is the entire delta. Fixture entries 143; changed entries 1; capture groups 2241 -> 2242. Fingerprint regenerated from the runner's canonicalized capture records. Scaling budgets unchanged." }, "ruby": { - "fingerprint": "3c222b5787c23a5d18780784906a8de0a6fe26dd0ff18c729c0690e2852c9389", + "fingerprint": "1878e6985e93d28c24dc78c5934d1df580fa0dcf1b9f653872e2ea216318c4bb", "_rebaselined_3502_block_boundary_fixture": "#3502 strengthens ruby-nested-method with a receiver-rebinding class_eval block. The provider fix changes binding placement only; capture emission is unchanged. Corpus size stays 94 entries while capture_groups_fp grows 1431 -> 1445 from the added fixture source. Synthetic counts remain 3011/9611 and measured scaling 0.922 < 1.5. Prior d304127cf066aac754605482102f82c268451ae31dd18d7a7e661ce3a2266a07 -> ff4bff0ddd0d43fe61baaba7937bb4b88bd8e98b4cadba696a6d0d363fdf1963.", "_rebaselined_3502_nested_method_fixture": "#3502 adds the two-file ruby-nested-method fixture used by the resolver regression. The Ruby provider change projects existing def captures during resolution and does not alter capture emission. This is fixture-corpus growth only: fixture_count 92 -> 94 and capture_groups_fp 1416 -> 1431, while synthetic scaling counts stay 3011/9611. Prior 45e65d9fa8a9e5e905ddb596b179b77c86ed13ab5139c6b17ccaaf7315dfe46a -> d304127cf066aac754605482102f82c268451ae31dd18d7a7e661ce3a2266a07.", "scaling_budget": 1.5, @@ -128,7 +135,9 @@ "capture_groups_large": 9611, "capture_groups_fp": 1488, "fixture_count": 94, - "_rebaselined_3502_ast_method_ownership": "#3502 review: Ruby ordinary method ownership is now marked from AST node types, including methods nested in a method body inside a class. Singleton methods and receiver-rebinding or unproven blocks do not carry the marker. Golden snapshots attribute the digest changes to the marker and expanded ruby-nested-method regressions; fixture count stays 94, capture_groups_fp becomes 1488, synthetic counts stay 3011/9611. Scaling ratio 1.093 remains below the unchanged 1.5 budget. Prior ff4bff0ddd0d43fe61baaba7937bb4b88bd8e98b4cadba696a6d0d363fdf1963 -> 3c222b5787c23a5d18780784906a8de0a6fe26dd0ff18c729c0690e2852c9389." + "_rebaselined_3502_ast_method_ownership": "#3502 review: Ruby ordinary method ownership is now marked from AST node types, including methods nested in a method body inside a class. Singleton methods and receiver-rebinding or unproven blocks do not carry the marker. Golden snapshots attribute the digest changes to the marker and expanded ruby-nested-method regressions; fixture count stays 94, capture_groups_fp becomes 1488, synthetic counts stay 3011/9611. Scaling ratio 1.093 remains below the unchanged 1.5 budget. Prior ff4bff0ddd0d43fe61baaba7937bb4b88bd8e98b4cadba696a6d0d363fdf1963 -> 3c222b5787c23a5d18780784906a8de0a6fe26dd0ff18c729c0690e2852c9389.", + "_rebaselined_3486_semantic_rename": "#3486: Compared actual origin/main (191a954b), committed dd3bf and frozen provider source over the same unchanged corpus. 28 require_relative source captures retain their relative meaning with a leading ./; declaration/reference groups and every other import capture field remain unchanged. Fixture entries 94; changed entries 23; capture groups 1488 -> 1488. Fingerprint regenerated from the runner's canonicalized capture records. Scaling budgets unchanged.", + "_rebaselined_3557_accessor_fixture": "PR #3557 adds ruby-accessor-dispatch/app.rb and model.rb to the benchmark corpus. Ruby capture emitter is unchanged from 6e8b5f9; only resolution-time member-dispatch changes. Corpus grows 94 -> 96 files and 1488 -> 1557 capture groups. Synthetic counts stay 3011/9611; measured scaling 1.158 < unchanged 1.5 budget. Previous fingerprint becc1380aae8f666da84d11fb8084bb93d1a05efabde49f5d02b080742b77b7c." }, "swift": { "fingerprint": "2507ac75ba6fb47c272a5caa97b5e8b9f6ebc5098c15295d05c7bff3cabc321a", @@ -152,7 +161,7 @@ "_rebaselined_3354_callable_alternatives": "#3354 callable alternatives: a callable chosen by a value-selecting source now flows every branch it can yield (`a ?? b`, `a || b`, `a or b`, `c ? a : b`, statement `if`, elvis), and an operator branch (`x == f || g`) stays opaque instead of seeding a qualified name. Verified by running the BASE (merge-base 233ca2849) and HEAD emitters over the SAME HEAD fixture corpus: every added or removed match is an `@callable-flow.*` match on one of those sources, and the pre-existing corpus is byte-identical (all other languages: zero delta). The rest of the drift is corpus growth from this PR's regression fixture, which this bench globs. Corpus growth: swift-callable-alternatives/App.swift (+1 file, +36 groups). Emitter delta: +6 / -3: `??` and ternary seeds and copies now name each operand; the 3 removed matches are the whole-expression seeds and copy that carried a `target-qualified-name` / `source-qualified-name` of the compound source. capture_groups_fp 1297 -> 1333, fixture_count 67 -> 68; synthetic counts unchanged; scaling 1.057 < 1.5. Prior decf74c01af0c7f403e203b19c2bd92dbf95b4562f6da2b0ac5117ef872204da -> c9fc553662f0db18027fba882e3ff730744f6153cc46eca7b00667fabd6a0df8." }, "dart": { - "fingerprint": "f4edcfedb6e97def5ffbb9d3227fce2549e5c6c6cc876a05bc3b68a46ed30c24", + "fingerprint": "557bdc505af40590b21e53298584f14227b7d79220806425f8e7da25d78f9712", "scaling_budget": 1.5, "_rebaselined_2963_package_imports": "#3369 package-import fixtures: dart-package-imports adds six .dart files. CORPUS GROWTH ONLY, NOT A CAPTURE CHANGE: parking that directory restores 795290a9524ee0dc38af635536e9744c401ec13828ec77822d39e59e1cd6e649 byte-for-byte. The six files contribute 33 capture groups (862 -> 895) and fixture_count 37 -> 43. Synthetic scale counts stay 5011/16011. Local scaling 1.091 < 1.5. Prior 795290a9524ee0dc38af635536e9744c401ec13828ec77822d39e59e1cd6e649 -> f4edcfedb6e97def5ffbb9d3227fce2549e5c6c6cc876a05bc3b68a46ed30c24.", "_rebaselined_generic_instantiation_2912": "#2912: the Dart heritage marker carries a fourth field \u2014 the type arguments the clause was written with (`implements Validator`) \u2014 so interface dispatch can prune implementors of a mismatched instantiation. Additive marker text on existing heritage matches rather than a new match, so this is digest drift only; a marker from a pre-#2912 cache simply has no fourth field and reads as unknown. Prior ba93c90dcd341259e8e088816bc8c76ad27882419f665e35c056dc22fa54cf73 -> 3a8ddabbeb1cba47a4757451d4f79d726ca230fd15e860772b11526fbb1c6687; scaling 1.027 < 1.5.", @@ -162,10 +171,11 @@ "_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: Dart function-tearoff and selector callable flow facts, split signature/body lexical ownership, and invocation-result suppression. Prior 94bf2c26e1ba96f4211634aa572c0a989b503e717e75dfc5df04f66c417de80f -> 29ce2bfe70b246b1c9d5e99c0ec11e850c22e9672737592207242b7f4cc824b8; scaling 0.906 < 1.5.", "_added": "#939: dart added to the scope-capture bench with the registry-primary migration. Heritage-bearing scale source (Entity extends Base implements Marker) gates the @reference.inherits synth + the postfix-chain reference walk at scale. emitDartScopeCaptures threads tree-sitter captured nodes (no findNodeAtRange root-walk), so it is linear (~1.0).", "_rebaselined": "#1919 review CF3 fix: extended kotlin-local-property-owner (init/accessor destructuring) + new dart-accessor-owner fixture (getter/setter ownership). Fingerprint-only corpus drift; scaling ~1.0.", - "_rebaselined_3354_callable_alternatives": "#3354 callable alternatives: a callable chosen by a value-selecting source now flows every branch it can yield (`a ?? b`, `a || b`, `a or b`, `c ? a : b`, statement `if`, elvis), and an operator branch (`x == f || g`) stays opaque instead of seeding a qualified name. Verified by running the BASE (merge-base 233ca2849) and HEAD emitters over the SAME HEAD fixture corpus: every added or removed match is an `@callable-flow.*` match on one of those sources, and the pre-existing corpus is byte-identical (all other languages: zero delta). The rest of the drift is corpus growth from this PR's regression fixture, which this bench globs. Corpus growth: dart-callable-alternatives/app.dart (+1 file, +33 groups). Emitter delta: +6 / -3: `??` and `?:` seeds and copies now name each operand; the 3 removed matches are the whole-expression seeds and copy that carried a qualified name of the compound source. capture_groups_fp 829 -> 862, fixture_count 36 -> 37; synthetic counts unchanged; scaling 1.033 < 1.5. Prior 3a8ddabbeb1cba47a4757451d4f79d726ca230fd15e860772b11526fbb1c6687 -> 795290a9524ee0dc38af635536e9744c401ec13828ec77822d39e59e1cd6e649." + "_rebaselined_3354_callable_alternatives": "#3354 callable alternatives: a callable chosen by a value-selecting source now flows every branch it can yield (`a ?? b`, `a || b`, `a or b`, `c ? a : b`, statement `if`, elvis), and an operator branch (`x == f || g`) stays opaque instead of seeding a qualified name. Verified by running the BASE (merge-base 233ca2849) and HEAD emitters over the SAME HEAD fixture corpus: every added or removed match is an `@callable-flow.*` match on one of those sources, and the pre-existing corpus is byte-identical (all other languages: zero delta). The rest of the drift is corpus growth from this PR's regression fixture, which this bench globs. Corpus growth: dart-callable-alternatives/app.dart (+1 file, +33 groups). Emitter delta: +6 / -3: `??` and `?:` seeds and copies now name each operand; the 3 removed matches are the whole-expression seeds and copy that carried a qualified name of the compound source. capture_groups_fp 829 -> 862, fixture_count 36 -> 37; synthetic counts unchanged; scaling 1.033 < 1.5. Prior 3a8ddabbeb1cba47a4757451d4f79d726ca230fd15e860772b11526fbb1c6687 -> 795290a9524ee0dc38af635536e9744c401ec13828ec77822d39e59e1cd6e649.", + "_rebaselined_3486_semantic_rename": "#3486: Compared actual origin/main (191a954b), committed dd3bf and frozen provider source over the same unchanged corpus. 2 existing prefixed package imports retain their explicit aliases (http/tool). Import sources, statement spans and all other matches are unchanged. Fixture entries 43; changed entries 1; capture groups 895 -> 895. Fingerprint regenerated from the runner's canonicalized capture records. Scaling budgets unchanged." }, "java": { - "fingerprint": "e22a753b9f59331898a3a27c43072b588c33639937b1f618f6c70ce04f072a9f", + "fingerprint": "41c4338f5813a73199dd5c2609313c11076812df21fd9c429039a833325870e0", "scaling_budget": 1.5, "_rebaselined_3190_ctor_qualified_name": "#3190: qualified constructors (`new pkg.Foo()`, `new pkg.Box()`) now copy @reference.call.constructor.qualified onto @reference.qualified-name so constructor fallback treats the written qualifier as precise. DIGEST DRIFT ONLY: the tag is added to existing F35 matches. Prior 2bf47cc19b595a9889ac21ec0154c6ce6786271d68551f21d1bc14c626bcd4ff -> e22a753b9f59331898a3a27c43072b588c33639937b1f618f6c70ce04f072a9f; capture_groups_fp 3586, fixture_count 209.", "_rebaselined_2935_synthetic_declarations": "PR #2935 review follow-up: synthesized Java anonymous classes and bodied enum constants now carry the presence-only @declaration.is-synthetic sidecar used to preserve source-written dispatch targets at the fanout cap. DIGEST DRIFT ONLY, NOT A CAPTURE-SET CHANGE: the tag is attached to existing synthetic declaration matches; capture groups and fixture count remain 5755/18405, 3512, and 206. Prior 36d689c58526c4482fbd701d1d9ca156623a3970734ead145717858712271ab5 -> 2e2150b4f4d64519e3f4c6d7a2c12259178d3117872203c904fab8cba96a694a; CI scaling 0.971 < 1.5.", @@ -187,21 +197,23 @@ "capture_groups_large": 20005, "capture_groups_fp": 3586, "fixture_count": 209, - "_rebaselined_2910_declared_package_fixtures": "#2910 adds three Java resolver fixture files covering an external JDK lookalike, a path/package mismatch, and wildcard package membership. Fixture-corpus growth only: Java query rules and synthetic scaling sources are unchanged; capture_groups_small/large remain 6255/20005. capture_groups_fp 3560 -> 3586 and fixture_count 206 -> 209." + "_rebaselined_2910_declared_package_fixtures": "#2910 adds three Java resolver fixture files covering an external JDK lookalike, a path/package mismatch, and wildcard package membership. Fixture-corpus growth only: Java query rules and synthetic scaling sources are unchanged; capture_groups_small/large remain 6255/20005. capture_groups_fp 3560 -> 3586 and fixture_count 206 -> 209.", + "_rebaselined_3486_semantic_rename": "#3486: Compared actual origin/main (191a954b), committed dd3bf and frozen provider source over the same unchanged corpus. 39 constructor-parameter type bindings are added, 6 member reads gain receiver chains, and 196 duplicate qualified free-call matches are removed. Every removed free-call match retains a member-call match with the identical call anchor and name capture; no qualified call disappears. Fixture entries 209; changed entries 89; capture groups 3586 -> 3429. Fingerprint regenerated from the runner's canonicalized capture records. Scaling budgets unchanged." }, "java-local-types": { - "fingerprint": "bdde823fa725e636e257940efb4c8655aa23124c1727cbaa8856d1ad8f71729e", + "fingerprint": "6ae163cd36c889454c8672b28944c765d3a3a5e8c49ea4db1cd6cc1787ede583", "scaling_budget": 1.5, "_rebaselined_2935_synthetic_declarations": "PR #2935 review follow-up: the local-type stress corpus includes synthesized anonymous declarations, which now carry the presence-only @declaration.is-synthetic sidecar. DIGEST DRIFT ONLY, NOT A CAPTURE-SET CHANGE. Prior 8c50bbc83dff4f7f5abd06078aa6abc6b64af05fddb17ee826b5f3df3d346633 -> 560734cd053fb4f4b23aa04bc7870c22089a8deedb0217fa9c1b4db689e02a97; CI scaling 1.002 < 1.5.", "_rebaselined_2917_record_component_accessors": "#2917: the focused local-type fixture corpus contains local records, so their implicit component accessors add the same bounded scope/declaration captures as the general Java corpus. No local-type naming logic changed. Prior 8c50bbc83dff4f7f5abd06078aa6abc6b64af05fddb17ee826b5f3df3d346633 -> 3e22f368a4ee139be7cb91ff4fb77ddadf60c55efe8d66955ec81f366a46e460; scaling 1.032 < 1.5, capture_groups_fp 680. Re-measured on top of #2935's is-synthetic sidecar after merging origin/main: 560734cd053fb4f4b23aa04bc7870c22089a8deedb0217fa9c1b4db689e02a97 -> bdde823fa725e636e257940efb4c8655aa23124c1727cbaa8856d1ad8f71729e; scaling 0.997 < 1.5, capture_groups_fp 680.", "_added": "#2562 performance follow-up: co-scales same-host, same-name local classes and anonymous classes to gate JLS binary-name ordinal allocation. Precomputed per-sequence ordinals reduce the focused 100->800 workload from 176->6655ms to 141->752ms; normalized 250->800 scaling is 1.054.", "_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged \u2014 the tag is added to existing call matches, never a new match \u2014 so this is digest drift only. Prior a9ad88de21ca6747a923260dbdf677fb74a004abbf9d57781f745e3a9027530b -> 3ca67847ea2b9a71b0a41e09f943767e5a2d3a113d3e203499ee364e37f40236.", "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 3ca67847ea2b9a71b0a41e09f943767e5a2d3a113d3e203499ee364e37f40236 -> 8c50bbc83dff4f7f5abd06078aa6abc6b64af05fddb17ee826b5f3df3d346633.", - "capture_groups_fp": 680 + "capture_groups_fp": 680, + "_rebaselined_3486_semantic_rename": "#3486: Compared actual origin/main (191a954b), committed dd3bf and frozen provider source over the same unchanged corpus. 2 constructor-parameter type bindings are added and 38 duplicate qualified free-call matches are removed. Every removed free-call match retains a member-call match with the identical call anchor and name capture. Fixture entries 6; changed entries 6; capture groups 680 -> 644. Fingerprint regenerated from the runner's canonicalized capture records. Scaling budgets unchanged." }, "typescript": { "_lexical_ownership_3532": "#3532: lexical ownership captures and fixture updates; fixture identities are relative to lang-resolution so serialized scope IDs are independent of checkout location. Full capture content remains hashed; scaling_budget is unchanged.", - "fingerprint": "bccca22b59d4c4d8aa4766c69092f29912fa035f08f037a639de3beaa725eaf4", + "fingerprint": "28cbf71b54870d9b5e4102031d758ca288d7ccc546adc892dae4c237d273b8ae", "_rebaselined_3446_mcp_tools_fixture": "#3446 adds typescript-mcp-tools/src/{handlers,server,tools}.ts: corpus growth only, with fixture_count 171 -> 174 and capture_groups_fp 2753 -> 2894. Excluding only that fixture directory restores the prior fingerprint 77c9b4ea654123a64972db8348190ec250b669472b150db65ea8c7f20b355467 exactly. The scope emitter, synthetic capture counts (4503/14403), scaling budget, and other language baselines are unchanged.", "_rebaselined_3190": "Capture matches now retain explicit ESM export/private evidence, including synthesized default HOCs; CommonJS surfaces remain undecided. Capture group counts unchanged. Scaling budget unchanged.", "scaling_budget": 1.5, @@ -227,7 +239,8 @@ "_rebaselined_2899_review_type_parameter_scope_fixtures": "PR #2899 review follow-up: FIXTURE-CORPUS GROWTH ONLY \u2014 no query rule changed and no capture name was added or removed. `typescript/query.ts` is byte-identical to the previous baseline; the type-parameter shadowing defect was fixed on the RESOLUTION side (`walkers.ts` gains a `declarationOpenedScope` gate so a declaration's `typeParameters` bind only inside the scope that declaration opened, and the `USES` guard moved from `graph-bridge/references-to-edges.ts` to `resolve-references.ts` where the spelled `site.name` is in hand). The fingerprint moves because measure.mjs fingerprints the whole `lang-resolution/typescript-*` fixture corpus and the regression tests add three files to `typescript-type-parameters/src/` (values.ts, aliased.ts, namespaced.ts) plus two scope-less generic aliases in shapes.ts. Per-file accounting sums exactly to the delta: shapes.ts 33->35 (+2), values.ts +11, aliased.ts +10, namespaced.ts +20 = +43. capture_groups_fp 2371 -> 2414; fixture_count 152 -> 155. capture_groups_small/large unchanged at 4503/14403 (they measure the SYNTHETIC scaling source, untouched). JAVASCRIPT IS THE CONTROL AND DID NOT MOVE (fingerprint 2026993b..., 43 fixtures) \u2014 which is the check that this is corpus growth and not a capture regression; all 14 other languages report `ok`. Scaling 0.976 < 1.5. Prior 62c7f1bfbe568eed927fb78f00061ed5e49d12511fd8260648b876df386f3b4c -> c2fbf8a89e5686dd1ff3659b20d41d8b05ebcc9790356e3653ee0c8ca5d365c8.", "_rebaselined_2953_workspace_fixture": "#2953 adds test/fixtures/lang-resolution/typescript-pnpm-workspace-imports, a pnpm monorepo of 12 .ts files, and the TypeScript capture corpus is collected from test/fixtures. CORPUS GROWTH ONLY, NOT A CAPTURE CHANGE: fixture_count 155 -> 167 and capture_groups_fp 2414 -> 2465 are the 12 new files' own matches; capture_groups_small/large are unchanged at 4503/14403 because those measure the SYNTHETIC scaling source, which the fixture corpus does not feed. Attribution is exact rather than inferred: moving that one fixture directory aside and re-running returns typescript to f719163eb03a447c9e40ca316a905dd76cee82192a75a403df478ebbdc13e98f byte-for-byte with fixture_count back at 155, and [scope-capture --check] PASSES for all 15 languages - so nothing in the TypeScript capture stream moved. #2953 changes import RESOLUTION, which runs after capture and feeds no capture tag. Prior f719163eb03a447c9e40ca316a905dd76cee82192a75a403df478ebbdc13e98f -> 05d1dadd6c9ef35c74079fa50f341b1b36e4fb02c9a89dd1b59f32b7cfd5e633.", "_rebaselined_1432_member_call_callee_name": "#1432 (Zig): the shared callable-flow reader no longer names a callee by simple name for a MEMBER call (`@callable-flow.direct-callee-name` requires a direct designator: `f(x)`, `ns.f(x)`), and a member call is a field-stored-callable invoke only when a MEMBER store (`o.f = handler`) or a declared callable-typed field is visible - a same-named plain binding no longer gates it. CAPTURE-EMISSION CHANGE, not fixture growth (fixture_count unchanged). Drift: `await svc.verify(token, ...)` (typescript-generic-calls/src/guest.ts, member call) and `initializer()(() => {...})` (typescript-hof-callbacks/src/store.ts, call-of-call) lose `direct-callee-name`. `await verifyToken(token, ...)` (admin.ts/auth.ts) KEEPS `direct-callee-name|verifyToken`: tree-sitter-typescript parses `await f(x)` as call_expression(function: await_expression(f), type_arguments, ...), and wrappedExpression now unwraps `await_expression` so the direct designator survives as it does for the un-awaited spelling. capture_groups_fp 2465 (unchanged). Prior 05d1dadd6c9ef35c74079fa50f341b1b36e4fb02c9a89dd1b59f32b7cfd5e633 -> fed04ed1d5db112387781e405da208ae6b3ab803889773b0455be96f01b893ff.", - "_rebaselined_3354_callable_alternatives": "#3354 callable alternatives: a callable chosen by a value-selecting source now flows every branch it can yield (`a ?? b`, `a || b`, `a or b`, `c ? a : b`, statement `if`, elvis), and an operator branch (`x == f || g`) stays opaque instead of seeding a qualified name. Verified by running the BASE (merge-base 233ca2849) and HEAD emitters over the SAME HEAD fixture corpus: every added or removed match is an `@callable-flow.*` match on one of those sources, and the pre-existing corpus is byte-identical (all other languages: zero delta). The rest of the drift is corpus growth from this PR's regression fixture, which this bench globs. Corpus growth: typescript-callable-alternatives/{index,compare,worker,sweep/index}.ts (+4 files, +288 groups). Emitter delta: +18 / -14: `??` / `||` / `&&` / ternary seeds and copies now name each operand (`&&` its right operand only); the 14 removed matches are the whole-expression seeds and copies carrying a qualified name of the compound source, including the comparison branches in compare.ts that now stay opaque. capture_groups_fp 2465 -> 2753, fixture_count 167 -> 171; synthetic counts unchanged; scaling 0.963 < 1.5. Prior 60e75bbe846f7200005f12c4d95c32e82d9d3feae9f2fd594e484670762b82b0 -> 77c9b4ea654123a64972db8348190ec250b669472b150db65ea8c7f20b355467." + "_rebaselined_3354_callable_alternatives": "#3354 callable alternatives: a callable chosen by a value-selecting source now flows every branch it can yield (`a ?? b`, `a || b`, `a or b`, `c ? a : b`, statement `if`, elvis), and an operator branch (`x == f || g`) stays opaque instead of seeding a qualified name. Verified by running the BASE (merge-base 233ca2849) and HEAD emitters over the SAME HEAD fixture corpus: every added or removed match is an `@callable-flow.*` match on one of those sources, and the pre-existing corpus is byte-identical (all other languages: zero delta). The rest of the drift is corpus growth from this PR's regression fixture, which this bench globs. Corpus growth: typescript-callable-alternatives/{index,compare,worker,sweep/index}.ts (+4 files, +288 groups). Emitter delta: +18 / -14: `??` / `||` / `&&` / ternary seeds and copies now name each operand (`&&` its right operand only); the 14 removed matches are the whole-expression seeds and copies carrying a qualified name of the compound source, including the comparison branches in compare.ts that now stay opaque. capture_groups_fp 2465 -> 2753, fixture_count 167 -> 171; synthetic counts unchanged; scaling 0.963 < 1.5. Prior 60e75bbe846f7200005f12c4d95c32e82d9d3feae9f2fd594e484670762b82b0 -> 77c9b4ea654123a64972db8348190ec250b669472b150db65ea8c7f20b355467.", + "_rebaselined_3486_semantic_rename": "#3486: Compared actual origin/main (191a954b), committed dd3bf and frozen provider source over the same unchanged corpus. 2 existing member-read matches gain receiver-chain tags; removing only these added tags restores the complete main capture set. No group is added or removed. Fixture entries 174; changed entries 2; capture groups 2935 -> 2935. Fingerprint regenerated from the runner's canonicalized capture records. Scaling budgets unchanged." }, "javascript": { "_lexical_ownership_3532": "#3532: lexical ownership captures and fixture updates; fixture identities are relative to lang-resolution so serialized scope IDs are independent of checkout location. Full capture content remains hashed; scaling_budget is unchanged.", @@ -247,8 +260,9 @@ "_rebaselined_blind_spots_2856": "#2856 blind-spots series: the JS/TS SCOPE queries gained capture rules, so fingerprint drift is expected and additive. Verified before re-baselining by diffing the capture-name sets in both scope queries against origin/main: TypeScript gained exactly @reference.read.identifier (A2 bare-identifier reads in value positions) and @reference.type (R2-2 type references, so a declared contract stops reporting incoming:{}); JavaScript gained exactly @reference.read.identifier, @reference.read.destructured (R2-1c) and @reference.write.property-key (R2-1b record-construction writes). NOTHING was removed on either side \u2014 the delta is a pure superset, which is the check that no existing capture moved. capture_groups_small/large are unchanged (4503/14403) because those measure the SYNTHETIC scaling source, which this branch does not touch; only the fixture-corpus count moves. capture_groups_fp 2097 -> 2338 and fixture_count 146 -> 151 from 21 new lang-resolution fixtures. Scaling stayed linear and inside budget: typescript 1.116 < 1.5, javascript 1.010 < 1.5. Prior typescript ed92588e0fc7b28b3a0174339ac378b4dd85965fe007db1208dea97a65ce0571 -> f66a3e6f1e096431e7046505129a627deaa00ca0de5bc846b080591b397248f7; prior javascript 806f70ad3cce5fc849f6d06a08ace8a95f92a1ea84a2418fddabb1eef5846594 -> 2026993b81b873839dd2ef8797d9c14d9c48516b2b57b05ac17d8d43f2f4eba3." }, "kotlin": { - "fingerprint": "f3fefe058484823761b52df5715f41d65fa4ff0223843bcc86893d7b0a6b3de2", + "fingerprint": "026392f9a60d9808dd993fa6d26f3a2ca6240ed6ac63981d614058ae7acd29e2", "scaling_budget": 1.5, + "_rebaselined_3486_bodyless_jvm_accessors": "#3486 validation repair: synthetic JVM accessors have no lexical body; nested getter/setter scopes hid their class bindings. A/B over the same 142 benchmark entries removes exactly 98 standalone @scope.function groups across 27 entries (40 in the 20-entity DAO, 58 in fixtures), with zero additions and every declaration capture, text and range byte-identical. Actual fingerprint groups 2634 -> 2536; single-emission scaling-size counts 5753/18403 -> 5253/16803. Java, java-local-types and Swift fingerprints are unchanged. Existing count metadata is synchronized with the current corpus. Scaling budgets unchanged.", "_rebaselined_interface_abstract_2885": "#2885: Kotlin interface property accessors stay in the capture set (groups still 5753/18403 and capture_groups_fp 2563) but Method isAbstract is now true for body-less interface properties, which changes accessor-plan identity in the fixture digest. Prior 82ae5e1f750580383344d4c84c400a290474528cd502be4af8cd56705819a683 -> aeafc7a87402c933786ef582b7c98683b1822b78fa909e605cb97552867fa0d5; CI scaling 0.838 < 1.5.", "_rebaselined_jvm_property_accessors_2885": "#2885: Kotlin val/var properties now emit JVM getter/setter scope and declaration captures, including data-class constructor properties and custom accessors. Synthetic scaling counts move 4753/15203 -> 5753/18403; fixture-corpus groups move 2367 -> 2563. Accessor declaration sidecars use the canonical @declaration.qualified_name key, preserve same-name owner identity, follow JvmAbi is-prefix naming, and suppress @JvmName-renamed accessors until their custom names are modeled. Prior f98e7e936afbce0e99588285cfc603bf945fd58c5de45271860509a5d90eb832 -> 82ae5e1f750580383344d4c84c400a290474528cd502be4af8cd56705819a683; scaling 0.869 < 1.5.", "_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior bddba25d5a88152bbbee8d70e82c944b5302accb4b625df782adb1d4f7a7ac12 -> e856951c2a779163d555dadc8e1bf59304a86caed78ac1f450d9caa2b50f63d1; scaling 1.090 < 1.5.", @@ -263,14 +277,15 @@ "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior d3c4d2fa0d82d248a2299cfc888b067187ad1faf2c87a97f93c6ed835eefc3f1 -> c1f0cc9058ab11b7cd6fc8b440deb6db2b2f530f2eb21178923e68a3d0796c4b.", "_rebaselined_2766_await_subscript_emission": "#2766: extractMixedChain now walks THROUGH await and subscript nodes and peels transparent wrappers at loop entry, so sites whose receiver is `repos[0]` or `(await f())` mint a receiver chain where they previously minted none. EMISSION CHANGE: more sites carry `@reference.receiver-chain`; no existing chain changed shape. Only go and kotlin drifted of 15 \u2014 the two whose fixture corpora contain such receivers. Prior c1f0cc9058ab11b7cd6fc8b440deb6db2b2f530f2eb21178923e68a3d0796c4b -> efd5dbf80ffcd3bab2834d1010f6fe2b239dcc5d58229938dea9cff8d0f380f2.", "_rebaselined_2960_declared_package_fixture": "#2960 adds four Kotlin declared-package import-resolution fixture files. This is fixture-corpus growth only: fixture_count 137 -> 141 and capture_groups_fp 2334 -> 2367; the synthetic capture counts remain 4753/15203, no Kotlin scope-capture query or implementation changed, and package resolution runs after capture. Other language fingerprints matched their baselines in the same CI run. Prior a184f8ff0ae40d246db855b63f7ff26bda3afac03e5f4c76e4593c7e2cefce54 -> f98e7e936afbce0e99588285cfc603bf945fd58c5de45271860509a5d90eb832.", - "capture_groups_small": 5753, - "capture_groups_large": 18403, - "capture_groups_fp": 2563, - "fixture_count": 141, + "capture_groups_small": 5253, + "capture_groups_large": 16803, + "capture_groups_fp": 2536, + "fixture_count": 142, "_rebaselined_1432_member_call_callee_name": "#1432 (Zig): the shared callable-flow reader no longer names a callee by simple name for a MEMBER call (`@callable-flow.direct-callee-name` requires a direct designator: `f(x)`, `ns.f(x)`), and a member call is a field-stored-callable invoke only when a MEMBER store (`o.f = handler`) or a declared callable-typed field is visible - a same-named plain binding no longer gates it. CAPTURE-EMISSION CHANGE, not fixture growth (fixture_count unchanged). Only drift: `users.map { it.name }.forEach { name -> println(name) }` (kotlin-lambda-scopes/App.kt) loses `direct-callee-name|forEach` (member call). capture_groups_fp 2334 (unchanged). Prior a184f8ff0ae40d246db855b63f7ff26bda3afac03e5f4c76e4593c7e2cefce54 -> 5a181af0dbc9451937da0964c40d3f3f9820914ca429d873bb5c812b5e2b9284.", "_rebaselined_1432_rebase_onto_2960": "#1432 rebase onto main @ aac7515d: the kotlin fingerprint is a COMBINATION of two independent changes, so neither side of the merge conflict was correct on its own and resolving it by picking a side would have committed a fingerprint no run can reproduce. main's #2960 added four declared-package fixture files (fixture_count 137 -> 141, capture_groups_fp 2334 -> 2367); this branch's `_rebaselined_1432_member_call_callee_name` drops `direct-callee-name|forEach` from one member call. Recomputed under both: capture_groups_fp 2367 and fixture_count 141 match main's committed counts EXACTLY (this branch's change is emission-only and moves no count), capture_groups_small/large stay 4753/15203 (the SYNTHETIC scaling source, which neither change touches), scaling 1.003 < 1.5, and the other 14 languages report ok against their committed baselines in the same run - which is the check that the rebase replayed nothing else into the capture stream. Attribution is exact rather than inferred: moving test/fixtures/lang-resolution/kotlin-import-package-evidence aside and re-running returns kotlin to 5a181af0dbc9451937da0964c40d3f3f9820914ca429d873bb5c812b5e2b9284 byte-for-byte with fixture_count back at 137 and capture_groups_fp back at 2334 - this branch's pre-rebase value - so the whole delta is #2960's corpus growth layered on top, with nothing else moving. Prior (this branch, pre-rebase) 5a181af0dbc9451937da0964c40d3f3f9820914ca429d873bb5c812b5e2b9284 and (main) f98e7e936afbce0e99588285cfc603bf945fd58c5de45271860509a5d90eb832 -> 973d702510002dda76166e017c5eca90cae37a139f5a512877b7a1b04ad19dc5.", "_rebaselined_1432_merge_main_2885": "#1432 merge of main @ 212e007a: the kotlin fingerprint is again a COMBINATION of two independent changes \u2014 main's #2885 JVM property accessors / interface-abstract (4753/15203 -> 5753/18403, capture_groups_fp 2367 -> 2563) and this branch's `_rebaselined_1432_member_call_callee_name` (drops `direct-callee-name|forEach` from one member call). Neither side's value reproduces under the merged tree. Recomputed under both: counts 5753/18403/2563 and fixture_count 141 match main's committed counts EXACTLY (this branch's change is emission-only), scaling 1.061 < 1.5, and csharp / cpp / typescript measure byte-for-byte at this branch's committed values (main did not touch them since the merge-base) while the other 11 languages report ok \u2014 the check that the merge replayed nothing else into the capture stream. Prior (main) aeafc7a87402c933786ef582b7c98683b1822b78fa909e605cb97552867fa0d5 and (this branch) 973d702510002dda76166e017c5eca90cae37a139f5a512877b7a1b04ad19dc5 -> a9d3f0db7547ff47856159debf15d2a6f427efca97a6af27b4004174ed432132.", - "_rebaselined_3354_callable_alternatives": "#3354 callable alternatives: a callable chosen by a value-selecting source now flows every branch it can yield (`a ?? b`, `a || b`, `a or b`, `c ? a : b`, statement `if`, elvis), and an operator branch (`x == f || g`) stays opaque instead of seeding a qualified name. Verified by running the BASE (merge-base 233ca2849) and HEAD emitters over the SAME HEAD fixture corpus: every added or removed match is an `@callable-flow.*` match on one of those sources, and the pre-existing corpus is byte-identical (all other languages: zero delta). The rest of the drift is corpus growth from this PR's regression fixture, which this bench globs. Corpus growth: kotlin-callable-alternatives/App.kt (+1 file, +71 groups). Emitter delta: +8 / -2: elvis and `if` expression (bare and braced) seeds and copies now name each operand; the 2 removed matches are the whole-expression seed and copy carrying a qualified name of the compound source; the multi-statement `if` stays opaque. capture_groups_fp 2563 -> 2634, fixture_count 141 -> 142; synthetic counts unchanged; scaling 1.17 < 1.5. Prior a9d3f0db7547ff47856159debf15d2a6f427efca97a6af27b4004174ed432132 -> f3fefe058484823761b52df5715f41d65fa4ff0223843bcc86893d7b0a6b3de2." + "_rebaselined_3354_callable_alternatives": "#3354 callable alternatives: a callable chosen by a value-selecting source now flows every branch it can yield (`a ?? b`, `a || b`, `a or b`, `c ? a : b`, statement `if`, elvis), and an operator branch (`x == f || g`) stays opaque instead of seeding a qualified name. Verified by running the BASE (merge-base 233ca2849) and HEAD emitters over the SAME HEAD fixture corpus: every added or removed match is an `@callable-flow.*` match on one of those sources, and the pre-existing corpus is byte-identical (all other languages: zero delta). The rest of the drift is corpus growth from this PR's regression fixture, which this bench globs. Corpus growth: kotlin-callable-alternatives/App.kt (+1 file, +71 groups). Emitter delta: +8 / -2: elvis and `if` expression (bare and braced) seeds and copies now name each operand; the 2 removed matches are the whole-expression seed and copy carrying a qualified name of the compound source; the multi-statement `if` stays opaque. capture_groups_fp 2563 -> 2634, fixture_count 141 -> 142; synthetic counts unchanged; scaling 1.17 < 1.5. Prior a9d3f0db7547ff47856159debf15d2a6f427efca97a6af27b4004174ed432132 -> f3fefe058484823761b52df5715f41d65fa4ff0223843bcc86893d7b0a6b3de2.", + "_rebaselined_3486_semantic_rename": "#3486: Compared actual origin/main (191a954b), committed dd3bf and frozen provider source over the same unchanged corpus. 1 existing member-read match gains a receiver-chain tag; removing only that added tag restores the complete main capture set. No group is added or removed. Fixture entries 142; changed entries 1; capture groups 2634 -> 2634. Fingerprint regenerated from the runner's canonicalized capture records. Scaling budgets unchanged." }, "typescript-deep-chain": { "_lexical_ownership_3532": "#3532: lexical ownership captures and fixture updates; fixture identities are relative to lang-resolution so serialized scope IDs are independent of checkout location. Full capture content remains hashed; scaling_budget is unchanged.", diff --git a/gitnexus/bench/tool-accuracy/README.md b/gitnexus/bench/tool-accuracy/README.md index d91e11232..8fc8eb947 100644 --- a/gitnexus/bench/tool-accuracy/README.md +++ b/gitnexus/bench/tool-accuracy/README.md @@ -30,7 +30,7 @@ errors always exit nonzero. `--check` additionally enforces the regression gate. | Fixture / checks | Public repro | Fixed-answer contract | | -------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `rename/` | [#3486](https://github.com/abhigyanpatwari/GitNexus/issues/3486) | All three Writer.close references; zero edits to comments, strings or unrelated homonyms. | +| `rename/` | [#3486](https://github.com/abhigyanpatwari/GitNexus/issues/3486) | All three Writer.close references; zero edits to comments, strings or unrelated homonyms; typed DeferredWriter.flush parameters refuse without edits or source changes. | | `src/server/`, `test/stub.test.ts` | [#3487](https://github.com/abhigyanpatwari/GitNexus/issues/3487) | Server beats a same-path test stub; `app.all` has method `*`; Map.get is absent; helper-registered literal route is present. | | `src/web/` | [#3488](https://github.com/abhigyanpatwari/GitNexus/issues/3488) | Both the base-URL template passed to a configured wrapper and a direct literal fetch link their consumer files. | | `src/security.ts`, `src/server/inline.ts` | [#3489](https://github.com/abhigyanpatwari/GitNexus/issues/3489), [#3490](https://github.com/abhigyanpatwari/GitNexus/issues/3490), [#3491](https://github.com/abhigyanpatwari/GitNexus/issues/3491) | Named and inline unsafe handlers reach the sink; guarded and constant commands have zero taint; statement and function-hop lines are 1-based. | @@ -45,6 +45,18 @@ the cap/consumer contract without requiring 501 parser fixtures. It does not claim parser coverage for those synthetic records. Python checks read actual `context.outgoing.calls` symbol UIDs, preserving lexical/class ownership. +Rename's positive fixtures construct each `Writer` receiver directly, including +the imported class. A separate `DeferredWriter.flush` fixture exercises a typed +parameter: a TypeScript annotation alone cannot prove which structurally compatible +runtime object owns the method. The public `rename` tool (`dry_run: true`) must refuse it with +`planning_status: "unsupported"` and `code: "ambiguous_reference"`, zero edits, +and unchanged source files. This safety check has no known-gap allowance. +Fixture line anchors are updated together; the three exact semantic answers and +the same-named method/function, comment and string controls remain required. +The runner validates v2 semantic provenance, exact UTF-16 spans and before/after +source lines, then scores the whole `after` line. Semantic rename does not use +ripgrep or text-search edits. + ## Known gaps and repairs The initial source baseline is diff --git a/gitnexus/bench/tool-accuracy/expectations.ts b/gitnexus/bench/tool-accuracy/expectations.ts index c6dbe5248..7f7a27db8 100644 --- a/gitnexus/bench/tool-accuracy/expectations.ts +++ b/gitnexus/bench/tool-accuracy/expectations.ts @@ -6,14 +6,24 @@ */ export const FIXTURE_ANCHORS = { writerCloseDeclaration: { file: 'rename/writer.ts', line: 2, text: 'close(): void {}' }, - writerCloseCall: { file: 'rename/writer.ts', line: 8, text: 'writer.close();' }, - callerCloseCall: { file: 'rename/caller.ts', line: 3, text: 'writer.close();' }, + writerCloseCall: { file: 'rename/writer.ts', line: 9, text: 'writer.close();' }, + callerCloseCall: { file: 'rename/caller.ts', line: 4, text: 'writer.close();' }, writerComment: { file: 'rename/writer.ts', - line: 10, + line: 11, text: '// close is a comment, not a reference.', }, - writerString: { file: 'rename/writer.ts', line: 11, text: "export const title = 'close';" }, + writerString: { file: 'rename/writer.ts', line: 12, text: "export const title = 'close';" }, + typedWriterFlushDeclaration: { + file: 'rename/typed-writer.ts', + line: 2, + text: 'flush(): void {}', + }, + typedWriterFlushCall: { + file: 'rename/typed-writer.ts', + line: 5, + text: 'writer.flush();', + }, runGit: { file: 'src/security.ts', line: 4, @@ -80,6 +90,16 @@ export const EXPECTATIONS = [ title: 'Unrelated methods and functions receive no edits', expected: [], }, + { + id: 'rename.typed-parameter-refusal', + issue: 3486, + title: 'A structural parameter receiver blocks rename without edits or source changes', + expected: [ + 'status=error planning=unsupported application=not_started code=ambiguous_reference', + 'edits=0 semantic=0 graph=0 text_search=0 files=0 applied=false changes=0', + 'sources=unchanged', + ], + }, { id: 'routes.production-handler', issue: 3487, diff --git a/gitnexus/bench/tool-accuracy/fixtures/rename/caller.ts b/gitnexus/bench/tool-accuracy/fixtures/rename/caller.ts index 321d3e926..18dc38018 100644 --- a/gitnexus/bench/tool-accuracy/fixtures/rename/caller.ts +++ b/gitnexus/bench/tool-accuracy/fixtures/rename/caller.ts @@ -1,4 +1,5 @@ import { Writer } from './writer'; -export function useImported(writer: Writer): void { +export function useImported(): void { + const writer = new Writer(); writer.close(); } diff --git a/gitnexus/bench/tool-accuracy/fixtures/rename/typed-writer.ts b/gitnexus/bench/tool-accuracy/fixtures/rename/typed-writer.ts new file mode 100644 index 000000000..62f52db44 --- /dev/null +++ b/gitnexus/bench/tool-accuracy/fixtures/rename/typed-writer.ts @@ -0,0 +1,6 @@ +export class DeferredWriter { + flush(): void {} +} +export function flushDeferred(writer: DeferredWriter): void { + writer.flush(); +} diff --git a/gitnexus/bench/tool-accuracy/fixtures/rename/writer.ts b/gitnexus/bench/tool-accuracy/fixtures/rename/writer.ts index 66c80fc59..1f4913c3f 100644 --- a/gitnexus/bench/tool-accuracy/fixtures/rename/writer.ts +++ b/gitnexus/bench/tool-accuracy/fixtures/rename/writer.ts @@ -4,7 +4,8 @@ export class Writer { export class Other { close(): void {} } -export function useWriter(writer: Writer): void { +export function useWriter(): void { + const writer = new Writer(); writer.close(); } // close is a comment, not a reference. diff --git a/gitnexus/bench/tool-accuracy/known-gaps.json b/gitnexus/bench/tool-accuracy/known-gaps.json index fa0c72f60..e2371924c 100644 --- a/gitnexus/bench/tool-accuracy/known-gaps.json +++ b/gitnexus/bench/tool-accuracy/known-gaps.json @@ -2,24 +2,6 @@ "schemaVersion": 1, "baselineSourceSha": "dd096e690c06e5d54ab3595c63b83f15e04b6191", "gaps": [ - { - "id": "rename.comments-strings", - "issue": 3486, - "reason": "Whole-file word replacement rewrites comments and strings (#3486).", - "baselineActual": [ - "rename/writer.ts:10:// closeWriter is a comment, not a reference.", - "rename/writer.ts:11:export const title = 'closeWriter';" - ] - }, - { - "id": "rename.homonyms", - "issue": 3486, - "reason": "Whole-file word replacement rewrites unrelated same-named symbols (#3486).", - "baselineActual": [ - "rename/homonym.ts:1:export function closeWriter(): string {", - "rename/writer.ts:5:closeWriter(): void {}" - ] - }, { "id": "routes.helper-registration", "issue": 3487, diff --git a/gitnexus/bench/tool-accuracy/run.ts b/gitnexus/bench/tool-accuracy/run.ts index aebb4144f..dd8dd9e44 100644 --- a/gitnexus/bench/tool-accuracy/run.ts +++ b/gitnexus/bench/tool-accuracy/run.ts @@ -82,27 +82,6 @@ async function fixtureDigest( return relative ? '' : digest.digest('hex'); } -// rename's text-search pass shells out to ripgrep and degrades silently when it is missing, -// which would change what the corpus measures. Fail closed before indexing instead. -const externalTools = Object.fromEntries( - ['rg'].map((tool) => { - try { - const version = execFileSync(tool, ['--version'], { - encoding: 'utf8', - timeout: 10_000, - stdio: ['ignore', 'pipe', 'ignore'], - }) - .split('\n')[0] - .trim(); - return [tool, version]; - } catch (error) { - throw new Error( - `Required external tool "${tool}" is unavailable (${error instanceof Error ? error.message : String(error)}); the tool-accuracy corpus is only valid when it is installed`, - ); - } - }), -); - const source = { sha: execFileSync('git', ['rev-parse', 'HEAD'], { cwd: CHECKOUT, encoding: 'utf8' }).trim(), dirty: !!execFileSync('git', ['status', '--porcelain', '--untracked-files=no'], { @@ -110,7 +89,6 @@ const source = { encoding: 'utf8', }).trim(), fixtureSha: await fixtureDigest(path.join(HERE, 'fixtures')), - externalTools, }; const manifest: KnownGapManifest = JSON.parse(await fs.readFile(knownGaps, 'utf8')); const temp = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-tool-accuracy-')); @@ -249,30 +227,84 @@ try { dry_run: true, }); need( - result.status === 'success' && result.applied === false && Array.isArray(result.changes), + result.result_version === 2 && + result.status === 'success' && + result.planning_status === 'ready' && + result.application_status === 'not_requested' && + result.applied === false && + result.old_name === 'close' && + result.new_name === 'closeWriter' && + result.text_search === 'not_used' && + result.coverage?.name === 'gitnexus-semantic' && + result.coverage.scope === 'indexed-repository' && + Array.isArray(result.changes), 'Malformed rename preview', ); const edits: Array<{ file: string; line: number; text: string }> = []; + const files = new Set(); for (const change of result.changes) { need( - typeof change.file_path === 'string' && Array.isArray(change.edits), + typeof change.file_path === 'string' && + change.file_path.length > 0 && + !files.has(change.file_path) && + Array.isArray(change.edits) && + change.edits.length > 0, 'Malformed rename file edits', ); + const absolute = path.resolve(repoPath, change.file_path); + const relative = path.relative(repoPath, absolute); + need( + relative && + relative !== '..' && + !relative.startsWith(`..${path.sep}`) && + !path.isAbsolute(relative), + 'Rename edit outside fixture repository', + ); + files.add(change.file_path); + const snapshot = await fs.readFile(absolute, 'utf8'); + let previousEnd = 0; for (const edit of change.edits) { need( - integer(edit.line, 1) && - typeof edit.old_text === 'string' && - typeof edit.new_text === 'string' && - ['graph', 'text_search'].includes(edit.confidence), + Number.isSafeInteger(edit.line) && + edit.line >= 1 && + Number.isSafeInteger(edit.start) && + edit.start >= previousEnd && + Number.isSafeInteger(edit.length) && + edit.length > 0 && + edit.start + edit.length <= snapshot.length && + edit.old_text === result.old_name && + edit.new_text === result.new_name && + edit.confidence === 'semantic' && + typeof edit.before === 'string' && + typeof edit.after === 'string', 'Malformed rename edit', ); - edits.push({ file: change.file_path, line: edit.line, text: edit.new_text }); + const lineStart = snapshot.lastIndexOf('\n', edit.start - 1) + 1; + const nextLine = snapshot.indexOf('\n', edit.start); + const lineEnd = nextLine < 0 ? snapshot.length : nextLine; + need( + snapshot.slice(edit.start, edit.start + edit.length) === edit.old_text && + edit.start + edit.length <= lineEnd && + edit.line === snapshot.slice(0, edit.start).split('\n').length && + edit.before === snapshot.slice(lineStart, lineEnd) && + edit.after === + snapshot.slice(lineStart, edit.start) + + edit.new_text + + snapshot.slice(edit.start + edit.length, lineEnd), + 'Inconsistent rename span or line context', + ); + previousEnd = edit.start + edit.length; + // v2 replacement text is an identifier; fixed answers retain whole source lines. + edits.push({ file: change.file_path, line: edit.line, text: edit.after.trim() }); } } need( integer(result.total_edits) && + integer(result.semantic_edits) && result.total_edits === edits.length && - result.graph_edits + result.text_search_edits === edits.length && + result.semantic_edits === edits.length && + result.graph_edits === 0 && + result.text_search_edits === 0 && result.files_affected === result.changes.length, 'Inconsistent rename totals', ); @@ -295,6 +327,43 @@ try { .map(key); }); + await capture('rename typed-parameter refusal', async () => { + const anchor = FIXTURE_ANCHORS.typedWriterFlushDeclaration; + const target = [...pipeline.graph.iterNodes()].find( + (node) => + node.label === 'Method' && + node.properties.filePath === anchor.file && + node.properties.startLine === anchor.line - 1, + ); + need(target, 'DeferredWriter.flush symbol missing'); + const before = await fixtureDigest(repoPath); + const params = { symbol_uid: target!.id, new_name: 'flushPending', dry_run: true }; + // Refusal is the expected public response; call() rejects error results. + const result = await backend!.callTool('rename', { ...params, repo }); + toolOutputs.push({ tool: 'rename', params, result }); + need( + result && + result.result_version === 2 && + typeof result.status === 'string' && + typeof result.planning_status === 'string' && + typeof result.application_status === 'string' && + typeof result.code === 'string' && + typeof result.applied === 'boolean' && + integer(result.total_edits) && + integer(result.semantic_edits) && + integer(result.graph_edits) && + integer(result.text_search_edits) && + integer(result.files_affected) && + Array.isArray(result.changes), + 'Malformed rename refusal', + ); + observations['rename.typed-parameter-refusal'] = [ + `status=${result.status} planning=${result.planning_status} application=${result.application_status} code=${result.code}`, + `edits=${result.total_edits} semantic=${result.semantic_edits} graph=${result.graph_edits} text_search=${result.text_search_edits} files=${result.files_affected} applied=${result.applied} changes=${result.changes.length}`, + `sources=${(await fixtureDigest(repoPath)) === before ? 'unchanged' : 'changed'}`, + ]; + }); + await capture('routes', async () => { const result = await call('route_map'); need( diff --git a/gitnexus/hooks/claude/session-start.sh b/gitnexus/hooks/claude/session-start.sh index 8960dd376..f7b29cca3 100755 --- a/gitnexus/hooks/claude/session-start.sh +++ b/gitnexus/hooks/claude/session-start.sh @@ -30,7 +30,7 @@ This codebase is indexed by GitNexus, providing a knowledge graph with execution - `context` — 360-degree symbol view (categorized refs, process participation) - `impact` — Blast radius analysis (what breaks if you change a symbol) - `detect_changes` — Git-diff impact analysis (what do your changes affect) -- `rename` — Multi-file coordinated rename with confidence tags +- `rename` — Semantic rename: previews by default (`dry_run` omitted or `true`); apply reviewed edits with `dry_run: false` and inspect application status and failures - `cypher` — Raw graph queries - `list_repos` — Discover indexed repos diff --git a/gitnexus/scripts/cross-platform-shard.ts b/gitnexus/scripts/cross-platform-shard.ts index 9e8f2d8e2..234881392 100644 --- a/gitnexus/scripts/cross-platform-shard.ts +++ b/gitnexus/scripts/cross-platform-shard.ts @@ -44,6 +44,27 @@ * partition depend on the very machine load it is trying to protect against. */ export const WINDOWS_WEIGHTS_SEC: Readonly> = { + // Conservative estimates from Windows run 38144297115: persisted + // rename cases include native indexing and take up to ~25s each. Keep + // each language together while charging its complete fixture inventory. + 'test/integration/rename-provider-c.test.ts': 175, + 'test/integration/rename-provider-cobol.test.ts': 75, + 'test/integration/rename-provider-cpp.test.ts': 250, + 'test/integration/rename-provider-csharp.test.ts': 200, + 'test/integration/rename-provider-dart.test.ts': 425, + 'test/integration/rename-provider-go.test.ts': 125, + 'test/integration/rename-provider-java.test.ts': 300, + 'test/integration/rename-provider-javascript.test.ts': 225, + 'test/integration/rename-provider-kotlin.test.ts': 300, + 'test/integration/rename-provider-objective-c.test.ts': 150, + 'test/integration/rename-provider-php.test.ts': 100, + 'test/integration/rename-provider-python.test.ts': 375, + 'test/integration/rename-provider-ruby.test.ts': 100, + 'test/integration/rename-provider-rust.test.ts': 75, + 'test/integration/rename-provider-swift.test.ts': 200, + 'test/integration/rename-provider-typescript.test.ts': 625, + 'test/integration/rename-provider-vue.test.ts': 200, + 'test/integration/rename-provider-zig.test.ts': 75, // Re-measured after the analyze --watch e2e landed in #3072. The previous // 361 s entry undercharged this suite and left shard 1 close to the watchdog. 'test/integration/cli-e2e.test.ts': 621, diff --git a/gitnexus/scripts/cross-platform-tests.ts b/gitnexus/scripts/cross-platform-tests.ts index e7dc5dc6e..f04008b33 100644 --- a/gitnexus/scripts/cross-platform-tests.ts +++ b/gitnexus/scripts/cross-platform-tests.ts @@ -157,6 +157,26 @@ const PLATFORM_LOGIC = [ // N-API addon which has known platform-specific behavior (Windows // file-lock lag after close, macOS N-API destructor segfaults) const LBUG_NATIVE = [ + 'test/integration/rename-provider-matrix.test.ts', + 'test/integration/rename-provider-c.test.ts', + 'test/integration/rename-provider-cobol.test.ts', + 'test/integration/rename-provider-cpp.test.ts', + 'test/integration/rename-provider-csharp.test.ts', + 'test/integration/rename-provider-dart.test.ts', + 'test/integration/rename-provider-go.test.ts', + 'test/integration/rename-provider-java.test.ts', + 'test/integration/rename-provider-javascript.test.ts', + 'test/integration/rename-provider-kotlin.test.ts', + 'test/integration/rename-provider-objective-c.test.ts', + 'test/integration/rename-provider-php.test.ts', + 'test/integration/rename-provider-python.test.ts', + 'test/integration/rename-provider-ruby.test.ts', + 'test/integration/rename-provider-rust.test.ts', + 'test/integration/rename-provider-swift.test.ts', + 'test/integration/rename-provider-typescript.test.ts', + 'test/integration/rename-provider-vue.test.ts', + 'test/integration/rename-provider-zig.test.ts', + 'test/integration/pdg-explicit-owner.test.ts', 'test/integration/skip-fts.test.ts', 'test/integration/lbug-core-adapter.test.ts', 'test/integration/lbug-vector-extension.test.ts', diff --git a/gitnexus/skills/gitnexus-guide.md b/gitnexus/skills/gitnexus-guide.md index 126886fd7..50ecb5a5b 100644 --- a/gitnexus/skills/gitnexus-guide.md +++ b/gitnexus/skills/gitnexus-guide.md @@ -38,7 +38,7 @@ For any task involving code understanding, debugging, impact analysis, or refact | `impact` | Symbol blast radius — what breaks at depth 1/2/3 with confidence | | `trace` | Shortest path between two symbols — "how does A reach B?" in one call | | `detect_changes` | Git-diff impact — what do your current changes affect | -| `rename` | Multi-file coordinated rename with confidence-tagged edits | +| `rename` | Semantic rename: previews by default (`dry_run` omitted/true), applies with `dry_run: false`; inspect application status and failures | | `cypher` | Raw graph queries (read `gitnexus://repo/{name}/schema` first) | | `explain` | Persisted taint findings — source→sink data flows (needs `analyze --pdg`) | | `pdg_query` | Control/data dependence — what gates X (CDG) / where Y flows (REACHING_DEF); needs `analyze --pdg` | diff --git a/gitnexus/skills/gitnexus-refactoring.md b/gitnexus/skills/gitnexus-refactoring.md index 7bcc12711..76087a4ec 100644 --- a/gitnexus/skills/gitnexus-refactoring.md +++ b/gitnexus/skills/gitnexus-refactoring.md @@ -54,9 +54,9 @@ checkout and reports nothing changed, which reads as a verified refactor. ``` - [ ] list_repos {} — bind repo; explicit repo when >1 indexed, ask if ambiguous -- [ ] rename({symbol_name: "oldName", new_name: "newName", dry_run: true}) — preview all edits +- [ ] rename({symbol_name: "oldName", new_name: "newName", dry_run: true}) — preview semantic edits (omitting dry_run also previews) - [ ] Confirm the previewed file paths are in the bound repository/worktree -- [ ] Review graph edits (high confidence) and text_search edits (review carefully) +- [ ] 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 @@ -89,15 +89,62 @@ checkout and reports nothing changed, which reads as a verified refactor. ## Tools -**rename** — automated multi-file rename: +**rename** — semantic rename; preview first, then apply: ``` rename({symbol_name: "validateUser", new_name: "authenticateUser", repo: "my-app", dry_run: true}) → 12 edits across 8 files -→ 10 graph edits (high confidence), 2 text_search edits (review) -→ Changes: [{file_path, edits: [{line, old_text, new_text, confidence}]}] +→ semantic_edits: 12, coverage: {name: "gitnexus-semantic", scope: "indexed-repository", limitations: [...]} +→ changes: [{file_path, edits: [{start, length, line, old_text, new_text, confidence: "semantic"}]}] ``` +`dry_run` omitted or `true` previews and never writes; `dry_run: false` applies. +An MCP server started with `GITNEXUS_MCP_READ_ONLY=1` still lists `rename`, but +as preview-only: it rejects any `dry_run` other than omitted or `true`, so apply +from a client that is not read-only. + +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 accepted by the selected provider, including supported embedded sources, +and excludes unindexed files and external consumers. All registered source-language +providers supply rename rules. Declaration and override families require complete +semantic evidence. Dynamic or computed references block a rename when the provider +cannot resolve them or exclude them from the selected family. Provider-specific +limits, including class renames requiring a file move, remain explicit in results. +There is no regex fallback: legacy `graph_edits` and `text_search_edits` are zero. +Comments, strings, and unrelated same-spelled symbols are not replacements. + +Preview and apply use the same planner. Results use `result_version: 2`; +`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`. A completed apply +has `status: "success"`, `application_status: "applied"`, and `applied: true`. +A successful preview has not written files. `rename` does not accept `branch`: +it inspects the current checkout. + +Recover from a blocked or incomplete rename before applying again. A failed +result carries `code` and `error`; nothing was written unless the last row applies. + +| Result | Next step | +| --- | --- | +| `stale_graph` | Re-analyze the bound repository, then preview again with `rename` (`dry_run` omitted or `true`). | +| `source_changed` | Preview again against the current source and review it. If it appears during an apply, see the `partial` row. | +| `unsupported_*` | Stop automated rename and inspect the reported limitation. Use a language-aware manual refactor if needed. | +| Ambiguous target (`status: "ambiguous"` with candidates) | Inspect the returned candidates or `context`, then preview with the chosen `symbol_uid`. | +| `ambiguous_reference`, `incomplete_semantics` | The planner could not prove a reference, import, or declaration is covered. Stop automated rename; resolve the named location by hand or pick a different symbol. | +| `name_collision` | `new_name` would collide with, or change the identity of, another declaration or binding. Choose a different name and preview again. | +| `invalid_name` | `new_name` is empty, equals the current name, or is not valid in the target language. Choose a valid, different identifier. | +| `invalid_argument` | `dry_run` was sent but is not a boolean. Omit it or send `true` or `false`. | +| `no_locations` | The planner produced no edits. Do not apply; re-check the target with `context` and pass `symbol_uid`. | +| `overlapping_edits`, `invalid_edit` | The planned spans conflict or do not match the source text. The plan is rejected before any write; do not apply. Refactor manually or report the case. | +| `duplicate_file`, `missing_snapshot`, `outside_repository` | A planner safety check failed (two paths resolve to one real file, a file has no source snapshot, or a path leaves the repository). Re-analyze and preview again; if it repeats, stop and report it. | +| `source-unavailable` (`reason: "content-retention"` or `"checkout-missing"`) | The index was built without full source retention (`GITNEXUS_CONTENT_RETENTION`), or the checkout directory is missing. Re-analyze with full retention, or run against the checkout. | +| `rename_failed` | An unexpected error while planning, before any write (`application_status: "not_started"`). Re-analyze and preview once more; if it repeats, stop and report the `error` text. | +| `application_status: "partial"` or `"failed"` (`write_failed`, or `source_changed` mid-apply) | Writes are not transactional. Inspect landed `changes` (already written, with their counts), `failed_files` (the one file whose write or validation failed), and `unattempted_files` (later files never touched). Reconcile the current files, re-index, and review a fresh preview; never blindly retry the apply call. | + +Run the project validation suite after applying. + **impact** — map all dependents first: ``` @@ -147,10 +194,10 @@ RETURN caller.name, caller.filePath ORDER BY caller.filePath → total: 2 (my-app, billing-api) — both define validateUser, so bind explicitly 1. rename({symbol_name: "validateUser", new_name: "authenticateUser", repo: "my-app", dry_run: true}) - → 12 edits: 10 graph (safe), 2 text_search (review) - → Files: validator.ts, login.ts, middleware.ts, config.json... + → 12 semantic occurrences in the indexed repository's supported provider scope + → Files: validator.ts, login.ts, middleware.ts... -2. Review text_search edits (config.json: dynamic reference!) +2. Review semantic spans and provider coverage; inspect dynamic references separately 3. rename({symbol_name: "validateUser", new_name: "authenticateUser", repo: "my-app", dry_run: false}) → Applied 12 edits across 8 files diff --git a/gitnexus/src/cli/ai-context.ts b/gitnexus/src/cli/ai-context.ts index 89bf912f0..f6f8cedb4 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\` which understands the call graph. +- NEVER rename symbols with find-and-replace — use \`rename\` with \`dry_run: true\` first, review the graph-backed semantic spans and coverage, then apply with \`dry_run: false\`. - NEVER commit before MCP/CLI graph change analysis. ## Resources diff --git a/gitnexus/src/core/analyzer-identity.ts b/gitnexus/src/core/analyzer-identity.ts index 628f27716..1a9351672 100644 --- a/gitnexus/src/core/analyzer-identity.ts +++ b/gitnexus/src/core/analyzer-identity.ts @@ -1199,7 +1199,9 @@ function collectRuntimePackages( edges: RuntimeDependencyEdge[]; } { const rootManifestPath = path.join(packageRoot, 'package.json'); - recordDirectoryGuard(directoryGuards, packageRoot); + // The package root also contains reports and temporary test output. Its + // inventory is not runtime input: the manifest, dependency resolution paths, + // build tree and vendored payloads each have their own guards. const rootRead = readManifest(rootManifestPath, options, budget, limits); const rootPackage: RuntimePackage = { root: packageRoot, @@ -1421,6 +1423,7 @@ function collectArtifacts( function collectVendoredGrammarInputs( packageRoot: string, directoryGuards: Map, + pathGuards: Map, options: AnalyzerIdentityResolveOptions, budget: RuntimeArtifactScanBudget, limits: AnalyzerIdentityTraversalLimits, @@ -1429,8 +1432,8 @@ function collectVendoredGrammarInputs( artifacts: RuntimeArtifact[]; } { const vendorRoot = path.join(packageRoot, 'vendor'); + recordDependencyPathGuard(pathGuards, vendorRoot); if (!existsSync(vendorRoot) || !lstatSync(vendorRoot).isDirectory()) { - recordDirectoryGuard(directoryGuards, packageRoot); return { manifests: [], artifacts: [] }; } @@ -1505,6 +1508,7 @@ function collectDependencyInputs( const vendored = collectVendoredGrammarInputs( packageRoot, directoryGuards, + pathGuards, options, artifactScanBudget, limits, diff --git a/gitnexus/src/core/ingestion/cfg/emit.ts b/gitnexus/src/core/ingestion/cfg/emit.ts index 456470ec7..d6396f820 100644 --- a/gitnexus/src/core/ingestion/cfg/emit.ts +++ b/gitnexus/src/core/ingestion/cfg/emit.ts @@ -179,6 +179,7 @@ export const isEmitSafeCfg = (cfg: FunctionCfg | undefined | null): cfg is Funct typeof cfg?.filePath !== 'string' || !Number.isInteger(cfg.functionStartLine) || !Number.isInteger(cfg.functionStartColumn) || + (cfg.ownerId !== undefined && (typeof cfg.ownerId !== 'string' || !cfg.ownerId.trim())) || !Array.isArray(cfg.blocks) || !Array.isArray(cfg.edges) ) { @@ -385,6 +386,7 @@ export function emitFileCfgs( // position (#2227 follow-up U3). Absent map (pdg off / no captures) // ⇒ `''`, and the bridge falls back to the leaf-name match (R3). calleeIds: calleeIdsOfBlock(b, calleeIdMap), + ...(cfg.ownerId === undefined ? {} : { ownerId: cfg.ownerId }), }, }); result.blocks++; diff --git a/gitnexus/src/core/ingestion/cfg/types.ts b/gitnexus/src/core/ingestion/cfg/types.ts index 55f95923a..abd50cc04 100644 --- a/gitnexus/src/core/ingestion/cfg/types.ts +++ b/gitnexus/src/core/ingestion/cfg/types.ts @@ -254,6 +254,9 @@ export interface CfgEdgeData { /** One function's control-flow graph. `cfgSideChannel` is `readonly FunctionCfg[]`. */ export interface FunctionCfg { readonly filePath: string; + /** Exact graph owner for executable regions that are not ordinary functions. + * Supplied by the provider; omission retains the source-anchor function join. */ + readonly ownerId?: string; /** Source span of the owning function — anchors the BasicBlock node ids. */ readonly functionStartLine: number; readonly functionEndLine: number; diff --git a/gitnexus/src/core/ingestion/cfg/visitors/c-cpp-harvest.ts b/gitnexus/src/core/ingestion/cfg/visitors/c-cpp-harvest.ts index 13e259731..adbae848c 100644 --- a/gitnexus/src/core/ingestion/cfg/visitors/c-cpp-harvest.ts +++ b/gitnexus/src/core/ingestion/cfg/visitors/c-cpp-harvest.ts @@ -95,7 +95,7 @@ export class CCppHarvester extends ScopeTreeHarvester { // ── phase 1: declaration pre-scan ──────────────────────────────────────── /** The bare identifier a declarator chain ultimately names (or undefined). */ - private declaratorName(node: SyntaxNode | null): SyntaxNode | undefined { + protected declaratorName(node: SyntaxNode | null): SyntaxNode | undefined { let cur: SyntaxNode | null = node; let hops = 12; while (cur && hops-- > 0) { @@ -119,19 +119,29 @@ export class CCppHarvester extends ScopeTreeHarvester { return undefined; } - private declareParams(fnNode: SyntaxNode): void { + protected parameterNames(fnNode: SyntaxNode): SyntaxNode[] { // function_definition: declarator → function_declarator → parameter_list. // A C++ lambda routes through abstract_function_declarator. const declarator = fnNode.childForFieldName('declarator'); const fnDeclarator = this.findFunctionDeclarator(declarator); const params = fnDeclarator?.childForFieldName('parameters'); - if (!params) return; + if (!params) return []; + const names: SyntaxNode[] = []; for (let i = 0; i < params.namedChildCount; i++) { const p = params.namedChild(i); if (p?.type !== 'parameter_declaration') continue; const name = this.declaratorName(p.childForFieldName('declarator') ?? null); - if (name) this.declare(name, 'param', this.root); + if (name) names.push(name); } + return names; + } + + private declareParams(fnNode: SyntaxNode): void { + for (const name of this.parameterNames(fnNode)) this.declare(name, 'param', this.root); + } + + protected isNestedFunction(node: SyntaxNode): boolean { + return NESTED_FUNCTION_TYPES.has(node.type) && node.id !== this.fnId; } private findFunctionDeclarator(node: SyntaxNode | null): SyntaxNode | undefined { @@ -149,7 +159,7 @@ export class CCppHarvester extends ScopeTreeHarvester { protected prescan(node: SyntaxNode, scope: Scope): void { this.nearestScopeCache.set(node.id, scope); const t = node.type; - if (NESTED_FUNCTION_TYPES.has(t) && node.id !== this.fnId) { + if (this.isNestedFunction(node)) { // A nested function/lambda body is opaque — do not descend. return; } @@ -274,17 +284,8 @@ export class CCppHarvester extends ScopeTreeHarvester { /** ENTRY-block facts for the function's parameters (defs only). */ paramFacts(): StatementFacts | undefined { - const declarator = this.fnNode.childForFieldName('declarator'); - const fnDeclarator = this.findFunctionDeclarator(declarator); - const params = fnDeclarator?.childForFieldName('parameters'); - if (!params) return undefined; const acc = new FactAccumulator(this.fnNode.startPosition.row + 1); - for (let i = 0; i < params.namedChildCount; i++) { - const p = params.namedChild(i); - if (p?.type !== 'parameter_declaration') continue; - const name = this.declaratorName(p.childForFieldName('declarator') ?? null); - if (name) this.def(name, acc); - } + for (const name of this.parameterNames(this.fnNode)) this.def(name, acc); return acc.defCount() ? acc.finish() : undefined; } @@ -315,10 +316,10 @@ export class CCppHarvester extends ScopeTreeHarvester { } /** Value-position walk: collect uses; route def positions to the lvalue handler. */ - private walkValue(node: SyntaxNode, acc: FactAccumulator): void { + protected walkValue(node: SyntaxNode, acc: FactAccumulator): void { const t = node.type; if (TYPE_CONTEXT_TYPES.has(t)) return; - if (NESTED_FUNCTION_TYPES.has(t) && node.id !== this.fnId) { + if (this.isNestedFunction(node)) { // Opaque nested function / lambda — captured reads/writes are invisible. return; } @@ -441,7 +442,7 @@ export class CCppHarvester extends ScopeTreeHarvester { * that its site should carry `resultDefs: defs` — consumed by * {@link visitCall} once the value walk reaches the node. */ - private registerResultDefs(value: SyntaxNode, defs: readonly number[]): void { + protected registerResultDefs(value: SyntaxNode, defs: readonly number[]): void { if (defs.length === 0) return; const root = this.unwrapLvalue(value); if (root.type === 'call_expression' || root.type === 'new_expression') { diff --git a/gitnexus/src/core/ingestion/cfg/visitors/c-cpp.ts b/gitnexus/src/core/ingestion/cfg/visitors/c-cpp.ts index 9dab1b91c..6284416db 100644 --- a/gitnexus/src/core/ingestion/cfg/visitors/c-cpp.ts +++ b/gitnexus/src/core/ingestion/cfg/visitors/c-cpp.ts @@ -73,7 +73,7 @@ const C_FUNCTION_TYPES = new Set(['function_definition']); const CPP_FUNCTION_TYPES = new Set(['function_definition', 'lambda_expression']); /** Statement node types that break a basic block (everything else coalesces). */ -const C_CONTROL_FLOW_TYPES = new Set([ +export const C_CONTROL_FLOW_TYPES = new Set([ 'if_statement', 'while_statement', 'do_statement', @@ -127,7 +127,7 @@ interface JumpFrame { * protected {@link visitStmt} override hook, so C++ adds its node types without * any language conditional in the C core. */ -class CCfgWalk { +export class CCfgWalk { protected readonly jumps: JumpFrame[] = []; /** Stack of exception-handler entry blocks (catch) a `throw` jumps to. */ protected readonly handlers: number[] = []; @@ -140,7 +140,10 @@ class CCfgWalk { constructor( protected readonly builder: CfgBuilder, - protected readonly harvest: CCppHarvester, + protected readonly harvest: Pick< + CCppHarvester, + 'facts' | 'factsConditional' | 'forRangeHeadFacts' | 'catchParamFacts' + >, controlFlowTypes: ReadonlySet, ) { this.controlFlowTypes = controlFlowTypes; @@ -270,24 +273,29 @@ class CCfgWalk { 'normal', this.harvest.facts(stmt), ); - this.builder.edge(idx, this.builder.exitIndex, 'return'); + this.routeJump(idx, this.builder.exitIndex, 'return'); return { entry: idx, exits: [] }; } protected visitBreak(stmt: SyntaxNode): TraversalResult { const idx = this.builder.newBlock(startLineOf(stmt), endLineOf(stmt), stmt.text); const target = this.nearestBreakTarget(); - this.builder.edge(idx, target ?? this.builder.exitIndex, 'break'); + this.routeJump(idx, target ?? this.builder.exitIndex, 'break'); return { entry: idx, exits: [] }; } protected visitContinue(stmt: SyntaxNode): TraversalResult { const idx = this.builder.newBlock(startLineOf(stmt), endLineOf(stmt), stmt.text); const target = this.nearestContinueTarget(); - this.builder.edge(idx, target ?? this.builder.exitIndex, 'continue'); + this.routeJump(idx, target ?? this.builder.exitIndex, 'continue'); return { entry: idx, exits: [] }; } + /** Dialects with finalizers can thread abrupt completion through them. */ + protected routeJump(from: number, target: number, kind: 'return' | 'break' | 'continue'): void { + this.builder.edge(from, target, kind); + } + /** Nearest enclosing loop/switch `break` target, or undefined (→ EXIT). */ private nearestBreakTarget(): number | undefined { return this.jumps.length ? this.jumps[this.jumps.length - 1].breakTo : undefined; diff --git a/gitnexus/src/core/ingestion/cfg/visitors/objective-c-ast.ts b/gitnexus/src/core/ingestion/cfg/visitors/objective-c-ast.ts new file mode 100644 index 000000000..74a689a00 --- /dev/null +++ b/gitnexus/src/core/ingestion/cfg/visitors/objective-c-ast.ts @@ -0,0 +1,35 @@ +import type { SyntaxNode } from '../../utils/ast-helpers.js'; + +export const OBJECTIVE_C_FUNCTION_TYPES = new Set([ + 'function_definition', + 'method_definition', + 'block_literal', +]); + +/** The grammar leaves fast-enumeration binding, collection and body unfielded. */ +export function fastEnumerationParts(node: SyntaxNode): + | { + declarator: SyntaxNode; + collection: SyntaxNode; + body: SyntaxNode; + } + | undefined { + if (node.type !== 'for_statement') return undefined; + const children = node.children.filter((child) => child.type !== 'comment'); + const inIndex = children.findIndex((child) => child.type === 'in'); + if (inIndex < 0) return undefined; + const declarator = children[inIndex - 1]; + const collection = children[inIndex + 1]; + const body = node.namedChildren.at(-1); + return declarator && collection && body ? { declarator, collection, body } : undefined; +} + +export function catchParameter(node: SyntaxNode): SyntaxNode | undefined { + const type = node.namedChildren.find((child) => child.type === 'type_name'); + const children = type?.namedChildren ?? []; + return ( + children.find((child) => child.type === 'identifier' || child.type.endsWith('_declarator')) ?? + // `@catch (id error)` parses its bare binding as a type_identifier. + (children.length > 1 ? children.at(-1) : undefined) + ); +} diff --git a/gitnexus/src/core/ingestion/cfg/visitors/objective-c-harvest.ts b/gitnexus/src/core/ingestion/cfg/visitors/objective-c-harvest.ts new file mode 100644 index 000000000..cd856e669 --- /dev/null +++ b/gitnexus/src/core/ingestion/cfg/visitors/objective-c-harvest.ts @@ -0,0 +1,161 @@ +import type { SyntaxNode } from '../../utils/ast-helpers.js'; +import type { StatementFacts } from '../types.js'; +import { CCppHarvester } from './c-cpp-harvest.js'; +import { CallSiteFactAccumulator as FactAccumulator } from './call-site-harvest.js'; +import type { Scope } from './scope-tree-harvest.js'; +import { + catchParameter, + fastEnumerationParts, + OBJECTIVE_C_FUNCTION_TYPES, +} from './objective-c-ast.js'; + +/** Objective-C grammar additions on the C scalar def/use and lexical-scope walk. */ +export class ObjectiveCHarvester extends CCppHarvester { + constructor(fnNode: SyntaxNode) { + super(fnNode); + for (const [formalIndex, name] of this.parameterNames(fnNode).entries()) { + const index = this.root.table.get(name.text); + if (index !== undefined) this.bindings[index] = { ...this.bindings[index], formalIndex }; + } + } + + protected override isNestedFunction(node: SyntaxNode): boolean { + return OBJECTIVE_C_FUNCTION_TYPES.has(node.type) && node.id !== this.fnId; + } + + protected override parameterNames(fnNode: SyntaxNode): SyntaxNode[] { + if (fnNode.type === 'method_definition') { + return fnNode.namedChildren + .filter((child) => child.type === 'method_parameter') + .flatMap((param) => + param.namedChildren.filter((child) => child.type === 'identifier').slice(-1), + ); + } + if (fnNode.type === 'block_literal') { + const params = fnNode.namedChildren.find((child) => child.type === 'parameter_list'); + return (params?.namedChildren ?? []).flatMap((param) => { + const name = this.declaratorName(param.childForFieldName('declarator')); + return name ? [name] : []; + }); + } + return super.parameterNames(fnNode); + } + + protected override declaratorName(node: SyntaxNode | null): SyntaxNode | undefined { + if (node?.type === 'type_identifier') return node; + if (node?.type === 'parenthesized_declarator') { + const block = node.namedChildren.find((child) => child.type === 'block_pointer_declarator'); + if (block) return this.declaratorName(block.childForFieldName('declarator')); + } + if (node?.type === 'function_declarator' || node?.type === 'block_pointer_declarator') { + return this.declaratorName(node.childForFieldName('declarator')); + } + return super.declaratorName(node); + } + + protected override prescan(node: SyntaxNode, scope: Scope): void { + const enumeration = fastEnumerationParts(node); + if (enumeration || node.type === 'catch_clause') { + this.nearestScopeCache.set(node.id, scope); + const local = this.openScope(node); + const name = this.declaratorName(enumeration?.declarator ?? catchParameter(node) ?? null); + if (name) this.declare(name, enumeration ? 'var' : 'catch', local); + for (const child of node.namedChildren) this.prescan(child, local); + return; + } + super.prescan(node, scope); + } + + override catchParamFacts(node: SyntaxNode): StatementFacts | undefined { + const name = this.declaratorName(catchParameter(node) ?? null); + if (!name) return undefined; + const acc = new FactAccumulator(node.startPosition.row + 1); + this.def(name, acc); + return acc.finish(); + } + + override forRangeHeadFacts(node: SyntaxNode): StatementFacts { + const parts = fastEnumerationParts(node); + if (!parts) return super.forRangeHeadFacts(node); + const acc = new FactAccumulator(node.startPosition.row + 1); + const name = this.declaratorName(parts.declarator); + if (name) this.def(name, acc); + this.walkValue(parts.collection, acc); + return acc.finish(); + } + + protected override registerResultDefs(value: SyntaxNode, defs: readonly number[]): void { + let root = value; + while (root.type === 'parenthesized_expression' && root.firstNamedChild) + root = root.firstNamedChild; + if (root.type === 'message_expression' && defs.length > 0) + this.resultDefTargets.set(root.id, [...defs]); + else super.registerResultDefs(value, defs); + } + + protected override walkValue(node: SyntaxNode, acc: FactAccumulator): void { + if (this.isNestedFunction(node)) return; + if ( + [ + 'type_name', + 'method_type', + 'typedefed_specifier', + 'selector_expression', + 'protocol_expression', + 'encode_expression', + ].includes(node.type) + ) + return; + if (node.type === 'declaration') { + // Object pointers and block variables are scalar bindings. Indirect writes + // through those bindings still use the inherited C lvalue rules. + for (const child of node.namedChildren) { + if (child.type !== 'init_declarator') continue; + const declarator = child.childForFieldName('declarator'); + const value = child.childForFieldName('value'); + const name = this.declaratorName(declarator); + if (name && value && declarator?.type !== 'array_declarator') { + const before = acc.defSnapshot(); + this.def(name, acc); + this.registerResultDefs(value, acc.defsSince(before)); + } + if (value) this.walkValue(value, acc); + } + return; + } + if (node.type !== 'message_expression') { + super.walkValue(node, acc); + return; + } + + const receiver = node.childForFieldName('receiver'); + const selectors = new Set(node.childrenForFieldName('method').map((child) => child.id)); + const children = node.children; + const selector = children + .filter((child) => selectors.has(child.id)) + .map((child) => `${child.text}${child.nextSibling?.type === ':' ? ':' : ''}`) + .join(''); + const site = acc.openCallSite('call', [node.startPosition.row + 1, node.startPosition.column]); + acc.pushFrame(site); + if (receiver) { + if (receiver.type === 'identifier') { + const index = this.resolve(receiver); + acc.addUse(index); + acc.setSiteReceiver(site, index); + acc.setSiteCallee(site, `${receiver.text}.${selector}`); + } else { + this.walkValue(receiver, acc); + } + } + const result = this.resultDefTargets.get(node.id); + if (result) acc.setSiteResultDefs(site, result); + let position = 0; + for (const child of node.namedChildren) { + if (child.id === receiver?.id || selectors.has(child.id) || child.type === 'comment') + continue; + acc.setFrameArg(position++); + this.walkValue(child, acc); + } + acc.popFrame(); + } +} diff --git a/gitnexus/src/core/ingestion/cfg/visitors/objective-c.ts b/gitnexus/src/core/ingestion/cfg/visitors/objective-c.ts new file mode 100644 index 000000000..3434e33f4 --- /dev/null +++ b/gitnexus/src/core/ingestion/cfg/visitors/objective-c.ts @@ -0,0 +1,319 @@ +/** + * Objective-C grammar adapter for the C CFG walk. Methods, blocks, message + * facts, fast enumeration and exception constructs live here; the CFG/CDG/RD + * builders and solvers remain unchanged. ARC and implicit monitor release are + * not explicit AST calls; non-local setjmp/longjmp are not modeled. + */ +import type { SyntaxNode } from '../../utils/ast-helpers.js'; +import { CfgBuilder } from '../cfg-builder.js'; +import type { TraversalResult } from '../traversal-result.js'; +import type { CfgEdgeKind, CfgVisitor } from '../types.js'; +import { CCfgWalk, C_CONTROL_FLOW_TYPES } from './c-cpp.js'; +import { ObjectiveCHarvester } from './objective-c-harvest.js'; +import { fastEnumerationParts, OBJECTIVE_C_FUNCTION_TYPES } from './objective-c-ast.js'; + +const CONTROL_FLOW_TYPES = new Set([ + ...C_CONTROL_FLOW_TYPES, + 'try_statement', + 'throw_statement', + 'synchronized_statement', +]); +type SeqResult = TraversalResult | null; +const startLine = (node: SyntaxNode) => node.startPosition.row + 1; +const endLine = (node: SyntaxNode) => node.endPosition.row + 1; +interface CleanupContext { + readonly jumpDepth: number; + readonly build: () => SeqResult; +} +interface GotoContext { + readonly block: number; + readonly cleanups: readonly CleanupContext[]; +} + +class ObjectiveCCfgWalk extends CCfgWalk { + private readonly finalizers: CleanupContext[] = []; + private readonly gotoLabels = new Map(); + private readonly gotoSources = new Map(); + private readonly exceptionRouted = new Set(); + private cleanupLabels: ReadonlyMap = new Map(); + + protected override visitGoto(stmt: SyntaxNode): TraversalResult { + const block = this.builder.newBlock(startLine(stmt), endLine(stmt), stmt.text); + const source: GotoContext = { block, cleanups: [...this.finalizers] }; + const name = stmt.childForFieldName('label')?.text; + const label = name ? (this.cleanupLabels.get(name) ?? name) : undefined; + const target = label ? this.gotoLabels.get(label) : undefined; + if (target) this.connectGoto(source, target); + else if (label) { + const pending = this.gotoSources.get(label) ?? []; + pending.push(source); + this.gotoSources.set(label, pending); + } else this.connectGoto(source, { block: this.builder.exitIndex, cleanups: [] }); + return { entry: block, exits: [] }; + } + + protected override visitLabeled(stmt: SyntaxNode): SeqResult { + const result = super.visitLabeled(stmt); + const name = stmt.childForFieldName('label')?.text; + const label = name ? (this.cleanupLabels.get(name) ?? name) : undefined; + const block = name ? this.labelBlocks.get(name) : undefined; + if (label && block !== undefined) { + const target: GotoContext = { block, cleanups: [...this.finalizers] }; + this.gotoLabels.set(label, target); + for (const source of this.gotoSources.get(label) ?? []) this.connectGoto(source, target); + this.gotoSources.delete(label); + } + return result; + } + + private connectGoto(source: GotoContext, target: GotoContext): void { + const crossed = source.cleanups + .filter((cleanup) => !target.cleanups.includes(cleanup)) + .reverse(); + this.builder.connect(this.runCleanups([source.block], crossed), target.block, 'seq'); + } + + /** Each completion gets its own cleanup blocks and therefore its own continuation. */ + private runCleanups( + exits: readonly number[], + cleanups: readonly CleanupContext[], + kind: CfgEdgeKind = 'seq', + ): number[] { + let dangling = [...exits]; + for (const cleanup of cleanups) { + if (!dangling.length) break; + const result = cleanup.build(); + if (result) { + this.builder.connect(dangling, result.entry, kind); + dangling = [...result.exits]; + kind = 'seq'; + } + } + return dangling; + } + + private cleanupContext(body: SyntaxNode): CleanupContext { + const finalizers = [...this.finalizers]; + const jumps = [...this.jumps]; + const handlers = [...this.handlers]; + const outerLabels = this.cleanupLabels; + const localLabels: string[] = []; + const pending = [body]; + while (pending.length) { + const node = pending.pop()!; + if (OBJECTIVE_C_FUNCTION_TYPES.has(node.type)) continue; + const name = + node.type === 'labeled_statement' ? node.childForFieldName('label')?.text : undefined; + if (name) localLabels.push(name); + pending.push(...node.namedChildren); + } + return { + jumpDepth: jumps.length, + build: () => { + const activeFinalizers = [...this.finalizers]; + const activeJumps = [...this.jumps]; + const activeHandlers = [...this.handlers]; + const activeLabels = this.cleanupLabels; + this.finalizers.splice(0, this.finalizers.length, ...finalizers); + this.jumps.splice(0, this.jumps.length, ...jumps); + this.handlers.splice(0, this.handlers.length, ...handlers); + // Only labels declared inside this cleanup are private to the replay; + // jumps to an enclosing label still leave through the ordinary route. + const labels = new Map(outerLabels); + for (const name of localLabels) labels.set(name, `${this.builder.blockCount}:${name}`); + this.cleanupLabels = labels; + try { + const firstBlock = this.builder.blockCount; + const result = this.visitBody(body); + // A cleanup exception belongs to the outer context, never the try + // whose pending completion is being resumed or replaced. + this.connectExceptions(firstBlock, [this.currentHandler()]); + return result; + } finally { + this.finalizers.splice(0, this.finalizers.length, ...activeFinalizers); + this.jumps.splice(0, this.jumps.length, ...activeJumps); + this.handlers.splice(0, this.handlers.length, ...activeHandlers); + this.cleanupLabels = activeLabels; + } + }, + }; + } + + override finishGotos(): void { + for (const [label, sources] of this.gotoSources) { + // eslint-disable-next-line no-console -- preserve the CFG builder's unresolved-label diagnostic + console.warn( + `[cfg] unresolved goto label "${label}" routed to EXIT (${sources.length} site(s))`, + ); + for (const source of sources) + this.connectGoto(source, { block: this.builder.exitIndex, cleanups: [] }); + } + this.gotoSources.clear(); + } + + private connectExceptions(firstBlock: number, targets: readonly number[]): void { + for (let block = firstBlock; block < this.builder.blockCount; block++) { + // A nested try already routes its protected blocks through its own + // handlers/cleanup. An outer exceptional edge would bypass that finally. + if (this.exceptionRouted.has(block)) continue; + for (const target of targets) this.builder.edge(block, target, 'throw'); + this.exceptionRouted.add(block); + } + } + + protected override routeJump( + from: number, + target: number, + kind: 'return' | 'break' | 'continue', + ): void { + // Loop/switch frames already belong to the C walk. Their depth identifies + // precisely which finalizers lie between this jump and its target: a loop + // wholly inside a try must not execute that try's finally on break. + const targetDepth = + kind === 'return' + ? -1 + : this.jumps.findIndex((frame) => + kind === 'break' + ? frame.breakTo === target + : frame.kind === 'loop' && frame.continueTo === target, + ); + const crossed = this.finalizers.filter((fin) => fin.jumpDepth > targetDepth).reverse(); + const exits = this.runCleanups([from], crossed, kind); + this.builder.connect(exits, target, exits[0] === from ? kind : `finally-${kind}`); + } + + protected override visitFor(stmt: SyntaxNode): TraversalResult { + const parts = fastEnumerationParts(stmt); + if (!parts) return super.visitFor(stmt); + const header = this.builder.newBlock( + startLine(stmt), + endLine(parts.collection), + `for (${stmt.childForFieldName('type')?.text ?? ''} ${parts.declarator.text} in ${parts.collection.text})`, + 'normal', + this.harvest.forRangeHeadFacts(stmt), + ); + const exit = this.builder.newBlock(endLine(stmt), endLine(stmt), ''); + this.jumps.push({ kind: 'loop', breakTo: exit, continueTo: header }); + const body = this.visitBody(parts.body); + this.jumps.pop(); + if (body) { + this.builder.edge(header, body.entry, 'cond-true'); + this.builder.connect(body.exits, header, 'loop-back'); + } else this.builder.edge(header, header, 'loop-back'); + this.builder.edge(header, exit, 'cond-false'); + return { entry: header, exits: [exit] }; + } + + protected override visitExtra(stmt: SyntaxNode): SeqResult | undefined { + if (stmt.type === 'try_statement') return this.visitTry(stmt); + if (stmt.type === 'throw_statement') { + const result = this.visitSimple(stmt); + this.builder.edge(result.entry, this.currentHandler(), 'throw'); + return { entry: result.entry, exits: [] }; + } + if (stmt.type === 'synchronized_statement') { + const body = this.bodyBlockOf(stmt); + const receiver = stmt.namedChildren.find( + (child) => child.id !== body?.id && child.type !== 'comment', + ); + const res = this.visitBody(body); + if (!receiver) return res; + const head = this.visitSimple(receiver); + if (res) this.builder.connect(head.exits, res.entry, 'seq'); + return { entry: head.entry, exits: res?.exits ?? head.exits }; + } + return undefined; + } + + private visitTry(stmt: SyntaxNode): SeqResult { + const catches = stmt.namedChildren.filter((child) => child.type === 'catch_clause'); + const finallyClause = stmt.namedChildren.find((child) => child.type === 'finally_clause'); + const outerHandler = this.currentHandler(); + const cleanupBody = finallyClause ? this.bodyBlockOf(finallyClause) : undefined; + const cleanup = cleanupBody ? this.cleanupContext(cleanupBody) : undefined; + const exceptionalCleanup = cleanup?.build(); + if (exceptionalCleanup) this.builder.connect(exceptionalCleanup.exits, outerHandler, 'throw'); + if (cleanup) this.finalizers.push(cleanup); + const exceptionalTarget = exceptionalCleanup?.entry ?? outerHandler; + + const handlers: TraversalResult[] = []; + for (const clause of catches) { + const firstBlock = this.builder.blockCount; + this.handlers.push(exceptionalTarget); + let handler = this.visitBody(this.bodyBlockOf(clause)); + this.handlers.pop(); + // Catch bodies can also raise, including without an explicit @throw. + this.connectExceptions(firstBlock, [exceptionalTarget]); + if (!handler) { + const entry = this.builder.newBlock(startLine(clause), endLine(clause), ''); + this.exceptionRouted.add(entry); + handler = { entry, exits: [entry] }; + } + const facts = this.harvest.catchParamFacts(clause); + if (facts) { + const binding = this.builder.newBlock( + startLine(clause), + startLine(clause), + '', + 'normal', + facts, + ); + this.exceptionRouted.add(binding); + this.builder.edge(binding, handler.entry, 'seq'); + handler = { entry: binding, exits: handler.exits }; + } + handlers.push(handler); + } + + const firstProtected = this.builder.blockCount; + this.handlers.push(handlers[0]?.entry ?? exceptionalTarget); + let body = this.visitBody(this.bodyBlockOf(stmt)); + this.handlers.pop(); + // Runtime exception matching is uncertain. Preserve every handler and the + // uncaught path, including exceptions raised inside a branch of the try. + this.connectExceptions(firstProtected, [ + ...handlers.map((handler) => handler.entry), + exceptionalTarget, + ]); + if (!body) { + // An empty try completes normally; it must not use a catch as its entry. + const entry = this.builder.newBlock(startLine(stmt), startLine(stmt), ''); + body = { entry, exits: [entry] }; + this.exceptionRouted.add(entry); + } + if (cleanup) this.finalizers.pop(); + const normalExits = [...body.exits, ...handlers.flatMap((handler) => handler.exits)]; + return { + entry: body.entry, + exits: cleanup ? this.runCleanups(normalExits, [cleanup]) : normalExits, + }; + } +} + +export function createObjectiveCCfgVisitor(): CfgVisitor { + return { + isFunction: (node) => OBJECTIVE_C_FUNCTION_TYPES.has(node.type), + buildFunctionCfg: (node, filePath) => { + if (!OBJECTIVE_C_FUNCTION_TYPES.has(node.type)) return undefined; + const body = + node.childForFieldName('body') ?? + node.namedChildren.find((child) => child.type === 'compound_statement'); + if (!body) return undefined; + const builder = new CfgBuilder( + filePath, + startLine(node), + endLine(node), + node.startPosition.column, + ); + const harvest = new ObjectiveCHarvester(node); + const params = harvest.paramFacts(); + if (params) builder.attachFacts(builder.entryIndex, params); + const walk = new ObjectiveCCfgWalk(builder, harvest, CONTROL_FLOW_TYPES); + const res = walk.visitSeq(body.namedChildren.filter((child) => child.type !== 'comment')); + walk.finishGotos(); + builder.edge(builder.entryIndex, res?.entry ?? builder.exitIndex, 'seq'); + if (res) builder.connect(res.exits, builder.exitIndex, 'seq'); + return builder.finish(harvest.bindingTable()); + }, + }; +} diff --git a/gitnexus/src/core/ingestion/cfg/visitors/zig-ast.ts b/gitnexus/src/core/ingestion/cfg/visitors/zig-ast.ts new file mode 100644 index 000000000..abeafe833 --- /dev/null +++ b/gitnexus/src/core/ingestion/cfg/visitors/zig-ast.ts @@ -0,0 +1,93 @@ +/** Grammar-specific shapes shared by the Zig CFG walk and lexical harvest. */ +import type { SyntaxNode } from '../../utils/ast-helpers.js'; + +export const ZIG_OPAQUE = new Set([ + 'function_declaration', + 'function_signature', + 'struct_declaration', + 'union_declaration', + 'enum_declaration', + 'opaque_declaration', +]); + +export const zigChildren = (node: SyntaxNode): SyntaxNode[] => + node.children.filter( + (c) => + c.type !== 'comment' && + (c.isNamed || c.type === 'unreachable' || c.type === 'undefined' || c.type === 'null'), + ); + +export const zigPayload = (node: SyntaxNode): SyntaxNode | undefined => + node.namedChildren.find((c) => c.type === 'payload'); + +export const zigLabel = (node: SyntaxNode): string | undefined => + node.namedChildren.find((c) => c.type === 'block_label' || c.type === 'break_label') + ?.namedChildren[0]?.text; + +/** The value operand of `return` / `break` (a labeled break's label is not a value). */ +export const zigJumpValue = (node: SyntaxNode): SyntaxNode | undefined => + zigChildren(node).find((c) => c.type !== 'break_label'); + +export function zigAssignment(node: SyntaxNode): { + targets: SyntaxNode[]; + value?: SyntaxNode; + operator?: string; +} { + const operator = node.children.find((c) => !c.isNamed && /^(?:[+*%/&|^<>-].*)?=$/.test(c.text)); + if (!operator) return { targets: [], value: undefined }; + const type = node.childForFieldName('type'); + return { + targets: node.namedChildren.filter( + (c) => c.endIndex <= operator.startIndex && c.id !== type?.id, + ), + value: zigChildren(node).find((c) => c.startIndex >= operator.endIndex), + operator: operator.text, + }; +} + +export function zigConditional(node: SyntaxNode): { + inputs: SyntaxNode[]; + body?: SyntaxNode; + payload?: SyntaxNode; + alternative?: SyntaxNode; + elsePayload?: SyntaxNode; + update?: SyntaxNode; +} { + const children = zigChildren(node); + const elseClause = children.find((c) => c.type === 'else_clause'); + const elseToken = node.children.find((c) => c.type === 'else'); + const close = node.children.find((c) => c.type === ')'); + const inputs = children.filter( + (c) => close && c.endIndex <= close.startIndex && c.type !== 'block_label', + ); + const tail = children.filter( + (c) => close && c.startIndex >= close.endIndex && c.id !== elseClause?.id, + ); + const colon = node.children.find((c) => c.type === ':'); + const update = colon + ? tail.find((c) => c.startIndex >= colon.endIndex && c.type !== 'payload') + : undefined; + const body = + node.childForFieldName('body') ?? + tail.find( + (c) => + c.type !== 'payload' && + c.id !== update?.id && + (!elseToken || c.startIndex < elseToken.startIndex), + ); + const alternative = + elseClause?.childForFieldName('alternative') ?? + tail.find((c) => elseToken && c.startIndex >= elseToken.endIndex && c.type !== 'payload'); + return { + inputs, + body: body ?? undefined, + update, + payload: tail.find( + (c) => c.type === 'payload' && (!elseToken || c.startIndex < elseToken.startIndex), + ), + alternative: alternative ?? undefined, + elsePayload: elseClause + ? zigPayload(elseClause) + : tail.find((c) => c.type === 'payload' && elseToken && c.startIndex > elseToken.endIndex), + }; +} diff --git a/gitnexus/src/core/ingestion/cfg/visitors/zig-harvest.ts b/gitnexus/src/core/ingestion/cfg/visitors/zig-harvest.ts new file mode 100644 index 000000000..3744c945f --- /dev/null +++ b/gitnexus/src/core/ingestion/cfg/visitors/zig-harvest.ts @@ -0,0 +1,347 @@ +/** + * Zig lexical def/use and call-site facts for the existing generic PDG solvers. + * ScopeTreeHarvester owns resolution; only grammar decisions live here. Payloads + * belong to their selected arm, `_` is discarded, and containers/functions are + * opaque. Member/index stores read the receiver, never kill its scalar binding. + * + * A control-flow-valued expression is evaluated in its own blocks, so its result + * must be carried to whatever consumes it. A {@link ZigResultSink} names the + * bindings every normal-result leaf defines: the assignment's own identifier + * targets when the expression is the whole value, otherwise a provider-owned + * temporary (`$resultN`) that the consuming statement reads. Evaluation facts and + * call sites are harvested once, at the leaf; a payload reads the uses of the + * expression it captures from. + */ +import type { SyntaxNode } from '../../utils/ast-helpers.js'; +import type { BindingEntry, StatementFacts } from '../types.js'; +import { ScopeTreeHarvester, type Scope, type FactAccumulator } from './scope-tree-harvest.js'; +import { CallSiteFactAccumulator, finalizeChain } from './call-site-harvest.js'; +import { + ZIG_OPAQUE, + zigAssignment, + zigChildren, + zigConditional, + zigJumpValue, + zigPayload, +} from './zig-ast.js'; + +/** Bindings every normal-result leaf of a control-flow-valued expression defines. */ +export interface ZigResultSink { + readonly defs: readonly number[]; + /** Bindings the leaf also reads first (a compound assignment's own target). */ + readonly uses: readonly number[]; +} + +/** Control-flow child expression id -> the binding its result is carried in. */ +export type ZigResultCarriers = ReadonlyMap; + +const NO_CARRIERS: ZigResultCarriers = new Map(); + +/** The call a value is exactly, seen through parentheses and `try`. */ +function resultCall(value: SyntaxNode): SyntaxNode | undefined { + let call: SyntaxNode | undefined = value; + while (call && (call.type === 'parenthesized_expression' || call.type === 'try_expression')) + call = call.namedChildren[0]; + return call && (call.type === 'call_expression' || call.type === 'builtin_function') + ? call + : undefined; +} + +export class ZigHarvester extends ScopeTreeHarvester { + private resultTemps = 0; + + constructor(fn: SyntaxNode) { + super(fn); + const params = fn.namedChildren.find((c) => c.type === 'parameters'); + for (const [formalIndex, param] of ( + params?.namedChildren.filter((c) => c.type === 'parameter') ?? [] + ).entries()) { + const name = param.childForFieldName('name'); + if (!name || name.text === '_') continue; + this.declare(name, 'param', this.root); + const idx = this.root.table.get(name.text)!; + this.bindings[idx] = { ...this.bindings[idx], formalIndex }; + } + const body = fn.childForFieldName('body'); + if (body) this.prescan(body, this.root); + } + + protected override declare(node: SyntaxNode, kind: BindingEntry['kind'], scope: Scope): void { + if (node.text !== '_') super.declare(node, kind, scope); + } + + private scanArm( + body: SyntaxNode | undefined, + payload: SyntaxNode | undefined, + outer: Scope, + ): void { + if (!body) return; + const scope: Scope = { parent: outer, table: new Map() }; + this.scopeByNode.set(body.id, scope); + if (payload) { + this.nearestScopeCache.set(payload.id, scope); + for (const name of payload.namedChildren) { + this.declare(name, 'let', scope); + this.nearestScopeCache.set(name.id, scope); + } + } + this.prescan(body, scope); + } + + protected prescan(node: SyntaxNode, scope: Scope): void { + if (ZIG_OPAQUE.has(node.type)) return; + if (node.type === 'block') { + scope = this.scopeByNode.get(node.id) ?? { parent: scope, table: new Map() }; + this.scopeByNode.set(node.id, scope); + } + this.nearestScopeCache.set(node.id, scope); + if (/^(if|while|for)_(statement|expression|type_expression)$/.test(node.type)) { + const parts = zigConditional(node); + for (const input of parts.inputs) this.prescan(input, scope); + this.scanArm(parts.body, parts.payload, scope); + if (parts.update) + this.prescan(parts.update, this.scopeByNode.get(parts.body?.id ?? -1) ?? scope); + this.scanArm(parts.alternative, parts.elsePayload, scope); + return; + } + if (node.type === 'catch_expression') { + this.prescan(zigChildren(node)[0], scope); + this.scanArm(zigChildren(node).at(-1), zigPayload(node), scope); + return; + } + if (node.type === 'switch_case' || node.type === 'errdefer_statement') { + const body = zigChildren(node).at(-1); + for (const child of zigChildren(node)) { + if (child.id !== body?.id && child.type !== 'payload') this.prescan(child, scope); + } + this.scanArm(body, zigPayload(node), scope); + return; + } + if (node.type === 'variable_declaration') { + const targets = zigAssignment(node).targets; + for (const target of targets) { + let previous = target.previousSibling; + while (previous?.type === 'comment') previous = previous.previousSibling; + if ( + target.type === 'identifier' && + (previous?.type === 'const' || previous?.type === 'var') + ) { + this.declare(target, previous.type === 'const' ? 'const' : 'var', scope); + } + } + } + for (const child of zigChildren(node)) this.prescan(child, scope); + } + + params(): StatementFacts { + return { + line: this.fnNode.startPosition.row + 1, + defs: this.bindings.flatMap((b, i) => (b.kind === 'param' ? [i] : [])), + uses: [], + }; + } + + /** + * A capture's definitions. The captured value comes from `subjects` (the + * condition, switch operand or error source it unwraps) and `operand` (a + * carrier bound to that operand), so the capture depends on what it unwraps. + * Subject calls are not re-recorded: they were harvested where evaluated. + */ + payload( + node: SyntaxNode, + subjects: readonly SyntaxNode[] = [], + operand: readonly number[] = [], + ): StatementFacts { + const acc = new CallSiteFactAccumulator(node.startPosition.row + 1); + for (const subject of subjects) + for (const use of this.facts(subject).uses) acc.addUseWithoutOccurrence(use); + for (const use of operand) acc.addUseWithoutOccurrence(use); + for (const name of node.namedChildren) if (name.text !== '_') this.def(name, acc); + return acc.finish(); + } + + /** + * Facts for `node`. `carriers` replaces control-flow children that were + * evaluated in their own blocks with a read of the binding carrying their + * result. `sink` makes `node` a result leaf: it additionally defines the sink's + * bindings, and a call that is exactly its value records them as its result. + */ + facts( + node: SyntaxNode, + carriers: ZigResultCarriers = NO_CARRIERS, + sink?: ZigResultSink, + ): StatementFacts { + const acc = new CallSiteFactAccumulator(node.startPosition.row + 1); + if (sink) { + const value = node.type === 'break_expression' ? zigJumpValue(node) : node; + const call = value && resultCall(value); + if (call && sink.defs.length) this.resultDefTargets.set(call.id, [...sink.defs]); + } + this.walk(node, acc, carriers); + if (sink) { + for (const use of sink.uses) acc.addUse(use); + for (const def of sink.defs) acc.addDef(def); + } + return acc.finish(); + } + + /** + * The destination of an assignment/declaration whose targets are all plain + * identifiers, or undefined for a member/index/dereference store (whose receiver + * reads belong to the consuming statement, not to each result leaf). + */ + assignmentSink(node: SyntaxNode): ZigResultSink | undefined { + const { targets, operator } = zigAssignment(node); + if (!targets.length || targets.some((t) => t.type !== 'identifier')) return undefined; + const acc = new CallSiteFactAccumulator(node.startPosition.row + 1); + for (const target of targets) this.target(target, acc, operator !== '='); + const { defs, uses } = acc.finish(); + return { defs, uses }; + } + + /** A provider-owned binding carrying the result of one control-flow-valued expression. */ + resultTemp(node: SyntaxNode): ZigResultSink { + this.bindings.push({ + name: `$result${this.resultTemps++}`, + declLine: node.startPosition.row + 1, + declColumn: node.startPosition.column, + kind: 'let', + }); + return { defs: [this.bindings.length - 1], uses: [] }; + } + + /** Facts-only read of carriers, for a decision that consumes an operand's result. */ + consume(node: SyntaxNode, carriers: readonly number[]): StatementFacts { + return { line: node.startPosition.row + 1, defs: [], uses: [...carriers] }; + } + + private target(node: SyntaxNode, acc: FactAccumulator, compound: boolean): void { + if (node.type === 'identifier') { + if (node.text !== '_') { + if (compound) this.use(node, acc); + this.def(node, acc); + } + } else this.walk(node, acc, NO_CARRIERS); + } + + private walk(node: SyntaxNode, acc: FactAccumulator, carriers: ZigResultCarriers): void { + const carrier = carriers.get(node.id); + if (carrier !== undefined) { + acc.addUse(carrier); + return; + } + if (ZIG_OPAQUE.has(node.type)) return; + switch (node.type) { + case 'identifier': + if (node.text !== '_') this.use(node, acc); + return; + case 'payload': + case 'block_label': + case 'break_label': + case 'error_type': + case 'builtin_type': + case 'string': + case 'multiline_string': + case 'comment': + return; + case 'variable_declaration': + case 'assignment_expression': { + const { targets, value, operator } = zigAssignment(node); + const defs = acc.defSnapshot(); + for (const target of targets) this.target(target, acc, operator !== '='); + if (value) { + const call = resultCall(value); + if (call) this.resultDefTargets.set(call.id, acc.defsSince(defs)); + this.walk(value, acc, carriers); + } + return; + } + case 'field_expression': { + this.chain(node, acc, false, carriers); + return; + } + case 'field_initializer': + for (const child of zigChildren(node).slice(1)) this.walk(child, acc, carriers); + return; + case 'call_expression': + case 'builtin_function': { + const site = acc.openCallSite('call', [ + node.startPosition.row + 1, + node.startPosition.column, + ]); + const targets = this.resultDefTargets.get(node.id); + if (targets?.length) acc.setSiteResultDefs(site, targets); + acc.pushFrame(site); + let fn: SyntaxNode | undefined; + let args: SyntaxNode | undefined; + if (node.type === 'call_expression') { + fn = node.childForFieldName('function') ?? undefined; + args = node.childForFieldName('arguments') ?? undefined; + } else { + // Builtins expose children without the call_expression field names. + fn = node.namedChildren.find((child) => child.type === 'builtin_identifier'); + args = node.namedChildren.find((child) => child.type === 'arguments'); + } + if (fn?.type === 'identifier' || fn?.type === 'builtin_identifier') { + acc.setSiteCallee(site, fn.text); + if (fn.type === 'identifier' && fn.text !== '_') + acc.addUseWithoutOccurrence(this.resolve(fn)); + } else if (fn) { + const chain = this.chain(fn, acc, true, carriers); + if (chain.path) acc.setSiteCallee(site, chain.path); + if (chain.rootIdx !== undefined) acc.setSiteReceiver(site, chain.rootIdx); + } + for (const [i, arg] of (args && !carriers.has(args.id) + ? zigChildren(args) + : [] + ).entries()) { + acc.setFrameArg(i); + this.walk(arg, acc, carriers); + } + acc.popFrame(); + return; + } + case 'binary_expression': { + const left = node.childForFieldName('left'); + const right = node.childForFieldName('right'); + if (left) this.walk(left, acc, carriers); + if (right) { + const op = node.childForFieldName('operator')?.text; + if (op === 'and' || op === 'or' || op === 'orelse') + this.conditional(() => this.walk(right, acc, carriers)); + else this.walk(right, acc, carriers); + } + return; + } + } + const type = node.childForFieldName('type'); + for (const child of zigChildren(node)) + if (child.id !== type?.id) this.walk(child, acc, carriers); + } + + private chain( + node: SyntaxNode, + acc: FactAccumulator, + skipFinal: boolean, + carriers: ZigResultCarriers, + ) { + let root = node; + const accesses: string[] = []; + while (root.type === 'field_expression') { + const object = root.childForFieldName('object'); + const member = root.childForFieldName('member'); + if (!object) return {}; // enum literals `.tag` are not variable reads. + accesses.unshift(member?.text ?? ''); + root = object; + } + const carrier = carriers.get(root.id); + if (carrier !== undefined) { + acc.addUse(carrier); + return {}; + } + return finalizeChain(acc, root, accesses, skipFinal, (t) => t === 'identifier', { + resolve: (n) => this.resolve(n), + walkRoot: (n) => this.walk(n, acc, carriers), + }); + } +} diff --git a/gitnexus/src/core/ingestion/cfg/visitors/zig.ts b/gitnexus/src/core/ingestion/cfg/visitors/zig.ts new file mode 100644 index 000000000..82afcb88d --- /dev/null +++ b/gitnexus/src/core/ingestion/cfg/visitors/zig.ts @@ -0,0 +1,566 @@ +/** + * Zig's provider-owned CFG strategy. The generic builder, CDG and reaching-defs + * solvers consume its ordinary FunctionCfg; there is no second PDG pipeline. + * Shapes are checked against the vendored tree-sitter-zig grammar. + * + * Cleanup is block-scoped, LIFO and replayed per completion path, so a return + * cannot leak into another return's continuation. errdefer runs only on error + * propagation. A return whose error-union value is unknown conservatively has + * both success/error completions. Calls alone do not throw: Zig errors are values. + * Loops retain a structural false escape (including `while (true)`) for the + * shared post-dominator solver. Iteration predicates and operand evaluation order + * are structural approximations; this is syntax-based analysis, not evaluation. + * A control expression is split into its own blocks, and its result is carried + * to the consumer: each normal-result leaf (an if/switch arm, a catch or orelse + * operand, a `try` value, a labeled block's `break` value) defines the assigned + * identifiers, or a provider-owned `$resultN` binding that the consuming + * statement (an argument, `return`, a store, a branch decision) reads. Calls are + * recorded once, at their leaf; a capture reads the uses of what it unwraps. + * Invalid syntax, unresolved jumps, asm and suspend/resume are skipped rather + * than represented as exact straight-line control. Nested containers/functions + * are opaque; each function declaration is separately collected by the pipeline. + */ +import type { SyntaxNode } from '../../utils/ast-helpers.js'; +import { CfgBuilder } from '../cfg-builder.js'; +import { ControlFlowContext } from '../control-flow-context.js'; +import type { CfgEdgeKind, CfgVisitor, FunctionCfg, StatementFacts } from '../types.js'; +import type { TraversalResult } from '../traversal-result.js'; +import { ZigHarvester, type ZigResultCarriers, type ZigResultSink } from './zig-harvest.js'; +import { + ZIG_OPAQUE, + zigAssignment, + zigChildren, + zigConditional, + zigJumpValue, + zigLabel, + zigPayload, +} from './zig-ast.js'; + +type Result = TraversalResult | null; +interface Cleanup { + node: SyntaxNode; + errorOnly: boolean; +} +/** What an arm needs from its construct: what it captures from, and where its value goes. */ +interface Arm { + subjects?: readonly SyntaxNode[]; + /** Binding that already carries the captured operand (labeled-switch dispatch). */ + operand?: readonly number[]; + sink?: ZigResultSink; +} +class UnsupportedZigCfg extends Error {} + +const CONTROL_TYPES = new Set([ + 'block', + 'block_expression', + 'labeled_type_expression', + 'labeled_statement', + 'if_statement', + 'if_expression', + 'if_type_expression', + 'while_statement', + 'while_expression', + 'for_statement', + 'for_expression', + 'switch_expression', + 'return_expression', + 'break_expression', + 'continue_expression', + 'try_expression', + 'catch_expression', + 'defer_statement', + 'errdefer_statement', + 'asm_expression', + 'suspend_statement', + 'resume_expression', + 'unreachable', +]); + +class ZigCfgWalk { + private readonly context = new ControlFlowContext(); + private readonly cleanup: Cleanup[] = []; + /** Number of already-active defers outside each jump target. */ + private readonly cleanupDepth = new Map(); + private readonly controlCache = new Map(); + private readonly switchContinues: { label: string; target: number; sink: ZigResultSink }[] = []; + /** Where a `break` to this join block delivers its value (loops, labeled blocks and switches). */ + private readonly resultSinks = new Map(); + + constructor( + private readonly builder: CfgBuilder, + private readonly harvest: ZigHarvester, + private readonly errorReturn: boolean, + ) {} + + private block(node: SyntaxNode, facts = this.harvest.facts(node), text = node.text): number { + return this.builder.newBlock( + node.startPosition.row + 1, + node.endPosition.row + 1, + text, + 'normal', + facts, + ); + } + + private empty(node: SyntaxNode): number { + return this.builder.newBlock(node.endPosition.row + 1, node.endPosition.row + 1, ''); + } + + private append(first: Result, next: Result): Result { + if (!first) return next; + if (!next) return first; + this.builder.connect(first.exits, next.entry); + return { entry: first.entry, exits: first.exits.length ? next.exits : [] }; + } + + private simple(node: SyntaxNode, facts?: StatementFacts): TraversalResult { + const entry = this.block(node, facts); + return { entry, exits: [entry] }; + } + + /** A result leaf: its own evaluation facts, plus the destination its value is delivered to. */ + private leaf(node: SyntaxNode, sink?: ZigResultSink): TraversalResult { + return this.simple(node, this.harvest.facts(node, undefined, sink)); + } + + private hasControl(node: SyntaxNode): boolean { + const cached = this.controlCache.get(node.id); + if (cached !== undefined) return cached; + const op = node.childForFieldName('operator')?.text; + const result = + !ZIG_OPAQUE.has(node.type) && + (CONTROL_TYPES.has(node.type) || + (node.type === 'binary_expression' && (op === 'and' || op === 'or' || op === 'orelse')) || + zigChildren(node).some((c) => this.hasControl(c))); + this.controlCache.set(node.id, result); + return result; + } + + private body(node: SyntaxNode | undefined, payload?: SyntaxNode, arm: Arm = {}): Result { + let result: Result = payload + ? this.simple(payload, this.harvest.payload(payload, arm.subjects, arm.operand)) + : null; + if (node) result = this.append(result, this.visit(node, undefined, arm.sink)); + return result; + } + + /** + * Lower control-flow-valued children in source order, each into its own result + * carrier, for a consumer that reads `carriers` instead of re-walking them. + */ + private evaluate(controls: readonly SyntaxNode[]): { + result: Result; + carriers: ZigResultCarriers; + } { + let result: Result = null; + const carriers = new Map(); + for (const control of controls) { + const carrier = this.harvest.resultTemp(control); + carriers.set(control.id, carrier.defs[0]); + result = this.append(result, this.visit(control, undefined, carrier)); + } + return { result, carriers }; + } + + /** + * Evaluate the operand a branch, loop or dispatch decides on. A control-flow + * operand's result is read at its join, so the decision depends on it. + */ + private operand(node: SyntaxNode): Result { + if (!this.hasControl(node)) return this.visit(node); + const carrier = this.harvest.resultTemp(node); + const evaluated = this.visit(node, undefined, carrier); + return evaluated && this.decide(evaluated, node, carrier.defs); + } + + /** Attach the read of `carriers` to the single point where the operand's paths join. */ + private decide( + evaluated: TraversalResult, + node: SyntaxNode, + carriers: readonly number[], + ): TraversalResult { + if (!evaluated.exits.length) return evaluated; + let at = evaluated.exits[0]; + if (evaluated.exits.length > 1) { + at = this.empty(node); + this.builder.connect(evaluated.exits, at); + } + this.builder.attachFacts(at, this.harvest.consume(node, carriers)); + return { entry: evaluated.entry, exits: [at] }; + } + + /** Replay only the cleanup scopes crossed by this completion. */ + private clean( + exits: readonly number[], + depth: number, + error: boolean, + firstKind: CfgEdgeKind = 'seq', + ): number[] { + let dangling = [...exits]; + const active = [...this.cleanup]; + for (let i = active.length - 1; i >= depth && dangling.length; i--) { + const deferred = active[i]; + if (deferred.errorOnly && !error) continue; + this.cleanup.length = i; + const payload = deferred.errorOnly ? zigPayload(deferred.node) : undefined; + const result = this.body(zigChildren(deferred.node).at(-1), payload); + if (result) { + this.builder.connect(dangling, result.entry, firstKind); + dangling = [...result.exits]; + firstKind = 'seq'; + } + } + this.cleanup.splice(0, this.cleanup.length, ...active); + return dangling; + } + + private complete( + from: number, + target: number, + kind: 'return' | 'break' | 'continue' | 'throw', + error = false, + ): void { + const depth = this.cleanupDepth.get(target) ?? 0; + const exits = this.clean([from], depth, error, kind); + const completion: CfgEdgeKind = kind === 'throw' ? 'throw' : `finally-${kind}`; + this.builder.connect(exits, target, exits[0] === from ? kind : completion); + } + + private sequence(node: SyntaxNode): Result { + const depth = this.cleanup.length; + let result: Result = null; + for (const child of zigChildren(node)) { + // Dead tails must not register a defer for an earlier jump. + if (result && !result.exits.length) break; + if (child.type === 'defer_statement' || child.type === 'errdefer_statement') { + this.cleanup.push({ node: child, errorOnly: child.type === 'errdefer_statement' }); + } else result = this.append(result, this.visit(child)); + } + if (!result && this.cleanup.length > depth) { + const entry = this.empty(node); + result = { entry, exits: [entry] }; + } + if (result) result = { entry: result.entry, exits: this.clean(result.exits, depth, false) }; + this.cleanup.length = depth; + return result; + } + + visit(node: SyntaxNode, label?: string, sink?: ZigResultSink): Result { + return this.builder.withNesting(() => this.visitNode(node, label, sink)); + } + + /** `sink`: the bindings this expression's normal-result leaves must define. */ + private visitNode(node: SyntaxNode, label?: string, sink?: ZigResultSink): Result { + if (ZIG_OPAQUE.has(node.type)) return this.leaf(node, sink); + switch (node.type) { + case 'asm_expression': + case 'suspend_statement': + case 'resume_expression': + throw new UnsupportedZigCfg(); + case 'unreachable': { + const entry = this.block(node); + this.builder.edge(entry, this.builder.exitIndex, 'throw'); + return { entry, exits: [] }; + } + case 'block': { + if (!label) return this.sequence(node); + const join = this.empty(node); + this.cleanupDepth.set(join, this.cleanup.length); + if (sink) this.resultSinks.set(join, sink); + this.context.pushLabeledBlock(join, [label]); + const body = this.sequence(node); + this.context.pop(); + if (body) this.builder.connect(body.exits, join); + return { entry: body?.entry ?? join, exits: [join] }; + } + case 'block_expression': + case 'labeled_type_expression': + case 'labeled_statement': + return this.visit( + zigChildren(node).filter((c) => c.type !== 'block_label')[0], + zigLabel(node) ?? label, + sink, + ); + case 'expression_statement': + case 'comptime_statement': + case 'nosuspend_statement': + if (zigChildren(node)[0] && this.hasControl(zigChildren(node)[0])) + return this.visit(zigChildren(node)[0], label, sink); + return this.leaf(node, sink); + case 'if_statement': + case 'if_expression': + case 'if_type_expression': + return this.branch(node, sink); + case 'while_statement': + case 'while_expression': + case 'for_statement': + case 'for_expression': + return this.loop(node, zigLabel(node) ?? label, sink); + case 'switch_expression': + return this.switch(node, sink); + case 'return_expression': + case 'break_expression': + case 'continue_expression': + return this.jump(node); + case 'try_expression': { + // The value's leaves define the destination before the throw edge leaves + // them: a cleanup reading a pre-declared destination on the error path sees + // one conservative extra definition (never a missing one). + const value = zigChildren(node)[0]; + const result = this.hasControl(value) + ? this.visit(value, undefined, sink) + : this.leaf(node, sink); + if (result) + for (const from of result.exits) + this.complete(from, this.builder.exitIndex, 'throw', true); + return result; + } + case 'catch_expression': + return this.catch(node, sink); + case 'defer_statement': + case 'errdefer_statement': + // The grammar permits these only as statements in a lexical block. + throw new UnsupportedZigCfg(); + case 'binary_expression': { + const op = node.childForFieldName('operator')?.text; + if (op === 'and' || op === 'or' || op === 'orelse') { + const left = this.visit(node.childForFieldName('left')!, undefined, sink); + const right = this.visit(node.childForFieldName('right')!, undefined, sink); + const join = this.empty(node); + const evaluateRight = op === 'and' ? 'cond-true' : 'cond-false'; + if (left) { + this.builder.connect(left.exits, right?.entry ?? join, evaluateRight); + this.builder.connect( + left.exits, + join, + evaluateRight === 'cond-true' ? 'cond-false' : 'cond-true', + ); + } + if (right) this.builder.connect(right.exits, join); + return { entry: left?.entry ?? join, exits: [join] }; + } + break; + } + case 'variable_declaration': + case 'assignment_expression': { + const value = zigAssignment(node).value; + if (!value || !this.hasControl(value)) break; + // Identifier targets are defined by each normal-result leaf of the value, + // and only on its successful continuation. + const destination = this.harvest.assignmentSink(node); + if (destination) return this.visit(value, undefined, destination) ?? this.simple(node); + // A store reads its receiver once, after the value is carried to it. + const { result, carriers } = this.evaluate([value]); + return this.append(result, this.simple(node, this.harvest.facts(node, carriers))); + } + } + const controls = zigChildren(node) + .flatMap((c) => (c.type === 'arguments' ? zigChildren(c) : [c])) + .filter((c) => this.hasControl(c)); + if (!controls.length) return this.leaf(node, sink); + const { result, carriers } = this.evaluate(controls); + return this.append(result, this.simple(node, this.harvest.facts(node, carriers, sink))); + } + + private branch(node: SyntaxNode, sink?: ZigResultSink): Result { + const { inputs, body, payload, alternative, elsePayload } = zigConditional(node); + if (!inputs.length || !body) throw new UnsupportedZigCfg(); + const condition = this.operand(inputs[0])!; + const yes = this.body(body, payload, { subjects: inputs, sink }); + const no = this.body(alternative, elsePayload, { subjects: inputs, sink }); + const join = this.empty(node); + this.builder.connect(condition.exits, yes?.entry ?? join, 'cond-true'); + this.builder.connect(condition.exits, no?.entry ?? join, 'cond-false'); + if (yes) this.builder.connect(yes.exits, join); + if (no) this.builder.connect(no.exits, join); + return { + entry: condition.entry, + exits: !yes || yes.exits.length || !no || no.exits.length ? [join] : [], + }; + } + + private loop(node: SyntaxNode, label?: string, sink?: ZigResultSink): Result { + const { inputs, body, payload, alternative, elsePayload, update } = zigConditional(node); + if (!body) throw new UnsupportedZigCfg(); + let evaluated: Result = null; + for (const input of inputs) evaluated = this.append(evaluated, this.operand(input)); + const isFor = node.type === 'for_statement' || node.type === 'for_expression'; + let condition = evaluated; + if (isFor) { + const header = this.empty(node); + if (evaluated) this.builder.connect(evaluated.exits, header); + condition = { entry: header, exits: [header] }; + } + if (!condition) + condition = this.simple(node, { line: node.startPosition.row + 1, defs: [], uses: [] }); + const join = this.empty(node); + this.cleanupDepth.set(join, this.cleanup.length); + // A loop expression's value is a `break` operand, or its `else` arm. + if (sink) this.resultSinks.set(join, sink); + let step: Result = null; + if (update) { + // The continuation is inside the loop: its breaks must see this exit, + // including cleanup registered by a block-valued continuation. + const updateEntry = this.empty(update); + this.cleanupDepth.set(updateEntry, this.cleanup.length); + this.context.pushLoop(updateEntry, join, label ? [label] : []); + step = this.visit(update); + this.context.pop(); + this.builder.edge(updateEntry, step?.entry ?? condition.entry, 'seq'); + } + const continueTo = step?.entry ?? condition.entry; + this.cleanupDepth.set(continueTo, this.cleanup.length); + this.context.pushLoop(continueTo, join, label ? [label] : []); + const loopBody = this.body(body, payload, { subjects: inputs }); + this.context.pop(); + const no = this.body(alternative, elsePayload, { subjects: inputs, sink }); + this.builder.connect(condition.exits, loopBody?.entry ?? continueTo, 'cond-true'); + this.builder.connect(condition.exits, no?.entry ?? join, 'cond-false'); + if (loopBody) this.builder.connect(loopBody.exits, continueTo, 'loop-back'); + if (step) this.builder.connect(step.exits, condition.entry, 'loop-back'); + if (no) this.builder.connect(no.exits, join); + return { entry: evaluated?.entry ?? condition.entry, exits: [join] }; + } + + private switch(node: SyntaxNode, sink?: ZigResultSink): Result { + const children = zigChildren(node); + const value = children.find((c) => c.type !== 'block_label' && c.type !== 'switch_case'); + if (!value) throw new UnsupportedZigCfg(); + const label = zigLabel(node); + // `continue :label next` re-dispatches on `next`: the operand and every + // continue value are delivered to one carrier that the dispatch reads. + const carrier = label ? this.harvest.resultTemp(value) : undefined; + const initial = (carrier ? this.visit(value, undefined, carrier) : this.operand(value))!; + const join = this.empty(node); + let dispatch = initial; + if (carrier) { + const header = this.block(value, this.harvest.consume(value, carrier.defs)); + this.builder.connect(initial.exits, header); + dispatch = { entry: header, exits: [header] }; + } + this.cleanupDepth.set(join, this.cleanup.length); + this.cleanupDepth.set(dispatch.entry, this.cleanup.length); + // Zig switches accept only labeled exits. Bare break/continue belongs to + // the enclosing loop, including when this switch happens to have a label. + if (label && carrier) { + if (sink) this.resultSinks.set(join, sink); + this.context.pushLabeledBlock(join, [label]); + this.switchContinues.push({ label, target: dispatch.entry, sink: carrier }); + } + let normal = false; + for (const arm of children.filter((c) => c.type === 'switch_case')) { + const result = this.body(zigChildren(arm).at(-1), zigPayload(arm), { + subjects: [value], + operand: carrier?.defs, + sink, + }); + this.builder.connect(dispatch.exits, result?.entry ?? join, 'switch-case'); + if (result) this.builder.connect(result.exits, join); + if (!result || result.exits.length) normal = true; + } + if (label) { + this.context.pop(); + this.switchContinues.pop(); + } + // Exhaustiveness is type-dependent; retain an unknown unmatched-value escape. + if ( + !children.some((c) => c.type === 'switch_case' && c.children.some((t) => t.type === 'else')) + ) { + this.builder.connect(dispatch.exits, join, 'cond-false'); + normal = true; + } + return { entry: initial.entry, exits: normal || label ? [join] : [] }; + } + + private catch(node: SyntaxNode, sink?: ZigResultSink): Result { + const source = this.visit(zigChildren(node)[0], undefined, sink)!; + const handler = this.body(zigChildren(node).at(-1), zigPayload(node), { + subjects: [zigChildren(node)[0]], + sink, + }); + const join = this.empty(node); + this.builder.connect(source.exits, join, 'cond-true'); + this.builder.connect(source.exits, handler?.entry ?? join, 'cond-false'); + if (handler) this.builder.connect(handler.exits, join); + return { entry: source.entry, exits: [join] }; + } + + private jump(node: SyntaxNode): Result { + const kind = + node.type === 'return_expression' + ? 'return' + : node.type === 'break_expression' + ? 'break' + : 'continue'; + const value = zigJumpValue(node); + const label = zigLabel(node); + let switchEntry: (typeof this.switchContinues)[number] | undefined; + if (label) + for (let i = this.switchContinues.length - 1; i >= 0; i--) { + if (this.switchContinues[i].label === label) { + switchEntry = this.switchContinues[i]; + break; + } + } + const target = + kind === 'return' + ? this.builder.exitIndex + : kind === 'break' + ? this.context.breakTarget(label) + : (switchEntry?.target ?? this.context.continueTarget(label)); + if (target === undefined) throw new UnsupportedZigCfg(); + // A break delivers its value to the loop or labeled block it leaves; a + // labeled-switch continue delivers it to that switch's dispatch. + const sink = kind === 'break' ? this.resultSinks.get(target) : switchEntry?.sink; + const { result: prefix, carriers } = this.evaluate( + value && this.hasControl(value) ? [value] : [], + ); + const jump = this.block(node, this.harvest.facts(node, carriers, sink)); + if (prefix) this.builder.connect(prefix.exits, jump); + if ( + kind === 'return' && + value && + (value.type === 'error_type' || this.errorReturn || this.cleanup.some((c) => c.errorOnly)) + ) { + // Aliases hide an error union's syntax. An explicit error still propagates; + // an unknown value with active errdefer retains both possible completions. + const knownError = value.type === 'error_type'; + const knownSuccess = ['integer', 'float', 'boolean', 'string', 'try_expression'].includes( + value.type, + ); + if (!knownError) this.complete(jump, target, kind); + if (!knownSuccess) this.complete(jump, target, 'throw', true); + } else this.complete(jump, target, kind); + return { entry: prefix?.entry ?? jump, exits: [] }; + } +} + +export function createZigCfgVisitor(): CfgVisitor { + return { + isFunction: (node) => node.type === 'function_declaration', + buildFunctionCfg(fn, filePath): FunctionCfg | undefined { + if (fn.type !== 'function_declaration' || fn.hasError) return undefined; + const body = fn.childForFieldName('body'); + if (!body) return undefined; + const builder = new CfgBuilder( + filePath, + fn.startPosition.row + 1, + fn.endPosition.row + 1, + fn.startPosition.column, + ); + const harvest = new ZigHarvester(fn); + builder.attachFacts(builder.entryIndex, harvest.params()); + const walk = new ZigCfgWalk( + builder, + harvest, + fn.childForFieldName('type')?.type === 'error_union_type', + ); + // The generic collector isolates and counts unsupported bodies. Returning + // undefined here would conceal their missing CFGs as bodyless declarations. + const result = walk.visit(body); + builder.edge(builder.entryIndex, result?.entry ?? builder.exitIndex, 'seq'); + if (result) builder.connect(result.exits, builder.exitIndex); + return builder.finish(harvest.bindingTable()); + }, + }; +} diff --git a/gitnexus/src/core/ingestion/cobol-processor.ts b/gitnexus/src/core/ingestion/cobol-processor.ts index 6fae16805..aceb82b87 100644 --- a/gitnexus/src/core/ingestion/cobol-processor.ts +++ b/gitnexus/src/core/ingestion/cobol-processor.ts @@ -281,12 +281,15 @@ export const processCobol = ( // Graph mapping // --------------------------------------------------------------------------- -/** Generate a deterministic Property node ID using composite key (section:level:name). */ +/** Preserve distinct declarations with the same level/name in different groups. */ function generatePropertyId( filePath: string, - item: { section: string; level: number; name: string }, + item: { section: string; level: number; name: string; line: number }, ): string { - return generateId('Property', `${filePath}:${item.section}:${item.level}:${item.name}`); + return generateId( + 'Property', + `${filePath}:${item.section}:${item.level}:${item.name}@${item.line}`, + ); } /** diff --git a/gitnexus/src/core/ingestion/language-provider.ts b/gitnexus/src/core/ingestion/language-provider.ts index 6dd8ddca1..813d47520 100644 --- a/gitnexus/src/core/ingestion/language-provider.ts +++ b/gitnexus/src/core/ingestion/language-provider.ts @@ -37,6 +37,8 @@ import type { VariableExtractor } from './variable-types.js'; import type { ImportResolverFn } from './import-resolvers/types.js'; import type { SyntaxNode } from './utils/ast-helpers.js'; import type { CfgVisitor } from './cfg/types.js'; +import type { CollectedCfgs } from './cfg/collect.js'; +import type { RenameProvider } from './rename-provider.js'; import type { GraphNode, NodeLabel, ParameterTypeClass, RelationshipType } from 'gitnexus-shared'; import type { ExtractedRoute } from './route-extractors/laravel.js'; import type { SharedSpringType } from './route-extractors/spring-shared.js'; @@ -409,6 +411,9 @@ interface LanguageProviderConfig { ancestorNode: SyntaxNode, ) => { funcName: string; label: NodeLabel } | null; + /** Free functions have distinct graph identities for each callable signature. */ + readonly overloadsFreeFunctions?: boolean; + // ── Template constraint extraction (SFINAE / `requires`) ──────────── /** * Extract a per-language template-constraint payload for a templated @@ -915,11 +920,23 @@ interface LanguageProviderConfig { * gated on the `--pdg` opt-in; the resulting per-function CFGs are serialized * onto `ParsedFile.cfgSideChannel` and emitted as BasicBlock nodes + CFG * edges during scope-resolution. `TNode` is `SyntaxNode` for the tree-sitter - * languages. Default: undefined (language has no CFG support yet — TS/JS are - * the M1 set). + * languages. Providers with a standalone source parser use collectSourceCfgs + * instead. Default: undefined. */ readonly cfgVisitor?: CfgVisitor; + /** Source-backed flow extraction for providers that do not use a tree AST. + * Runs on fresh scope extraction only when PDG is enabled. The provider owns + * executable-region discovery and reports the same caps as AST visitors. */ + readonly collectSourceCfgs?: ( + sourceText: string, + filePath: string, + maxFunctionLines: number, + ) => CollectedCfgs; + + /** Provider-owned source names and rewrites over the existing semantic model. */ + readonly rename?: RenameProvider; + /** * Interpret a raw `@import.statement` capture group into a `ParsedImport`. * The central finalize algorithm resolves `ParsedImport.targetRaw` to a diff --git a/gitnexus/src/core/ingestion/languages/c-cpp.ts b/gitnexus/src/core/ingestion/languages/c-cpp.ts index 88961bcf9..c6e01ac6e 100644 --- a/gitnexus/src/core/ingestion/languages/c-cpp.ts +++ b/gitnexus/src/core/ingestion/languages/c-cpp.ts @@ -1,3 +1,5 @@ +import { cppRenameProvider } from './cpp/rename.js'; +import { cRenameProvider } from './c/rename.js'; /** * C and C++ language providers. * @@ -352,6 +354,7 @@ const cppLabelOverride: NonNullable = ( }; export const cProvider = defineLanguage({ + rename: cRenameProvider, id: SupportedLanguages.C, extensions: ['.c'], entryPointPatterns: [ @@ -386,7 +389,12 @@ export const cProvider = defineLanguage({ /^notify_/, /^control_/, ], - treeSitterQueries: C_QUERIES, + // C field definitions must carry graph identities as well as scope captures. + treeSitterQueries: `${C_QUERIES} + (field_declaration declarator: (field_identifier) @name) @definition.property + (field_declaration declarator: (pointer_declarator declarator: (field_identifier) @name)) @definition.property + (field_declaration declarator: (array_declarator declarator: (field_identifier) @name)) @definition.property + `, typeConfig: cCppConfig, exportChecker: cCppExportChecker, importResolver: createImportResolver(cImportConfig), @@ -436,6 +444,8 @@ export const cProvider = defineLanguage({ }); export const cppProvider = defineLanguage({ + overloadsFreeFunctions: true, + rename: cppRenameProvider, id: SupportedLanguages.CPlusPlus, // CUDA files route through tree-sitter-cpp as a conservative C++-subset parser: // definitions still extract, but CUDA launch syntax (`<<< >>>`) is not modeled as calls. diff --git a/gitnexus/src/core/ingestion/languages/c/arity-metadata.ts b/gitnexus/src/core/ingestion/languages/c/arity-metadata.ts index 013d78ea8..adee38c1e 100644 --- a/gitnexus/src/core/ingestion/languages/c/arity-metadata.ts +++ b/gitnexus/src/core/ingestion/languages/c/arity-metadata.ts @@ -1,9 +1,12 @@ +import type { ParameterTypeClass } from 'gitnexus-shared'; +import { classifyCppParameterType } from '../cpp/arity-metadata.js'; import type { SyntaxNode } from '../../utils/ast-helpers.js'; export interface CArityInfo { parameterCount?: number; requiredParameterCount?: number; parameterTypes?: string[]; + parameterTypeClasses?: ParameterTypeClass[]; } /** @@ -46,12 +49,18 @@ export function computeCDeclarationArity(node: SyntaxNode): CArityInfo { const nonVariadicCount = params.filter((p) => p.type !== 'variadic_parameter').length; const types: string[] = []; + const typeClasses: ParameterTypeClass[] = []; for (const p of params) { if (p.type === 'variadic_parameter') { types.push('...'); + typeClasses.push({ base: '...', cv: 'unknown', indirection: 'unknown', pointerDepth: 0 }); } else { const typeNode = p.childForFieldName('type'); - types.push(typeNode?.text ?? 'unknown'); + const type = typeNode?.text ?? 'unknown'; + types.push(type); + typeClasses.push( + classifyCppParameterType(type, p.childForFieldName('declarator')?.text, p.text), + ); } } @@ -59,6 +68,7 @@ export function computeCDeclarationArity(node: SyntaxNode): CArityInfo { parameterCount: isVariadic ? undefined : nonVariadicCount, requiredParameterCount: nonVariadicCount, parameterTypes: types, + parameterTypeClasses: typeClasses, }; } diff --git a/gitnexus/src/core/ingestion/languages/c/captures.ts b/gitnexus/src/core/ingestion/languages/c/captures.ts index 14049375d..ecd34311b 100644 --- a/gitnexus/src/core/ingestion/languages/c/captures.ts +++ b/gitnexus/src/core/ingestion/languages/c/captures.ts @@ -11,6 +11,7 @@ import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js'; import { splitCInclude } from './import-decomposer.js'; import { computeCDeclarationArity, computeCCallArity } from './arity-metadata.js'; import { markStaticName } from './static-linkage.js'; +import { normalizeCppParamType } from '../cpp/arity-metadata.js'; import { synthesizeReceiverChainCapture } from '../../utils/receiver-chain-captures.js'; import { synthesizeCallableFlowCaptures, @@ -135,7 +136,16 @@ export function emitCScopeCaptures( grouped['@declaration.parameter-types'] = syntheticCapture( '@declaration.parameter-types', fnNode, - JSON.stringify(arity.parameterTypes), + // Headers use the C++ grammar; retain the same signature vocabulary. + JSON.stringify(arity.parameterTypes.map(normalizeCppParamType)), + ); + } + + if (arity.parameterTypeClasses !== undefined) { + grouped['@declaration.parameter-type-classes'] = syntheticCapture( + '@declaration.parameter-type-classes', + fnNode, + JSON.stringify(arity.parameterTypeClasses), ); } diff --git a/gitnexus/src/core/ingestion/languages/c/query.ts b/gitnexus/src/core/ingestion/languages/c/query.ts index c83ab6a6f..aa4f3fc1a 100644 --- a/gitnexus/src/core/ingestion/languages/c/query.ts +++ b/gitnexus/src/core/ingestion/languages/c/query.ts @@ -14,8 +14,8 @@ import { getLanguageGrammar } from '../../../tree-sitter/parser-loader.js'; const C_SCOPE_QUERY = ` ;; Scopes (translation_unit) @scope.module -(struct_specifier) @scope.class -(union_specifier) @scope.class +(struct_specifier body: (field_declaration_list)) @scope.class +(union_specifier body: (field_declaration_list)) @scope.class (function_definition) @scope.function (compound_statement) @scope.block (if_statement) @scope.block @@ -153,6 +153,15 @@ const C_SCOPE_QUERY = ` (parameter_declaration type: (_) @type-binding.type declarator: (identifier) @type-binding.name) @type-binding.parameter +(parameter_declaration + type: (_) @type-binding.type + declarator: (pointer_declarator + declarator: (identifier) @type-binding.name)) @type-binding.parameter +(parameter_declaration + type: (_) @type-binding.type + declarator: (pointer_declarator + declarator: (pointer_declarator + declarator: (identifier) @type-binding.name))) @type-binding.parameter ;; Type bindings — variable with type (init_declarator) (declaration @@ -160,6 +169,15 @@ const C_SCOPE_QUERY = ` declarator: (init_declarator declarator: (identifier) @type-binding.name)) @type-binding.assignment +;; Type bindings — struct and union fields, including pointers. +(field_declaration + type: (_) @type-binding.type + declarator: (field_identifier) @type-binding.name) @type-binding.field +(field_declaration + type: (_) @type-binding.type + declarator: (pointer_declarator + declarator: (field_identifier) @type-binding.name)) @type-binding.field + ;; References — free calls ;; Note: This also captures calls through function pointer variables (e.g. fp(x)) ;; since tree-sitter-c produces structurally identical AST nodes for both direct diff --git a/gitnexus/src/core/ingestion/languages/c/rename.ts b/gitnexus/src/core/ingestion/languages/c/rename.ts new file mode 100644 index 000000000..b20666914 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/c/rename.ts @@ -0,0 +1,220 @@ +import type Parser from 'tree-sitter'; +import { SupportedLanguages } from 'gitnexus-shared'; +import type { + RenameIssue, + RenameOccurrence, + RenameProvider, + RenameSemanticContext, + RenameSource, +} from '../../rename-provider.js'; +import { + createTreeSitterRenameSource, + innermostRenameScope, + nodeRange, + walkNamedNodes, +} from '../rename-tree-sitter.js'; +import { callableDeclarationFamilies } from '../../scope-resolution/passes/callable-value-flow.js'; +import { buildGraphNodeLookup } from '../../scope-resolution/graph-bridge/node-lookup.js'; +import { simpleQualifiedName } from '../../scope-resolution/graph-bridge/ids.js'; +import { isStaticName } from './static-linkage.js'; +import { isFileLocal } from '../cpp/file-local-linkage.js'; + +export const cKeywords = new Set( + 'auto break case char const continue default do double else enum extern float for goto if inline int long register restrict return short signed sizeof static struct switch typedef union unsigned void volatile while _Alignas _Alignof _Atomic _Bool _Complex _Generic _Imaginary _Noreturn _Static_assert _Thread_local alignas alignof bool constexpr false nullptr static_assert thread_local true typeof typeof_unqual _BitInt'.split( + ' ', + ), +); +const names = new Set([ + 'identifier', + 'type_identifier', + 'field_identifier', + 'statement_identifier', +]); + +export function cMacroNames(source: RenameSource): readonly string[] { + const names: string[] = []; + if (!source.tree) return names; + for (const node of walkNamedNodes(source.tree.rootNode)) { + if (node.type !== 'preproc_arg') continue; + // Macro replacement lists are opaque to tree-sitter. Inventory identifiers, + // excluding complete literals/comments; never infer an expansion binding. + const tokens = + node.text.match( + /"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|\/\*[\s\S]*?\*\/|\/\/[^\n]*|[_\p{ID_Start}][_\p{ID_Continue}]*/gu, + ) ?? []; + names.push(...tokens.filter((token) => /^[_\p{ID_Start}][_\p{ID_Continue}]*$/u.test(token))); + } + return names; +} + +export function checkCMacroSafety(context: RenameSemanticContext): RenameIssue | void { + const targetName = simpleQualifiedName(context.target); + for (const source of context.files.values()) { + if (cMacroNames(source).includes(targetName ?? '')) + return { + code: 'unsupported_occurrence', + status: 'unsupported', + message: 'A macro replacement list contains this name without a proven source binding.', + }; + } +} + +export function cNameRole(node: Parser.SyntaxNode): RenameOccurrence['role'] { + if (node.type === 'statement_identifier') return 'unrelated'; + for (let parent = node.parent; parent; parent = parent.parent) { + if (parent.type === 'parameter_declaration') { + const declarator = parent.childForFieldName('declarator'); + if ( + declarator && + node.startIndex >= declarator.startIndex && + node.endIndex <= declarator.endIndex + ) + return 'binding'; + break; + } + if (!parent.type.includes('declarator')) break; + } + return 'name'; +} + +export function cNameReference(node: Parser.SyntaxNode, source: RenameSource) { + const parent = node.parent; + if ( + !parent || + node.type === 'field_identifier' || + node.type === 'statement_identifier' || + parent.type.startsWith('preproc_') + ) + return undefined; + if ( + parent.childForFieldName('declarator')?.id === node.id || + (parent.childForFieldName('name')?.id === node.id && + ['enum_specifier', 'struct_specifier', 'union_specifier'].includes(parent.type) && + parent.childForFieldName('body')) + ) + return undefined; + const inScope = innermostRenameScope(source.parsed, node); + if (!inScope) return undefined; + const range = nodeRange(node); + return { + name: node.text, + nameRange: range, + atRange: range, + inScope, + kind: 'read' as const, + ...(node.type === 'type_identifier' ? { lookupPurpose: 'type' as const } : {}), + }; +} + +/** Parameters shadow outer values even when their primitive type is not indexed. */ +export function withParameterClaims(source: RenameSource): RenameSource { + const claims = new Map(); + for (const occurrence of source.occurrences) { + if (occurrence.role !== 'binding' || !occurrence.node) continue; + const scope = innermostRenameScope(source.parsed, occurrence.node); + if (!scope) continue; + claims.set(scope, [ + ...(claims.get(scope) ?? []), + { + name: occurrence.name, + range: occurrence.range, + kind: 'binding', + purpose: 'value', + hoisted: true, + }, + ]); + } + return { + ...source, + parsed: { + ...source.parsed, + scopes: source.parsed.scopes.map((scope) => ({ + ...scope, + nameClaims: [...(scope.nameClaims ?? []), ...(claims.get(scope.id) ?? [])], + })), + }, + }; +} + +/** C local values enter scope after their declarator, including inside an initializer. */ +function withLocalValueClaims(source: RenameSource): RenameSource { + const occurrences = new Map( + source.occurrences + .filter((occurrence) => occurrence.definitionId) + .map((occurrence) => [occurrence.definitionId, occurrence]), + ); + return { + ...source, + parsed: { + ...source.parsed, + scopes: source.parsed.scopes.map((scope) => { + const claims = [...(scope.nameClaims ?? [])]; + for (const [name, bindings] of scope.bindings) { + for (const { origin, def } of bindings) { + if (origin !== 'local' || !['Variable', 'Const'].includes(def.type)) continue; + const occurrence = occurrences.get(def.nodeId); + if (!occurrence?.node) continue; + let declarator = occurrence.node; + while ( + declarator.parent?.type.includes('declarator') && + declarator.parent.type !== 'init_declarator' + ) + declarator = declarator.parent; + const range = nodeRange(declarator); + claims.push({ + name, + range: occurrence.range, + kind: 'binding', + purpose: 'value', + inactive: 'outer', + availableFrom: { startLine: range.endLine, startCol: range.endCol }, + }); + } + } + return { ...scope, nameClaims: claims }; + }), + }, + }; +} + +const parseSource = createTreeSitterRenameSource({ + isName: (node) => names.has(node.type), + skip: (node) => node.type === 'comment', + role: cNameRole, + reference: cNameReference, +}); + +export const cRenameProvider: RenameProvider = { + checkSafety: checkCMacroSafety, + additionalInteropNames: cMacroNames, + // Sources that declare or call a C function by its own name, without a proven binding. + interoperatesWithSourceProvider: (provider) => + [ + SupportedLanguages.CPlusPlus, + SupportedLanguages.ObjectiveC, + SupportedLanguages.Swift, + SupportedLanguages.Rust, + SupportedLanguages.Go, + SupportedLanguages.Zig, + ].includes(provider.id), + acceptsSourceProvider: (provider, filePath) => + provider.id === SupportedLanguages.C || + (provider.id === SupportedLanguages.CPlusPlus && filePath.endsWith('.h')), + symbolFamily(context) { + return ( + callableDeclarationFamilies( + context.indexes, + context.graph, + buildGraphNodeLookup(context.graph), + (def) => + isStaticName(def.filePath, simpleQualifiedName(def) ?? '') || + isFileLocal(def.filePath, simpleQualifiedName(def) ?? ''), + ).get(context.target.nodeId) ?? [context.target] + ); + }, + validateName(name) { + if (!/^[_\p{ID_Start}][_\p{ID_Continue}]*$/u.test(name) || cKeywords.has(name)) + return { code: 'invalid_name', message: 'Choose a valid nonreserved C identifier.' }; + }, + parseSource: async (input) => withLocalValueClaims(withParameterClaims(await parseSource(input))), +}; diff --git a/gitnexus/src/core/ingestion/languages/cobol.ts b/gitnexus/src/core/ingestion/languages/cobol.ts index 4a8ba2e2e..ea754f1e9 100644 --- a/gitnexus/src/core/ingestion/languages/cobol.ts +++ b/gitnexus/src/core/ingestion/languages/cobol.ts @@ -12,6 +12,8 @@ */ import { SupportedLanguages } from 'gitnexus-shared'; import { defineLanguage } from '../language-provider.js'; +import { collectCobolSourceCfgs } from './cobol/flow.js'; +import { cobolRenameProvider } from './cobol/rename.js'; import { emitCobolScopeCaptures, interpretCobolImport, @@ -22,7 +24,7 @@ import { export const cobolProvider = defineLanguage({ id: SupportedLanguages.Cobol, parseStrategy: 'standalone', - extensions: [], // COBOL files detected by cobol-processor's isCobolFile/isJclFile + extensions: ['.cbl', '.cob', '.cobol', '.cpy', '.copybook'], entryPointPatterns: [], astFrameworkPatterns: [], treeSitterQueries: '', @@ -33,11 +35,11 @@ export const cobolProvider = defineLanguage({ }, exportChecker: () => false, importResolver: () => null, + rename: cobolRenameProvider, - // No `cfgVisitor`: COBOL is the deliberate non-goal of the PDG-language - // rollout (#2195). There is no installed tree-sitter grammar and COBOL's - // PERFORM / GO-TO control flow is exotic; the worker's `provider.cfgVisitor &&` - // gate therefore emits no CFG/PDG layer for COBOL (see worker-roundtrip.test.ts). + // Standalone source provider: program regions preserve paragraph fallthrough + // and PERFORM return continuations without introducing an AST dependency. + collectSourceCfgs: collectCobolSourceCfgs, // ── Scope-resolution hooks ─────────────────────────────────────── emitScopeCaptures: emitCobolScopeCaptures, @@ -47,13 +49,8 @@ export const cobolProvider = defineLanguage({ // anything runs, so a copybook cycle built from `COPY` statements is real and // must not be tagged `runsOnlyWhenCalled` by the central Pass-3 position rule. // - // LATENT today, declared anyway. `cobol/captures.ts` ranges every - // `@scope.function` (PROCEDURE DIVISION sections and paragraphs) over a - // SINGLE line — `rangeOf(line, 0, line, endCol)` — so a `COPY` on any later - // line never resolves inside a Function scope and Pass 3 has nothing to mark. - // The flag is here so that giving those anchors their true multi-line ranges - // is a scope-resolution fix and not, silently, a cycle-suppression bug. - // See `LanguageProvider.importsExecuteWhereWritten`. + // Procedure scopes include their bodies, so COPY inside a paragraph must + // still retain its textual-splice semantics. importsExecuteWhereWritten: false, importOwningScope: cobolImportOwningScope, receiverBinding: cobolReceiverBinding, diff --git a/gitnexus/src/core/ingestion/languages/cobol/captures.ts b/gitnexus/src/core/ingestion/languages/cobol/captures.ts index 6eee60bf3..3d6698514 100644 --- a/gitnexus/src/core/ingestion/languages/cobol/captures.ts +++ b/gitnexus/src/core/ingestion/languages/cobol/captures.ts @@ -11,10 +11,8 @@ */ import type { Capture, CaptureMatch, Range } from 'gitnexus-shared'; -import { - extractCobolSymbolsWithRegex, - preprocessCobolSource, -} from '../../cobol/cobol-preprocessor.js'; +import { preprocessCobolSource } from '../../cobol/cobol-preprocessor.js'; +import { cobolSemanticCaptures, readCobolSemanticSource } from './semantic.js'; // --------------------------------------------------------------------------- // Capture building helpers @@ -28,15 +26,6 @@ function rangeOf(startLine: number, startCol: number, endLine: number, endCol: n return { startLine, startCol, endLine, endCol }; } -/** - * Build a single CaptureMatch from a record of captures. - * Returns null if the record is empty. - */ -function matchFrom(grouped: Record): CaptureMatch | null { - if (Object.keys(grouped).length === 0) return null; - return Object.freeze(grouped) as CaptureMatch; -} - /** * Compute end column for a single-line capture from the source lines array. */ @@ -56,8 +45,8 @@ export function emitCobolScopeCaptures( const lines = sourceText.split(/\r?\n/); // Preprocess: strip patch markers from columns 1-6 const cleaned = preprocessCobolSource(sourceText); - // Run the regex tagger on the preprocessed source - const extracted = extractCobolSymbolsWithRegex(cleaned, _filePath); + const facts = readCobolSemanticSource(sourceText, _filePath); + const extracted = facts.extracted; const out: CaptureMatch[] = []; const procedurePointers = new Set( @@ -78,221 +67,15 @@ export function emitCobolScopeCaptures( if (declaration !== null) procedurePointers.add(declaration[1]!.toUpperCase()); } - // ── 1. PROGRAM-ID → @scope.module ─────────────────────────────────── - // The primary program name (first PROGRAM-ID encountered) - if (extracted.programName) { - const name = extracted.programName; - const lastLine = lines.length; - - const progDef = extracted.programs.find((p) => p.name.toUpperCase() === name.toUpperCase()); - const startLine = progDef?.startLine ?? 1; - const endLine = progDef?.endLine ?? lastLine; - const startCol = 0; - const endCol = endColFrom(lines[Math.min(endLine, lines.length) - 1] ?? ''); - - const progIdLine = findProgramIdLine(cleaned, name); - // Determine PROGRAM-ID name column: free-format has no fixed column; - // fixed-format uses column 7 (after 6-char sequence area replaced by preprocessing) - const isFreeFormat = />>SOURCE\s+(?:FORMAT\s+(?:IS\s+)?)?FREE/i.test(cleaned); - const nameCol = isFreeFormat ? findProgramIdNameColumn(lines, progIdLine) : 7; - const nameRange = - progIdLine !== -1 - ? rangeOf(progIdLine, nameCol, progIdLine, lines[progIdLine - 1]?.length ?? endCol) - : rangeOf(startLine, startCol, endLine, endCol); - - const grouped: Record = { - '@scope.module': capture( - '@scope.module', - rangeOf(startLine, startCol, endLine, endCol), - name, - ), - '@declaration.program': capture( - '@declaration.program', - rangeOf(startLine, startCol, endLine, endCol), - name, - ), - '@declaration.name': capture('@declaration.name', nameRange, name), - }; - - if (progDef?.procedureUsing && progDef.procedureUsing.length > 0) { - grouped['@declaration.parameter-count'] = capture( - '@declaration.parameter-count', - nameRange, - String(progDef.procedureUsing.length), - ); - } - - const m = matchFrom(grouped); - if (m !== null) out.push(m); - } - - // ── 2. Nested / additional programs → @scope.module ────────────── - for (const prog of extracted.programs) { - if (extracted.programName && prog.name.toUpperCase() === extracted.programName.toUpperCase()) - continue; - - const startLine = prog.startLine; - const endLine = prog.endLine; - const startCol = 0; - const endCol = endColFrom(lines[Math.min(endLine, lines.length) - 1] ?? ''); - - const progIdLine = findProgramIdLine(cleaned, prog.name); - const isFreeFormatNested = />>SOURCE\s+(?:FORMAT\s+(?:IS\s+)?)?FREE/i.test(cleaned); - const nameColNested = isFreeFormatNested ? findProgramIdNameColumn(lines, progIdLine) : 7; - const nameRange = - progIdLine !== -1 - ? rangeOf(progIdLine, nameColNested, progIdLine, lines[progIdLine - 1]?.length ?? endCol) - : rangeOf(startLine, startCol, endLine, endCol); - - const grouped: Record = { - '@scope.module': capture( - '@scope.module', - rangeOf(startLine, startCol, endLine, endCol), - prog.name, - ), - '@declaration.program': capture( - '@declaration.program', - rangeOf(startLine, startCol, endLine, endCol), - prog.name, - ), - '@declaration.name': capture('@declaration.name', nameRange, prog.name), - }; - - if (prog.procedureUsing && prog.procedureUsing.length > 0) { - grouped['@declaration.parameter-count'] = capture( - '@declaration.parameter-count', - nameRange, - String(prog.procedureUsing.length), - ); - } - - const m = matchFrom(grouped); - if (m !== null) out.push(m); - } - - // ── 3. PROCEDURE DIVISION sections → @scope.function ───────────── - for (const section of extracted.sections) { - const lineIdx = section.line - 1; - if (lineIdx < 0 || lineIdx >= lines.length) continue; - - const sectionLine = lines[lineIdx]; - const startCol = 0; - const endCol = endColFrom(sectionLine); - const nameRange = rangeOf(section.line, startCol, section.line, endCol); - - const grouped: Record = { - '@scope.function': capture('@scope.function', nameRange, section.name), - '@declaration.function': capture('@declaration.function', nameRange, section.name), - '@declaration.name': capture('@declaration.name', nameRange, section.name), - }; - - const m = matchFrom(grouped); - if (m !== null) out.push(m); - } - - // ── 4. Paragraphs → @scope.function ────────────────────────────── - for (const para of extracted.paragraphs) { - const lineIdx = para.line - 1; - if (lineIdx < 0 || lineIdx >= lines.length) continue; - - const paraLine = lines[lineIdx]; - const startCol = 0; - const endCol = endColFrom(paraLine); - const nameRange = rangeOf(para.line, startCol, para.line, endCol); - - const grouped: Record = { - '@scope.function': capture('@scope.function', nameRange, para.name), - '@declaration.function': capture('@declaration.function', nameRange, para.name), - '@declaration.name': capture('@declaration.name', nameRange, para.name), - }; - - const m = matchFrom(grouped); - if (m !== null) out.push(m); - } - - // ── 5. COPY → @import.statement ────────────────────────────────── + // Exact declaration/reference captures share the provider's semantic source + // facts with ordinary resolution and rename; callable-value facts remain below. + out.push(...cobolSemanticCaptures(facts)); for (const copy of extracted.copies) { - const lineIdx = copy.line - 1; - if (lineIdx < 0 || lineIdx >= lines.length) continue; - - const copyLine = lines[lineIdx]; - const startCol = 0; - const endCol = endColFrom(copyLine); - const stmtRange = rangeOf(copy.line, startCol, copy.line, endCol); - - const grouped: Record = { + const stmtRange = rangeOf(copy.line, 0, copy.line, lines[copy.line - 1]?.length ?? 0); + out.push({ '@import.statement': capture('@import.statement', stmtRange, copy.target), '@import.name': capture('@import.name', stmtRange, copy.target), - }; - - const m = matchFrom(grouped); - if (m !== null) out.push(m); - } - - // ── 6. CALL (quoted/referenced) → @reference.call ──────────────── - for (const call of extracted.calls) { - const lineIdx = call.line - 1; - if (lineIdx < 0 || lineIdx >= lines.length) continue; - - const callLine = lines[lineIdx]; - const startCol = 0; - const endCol = endColFrom(callLine); - const stmtRange = rangeOf(call.line, startCol, call.line, endCol); - - const grouped: Record = { - '@reference.call': capture('@reference.call', stmtRange, call.target), - '@reference.name': capture('@reference.name', stmtRange, call.target), - }; - - // Arity from CALL USING parameters - if (call.parameters && call.parameters.length > 0) { - grouped['@reference.arity'] = capture( - '@reference.arity', - stmtRange, - String(call.parameters.length), - ); - } - - const m = matchFrom(grouped); - if (m !== null) out.push(m); - } - - // ── 7. PERFORM → @reference.call ───────────────────────────────── - for (const perf of extracted.performs) { - const lineIdx = perf.line - 1; - if (lineIdx < 0 || lineIdx >= lines.length) continue; - - const perfLine = lines[lineIdx]; - const startCol = 0; - const endCol = endColFrom(perfLine); - const stmtRange = rangeOf(perf.line, startCol, perf.line, endCol); - - const grouped: Record = { - '@reference.call': capture('@reference.call', stmtRange, perf.target), - '@reference.name': capture('@reference.name', stmtRange, perf.target), - }; - - const m = matchFrom(grouped); - if (m !== null) out.push(m); - } - - // ── 8. GO TO → @reference.call ─────────────────────────────────── - for (const gt of extracted.gotos) { - const lineIdx = gt.line - 1; - if (lineIdx < 0 || lineIdx >= lines.length) continue; - - const gtLine = lines[lineIdx]; - const startCol = 0; - const endCol = endColFrom(gtLine); - const stmtRange = rangeOf(gt.line, startCol, gt.line, endCol); - - const grouped: Record = { - '@reference.call': capture('@reference.call', stmtRange, gt.target), - '@reference.name': capture('@reference.name', stmtRange, gt.target), - }; - - const m = matchFrom(grouped); - if (m !== null) out.push(m); + }); } // ── 9. Procedure-pointer value flow ──────────────────────────────── @@ -414,42 +197,3 @@ export function emitCobolScopeCaptures( return out; } - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -/** - * Find the PROGRAM-ID. line for a given program name in the cleaned source. - * Returns 1-based line number, or -1 if not found. - */ -function findProgramIdLine(cleanedSource: string, programName: string): number { - const lines = cleanedSource.split(/\r?\n/); - const upper = programName.toUpperCase(); - const re = new RegExp(`\\bPROGRAM-ID\\.\\s*${escapeRegex(upper)}\\b`, 'i'); - for (let i = 0; i < lines.length; i++) { - if (re.test(lines[i])) return i + 1; // 1-based - } - return -1; -} - -/** Simple regex escape for special chars. */ -function escapeRegex(s: string): string { - return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} - -/** - * Find the column position of the program name on the PROGRAM-ID line. - * Searches for `PROGRAM-ID. name` and returns the column where name starts. - * Returns 0 as fallback if the line can't be parsed (the range will be - * from column 0 which is still valid for capture bounds). - */ -function findProgramIdNameColumn(lines: string[], lineNum: number): number { - if (lineNum < 1 || lineNum > lines.length) return 0; - const line = lines[lineNum - 1]; - const m = line.match(/\bPROGRAM-ID\.\s+([A-Z0-9][A-Z0-9-]*)/i); - if (!m || m.index === undefined) return 0; - // Column = index of start of capture group 1 - const nameStart = m.index + m[0].length - m[1].length; - return nameStart; -} diff --git a/gitnexus/src/core/ingestion/languages/cobol/flow-harvest.ts b/gitnexus/src/core/ingestion/languages/cobol/flow-harvest.ts new file mode 100644 index 000000000..74f52b09e --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/cobol/flow-harvest.ts @@ -0,0 +1,289 @@ +/** COBOL storage bindings and statement facts for the shared reaching-def solver. */ +import type { BindingEntry, StatementFacts, SiteRecord } from '../../cfg/types.js'; +import type { CobolRegexResults } from '../../cobol/cobol-preprocessor.js'; +import { CobolFlowUnsupported, type CobolToken } from './flow-source.js'; + +interface DataBinding { + readonly index: number; + readonly name: string; + readonly parents: readonly string[]; + readonly level: number; + readonly storage?: number; +} +interface Reference { + readonly bindings: readonly number[]; + readonly indices: readonly number[]; + readonly partial: boolean; + readonly position: number; +} + +const KEYWORDS = new Set( + ( + 'TO FROM BY INTO GIVING REMAINDER ROUNDED IS NOT AND OR EQUAL EQUALS GREATER LESS THAN ' + + 'THEN TRUE FALSE ZERO ZEROS ZEROES SPACE SPACES HIGH-VALUES LOW-VALUES QUOTES NULL NULLS ' + + 'ALL CORRESPONDING CORR OF IN ON UP DOWN WITH NO ADVANCING UPON CONSOLE SYSIN SYSOUT ' + + 'RETURNING USING REFERENCE CONTENT VALUE LENGTH ADDRESS FUNCTION ' + + 'UNTIL VARYING TEST BEFORE AFTER TIMES FOREVER OTHER ALSO WHEN ' + + 'END-CALL END-COMPUTE END-ADD END-SUBTRACT END-MULTIPLY END-DIVIDE END-ACCEPT END-DISPLAY' + ).split(' '), +); + +const unique = (items: readonly number[]): number[] => [...new Set(items)]; + +export class CobolHarvester { + readonly bindings: BindingEntry[] = []; + private readonly declared: DataBinding[] = []; + private readonly synthetic = new Map(); + + constructor( + items: readonly CobolRegexResults['dataItems'][number][], + private readonly tokens: readonly CobolToken[], + parameters: readonly string[], + ) { + const parents: DataBinding[] = []; + for (const item of items) { + if (item.redefines || item.level === 66) + throw new CobolFlowUnsupported('storage alias declaration'); + while (parents.length && parents.at(-1)!.level >= item.level) parents.pop(); + if ([1, 77].includes(item.level)) parents.length = 0; + const name = item.name.toUpperCase(); + if (name === 'FILLER') continue; + const chain = [...parents].reverse().map((p) => p.name); + const canonical = [name, ...chain].join(' OF '); + const index = this.bindings.length; + const formalIndex = parameters.findIndex((p) => p.toUpperCase() === name); + const declaration = tokens.find((t) => t.line === item.line && t.upper === name); + this.bindings.push({ + name: canonical, + declLine: item.line, + declColumn: declaration?.column ?? 0, + kind: formalIndex >= 0 ? 'param' : 'var', + ...(formalIndex >= 0 ? { formalIndex } : {}), + }); + const binding: DataBinding = { + index, + name, + parents: chain, + level: item.level, + ...(item.level === 88 && parents.length ? { storage: parents.at(-1)!.index } : {}), + }; + this.declared.push(binding); + if (item.level !== 88) parents.push(binding); + } + } + + entryFacts(line: number): StatementFacts { + return { line, defs: this.bindings.map((_, i) => i), uses: [] }; + } + + private resolve(name: string, qualifiers: readonly string[]): number[] { + const candidates = this.declared.filter((b) => { + if (b.name !== name) return false; + let position = 0; + for (const q of qualifiers) { + const found = b.parents.indexOf(q, position); + if (found < 0) return false; + position = found + 1; + } + return true; + }); + if (candidates.length > 1) throw new CobolFlowUnsupported('ambiguous data name ' + name); + const found = candidates[0]; + if (found) { + if (found.storage !== undefined) return [found.storage]; + // Group operations touch their subordinate storage as well. + const ancestry = [found.name, ...found.parents]; + const descendants = this.declared.filter( + (b) => + b.index > found.index && + b.parents.length >= ancestry.length && + ancestry.every((name, i) => b.parents[b.parents.length - ancestry.length + i] === name), + ); + return unique([ + found.index, + ...descendants.filter((b) => b.storage === undefined).map((b) => b.index), + ]); + } + const key = [name, ...qualifiers].join(' OF '); + let index = this.synthetic.get(key); + if (index === undefined) { + index = this.bindings.length; + this.bindings.push({ + name: key, + declLine: 0, + declColumn: 0, + kind: 'module', + synthetic: true, + }); + this.synthetic.set(key, index); + } + return [index]; + } + + private references(tokens: readonly CobolToken[]): Reference[] { + const out: Reference[] = []; + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i]!; + if (token.kind !== 'word') continue; + if (KEYWORDS.has(token.upper) && !this.declared.some((b) => b.name === token.upper)) continue; + const position = i; + const qualifiers: string[] = []; + while (['OF', 'IN'].includes(tokens[i + 1]?.upper ?? '') && tokens[i + 2]?.kind === 'word') { + qualifiers.push(tokens[i + 2]!.upper); + i += 2; + } + const indices: number[] = []; + let partial = false; + if (tokens[i + 1]?.upper === '(') { + partial = true; + const begin = i + 2; + let depth = 1; + i += 2; + while (i < tokens.length && depth > 0) { + if (tokens[i]!.upper === '(') depth++; + if (tokens[i]!.upper === ')') depth--; + if (depth > 0) i++; + } + if (depth) throw new CobolFlowUnsupported('unterminated subscript'); + indices.push(...this.uses(tokens.slice(begin, i))); + } + out.push({ bindings: this.resolve(token.upper, qualifiers), indices, partial, position }); + } + return out; + } + + uses(tokens: readonly CobolToken[]): number[] { + return unique(this.references(tokens).flatMap((r) => [...r.bindings, ...r.indices])); + } + + facts( + tokens: readonly CobolToken[], + read: readonly CobolToken[] = [], + written: readonly CobolToken[] = [], + readWritten = false, + ): StatementFacts { + const defs: number[] = [], + mayDefs: number[] = [], + uses = this.uses(read); + for (const ref of this.references(written)) { + (ref.partial ? mayDefs : defs).push(...ref.bindings); + uses.push(...ref.indices, ...(readWritten ? ref.bindings : [])); + } + return { + line: tokens[0]!.line, + defs: unique(defs), + uses: unique(uses), + ...(mayDefs.length ? { mayDefs: unique(mayDefs) } : {}), + }; + } + + statement(tokens: readonly CobolToken[]): StatementFacts { + const verb = tokens[0]!.upper; + const rest = tokens.slice(1); + const at = (word: string) => rest.findIndex((t) => t.upper === word); + const split = (word: string, readWritten = false) => { + const n = at(word); + if (n < 0) throw new CobolFlowUnsupported('missing ' + word + ' in ' + verb); + return this.facts(tokens, rest.slice(0, n), rest.slice(n + 1), readWritten); + }; + if ( + ['MOVE', 'INITIALIZE', 'ADD', 'SUBTRACT'].includes(verb) && + rest.some((t) => ['CORRESPONDING', 'CORR', 'REPLACING'].includes(t.upper)) + ) { + throw new CobolFlowUnsupported('group correspondence/replacement'); + } + switch (verb) { + case 'MOVE': + return split('TO'); + case 'COMPUTE': { + const n = at('='); + if (n < 0) throw new CobolFlowUnsupported('missing COMPUTE assignment'); + return this.facts(tokens, rest.slice(n + 1), rest.slice(0, n)); + } + case 'ADD': + case 'SUBTRACT': + case 'MULTIPLY': + case 'DIVIDE': { + const giving = at('GIVING'); + if (giving >= 0) return this.facts(tokens, rest.slice(0, giving), rest.slice(giving + 1)); + return split( + verb === 'ADD' ? 'TO' : verb === 'SUBTRACT' ? 'FROM' : verb === 'DIVIDE' ? 'INTO' : 'BY', + true, + ); + } + case 'SET': { + const to = at('TO'); + if (to >= 0) return this.facts(tokens, rest.slice(to + 1), rest.slice(0, to)); + const direction = rest.findIndex((t) => ['UP', 'DOWN'].includes(t.upper)); + if (direction < 0) throw new CobolFlowUnsupported('unsupported SET'); + return this.facts(tokens, rest.slice(direction + 1), rest.slice(0, direction), true); + } + case 'ACCEPT': + return this.facts( + tokens, + at('FROM') < 0 ? [] : rest.slice(at('FROM') + 1), + at('FROM') < 0 ? rest : rest.slice(0, at('FROM')), + ); + case 'INITIALIZE': + return this.facts(tokens, [], rest); + case 'DISPLAY': + return this.facts(tokens, rest); + case 'CALL': + return this.call(tokens); + default: + return this.facts(tokens); + } + } + + private call(tokens: readonly CobolToken[]): StatementFacts { + const target = tokens[1]; + if (!target) throw new CobolFlowUnsupported('missing CALL target'); + const using = tokens.findIndex((t) => t.upper === 'USING'); + const returning = tokens.findIndex((t) => t.upper === 'RETURNING'); + const operands = + using < 0 ? [] : tokens.slice(using + 1, returning < 0 ? undefined : returning); + const refs = this.references(operands); + const uses = target.kind === 'literal' ? [] : this.uses([target]); + const mayDefs: number[] = []; + const args: number[][] = []; + let mode = 'REFERENCE'; + let refIndex = 0; + // COBOL USING operands are identifiers/literals, each occupying one slot. + for (let p = 0; p < operands.length; p++) { + const token = operands[p]!; + if (token.upper === 'BY') { + mode = operands[++p]?.upper ?? 'REFERENCE'; + continue; + } + const ref = refs[refIndex]; + if (ref?.position === p) { + args.push(unique([...ref.bindings, ...ref.indices])); + uses.push(...ref.bindings, ...ref.indices); + if (mode === 'REFERENCE') mayDefs.push(...ref.bindings); + refIndex++; + } else if (['literal', 'number'].includes(token.kind) || token.upper === 'OMITTED') { + // Numbers inside a subscript do not introduce another CALL argument. + const prior = refs[refIndex - 1]; + const insideIndex = + prior?.partial && + operands.slice(prior.position, p).filter((t) => t.upper === '(').length > + operands.slice(prior.position, p).filter((t) => t.upper === ')').length; + if (!insideIndex) args.push([]); + } + } + const result = this.facts(tokens, [], returning < 0 ? [] : tokens.slice(returning + 1)); + const site: SiteRecord = { + kind: 'call', + ...(target.kind === 'literal' ? { callee: target.text.slice(1, -1) } : {}), + ...(args.length ? { args } : {}), + ...(result.defs.length ? { resultDefs: result.defs } : {}), + at: [tokens[0]!.line, tokens[0]!.column], + }; + return { + ...result, + uses: unique([...uses, ...result.uses]), + ...(mayDefs.length ? { mayDefs: unique([...mayDefs, ...(result.mayDefs ?? [])]) } : {}), + sites: [site], + }; + } +} diff --git a/gitnexus/src/core/ingestion/languages/cobol/flow-source.ts b/gitnexus/src/core/ingestion/languages/cobol/flow-source.ts new file mode 100644 index 000000000..aa58feb98 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/cobol/flow-source.ts @@ -0,0 +1,248 @@ +/** + * Source-only COBOL procedure parser. This is a bounded control-flow reader, + * not a compiler: an unrecognized executable construct rejects its program. + * All token positions stay in the original file's line/column coordinates. + */ +import { CfgNestingDepthError, MAX_CFG_NESTING_DEPTH } from '../../cfg/cfg-builder.js'; +import { preprocessCobolSource } from '../../cobol/cobol-preprocessor.js'; + +export interface CobolToken { + readonly text: string; + readonly upper: string; + readonly line: number; + readonly column: number; + readonly kind: 'word' | 'literal' | 'number' | 'punctuation' | 'invalid'; +} + +export const tokenText = (tokens: readonly CobolToken[]): string => + tokens.map((t) => t.text).join(' '); + +/** Reuse the ingestion preprocessor; mask indicator/identification areas. */ +export function tokenizeCobol(source: string): CobolToken[] { + const free = />>SOURCE\s+(?:FORMAT\s+(?:IS\s+)?)?FREE/i.test(source.split('\n', 10).join('\n')); + const lines = preprocessCobolSource(source).split(/\r?\n/); + const out: CobolToken[] = []; + for (let row = 0; row < lines.length; row++) { + const raw = lines[row]!; + if (free ? /^\s*\*>/.test(raw) : /[*/]/.test(raw[6] ?? '')) continue; + // Debug lines are conditional compiler input. Split-word/literal continuation + // requires lexical stitching; reject it instead of inventing new bindings. + if (!free && /[dD-]/.test(raw[6] ?? '')) { + out.push({ text: raw, upper: raw.toUpperCase(), line: row + 1, column: 6, kind: 'invalid' }); + continue; + } + const code = free ? raw : ' ' + raw.slice(7, 72); + if (/^\s*>>SOURCE/.test(code)) continue; + const re = /"(?:""|[^"])*"|'(?:''|[^'])*'|\*>.*|\d+\.\d+|[A-Za-z0-9][A-Za-z0-9_-]*|[^\s]/g; + for (const m of code.matchAll(re)) { + const text = m[0]; + if (text.startsWith('*>')) break; + const kind = /^['"]/.test(text) + ? text.length > 1 && text.at(-1) === text[0] + ? 'literal' + : 'invalid' + : /[A-Za-z]/.test(text) + ? 'word' + : /^\d+(?:\.\d+)?$/.test(text) + ? 'number' + : 'punctuation'; + out.push({ text, upper: text.toUpperCase(), line: row + 1, column: m.index!, kind }); + } + } + return out; +} + +const VERBS = new Set( + ( + 'IF EVALUATE PERFORM MOVE COMPUTE ADD SUBTRACT MULTIPLY DIVIDE SET INITIALIZE ' + + 'DISPLAY ACCEPT CALL CONTINUE EXIT GOBACK STOP GO GOTO ' + + 'ALTER EXEC SEARCH SORT MERGE READ WRITE REWRITE DELETE START OPEN CLOSE ' + + 'STRING UNSTRING INSPECT NEXT CANCEL RELEASE RETURN ENTRY INVOKE ' + + 'DECLARATIVES USE COPY' + ).split(' '), +); +const SIMPLE = new Set( + ( + 'MOVE COMPUTE ADD SUBTRACT MULTIPLY DIVIDE SET INITIALIZE DISPLAY ACCEPT CALL ' + + 'CONTINUE EXIT GOBACK STOP GO GOTO' + ).split(' '), +); +const TERMINATORS = new Set(['ELSE', 'WHEN', 'END-IF', 'END-EVALUATE', 'END-PERFORM', '.']); + +/** The legacy tagger excludes one-letter names; procedure labels do not. */ +export function isCobolParagraphHeading(tokens: readonly CobolToken[]): boolean { + return ( + tokens.length === 2 && + tokens[0]?.kind === 'word' && + tokens[1]?.upper === '.' && + !VERBS.has(tokens[0]!.upper) && + !TERMINATORS.has(tokens[0]!.upper) && + !tokens[0]!.upper.startsWith('END-') + ); +} + +interface StatementBase { + readonly tokens: readonly CobolToken[]; +} +export type CobolStatement = + | (StatementBase & { readonly kind: 'simple' }) + | (StatementBase & { + readonly kind: 'if'; + readonly then: readonly CobolStatement[]; + readonly otherwise: readonly CobolStatement[]; + }) + | (StatementBase & { + readonly kind: 'evaluate'; + readonly cases: readonly { + readonly tests: readonly CobolToken[]; + readonly body: readonly CobolStatement[]; + readonly other: boolean; + }[]; + }) + | (StatementBase & { + readonly kind: 'perform'; + readonly target?: string; + readonly thru?: string; + readonly body: readonly CobolStatement[]; + readonly options: readonly CobolToken[]; + }); + +export class CobolFlowUnsupported extends Error { + constructor(reason: string) { + super(reason); + this.name = 'CobolFlowUnsupported'; + } +} + +/** Sentence periods close every implicit scope, but only the outer sequence consumes them. */ +export class CobolProcedureParser { + private position = 0; + private depth = 0; + constructor(private readonly tokens: readonly CobolToken[]) {} + + parse(): readonly CobolStatement[] { + const result = this.sequence(new Set(), true); + if (this.position !== this.tokens.length) this.fail('unexpected scope terminator'); + return result; + } + + private fail(reason: string): never { + throw new CobolFlowUnsupported(reason + ' at line ' + (this.tokens[this.position]?.line ?? 0)); + } + + private sequence(stops: ReadonlySet, sentences = false): CobolStatement[] { + this.depth += 2; + if (this.depth > MAX_CFG_NESTING_DEPTH) throw new CfgNestingDepthError(MAX_CFG_NESTING_DEPTH); + try { + const result: CobolStatement[] = []; + while (this.position < this.tokens.length) { + const word = this.tokens[this.position]!.upper; + if (stops.has(word)) break; + if (word === '.') { + if (!sentences) break; + this.position++; + continue; + } + if (word === 'THEN') { + this.position++; + continue; + } + result.push(this.statement()); + } + return result; + } finally { + this.depth -= 2; + } + } + + private takeHeader(): CobolToken[] { + const start = this.position++; + while (this.position < this.tokens.length) { + const t = this.tokens[this.position]!; + if (VERBS.has(t.upper) || TERMINATORS.has(t.upper)) break; + if (t.kind === 'invalid') this.fail('invalid token'); + this.position++; + } + return this.tokens.slice(start, this.position); + } + + private consume(word: string): boolean { + if (this.tokens[this.position]?.upper !== word) return false; + this.position++; + return true; + } + + private statement(): CobolStatement { + const verb = this.tokens[this.position]!.upper; + if (verb === 'IF') { + const tokens = this.takeHeader(); + if (tokens.length < 2) this.fail('missing IF condition'); + const then = this.sequence(new Set(['ELSE', 'END-IF'])); + const otherwise = this.consume('ELSE') ? this.sequence(new Set(['END-IF'])) : []; + this.consume('END-IF'); + return { kind: 'if', tokens, then, otherwise }; + } + if (verb === 'EVALUATE') { + const tokens = this.takeHeader(); + const cases: { tests: CobolToken[]; body: readonly CobolStatement[]; other: boolean }[] = []; + while (this.tokens[this.position]?.upper === 'WHEN') { + const tests: CobolToken[] = []; + while (this.tokens[this.position]?.upper === 'WHEN') + tests.push(...this.takeHeader().slice(1)); + const body = this.sequence(new Set(['WHEN', 'END-EVALUATE'])); + cases.push({ tests, body, other: tests.some((t) => t.upper === 'OTHER') }); + } + if (!cases.length) this.fail('missing EVALUATE cases'); + this.consume('END-EVALUATE'); + return { kind: 'evaluate', tokens, cases }; + } + if (verb === 'PERFORM') { + const tokens = this.takeHeader(); + const tail = tokens.slice(1); + let offset = 0; + const procedure = (): string => { + const words = [tail[offset++]!.upper]; + while (['OF', 'IN'].includes(tail[offset]?.upper ?? '')) { + offset++; + if (tail[offset]?.kind !== 'word') this.fail('invalid procedure qualification'); + words.push('OF', tail[offset++]!.upper); + } + return words.join(' '); + }; + let target: string | undefined; + let thru: string | undefined; + if ( + tail[0]?.kind === 'word' && + !['UNTIL', 'VARYING', 'WITH', 'TEST', 'FOREVER'].includes(tail[0]!.upper) && + tail[1]?.upper !== 'TIMES' + ) { + target = procedure(); + if (['THRU', 'THROUGH'].includes(tail[offset]?.upper ?? '')) { + offset++; + if (tail[offset]?.kind !== 'word') this.fail('missing PERFORM THRU target'); + thru = procedure(); + } + } + const body = target ? [] : this.sequence(new Set(['END-PERFORM'])); + if (!target && !this.consume('END-PERFORM')) this.fail('missing END-PERFORM'); + return { kind: 'perform', tokens, target, thru, body, options: tail.slice(offset) }; + } + if (!SIMPLE.has(verb)) this.fail('unsupported ' + verb); + // PERFORM in EXIT PERFORM is a qualifier, not another statement. + const start = this.position; + this.position++; + if (verb === 'EXIT' && this.tokens[this.position]?.upper === 'PERFORM') this.position++; + while (this.position < this.tokens.length) { + const t = this.tokens[this.position]!; + if (VERBS.has(t.upper) || TERMINATORS.has(t.upper)) break; + if (t.kind === 'invalid') this.fail('invalid token'); + this.position++; + } + const tokens = this.tokens.slice(start, this.position); + // Exception/size handlers introduce control flow; do not flatten them. + if (tokens.some((t) => ['EXCEPTION', 'SIZE', 'INVALID', 'OVERFLOW'].includes(t.upper))) { + this.fail('unsupported conditional statement handler'); + } + return { kind: 'simple', tokens }; + } +} diff --git a/gitnexus/src/core/ingestion/languages/cobol/flow.ts b/gitnexus/src/core/ingestion/languages/cobol/flow.ts new file mode 100644 index 000000000..88aea676d --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/cobol/flow.ts @@ -0,0 +1,499 @@ +/** + * COBOL PROCEDURE DIVISION → the shared serializable CFG/def-use substrate. + * + * A program, not a paragraph, owns the executable region: ordinary flow falls + * through paragraph boundaries. PERFORM activates a return at the end of its + * paragraph/section/THRU range. Expanding reachable (instruction, return-stack) + * states keeps different return continuations separate without pretending that + * paragraphs are ordinary functions. Recursive/excessive expansion fails closed. + */ +import { generateId } from '../../../../lib/utils.js'; +import { + extractCobolSymbolsWithRegex, + type CobolRegexResults, +} from '../../cobol/cobol-preprocessor.js'; +import { CfgBuilder, CfgNestingDepthError } from '../../cfg/cfg-builder.js'; +import type { CollectedCfgs } from '../../cfg/collect.js'; +import type { CfgEdgeKind, FunctionCfg, StatementFacts } from '../../cfg/types.js'; +import { CobolHarvester } from './flow-harvest.js'; +import { + CobolFlowUnsupported, + CobolProcedureParser, + tokenizeCobol, + tokenText, + isCobolParagraphHeading, + type CobolStatement, + type CobolToken, +} from './flow-source.js'; + +interface Arc { + readonly to: number; + readonly kind: CfgEdgeKind; +} +interface Instruction { + readonly tokens: readonly CobolToken[]; + readonly facts?: StatementFacts; + readonly edges: Arc[]; + readonly boundary?: boolean; + readonly perform?: { + readonly start: number; + readonly end: number; + readonly continuation: number; + }; +} +interface ProcedureRange { + readonly name: string; + readonly section?: string; + readonly kind: 'paragraph' | 'section'; + readonly start: number; + readonly end: number; +} +interface CompileContext { + readonly paragraphEnd: number; + readonly sectionEnd?: number; + readonly section?: string; + readonly breakTarget?: number; + readonly continueTarget?: number; +} + +class CobolFlowCompiler { + readonly instructions: Instruction[] = []; + readonly ranges: ProcedureRange[] = []; + constructor(private readonly harvest: CobolHarvester) {} + + instruction( + tokens: readonly CobolToken[], + edges: Arc[], + facts?: StatementFacts, + boundary = false, + ): number { + const index = this.instructions.length; + this.instructions.push({ tokens, edges, facts, ...(boundary ? { boundary } : {}) }); + return index; + } + + private resolve(name: string, section?: string): ProcedureRange { + const [base, qualifier] = name.split(' OF '); + let matches = this.ranges.filter( + (r) => r.name === base && (!qualifier || r.section === qualifier), + ); + if (matches.length > 1 && section) { + const local = matches.filter((r) => r.section === section); + if (local.length) matches = local; + } + if (matches.length !== 1) throw new CobolFlowUnsupported('unresolved procedure ' + name); + return matches[0]!; + } + + sequence(statements: readonly CobolStatement[], next: number, context: CompileContext): number { + for (let i = statements.length - 1; i >= 0; i--) + next = this.statement(statements[i]!, next, context); + return next; + } + + private statement(stmt: CobolStatement, next: number, context: CompileContext): number { + const tokens = stmt.tokens; + switch (stmt.kind) { + case 'if': + return this.instruction( + tokens, + [ + { to: this.sequence(stmt.then, next, context), kind: 'cond-true' }, + { to: this.sequence(stmt.otherwise, next, context), kind: 'cond-false' }, + ], + this.harvest.facts(tokens, tokens.slice(1)), + ); + case 'evaluate': { + const edges = stmt.cases.map((c) => ({ + to: this.sequence(c.body, next, context), + kind: 'switch-case' as const, + })); + if (!stmt.cases.some((c) => c.other)) edges.push({ to: next, kind: 'switch-case' }); + return this.instruction( + tokens, + edges, + this.harvest.facts(tokens, [...tokens.slice(1), ...stmt.cases.flatMap((c) => c.tests)]), + ); + } + case 'perform': + return this.perform(stmt, next, context); + case 'simple': { + const verb = tokens[0]!.upper; + let to = next; + let kind: CfgEdgeKind = 'seq'; + if ( + verb === 'GOBACK' || + (verb === 'STOP' && tokens[1]?.upper === 'RUN') || + (verb === 'EXIT' && tokens[1]?.upper === 'PROGRAM') + ) { + to = -1; + kind = 'return'; + } else if (verb === 'STOP') { + throw new CobolFlowUnsupported('non-RUN STOP'); + } else if (verb === 'GO' || verb === 'GOTO') { + const name = tokens.slice(tokens[1]?.upper === 'TO' ? 2 : 1); + if ( + !name.length || + name.some((t) => t.upper === 'DEPENDING') || + (name.length > 1 && !['OF', 'IN'].includes(name[1]!.upper)) + ) { + throw new CobolFlowUnsupported('computed GO TO'); + } + to = this.resolve( + name.map((t) => (t.upper === 'IN' ? 'OF' : t.upper)).join(' '), + context.section, + ).start; + } else if (verb === 'EXIT' && tokens.length > 1) { + switch (tokens[1]!.upper) { + case 'PARAGRAPH': + to = context.paragraphEnd; + break; + case 'SECTION': + if (context.sectionEnd === undefined) + throw new CobolFlowUnsupported('EXIT SECTION outside section'); + to = context.sectionEnd; + break; + case 'PERFORM': + if (context.breakTarget === undefined) + throw new CobolFlowUnsupported('EXIT PERFORM outside inline PERFORM'); + to = tokens[2]?.upper === 'CYCLE' ? context.continueTarget! : context.breakTarget; + kind = tokens[2]?.upper === 'CYCLE' ? 'continue' : 'break'; + break; + default: + throw new CobolFlowUnsupported('unsupported EXIT'); + } + } + return this.instruction(tokens, [{ to, kind }], this.harvest.statement(tokens)); + } + } + } + + private perform( + stmt: Extract, + next: number, + context: CompileContext, + ): number { + const options = stmt.options; + const until = options.findIndex((t) => t.upper === 'UNTIL'); + const varying = options.findIndex((t) => t.upper === 'VARYING'); + const times = options.findIndex((t) => t.upper === 'TIMES'); + const forever = options.some((t) => t.upper === 'FOREVER'); + const after = options.some((t, i) => t.upper === 'TEST' && options[i + 1]?.upper === 'AFTER'); + if (options.some((t, i) => t.upper === 'AFTER' && options[i - 1]?.upper !== 'TEST')) { + throw new CobolFlowUnsupported('multiple VARYING controls'); + } + if (options.length && until < 0 && times < 0 && !forever) { + throw new CobolFlowUnsupported('unsupported PERFORM options'); + } + if (times === 1 && options[0]?.kind === 'number' && Number(options[0].text) === 0) { + return this.instruction( + stmt.tokens, + [{ to: next, kind: 'seq' }], + this.harvest.facts(stmt.tokens), + ); + } + const iteration = (continuation: number, loopContext: CompileContext): number => { + if (!stmt.target) return this.sequence(stmt.body, continuation, loopContext); + const start = this.resolve(stmt.target, context.section); + const end = stmt.thru ? this.resolve(stmt.thru, context.section) : start; + if (end.end <= start.start) throw new CobolFlowUnsupported('reversed PERFORM range'); + const index = this.instructions.length; + this.instructions.push({ + tokens: stmt.tokens, + facts: this.harvest.facts(stmt.tokens), + edges: [], + perform: { start: start.start, end: end.end, continuation }, + }); + return index; + }; + if (!options.length) + return iteration(next, { ...context, breakTarget: next, continueTarget: next }); + const header = this.instruction( + stmt.tokens, + [], + this.harvest.facts( + stmt.tokens, + until >= 0 ? options.slice(until + 1) : times >= 0 ? options.slice(0, times) : [], + ), + ); + // The explicit latch owns the loop-back kind; completion edges from a + // performed procedure remain seq, preserving the generic jump-kind invariant. + let latch = this.instruction([], [{ to: header, kind: 'loop-back' }]); + let init: { tokens: readonly CobolToken[]; facts: StatementFacts } | undefined; + if (varying >= 0) { + const from = options.findIndex((t) => t.upper === 'FROM'); + const by = options.findIndex((t) => t.upper === 'BY'); + if (from < varying || by < from || until < by) + throw new CobolFlowUnsupported('malformed VARYING'); + const variable = options.slice(varying + 1, from); + init = { + tokens: options.slice(varying + 1, by), + facts: this.harvest.facts(stmt.tokens, options.slice(from + 1, by), variable), + }; + latch = this.instruction( + [...variable, ...options.slice(by, until)], + [{ to: header, kind: 'loop-back' }], + this.harvest.facts(stmt.tokens, options.slice(by + 1, until), variable, true), + ); + } + const body = iteration(latch, { ...context, breakTarget: next, continueTarget: latch }); + if (forever) this.instructions[header]!.edges.push({ to: body, kind: 'seq' }); + else if (until >= 0) + this.instructions[header]!.edges.push( + { to: next, kind: 'cond-true' }, + { to: body, kind: 'cond-false' }, + ); + else + this.instructions[header]!.edges.push( + { to: body, kind: 'cond-true' }, + { to: next, kind: 'cond-false' }, + ); + const entry = after ? body : header; + return init ? this.instruction(init.tokens, [{ to: entry, kind: 'seq' }], init.facts) : entry; + } +} + +type Program = CobolRegexResults['programs'][number]; + +/** Expand only reachable contexts, never merge unrelated PERFORM return sites. */ +function emitStates(compiler: CobolFlowCompiler, builder: CfgBuilder, entry: number): void { + type Frame = { readonly start: number; readonly end: number; readonly continuation: number }; + type State = { + readonly instruction: number; + readonly stack: readonly Frame[]; + readonly block: number; + }; + const states = new Map(); + const pending: State[] = []; + const MAX_STATES = 12000; + const resolve = ( + initial: number, + frames: readonly Frame[], + ): { instruction: number; stack: readonly Frame[]; kind?: CfgEdgeKind } => { + let instruction = initial; + let stack = frames; + let kind: CfgEdgeKind | undefined; + const seen = new Set(); + while (instruction >= 0) { + const key = instruction + ':' + stack.map((f) => f.end + ',' + f.continuation).join('/'); + if (seen.has(key)) throw new CobolFlowUnsupported('empty procedure cycle'); + seen.add(key); + const op = compiler.instructions[instruction]!; + if (op.boundary && stack.at(-1)?.end === instruction) { + const frame = stack.at(-1)!; + stack = stack.slice(0, -1); + instruction = frame.continuation; + } else if (!op.tokens.length && op.edges.length === 1) { + const edge = op.edges[0]!; + if (edge.kind === 'loop-back') kind = edge.kind; + instruction = edge.to; + } else break; + } + return { instruction, stack, kind }; + }; + const state = (instruction: number, stack: readonly Frame[]): number => { + if (instruction < 0) return builder.exitIndex; + const key = + instruction + ':' + stack.map((f) => f.start + ',' + f.end + ',' + f.continuation).join('/'); + const existing = states.get(key); + if (existing) return existing.block; + if (states.size >= MAX_STATES) throw new CobolFlowUnsupported('PERFORM state budget exceeded'); + const op = compiler.instructions[instruction]!; + const first = op.tokens[0]; + const block = builder.newBlock( + first?.line ?? 0, + op.tokens.at(-1)?.line ?? first?.line ?? 0, + tokenText(op.tokens), + 'normal', + op.facts, + ); + const value = { instruction, stack, block }; + states.set(key, value); + pending.push(value); + return block; + }; + const first = resolve(entry, []); + builder.edge(builder.entryIndex, state(first.instruction, first.stack), 'seq'); + for (let p = 0; p < pending.length; p++) { + const current = pending[p]!; + const op = compiler.instructions[current.instruction]!; + if (op.perform) { + if (current.stack.length >= 64 || current.stack.some((f) => f.start === op.perform!.start)) { + throw new CobolFlowUnsupported('recursive PERFORM'); + } + const target = resolve(op.perform.start, [...current.stack, op.perform]); + builder.edge(current.block, state(target.instruction, target.stack), target.kind ?? 'seq'); + } else + for (const arc of op.edges) { + const target = resolve(arc.to, current.stack); + builder.edge( + current.block, + state(target.instruction, target.stack), + target.kind ?? arc.kind, + ); + } + } +} + +function buildProgram( + sourceTokens: readonly CobolToken[], + extracted: CobolRegexResults, + program: Program, + filePath: string, + maxFunctionLines: number, +): FunctionCfg | 'too-many-lines' | undefined { + const children = extracted.programs.filter( + (p) => p.startLine > program.startLine && p.endLine <= program.endLine, + ); + const ownedLine = (line: number) => + line >= program.startLine && + line <= program.endLine && + !children.some((child) => line >= child.startLine && line <= child.endLine); + const owned = sourceTokens.filter((t) => ownedLine(t.line)); + if (owned.some((t) => t.kind === 'invalid')) + throw new CobolFlowUnsupported('unsupported source token'); + const procedure = owned.findIndex( + (t, i) => t.upper === 'PROCEDURE' && owned[i + 1]?.upper === 'DIVISION', + ); + if (procedure < 0) return undefined; // data-only copybook / incomplete program + const headerEnd = owned.findIndex((t, i) => i > procedure && t.upper === '.'); + if (headerEnd < 0) throw new CobolFlowUnsupported('unterminated PROCEDURE DIVISION'); + let end = owned.findIndex( + (t, i) => + i > headerEnd && + ((t.upper === 'IDENTIFICATION' && owned[i + 1]?.upper === 'DIVISION') || + (t.upper === 'END' && owned[i + 1]?.upper === 'PROGRAM')), + ); + if (end < 0) end = owned.length; + const tokens = owned.slice(headerEnd + 1, end); + const firstLine = owned[procedure]!.line; + const lastLine = tokens.at(-1)?.line ?? firstLine; + if (maxFunctionLines > 0 && lastLine - firstLine + 1 > maxFunctionLines) return 'too-many-lines'; + const builder = new CfgBuilder(filePath, firstLine, lastLine, owned[procedure]!.column); + const dataItems = extracted.dataItems.filter((i) => ownedLine(i.line) && i.line < firstLine); + const lines = new Map(); + for (const token of owned) { + const line = lines.get(token.line) ?? []; + line.push(token); + lines.set(token.line, line); + } + // Supplement the regex tagger's two-character-name minimum with tokenized + // declarations. Declaration coordinates still come directly from source. + for (const [line, ts] of lines) { + if ( + line >= firstLine || + ts[0]?.kind !== 'number' || + ts[0].text.length > 2 || + ts[1]?.kind !== 'word' || + dataItems.some((item) => item.line === line) + ) + continue; + const redefines = ts.findIndex((t) => t.upper === 'REDEFINES'); + dataItems.push({ + name: ts[1].text, + level: Number(ts[0].text), + line, + section: 'unknown', + ...(redefines >= 0 ? { redefines: ts[redefines + 1]?.text ?? '?' } : {}), + }); + } + dataItems.sort((a, b) => a.line - b.line); + const harvest = new CobolHarvester(dataItems, sourceTokens, program.procedureUsing ?? []); + const compiler = new CobolFlowCompiler(harvest); + const headings = [ + ...extracted.sections.map((s) => ({ ...s, kind: 'section' as const })), + ...extracted.paragraphs.map((p) => ({ ...p, kind: 'paragraph' as const })), + ] + .filter((h) => ownedLine(h.line) && h.line > firstLine && h.line <= lastLine) + .sort((a, b) => a.line - b.line); + for (const [line, ts] of lines) { + if ( + line > firstLine && + line <= lastLine && + isCobolParagraphHeading(ts) && + !headings.some((h) => h.line === line) + ) { + headings.push({ name: ts[0]!.text, line, kind: 'paragraph' }); + } + } + headings.sort((a, b) => a.line - b.line); + const starts = [firstLine, ...headings.map((h) => h.line)]; + const boundaries = [...starts, lastLine + 1].map(() => + compiler.instruction([], [], undefined, true), + ); + let section: string | undefined; + for (let i = 0; i < headings.length; i++) { + const heading = headings[i]!; + const name = heading.name.toUpperCase(); + if (heading.kind === 'section') section = name; + const endIndex = + heading.kind === 'paragraph' + ? i + 2 + : headings.findIndex((h, j) => j > i && h.kind === 'section'); + compiler.ranges.push({ + name, + kind: heading.kind, + section: heading.kind === 'paragraph' ? section : undefined, + start: boundaries[i + 1]!, + end: + heading.kind === 'paragraph' + ? boundaries[endIndex]! + : endIndex < 0 + ? boundaries.at(-1)! + : boundaries[endIndex + 1]!, + }); + } + // A final boundary can either return the active PERFORM or fall off the program. + compiler.instructions[boundaries.at(-1)!]!.edges.push({ to: -1, kind: 'seq' }); + for (let i = 0; i < starts.length; i++) { + let chunk = tokens.filter( + (t) => t.line >= starts[i]! && t.line < (starts[i + 1] ?? lastLine + 1), + ); + if (i > 0) { + const period = chunk.findIndex((t) => t.upper === '.'); + if (period < 0) throw new CobolFlowUnsupported('unterminated procedure heading'); + chunk = chunk.slice(period + 1); + } + const currentSection = compiler.ranges + .filter((r) => r.kind === 'section' && r.start <= boundaries[i]! && r.end > boundaries[i]!) + .at(-1); + const body = new CobolProcedureParser(chunk).parse(); + const entry = compiler.sequence(body, boundaries[i + 1]!, { + paragraphEnd: boundaries[i + 1]!, + section: currentSection?.name, + sectionEnd: currentSection?.end, + }); + compiler.instructions[boundaries[i]!]!.edges.push({ to: entry, kind: 'seq' }); + } + builder.attachFacts(builder.entryIndex, harvest.entryFacts(firstLine)); + emitStates(compiler, builder, boundaries[0]!); + return { + ...builder.finish(harvest.bindings), + ownerId: generateId('Module', filePath + ':' + program.name), + }; +} + +/** Standalone provider hook; isolated skips share the AST collector's telemetry. */ +export function collectCobolSourceCfgs( + sourceText: string, + filePath: string, + maxFunctionLines = 0, +): CollectedCfgs { + const tokens = tokenizeCobol(sourceText); + const extracted = extractCobolSymbolsWithRegex(sourceText, filePath); + const cfgs: FunctionCfg[] = []; + let tooManyLines = 0, + tooDeeplyNested = 0, + buildError = 0; + for (const program of extracted.programs) { + try { + const result = buildProgram(tokens, extracted, program, filePath, maxFunctionLines); + if (result === 'too-many-lines') tooManyLines++; + else if (result) cfgs.push(result); + } catch (error) { + if (error instanceof CfgNestingDepthError) tooDeeplyNested++; + else buildError++; + } + } + return { cfgs, skipped: { tooManyLines, tooDeeplyNested, buildError } }; +} diff --git a/gitnexus/src/core/ingestion/languages/cobol/rename.ts b/gitnexus/src/core/ingestion/languages/cobol/rename.ts new file mode 100644 index 000000000..7f2bbb494 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/cobol/rename.ts @@ -0,0 +1,211 @@ +import type { RenameIssue, RenameProvider, RenameSource } from '../../rename-provider.js'; +import { extractParsedFile } from '../../scope-extractor-bridge.js'; +import { + COBOL_RESERVED, + cobolRangeKey, + cobolTokenRange, + readCobolSemanticSource, + type CobolSemanticSource, +} from './semantic.js'; + +const unsupported = (message: string): RenameIssue => ({ + code: 'unsupported_occurrence', + message, + status: 'unsupported', +}); +const evidence = new WeakMap(); +const factsFor = (source: RenameSource) => evidence.get(source)!; + +export const cobolRenameProvider: RenameProvider = { + canonicalName: (name, context) => + (context?.definition?.type === 'Namespace' + ? name.slice(name.lastIndexOf(':') + 1) + : name + ).toUpperCase(), + validateName(name) { + if ( + !/^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?$/.test(name) || + !/[A-Za-z]/.test(name) || + name.length > 31 || + COBOL_RESERVED.has(name.toUpperCase()) + ) + return { + code: 'invalid_name', + message: + 'Use a COBOL identifier of at most 31 letters, digits or hyphens, containing a letter and no reserved word.', + }; + }, + matchesGraphIdentity({ definition, node }) { + return ( + definition.type === node.label && + definition.nameRange !== undefined && + definition.declarationRange?.startLine === node.properties.startLine! + 1 && + definition.declarationRange?.endLine === node.properties.endLine! + 1 + ); + }, + parseSource({ filePath, content, languageProvider }) { + const facts = readCobolSemanticSource(content, filePath); + if (facts.tokens.some((token) => token.kind === 'invalid')) + throw unsupported( + 'COBOL debug lines or split-token continuations require source expansion before rename.', + ); + const parsed = extractParsedFile(languageProvider, content, filePath); + if (!parsed) + throw { + code: 'incomplete_semantics', + message: `Could not extract COBOL bindings in ${filePath}.`, + }; + const definitions = new Map( + parsed.localDefs + .filter((def) => def.nameRange) + .map((def) => [cobolRangeKey(def.nameRange!), def]), + ); + const references = new Map( + parsed.referenceSites + .filter((site) => site.nameRange) + .map((site) => [cobolRangeKey(site.nameRange!), site]), + ); + const offsets = [0]; + for (let index = 0; index < content.length; index++) + if (content[index] === '\n') offsets.push(index + 1); + const tokens = new Map( + facts.tokens + .filter((token) => token.kind === 'word') + .map((token) => [cobolRangeKey(cobolTokenRange(token)), token]), + ); + for (const ref of facts.references) + tokens.set(cobolRangeKey(cobolTokenRange(ref.token)), ref.token); + const unclassified = new Set(facts.unclassified); + const source: RenameSource = { + path: filePath, + content, + parsed, + occurrences: [...tokens.values()].map((token) => { + const range = cobolTokenRange(token); + const key = cobolRangeKey(range); + const definition = definitions.get(key); + const referenceSite = references.get(key); + return { + name: token.upper, + range, + spans: [{ start: offsets[token.line - 1]! + token.column, length: token.text.length }], + ...(definition ? { definitionId: definition.nodeId } : {}), + ...(referenceSite ? { referenceSite } : {}), + role: + definition || referenceSite || unclassified.has(token) + ? ('name' as const) + : ('unrelated' as const), + }; + }), + }; + evidence.set(source, facts); + return source; + }, + checkSafety(context) { + const source = context.files.get(context.target.filePath)!; + const facts = factsFor(source); + const target = facts.declarations.find( + (d) => cobolRangeKey(cobolTokenRange(d.token)) === cobolRangeKey(context.target.nameRange!), + ); + if (!target) return unsupported('The COBOL declaration has no original source identity.'); + for (const file of context.files.values()) { + const unknown = factsFor(file).unclassified.find( + (token) => + token.upper === target.token.upper || token.upper === context.newName.toUpperCase(), + ); + if (unknown) + return unsupported( + `An unclassified COBOL clause operand in ${file.path}:${unknown.line} prevents proving rename coverage.`, + ); + } + if (context.target.type === 'Module') { + for (const file of context.files.values()) { + const candidate = factsFor(file); + if (candidate.dynamicCalls) + return unsupported( + 'A dynamic CALL or CANCEL prevents proving all uses of this program name.', + ); + if ( + candidate.declarations.some( + (d) => + d.type === 'Module' && + d.token.upper === context.newName.toUpperCase() && + !( + file.path === source.path && + cobolRangeKey(cobolTokenRange(d.token)) === cobolRangeKey(context.target.nameRange!) + ), + ) + ) + return { + code: 'name_collision', + message: 'The replacement already names a program in this COBOL workspace.', + }; + if ( + candidate.extracted.execCicsBlocks.some( + (block) => + block.programName?.toUpperCase() === target.token.upper || + (block.programName && !block.programIsLiteral), + ) + ) + return unsupported( + 'An embedded CICS program lookup prevents complete static program rename coverage.', + ); + } + } + const sameOwner = facts.declarations.filter( + (d) => + d !== target && + d.program === target.program && + d.type === target.type && + d.parents.length === target.parents.length && + d.parents.every((parent, index) => parent === target.parents[index]), + ); + if (sameOwner.some((d) => d.token.upper === context.newName.toUpperCase())) + return { + code: 'name_collision', + message: 'The replacement already names a declaration in this COBOL owner.', + }; + }, + checkOccurrence({ selected, source, occurrence }) { + if (!selected) return; + const tokens = factsFor(source).tokens; + let embedded = false; + for (const token of tokens) { + if ( + token.line > occurrence.range.startLine || + (token.line === occurrence.range.startLine && token.column > occurrence.range.startCol) + ) + break; + if (token.upper === 'EXEC') embedded = true; + if (token.upper === 'END-EXEC') embedded = false; + } + if (embedded) + return unsupported( + 'An embedded EXEC operand requires its own language binding proof before rename.', + ); + }, + validateRewrite({ source, updated, edits }) { + const free = />>SOURCE\s+(?:FORMAT\s+(?:IS\s+)?)?FREE/i.test( + source.content.split('\n', 10).join('\n'), + ); + if (free) return; + const oldLines = source.content.split(/\r?\n/); + const newLines = updated.content.split(/\r?\n/); + let offset = 0; + for (let line = 0; line < oldLines.length; line++) { + const original = oldLines[line]!; + const touched = edits.some( + (edit) => edit.start >= offset && edit.start < offset + original.length, + ); + if ( + touched && + (original.slice(72) !== newLines[line]!.slice(72) || + (!original.slice(72).trim() && newLines[line]!.slice(72).trim())) + ) + return unsupported( + 'The replacement would move fixed-format code or identification columns past column 72.', + ); + offset += original.length + (source.content[offset + original.length] === '\r' ? 2 : 1); + } + }, +}; diff --git a/gitnexus/src/core/ingestion/languages/cobol/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/cobol/scope-resolver.ts index 3d9e0eea6..ce908a24f 100644 --- a/gitnexus/src/core/ingestion/languages/cobol/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/cobol/scope-resolver.ts @@ -2,9 +2,8 @@ * COBOL `ScopeResolver` registered in `SCOPE_RESOLVERS` and consumed * by the generic `runScopeResolution` orchestrator. * - * The provider is a thin wiring object — COBOL's simple scope model - * (Module + Function only, no inheritance, no type system) plugs into - * `runScopeResolution` with minimal configuration. + * Program, section, paragraph and qualified data declarations use the shared + * resolution pipeline without introducing a compiler or inheritance model. * * Reference: `languages/python/scope-resolver.ts`. */ @@ -15,6 +14,12 @@ import { populateClassOwnedMembers } from '../../scope-resolution/scope/walkers. import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js'; import { cobolProvider } from '../cobol.js'; import { resolveCobolCopyTarget } from './copy-target.js'; +import { + cobolRangeKey, + cobolTokenRange, + readCobolSemanticSource, + resolveCobolReference, +} from './semantic.js'; const cobolScopeResolver: ScopeResolver = { language: SupportedLanguages.Cobol, @@ -53,7 +58,63 @@ const cobolScopeResolver: ScopeResolver = { buildMro: () => new Map(), // Everything lives under the PROGRAM-ID Module scope. - populateOwners: (parsed: ParsedFile) => populateClassOwnedMembers(parsed), + populateOwners: (parsed: ParsedFile) => { + const qualifiedNames = new Map(parsed.localDefs.map((def) => [def.nodeId, def.qualifiedName])); + populateClassOwnedMembers(parsed); + for (const def of parsed.localDefs) def.qualifiedName = qualifiedNames.get(def.nodeId); + }, + + // Observe the provider's exact declaration/qualification model. Structural + // edges remain owned by cobol-processor; storage-flow expansion is unrelated + // to the single source declaration named by an operand. + emitPostResolutionEdges(_graph, parsedFiles, _nodeLookup, _indexes, context) { + if (!context?.recordSourceBinding) return; + const sources = parsedFiles.map((parsed) => ({ + parsed, + facts: readCobolSemanticSource( + context.fileContents.get(parsed.filePath) ?? '', + parsed.filePath, + ), + })); + const definitions = new Map( + sources.flatMap(({ parsed, facts }) => { + const byRange = new Map( + parsed.localDefs + .filter((def) => def.nameRange) + .map((def) => [cobolRangeKey(def.nameRange!), def]), + ); + return facts.declarations.flatMap((declaration) => { + const def = byRange.get(cobolRangeKey(cobolTokenRange(declaration.token))); + return def ? [[declaration, def] as const] : []; + }); + }), + ); + const workspace = sources.map((source) => source.facts); + for (const { parsed, facts } of sources) { + const references = new Map( + facts.references.map((ref) => [cobolRangeKey(cobolTokenRange(ref.token)), ref]), + ); + for (const site of parsed.referenceSites) { + const reference = site.nameRange && references.get(cobolRangeKey(site.nameRange)); + if (!reference) continue; + const targets = resolveCobolReference(reference, facts, workspace); + const targetDefIds = targets.flatMap((target) => { + const def = definitions.get(target); + return def ? [def.nodeId] : []; + }); + context.recordSourceBinding(site, { + status: + targetDefIds.length === 1 + ? 'resolved' + : targetDefIds.length > 1 + ? 'ambiguous' + : 'unresolved', + targetDefIds, + basis: `cobol-${reference.kind}-binding`, + }); + } + } + }, // COBOL has no super calls. isSuperReceiver: () => false, diff --git a/gitnexus/src/core/ingestion/languages/cobol/semantic.ts b/gitnexus/src/core/ingestion/languages/cobol/semantic.ts new file mode 100644 index 000000000..fbb9f2a29 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/cobol/semantic.ts @@ -0,0 +1,471 @@ +/** Written COBOL declarations and references shared by capture and binding resolution. */ +import type { Capture, CaptureMatch, NodeLabel, Range } from 'gitnexus-shared'; +import { + extractCobolSymbolsWithRegex, + preprocessCobolSource, + type CobolRegexResults, +} from '../../cobol/cobol-preprocessor.js'; +import { tokenizeCobol, type CobolToken } from './flow-source.js'; + +type Program = CobolRegexResults['programs'][number]; +export interface CobolDeclaration { + readonly token: CobolToken; + readonly type: NodeLabel; + readonly range: Range; + readonly scopeRange?: Range; + readonly program?: Program; + readonly parents: readonly CobolDeclaration[]; + readonly qualifiedName: string; + readonly parameterCount?: number; +} +export interface CobolReference { + readonly token: CobolToken; + readonly kind: 'program' | 'procedure' | 'data'; + readonly program?: Program; + /** The first token is the referenced name; the rest are its OF/IN qualifiers. */ + readonly chain: readonly CobolToken[]; + readonly chainIndex: number; + readonly isCall?: boolean; + readonly localProgram?: boolean; +} +export interface CobolSemanticSource { + readonly tokens: readonly CobolToken[]; + readonly declarations: readonly CobolDeclaration[]; + readonly references: readonly CobolReference[]; + /** Written words whose declaration-clause role has not been proven. */ + readonly unclassified: readonly CobolToken[]; + readonly dynamicCalls: boolean; + readonly extracted: CobolRegexResults; +} +export const cobolTokenRange = (token: CobolToken): Range => ({ + startLine: token.line, + endLine: token.line, + startCol: token.column, + endCol: token.column + token.text.length, +}); +export const cobolRangeKey = (range: Range): string => + `${range.startLine}:${range.startCol}:${range.endLine}:${range.endCol}`; +const tokenKey = (token: CobolToken): string => cobolRangeKey(cobolTokenRange(token)); +const capture = (name: string, range: Range, text: string): Capture => ({ name, range, text }); + +/** Reserved syntax cannot be renamed to a data/procedure/program identifier. */ +export const COBOL_RESERVED = new Set( + ( + 'ACCEPT ADD ADDRESS ADVANCING AFTER ALL ALSO ALTER AND ARE ASCENDING ASSIGN AT AUTHOR BEFORE ' + + 'BINARY BY CALL CANCEL CLOSE COMP COMP-1 COMP-2 COMP-3 COMP-4 COMP-5 COMPUTATIONAL COMPUTE ' + + 'CONFIGURATION CONTENT CONTINUE CORR CORRESPONDING DATA DATE-COMPILED DATE-WRITTEN ' + + 'DECLARATIVES DELETE DELIMITED DEPENDING DESCENDING DISPLAY DIVIDE DIVISION ELSE END ' + + 'END-ACCEPT END-ADD END-CALL END-COMPUTE END-DELETE END-DISPLAY END-DIVIDE END-EVALUATE ' + + 'END-IF END-MULTIPLY END-PERFORM END-READ END-REWRITE END-SEARCH END-START END-STRING ' + + 'END-SUBTRACT END-UNSTRING END-WRITE ENTRY ENVIRONMENT EQUAL EQUALS EVALUATE EXCEPTION EXEC ' + + 'EXIT EXTEND EXTERNAL FALSE FD FILE FILE-CONTROL FILLER FIRST FOREVER FROM FUNCTION ' + + 'FUNCTION-POINTER GIVING GLOBAL GO GOBACK GOTO GREATER HIGH-VALUE HIGH-VALUES ' + + 'IDENTIFICATION IF IN INDEXED INITIALIZE INPUT INPUT-OUTPUT INSPECT INSTALLATION ' + + 'INTO INVALID IS JUSTIFIED KEY LAST LEADING LENGTH LESS LINKAGE LOCAL-STORAGE ' + + 'LOW-VALUE LOW-VALUES MERGE MODE MOVE MULTIPLY NATIONAL NEXT NO NOT NULL NULLS ' + + 'OBJECT-COMPUTER OCCURS OF OMITTED ON OPEN OR ORGANIZATION OTHER OUTPUT OVERFLOW ' + + 'PACKED-DECIMAL PERFORM PIC PICTURE POINTER POSITIVE PROCEDURE PROCEDURE-POINTER PROGRAM ' + + 'PROGRAM-ID QUOTE QUOTES READ REDEFINES REFERENCE RELEASE REMAINDER RENAMES REPLACING ' + + 'RETURN RETURNING REVERSE REWIND REWRITE ROUNDED RUN SAME SCREEN SD SEARCH SECTION ' + + 'SELECT SENTENCE SEPARATE SEQUENCE SET SIGN SIZE SORT SOURCE-COMPUTER SPACE SPACES ' + + 'SPECIAL-NAMES STANDARD START STOP STRING SUBTRACT SYNC SYNCHRONIZED TALLYING TEST THAN ' + + 'THEN THROUGH THRU TIMES TO TRAILING TRUE TYPE UNSTRING UNTIL UP UPON USAGE USE USING ' + + 'VALUE VALUES VARYING WHEN WITH WORKING-STORAGE WRITE ZERO ZEROES ZEROS COPY' + ).split(' '), +); + +export function readCobolSemanticSource(source: string, filePath: string): CobolSemanticSource { + const extracted = extractCobolSymbolsWithRegex(preprocessCobolSource(source), filePath); + const tokens = tokenizeCobol(source); + const lines = source.split(/\r?\n/); + const lineRange = (start: number, end = start): Range => ({ + startLine: start, + startCol: 0, + endLine: end, + endCol: lines[end - 1]?.length ?? 0, + }); + const programAt = (line: number): Program | undefined => + extracted.programs + .filter((p) => p.startLine <= line && p.endLine >= line) + .sort((a, b) => b.nestingDepth - a.nestingDepth || b.startLine - a.startLine)[0]; + const programKey = (program?: Program) => `program:${program?.startLine ?? 0}`; + const tokenAt = (line: number, name: string) => + tokens.find((t) => t.line === line && t.upper === name.toUpperCase()); + const declarations: CobolDeclaration[] = []; + const declared = new Set(); + const add = (declaration: CobolDeclaration) => { + declarations.push(declaration); + declared.add(tokenKey(declaration.token)); + }; + for (const [index, program] of extracted.programs.entries()) { + const tokenIndex = tokens.findIndex( + (t, i) => + t.upper === 'PROGRAM-ID' && + tokens[i + 2]?.upper === program.name.toUpperCase() && + t.line === program.startLine, + ); + const token = tokens[tokenIndex + 2]; + if (tokenIndex < 0 || !token) continue; + add({ + token, + type: 'Module', + program, + parents: [], + qualifiedName: program.name.toUpperCase(), + // The established graph models the primary module as the entire file. + range: lineRange( + index === 0 ? 1 : program.startLine, + index === 0 ? lines.length : program.endLine, + ), + scopeRange: lineRange(index === 0 ? 1 : program.startLine, program.endLine), + ...(program.procedureUsing ? { parameterCount: program.procedureUsing.length } : {}), + }); + } + for (const [index, section] of extracted.sections.entries()) { + const token = tokenAt(section.line, section.name); + if (!token) continue; + const program = programAt(section.line); + const end = extracted.sections[index + 1]?.line + ? extracted.sections[index + 1]!.line - 1 + : lines.length; + const nextProgram = extracted.programs.find((item) => item.startLine > token.line)?.startLine; + add({ + token, + type: 'Namespace', + program, + parents: [], + qualifiedName: `${program ? `${program.name.toUpperCase()}:` : ''}${token.upper}`, + range: lineRange(section.line, end), + scopeRange: lineRange( + section.line, + Math.min(end, nextProgram === undefined ? end : nextProgram - 1, program?.endLine ?? end), + ), + }); + } + for (const [index, paragraph] of extracted.paragraphs.entries()) { + const token = tokenAt(paragraph.line, paragraph.name); + if (!token) continue; + const program = programAt(paragraph.line); + const section = declarations + .filter((d) => d.type === 'Namespace' && d.program === program && d.token.line < token.line) + .at(-1); + const end = extracted.paragraphs[index + 1]?.line + ? extracted.paragraphs[index + 1]!.line - 1 + : lines.length; + const nextSection = extracted.sections.find((item) => item.line > token.line)?.line; + const nextProgram = extracted.programs.find((item) => item.startLine > token.line)?.startLine; + add({ + token, + type: 'Function', + program, + parents: section ? [section] : [], + qualifiedName: `${programKey(program)}.${section ? `section:${section.token.line}` : 'procedure'}.${token.upper}`, + range: lineRange(token.line, end), + scopeRange: lineRange( + token.line, + Math.min( + end, + nextSection === undefined ? end : nextSection - 1, + nextProgram === undefined ? end : nextProgram - 1, + section?.scopeRange?.endLine ?? end, + program?.endLine ?? end, + ), + ), + }); + } + const dataParents: { + item: CobolRegexResults['dataItems'][number]; + declaration: CobolDeclaration; + }[] = []; + let previousProgram: Program | undefined; + let previousSection: string | undefined; + for (const item of extracted.dataItems) { + const token = tokenAt(item.line, item.name); + if (!token) continue; + const program = programAt(item.line); + if ( + program !== previousProgram || + item.section !== previousSection || + [1, 77].includes(item.level) + ) + dataParents.length = 0; + previousProgram = program; + previousSection = item.section; + while (dataParents.length && dataParents.at(-1)!.item.level >= item.level) dataParents.pop(); + const parents = [...dataParents].reverse().map((p) => p.declaration); + const declaration: CobolDeclaration = { + token, + type: 'Property', + range: lineRange(item.line), + program, + parents, + qualifiedName: `${programKey(program)}.${item.section}.${[...parents] + .reverse() + .map((p) => p.token.upper) + .concat(token.upper) + .join('.')}`, + }; + if (token.upper !== 'FILLER') add(declaration); + if (item.level !== 88) dataParents.push({ item, declaration }); + } + + const references: CobolReference[] = []; + const used = new Set(declared); + const addReference = ( + chain: readonly CobolToken[], + kind: CobolReference['kind'], + program = programAt(chain[0]!.line), + extra?: Pick, + ) => { + chain.forEach((token, chainIndex) => { + if (used.has(tokenKey(token))) return; + used.add(tokenKey(token)); + references.push({ token, kind, program, chain, chainIndex, ...extra }); + }); + }; + const qualified = (index: number): { chain: CobolToken[]; end: number } => { + const chain = [tokens[index]!]; + while ( + ['OF', 'IN'].includes(tokens[index + 1]?.upper ?? '') && + tokens[index + 2]?.kind === 'word' + ) { + chain.push(tokens[index + 2]!); + index += 2; + } + return { chain, end: index }; + }; + const literalName = (token: CobolToken): CobolToken => ({ + ...token, + kind: 'word', + column: token.column + 1, + text: token.text.slice(1, -1), + upper: token.upper.slice(1, -1), + }); + const procedureLines = new Map(); + for (let i = 0; i < tokens.length; i++) + if (tokens[i]!.upper === 'PROCEDURE' && tokens[i + 1]?.upper === 'DIVISION') + procedureLines.set(programAt(tokens[i]!.line), tokens[i]!.line); + let dynamicCalls = false; + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i]!; + const procedureLine = procedureLines.get(programAt(token.line)); + if (procedureLine === undefined || token.line < procedureLine) { + // These clauses name data items even though they precede PROCEDURE + // DIVISION. Keep their OF/IN chain for the existing data resolver. + if ( + (token.upper === 'STATUS' && + ['FILE', 'SORT', 'CRT'].includes(tokens[i - 1]?.upper ?? '')) || + (token.upper === 'KEY' && ['RECORD', 'RELATIVE'].includes(tokens[i - 1]?.upper ?? '')) + ) { + const operand = i + (tokens[i + 1]?.upper === 'IS' ? 2 : 1); + if (tokens[operand]?.kind === 'word' && !COBOL_RESERVED.has(tokens[operand]!.upper)) + addReference(qualified(operand).chain, 'data'); + } + if (token.upper === 'PIC' || token.upper === 'PICTURE') { + // A picture is one contiguous character string, not a data reference. + // Do not swallow following clauses or an unrecognized word. + let next = i + (tokens[i + 1]?.upper === 'IS' ? 2 : 1); + while (tokens[next] && /^[AX9ZSVPBNCRDEG0-9()+,\-/$*.]+$/i.test(tokens[next]!.text)) { + used.add(tokenKey(tokens[next]!)); + const current = tokens[next++]!; + if ( + tokens[next]?.line !== current.line || + tokens[next]?.column !== current.column + current.text.length + ) + break; + } + } + } + if ( + token.upper === 'END' && + tokens[i + 1]?.upper === 'PROGRAM' && + tokens[i + 2]?.kind === 'word' + ) { + addReference([tokens[i + 2]!], 'program', programAt(token.line), { localProgram: true }); + } + if ( + ['CALL', 'CANCEL'].includes(token.upper) || + (token.upper === 'ENTRY' && tokens[i - 1]?.upper === 'TO') + ) { + const target = tokens[i + 1]; + if (target?.kind === 'literal') addReference([literalName(target)], 'program'); + else if (token.upper === 'CALL' || token.upper === 'CANCEL') { + dynamicCalls = true; + if (target?.kind === 'word') + addReference(qualified(i + 1).chain, 'data', programAt(token.line), { + isCall: token.upper === 'CALL', + }); + } + if (token.upper === 'CANCEL') + for (let next = i + 2; tokens[next]?.kind === 'literal'; next++) + addReference([literalName(tokens[next]!)], 'program'); + } + if (token.upper === 'PERFORM' && tokens[i - 1]?.upper !== 'EXIT') { + const target = tokens[i + 1]; + if ( + target?.kind === 'word' && + !COBOL_RESERVED.has(target.upper) && + tokens[i + 2]?.upper !== 'TIMES' + ) { + const first = qualified(i + 1); + addReference(first.chain, 'procedure'); + if ( + ['THRU', 'THROUGH'].includes(tokens[first.end + 1]?.upper ?? '') && + tokens[first.end + 2]?.kind === 'word' + ) + addReference(qualified(first.end + 2).chain, 'procedure'); + } + } + if (token.upper === 'GO' || token.upper === 'GOTO') { + let next = i + (tokens[i + 1]?.upper === 'TO' ? 2 : 1); + while (tokens[next]?.kind === 'word' && !COBOL_RESERVED.has(tokens[next]!.upper)) { + const target = qualified(next); + addReference(target.chain, 'procedure'); + next = target.end + 1; + } + } + if ( + ['REDEFINES', 'RENAMES'].includes(token.upper) || + (token.upper === 'ON' && tokens[i - 1]?.upper === 'DEPENDING') + ) { + if (tokens[i + 1]?.kind === 'word') { + const reference = qualified(i + 1); + addReference(reference.chain, 'data'); + if ( + token.upper === 'RENAMES' && + ['THRU', 'THROUGH'].includes(tokens[reference.end + 1]?.upper ?? '') && + tokens[reference.end + 2]?.kind === 'word' + ) + addReference(qualified(reference.end + 2).chain, 'data'); + } + } + if (token.upper === 'FUNCTION' && tokens[i + 1]?.kind === 'word') + used.add(tokenKey(tokens[i + 1]!)); + } + const unclassified: CobolToken[] = []; + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i]!; + if (token.kind !== 'word' || used.has(tokenKey(token)) || COBOL_RESERVED.has(token.upper)) + continue; + const program = programAt(token.line); + // Known syntax and clause operands were classified above. Preserve any + // remaining declaration-region word as unknown, never as unrelated text. + const procedureLine = procedureLines.get(program); + if (procedureLine === undefined || token.line < procedureLine) { + unclassified.push(token); + continue; + } + const reference = qualified(i); + addReference(reference.chain, 'data', program); + i = reference.end; + } + return { tokens, declarations, references, unclassified, dynamicCalls, extracted }; +} + +export function cobolSemanticCaptures(facts: CobolSemanticSource): CaptureMatch[] { + const result: CaptureMatch[] = []; + for (const declaration of facts.declarations) { + const { token, range, type, qualifiedName, scopeRange } = declaration; + const nameRange = cobolTokenRange(token); + const tag = `@declaration.${type === 'Module' ? 'program' : type.toLowerCase()}`; + const scope = type === 'Module' ? 'module' : type === 'Namespace' ? 'namespace' : 'function'; + result.push({ + [tag]: capture(tag, range, token.upper), + '@declaration.name': capture('@declaration.name', nameRange, token.upper), + '@declaration.qualified_name': capture( + '@declaration.qualified_name', + nameRange, + qualifiedName, + ), + '@declaration.binding-name': capture( + '@declaration.binding-name', + nameRange, + type === 'Property' ? qualifiedName : token.upper, + ), + ...(scopeRange + ? { [`@scope.${scope}`]: capture(`@scope.${scope}`, scopeRange, token.upper) } + : {}), + ...(declaration.parameterCount !== undefined + ? { + '@declaration.parameter-count': capture( + '@declaration.parameter-count', + nameRange, + String(declaration.parameterCount), + ), + } + : {}), + }); + } + for (const ref of facts.references) { + const range = cobolTokenRange(ref.token); + const tag = + (ref.kind === 'data' && !ref.isCall) || ref.chainIndex > 0 + ? '@reference.read' + : '@reference.call'; + const call = facts.extracted.calls.find( + (call) => call.line === ref.token.line && call.target.toUpperCase() === ref.token.upper, + ); + const arity = call?.parameters?.length; + // Callable-flow sites use the established statement anchor at column 0; + // the exact written name remains independent in @reference.name. + result.push({ + [tag]: capture(tag, call ? { ...range, startCol: 0 } : range, ref.token.upper), + '@reference.name': capture('@reference.name', range, ref.token.upper), + ...(arity ? { '@reference.arity': capture('@reference.arity', range, String(arity)) } : {}), + }); + } + return result; +} + +/** A qualified source name denotes its declaration, never the group's storage descendants. */ +export function resolveCobolReference( + reference: CobolReference, + source: CobolSemanticSource, + workspace: readonly CobolSemanticSource[], +): CobolDeclaration[] { + const { chain, kind, program } = reference; + const name = chain[0]!.upper; + const qualifiers = chain.slice(1).map((token) => token.upper); + let candidates = ( + kind === 'program' ? workspace.flatMap((file) => file.declarations) : source.declarations + ).filter( + (d) => + d.token.upper === name && + (kind === 'program' + ? d.type === 'Module' && (!reference.localProgram || d.program === program) + : d.program === program && + (kind === 'data' + ? d.type === 'Property' + : d.type === 'Function' || d.type === 'Namespace')), + ); + candidates = candidates.filter((d) => { + let position = 0; + for (const qualifier of qualifiers) { + const next = d.parents.findIndex( + (parent, index) => index >= position && parent.token.upper === qualifier, + ); + if (next < 0) return false; + position = next + 1; + } + return true; + }); + if (kind === 'procedure' && candidates.length > 1 && !qualifiers.length) { + const section = source.declarations.find( + (d) => + d.type === 'Namespace' && + d.program === program && + d.scopeRange && + d.scopeRange.startLine <= reference.token.line && + d.scopeRange.endLine >= reference.token.line, + ); + const local = candidates.filter((d) => d.parents.includes(section!)); + if (local.length) candidates = local; + } + if (reference.chainIndex === 0) return candidates; + const qualifier = chain[reference.chainIndex]!.upper; + return [ + ...new Set( + candidates.flatMap((d) => d.parents.filter((parent) => parent.token.upper === qualifier)), + ), + ]; +} diff --git a/gitnexus/src/core/ingestion/languages/cpp/callable-bindings.ts b/gitnexus/src/core/ingestion/languages/cpp/callable-bindings.ts index 3352055d7..9c866c242 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/callable-bindings.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/callable-bindings.ts @@ -4,6 +4,59 @@ import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexe import { lookupBindingsAt, lookupNameClaim } from '../../scope-resolution/scope/walkers.js'; import { definitionIdPosition } from '../../scope-resolution/utils/definition-id.js'; +/** A literal include exposes every overload declared in the header's module scope. */ +export function populateCppIncludedOverloads(indexes: ScopeResolutionIndexes): void { + const augmentations = indexes.bindingAugmentations as Map>; + for (const [scopeId, names] of indexes.bindings) { + for (const [name, bindings] of names) { + const additions: BindingRef[] = []; + const seen = new Set(bindings.map((binding) => binding.def.nodeId)); + for (const binding of bindings) { + const edge = binding.via; + if ( + edge?.kind !== 'wildcard-expanded' || + edge.targetModuleScope === undefined || + binding.def.type !== 'Function' + ) + continue; + const target = indexes.scopeTree.getScope(edge.targetModuleScope); + for (const candidate of target?.bindings.get(edge.targetExportedName ?? name) ?? []) { + const def = candidate.def; + if ( + def.type !== 'Function' || + def.ownerId !== undefined || + def.qualifiedName !== binding.def.qualifiedName || + seen.has(def.nodeId) + ) + continue; + seen.add(def.nodeId); + additions.push({ + ...binding, + def, + via: { ...edge, targetDefId: def.nodeId }, + ...(edge.atRange === undefined + ? {} + : { + declarationRange: edge.atRange, + availableFrom: { + startLine: edge.atRange.endLine, + startCol: edge.atRange.endCol, + }, + }), + }); + } + } + if (additions.length === 0) continue; + let augmented = augmentations.get(scopeId); + if (augmented === undefined) { + augmented = new Map(); + augmentations.set(scopeId, augmented); + } + augmented.set(name, [...(augmented.get(name) ?? []), ...additions]); + } + } +} + /** * ISO C++ `[basic.lookup.unqual]` §7: ADL is suppressed when ordinary * unqualified lookup finds: diff --git a/gitnexus/src/core/ingestion/languages/cpp/captures.ts b/gitnexus/src/core/ingestion/languages/cpp/captures.ts index 0cfcccba6..c8dd4075d 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/captures.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/captures.ts @@ -426,15 +426,11 @@ export function emitCppScopeCaptures( } if (operatorAnchor !== undefined && grouped['@reference.name'] === undefined) { - // The old code did `findNodeAtRange(tree.rootNode, operatorAnchor.range, - // operatorAnchor.text)`, searching for a node of type `+` / `<<` at the - // operator-token range. That token is an UNNAMED grammar node, and - // findNodeAtRange only descends `namedChild`ren, so the search NEVER hit - // and ALWAYS fell back to `tree.rootNode`. Use `tree.rootNode` directly to - // preserve the exact synthetic-capture range while dropping the root-walk. + // Synthetic operator names still refer to the written operator token. + // A translation-unit range makes distinct operators share one identity. grouped['@reference.name'] = syntheticCapture( '@reference.name', - tree.rootNode, + nodeMap['@reference.operator']!, `operator${operatorAnchor.text}`, ); } diff --git a/gitnexus/src/core/ingestion/languages/cpp/rename.ts b/gitnexus/src/core/ingestion/languages/cpp/rename.ts new file mode 100644 index 000000000..8daf17c22 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/cpp/rename.ts @@ -0,0 +1,256 @@ +import { CLASS_KINDS, SupportedLanguages, type SymbolDefinition } from 'gitnexus-shared'; +import type { RenameProvider, RenameSource, RenameSemanticContext } from '../../rename-provider.js'; +import { createTreeSitterRenameSource } from '../rename-tree-sitter.js'; +import { + cMacroNames, + cNameReference, + cNameRole, + checkCMacroSafety, + withParameterClaims, +} from '../c/rename.js'; +import { callableDeclarationFamilies } from '../../scope-resolution/passes/callable-value-flow.js'; +import { buildGraphNodeLookup } from '../../scope-resolution/graph-bridge/node-lookup.js'; +import { simpleQualifiedName } from '../../scope-resolution/graph-bridge/ids.js'; +import { graphRenameFamily } from '../rename-family.js'; +import { isFileLocal } from './file-local-linkage.js'; +import { isStaticName } from '../c/static-linkage.js'; +import { normalizeQualifiedName } from '../../utils/qualified-name.js'; +import { lookupNameClaim } from '../../scope-resolution/scope/walkers.js'; + +// C++ [lex.key], including alternative operator tokens. C-only keywords such as +// restrict are valid C++ identifiers and must not enter this provider's set. +const keywords = new Set( + 'alignas alignof and and_eq asm auto bitand bitor bool break case catch char char8_t char16_t char32_t class compl concept const consteval constexpr constinit const_cast continue contract_assert co_await co_return co_yield decltype default delete do double dynamic_cast else enum explicit export extern false float for friend goto if inline int long mutable namespace new noexcept not not_eq nullptr operator or or_eq private protected public register reinterpret_cast requires return short signed sizeof static static_assert static_cast struct switch template this thread_local throw true try typedef typeid typename union unsigned using virtual void volatile wchar_t while xor xor_eq'.split( + ' ', + ), +); +const names = new Set([ + 'identifier', + 'type_identifier', + 'field_identifier', + 'namespace_identifier', + 'statement_identifier', +]); + +const parseSource = createTreeSitterRenameSource({ + isName: (node) => names.has(node.type), + skip: (node) => node.type === 'comment', + role: cNameRole, + transformParsed: (parsed) => ({ + ...parsed, + localDefs: parsed.localDefs.map((def) => { + if (!simpleQualifiedName(def)?.startsWith('~') || !def.nameRange) return def; + // The destructor's tilde is syntax; the source name is the child identifier. + return Object.assign(def, { + nameRange: { ...def.nameRange, startCol: def.nameRange.startCol + 1 }, + }); + }), + }), + reference(node, source) { + const site = cNameReference(node, source); + return site && + (node.type === 'type_identifier' || + node.type === 'namespace_identifier' || + node.parent?.type === 'destructor_name') + ? { ...site, kind: 'type-reference' as const, lookupPurpose: 'type' as const } + : site; + }, +}); + +/** Qualified members and constructors do not declare a free lexical function. */ +function withCppLexicalBindings(source: RenameSource): RenameSource { + const nonLexical = new Set(); + const defs = new Map(source.parsed.localDefs.map((def) => [def.nodeId, def])); + for (const def of defs.values()) { + const owner = def.ownerId && defs.get(def.ownerId); + if ( + owner && + CLASS_KINDS.includes(owner.type) && + (simpleQualifiedName(def) ?? '').replace(/^~/, '') === simpleQualifiedName(owner) + ) + nonLexical.add(def.nodeId); + } + for (const occurrence of source.occurrences) { + if (!occurrence.definitionId || !occurrence.node) continue; + const name = + occurrence.node.parent?.type === 'destructor_name' ? occurrence.node.parent : occurrence.node; + if (name.parent?.type === 'qualified_identifier') nonLexical.add(occurrence.definitionId); + } + return { + ...source, + parsed: { + ...source.parsed, + scopes: source.parsed.scopes.map((scope) => ({ + ...scope, + bindings: new Map( + [...scope.bindings].flatMap(([name, bindings]) => { + const lexical = bindings.filter((binding) => !nonLexical.has(binding.def.nodeId)); + return lexical.length ? [[name, lexical]] : []; + }), + ), + })), + }, + }; +} + +function classRenameTarget(context: RenameSemanticContext): SymbolDefinition | undefined { + if (CLASS_KINDS.includes(context.target.type)) return context.target; + const owner = + context.target.ownerId && + context.definitions.find((def) => def.nodeId === context.target.ownerId); + return owner && + (simpleQualifiedName(context.target) ?? '').replace(/^~/, '') === simpleQualifiedName(owner) + ? owner + : undefined; +} + +export const cppRenameProvider: RenameProvider = { + additionalInteropNames: cMacroNames, + // Objective-C++ shares these names, and cgo calls an `extern "C"` function by its own name. + interoperatesWithSourceProvider: (provider) => + [SupportedLanguages.ObjectiveC, SupportedLanguages.Go].includes(provider.id), + checkSafety(context) { + const macroIssue = checkCMacroSafety(context); + if (macroIssue) return macroIssue; + if (!context.newName.startsWith('_')) return; + const target = classRenameTarget(context) ?? context.target; + const occurrence = context.files + .get(target.filePath) + ?.occurrences.find((candidate) => candidate.definitionId === target.nodeId); + for (let node = occurrence?.node?.parent; node; node = node.parent ?? undefined) { + const declaredName = node.childForFieldName('name'); + const isOwnName = + declaredName && + occurrence?.node && + declaredName.startIndex <= occurrence.node.startIndex && + declaredName.endIndex >= occurrence.node.endIndex; + if ( + ['namespace_definition', 'class_specifier', 'struct_specifier', 'union_specifier'].includes( + node.type, + ) && + !isOwnName + ) + return; + if (node.type === 'qualified_identifier' && !node.text.startsWith('::')) return; + if ( + node.type === 'function_definition' && + !['Function', 'Method', 'Constructor'].includes(target.type) + ) + return; + } + // [lex.name]: identifiers beginning with an underscore are reserved in + // the global namespace. Class, namespace and local names remain legal. + return { + code: 'invalid_name', + message: 'A leading underscore is reserved in the C++ global namespace.', + }; + }, + canonicalName: (name) => name.replace(/^~/, ''), + acceptsSourceProvider: (provider) => + [SupportedLanguages.C, SupportedLanguages.CPlusPlus].includes(provider.id), + symbolFamily(context) { + const writtenQualifications = new Map(); + const writtenOwners = new Map(); + for (const source of context.files.values()) + for (const occurrence of source.occurrences) { + if (!occurrence.definitionId || !occurrence.node) continue; + const name = + occurrence.node.parent?.type === 'destructor_name' + ? occurrence.node.parent + : occurrence.node; + const qualified = name.parent; + if ( + qualified?.type === 'qualified_identifier' && + qualified.childForFieldName('name')?.id === name.id + ) { + writtenQualifications.set( + occurrence.definitionId, + normalizeQualifiedName(qualified.text), + ); + const qualifier = qualified.childForFieldName('scope'); + const qualifierOccurrence = source.occurrences.find( + (candidate) => candidate.node?.id === qualifier?.id, + ); + const site = qualifierOccurrence?.referenceSite; + if (site) { + const claim = lookupNameClaim( + site.lookupScope ?? site.inScope, + site.name, + context.indexes, + { + position: site.atRange, + purpose: 'type', + }, + ); + const owners = [ + ...new Set( + claim.bindings + .filter((binding) => CLASS_KINDS.includes(binding.def.type)) + .map((binding) => binding.def.nodeId), + ), + ]; + if (claim.status === 'resolved' && owners.length === 1) + writtenOwners.set(occurrence.definitionId, owners[0]); + } + } + } + const declarations = callableDeclarationFamilies( + context.indexes, + context.graph, + buildGraphNodeLookup(context.graph), + (def) => + !def.ownerId && + (isFileLocal(def.filePath, simpleQualifiedName(def) ?? '') || + isStaticName(def.filePath, simpleQualifiedName(def) ?? '')), + writtenQualifications, + ); + const classTarget = classRenameTarget(context); + if (classTarget) { + const className = simpleQualifiedName(classTarget); + const selected = new Map([[classTarget.nodeId, classTarget]]); + for (const def of context.definitions) { + if ( + (def.ownerId ?? writtenOwners.get(def.nodeId)) !== classTarget.nodeId || + (simpleQualifiedName(def) ?? '').replace(/^~/, '') !== className + ) + continue; + for (const member of declarations.get(def.nodeId) ?? [def]) + selected.set(member.nodeId, member); + } + return [...selected.values()]; + } + return graphRenameFamily(context, { + requiresBaseMember: (def) => { + const name = context.files + .get(def.filePath) + ?.occurrences.find((occurrence) => occurrence.definitionId === def.nodeId)?.node; + const declarator = + name?.parent?.type === 'qualified_identifier' ? name.parent.parent : name?.parent; + return ( + declarator?.namedChildren.some( + (node) => node.type === 'virtual_specifier' && node.text === 'override', + ) === true + ); + }, + matches: (left, right) => + !!left.ownerId && + !!right.ownerId && + simpleQualifiedName(left) === simpleQualifiedName(right) && + left.parameterCount === right.parameterCount && + JSON.stringify(left.parameterTypes) === JSON.stringify(right.parameterTypes), + sameDeclarationGroup: (left, right) => + declarations.get(left.nodeId)?.some((def) => def.nodeId === right.nodeId) === true, + }); + }, + validateName(name) { + if ( + !/^[_\p{ID_Start}][_\p{ID_Continue}]*$/u.test(name) || + keywords.has(name) || + name.includes('__') || + /^_[A-Z]/.test(name) + ) + return { code: 'invalid_name', message: 'Choose a valid nonreserved C++ identifier.' }; + }, + parseSource: async (input) => + withCppLexicalBindings(withParameterClaims(await parseSource(input))), +}; diff --git a/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts index b3feeac8d..8dc8752d8 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts @@ -35,7 +35,10 @@ import { isCppDependentBaseMember, } from './two-phase-lookup.js'; import { populateCppAssociatedNamespaces, clearCppAdlState, pickCppAdlCandidates } from './adl.js'; -import { findCallableBindingsAndAdlBlocker } from './callable-bindings.js'; +import { + findCallableBindingsAndAdlBlocker, + populateCppIncludedOverloads, +} from './callable-bindings.js'; import { applyCppCaptureSideChannel } from './capture-side-channel.js'; import { clearCppInlineNamespaces, @@ -227,7 +230,10 @@ export const cppScopeResolver: ScopeResolver = { ); }, - populateNamespaceSiblings: populateCppUsingBindings, + populateNamespaceSiblings: (parsedFiles, indexes) => { + populateCppUsingBindings(parsedFiles, indexes); + populateCppIncludedOverloads(indexes); + }, // Simple `isSuperReceiver` returns false for C++. Real super // classification is caller-context-dependent and lives in diff --git a/gitnexus/src/core/ingestion/languages/csharp.ts b/gitnexus/src/core/ingestion/languages/csharp.ts index 99bca7a6c..90fbc7c28 100644 --- a/gitnexus/src/core/ingestion/languages/csharp.ts +++ b/gitnexus/src/core/ingestion/languages/csharp.ts @@ -7,6 +7,7 @@ */ import { SupportedLanguages } from 'gitnexus-shared'; +import { csharpRenameProvider } from './csharp/rename.js'; import { createClassExtractor } from '../class-extractors/generic.js'; import { csharpClassConfig } from '../class-extractors/configs/csharp.js'; import { defineLanguage } from '../language-provider.js'; @@ -134,6 +135,7 @@ const BUILT_INS: ReadonlySet = new Set([ ]); export const csharpProvider = defineLanguage({ + rename: csharpRenameProvider, id: SupportedLanguages.CSharp, extensions: ['.cs'], entryPointPatterns: [ @@ -185,7 +187,12 @@ export const csharpProvider = defineLanguage({ patterns: ['DbContext', 'DbSet<', 'OnModelCreating'], }, ] satisfies AstFrameworkPatternConfig[], - treeSitterQueries: CSHARP_QUERIES, + treeSitterQueries: `${CSHARP_QUERIES} +; Fields are graph-addressable members, alongside C# properties. +(field_declaration + (variable_declaration + (variable_declarator name: (identifier) @name))) @definition.property +`, typeConfig: csharpConfig, exportChecker: csharpExportChecker, importResolver: createImportResolver(csharpImportConfig), diff --git a/gitnexus/src/core/ingestion/languages/csharp/identifiers.ts b/gitnexus/src/core/ingestion/languages/csharp/identifiers.ts new file mode 100644 index 000000000..28302b72d --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/csharp/identifiers.ts @@ -0,0 +1,11 @@ +/** Semantic identifier spelling; source graph names and ranges remain unchanged. */ +export const canonicalName = (name: string): string => + name + .replace(/^@/, '') + .replace( + /\\(?:u([0-9a-fA-F]{4})|U([0-9a-fA-F]{8}))/g, + (escape, short: string | undefined, long: string | undefined) => { + const value = Number.parseInt(short ?? long!, 16); + return value <= 0x10ffff ? String.fromCodePoint(value) : escape; + }, + ); diff --git a/gitnexus/src/core/ingestion/languages/csharp/query.ts b/gitnexus/src/core/ingestion/languages/csharp/query.ts index da89f29b7..a219cc0c3 100644 --- a/gitnexus/src/core/ingestion/languages/csharp/query.ts +++ b/gitnexus/src/core/ingestion/languages/csharp/query.ts @@ -133,11 +133,12 @@ const CSHARP_SCOPE_QUERY = ` (indexer_declaration) @declaration.property ;; Fields — \`int x;\` at class scope. variable_declarator inside -;; field_declaration carries the name. +;; field_declaration carries the name. Fields are addressable members, like +;; properties; local variables remain lexical bindings below. (field_declaration (variable_declaration (variable_declarator - name: (identifier) @declaration.name))) @declaration.variable + name: (identifier) @declaration.name))) @declaration.property ;; Local variables — \`int x = 1;\` inside a method body (local_declaration_statement @@ -165,6 +166,10 @@ const CSHARP_SCOPE_QUERY = ` type: (nullable_type) @type-binding.type name: (identifier) @type-binding.name) @type-binding.parameter +(parameter + type: (predefined_type) @type-binding.type + name: (identifier) @type-binding.name) @type-binding.parameter + ;; Type bindings — local variable annotations: \`User u = new User();\` ;; Typed local with identifier type + \`new X()\` initializer — shape ;; matters so \`u\` binds to \`X\` (the constructor call's type), not the diff --git a/gitnexus/src/core/ingestion/languages/csharp/rename.ts b/gitnexus/src/core/ingestion/languages/csharp/rename.ts new file mode 100644 index 000000000..520cb776f --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/csharp/rename.ts @@ -0,0 +1,156 @@ +import type Parser from 'tree-sitter'; +import { CLASS_KINDS } from 'gitnexus-shared'; +import type { RenameOccurrence, RenameProvider, RenameSource } from '../../rename-provider.js'; +import { simpleQualifiedName } from '../../scope-resolution/graph-bridge/ids.js'; +import { + canonicalizeRenameBindings, + createTreeSitterRenameSource, + innermostRenameScope, + nodeRange, +} from '../rename-tree-sitter.js'; +import { graphRenameFamily } from '../rename-family.js'; +import { canonicalName } from './identifiers.js'; + +const names = new Set(['identifier']); +const keywords = new Set( + 'abstract as base bool break byte case catch char checked class const continue decimal default delegate do double else enum event explicit extern false finally fixed float for foreach goto if implicit in int interface internal is lock long namespace new null object operator out override params private protected public readonly ref return sbyte sealed short sizeof stackalloc static string struct switch this throw true try typeof uint ulong unchecked unsafe ushort using virtual void volatile while'.split( + ' ', + ), +); + +function role(node: Parser.SyntaxNode): RenameOccurrence['role'] { + const parent = node.parent; + if (!parent) return 'name'; + if ( + (parent.type === 'parameter' && parent.childForFieldName('name')?.id === node.id) || + parent.type === 'implicit_parameter' + ) + return 'binding'; + if ( + (parent.type === 'argument' && parent.childForFieldName('name')?.id === node.id) || + parent.type === 'labeled_statement' || + parent.type === 'goto_statement' + ) + return 'unrelated'; + for (let ancestor: Parser.SyntaxNode | null = parent; ancestor; ancestor = ancestor.parent) { + if (ancestor.type === 'using_directive') + return ancestor.childForFieldName('name')?.id === node.id && + ancestor.children.some((child) => child.text === '=') + ? 'alias' + : 'imported'; + } + return 'name'; +} + +function reference(node: Parser.SyntaxNode, source: RenameSource) { + const parent = node.parent; + if ( + !parent || + (parent.childForFieldName('name')?.id === node.id && + /declaration|definition|signature|declarator/.test(parent.type)) + ) + return undefined; + // A qualified member has its own semantic receiver; never reinterpret it as a bare lexical read. + if ( + ['member_access_expression', 'qualified_name', 'alias_qualified_name'].includes(parent.type) && + parent.namedChildCount > 1 && + parent.lastNamedChild?.id === node.id + ) + return undefined; + const inScope = innermostRenameScope(source.parsed, node); + if (!inScope) return undefined; + const range = nodeRange(node); + const isType = + parent.childForFieldName('type')?.id === node.id || + parent.childForFieldName('returns')?.id === node.id || + [ + 'type_argument_list', + 'base_list', + 'type_parameter_constraint', + 'nullable_type', + 'array_type', + ].includes(parent.type); + return { + name: canonicalName(node.text), + atRange: range, + nameRange: range, + inScope, + kind: isType ? ('type-reference' as const) : ('read' as const), + }; +} + +export const csharpRenameProvider: RenameProvider = { + canonicalName, + validateName(name) { + const canonical = canonicalName(name); + if ( + !( + /^[_\p{ID_Start}][_\p{ID_Continue}]*$/u.test(canonical) && + (!keywords.has(canonical) || name.startsWith('@')) + ) + ) + return { code: 'invalid_name', message: 'Choose a valid nonreserved identifier.' }; + }, + parseSource: createTreeSitterRenameSource({ + isName: (node) => names.has(node.type), + skip: (node) => node.type.includes('comment'), + role, + canonicalName, + transformParsed: (parsed) => canonicalizeRenameBindings(parsed, canonicalName), + reference, + }), + rewrite({ source, occurrence, newName }) { + return occurrence.spans.map((span) => { + const oldText = source.content.slice(span.start, span.start + span.length); + return { + ...span, + oldText, + newText: oldText.startsWith('@') ? '@' + canonicalName(newName) : newName, + }; + }); + }, + symbolFamily(context) { + const name = (def: typeof context.target) => canonicalName(simpleQualifiedName(def) ?? ''); + const isTypeNamedMember = (def: typeof context.target) => + def.type === 'Constructor' || + context.files + .get(def.filePath) + ?.occurrences.find((occurrence) => occurrence.definitionId === def.nodeId)?.node?.parent + ?.type === 'destructor_declaration'; + const method = (def: typeof context.target) => + ['Method', 'Function'].includes(def.type) && !!def.ownerId; + const declaration = (def: typeof context.target) => + context.files + .get(def.filePath) + ?.occurrences.find((occurrence) => occurrence.definitionId === def.nodeId)?.node?.parent; + const isInstance = (def: typeof context.target) => + !declaration(def)?.namedChildren.some( + (node) => node.type === 'modifier' && ['private', 'static'].includes(node.text), + ); + return graphRenameFamily(context, { + requiresBaseMember: (def) => + declaration(def)?.namedChildren.some( + (node) => node.type === 'modifier' && node.text === 'override', + ) === true, + matches: (left, right) => + method(left) && + method(right) && + isInstance(left) && + isInstance(right) && + name(left) === name(right), + sameDeclarationGroup: (left, right) => + (method(left) && + method(right) && + left.ownerId === right.ownerId && + name(left) === name(right)) || + (CLASS_KINDS.includes(left.type) && + isTypeNamedMember(right) && + right.ownerId === left.nodeId && + name(left) === name(right)) || + (CLASS_KINDS.includes(right.type) && + isTypeNamedMember(left) && + left.ownerId === right.nodeId && + name(left) === name(right)), + }); + }, +}; diff --git a/gitnexus/src/core/ingestion/languages/csharp/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/csharp/scope-resolver.ts index 6d200de9b..b767e8c3f 100644 --- a/gitnexus/src/core/ingestion/languages/csharp/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/csharp/scope-resolver.ts @@ -7,11 +7,16 @@ */ import type { ParsedFile } from 'gitnexus-shared'; -import { SupportedLanguages } from 'gitnexus-shared'; +import { CLASS_KINDS, SupportedLanguages } from 'gitnexus-shared'; import { buildMro, defaultLinearize } from '../../scope-resolution/passes/mro.js'; import { populateClassOwnedMembers } from '../../scope-resolution/scope/walkers.js'; import { populateCsharpNamespacePrefixes } from './qualified-type-names.js'; import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js'; +import { + canonicalizeWorkspaceBindings, + canonicalModuleExports, +} from '../../scope-resolution/scope/canonicalize-bindings.js'; +import { canonicalName } from './identifiers.js'; import { csharpProvider } from '../csharp.js'; import { csharpArityCompatibility, @@ -24,7 +29,15 @@ import { loadCsharpResolutionConfig, type CsharpResolutionConfig } from './resol import { unwrapCsharpElementType } from './accessor-unwrap.js'; import { emitRazorViewComponentEdges } from './razor-view-components.js'; +const declarationImports = new WeakMap< + readonly ParsedFile[], + ReadonlyMap +>(); + const csharpScopeResolver: ScopeResolver = { + moduleExports: (parsed) => canonicalModuleExports(parsed, canonicalName), + populateWorkspaceReferences: (parsedFiles) => + canonicalizeWorkspaceBindings(parsedFiles, canonicalName), // Construction is keyword-prefixed: `new Service(db).doWork()` (#2708). constructionSyntax: { keyword: 'new' }, language: SupportedLanguages.CSharp, @@ -33,7 +46,33 @@ const csharpScopeResolver: ScopeResolver = { loadResolutionConfig: (repoPath) => loadCsharpResolutionConfig(repoPath), - resolveImportTarget: (targetRaw, fromFile, allFilePaths, resolutionConfig) => { + resolveImportTarget: (targetRaw, fromFile, allFilePaths, resolutionConfig, context) => { + // A using names a declaration, independently of its containing filename. + // Prefer the already-extracted namespace identity; file suffixes are only + // the fallback for imports with no source declaration in this workspace. + const target = targetRaw.replace(/^global::/, '').replace(/@/g, ''); + let declarations = context && declarationImports.get(context.parsedFiles); + if (context && !declarations) { + const filesByName = new Map>(); + for (const parsed of context.parsedFiles) { + for (const def of parsed.localDefs) { + if (!CLASS_KINDS.includes(def.type) && def.type !== 'Namespace') continue; + const qualified = + def.namespacePrefix && !def.qualifiedName?.startsWith(def.namespacePrefix + '.') + ? def.namespacePrefix + '.' + def.qualifiedName + : def.qualifiedName; + if (!qualified) continue; + const key = qualified.replace(/@/g, ''); + const paths = filesByName.get(key) ?? new Set(); + paths.add(parsed.filePath); + filesByName.set(key, paths); + } + } + declarations = new Map([...filesByName].map(([name, paths]) => [name, [...paths].sort()])); + declarationImports.set(context.parsedFiles, declarations); + } + const exactFiles = declarations?.get(target); + if (exactFiles?.length) return exactFiles; const config = resolutionConfig as CsharpResolutionConfig | undefined; const ws: CsharpResolveContext = { fromFile, diff --git a/gitnexus/src/core/ingestion/languages/dart.ts b/gitnexus/src/core/ingestion/languages/dart.ts index f0efd455e..53c31f70a 100644 --- a/gitnexus/src/core/ingestion/languages/dart.ts +++ b/gitnexus/src/core/ingestion/languages/dart.ts @@ -16,6 +16,7 @@ import { } from '../utils/ast-helpers.js'; import type { NodeLabel } from 'gitnexus-shared'; import { SupportedLanguages } from 'gitnexus-shared'; +import { dartRenameProvider } from './dart/rename.js'; import { createClassExtractor } from '../class-extractors/generic.js'; import { dartClassConfig } from '../class-extractors/configs/dart.js'; import { defineLanguage } from '../language-provider.js'; @@ -82,6 +83,7 @@ const dartEnclosingFunctionFinder = ( }; export const dartProvider = defineLanguage({ + rename: dartRenameProvider, id: SupportedLanguages.Dart, extensions: ['.dart'], entryPointPatterns: [ diff --git a/gitnexus/src/core/ingestion/languages/dart/captures.ts b/gitnexus/src/core/ingestion/languages/dart/captures.ts index fb5fc63ee..58c68a648 100644 --- a/gitnexus/src/core/ingestion/languages/dart/captures.ts +++ b/gitnexus/src/core/ingestion/languages/dart/captures.ts @@ -291,7 +291,10 @@ export function emitDartScopeCaptures( // ── Pass B: tree-walked references, type inference, heritage ──────────── const seenReadSpans = new Set(); walkNamedTree(root, (node) => { - if (node.type === 'selector') { + if ( + node.type === 'selector' || + (ASSIGNABLE_SELECTORS.has(node.type) && node.parent?.type !== 'selector') + ) { emitSelectorReference(node, out, seenReadSpans); return; } @@ -446,7 +449,7 @@ function computeReceiverText(nameSelector: SyntaxNode): string | null { let cur = nameSelector.previousNamedSibling; let head: SyntaxNode | null = null; while (cur !== null) { - if (cur.type === 'selector') { + if (cur.type === 'selector' || ASSIGNABLE_SELECTORS.has(cur.type)) { selectors.push(cur); cur = cur.previousNamedSibling; continue; @@ -467,7 +470,9 @@ function emitSelectorReference( out: CaptureMatch[], seenReadSpans: Set, ): void { - const inner = selector.namedChild(0); + // `super.member` and the final member of an assignment target are bare + // assignable selectors; other postfix members have a `selector` wrapper. + const inner = selector.type === 'selector' ? selector.namedChild(0) : selector; if (inner === null) return; // A `selector(argument_part)` is the call marker; the callee is the @@ -492,8 +497,8 @@ function emitSelectorReference( }); return; } - if (prev.type === 'selector') { - const prevInner = prev.namedChild(0); + if (prev.type === 'selector' || ASSIGNABLE_SELECTORS.has(prev.type)) { + const prevInner = prev.type === 'selector' ? prev.namedChild(0) : prev; if (prevInner === null) return; if (ASSIGNABLE_SELECTORS.has(prevInner.type)) { const nameId = selectorName(prevInner); @@ -514,8 +519,8 @@ function emitSelectorReference( return; } - // A member access selector that is NOT immediately followed by a call is a - // field read (`user.address` in `user.address.save()`). + // A member not followed by a call is a read, except the final selector on + // an assignment's left side. Intermediate receivers remain reads. if (ASSIGNABLE_SELECTORS.has(inner.type)) { const next = selector.nextNamedSibling; const isCall = @@ -531,8 +536,14 @@ function emitSelectorReference( if (seenReadSpans.has(spanKey)) return; seenReadSpans.add(spanKey); + const target = selector.parent; + const isWrite = + target?.type === 'assignable_expression' && + target.lastNamedChild?.id === selector.id && + target.parent?.childForFieldName('left')?.id === target.id; + const tag = isWrite ? '@reference.write.member' : '@reference.read.member'; out.push({ - '@reference.read.member': nodeToCapture('@reference.read.member', nameId), + [tag]: nodeToCapture(tag, nameId), '@reference.name': nodeToCapture('@reference.name', nameId), '@reference.receiver': syntheticCapture('@reference.receiver', selector, recv), }); diff --git a/gitnexus/src/core/ingestion/languages/dart/interpret.ts b/gitnexus/src/core/ingestion/languages/dart/interpret.ts index 31e5c3fef..6872bfc01 100644 --- a/gitnexus/src/core/ingestion/languages/dart/interpret.ts +++ b/gitnexus/src/core/ingestion/languages/dart/interpret.ts @@ -36,6 +36,16 @@ export function interpretDartImport(captures: CaptureMatch): ParsedImport | null const sourceCap = captures['@import.source']; if (sourceCap === undefined) return null; const raw = stripQuotes(sourceCap.text); + const alias = captures['@import.alias']; + if (alias !== undefined) { + return { + kind: 'namespace', + targetRaw: raw, + localName: alias.text, + importedName: raw, + explicitAlias: true, + }; + } // Dart `import '...'` brings every PUBLIC top-level symbol of the target // library directly into scope (no prefix) — wildcard semantics. The // `expandsWildcardTo` hook enumerates those names so cross-file return diff --git a/gitnexus/src/core/ingestion/languages/dart/query.ts b/gitnexus/src/core/ingestion/languages/dart/query.ts index fb93f5fb8..c646a0b43 100644 --- a/gitnexus/src/core/ingestion/languages/dart/query.ts +++ b/gitnexus/src/core/ingestion/languages/dart/query.ts @@ -227,7 +227,8 @@ const DART_SCOPE_QUERY = ` (import_or_export (library_import (import_specification - (configurable_uri) @import.source))) @import.statement + (configurable_uri) @import.source + (identifier)? @import.alias))) @import.statement (import_or_export (library_export (configurable_uri) @import.source)) @import.statement diff --git a/gitnexus/src/core/ingestion/languages/dart/rename.ts b/gitnexus/src/core/ingestion/languages/dart/rename.ts new file mode 100644 index 000000000..36b39c722 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/dart/rename.ts @@ -0,0 +1,166 @@ +import type Parser from 'tree-sitter'; +import { CLASS_KINDS } from 'gitnexus-shared'; +import type { RenameOccurrence, RenameProvider, RenameSource } from '../../rename-provider.js'; +import { simpleQualifiedName } from '../../scope-resolution/graph-bridge/ids.js'; +import { hasKeyword } from '../../field-extractors/configs/helpers.js'; +import { + canonicalizeRenameBindings, + createTreeSitterRenameSource, + innermostRenameScope, + nodeRange, +} from '../rename-tree-sitter.js'; +import { graphRenameFamily } from '../rename-family.js'; + +const canonicalName = (name: string): string => name; +const names = new Set(['identifier', 'type_identifier']); +const keywords = new Set( + 'assert break case catch class const continue default do else enum extends false final finally for if in is new null rethrow return super switch this throw true try var void while with'.split( + ' ', + ), +); +// Built-in identifiers remain legal member names, but cannot name types, +// extensions or import prefixes: https://dart.dev/language/keywords. +const builtInIdentifiers = new Set( + 'abstract as covariant deferred dynamic export extension external factory Function get implements import interface late library mixin operator part required set static type typedef'.split( + ' ', + ), +); + +function role(node: Parser.SyntaxNode): RenameOccurrence['role'] { + const parent = node.parent; + if (!parent) return 'name'; + if (parent.type === 'formal_parameter' && parent.childForFieldName('name')?.id === node.id) + return 'binding'; + if ( + parent.type === 'label' || + (parent.type === 'argument' && + parent.firstNamedChild?.id === node.id && + parent.children.some((child) => child.text === ':')) + ) + return 'unrelated'; + for (let ancestor: Parser.SyntaxNode | null = parent; ancestor; ancestor = ancestor.parent) + if (ancestor.type === 'import_or_export') return 'imported'; + return 'name'; +} + +function reference(node: Parser.SyntaxNode, source: RenameSource) { + const parent = node.parent; + if ( + !parent || + (parent.childForFieldName('name')?.id === node.id && + /declaration|definition|signature|declarator/.test(parent.type)) + ) + return undefined; + // A qualified member has its own semantic receiver; never reinterpret it as a bare lexical read. + if ( + ['unconditional_assignable_selector', 'conditional_assignable_selector'].includes(parent.type) + ) + return undefined; + if ( + ['selector'].includes(parent.type) && + parent.namedChildCount > 1 && + parent.lastNamedChild?.id === node.id + ) + return undefined; + const inScope = innermostRenameScope(source.parsed, node); + if (!inScope) return undefined; + const range = nodeRange(node); + return { + name: canonicalName(node.text), + atRange: range, + nameRange: range, + inScope, + kind: node.type === 'type_identifier' ? ('type-reference' as const) : ('read' as const), + }; +} + +export const dartRenameProvider: RenameProvider = { + canonicalName, + validateName(name) { + const canonical = canonicalName(name); + if (!(/^[$_\p{ID_Start}][$_\p{ID_Continue}]*$/u.test(canonical) && !keywords.has(canonical))) + return { code: 'invalid_name', message: 'Choose a valid nonreserved identifier.' }; + }, + parseSource: createTreeSitterRenameSource({ + isName: (node) => names.has(node.type), + skip: (node) => node.type.includes('comment'), + role, + canonicalName, + transformParsed: (parsed) => canonicalizeRenameBindings(parsed, canonicalName), + reference, + }), + checkSafety({ target, definitions, newName }) { + const type = + target.type === 'Constructor' + ? definitions.find((definition) => definition.nodeId === target.ownerId) + : target; + if (type && CLASS_KINDS.includes(type.type) && builtInIdentifiers.has(canonicalName(newName))) + return { + code: 'invalid_name', + message: 'A built-in identifier cannot name a type, extension or import prefix.', + }; + }, + checkOccurrence({ selected, occurrence, newName }) { + if (!selected || !['await', 'yield'].includes(newName)) return; + for (let node = occurrence.node?.parent; node; node = node.parent ?? undefined) { + if (node.type !== 'function_body') continue; + if (node.children.some((child) => ['async', 'async*', 'sync*'].includes(child.text))) + return { + code: 'invalid_name', + message: 'await and yield cannot be identifiers in an async or generator body.', + }; + // A nested synchronous function has its own identifier context. + return; + } + }, + symbolFamily(context) { + const name = (def: typeof context.target) => canonicalName(simpleQualifiedName(def) ?? ''); + const method = (def: typeof context.target) => + ['Method', 'Function'].includes(def.type) && !!def.ownerId; + const sameMethod = (left: typeof context.target, right: typeof context.target) => + method(left) && method(right) && left.ownerId === right.ownerId && name(left) === name(right); + const isInstance = (def: typeof context.target) => { + const signature = context.files + .get(def.filePath) + ?.occurrences.find((occurrence) => occurrence.definitionId === def.nodeId)?.node + ?.parent?.parent; + return signature?.type !== 'method_signature' || !hasKeyword(signature, 'static'); + }; + // Static members are not inherited; syntax proves their family stays in one owner. + if (method(context.target) && !isInstance(context.target)) + return context.definitions.filter((definition) => sameMethod(context.target, definition)); + return graphRenameFamily(context, { + requiresBaseMember: (def) => { + const signature = context.files + .get(def.filePath) + ?.occurrences.find((occurrence) => occurrence.definitionId === def.nodeId)?.node + ?.parent?.parent; + if (signature?.type !== 'method_signature') return false; + for ( + let node = signature.previousNamedSibling; + node?.type === 'annotation'; + node = node.previousNamedSibling + ) { + if (node.childForFieldName('name')?.text === 'override') return true; + } + return false; + }, + matches: (left, right) => + method(left) && + method(right) && + isInstance(left) && + isInstance(right) && + name(left) === name(right), + sameDeclarationGroup: (left, right) => + sameMethod(left, right) || + (CLASS_KINDS.includes(left.type) && + right.type === 'Constructor' && + right.ownerId === left.nodeId && + name(left) === name(right)) || + (CLASS_KINDS.includes(right.type) && + left.type === 'Constructor' && + left.ownerId === right.nodeId && + name(left) === name(right)), + }); + }, +}; diff --git a/gitnexus/src/core/ingestion/languages/dart/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/dart/scope-resolver.ts index bc286179e..364551565 100644 --- a/gitnexus/src/core/ingestion/languages/dart/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/dart/scope-resolver.ts @@ -210,6 +210,8 @@ export const dartScopeResolver: ScopeResolver = { resolveImportTarget: (targetRaw, fromFile, allFilePaths, resolutionConfig) => resolveDartImportTarget(targetRaw, fromFile, allFilePaths, resolutionConfig), + isNamespaceImport: (parsedImport) => parsedImport.kind === 'namespace', + // Dart `import` is whole-library: every public top-level symbol of the // target enters scope. Enumerating them lets `propagateImportedReturnTypes` // mirror imported functions' return types into the importer. diff --git a/gitnexus/src/core/ingestion/languages/go.ts b/gitnexus/src/core/ingestion/languages/go.ts index e39963c56..91b973146 100644 --- a/gitnexus/src/core/ingestion/languages/go.ts +++ b/gitnexus/src/core/ingestion/languages/go.ts @@ -1,3 +1,4 @@ +import { goRenameProvider } from './go/rename.js'; /** * Go Language Provider * @@ -91,6 +92,7 @@ const GO_BUILT_INS: ReadonlySet = new Set([ ]); export const goProvider = defineLanguage({ + rename: goRenameProvider, id: SupportedLanguages.Go, extensions: ['.go'], entryPointPatterns: [/Handler$/, /^Serve/, /^New[A-Z]/, /^Make[A-Z]/], diff --git a/gitnexus/src/core/ingestion/languages/go/name-fallback-visibility.ts b/gitnexus/src/core/ingestion/languages/go/name-fallback-visibility.ts index 02b968e7b..831179ace 100644 --- a/gitnexus/src/core/ingestion/languages/go/name-fallback-visibility.ts +++ b/gitnexus/src/core/ingestion/languages/go/name-fallback-visibility.ts @@ -47,8 +47,12 @@ function classifyGoFile( }; } -/** Go export rule: an upper-case initial, by Unicode letter case. */ -function isExportedGoName(name: string): boolean { +/** + * Go export rule (spec, Exported identifiers): the first code point is a Unicode + * uppercase letter, category `Lu`. Titlecase (`Lt`), caseless letters (`日本`), + * letter numbers (`Ⅷ`), and `_` are NOT exported. + */ +export function isExportedGoName(name: string): boolean { return /^\p{Lu}/u.test(name); } @@ -58,7 +62,7 @@ function isExportedGoName(name: string): boolean { * bare identifier for a function. Either way the LAST segment is the identifier * whose case decides export. */ -function goSimpleName(candidate: SymbolDefinition): string { +export function goSimpleName(candidate: SymbolDefinition): string { const qualified = candidate.qualifiedName ?? ''; const dot = qualified.lastIndexOf('.'); return dot === -1 ? qualified : qualified.slice(dot + 1); diff --git a/gitnexus/src/core/ingestion/languages/go/namespace-member-visibility.ts b/gitnexus/src/core/ingestion/languages/go/namespace-member-visibility.ts new file mode 100644 index 000000000..801dcfb28 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/go/namespace-member-visibility.ts @@ -0,0 +1,29 @@ +/** + * Go's answer to `ScopeResolver.isNamespaceMemberVisible`. + * + * A namespace-qualified reference (`api.Target()`, `x: api.Thing`) always + * crosses a package boundary: a package cannot import itself, and a `_test` + * external package is a different package from the one under test. Across that + * boundary Go exports an identifier only when it begins with a Unicode + * uppercase letter, so `api.target` names nothing no matter which import + * spelling (plain, alias, or the package's own `_test` twin) introduced `api`. + * + * The shared namespace lookups know nothing of that rule: they treat every + * module-scope binding of the target package as a member. Without this hook + * `api.target()` in invalid code resolves at `import-resolved` confidence, and + * a rename that unexports `api.Target` re-resolves the now-lowercase call back + * to the same declaration and certifies a program that no longer compiles. + * + * Same-package access never reaches this hook: bare names come from the + * package-sibling and dot-import channels, which decide their own visibility. + * Methods and fields are not module-scope bindings and are not consulted. + */ + +import type { SymbolDefinition } from 'gitnexus-shared'; +import { goSimpleName, isExportedGoName } from './name-fallback-visibility.js'; + +export function goIsNamespaceMemberVisible(ctx: { readonly member: SymbolDefinition }): boolean { + const simpleName = goSimpleName(ctx.member); + // No identifier to read the case of is an unanswered question, not a refusal. + return simpleName === '' || isExportedGoName(simpleName); +} diff --git a/gitnexus/src/core/ingestion/languages/go/rename.ts b/gitnexus/src/core/ingestion/languages/go/rename.ts new file mode 100644 index 000000000..cd1ba0395 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/go/rename.ts @@ -0,0 +1,239 @@ +import type Parser from 'tree-sitter'; +import { SupportedLanguages, type NameClaim } from 'gitnexus-shared'; +import type { RenameOccurrence, RenameProvider, RenameSource } from '../../rename-provider.js'; +import { + createTreeSitterRenameSource, + innermostRenameScope, + nodeRange, +} from '../rename-tree-sitter.js'; +import { graphRenameFamily } from '../rename-family.js'; +import { simpleQualifiedName } from '../../scope-resolution/graph-bridge/ids.js'; +import { + buildGraphNodeLookup, + qualifiedKey, +} from '../../scope-resolution/graph-bridge/node-lookup.js'; +import { isExportedGoName } from './name-fallback-visibility.js'; +import { goPackageDir, inferGoPackageName } from './package-clause.js'; + +const keywords = new Set( + 'break default func interface select case defer go map struct chan else goto package switch const fallthrough if range type continue for import return var'.split( + ' ', + ), +); +const names = new Set([ + 'identifier', + 'type_identifier', + 'field_identifier', + 'package_identifier', + 'label_name', +]); +function role(node: Parser.SyntaxNode): RenameOccurrence['role'] { + const parent = node.parent; + if (node.type === 'label_name' || parent?.type === 'package_clause') return 'unrelated'; + if (parent?.type === 'import_spec') return 'alias'; + if ( + parent && + [ + 'parameter_declaration', + 'variadic_parameter_declaration', + 'type_parameter_declaration', + ].includes(parent.type) && + node.type === 'identifier' + ) + return 'binding'; + return 'name'; +} + +const parseSource = createTreeSitterRenameSource({ + isName: (node) => names.has(node.type), + skip: (node) => node.type === 'comment', + role, + reference, +}); + +function withLexicalClaims(source: RenameSource): RenameSource { + const parameters = new Map(); + for (const occurrence of source.occurrences) { + if (occurrence.role !== 'binding' || !occurrence.node) continue; + const scope = innermostRenameScope(source.parsed, occurrence.node); + if (scope) + parameters.set(scope, [ + ...(parameters.get(scope) ?? []), + { + name: occurrence.name, + range: occurrence.range, + kind: 'binding', + purpose: occurrence.node.parent?.type === 'type_parameter_declaration' ? 'both' : 'value', + hoisted: true, + }, + ]); + } + return { + ...source, + parsed: { + ...source.parsed, + scopes: source.parsed.scopes.map((scope) => { + const claims: NameClaim[] = [ + ...(scope.nameClaims ?? []), + ...(parameters.get(scope.id) ?? []), + ]; + for (const [name, bindings] of scope.bindings) + for (const binding of bindings) { + const { def } = binding; + if ( + binding.origin !== 'local' || + !['Variable', 'Const'].includes(def.type) || + !def.declarationRange + ) + continue; + claims.push({ + name, + range: def.nameRange ?? def.declarationRange, + kind: 'binding', + purpose: 'value', + ...(scope.kind === 'Module' + ? { hoisted: true } + : { + inactive: 'outer', + availableFrom: { + startLine: def.declarationRange.endLine, + startCol: def.declarationRange.endCol, + }, + }), + }); + } + return { ...scope, nameClaims: claims }; + }), + }, + }; +} +function reference(node: Parser.SyntaxNode, source: RenameSource) { + const parent = node.parent; + if ( + !parent || + node.type === 'field_identifier' || + node.type === 'label_name' || + parent.type === 'import_spec' || + parent.type === 'package_clause' + ) + return undefined; + if ( + parent.childForFieldName('name')?.id === node.id && + ['function_declaration', 'method_declaration', 'var_spec', 'const_spec', 'type_spec'].includes( + parent.type, + ) + ) + return undefined; + const inScope = innermostRenameScope(source.parsed, node); + if (!inScope) return undefined; + const range = nodeRange(node); + return { + name: node.text, + nameRange: range, + atRange: range, + inScope, + kind: 'read' as const, + ...(node.type === 'type_identifier' ? { lookupPurpose: 'type' as const } : {}), + }; +} + +type GoSourceFile = Pick; + +const packageKeys = new WeakMap(); + +/** + * A Go package is a directory plus its package clause: `api` and its external + * test package `api_test` share a directory but cannot see each other's + * unexported names. Undefined when the clause cannot be read, which proves no + * package identity at all. + */ +function goPackageKey(file: GoSourceFile): string | undefined { + if (packageKeys.has(file)) return packageKeys.get(file); + const name = inferGoPackageName(file.content); + const key = name === null ? undefined : `${goPackageDir(file.path)}\0${name}`; + packageKeys.set(file, key); + return key; +} + +/** + * Whether renaming `oldName` to `newName` stops `user` from naming a symbol + * declared in `declaring`. Only an exported name crosses a package boundary, so + * an exported-to-unexported rename is safe exactly when every use stays in the + * declaring package. The re-resolution pass cannot catch this: it resolves the + * unexported name back to the same declaration, so identity is unchanged while + * the program no longer compiles. + */ +export function hidesExportFromPackage(input: { + readonly oldName: string; + readonly newName: string; + readonly declaring: GoSourceFile | undefined; + readonly user: GoSourceFile; +}): boolean { + if (!isExportedGoName(input.oldName) || isExportedGoName(input.newName)) return false; + if (input.declaring?.path === input.user.path) return false; + const home = input.declaring && goPackageKey(input.declaring); + return home === undefined || goPackageKey(input.user) !== home; +} + +/** + * cgo writes a C tag as `C.struct_tag`, `C.enum_tag` or `C.union_tag` and a C size as + * `C.sizeof_T`, so a written name is never the spelling of the C declaration it denotes. + */ +function cgoCNames(name: string): readonly string[] { + const sized = name.replace(/^sizeof_(?=.)/, ''); + const tagged = sized.replace(/^(?:struct|enum|union)_(?=.)/, ''); + return [...new Set([sized, tagged])].filter((spelling) => spelling !== name); +} + +export const goRenameProvider: RenameProvider = { + // An indexed C source may call a function exported with a cgo `//export` directive. + interoperatesWithSourceProvider: (provider) => provider.id === SupportedLanguages.C, + boundaryNames: (name, peer) => + [SupportedLanguages.C, SupportedLanguages.CPlusPlus].includes(peer.id) ? cgoCNames(name) : [], + matchesGraphIdentity({ definition, node, graph, files }) { + const ownName = definition.qualifiedName; + if (!ownName) return false; + const packageName = files + .get(definition.filePath) + ?.tree?.rootNode.namedChildren.find((child) => child.type === 'package_clause') + ?.namedChildren.find((child) => child.type === 'package_identifier')?.text; + if (node.properties.qualifiedName) + return ( + node.properties.qualifiedName === (packageName ? `${packageName}.${ownName}` : ownName) + ); + return ( + buildGraphNodeLookup(graph).get(qualifiedKey(definition.filePath, node.label, ownName)) === + node.id + ); + }, + symbolFamily(context) { + return graphRenameFamily(context, { + matches: (left, right) => + !!left.ownerId && + !!right.ownerId && + simpleQualifiedName(left) === simpleQualifiedName(right) && + left.parameterCount === right.parameterCount, + }); + }, + validateName(name) { + if (!/^[_\p{L}][_\p{L}\p{Nd}]*$/u.test(name) || name === '_' || keywords.has(name)) + return { code: 'invalid_name', message: 'Choose a valid nonreserved Go identifier.' }; + }, + checkOccurrence({ selected, source, occurrence, target, files, newName }) { + const oldName = simpleQualifiedName(target) ?? ''; + if ( + selected && + hidesExportFromPackage({ + oldName, + newName, + declaring: files.get(target.filePath), + user: source, + }) + ) + return { + code: 'invalid_name', + message: `Go exports an identifier only when it begins with a Unicode uppercase letter; ${newName} would hide ${oldName} from ${source.path}:${occurrence.range.startLine}, outside its declaring package.`, + }; + }, + parseSource: async (input) => withLexicalClaims(await parseSource(input)), +}; diff --git a/gitnexus/src/core/ingestion/languages/go/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/go/scope-resolver.ts index a525949a3..ec9d083b6 100644 --- a/gitnexus/src/core/ingestion/languages/go/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/go/scope-resolver.ts @@ -17,6 +17,7 @@ import { populateGoRangeBindings } from './range-binding.js'; import { expandGoWildcardNames } from './expand-wildcards.js'; import { goMapValueType } from './interpret.js'; import { goIsGlobalNameFallbackPlausible } from './name-fallback-visibility.js'; +import { goIsNamespaceMemberVisible } from './namespace-member-visibility.js'; import { resolveGoImportBinding } from './import-binding.js'; /** Slice `[]T` and array `[N]T` / `[...]T` → the element spelling. Hoisted — @@ -90,6 +91,7 @@ export const goScopeResolver: ScopeResolver = { propagatesReturnTypesAcrossImports: true, allowGlobalFreeCallFallback: true, isGlobalNameFallbackPlausible: goIsGlobalNameFallbackPlausible, + isNamespaceMemberVisible: goIsNamespaceMemberVisible, populateNamespaceSiblings: populateGoPackageSiblings, mirrorNamespaceTypeBindings: mirrorGoNamespaceTypeBindings, diff --git a/gitnexus/src/core/ingestion/languages/java.ts b/gitnexus/src/core/ingestion/languages/java.ts index 45386dd7b..80851ee4e 100644 --- a/gitnexus/src/core/ingestion/languages/java.ts +++ b/gitnexus/src/core/ingestion/languages/java.ts @@ -8,6 +8,7 @@ */ import { SupportedLanguages } from 'gitnexus-shared'; +import { javaRenameProvider } from './java/rename.js'; import { createClassExtractor } from '../class-extractors/generic.js'; import { javaClassConfig } from '../class-extractors/configs/jvm.js'; import { defineLanguage } from '../language-provider.js'; @@ -170,6 +171,7 @@ const sharedPrefixLength = (left: readonly string[], right: readonly string[]): }; export const javaProvider = defineLanguage({ + rename: javaRenameProvider, id: SupportedLanguages.Java, extensions: ['.java'], entryPointPatterns: [/^do[A-Z]/, /^create[A-Z]/, /^build[A-Z]/, /Service$/], diff --git a/gitnexus/src/core/ingestion/languages/java/captures.ts b/gitnexus/src/core/ingestion/languages/java/captures.ts index b227d549c..f47758b8e 100644 --- a/gitnexus/src/core/ingestion/languages/java/captures.ts +++ b/gitnexus/src/core/ingestion/languages/java/captures.ts @@ -217,6 +217,7 @@ export function emitJavaScopeCaptures( const typeDeclaration = [ nodeMap['@declaration.class'], + nodeMap['@declaration.annotation'], nodeMap['@declaration.enum'], nodeMap['@declaration.record'], nodeMap['@declaration.interface'], @@ -263,12 +264,12 @@ export function emitJavaScopeCaptures( // matches ALL method_invocations as @reference.call.free (without // negation) because tree-sitter-java's query engine drops !object // patterns when a positive object: pattern exists for the same node - // type. Filter here: if the match has @reference.call.free but also - // has @reference.receiver, it's a member call — skip the free match + // type. The free pattern has no receiver capture, so inspect its AST + // object field instead. A qualified call belongs only to the member match // (the separate @reference.call.member match covers it). if ( grouped['@reference.call.free'] !== undefined && - grouped['@reference.receiver'] !== undefined + nodeMap['@reference.call.free']?.childForFieldName('object') ) { continue; } diff --git a/gitnexus/src/core/ingestion/languages/java/query.ts b/gitnexus/src/core/ingestion/languages/java/query.ts index 507d3ce79..7c9d6c0ef 100644 --- a/gitnexus/src/core/ingestion/languages/java/query.ts +++ b/gitnexus/src/core/ingestion/languages/java/query.ts @@ -77,7 +77,7 @@ const JAVA_SCOPE_QUERY = ` type_parameters: (type_parameters)? @declaration.type-parameters) @declaration.record (annotation_type_declaration - name: (identifier) @declaration.name) @declaration.class + name: (identifier) @declaration.name) @declaration.annotation ;; Class annotation syntax is carried to post-resolution enrichment. Keeping ;; this in the existing scope query avoids a second AST build/traversal. @@ -127,6 +127,11 @@ const JAVA_SCOPE_QUERY = ` type: (scoped_type_identifier) @type-binding.type name: (identifier) @type-binding.name) @type-binding.parameter +;; Primitive parameters own their lexical name even though they have no class target. +(formal_parameter + type: [(integral_type) (floating_point_type) (boolean_type)] @type-binding.type + name: (identifier) @type-binding.name) @type-binding.parameter + ;; Type bindings — local variable annotations: User u = new User(); (local_variable_declaration type: (type_identifier) @type-binding.type diff --git a/gitnexus/src/core/ingestion/languages/java/rename.ts b/gitnexus/src/core/ingestion/languages/java/rename.ts new file mode 100644 index 000000000..0305fbc1f --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/java/rename.ts @@ -0,0 +1,212 @@ +import type Parser from 'tree-sitter'; +import { CLASS_KINDS, SupportedLanguages } from 'gitnexus-shared'; +import type { RenameOccurrence, RenameProvider, RenameSource } from '../../rename-provider.js'; +import { simpleQualifiedName } from '../../scope-resolution/graph-bridge/ids.js'; +import { + buildGraphNodeLookup, + qualifiedKey, +} from '../../scope-resolution/graph-bridge/node-lookup.js'; +import { + canonicalizeRenameBindings, + createTreeSitterRenameSource, + innermostRenameScope, + nodeRange, +} from '../rename-tree-sitter.js'; +import { graphRenameFamily } from '../rename-family.js'; + +const canonicalName = (name: string): string => + name.replace(/\\u+([0-9a-fA-F]{4})/g, (_, code: string) => + String.fromCharCode(Number.parseInt(code, 16)), + ); +const names = new Set(['identifier', 'type_identifier']); +const keywords = new Set( + '_ abstract assert boolean break byte case catch char class const continue default do double else enum extends final finally float for goto if implements import instanceof int interface long native new package private protected public return short static strictfp super switch synchronized this throw throws transient try void volatile while true false null record sealed permits yield var'.split( + ' ', + ), +); + +function role(node: Parser.SyntaxNode): RenameOccurrence['role'] { + const parent = node.parent; + if (!parent) return 'name'; + if ( + (parent.type === 'formal_parameter' && parent.childForFieldName('name')?.id === node.id) || + parent.type === 'inferred_parameters' || + (parent.type === 'lambda_expression' && parent.childForFieldName('parameters')?.id === node.id) + ) + return 'binding'; + if ( + parent.type === 'labeled_statement' || + parent.type === 'break_statement' || + parent.type === 'continue_statement' + ) + return 'unrelated'; + for (let ancestor: Parser.SyntaxNode | null = parent; ancestor; ancestor = ancestor.parent) { + if (ancestor.type === 'import_declaration') return 'imported'; + if (ancestor.type === 'package_declaration') return 'unrelated'; + } + return 'name'; +} + +function reference(node: Parser.SyntaxNode, source: RenameSource) { + const parent = node.parent; + if ( + !parent || + (parent.childForFieldName('name')?.id === node.id && + /declaration|definition|signature|declarator/.test(parent.type)) + ) + return undefined; + // A qualified member has its own semantic receiver; never reinterpret it as a bare lexical read. + if ( + [ + 'field_access', + 'method_invocation', + 'method_reference', + 'scoped_identifier', + 'scoped_type_identifier', + ].includes(parent.type) && + parent.namedChildCount > 1 && + parent.lastNamedChild?.id === node.id + ) + return undefined; + const inScope = innermostRenameScope(source.parsed, node); + if (!inScope) return undefined; + const range = nodeRange(node); + return { + name: canonicalName(node.text), + atRange: range, + nameRange: range, + inScope, + kind: node.type === 'type_identifier' ? ('type-reference' as const) : ('read' as const), + }; +} + +export const javaRenameProvider: RenameProvider = { + interoperatesWithSourceProvider: (provider) => provider.id === SupportedLanguages.Kotlin, + interopNames(name) { + // Kotlin can spell a Java accessor call as a synthetic property read/write. + const suffix = /^(?:get|set)([A-Z].*)$/.exec(name)?.[1]; + return suffix ? [suffix[0].toLowerCase() + suffix.slice(1), suffix, `is${suffix}`] : []; + }, + canonicalName, + matchesGraphIdentity({ definition, definitions, node, graph }) { + let name = definition.qualifiedName; + if (!name) return false; + if (definition.namespacePrefix && !name.startsWith(`${definition.namespacePrefix}.`)) + name = `${definition.namespacePrefix}.${name}`; + if ( + !node.properties.qualifiedName && + (definition.type === 'Constructor' || definition.type === 'Annotation') + ) { + // These graph labels retain the immediate owner or bare declaration name. + // The shared bridge already checked the file and current source range. + const graphName = (def: typeof definition) => { + const ownName = simpleQualifiedName(def); + if (def.type === 'Annotation') return ownName; + const owner = definitions.find((candidate) => candidate.nodeId === def.ownerId); + return owner && ownName ? `${simpleQualifiedName(owner)}.${ownName}` : undefined; + }; + name = graphName(definition); + if ( + !name || + definitions.some( + (other) => + other.nodeId !== definition.nodeId && + other.filePath === definition.filePath && + other.type === definition.type && + graphName(other) === name && + (definition.type === 'Annotation' || other.ownerId !== definition.ownerId), + ) + ) + return false; + } + return ( + buildGraphNodeLookup(graph, new Set([node.label])).get( + qualifiedKey(definition.filePath, node.label, name), + ) === node.id + ); + }, + validateName(name) { + const canonical = canonicalName(name); + if (!(/^[$_\p{ID_Start}][$_\p{ID_Continue}]*$/u.test(canonical) && !keywords.has(canonical))) + return { code: 'invalid_name', message: 'Choose a valid nonreserved identifier.' }; + }, + parseSource: createTreeSitterRenameSource({ + isName: (node) => names.has(node.type), + skip: (node) => node.type.includes('comment'), + role, + canonicalName, + transformParsed: (parsed) => canonicalizeRenameBindings(parsed, canonicalName), + reference, + }), + checkSafety({ target, definitions, files, newName }) { + const type = + target.type === 'Constructor' + ? definitions.find((definition) => definition.nodeId === target.ownerId) + : target; + if (!type || !CLASS_KINDS.includes(type.type)) return; + const declaration = files + .get(type.filePath) + ?.occurrences.find((occurrence) => occurrence.definitionId === type.nodeId)?.node?.parent; + if ( + declaration?.parent?.type === 'program' && + declaration.namedChildren.some( + (node) => + node.type === 'modifiers' && node.children.some((child) => child.type === 'public'), + ) && + type.filePath.split('/').at(-1) !== `${canonicalName(newName)}.java` + ) + return { + code: 'unsupported_occurrence', + status: 'unsupported', + message: 'Renaming this public top-level Java type requires a file move to match its name.', + }; + }, + symbolFamily(context) { + const name = (def: typeof context.target) => canonicalName(simpleQualifiedName(def) ?? ''); + const method = (def: typeof context.target) => + ['Method', 'Function'].includes(def.type) && !!def.ownerId; + const declaration = (def: typeof context.target) => + context.files + .get(def.filePath) + ?.occurrences.find((occurrence) => occurrence.definitionId === def.nodeId)?.node?.parent; + const isInstance = (def: typeof context.target) => + !declaration(def)?.namedChildren.some( + (node) => + node.type === 'modifiers' && + node.children.some((child) => ['private', 'static'].includes(child.text)), + ); + return graphRenameFamily(context, { + requiresBaseMember: (def) => + declaration(def)?.namedChildren.some( + (node) => + node.type === 'modifiers' && + node.namedChildren.some( + (child) => + ['marker_annotation', 'annotation'].includes(child.type) && + ['Override', 'java.lang.Override'].includes( + canonicalName(child.childForFieldName('name')?.text ?? ''), + ), + ), + ) === true, + matches: (left, right) => + method(left) && + method(right) && + isInstance(left) && + isInstance(right) && + name(left) === name(right), + sameDeclarationGroup: (left, right) => + (method(left) && + method(right) && + left.ownerId === right.ownerId && + name(left) === name(right)) || + (CLASS_KINDS.includes(left.type) && + right.type === 'Constructor' && + right.ownerId === left.nodeId && + name(left) === name(right)) || + (CLASS_KINDS.includes(right.type) && + left.type === 'Constructor' && + left.ownerId === right.nodeId && + name(left) === name(right)), + }); + }, +}; diff --git a/gitnexus/src/core/ingestion/languages/javascript/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/javascript/scope-resolver.ts index 7f4b5a48f..4d596229d 100644 --- a/gitnexus/src/core/ingestion/languages/javascript/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/javascript/scope-resolver.ts @@ -38,14 +38,20 @@ import { SupportedLanguages } from 'gitnexus-shared'; import { buildMro, defaultLinearize } from '../../scope-resolution/passes/mro.js'; import { populateClassOwnedMembers } from '../../scope-resolution/scope/walkers.js'; import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js'; +import { canonicalizeWorkspaceBindings } from '../../scope-resolution/scope/canonicalize-bindings.js'; +import { canonicalTypeScriptName } from '../typescript/identifiers.js'; import { javascriptProvider } from '../typescript.js'; import { jsMergeBindings } from './merge-bindings.js'; import { jsArityCompatibility } from './arity.js'; import { makeJsResolveImportTarget } from './import-target.js'; import { loadTsconfigIndex } from '../typescript/tsconfig.js'; +import { typescriptModuleExports } from '../typescript/module-exports.js'; import { loadNodeWorkspacePackages } from '../../import-resolvers/node-workspace-packages.js'; const javascriptScopeResolver: ScopeResolver = { + postExtractSourceTextPolicy: 'uncached-files', + populateWorkspaceReferences: (parsedFiles) => + canonicalizeWorkspaceBindings(parsedFiles, canonicalTypeScriptName), // Construction is keyword-prefixed: `new Service(db).doWork()` (#2708). constructionSyntax: { keyword: 'new' }, language: SupportedLanguages.JavaScript, @@ -55,6 +61,7 @@ const javascriptScopeResolver: ScopeResolver = { ownedMembersBindAtModuleScope: false, resolveImportTarget: makeJsResolveImportTarget(), + moduleExports: typescriptModuleExports, // JavaScript resolution reads the same declared inputs TypeScript does — // `jsconfig.json` is a tsconfig by another name, and `package.json` is shared diff --git a/gitnexus/src/core/ingestion/languages/jvm/accessor-synthesis.ts b/gitnexus/src/core/ingestion/languages/jvm/accessor-synthesis.ts index e1cb89253..fd0feda82 100644 --- a/gitnexus/src/core/ingestion/languages/jvm/accessor-synthesis.ts +++ b/gitnexus/src/core/ingestion/languages/jvm/accessor-synthesis.ts @@ -286,9 +286,9 @@ function capturesForPlannedAccessors(owners: readonly PlannedJvmAccessorOwner[]) const identity = `${qualifiedName}#${arity}`; if (emitted.has(identity)) continue; emitted.add(identity); - captures.push({ - '@scope.function': accessorCapture('@scope.function', acc, acc.name), - }); + // Generated accessors have no lexical body. A getter and setter share + // the field anchor; function scopes there would nest and hide their + // bindings inside each other instead of exposing them on the class. captures.push({ '@declaration.method': accessorCapture('@declaration.method', acc, acc.name), '@declaration.name': accessorCapture('@declaration.name', acc, acc.name), diff --git a/gitnexus/src/core/ingestion/languages/jvm/package-siblings.ts b/gitnexus/src/core/ingestion/languages/jvm/package-siblings.ts index e524dc333..b3e94b71e 100644 --- a/gitnexus/src/core/ingestion/languages/jvm/package-siblings.ts +++ b/gitnexus/src/core/ingestion/languages/jvm/package-siblings.ts @@ -109,7 +109,9 @@ export function createJvmPackageSiblingVisibility( const moduleScopeId = parsed.scopes.find((scope) => scope.kind === 'Module')?.id; for (const scope of parsed.scopes) { if (scope.kind !== 'Class' || scope.parent !== moduleScopeId) continue; - const def = scope.ownedDefs.find((candidate) => isClassLike(candidate.type)); + const def = scope.ownedDefs.find( + (candidate) => isClassLike(candidate.type) || candidate.type === 'Annotation', + ); if (def !== undefined) classDefs.push({ def, filePath: parsed.filePath }); } } diff --git a/gitnexus/src/core/ingestion/languages/kotlin.ts b/gitnexus/src/core/ingestion/languages/kotlin.ts index b04a58427..4ffda436d 100644 --- a/gitnexus/src/core/ingestion/languages/kotlin.ts +++ b/gitnexus/src/core/ingestion/languages/kotlin.ts @@ -8,6 +8,7 @@ */ import { SupportedLanguages } from 'gitnexus-shared'; +import { kotlinRenameProvider } from './kotlin/rename.js'; import { createClassExtractor } from '../class-extractors/generic.js'; import { kotlinClassConfig } from '../class-extractors/configs/jvm.js'; import { defineLanguage } from '../language-provider.js'; @@ -130,6 +131,7 @@ const BUILT_INS: ReadonlySet = new Set([ ]); export const kotlinProvider = defineLanguage({ + rename: kotlinRenameProvider, id: SupportedLanguages.Kotlin, extensions: ['.kt', '.kts'], entryPointPatterns: [ diff --git a/gitnexus/src/core/ingestion/languages/kotlin/captures.ts b/gitnexus/src/core/ingestion/languages/kotlin/captures.ts index 68b01e421..b877fa9a8 100644 --- a/gitnexus/src/core/ingestion/languages/kotlin/captures.ts +++ b/gitnexus/src/core/ingestion/languages/kotlin/captures.ts @@ -276,13 +276,40 @@ export function emitKotlinScopeCaptures( // `@reference.callable`. Rewrite into a call reference so it participates // in call-graph resolution: a bare `::member` resolves as a free call; // a `Receiver::member` resolves as a member call against the receiver - // type. The function/constructor is referenced (not invoked), so no - // arity/argument metadata is attached. + // type. A free reference's explicit function type can constrain arity; + // otherwise there are no invocation arguments to infer it from. if (grouped['@reference.callable'] !== undefined) { const nameCap = grouped['@reference.name']; const callableNode = groupedNodes['@reference.callable']; if (nameCap !== undefined && callableNode !== undefined) { const receiverCap = grouped['@reference.receiver']; + const declaration = callableNode.parent; + const variable = + declaration?.type === 'property_declaration' + ? declaration.namedChildren.find((child) => child.type === 'variable_declaration') + : undefined; + const expectedType = variable?.namedChildren.find( + (child) => child.type === 'function_type', + ); + const expectedParameters = expectedType?.namedChildren.find( + (child) => child.type === 'function_type_parameters', + ); + // A typed callable reference carries its expected function arity even + // though it has no invocation arguments. Let the existing overload + // resolver use that semantic constraint, rather than inventing a call. + const arity = + receiverCap === undefined && + expectedParameters && + expectedType && + namedNonComments(expectedType)[0]?.id === expectedParameters.id + ? { + '@reference.arity': syntheticCapture( + '@reference.arity', + callableNode, + String(namedNonComments(expectedParameters).length), + ), + } + : {}; // The anchor Capture must carry the call-form tag as its `name` — // the scope-extractor reads `Capture.name` (not the map key) to // classify the reference kind, so re-wrap via nodeToCapture rather @@ -293,17 +320,45 @@ export function emitKotlinScopeCaptures( '@reference.call.member': nodeToCapture('@reference.call.member', callableNode), '@reference.name': nameCap, '@reference.receiver': receiverCap, + ...arity, }); } else { out.push({ '@reference.call.free': nodeToCapture('@reference.call.free', callableNode), '@reference.name': nameCap, + ...arity, }); } } continue; } + if (grouped['@reference.write.member'] !== undefined) { + const assignment = groupedNodes['@reference.write.member']; + const target = assignment?.firstNamedChild; + const receiver = groupedNodes['@reference.receiver']; + const name = groupedNodes['@reference.name']; + if (target?.type === 'directly_assignable_expression' && receiver && name) { + // Assignment chains are flat in this grammar. The query can pair any + // preceding child with any suffix; only the base starts a receiver. + if (receiver.id !== target.firstNamedChild?.id) continue; + const suffix = name.parent; + if (!suffix) continue; + const prefix = target.text.slice(0, suffix.startIndex - target.startIndex); + grouped['@reference.receiver'] = syntheticCapture('@reference.receiver', receiver, prefix); + // The original capture names only the base, not this reconstructed + // prefix. Do not later encode that shorter AST as the full receiver. + delete groupedNodes['@reference.receiver']; + if (suffix.id !== target.lastNamedChild?.id) { + delete grouped['@reference.write.member']; + grouped['@reference.read.member'] = nodeToCapture('@reference.read.member', suffix); + // Prefixes are reads, but are not nested navigation_expression nodes. + out.push(grouped); + continue; + } + } + } + if ( grouped['@reference.call.free'] !== undefined && grouped['@reference.receiver'] !== undefined diff --git a/gitnexus/src/core/ingestion/languages/kotlin/identifiers.ts b/gitnexus/src/core/ingestion/languages/kotlin/identifiers.ts new file mode 100644 index 000000000..675277e15 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/kotlin/identifiers.ts @@ -0,0 +1,3 @@ +/** Semantic identifier spelling; source graph names and ranges remain unchanged. */ +export const canonicalName = (name: string): string => + name.startsWith('`') && name.endsWith('`') ? name.slice(1, -1) : name; diff --git a/gitnexus/src/core/ingestion/languages/kotlin/module-resolution.ts b/gitnexus/src/core/ingestion/languages/kotlin/module-resolution.ts index e56d538c2..7767f8a3c 100644 --- a/gitnexus/src/core/ingestion/languages/kotlin/module-resolution.ts +++ b/gitnexus/src/core/ingestion/languages/kotlin/module-resolution.ts @@ -10,6 +10,7 @@ import type { ParsedFile } from 'gitnexus-shared'; import type { JvmPackageFact } from '../jvm/package-facts.js'; +import { canonicalName } from './identifiers.js'; export interface KotlinPackageIndex { /** Declared package -> top-level exported name -> files declaring that name. */ @@ -67,8 +68,9 @@ export function buildKotlinPackageIndex( ) { continue; } - const files = declarations.get(name); - if (files === undefined) declarations.set(name, [parsed.filePath]); + const key = canonicalName(name); + const files = declarations.get(key); + if (files === undefined) declarations.set(key, [parsed.filePath]); else if (!files.includes(parsed.filePath)) files.push(parsed.filePath); } } @@ -111,7 +113,7 @@ function resolveTopLevelDeclaration( const name = parts[0]; return name === undefined ? null - : declarationFiles(index.declarationsByPackage.get('')?.get(name)); + : declarationFiles(index.declarationsByPackage.get('')?.get(canonicalName(name))); } for (let split = parts.length - 1; split >= 1; split--) { @@ -121,7 +123,7 @@ function resolveTopLevelDeclaration( const declarationName = parts[split]; if (declarationName === undefined) continue; - const files = declarations.get(declarationName); + const files = declarations.get(canonicalName(declarationName)); if (files === undefined) continue; return declarationFiles(files); } diff --git a/gitnexus/src/core/ingestion/languages/kotlin/rename.ts b/gitnexus/src/core/ingestion/languages/kotlin/rename.ts new file mode 100644 index 000000000..cc4bc977a --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/kotlin/rename.ts @@ -0,0 +1,307 @@ +import type Parser from 'tree-sitter'; +import { CLASS_KINDS, SupportedLanguages, type SymbolDefinition } from 'gitnexus-shared'; +import type { + RenameIssue, + RenameOccurrence, + RenameProvider, + RenameSemanticContext, + RenameSource, +} from '../../rename-provider.js'; +import { simpleQualifiedName } from '../../scope-resolution/graph-bridge/ids.js'; +import { + canonicalizeRenameBindings, + createTreeSitterRenameSource, + innermostRenameScope, + nodeRange, + rangeKey, +} from '../rename-tree-sitter.js'; +import { graphRenameFamily } from '../rename-family.js'; +import { canonicalName } from './identifiers.js'; + +const names = new Set(['simple_identifier', 'type_identifier']); +const keywords = new Set( + 'as break class continue do else false for fun if in interface is null object package return super this throw true try typealias typeof val var when while'.split( + ' ', + ), +); + +function role(node: Parser.SyntaxNode): RenameOccurrence['role'] { + const parent = node.parent; + if (!parent) return 'name'; + if ( + (parent.type === 'parameter' && parent.firstNamedChild?.id === node.id) || + parent.type === 'lambda_parameters' + ) + return 'binding'; + if (parent.type === 'import_alias') return 'alias'; + if ( + parent.type === 'value_argument' && + parent.firstNamedChild?.id === node.id && + parent.children.some((child) => child.text === '=') + ) + return 'unrelated'; + for (let ancestor: Parser.SyntaxNode | null = parent; ancestor; ancestor = ancestor.parent) { + if (ancestor.type === 'import_header') return 'imported'; + if (ancestor.type === 'package_header' || ancestor.type === 'label') return 'unrelated'; + } + return 'name'; +} + +// Callees whose labels bind their own parameters, never a property. +const CALLABLE_KINDS: readonly string[] = ['Function', 'Method']; +// An alias or namespace hides the constructor a label would bind. +const OPAQUE_TYPE_KINDS: readonly string[] = [ + 'TypeAlias', + 'Typedef', + 'Namespace', + 'Template', + 'Category', +]; + +/** Two types can share one instance when either lies on the other's method resolution order. */ +function relatedTypes(context: RenameSemanticContext, left: string, right: string): boolean { + const { methodDispatch } = context.indexes; + return ( + left === right || + methodDispatch.mroFor(left).includes(right) || + methodDispatch.mroFor(right).includes(left) + ); +} + +/** The identifier an argument list calls; undefined when its syntax shape is not understood. */ +function calleeName(label: Parser.SyntaxNode): Parser.SyntaxNode | undefined { + const invocation = label.parent?.parent?.parent; + if (invocation?.type === 'call_suffix') { + const callee = invocation.parent?.firstNamedChild; + const name = + callee?.type === 'navigation_expression' ? callee.lastNamedChild?.lastNamedChild : callee; + return name?.type === 'simple_identifier' ? name : undefined; + } + // A supertype initializer or annotation names its class as a type. + if (invocation?.type === 'constructor_invocation') { + const type = invocation.firstNamedChild; + const name = type?.type === 'user_type' ? type.lastNamedChild : undefined; + return name?.type === 'type_identifier' ? name : undefined; + } + return undefined; +} + +/** Whether a `val`/`var` parameter of the class's primary constructor has one of these spellings. */ +function declaresPrimaryProperty( + declaration: Parser.SyntaxNode | undefined, + names: ReadonlySet, +) { + // An unreadable declaration cannot prove that the label binds only a plain parameter. + if (!declaration) return true; + return declaration.namedChildren.some( + (child) => + child.type === 'primary_constructor' && + child.namedChildren.some( + (parameter) => + parameter.type === 'class_parameter' && + parameter.namedChildren.some((part) => part.type === 'binding_pattern_kind') && + names.has( + canonicalName( + parameter.namedChildren.find((part) => part.type === 'simple_identifier')?.text ?? '', + ), + ), + ), + ); +} + +/** + * A named argument label is an ordinary token for the callee's parameter, but a + * primary-constructor `val`/`var` is a parameter and a property at once, and a data + * class `copy()` repeats it. Only a callee proven to be another function, or a type + * whose own primary constructor declares neither the selected nor the destination + * name as a property, keeps the label unrelated. + */ +function checkNamedArgumentLabels(context: RenameSemanticContext): RenameIssue | void { + const { target } = context; + const ownerId = target.ownerId; + if (!ownerId || target.type !== 'Property') return; + const names = new Set([ + canonicalName(simpleQualifiedName(target) ?? ''), + canonicalName(context.newName), + ]); + const declarations = new Map(); + const declarationOf = (type: SymbolDefinition): Parser.SyntaxNode | undefined => { + if (!declarations.has(type.nodeId)) + declarations.set( + type.nodeId, + context.files + .get(type.filePath) + ?.occurrences.find((occurrence) => occurrence.definitionId === type.nodeId)?.node + ?.parent ?? undefined, + ); + return declarations.get(type.nodeId); + }; + for (const source of context.files.values()) { + let occurrencesByRange: Map | undefined; + for (const occurrence of source.occurrences) { + const label = occurrence.node; + if ( + occurrence.role !== 'unrelated' || + !names.has(occurrence.name) || + label?.parent?.type !== 'value_argument' + ) + continue; + occurrencesByRange ??= new Map( + source.occurrences.map((candidate) => [rangeKey(candidate.range), candidate]), + ); + const name = calleeName(label); + const site = name && occurrencesByRange.get(rangeKey(nodeRange(name)))?.referenceSite; + const binding = site && context.referenceBindings.get(site); + const callees = + binding?.status === 'resolved' && binding.targetDefIds.length + ? binding.targetDefIds.map((id) => context.indexes.defs.get(id)) + : undefined; + const bindsProperty = (callee: SymbolDefinition | undefined): boolean => { + if (callee && CALLABLE_KINDS.includes(callee.type)) return false; + const type = + callee?.type === 'Constructor' && callee.ownerId + ? context.indexes.defs.get(callee.ownerId) + : callee; + return ( + !type || + !CLASS_KINDS.includes(type.type) || + OPAQUE_TYPE_KINDS.includes(type.type) || + (relatedTypes(context, type.nodeId, ownerId) && + declaresPrimaryProperty(declarationOf(type), names)) + ); + }; + if (callees && !callees.some(bindsProperty)) continue; + return { + code: 'ambiguous_reference', + message: `A named argument label may bind the selected property or its destination name through a constructor or copy() parameter: ${source.path}:${label.startPosition.row + 1}.`, + status: 'unsupported', + }; + } + } +} + +function reference(node: Parser.SyntaxNode, source: RenameSource) { + const parent = node.parent; + if ( + !parent || + (parent.namedChildren.find((child) => names.has(child.type))?.id === node.id && + /declaration|definition|signature|declarator/.test(parent.type)) + ) + return undefined; + // A qualified member has its own semantic receiver; never reinterpret it as a bare lexical read. + if (parent.type === 'navigation_suffix') return undefined; + if ( + ['navigation_expression', 'user_type'].includes(parent.type) && + parent.namedChildCount > 1 && + parent.lastNamedChild?.id === node.id + ) + return undefined; + const inScope = innermostRenameScope(source.parsed, node); + if (!inScope) return undefined; + const range = nodeRange(node); + return { + name: canonicalName(node.text), + atRange: range, + nameRange: range, + inScope, + kind: + node.type === 'type_identifier' || parent.type === 'user_type' + ? ('type-reference' as const) + : ('read' as const), + }; +} + +export const kotlinRenameProvider: RenameProvider = { + canonicalName, + interoperatesWithSourceProvider: (provider) => provider.id === SupportedLanguages.Java, + interopNames(name) { + const suffix = name[0].toUpperCase() + name.slice(1); + return [ + `get${suffix}`, + `set${suffix}`, + ...(/^is[A-Z]/.test(name) ? [`set${name.slice(2)}`] : []), + ]; + }, + checkSafety: checkNamedArgumentLabels, + validateName(name) { + const canonical = canonicalName(name); + if ( + !( + /^[_\p{ID_Start}][_\p{ID_Continue}]*$/u.test(canonical) && + (!keywords.has(canonical) || name.startsWith('`')) + ) + ) + return { code: 'invalid_name', message: 'Choose a valid nonreserved identifier.' }; + }, + parseSource: createTreeSitterRenameSource({ + isName: (node) => names.has(node.type), + skip: (node) => node.type.includes('comment'), + role, + canonicalName, + transformParsed: (parsed) => canonicalizeRenameBindings(parsed, canonicalName), + reference, + }), + rewrite({ source, occurrence, newName }) { + return occurrence.spans.map((span) => { + const oldText = source.content.slice(span.start, span.start + span.length); + return { + ...span, + oldText, + newText: oldText.startsWith('`') ? '`' + canonicalName(newName) + '`' : newName, + }; + }); + }, + symbolFamily(context) { + const name = (def: typeof context.target) => canonicalName(simpleQualifiedName(def) ?? ''); + const method = (def: typeof context.target) => + ['Method', 'Function'].includes(def.type) && !!def.ownerId; + const declarationGroup = (def: typeof context.target) => + context.files + .get(def.filePath) + ?.occurrences.find((occurrence) => occurrence.definitionId === def.nodeId)?.node?.parent + ?.parent?.id; + const declaration = (def: typeof context.target) => + context.files + .get(def.filePath) + ?.occurrences.find((occurrence) => occurrence.definitionId === def.nodeId)?.node?.parent; + const isInstance = (def: typeof context.target) => + !declaration(def)?.namedChildren.some( + (node) => + node.type === 'modifiers' && node.children.some((child) => child.text === 'private'), + ); + return graphRenameFamily(context, { + requiresBaseMember: (def) => + declaration(def)?.namedChildren.some( + (node) => + node.type === 'modifiers' && node.children.some((child) => child.text === 'override'), + ) === true, + matches: (left, right) => + method(left) && + method(right) && + isInstance(left) && + isInstance(right) && + name(left) === name(right), + sameDeclarationGroup: (left, right) => + (left.type === 'Function' && + right.type === 'Function' && + !left.ownerId && + !right.ownerId && + left.filePath === right.filePath && + declarationGroup(left) !== undefined && + declarationGroup(left) === declarationGroup(right) && + name(left) === name(right)) || + (method(left) && + method(right) && + left.ownerId === right.ownerId && + name(left) === name(right)) || + (CLASS_KINDS.includes(left.type) && + right.type === 'Constructor' && + right.ownerId === left.nodeId && + name(left) === name(right)) || + (CLASS_KINDS.includes(right.type) && + left.type === 'Constructor' && + left.ownerId === right.nodeId && + name(left) === name(right)), + }); + }, +}; diff --git a/gitnexus/src/core/ingestion/languages/kotlin/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/kotlin/scope-resolver.ts index 983f181b8..5e341ebe1 100644 --- a/gitnexus/src/core/ingestion/languages/kotlin/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/kotlin/scope-resolver.ts @@ -2,6 +2,11 @@ import { SupportedLanguages, type ParsedFile } from 'gitnexus-shared'; import type { KnowledgeGraph } from '../../../graph/types.js'; import { buildMro, defaultLinearize } from '../../scope-resolution/passes/mro.js'; import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js'; +import { + canonicalizeWorkspaceBindings, + canonicalModuleExports, +} from '../../scope-resolution/scope/canonicalize-bindings.js'; +import { canonicalName } from './identifiers.js'; import { resolveDefGraphId } from '../../scope-resolution/graph-bridge/ids.js'; import type { GraphNodeLookup } from '../../scope-resolution/graph-bridge/node-lookup.js'; import { isClassLike } from '../../scope-resolution/scope/walkers.js'; @@ -66,6 +71,9 @@ import { attachKotlinSpringDynamicLookup } from './spring-dynamic-lookup.js'; * its issue ref and rationale. */ export const kotlinScopeResolver: ScopeResolver = { + moduleExports: (parsed) => canonicalModuleExports(parsed, canonicalName), + populateWorkspaceReferences: (parsedFiles) => + canonicalizeWorkspaceBindings(parsedFiles, canonicalName), language: SupportedLanguages.Kotlin, languageProvider: kotlinProvider, importEdgeReason: 'kotlin-scope: import', diff --git a/gitnexus/src/core/ingestion/languages/objective-c.ts b/gitnexus/src/core/ingestion/languages/objective-c.ts index 8e7f3e2b4..6a86e9dd7 100644 --- a/gitnexus/src/core/ingestion/languages/objective-c.ts +++ b/gitnexus/src/core/ingestion/languages/objective-c.ts @@ -7,11 +7,13 @@ import { } from 'gitnexus-shared'; import Parser from 'tree-sitter'; import { defineLanguage } from '../language-provider.js'; +import { createObjectiveCCfgVisitor } from '../cfg/visitors/objective-c.js'; import type { ImportResolverFn } from '../import-resolvers/types.js'; import { getLanguageGrammar } from '../../tree-sitter/parser-loader.js'; import { parseSourceSafe } from '../../tree-sitter/safe-parse.js'; import { assertCloneable } from '../workers/clone-safety.js'; import { preprocessObjectiveCMacroMarkers } from './objective-c/macro-marker-preprocess.js'; +import { objectiveCRenameProvider } from './objective-c/rename.js'; import { buildObjectiveCSemanticGraph, buildObjectiveCScopeCaptures, @@ -320,6 +322,8 @@ export const objectiveCProvider = defineLanguage({ classifyFileContent: classifyObjectiveCFileContent, shouldClassifyFileContent: isHeaderPath, preprocessSource: preprocessObjectiveCMacroMarkers, + cfgVisitor: createObjectiveCCfgVisitor(), + rename: objectiveCRenameProvider, importsExecuteWhereWritten: false, emitScopeCaptures: (sourceText, filePath, cachedTree): readonly CaptureMatch[] => { diff --git a/gitnexus/src/core/ingestion/languages/objective-c/facts.ts b/gitnexus/src/core/ingestion/languages/objective-c/facts.ts index d0ae21bef..d813900ef 100644 --- a/gitnexus/src/core/ingestion/languages/objective-c/facts.ts +++ b/gitnexus/src/core/ingestion/languages/objective-c/facts.ts @@ -10,7 +10,7 @@ import type { } from '../../language-provider.js'; import { nodeToCapture, walkNamedTree, type SyntaxNode } from '../../utils/ast-helpers.js'; -export const OBJECTIVE_C_PROVIDER_VERSION = '0.1.8'; +export const OBJECTIVE_C_PROVIDER_VERSION = '0.1.10'; export const OBJECTIVE_C_GRAMMAR_PACKAGE = 'tree-sitter-objc'; export const OBJECTIVE_C_GRAMMAR_VERSION = '3.0.2'; @@ -72,6 +72,23 @@ export interface ObjCMemberFact { readonly filePath: string; readonly startLine: number; readonly endLine: number; + readonly accessors?: { + readonly getter: string; + readonly setter?: string; + readonly custom: boolean; + readonly methodKind: ObjCMethodKind; + }; +} + +export interface ObjCMemberAccessFact extends ObjCMessageFact { + readonly accessKind: 'property' | 'ivar'; +} + +export interface ObjCPropertyImplementationFact { + readonly ownerQualifiedName: string; + readonly property: string; + readonly ivar?: string; + readonly dynamic: boolean; } export interface ObjCFunctionFact { @@ -110,6 +127,8 @@ export interface ObjCMessageFact { readonly receiverType?: ObjCTypeInfo; /** Member name retained when its type must be resolved across files. */ readonly receiverMemberName?: string; + /** Statically written property/ivar hops after the classified receiver. */ + readonly receiverMemberPath?: readonly string[]; readonly sourceMethodQualifiedName: string; readonly sourceMethodId: string; readonly sourceOwnerQualifiedName: string; @@ -143,6 +162,8 @@ export interface ObjCFileFacts { readonly imports: readonly ObjCImportFact[]; readonly messages: readonly ObjCMessageFact[]; readonly unresolvedMessages: readonly ObjCUnresolvedMessageFact[]; + readonly memberAccesses?: readonly ObjCMemberAccessFact[]; + readonly propertyImplementations?: readonly ObjCPropertyImplementationFact[]; } export interface ObjCCaptureSideChannel { @@ -365,43 +386,48 @@ function methodTypeText(node: SyntaxNode): string | undefined { return cleanType(typeNode?.text); } -function methodSelector(node: SyntaxNode): string { +/** Written selector pieces shared by graph extraction and source refactoring. */ +export function objectiveCSelectorParts( + node: SyntaxNode, +): readonly { node: SyntaxNode; colon: boolean }[] { const children = directChildren(node); + if (node.type === 'message_expression') { + const parts: { node: SyntaxNode; colon: boolean }[] = []; + for (let i = 2; i < children.length; i++) { + const child = children[i]; + if (child.type !== 'identifier' && child.type !== 'field_identifier') continue; + if (children[i + 1]?.text === ':') parts.push({ node: child, colon: true }); + else if (parts.length === 0 && children[i + 1]?.text === ']') + parts.push({ node: child, colon: false }); + } + return parts; + } const keywordDeclarators = children.filter((child) => child.type === 'keyword_declarator'); if (keywordDeclarators.length > 0) { return keywordDeclarators - .map((declarator) => directIdentifiers(declarator)[0]?.text) - .filter((name): name is string => name !== undefined) - .map((name) => `${name}:`) - .join(''); + .map((declarator) => directIdentifiers(declarator)[0]) + .filter((name): name is SyntaxNode => name !== undefined) + .map((node) => ({ node, colon: true })); } - - const pieces: string[] = []; + const pieces: { node: SyntaxNode; colon: boolean }[] = []; for (let i = 0; i < children.length; i++) { const child = children[i]; if (child.type !== 'identifier') continue; const next = children[i + 1]; if (next?.type === 'method_parameter') { - pieces.push(`${child.text}:`); + pieces.push({ node: child, colon: true }); continue; } - if (pieces.length === 0) pieces.push(child.text); + if (pieces.length === 0) pieces.push({ node: child, colon: false }); } - return pieces.join(''); + return pieces; } -function messageSelector(node: SyntaxNode): string { - const children = directChildren(node); - const pieces: string[] = []; - for (let i = 2; i < children.length; i++) { - const child = children[i]; - if (child.type !== 'identifier' && child.type !== 'field_identifier') continue; - const next = children[i + 1]; - if (next?.text === ':') pieces.push(`${child.text}:`); - else if (pieces.length === 0 && next?.text === ']') pieces.push(child.text); - } - return pieces.join(''); -} +const methodSelector = (node: SyntaxNode): string => + objectiveCSelectorParts(node) + .map((part) => `${part.node.text}${part.colon ? ':' : ''}`) + .join(''); +const messageSelector = methodSelector; function methodParameterInfo(node: SyntaxNode): { parameterTypes: string[]; @@ -427,14 +453,35 @@ function methodParameterInfo(node: SyntaxNode): { return { parameterTypes, parameterNames, typeBindings }; } -function propertyInfos(node: SyntaxNode): Array<{ name: string; type?: string }> { +function propertyInfos( + node: SyntaxNode, +): Array<{ name: string; type?: string; accessors: NonNullable }> { const structDecl = directNamedChildren(node).find((child) => child.type === 'struct_declaration'); if (structDecl === undefined) return []; - return declarationNamesAndType(structDecl); -} - -function declarationNameAndType(node: SyntaxNode): { name: string; type?: string } | null { - return declarationNamesAndType(node)[0] ?? null; + const attributes = directNamedChildren(node).find( + (child) => child.type === 'property_attributes_declaration', + ); + const values = new Map( + attributes?.namedChildren.map((attribute) => { + const ids = directIdentifiers(attribute); + return [ids[0]?.text ?? attribute.text, ids[1]?.text]; + }), + ); + return declarationNamesAndType(structDecl).map((property) => ({ + ...property, + accessors: { + getter: values.get('getter') ?? property.name, + ...(values.has('readonly') + ? {} + : { + setter: values.has('setter') + ? `${values.get('setter')}:` + : `set${property.name[0].toUpperCase()}${property.name.slice(1)}:`, + }), + custom: values.has('getter') || values.has('setter'), + methodKind: values.has('class') ? '+' : '-', + }, + })); } function declarationNamesAndType(node: SyntaxNode): Array<{ name: string; type?: string }> { @@ -442,6 +489,15 @@ function declarationNamesAndType(node: SyntaxNode): Array<{ name: string; type?: return declaratorNames(node).map((name) => (type !== undefined ? { name, type } : { name })); } +/** The declared identifier, excluding initializer and nested prototype names. */ +export function objectiveCDeclaratorName(node: SyntaxNode): SyntaxNode | undefined { + if (node.type === 'identifier') return node; + const child = + node.childForFieldName('declarator') ?? + (node.type === 'parenthesized_declarator' ? node.firstNamedChild : null); + return child ? objectiveCDeclaratorName(child) : undefined; +} + function declaratorNames(node: SyntaxNode): string[] { const names: string[] = []; collectDeclaratorNames(node, names); @@ -609,7 +665,7 @@ function directProtocolNames(node: SyntaxNode): string[] { return Array.from(new Set(out)); } -function parseContainer(node: SyntaxNode, filePath: string): ObjCContainerFact | null { +export function parseContainer(node: SyntaxNode, filePath: string): ObjCContainerFact | null { if ( node.type !== 'class_interface' && node.type !== 'class_implementation' && @@ -694,7 +750,7 @@ interface ObjCBinding { function enclosingCompoundScope(node: SyntaxNode, methodBody: SyntaxNode): SyntaxNode { let current = node.parent; while (current !== null && current !== methodBody) { - if (current.type === 'compound_statement') return current; + if (current.type === 'compound_statement' || current.type === 'for_statement') return current; current = current.parent; } return methodBody; @@ -717,19 +773,35 @@ function collectMethodBindings( scopeEndIndex: methodBody.endIndex, })); walkNamedTree(methodBody, (node) => { - if (node.type !== 'declaration') return; - const info = declarationNameAndType(node); - if (info === null) return; - const type = parseObjCType(info.type); - if (type === undefined) return; - const scope = enclosingCompoundScope(node, methodBody); - bindings.push({ - name: info.name, - type, - declarationIndex: node.endIndex, - scopeStartIndex: scope.startIndex, - scopeEndIndex: scope.endIndex, - }); + if (node.type !== 'declaration' && node.type !== 'parameter_declaration') return; + let scope: SyntaxNode | undefined; + if (node.type === 'parameter_declaration') { + // Block parameters own the block body; names in a function-pointer type + // only describe its prototype and cannot shadow enclosing values. + for (let parent = node.parent; parent && parent !== methodBody; parent = parent.parent) { + if (parent.type === 'function_declarator') return; + if (parent.type === 'block_literal') { + scope = parent; + break; + } + } + if (!scope) return; + } else scope = enclosingCompoundScope(node, methodBody); + const type = parseObjCType(firstTypeNode(node)?.text) ?? { + kind: 'unknown' as const, + raw: node.text, + }; + for (const declarator of node.childrenForFieldName('declarator')) { + const name = objectiveCDeclaratorName(declarator); + if (!name) continue; + bindings.push({ + name: name.text, + type, + declarationIndex: name.endIndex, + scopeStartIndex: scope.startIndex, + scopeEndIndex: scope.endIndex, + }); + } }); return bindings; } @@ -788,6 +860,7 @@ function classifyReceiver( kind: ObjCMessageFact['receiverKind']; type?: ObjCTypeInfo; memberName?: string; + memberPath?: readonly string[]; unresolvedReason?: string; } { const text = receiver.text; @@ -837,17 +910,26 @@ function classifyReceiver( } if (receiver.type === 'field_expression') { - const parts = directNamedChildren(receiver); - const base = parts[0]; - const field = parts[1]; - if (base?.text === 'self' && field !== undefined) { - const hostOwner = - method.hostClass !== undefined - ? objcClassQualifiedName(method.hostClass) - : method.ownerQualifiedName; - const memberType = membersByOwner.get(hostOwner)?.get(field.text); - if (memberType !== undefined) return { kind: 'property', type: memberType }; - return { kind: 'property', memberName: field.text }; + const base = receiver.childForFieldName('argument'); + const field = receiver.childForFieldName('field'); + if (base?.text === 'self' && field) { + const hostOwner = method.hostClass + ? objcClassQualifiedName(method.hostClass) + : method.ownerQualifiedName; + const type = membersByOwner.get(hostOwner)?.get(field.text); + return type ? { kind: 'property', type } : { kind: 'property', memberName: field.text }; + } + if (base && field) { + const classified = classifyReceiver( + base, + selector, + method, + methodBindings, + membersByOwner, + macroNames, + classNames, + ); + return { ...classified, memberPath: [...(classified.memberPath ?? []), field.text] }; } } return { @@ -865,8 +947,65 @@ function collectMethodMessages( classNames: ReadonlySet, messages: ObjCMessageFact[], unresolvedMessages: ObjCUnresolvedMessageFact[], + memberAccesses: ObjCMemberAccessFact[], ): void { const methodBindings = collectMethodBindings(methodNode, parameterTypes); + const methodBody = directNamedChildren(methodNode).find( + (node) => node.type === 'compound_statement', + ); + if (methodBody) + walkNamedTree(methodBody, (node) => { + const field = node.type === 'field_expression' ? node.childForFieldName('field') : undefined; + // Only instance methods have an implicit instance receiver. A class + // method's bare C names cannot refer to an instance ivar. + const bare = + method.methodKind === '-' && + node.type === 'identifier' && + !['self', 'super'].includes(node.text); + if (!field && !bare) return; + if ( + bare && + (bindingAt(methodBindings, node.text, node.startIndex) || + (node.parent?.type === 'message_expression' && + objectiveCSelectorParts(node.parent).some((part) => part.node.id === node.id))) + ) + return; + const token = field ?? node; + const receiver = field ? node.childForFieldName('argument') : undefined; + const classified = receiver + ? classifyReceiver( + receiver, + token.text, + method, + methodBindings, + membersByOwner, + macroNames, + classNames, + ) + : { kind: 'self' as const }; + memberAccesses.push({ + selector: token.text, + receiverText: receiver?.text ?? 'self', + receiverKind: classified.kind, + ...('type' in classified && classified.type ? { receiverType: classified.type } : {}), + ...('memberName' in classified && classified.memberName + ? { receiverMemberName: classified.memberName } + : {}), + ...('memberPath' in classified && classified.memberPath + ? { receiverMemberPath: classified.memberPath } + : {}), + sourceMethodQualifiedName: method.qualifiedName, + sourceMethodId: method.nodeId, + sourceOwnerQualifiedName: method.ownerQualifiedName, + sourceOwnerName: method.ownerName, + sourceMethodKind: method.methodKind, + filePath: method.filePath, + startLine: token.startPosition.row, + startCol: token.startPosition.column, + accessKind: + field && node.children.some((child) => child.text === '.') ? 'property' : 'ivar', + }); + }); walkNamedTree(methodNode, (messageNode) => { if (messageNode.type !== 'message_expression') return; const receiver = messageReceiver(messageNode); @@ -888,6 +1027,7 @@ function collectMethodMessages( receiverKind: classified.kind, ...(classified.type !== undefined ? { receiverType: classified.type } : {}), ...(classified.memberName !== undefined ? { receiverMemberName: classified.memberName } : {}), + ...(classified.memberPath !== undefined ? { receiverMemberPath: classified.memberPath } : {}), sourceMethodQualifiedName: method.qualifiedName, sourceMethodId: method.nodeId, sourceOwnerQualifiedName: method.ownerQualifiedName, @@ -921,6 +1061,8 @@ export function collectObjectiveCFacts(tree: Parser.Tree, filePath: string): Obj const imports: ObjCImportFact[] = []; const messages: ObjCMessageFact[] = []; const unresolvedMessages: ObjCUnresolvedMessageFact[] = []; + const memberAccesses: ObjCMemberAccessFact[] = []; + const propertyImplementations: ObjCPropertyImplementationFact[] = []; const membersByOwner = new Map>(); const macroNames = new Set(); const classNames = new Set(); @@ -1076,6 +1218,7 @@ export function collectObjectiveCFacts(tree: Parser.Tree, filePath: string): Obj classNames, messages, unresolvedMessages, + memberAccesses, ); } else if (inner.type === 'property_declaration') { const { startLine, endLine } = range(inner); @@ -1092,6 +1235,7 @@ export function collectObjectiveCFacts(tree: Parser.Tree, filePath: string): Obj ownerLabel: ownerLabel(container.kind), ...(container.hostClass !== undefined ? { hostClass: container.hostClass } : {}), ...(prop.type !== undefined ? { declaredType: prop.type } : {}), + accessors: prop.accessors, filePath, startLine, endLine, @@ -1121,6 +1265,23 @@ export function collectObjectiveCFacts(tree: Parser.Tree, filePath: string): Obj } }); } else if (inner.type === 'implementation_definition') { + const propertyNode = directNamedChildren(inner).find( + (node) => node.type === 'property_implementation', + ); + if (propertyNode) { + const ids = directIdentifiers(propertyNode); + const dynamic = propertyNode.child(0)?.text === '@dynamic'; + for (let index = 0; index < ids.length; index++) { + const property = ids[index]; + const explicitIvar = property.nextSibling?.text === '=' ? ids[++index] : undefined; + propertyImplementations.push({ + ownerQualifiedName: container.qualifiedName, + property: property.text, + ...(dynamic ? {} : { ivar: explicitIvar?.text ?? property.text }), + dynamic, + }); + } + } const functionNode = directNamedChildren(inner).find( (node) => node.type === 'function_definition', ); @@ -1166,6 +1327,7 @@ export function collectObjectiveCFacts(tree: Parser.Tree, filePath: string): Obj classNames, messages, unresolvedMessages, + memberAccesses, ); } } @@ -1190,6 +1352,8 @@ export function collectObjectiveCFacts(tree: Parser.Tree, filePath: string): Obj imports, messages, unresolvedMessages, + memberAccesses, + propertyImplementations, }; } @@ -1584,6 +1748,15 @@ export function buildObjectiveCScopeCaptures( ]; const seenDeclarations = new Set(); + const functionNodes = new Map(); + walkNamedTree(root, (node) => { + if (node.type !== 'function_definition' && node.type !== 'declaration') return; + for (const declarator of node.childrenForFieldName('declarator')) { + const name = objectiveCDeclaratorName(declarator); + if (name) + functionNodes.set(`${node.startPosition.row}:${name.text}`, { declaration: node, name }); + } + }); for (const container of facts.containers) { const kind = declarationCaptureKind(container.kind); if (kind === undefined) continue; @@ -1603,11 +1776,95 @@ export function buildObjectiveCScopeCaptures( for (const fn of facts.functions) { if (!seenDeclarations.add(`function:${fn.qualifiedName}`)) continue; - captures.push( - declarationMatch('function', fn.name, fn.qualifiedName, fn.startLine, fn.endLine), - ); + const source = functionNodes.get(`${fn.startLine}:${fn.name}`); + captures.push({ + ...declarationMatch('function', fn.name, fn.qualifiedName, fn.startLine, fn.endLine), + ...(source + ? { + '@declaration.function': nodeToCapture('@declaration.function', source.declaration), + '@declaration.name': nodeToCapture('@declaration.name', source.name), + } + : {}), + }); } + // C calls use the ordinary scope resolver, with lexical bindings preventing + // a parameter or function-pointer local from borrowing a global function. + const methodsByLocation = new Map( + facts.methods.map((method) => [ + `${method.ownerQualifiedName}:${method.startLine}:${method.methodKind}:${method.selector}`, + method, + ]), + ); + walkNamedTree(root, (node) => { + if ( + node.type === 'function_definition' || + node.type === 'method_definition' || + node.type === 'block_literal' + ) { + captures.push({ '@scope.function': nodeToCapture('@scope.function', node) }); + if (node.type === 'method_definition') { + let owner: ObjCContainerFact | null = null; + for (let parent = node.parent; parent && !owner; parent = parent.parent) + owner = parseContainer(parent, facts.filePath); + const method = methodsByLocation.get( + `${owner?.qualifiedName}:${node.startPosition.row}:${methodKind(node)}:${methodSelector(node)}`, + ); + const name = objectiveCSelectorParts(node)[0]?.node; + if (method && name) + captures.push({ + '@declaration.method': nodeToCapture('@declaration.method', node), + '@declaration.name': { + ...nodeToCapture('@declaration.name', name), + text: method.selector, + }, + '@declaration.qualified_name': { + ...nodeToCapture('@declaration.qualified_name', name), + text: method.qualifiedName, + }, + '@declaration.binding-name': { + ...nodeToCapture('@declaration.binding-name', name), + text: `${method.methodKind}${method.selector}`, + }, + }); + } + } else if (node.type === 'compound_statement') + captures.push({ '@scope.block': nodeToCapture('@scope.block', node) }); + else if ( + (node.type === 'parameter_declaration' || node.type === 'declaration') && + functionInfos(node).length === 0 + ) { + if (node.type === 'parameter_declaration') { + for (let parent = node.parent; parent; parent = parent.parent) { + if (parent.type === 'block_literal' || parent.type === 'function_definition') break; + if (parent.type === 'declaration' || parent.type === 'parameter_declaration') return; + } + } + for (const declarator of node.childrenForFieldName('declarator')) { + const name = objectiveCDeclaratorName(declarator); + if (name) + captures.push({ + '@declaration.variable': nodeToCapture('@declaration.variable', name), + '@declaration.name': nodeToCapture('@declaration.name', name), + }); + } + } else if (node.type === 'method_parameter' || node.type === 'keyword_declarator') { + const name = directIdentifiers(node).at(-1); + if (name) + captures.push({ + '@declaration.variable': nodeToCapture('@declaration.variable', name), + '@declaration.name': nodeToCapture('@declaration.name', name), + }); + } else if (node.type === 'call_expression') { + const callee = node.childForFieldName('function'); + if (callee?.type === 'identifier') + captures.push({ + '@reference.call.free': nodeToCapture('@reference.call.free', node), + '@reference.name': nodeToCapture('@reference.name', callee), + }); + } + }); + for (const imp of facts.imports) { const anchor = captureAt('@import.statement', imp.raw, imp.startLine, imp.endLine); const sourceText = isSystemHeaderSpelling(imp.raw) ? imp.raw : imp.targetRaw; diff --git a/gitnexus/src/core/ingestion/languages/objective-c/rename.ts b/gitnexus/src/core/ingestion/languages/objective-c/rename.ts new file mode 100644 index 000000000..6a83a00f7 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/objective-c/rename.ts @@ -0,0 +1,744 @@ +import type Parser from 'tree-sitter'; +import { SupportedLanguages } from 'gitnexus-shared'; +import type { + Capture, + CaptureMatch, + ParsedFile, + ReferenceSite, + SymbolDefinition, +} from 'gitnexus-shared'; +import type { + RenameIssue, + RenameOccurrence, + RenameProvider, + RenameSemanticContext, + RenameSource, +} from '../../rename-provider.js'; +import { syntheticCapture } from '../../utils/ast-helpers.js'; +import { cMacroNames } from '../c/rename.js'; +import { + createTreeSitterRenameSource, + innermostRenameScope, + nodeRange, + rangeKey, + walkNamedNodes, +} from '../rename-tree-sitter.js'; +import { + getObjectiveCFileFacts, + objectiveCDeclaratorName, + objectiveCSelectorParts, + parseContainer, + type ObjCFileFacts, + type ObjCMemberFact, +} from './facts.js'; + +/** The provider's normal source facts carry identities; this layer retains their written names. */ +export interface ObjectiveCRenameEvidence { + readonly facts: ObjCFileFacts; + readonly symbolFamilies: ReadonlyMap>; + readonly messages: ReadonlyMap; + readonly directReferences: ReadonlyMap; +} +const evidence = new WeakMap(); +export const objectiveCRenameEvidence = ( + parsed: ParsedFile, +): ObjectiveCRenameEvidence | undefined => evidence.get(parsed); + +/** Share this resolution pass's families without changing earlier evidence snapshots. */ +export function setObjectiveCRenameFamilies( + parsed: ParsedFile, + symbolFamilies: ReadonlyMap>, +): void { + const existing = evidence.get(parsed); + if (existing) evidence.set(parsed, { ...existing, symbolFamilies }); +} + +interface WrittenName { + readonly name: string; + readonly nodes: readonly Parser.SyntaxNode[]; + readonly anchor: Parser.SyntaxNode; + readonly role?: RenameOccurrence['role']; + readonly lexical?: boolean; +} + +const names = new Set(['identifier', 'type_identifier', 'field_identifier']); +const containers = new Set(['class_interface', 'class_implementation', 'protocol_declaration']); +const methods = new Set(['method_definition', 'method_declaration']); +// Attribute text such as NS_SWIFT_NAME(...) spells Swift-facing names, never Objective-C references. +const attributeSpecifiers = new Set(['availability_attribute_specifier', 'attribute_specifier']); +const nonNames = new Set([ + 'self', + 'super', + 'nil', + 'Nil', + 'YES', + 'NO', + 'id', + 'Class', + 'instancetype', +]); +const keywords = new Set( + 'auto break case char const continue default do double else enum extern float for goto if inline int long register restrict return short signed sizeof static struct switch typedef union unsigned void volatile while _Bool _Complex _Imaginary self super nil Nil YES NO id Class instancetype'.split( + ' ', + ), +); +const invalid = (message: string): RenameIssue => ({ code: 'invalid_name', message }); +const unsupported = (message: string): RenameIssue => ({ + code: 'unsupported_symbol_family', + message, + status: 'unsupported', +}); + +function canonicalName(name: string, context?: { readonly definition?: SymbolDefinition }): string { + const qualified = context?.definition?.qualifiedName; + const category = qualified?.match(/^objc:category:([^:]+):([^:]+)$/); + if (category && name === `${category[1]} (${category[2]})`) return category[2]; + if (qualified?.startsWith('objc:') && name === qualified.slice(qualified.lastIndexOf('.') + 1)) + name = qualified; + if (name.startsWith('objc:method:')) return name.slice(name.search(/:[+-]:/) + 3); + return name.startsWith('objc:') ? name.slice(name.lastIndexOf(':') + 1) : name; +} + +function capture(tag: string, node: Parser.SyntaxNode, text = node.text): Capture { + return syntheticCapture(tag, node, text); +} + +function declaration( + kind: string, + written: WrittenName, + qualifiedName: string, + bindingName = written.name, +): CaptureMatch { + const first = written.nodes[0]!; + const last = written.nodes.at(-1)!; + return { + [`@declaration.${kind}`]: capture(`@declaration.${kind}`, written.anchor), + '@declaration.name': { + ...capture('@declaration.name', first, written.name), + range: { + ...nodeRange(first), + endLine: last.endPosition.row + 1, + endCol: last.endPosition.column, + }, + }, + '@declaration.qualified_name': capture('@declaration.qualified_name', first, qualifiedName), + '@declaration.binding-name': capture('@declaration.binding-name', first, bindingName), + }; +} + +function enclosing( + node: Parser.SyntaxNode, + types: ReadonlySet, +): Parser.SyntaxNode | undefined { + for (let current = node.parent; current; current = current.parent) + if (types.has(current.type)) return current; + return undefined; +} + +/** Add exact declaration, lexical, and reference captures to the provider's existing facts. */ +function sourceCaptures( + root: Parser.SyntaxNode, + facts: ObjCFileFacts, + base: readonly CaptureMatch[], +) { + const captures = base.filter((match) => match['@scope.module'] || match['@import.statement']); + const written: WrittenName[] = []; + const occupied = new Set(); + const all = [ + ...walkNamedNodes( + root, + (node) => + node.type === 'comment' || node.type === 'string_literal' || node.type === 'preproc_arg', + ), + ]; + const ownerByNode = new Map(); + const directReferences = new Map(); + const addDeclaration = ( + kind: string, + node: Parser.SyntaxNode, + tokens: readonly Parser.SyntaxNode[], + name: string, + qualified: string, + binding = name, + lexical = false, + ) => { + if (!tokens.length) return; + const item = { name, nodes: tokens, anchor: node, lexical }; + written.push(item); + tokens.forEach((token) => occupied.add(token.id)); + captures.push(declaration(kind, item, qualified, binding)); + }; + for (const node of all) { + if (containers.has(node.type)) { + const identifiers = node.namedChildren.filter((child) => child.type === 'identifier'); + const first = identifiers[0]; + const fact = parseContainer(node, facts.filePath); + if (!fact || !first) continue; + ownerByNode.set(node.id, fact.qualifiedName); + captures.push({ '@scope.class': capture('@scope.class', node) }); + if (fact.kind === 'extension') continue; + const token = + fact.kind === 'category' + ? identifiers.find((item) => item.text === fact.categoryName) + : first; + if (token) + addDeclaration(fact.label.toLowerCase(), node, [token], token.text, fact.qualifiedName); + } else if (methods.has(node.type)) { + const pieces = objectiveCSelectorParts(node); + const selector = pieces + .map((piece) => `${piece.node.text}${piece.colon ? ':' : ''}`) + .join(''); + const owner = enclosing(node, containers); + const qn = owner && ownerByNode.get(owner.id); + const fact = facts.methods.find( + (item) => + item.ownerQualifiedName === qn && + item.selector === selector && + item.startLine === node.startPosition.row && + item.methodKind === node.child(0)?.text, + ); + if (!fact) continue; + captures.push({ '@scope.function': capture('@scope.function', node) }); + addDeclaration( + 'method', + node, + pieces.map((part) => part.node), + selector, + fact.qualifiedName, + `${fact.methodKind}${selector}`, + ); + } else if (node.type === 'function_definition' || node.type === 'block_literal') { + captures.push({ '@scope.function': capture('@scope.function', node) }); + } else if ( + node.type === 'parameter_list' && + node.parent?.type === 'function_declarator' && + enclosing(node, new Set(['parameter_declaration', 'declaration', 'function_definition'])) + ?.type !== 'function_definition' + ) { + // Prototype and function-pointer type parameter names end at their list. + captures.push({ '@scope.function': capture('@scope.function', node) }); + } else if (node.type === 'compound_statement' || node.type === 'for_statement') { + captures.push({ '@scope.block': capture('@scope.block', node) }); + } + } + for (const fact of [...facts.functions, ...facts.members]) { + const type = 'kind' in fact ? (fact.kind === 'property' ? 'property' : 'variable') : 'function'; + const candidates = all.filter( + (node) => + names.has(node.type) && + node.text === fact.name && + node.startPosition.row >= fact.startLine && + node.endPosition.row <= fact.endLine && + !occupied.has(node.id), + ); + const token = candidates.find((node) => { + const declaration = enclosing( + node, + new Set( + type === 'function' + ? ['function_declarator'] + : ['property_declaration', 'instance_variable'], + ), + ); + return declaration !== undefined; + }); + if (!token) continue; + const anchor = enclosing( + token, + new Set( + type === 'function' + ? ['function_definition', 'declaration'] + : ['property_declaration', 'instance_variable'], + ), + ); + if (anchor) addDeclaration(type, anchor, [token], fact.name, fact.qualifiedName); + } + // Parameters and locals need real ownership too: a homonym must stop lookup. + for (const node of all) { + let tokens: readonly Parser.SyntaxNode[] = []; + if (node.type === 'method_parameter' || node.type === 'keyword_declarator') { + const token = [...node.namedChildren].reverse().find((child) => child.type === 'identifier'); + if (token) tokens = [token]; + } else if (node.type === 'parameter_declaration' || node.type === 'declaration') { + tokens = node + .childrenForFieldName('declarator') + .flatMap((declarator) => objectiveCDeclaratorName(declarator) ?? []); + } + for (const token of tokens) + if (!occupied.has(token.id)) + addDeclaration('variable', token, [token], token.text, token.text, token.text, true); + } + for (const node of all) { + if (node.type !== 'message_expression') continue; + const pieces = objectiveCSelectorParts(node); + if (!pieces.length) continue; + const tokens = pieces.map((part) => part.node); + tokens.forEach((token) => occupied.add(token.id)); + const name = pieces.map((part) => `${part.node.text}${part.colon ? ':' : ''}`).join(''); + written.push({ name, nodes: tokens, anchor: node }); + const nameCapture = declaration('method', { name, nodes: tokens, anchor: node }, name)[ + '@declaration.name' + ]!; + captures.push({ + '@reference.call.member': capture('@reference.call.member', node), + '@reference.name': { ...nameCapture, name: '@reference.name' }, + '@reference.receiver': capture( + '@reference.receiver', + node.childForFieldName('receiver') ?? node.firstNamedChild!, + ), + }); + } + for (const node of all) { + if (!names.has(node.type) || occupied.has(node.id)) continue; + const parent = node.parent; + const unrelated = + nonNames.has(node.text) || + parent?.type === 'property_attribute' || + parent?.type === 'module_import' || + parent?.type === 'preproc_def' || + parent?.type === 'preproc_function_def' || + parent?.type === 'labeled_statement'; + written.push({ + name: node.text, + nodes: [node], + anchor: node, + role: unrelated ? 'unrelated' : 'name', + }); + if (unrelated) continue; + if (parent?.type === 'selector_expression') continue; + if (parent?.type === 'property_implementation') { + const owner = enclosing(parent, containers); + const ownerName = owner && ownerByNode.get(owner.id); + if (ownerName) + directReferences.set( + rangeKey(nodeRange(node)), + `${node.previousSibling?.text === '=' ? 'objc:ivar' : 'objc:property'}:${ownerName}:${node.text}`, + ); + } + const isCall = + parent?.type === 'call_expression' && parent.childForFieldName('function')?.id === node.id; + const isType = + node.type === 'type_identifier' || + (parent && containers.has(parent.type)) || + parent?.type === 'class_declaration'; + const tag = isCall ? '@reference.call' : isType ? '@reference.type' : '@reference.read'; + captures.push({ + [tag]: capture(tag, isCall ? parent! : node), + '@reference.name': capture('@reference.name', node), + }); + } + return { captures, written, directReferences }; +} + +async function parseSource( + input: Parameters[0], +): Promise { + let written: WrittenName[] = []; + let facts: ObjCFileFacts | undefined; + let directReferences = new Map(); + const parsed = await createTreeSitterRenameSource({ isName: () => false })({ + ...input, + languageProvider: { + ...input.languageProvider, + emitScopeCaptures(text, path, tree, options) { + const base = input.languageProvider.emitScopeCaptures!(text, path, tree, options); + facts = getObjectiveCFileFacts(path)!; + const inventory = sourceCaptures((tree as Parser.Tree).rootNode, facts, base); + written = inventory.written; + directReferences = inventory.directReferences; + return inventory.captures; + }, + }, + }); + const definitions = new Map( + parsed.parsed.localDefs.map((def) => [rangeKey(def.nameRange!), def]), + ); + const references = new Map( + parsed.parsed.referenceSites.map((site) => [rangeKey(site.nameRange!), site]), + ); + const messages = new Map(); + const occurrences: RenameOccurrence[] = written.map((item) => { + const first = item.nodes[0]!; + const last = item.nodes.at(-1)!; + const range = { + ...nodeRange(first), + endLine: last.endPosition.row + 1, + endCol: last.endPosition.column, + }; + const referenceSite = references.get(rangeKey(range)); + if (referenceSite && item.anchor.type === 'message_expression') + messages.set(referenceSite, { + line: item.anchor.startPosition.row, + column: item.anchor.startPosition.column, + }); + return { + name: item.name, + node: first, + range, + role: item.role, + spans: item.nodes.map((node) => ({ + start: node.startIndex, + length: node.endIndex - node.startIndex, + })), + definitionId: definitions.get(rangeKey(range))?.nodeId, + referenceSite, + }; + }); + const claims = new Map>(); + for (const item of written) { + if (!item.lexical) continue; + const token = item.nodes[0]!; + const scope = innermostRenameScope(parsed.parsed, token); + if (!scope) continue; + const range = nodeRange(token); + claims.set(scope, [ + ...(claims.get(scope) ?? []), + { + name: item.name, + range, + kind: 'binding', + availableFrom: { startLine: range.endLine, startCol: range.endCol }, + inactive: 'outer', + }, + ]); + } + const result = { + ...parsed, + occurrences, + parsed: { + ...parsed.parsed, + scopes: parsed.parsed.scopes.map((scope) => ({ + ...scope, + nameClaims: [...(scope.nameClaims ?? []), ...(claims.get(scope.id) ?? [])], + })), + captureSideChannel: { kind: 'objective-c', facts }, + }, + }; + evidence.set(result.parsed, { + facts: facts!, + messages, + symbolFamilies: new Map(), + directReferences: new Map( + [...references].flatMap(([range, site]) => + directReferences.has(range) ? [[site, directReferences.get(range)!] as const] : [], + ), + ), + }); + return result; +} + +/** + * Swift calls a multipart selector by its first piece as the base name; a trailing + * `With...` clause of that piece becomes an argument label (SE-0005). Later pieces only + * label arguments, so the full selector never appears as one written name. + */ +function swiftSelectorNames(name: string): readonly string[] { + if (!name.includes(':')) return []; + const first = name.slice(0, name.indexOf(':')); + const base = first.match(/^(.+?)With[A-Z]/)?.[1]; + return base ? [first, base] : [first]; +} + +/** Swift lowers a leading acronym as a unit (`URLString` is `urlString`); keep both readings. */ +function swiftLabels(piece: string): readonly string[] { + const simple = piece[0].toLowerCase() + piece.slice(1); + const acronym = piece.match(/^[A-Z]+(?=[A-Z][a-z]|$)/)?.[0]; + return acronym && acronym.length > 1 + ? [simple, acronym.toLowerCase() + piece.slice(acronym.length)] + : [simple]; +} + +/** + * Swift constructs an `initWith...` selector as `Type(label:)`, so the call never writes + * `init`; the labels (the With clause, then the later pieces) are its only spelling. + */ +function swiftInitLabels(name: string): readonly string[] { + const pieces = name.split(':'); + const clause = pieces[0].match(/^initWith([A-Z].*)$/)?.[1]; + return clause ? ['init', ...swiftLabels(clause), ...pieces.slice(1).filter(Boolean)] : []; +} + +/** Names inside `swift_name("...")` strings, which are opaque literals to the parser. */ +function swiftNameAttributeNames(source: RenameSource): readonly string[] { + const names: string[] = []; + if (!source.tree) return names; + for (const node of walkNamedNodes(source.tree.rootNode)) { + if ( + node.type !== 'call_expression' || + node.childForFieldName('function')?.text !== 'swift_name' + ) + continue; + for (const literal of node.text.match(/"(?:\\.|[^"\\])*"/g) ?? []) + names.push(...(literal.match(/[A-Za-z_][A-Za-z0-9_]*/g) ?? [])); + } + return names; +} + +export const objectiveCRenameProvider: RenameProvider = { + additionalInteropNames: (source) => [...cMacroNames(source), ...swiftNameAttributeNames(source)], + // Swift calls an imported Objective-C selector or property by its own name. + interoperatesWithSourceProvider: (provider) => + [SupportedLanguages.C, SupportedLanguages.CPlusPlus, SupportedLanguages.Swift].includes( + provider.id, + ), + canonicalName, + /** + * How an indexed source can spell a selector declared here. Every peer: the first piece + * and SE-0005 base name. Swift only: the `__` refinement NS_REFINED_FOR_SWIFT gives it + * (written for every selector: the spelling is distinctive and costs a refusal only where + * it appears) and the argument labels of an `initWith...` selector. An explicit + * NS_SWIFT_NAME keeps the Swift spelling independent of the selector, so it needs no entry. + */ + interopNames(name, peer) { + const swift = swiftSelectorNames(name); + if (peer.id !== SupportedLanguages.Swift) return swift; + return [ + ...swift, + ...(swift.length ? swift : [name]).map((spelling) => `__${spelling}`), + ...swiftInitLabels(name), + ]; + }, + /** + * The inverse bridge for a Swift rename: a selector written here denotes the Swift + * declaration named by its base name, and `setFoo:` writes the Swift property `foo`. + */ + boundaryNames(name, peer) { + if (peer.id !== SupportedLanguages.Swift) return []; + const property = name.match(/^set([A-Z][A-Za-z0-9_]*):$/)?.[1]; + return [ + ...swiftSelectorNames(name), + ...(property ? [property[0].toLowerCase() + property.slice(1), property] : []), + ]; + }, + parseSource, + validateName(name) { + if ( + !/^[A-Za-z_][A-Za-z0-9_]*(?::(?:[A-Za-z_][A-Za-z0-9_]*:)*)?$/.test(name) || + keywords.has(name) + ) + return invalid('Choose a valid Objective-C identifier or complete selector.'); + }, + checkSafety(context) { + const oldName = canonicalName(context.target.qualifiedName ?? ''); + const oldArity = oldName.split(':').length - 1; + const newArity = context.newName.split(':').length - 1; + if (context.target.type === 'Method' ? oldArity !== newArity : newArity !== 0) + return invalid('The replacement must preserve the full Objective-C selector arity.'); + const property = propertyForTarget(context); + const family = familyNames(context); + if (property?.accessors?.custom) + return unsupported( + 'This property has explicit getter/setter names; its accessor contract needs a separate rename plan.', + ); + if ( + property && + [...context.files.values()].some((source) => + objectiveCRenameEvidence(source.parsed)?.facts.propertyImplementations?.some( + (implementation) => + implementation.dynamic && + family.has( + `objc:property:${implementation.ownerQualifiedName}:${implementation.property}`, + ), + ), + ) + ) + return unsupported('This property uses @dynamic; runtime accessor ownership is unavailable.'); + const spellings = new Set( + [...family] + .filter((name): name is string => name !== undefined) + .map((name) => canonicalName(name)), + ); + if (property) { + spellings.add(property.accessors?.getter ?? property.name); + if (property.accessors?.setter) spellings.add(property.accessors.setter); + spellings.add(`_${property.name}`); + } + for (const source of context.files.values()) { + if (!source.tree) continue; + for (const node of walkNamedNodes(source.tree.rootNode)) { + if (node.type === 'selector_expression') { + const selector = node.text + .slice(node.text.indexOf('(') + 1, node.text.lastIndexOf(')')) + .replace(/\s+/g, ''); + if (spellings.has(selector)) + return unsupported('A selector literal has no statically bound owner.'); + } + if (node.type === 'preproc_arg') { + const identifiers = new Set(node.text.match(/[A-Za-z_][A-Za-z0-9_]*/g) ?? []); + if ([...spellings].some((spelling) => identifiers.has(spelling.split(':')[0]))) + return unsupported( + 'A macro body contains the selected name; expansion ownership is unavailable.', + ); + } + if (node.type === 'call_expression') { + const callee = node.childForFieldName('function')?.text; + if ( + (context.target.type === 'Class' && + ['NSClassFromString', 'objc_getClass', 'objc_lookUpClass'].includes(callee ?? '')) || + ((context.target.type === 'Method' || property) && + ['NSSelectorFromString', 'sel_registerName', 'sel_getUid'].includes(callee ?? '')) + ) + return unsupported( + 'Runtime name construction prevents complete static rename coverage.', + ); + } + } + if ( + (context.target.type === 'Method' || property) && + objectiveCRenameEvidence(source.parsed)?.facts.messages.some( + (message) => + message.selector.startsWith('performSelector:') || + message.selector === 'methodForSelector:' || + message.selector === 'forwardInvocation:' || + (property && + [ + 'valueForKey:', + 'setValue:forKey:', + 'valueForKeyPath:', + 'setValue:forKeyPath:', + ].includes(message.selector)), + ) + ) + return unsupported( + 'A runtime selector or property lookup prevents complete static rename coverage.', + ); + } + if (property) { + const replacement = renamedProperty(context, property); + if (!replacement) + return invalid('Use the corresponding ordinary property, setter or backing-ivar name.'); + const owners = new Set([property.ownerQualifiedName]); + for (const source of context.files.values()) { + const facts = objectiveCRenameEvidence(source.parsed)?.facts; + for (const member of [...(facts?.members ?? []), ...(facts?.methods ?? [])]) { + if (family.has(member.qualifiedName)) owners.add(member.ownerQualifiedName); + } + } + const replacements = new Set( + [...owners].flatMap((owner) => [ + `objc:property:${owner}:${replacement}`, + `objc:ivar:${owner}:_${replacement}`, + `objc:method:${owner}:${property.accessors?.methodKind ?? '-'}:${replacement}`, + ...(property.accessors?.setter + ? [ + `objc:method:${owner}:${property.accessors.methodKind}:set${replacement[0].toUpperCase()}${replacement.slice(1)}:`, + ] + : []), + ]), + ); + if ( + context.definitions.some( + (def) => !family.has(def.qualifiedName) && replacements.has(def.qualifiedName ?? ''), + ) + ) + return { + code: 'name_collision', + message: 'The replacement conflicts with a property accessor or backing ivar.', + }; + } + const prefix = context.target.qualifiedName?.slice(0, -oldName.length); + if (context.definitions.some((def) => def.qualifiedName === `${prefix}${context.newName}`)) + return { + code: 'name_collision', + message: 'The replacement already names a declaration on this Objective-C owner.', + }; + }, + symbolFamily(context) { + const family = familyNames(context); + return context.definitions.filter((definition) => family.has(definition.qualifiedName)); + }, + checkOccurrence({ occurrence, selected }) { + if (!selected || !occurrence.node) return; + if (enclosing(occurrence.node, attributeSpecifiers)) + return { + code: 'unsupported_boundary', + message: + 'An attribute argument such as NS_SWIFT_NAME spells the selected name; attribute text is a Swift-facing name, not an Objective-C reference, so it cannot be rewritten safely.', + status: 'unsupported', + }; + const selector = enclosing(occurrence.node, new Set([...methods, 'message_expression'])); + if (!selector) return; + const parts = objectiveCSelectorParts(selector); + if (!parts.some((part) => part.node.id === occurrence.node!.id)) return; + const arity = + selector.type === 'message_expression' + ? selector.children.filter((child) => child.text === ':').length + : selector.namedChildren.filter( + (child) => child.type === 'method_parameter' || child.type === 'keyword_declarator', + ).length; + if (arity !== parts.filter((part) => part.colon).length) + return unsupported( + 'The selected selector contains an unnamed piece whose complete source shape is not represented.', + ); + }, + rewrite(context) { + const { source, occurrence } = context; + let { newName } = context; + const property = propertyForTarget(context); + if (property) { + const replacement = renamedProperty(context, property)!; + newName = + occurrence.name === property.accessors?.setter + ? `set${replacement[0].toUpperCase()}${replacement.slice(1)}:` + : occurrence.name === `_${property.name}` + ? `_${replacement}` + : occurrence.name === property.name || occurrence.name === property.accessors?.getter + ? replacement + : occurrence.name; + } + const parts = newName.endsWith(':') ? newName.slice(0, -1).split(':') : [newName]; + if (parts.length !== occurrence.spans.length) + return invalid('The replacement does not match the written selector shape.'); + return occurrence.spans + .map((span, index) => ({ + ...span, + oldText: source.content.slice(span.start, span.start + span.length), + newText: parts[index]!, + })) + .filter((replacement) => replacement.oldText !== replacement.newText); + }, +}; + +function familyNames(context: RenameSemanticContext): Set { + const qualified = context.target.qualifiedName; + const family = new Set([qualified]); + for (const source of context.files.values()) { + for (const member of objectiveCRenameEvidence(source.parsed)?.symbolFamilies.get( + qualified ?? '', + ) ?? []) + family.add(member); + } + return family; +} + +function propertyForTarget(context: RenameSemanticContext): ObjCMemberFact | undefined { + const family = familyNames(context); + const properties: ObjCMemberFact[] = []; + for (const source of context.files.values()) { + properties.push( + ...(objectiveCRenameEvidence(source.parsed)?.facts.members.filter( + (member) => member.kind === 'property' && family.has(member.qualifiedName), + ) ?? []), + ); + } + return ( + properties.find((property) => property.accessors?.custom) ?? + properties.find((property) => property.accessors?.setter) ?? + properties[0] + ); +} + +function renamedProperty( + context: RenameSemanticContext, + property: ObjCMemberFact, +): string | undefined { + const oldName = canonicalName(context.target.qualifiedName ?? ''); + if (oldName === property.accessors?.setter) { + if (!/^set[A-Z][A-Za-z0-9_]*:$/.test(context.newName)) return undefined; + const suffix = context.newName.slice(3, -1); + return suffix[0].toLowerCase() + suffix.slice(1); + } + if (oldName === `_${property.name}`) + return context.newName.startsWith('_') ? context.newName.slice(1) : undefined; + return context.newName; +} diff --git a/gitnexus/src/core/ingestion/languages/objective-c/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/objective-c/scope-resolver.ts index 7ae8d5d12..d7a734172 100644 --- a/gitnexus/src/core/ingestion/languages/objective-c/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/objective-c/scope-resolver.ts @@ -3,16 +3,23 @@ import { type ParsedFile, type SymbolDefinition, type Callsite, + type ReferenceSite, } from 'gitnexus-shared'; import type { RelationshipType } from 'gitnexus-shared'; import type { KnowledgeGraph } from '../../../graph/types.js'; import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js'; import { generateId } from '../../../../lib/utils.js'; -import { populateClassOwnedMembers } from '../../scope-resolution/scope/walkers.js'; +import { + lookupNameClaim, + populateClassOwnedMembers, +} from '../../scope-resolution/scope/walkers.js'; +import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; +import type { SourceBindingRecorder } from '../../scope-resolution/source-binding-resolution.js'; import { objectiveCProvider } from '../objective-c.js'; import { populateObjectiveCCompilationUnitSiblings } from './compilation-unit-siblings.js'; import { resolveObjectiveCImportTarget } from './import-target.js'; import { loadObjectiveCResolutionConfig } from './resolution-config.js'; +import { objectiveCRenameEvidence, setObjectiveCRenameFamilies } from './rename.js'; import { applyObjectiveCCaptureSideChannel, isInternalObjectiveCFunctionDef, @@ -23,6 +30,7 @@ import { type ObjCContainerFact, type ObjCFileFacts, type ObjCMemberFact, + type ObjCPropertyImplementationFact, type ObjCMessageFact, type ObjCMethodFact, type ObjCTypeInfo, @@ -36,6 +44,8 @@ interface ObjCWorkspaceFacts { readonly methodsByDispatchOwner: ReadonlyMap; readonly methodsByExactOwner: ReadonlyMap; readonly memberTypesByOwner: ReadonlyMap>; + readonly membersByOwner: ReadonlyMap; + readonly propertyImplementations: ReadonlyMap; readonly classProtocols: ReadonlyMap>; readonly protocolParents: ReadonlyMap>; readonly superclassByClass: ReadonlyMap; @@ -74,7 +84,13 @@ export const objectiveCScopeResolver: ScopeResolver = { buildMro: () => new Map(), applyCaptureSideChannel: applyObjectiveCCaptureSideChannel, - populateOwners: (parsed: ParsedFile) => populateClassOwnedMembers(parsed), + populateOwners: (parsed: ParsedFile) => { + // Objective-C facts already encode ownership in their stable qualified IDs. + // The shared walker supplies ownerId but must not prepend a dotted owner. + const qualifiedNames = new Map(parsed.localDefs.map((def) => [def.nodeId, def.qualifiedName])); + populateClassOwnedMembers(parsed); + for (const def of parsed.localDefs) def.qualifiedName = qualifiedNames.get(def.nodeId); + }, populateNamespaceSiblings: populateObjectiveCCompilationUnitSiblings, isFileLocalDef: (def: SymbolDefinition) => isInternalObjectiveCFunctionDef(def), isSuperReceiver: (receiverText) => receiverText.trim() === 'super', @@ -83,21 +99,290 @@ export const objectiveCScopeResolver: ScopeResolver = { propagatesReturnTypesAcrossImports: false, collapseMemberCallsByCallerTarget: true, - emitPostResolutionEdges(graph, parsedFiles) { + emitPostResolutionEdges(graph, parsedFiles, _nodeLookup, indexes, context) { const facts = objectiveCFactsFromParsedFiles(parsedFiles); if (facts.length === 0) return; + // Persisted inventories retain stable IDs but may omit qualifiedName. + // Restore only missing metadata from the exact provider-owned identity; + // contradictory stored metadata remains available to the caller's checks. + for (const file of facts) + for (const fact of [ + ...file.containers, + ...file.methods, + ...file.members, + ...file.functions, + ]) { + const node = graph.getNode(fact.nodeId); + if (node && node.properties.qualifiedName === undefined) + node.properties.qualifiedName = fact.qualifiedName; + } const workspace = buildObjectiveCWorkspaceFacts(facts); + const renameFiles = parsedFiles.filter( + (parsed) => objectiveCRenameEvidence(parsed) !== undefined, + ); + const definitions = renameFiles.flatMap((parsed) => parsed.localDefs); + const definitionsByQualifiedName = new Map(); + for (const definition of definitions) { + if (!definition.qualifiedName) continue; + const group = definitionsByQualifiedName.get(definition.qualifiedName) ?? []; + group.push(definition); + definitionsByQualifiedName.set(definition.qualifiedName, group); + } + const messageSites = new Map(); + const families = renameFiles.length + ? objectiveCSymbolFamilies(workspace) + : new Map>(); + if (context?.recordSourceBinding) + for (const parsed of renameFiles) { + const evidence = objectiveCRenameEvidence(parsed); + if (!evidence) continue; + setObjectiveCRenameFamilies(parsed, families); + for (const [site, position] of evidence.messages) + messageSites.set(`${parsed.filePath}:${position.line}:${position.column}`, site); + recordObjectiveCLexicalBindings( + parsed, + indexes, + definitionsByQualifiedName, + context.recordSourceBinding, + ); + recordObjectiveCMemberBindings( + parsed, + workspace, + definitionsByQualifiedName, + context.recordSourceBinding, + ); + } for (const fact of facts) { emitObjectiveCHeritageEdges(graph, fact, workspace); emitObjectiveCCategoryEdges(graph, fact, workspace); emitObjectiveCImplementationEvidence(graph, fact); emitObjectiveCUnresolvedMessageEvidence(graph, fact); - emitObjectiveCMessageEdges(graph, fact, workspace); + emitObjectiveCMessageEdges( + graph, + fact, + workspace, + context?.recordSourceBinding && renameFiles.length + ? { + sites: messageSites, + definitions: definitionsByQualifiedName, + families, + record: context.recordSourceBinding, + } + : undefined, + ); } }, }; +/** Override, protocol, and category declarations share a source selector contract. */ +function objectiveCSymbolFamilies( + workspace: ObjCWorkspaceFacts, +): ReadonlyMap> { + const families = new Map>(); + const join = (methods: readonly { readonly qualifiedName: string }[]) => { + const merged = new Set( + methods.flatMap((method) => [ + ...(families.get(method.qualifiedName) ?? [method.qualifiedName]), + ]), + ); + for (const name of merged) families.set(name, merged); + }; + const all = [...workspace.methodsByExactOwner.values()].flat(); + for (const method of all) { + join([method]); + if (method.ownerKind === 'protocol') { + for (const parent of protocolHierarchy(workspace, method.ownerName)) { + join([ + method, + ...findExactOwnerMethods( + workspace, + objcProtocolQualifiedName(parent), + method.methodKind, + method.selector, + ), + ]); + } + for (const className of workspace.classesByProtocol.get(method.ownerName) ?? []) { + join([ + method, + ...findDispatchMethods(workspace, className, method.methodKind, method.selector).methods, + ]); + } + } else { + const className = method.hostClass ?? method.ownerName; + join([ + method, + ...findDispatchMethods(workspace, className, method.methodKind, method.selector).methods, + ]); + const parent = workspace.superclassByClass.get(className); + if (parent) + join([ + method, + ...findDispatchMethods(workspace, parent, method.methodKind, method.selector).methods, + ]); + } + } + for (const members of workspace.membersByOwner.values()) + for (const property of members) { + if (property.kind !== 'property') continue; + const owner = property.hostClass + ? objcClassQualifiedName(property.hostClass) + : property.ownerQualifiedName; + const accessors = property.accessors; + const related = (workspace.methodsByDispatchOwner.get(owner) ?? []).filter( + (method) => + method.methodKind === (accessors?.methodKind ?? '-') && + (method.selector === (accessors?.getter ?? property.name) || + method.selector === accessors?.setter), + ); + const ivars = (workspace.membersByOwner.get(owner) ?? []).filter( + (member) => + member.kind === 'ivar' && + member.name === `_${property.name}` && + propertyIvar(property, workspace) === member.name, + ); + const properties = (workspace.membersByOwner.get(owner) ?? []).filter( + (member) => member.kind === 'property' && member.name === property.name, + ); + join([property, ...properties, ...related, ...ivars]); + const matches = (member: ObjCMemberFact) => + member.kind === 'property' && member.name === property.name; + const methodKind = property.accessors?.methodKind ?? '-'; + if (property.ownerKind === 'protocol') { + for (const parent of workspace.protocolParents.get(property.ownerName) ?? []) { + join([ + property, + ...findMembersOnReceiver( + { kind: 'protocol', name: parent, methodKind }, + workspace, + matches, + ), + ]); + } + for (const name of workspace.classesByProtocol.get(property.ownerName) ?? []) { + join([ + property, + ...findMembersOnReceiver({ kind: 'class', name, methodKind }, workspace, matches), + ]); + } + } else { + const parent = workspace.superclassByClass.get(property.hostClass ?? property.ownerName); + if (parent) + join([ + property, + ...findMembersOnReceiver( + { kind: 'class', name: parent, methodKind }, + workspace, + matches, + ), + ]); + } + } + return families; +} + +/** Preserve selected lexical ownership; a merged header/implementation is one written binding. */ +function recordObjectiveCLexicalBindings( + parsed: ParsedFile, + indexes: ScopeResolutionIndexes, + definitions: ReadonlyMap, + record: SourceBindingRecorder, +): void { + const evidence = objectiveCRenameEvidence(parsed)!; + for (const site of parsed.referenceSites) { + if (evidence.messages.has(site)) continue; + const direct = evidence.directReferences.get(site); + if (direct) { + const implementation = evidence.facts.propertyImplementations?.find( + (item) => `objc:ivar:${item.ownerQualifiedName}:${item.ivar}` === direct, + ); + const targets = + definitions.get(direct) ?? + (implementation + ? definitions.get( + `objc:property:${implementation.ownerQualifiedName}:${implementation.property}`, + ) + : undefined) ?? + []; + record(site, { + status: targets.length ? 'resolved' : 'unresolved', + targetDefIds: targets.map((def) => def.nodeId), + basis: 'objective-c-property-implementation', + }); + continue; + } + const claim = lookupNameClaim(site.inScope, site.name, indexes, { + position: site.atRange, + purpose: site.kind === 'type-reference' ? 'type' : 'value', + }); + if (!claim.bindings.length) continue; + const qualified = new Set(claim.bindings.map((binding) => binding.def.qualifiedName)); + if (qualified.size !== 1) continue; + const name = [...qualified][0]; + const targets = name?.startsWith('objc:') + ? definitions.get(name) + : claim.bindings.map((binding) => binding.def); + if (!targets?.length) continue; + record(site, { + status: 'resolved', + targetDefIds: targets.map((def) => def.nodeId), + basis: 'objective-c-lexical-binding', + }); + } +} + +function propertyIvar(property: ObjCMemberFact, workspace: ObjCWorkspaceFacts): string | undefined { + const owner = property.hostClass + ? objcClassQualifiedName(property.hostClass) + : property.ownerQualifiedName; + const implementation = workspace.propertyImplementations.get(`${owner}:${property.name}`); + if (implementation?.dynamic || property.accessors?.methodKind === '+') return undefined; + return implementation?.ivar ?? `_${property.name}`; +} + +function recordObjectiveCMemberBindings( + parsed: ParsedFile, + workspace: ObjCWorkspaceFacts, + definitions: ReadonlyMap, + record: SourceBindingRecorder, +): void { + const facts = objectiveCRenameEvidence(parsed)!.facts; + const sites = new Map( + parsed.referenceSites + .filter((site) => site.nameRange) + .map((site) => [`${site.nameRange!.startLine - 1}:${site.nameRange!.startCol}`, site]), + ); + for (const access of facts.memberAccesses ?? []) { + const site = sites.get(`${access.startLine}:${access.startCol}`); + if (!site) continue; + const members = findReceiverMembers(access, workspace, (member) => + access.accessKind === 'property' + ? member.kind === 'property' && member.name === access.selector + : member.kind === 'ivar' && member.name === access.selector, + ); + const targets = members.length + ? members + : access.accessKind === 'ivar' + ? findReceiverMembers( + access, + workspace, + (member) => + member.kind === 'property' && propertyIvar(member, workspace) === access.selector, + ) + : []; + // Bare names without member evidence remain lexical; a dotted field is + // owned by its receiver and can never borrow a global same-name binding. + if (!targets.length && access.accessKind === 'ivar') continue; + const defs = targets.flatMap((member) => definitions.get(member.qualifiedName) ?? []); + record(site, { + status: defs.length ? 'resolved' : 'unresolved', + targetDefIds: defs.map((def) => def.nodeId), + basis: `objective-c-${access.accessKind}`, + }); + } +} + function graphNodeId(label: string, qualifiedName: string): string { return generateId(label, qualifiedName); } @@ -140,6 +425,8 @@ function buildObjectiveCWorkspaceFacts(facts: readonly ObjCFileFacts[]): ObjCWor const methodsByExactOwner = new Map(); const methodsByDispatchOwner = new Map(); const memberTypesByOwner = new Map>(); + const membersByOwner = new Map(); + const propertyImplementations = new Map(); const classProtocols = new Map>(); const protocolParents = new Map>(); const superclassByClass = new Map(); @@ -194,11 +481,19 @@ function buildObjectiveCWorkspaceFacts(facts: readonly ObjCFileFacts[]): ObjCWor } for (const member of fileFact.members) { + appendMap(membersByOwner, member.ownerQualifiedName, member); addMemberType(memberTypesByOwner, member.ownerQualifiedName, member); if (member.hostClass !== undefined) { + appendMap(membersByOwner, objcClassQualifiedName(member.hostClass), member); addMemberType(memberTypesByOwner, objcClassQualifiedName(member.hostClass), member); } } + for (const implementation of fileFact.propertyImplementations ?? []) { + propertyImplementations.set( + `${implementation.ownerQualifiedName}:${implementation.property}`, + implementation, + ); + } } const hierarchyWorkspace = { protocolParents }; @@ -240,6 +535,8 @@ function buildObjectiveCWorkspaceFacts(facts: readonly ObjCFileFacts[]): ObjCWor methodsByDispatchOwner, methodsByExactOwner, memberTypesByOwner, + membersByOwner, + propertyImplementations, classProtocols, protocolParents, superclassByClass, @@ -454,9 +751,48 @@ function emitObjectiveCMessageEdges( graph: KnowledgeGraph, facts: ObjCFileFacts, workspace: ObjCWorkspaceFacts, + observation?: { + readonly sites: ReadonlyMap; + readonly definitions: ReadonlyMap; + readonly families: ReadonlyMap>; + readonly record: SourceBindingRecorder; + }, ): void { for (const message of facts.messages) { const targets = resolveMessageTargets(message, workspace); + const site = observation?.sites.get( + `${message.filePath}:${message.startLine}:${message.startCol}`, + ); + if (site && observation) { + const methods = targets.kind === 'category-collision' ? targets.candidates : targets.methods; + const properties = methods.length + ? [] + : findReceiverMembers( + message, + workspace, + (member) => + member.kind === 'property' && + (member.accessors?.getter === message.selector || + member.accessors?.setter === message.selector), + ); + const definitions = [...methods, ...properties].flatMap( + (method) => observation.definitions.get(method.qualifiedName) ?? [], + ); + const family = methods[0] && observation.families.get(methods[0].qualifiedName); + const sameFamily = family && methods.every((method) => family.has(method.qualifiedName)); + observation.record(site, { + // Runtime winner remains unknown. The written selector contract is + // nevertheless exact when every candidate belongs to one rename family. + status: + targets.kind === 'category-collision' && !sameFamily + ? 'ambiguous' + : definitions.length + ? 'resolved' + : 'unresolved', + targetDefIds: [...new Set(definitions.map((def) => def.nodeId))], + basis: `objective-c-message:${targets.kind}`, + }); + } if (targets.kind === 'none') continue; if (targets.kind === 'category-collision') { emitCategoryDispatchEvidence(graph, facts, message, targets.candidates); @@ -509,76 +845,129 @@ function resolveMessageTargets( message: ObjCMessageFact, workspace: ObjCWorkspaceFacts, ): MessageTargets { - if (message.receiverKind === 'dynamic') { - return { kind: 'none', methods: [] }; - } - - if (message.receiverKind === 'class') { - const className = message.receiverType?.name ?? message.receiverText; + const receiver = resolveMessageReceiver(message, workspace); + if (!receiver) return { kind: 'none', methods: [] }; + if (receiver.kind === 'class') return messageTargetsFromDispatchLookup( - findDispatchMethods(workspace, className, '+', message.selector), + findDispatchMethods(workspace, receiver.name, receiver.methodKind, message.selector), ); - } - - if (message.receiverKind === 'self') { - const owner = workspace.containersByQualifiedName.get(message.sourceOwnerQualifiedName); - const className = owner?.hostClass ?? owner?.name ?? message.sourceOwnerName; - if (owner?.kind === 'protocol') { - return { - kind: 'direct', - methods: findProtocolMethods( + const methods = findProtocolMethods( + workspace, + receiver.name, + receiver.methodKind, + message.selector, + ); + return message.receiverKind === 'self' + ? { kind: 'direct', methods } + : { + kind: 'protocol', + protocolName: receiver.name, + methods, + candidates: findProtocolImplementationCandidates( workspace, - owner.name, - message.sourceMethodKind, + receiver.name, message.selector, ), }; - } - return messageTargetsFromDispatchLookup( - findDispatchMethods(workspace, className, message.sourceMethodKind, message.selector), - ); - } +} - if (message.receiverKind === 'super') { +/** The same static receiver classification drives sends and member bindings. */ +interface ObjectiveCReceiver { + readonly kind: 'class' | 'protocol'; + readonly name: string; + readonly methodKind: '-' | '+'; +} + +function resolveMessageReceiver( + message: ObjCMessageFact, + workspace: ObjCWorkspaceFacts, +): ObjectiveCReceiver | undefined { + let receiver = resolveBaseMessageReceiver(message, workspace); + for (const name of message.receiverMemberPath ?? []) { + if (!receiver) return undefined; + const members = findMembersOnReceiver(receiver, workspace, (member) => member.name === name); + const types = members.map((member) => parseObjCType(member.declaredType)); + const type = types[0]; + if ( + !type?.name || + (type.kind !== 'class' && type.kind !== 'protocol') || + types.some((candidate) => candidate?.kind !== type.kind || candidate.name !== type.name) + ) + return undefined; + receiver = { kind: type.kind, name: type.name, methodKind: '-' }; + } + return receiver; +} + +function resolveBaseMessageReceiver( + message: ObjCMessageFact, + workspace: ObjCWorkspaceFacts, +): ObjectiveCReceiver | undefined { + if (message.receiverKind === 'dynamic') return undefined; + if (message.receiverKind === 'class') + return { + kind: 'class', + name: message.receiverType?.name ?? message.receiverText, + methodKind: '+', + }; + if (message.receiverKind === 'self' || message.receiverKind === 'super') { const owner = workspace.containersByQualifiedName.get(message.sourceOwnerQualifiedName); const className = owner?.hostClass ?? owner?.name ?? message.sourceOwnerName; - const superclass = workspace.superclassByClass.get(className); - return superclass === undefined - ? { kind: 'none', methods: [] } - : messageTargetsFromDispatchLookup( - findDispatchMethods(workspace, superclass, message.sourceMethodKind, message.selector), - ); + const name = + message.receiverKind === 'super' ? workspace.superclassByClass.get(className) : className; + return name + ? { + kind: owner?.kind === 'protocol' ? 'protocol' : 'class', + name, + methodKind: message.sourceMethodKind, + } + : undefined; } - const receiverType = message.receiverType ?? resolveMemberReceiverType(message, workspace); - if (receiverType?.kind === 'dynamic' || receiverType?.kind === 'class-object') { - return { kind: 'none', methods: [] }; - } - if (message.receiverKind === 'unknown' && receiverType === undefined) { - return { kind: 'none', methods: [] }; - } - if (receiverType?.kind === 'class' && receiverType.name !== undefined) { - return messageTargetsFromDispatchLookup( - findDispatchMethods(workspace, receiverType.name, '-', message.selector), - ); - } + return receiverType?.name && (receiverType.kind === 'class' || receiverType.kind === 'protocol') + ? { kind: receiverType.kind, name: receiverType.name, methodKind: '-' } + : undefined; +} - if (receiverType?.kind === 'protocol' && receiverType.name !== undefined) { - const methods = findProtocolMethods(workspace, receiverType.name, '-', message.selector); - const candidates = findProtocolImplementationCandidates( - workspace, - receiverType.name, - message.selector, - ); - return { - kind: 'protocol', - protocolName: receiverType.name, - methods, - candidates, - }; - } +function findReceiverMembers( + message: ObjCMessageFact, + workspace: ObjCWorkspaceFacts, + predicate: (member: ObjCMemberFact) => boolean, +): readonly ObjCMemberFact[] { + const receiver = resolveMessageReceiver(message, workspace); + return receiver ? findMembersOnReceiver(receiver, workspace, predicate) : []; +} - return { kind: 'none', methods: [] }; +function findMembersOnReceiver( + receiver: ObjectiveCReceiver, + workspace: ObjCWorkspaceFacts, + predicate: (member: ObjCMemberFact) => boolean, +): readonly ObjCMemberFact[] { + const pending = + receiver.kind === 'protocol' + ? [...protocolHierarchy(workspace, receiver.name)] + : [receiver.name]; + const seen = new Set(); + while (pending.length) { + const owner = pending.shift()!; + if (seen.has(owner)) continue; + seen.add(owner); + const qualified = + receiver.kind === 'protocol' + ? objcProtocolQualifiedName(owner) + : objcClassQualifiedName(owner); + const members = (workspace.membersByOwner.get(qualified) ?? []).filter( + (member) => + predicate(member) && + (member.kind === 'property' + ? (member.accessors?.methodKind ?? '-') === receiver.methodKind + : receiver.methodKind === '-'), + ); + if (members.length) return members; + const parent = workspace.superclassByClass.get(owner); + if (parent && receiver.kind === 'class') pending.push(parent); + } + return []; } function resolveMemberReceiverType( diff --git a/gitnexus/src/core/ingestion/languages/php.ts b/gitnexus/src/core/ingestion/languages/php.ts index a7d7fa4da..644b6d352 100644 --- a/gitnexus/src/core/ingestion/languages/php.ts +++ b/gitnexus/src/core/ingestion/languages/php.ts @@ -18,6 +18,7 @@ import { } from './php/index.js'; import { SupportedLanguages } from 'gitnexus-shared'; +import { phpRenameProvider } from './php/rename.js'; import { createClassExtractor } from '../class-extractors/generic.js'; import { phpClassConfig } from '../class-extractors/configs/php.js'; import { createPhpCfgVisitor } from '../cfg/visitors/php.js'; @@ -266,6 +267,7 @@ function isPhpRouteFile(filePath: string): boolean { } export const phpProvider = defineLanguage({ + rename: phpRenameProvider, id: SupportedLanguages.PHP, extensions: ['.php', '.phtml', '.php3', '.php4', '.php5', '.php8'], entryPointPatterns: [ diff --git a/gitnexus/src/core/ingestion/languages/php/import-target.ts b/gitnexus/src/core/ingestion/languages/php/import-target.ts index 9c9d14aa1..ae7783b67 100644 --- a/gitnexus/src/core/ingestion/languages/php/import-target.ts +++ b/gitnexus/src/core/ingestion/languages/php/import-target.ts @@ -28,6 +28,75 @@ import { } from '../../language-config.js'; import { readdirSync, readFileSync, type Dirent } from 'node:fs'; import { dirname, join, relative } from 'node:path'; +import { getPhpParser } from './query.js'; +import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js'; + +interface PhpImportSpellings { + readonly source: string; + readonly functions: ReadonlyMap>; + readonly types: ReadonlyMap>; +} + +// One plain-name inventory per target file in the current resolution pass. +// Source equality also invalidates the inventory for rename's updated snapshot. +const importSpellingsByFile = new Map(); + +export function clearPhpImportSpellings(): void { + importSpellingsByFile.clear(); +} + +function phpImportSpellings(file: string, source: string): PhpImportSpellings { + const cached = importSpellingsByFile.get(file); + if (cached?.source === source) return cached; + const functions = new Map>(); + const types = new Map>(); + const tree = parseSourceSafe(getPhpParser(), source); + if (tree && !tree.rootNode.hasError) { + for (const declaration of tree.rootNode.descendantsOfType([ + 'function_definition', + 'class_declaration', + 'interface_declaration', + 'trait_declaration', + 'enum_declaration', + ])) { + const name = declaration.childForFieldName('name')?.text; + if (!name) continue; + const names = declaration.type === 'function_definition' ? functions : types; + const key = name.toLowerCase(); + const spellings = names.get(key) ?? new Set(); + spellings.add(name); + names.set(key, spellings); + } + } + const inventory = { source, functions, types }; + importSpellingsByFile.set(file, inventory); + return inventory; +} + +/** Keep the local binding canonical while matching the export's written spelling. */ +export function resolvePhpImportBinding( + parsedImport: ParsedImport, + resolveTargetFiles: () => readonly string[], + sourceTextFor: (filePath: string) => string | undefined, +): ParsedImport { + if ( + (parsedImport.kind !== 'named' && parsedImport.kind !== 'alias') || + parsedImport.importedSymbolKind === 'const' + ) + return parsedImport; + const spellings = new Set(); + for (const file of resolveTargetFiles()) { + const source = sourceTextFor(file); + if (source === undefined) continue; + const inventory = phpImportSpellings(file, source); + const names = + parsedImport.importedSymbolKind === 'function' ? inventory.functions : inventory.types; + for (const name of names.get(parsedImport.importedName.toLowerCase()) ?? []) + spellings.add(name); + } + const spelling = spellings.size === 1 ? spellings.values().next().value : undefined; + return spelling ? { ...parsedImport, importedName: spelling } : parsedImport; +} export interface PhpResolveContext { readonly fromFile: string; @@ -456,20 +525,26 @@ export function resolvePhpImportTargetInternal( }), ), ]; - const expectedType = symbolKind === 'function' ? 'Function' : 'Variable'; const declaringFiles = candidateFiles.filter((parsed) => parsed.localDefs.some((def) => { - if (def.type !== expectedType) return false; + if ( + symbolKind === 'function' + ? def.type !== 'Function' + : def.type !== 'Const' && def.type !== 'Variable' + ) + return false; const simpleName = (def.qualifiedName ?? '').split(/[\\.]/).at(-1); - return simpleName === importedName; + return symbolKind === 'function' + ? simpleName?.toLowerCase() === importedName.toLowerCase() + : simpleName === importedName; }), ); if (declaringFiles.length > 1) return null; if (declaringFiles.length === 1) return declaringFiles[0].filePath; - // PHP constants are not currently emitted as local definitions. A single - // file in the namespace directory is still unambiguous; multiple files must + // Legacy captures may omit constants. A single file in the namespace + // directory is still unambiguous; multiple files must // fail closed rather than inheriting Set iteration order. if (symbolKind === 'const' && candidateFiles.length === 1) return candidateFiles[0].filePath; return resolved; diff --git a/gitnexus/src/core/ingestion/languages/php/query.ts b/gitnexus/src/core/ingestion/languages/php/query.ts index 47bc65a26..3f1f57120 100644 --- a/gitnexus/src/core/ingestion/languages/php/query.ts +++ b/gitnexus/src/core/ingestion/languages/php/query.ts @@ -79,6 +79,9 @@ const PHP_SCOPE_QUERY = ` (enum_case name: (name) @declaration.name) @declaration.const +(const_declaration + (const_element (name) @declaration.name)) @declaration.const + ;; ── Declarations — methods / functions / constructors ───────────────────── (method_declaration diff --git a/gitnexus/src/core/ingestion/languages/php/rename.ts b/gitnexus/src/core/ingestion/languages/php/rename.ts new file mode 100644 index 000000000..1044adbfe --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/php/rename.ts @@ -0,0 +1,326 @@ +import type Parser from 'tree-sitter'; +import { + CLASS_KINDS, + type BindingRef, + type ParsedFile, + type ReferenceSite, + type SymbolDefinition, +} from 'gitnexus-shared'; +import type { RenameOccurrence, RenameProvider, RenameSource } from '../../rename-provider.js'; +import { simpleQualifiedName } from '../../scope-resolution/graph-bridge/ids.js'; +import { lookupNameClaim } from '../../scope-resolution/scope/walkers.js'; +import { graphRenameFamily } from '../rename-family.js'; +import { extractMixedChain } from '../../utils/call-analysis.js'; +import { encodeReceiverChain } from '../../utils/receiver-chain-codec.js'; +import { + createTreeSitterRenameSource, + innermostRenameScope, + nodeRange, + walkNamedNodes, +} from '../rename-tree-sitter.js'; + +const keywords = new Set( + 'abstract and array as break callable case catch class clone const continue declare default die do echo else elseif empty enddeclare endfor endforeach endif endswitch endwhile enum eval exit extends final finally fn for foreach function global goto if implements include include_once instanceof insteadof interface isset list match namespace new or print private protected public readonly require require_once return static switch throw trait try unset use var while xor yield'.split( + ' ', + ), +); + +// PHP lexes a label as [a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*, so the language itself +// accepts every non-ASCII code point (there is no ID_Start/ID_Continue restriction). A +// rewrite must still not mint names that render invisibly or reorder surrounding source +// (bidi controls, zero-width characters, byte order marks), so non-ASCII control, format, +// separator, surrogate, private-use and unassigned code points are refused. Letters, +// marks, digits and symbols stay legal, including astral ones. +const phpIdentifier = /^[A-Za-z_\u0080-\u{10FFFF}][A-Za-z0-9_\u0080-\u{10FFFF}]*$/u; +const invisibleOrFormat = /[\p{Cc}\p{Cf}\p{Cs}\p{Co}\p{Cn}\p{Zl}\p{Zp}\p{Zs}]/u; + +function insensitiveDefinition(definition: SymbolDefinition): boolean { + return ( + CLASS_KINDS.includes(definition.type) || + ((definition.type === 'Function' || definition.type === 'Method') && + !simpleQualifiedName(definition)?.startsWith('$')) + ); +} + +function canonicalBindings(parsed: ParsedFile): ParsedFile { + return { + ...parsed, + scopes: parsed.scopes.map((scope) => { + const bindings = new Map(); + for (const [name, values] of scope.bindings) + for (const value of values) { + const key = insensitiveDefinition(value.def) ? name.toLowerCase() : name; + bindings.set(key, [...(bindings.get(key) ?? []), value]); + } + return { + ...scope, + bindings, + typeBindings: new Map( + [...scope.typeBindings].map(([name, type]) => [ + name, + { + ...type, + rawName: type.rawName.startsWith('$') ? type.rawName : type.rawName.toLowerCase(), + }, + ]), + ), + }; + }), + parsedImports: parsed.parsedImports.map((entry) => + (entry.kind === 'named' || entry.kind === 'alias') && entry.importedSymbolKind !== 'const' + ? { + ...entry, + localName: entry.localName.toLowerCase(), + importedName: entry.importedName.toLowerCase(), + ...(entry.kind === 'alias' ? { alias: entry.alias.toLowerCase() } : {}), + } + : entry, + ), + referenceSites: parsed.referenceSites.map((site) => ({ + ...site, + name: + site.kind === 'call' || site.kind === 'type-reference' || site.kind === 'inherits' + ? site.name.toLowerCase() + : site.name, + ...(site.explicitReceiver && !site.explicitReceiver.name.startsWith('$') + ? { explicitReceiver: { name: site.explicitReceiver.name.toLowerCase() } } + : {}), + })), + }; +} + +function importClause(node: Parser.SyntaxNode): Parser.SyntaxNode | undefined { + for (let parent = node.parent; parent; parent = parent.parent) { + if (parent.type === 'namespace_use_clause') return parent; + if (parent.type === 'namespace_use_declaration') break; + } + return undefined; +} + +function role(node: Parser.SyntaxNode): RenameOccurrence['role'] { + const clause = importClause(node); + if (clause) + return clause.childForFieldName('alias')?.id === node.id || + (clause.namedChildCount > 1 && clause.lastNamedChild?.id === node.id) + ? 'alias' + : 'imported'; + const parent = node.parent; + if (parent?.type === 'namespace_name' || parent?.type === 'namespace_definition') + return 'unrelated'; + if ( + node.type === 'variable_name' && + parent && + ['simple_parameter', 'variadic_parameter'].includes(parent.type) + ) + return 'binding'; + if (parent?.type === 'argument' && parent.childForFieldName('name')?.id === node.id) + return 'unrelated'; + return 'name'; +} + +function reference(node: Parser.SyntaxNode, source: RenameSource): ReferenceSite | undefined { + const parent = node.parent; + if ( + !parent || + (/declaration|definition/.test(parent.type) && parent.childForFieldName('name')?.id === node.id) + ) + return undefined; + const inScope = innermostRenameScope(source.parsed, node); + if (!inScope) return undefined; + const range = nodeRange(node); + if ( + parent.type === 'member_access_expression' && + parent.childForFieldName('name')?.id === node.id + ) { + const receiver = parent.childForFieldName('object'); + const chain = receiver && extractMixedChain(receiver); + const receiverChain = chain?.baseReceiverName + ? encodeReceiverChain(chain.baseReceiverName, chain.chain) + : undefined; + return receiver + ? { + name: node.text, + nameRange: range, + atRange: nodeRange(parent), + inScope, + kind: 'read', + explicitReceiver: { name: receiver.text }, + ...(receiverChain ? { receiverChain } : {}), + } + : undefined; + } + return { + name: parent.type === 'named_type' ? node.text.toLowerCase() : node.text, + nameRange: range, + atRange: range, + inScope, + kind: parent.type === 'named_type' ? 'type-reference' : 'read', + }; +} + +export const phpRenameProvider: RenameProvider = { + canonicalName(name, context) { + const definition = context?.definition; + if (definition && insensitiveDefinition(definition)) return name.toLowerCase(); + const node = context?.occurrence?.node; + if (node && importClause(node)) { + for (let parent = node.parent; parent; parent = parent.parent) { + if (parent.children.some((child) => child.type === 'const')) return name; + if (parent.type === 'namespace_use_declaration') break; + } + return name.toLowerCase(); + } + if ( + node?.type === 'variable_name' && + [ + 'property_element', + 'property_promotion_parameter', + 'scoped_property_access_expression', + ].includes(node.parent?.type ?? '') + ) + return name.replace(/^\$/, ''); + return context?.site?.kind === 'call' || + (node?.type === 'name' && + /call_expression|object_creation_expression|named_type|class_declaration|method_declaration|function_definition/.test( + node.parent?.type ?? '', + )) + ? name.toLowerCase() + : name; + }, + validateName(name) { + if ( + !phpIdentifier.test(name) || + invisibleOrFormat.test(name) || + keywords.has(name.toLowerCase()) + ) + return { code: 'invalid_name', message: 'Choose a valid nonreserved PHP identifier.' }; + }, + parseSource: createTreeSitterRenameSource({ + isName: (node) => + node.type === 'variable_name' || + (node.type === 'name' && node.parent?.type !== 'variable_name'), + skip: (node) => node.type === 'comment', + role, + transformParsed: canonicalBindings, + reference, + }), + symbolFamily(context) { + return graphRenameFamily(context, { + matches: (left, right) => + left.type === 'Method' && + right.type === 'Method' && + simpleQualifiedName(left)?.toLowerCase() === simpleQualifiedName(right)?.toLowerCase(), + }); + }, + checkSafety({ target, newName, files, indexes }) { + if ( + CLASS_KINDS.includes(target.type) && + target.filePath + .split('/') + .at(-1) + ?.replace(/\.php$/i, '') === simpleQualifiedName(target) && + newName !== simpleQualifiedName(target) && + [...indexes.imports.values()].some((imports) => + imports.some((entry) => entry.targetDefId === target.nodeId), + ) + ) + return { + code: 'unsupported_occurrence', + status: 'unsupported', + message: 'Renaming this PHP class import also requires its autoload filename to change.', + }; + for (const source of files.values()) { + if (!source.tree) continue; + for (const node of walkNamedNodes(source.tree.rootNode)) { + if (target.type === 'Function' && node.type === 'function_call_expression') { + const selector = node.childForFieldName('function'); + if (selector && !['name', 'qualified_name', 'relative_name'].includes(selector.type)) { + const literal = + selector.namedChildCount === 1 && selector.firstNamedChild?.type === 'string_content' + ? selector.firstNamedChild.text + : undefined; + if (literal && literal.toLowerCase() !== simpleQualifiedName(target)?.toLowerCase()) + continue; + const scope = innermostRenameScope(source.parsed, selector); + const bindings = scope + ? lookupNameClaim(scope, selector.text, indexes, { + position: nodeRange(selector), + purpose: 'value', + }).bindings + : []; + if ( + bindings.length && + bindings.every(({ def }) => def.type === 'Function' && def.nodeId !== target.nodeId) + ) + continue; + return { + code: 'unsupported_occurrence', + status: 'unsupported', + message: 'Dynamic PHP calls may refer to the selected function.', + }; + } + } + if (!target.ownerId) continue; + if ( + ![ + 'member_call_expression', + 'nullsafe_member_call_expression', + 'member_access_expression', + 'nullsafe_member_access_expression', + ].includes(node.type) + ) + continue; + const name = node.childForFieldName('name'); + if (!name || name.type === 'name') continue; + const literal = + name.namedChildCount === 1 && name.firstNamedChild?.type === 'string_content' + ? name.firstNamedChild.text + : undefined; + if ( + literal && + (target.type === 'Method' + ? literal.toLowerCase() !== simpleQualifiedName(target)?.toLowerCase() + : literal !== simpleQualifiedName(target)) + ) + continue; + const receiver = node.childForFieldName('object'); + const scope = receiver && innermostRenameScope(source.parsed, receiver); + const type = + scope && + receiver && + lookupNameClaim(scope, receiver.text, indexes, { + position: nodeRange(receiver), + purpose: 'value', + }).typeBinding; + const owners = type + ? lookupNameClaim(type.declaredAtScope, type.rawName, indexes, { + position: nodeRange(node), + purpose: 'type', + }).bindings + : []; + if ( + owners.length && + owners.every( + ({ def }) => + def.nodeId !== target.ownerId && + !indexes.methodDispatch.mroFor(def.nodeId).includes(target.ownerId!) && + !indexes.methodDispatch.mroFor(target.ownerId!).includes(def.nodeId), + ) + ) + continue; + return { + code: 'unsupported_occurrence', + status: 'unsupported', + message: 'Dynamic PHP member access may refer to the selected declaration.', + }; + } + } + }, + rewrite({ source, occurrence, newName }) { + return occurrence.spans.map((span) => ({ + ...span, + oldText: source.content.slice(span.start, span.start + span.length), + newText: occurrence.node?.type === 'variable_name' ? `$${newName}` : newName, + })); + }, +}; diff --git a/gitnexus/src/core/ingestion/languages/php/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/php/scope-resolver.ts index 2b775e0e3..c24260a90 100644 --- a/gitnexus/src/core/ingestion/languages/php/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/php/scope-resolver.ts @@ -39,7 +39,12 @@ import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexe import type { SymbolDefinition } from 'gitnexus-shared'; import { phpProvider } from '../php.js'; import { phpArityCompatibility, phpMergeBindings } from './index.js'; -import { resolvePhpImportTargetInternal, loadPhpComposerConfig } from './import-target.js'; +import { + resolvePhpImportTargetInternal, + resolvePhpImportBinding, + clearPhpImportSpellings, + loadPhpComposerConfig, +} from './import-target.js'; import { populatePhpNamespaceSiblings, getPhpNamespaceForFile } from './namespace-siblings.js'; /** @@ -350,14 +355,36 @@ function phpEmitUnresolvedReceiverEdges( } const phpScopeResolver: ScopeResolver = { + // PHP has separate method and property namespaces. A same-spelled + // method must never satisfy `$object->property`, or vice versa. + resolveReceiverMember(owner, name, site, indexes, model) { + if (!['call', 'read', 'write'].includes(site.kind)) return undefined; + const members = site.kind === 'call' ? model.methods : model.fields; + for (const ownerId of [owner.nodeId, ...indexes.methodDispatch.mroFor(owner.nodeId)]) { + const candidates = members.lookupAllByOwner(ownerId, name); + if (!candidates.length) continue; + const compatible = + site.kind === 'call' + ? narrowOverloadCandidates(candidates, site.arity, site.argumentTypes) + : candidates; + return compatible.length === 1 + ? { kind: 'resolved', definition: compatible[0]! } + : { kind: 'ambiguous', candidateIds: compatible.map((def) => def.nodeId) }; + } + return { kind: 'ambiguous', candidateIds: [] }; + }, language: SupportedLanguages.PHP, languageProvider: phpProvider, importEdgeReason: 'php-scope: use', resolveImportTarget: (targetRaw, fromFile, allFilePaths, resolutionConfig, context) => resolvePhpImportTargetInternal(targetRaw, fromFile, allFilePaths, resolutionConfig, context), + resolveImportBinding: resolvePhpImportBinding, - loadResolutionConfig: (repoPath) => loadPhpComposerConfig(repoPath), + loadResolutionConfig: (repoPath) => { + clearPhpImportSpellings(); + return loadPhpComposerConfig(repoPath); + }, // PHP LEGB-like precedence: local > import/namespace/reexport > wildcard. // The per-scope id is unused by phpMergeBindings (tier ordering computed diff --git a/gitnexus/src/core/ingestion/languages/python.ts b/gitnexus/src/core/ingestion/languages/python.ts index 18b030e43..cd7230abb 100644 --- a/gitnexus/src/core/ingestion/languages/python.ts +++ b/gitnexus/src/core/ingestion/languages/python.ts @@ -48,6 +48,7 @@ import { extractPythonModuleConstants } from '../route-extractors/python-const-r import { pythonDecoratorRouteHandlerName } from '../route-extractors/python-decorator-handler.js'; import { assertCloneable } from '../workers/clone-safety.js'; import { collectPythonCaptureSideChannel } from './python/module-accessors.js'; +import { pythonRenameProvider } from './python/rename.js'; const BUILT_INS: ReadonlySet = new Set([ 'print', @@ -109,6 +110,7 @@ function normalizePythonStringLiteral(text: string): string | undefined { export const pythonProvider = defineLanguage({ id: SupportedLanguages.Python, + rename: pythonRenameProvider, extensions: ['.py', '.ipynb'], entryPointPatterns: [/^app$/, /^(get|post|put|delete|patch)_/i, /^api_/, /^view_/], astFrameworkPatterns: [ diff --git a/gitnexus/src/core/ingestion/languages/python/rename.ts b/gitnexus/src/core/ingestion/languages/python/rename.ts new file mode 100644 index 000000000..770120ec0 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/python/rename.ts @@ -0,0 +1,474 @@ +import type Parser from 'tree-sitter'; +import { CLASS_KINDS, type SymbolDefinition } from 'gitnexus-shared'; +import type { + RenameIssue, + RenameOccurrence, + RenameProvider, + RenameSemanticContext, + RenameSource, +} from '../../rename-provider.js'; +import { lookupNameClaim } from '../../scope-resolution/scope/walkers.js'; +import { + canonicalizeRenameBindings, + containsRange, + createTreeSitterRenameSource, + innermostRenameScope, + nodeRange, + rangeKey, + walkNamedNodes, +} from '../rename-tree-sitter.js'; +import { graphRenameFamily } from '../rename-family.js'; +import { simpleQualifiedName } from '../../scope-resolution/graph-bridge/ids.js'; +import { extractMixedChain } from '../../utils/call-analysis.js'; +import { encodeReceiverChain } from '../../utils/receiver-chain-codec.js'; +import { pythonStringLiteral } from './string-literal.js'; + +const canonicalName = (name: string): string => name.normalize('NFKC'); +const keywords = new Set( + 'False None True and as assert async await break class continue def del elif else except finally for from global if import in is lambda nonlocal not or pass raise return try while with yield'.split( + ' ', + ), +); +// Definition kinds whose parameters (not fields) a keyword argument label binds. +const CALLABLE_KINDS: readonly string[] = ['Function', 'Method', 'Constructor']; +const unsupported = (message: string, code = 'unsupported_occurrence'): RenameIssue => ({ + code, + message, + status: 'unsupported', +}); + +// https://docs.python.org/3/reference/expressions.html#private-name-mangling +// A class-body identifier with two leading underscores and fewer than two +// trailing ones is rewritten to `_Class__name`; written external spellings differ. +const isPrivateName = (name: string): boolean => name.startsWith('__') && !name.endsWith('__'); +// https://docs.python.org/3/reference/lexical_analysis.html#reserved-classes-of-identifiers +// `__name__` is reserved for the data model: the interpreter looks these members up +// implicitly (len(), iteration, construction, ...), so no written token proves a use. +const isSpecialName = (name: string): boolean => /^__.+__$/s.test(name); + +function role(node: Parser.SyntaxNode): RenameOccurrence['role'] { + const parent = node.parent; + if (!parent) return 'name'; + if (parent.type === 'aliased_import' && parent.childForFieldName('alias')?.id === node.id) + return 'alias'; + if ( + parent.type === 'dotted_name' && + (parent.parent?.type === 'import_from_statement' || parent.parent?.type === 'aliased_import') + ) + return 'imported'; + if (parent.type === 'keyword_argument' && parent.childForFieldName('name')?.id === node.id) + return 'unrelated'; + if ( + parent.type === 'parameters' || + (['default_parameter', 'typed_default_parameter'].includes(parent.type) && + parent.childForFieldName('name')?.id === node.id) || + (['typed_parameter', 'list_splat_pattern', 'dictionary_splat_pattern'].includes(parent.type) && + parent.firstNamedChild?.id === node.id) + ) + return 'binding'; + return 'name'; +} + +function bareReference(node: Parser.SyntaxNode, source: RenameSource) { + const parent = node.parent; + if ( + !parent || + ['dotted_name', 'aliased_import', 'global_statement', 'nonlocal_statement'].includes( + parent.type, + ) + ) + return undefined; + if ( + parent.childForFieldName('name')?.id === node.id && + ['function_definition', 'class_definition'].includes(parent.type) + ) + return undefined; + const range = nodeRange(node); + const scope = innermostRenameScope(source.parsed, node); + if (!scope) return undefined; + if (parent.type === 'attribute' && parent.childForFieldName('attribute')?.id === node.id) { + const receiver = parent.childForFieldName('object'); + if (!receiver) return undefined; + const chain = extractMixedChain(receiver); + const receiverChain = chain?.baseReceiverName + ? encodeReceiverChain(chain.baseReceiverName, chain.chain) + : undefined; + return { + name: canonicalName(node.text), + nameRange: range, + atRange: nodeRange(parent), + inScope: scope, + kind: 'read' as const, + explicitReceiver: { name: receiver.text }, + ...(receiverChain ? { receiverChain } : {}), + }; + } + // The provider's lexical claims establish assignment/comprehension ownership. + return { + name: canonicalName(node.text), + nameRange: range, + atRange: range, + inScope: scope, + kind: 'read' as const, + }; +} + +function within(node: Parser.SyntaxNode, container: Parser.SyntaxNode): boolean { + return containsRange(nodeRange(container), nodeRange(node)); +} + +function bindsInClassBody(token: Parser.SyntaxNode, declaration: Parser.SyntaxNode): boolean { + for (let node = token.parent; node && node.id !== declaration.id; node = node.parent) { + if (node.type !== 'function_definition' && node.type !== 'lambda') continue; + const body = node.childForFieldName('body'); + if (body && within(token, body)) return false; + const parameters = node.childForFieldName('parameters'); + if (parameters && within(token, parameters)) { + let parameter = token; + while (parameter.parent && parameter.parent.id !== parameters.id) + parameter = parameter.parent; + const value = parameter.childForFieldName('value'); + const annotation = parameter.childForFieldName('type'); + if (!(value && within(token, value)) && !(annotation && within(token, annotation))) + return false; + } + } + return true; +} + +function hasMangledNames(className: Parser.SyntaxNode): boolean { + const declaration = className.parent; + if (declaration?.type !== 'class_definition') return false; + const pending = [...declaration.namedChildren]; + while (pending.length) { + const node = pending.pop()!; + if (node.type === 'class_definition') { + if (isPrivateName(node.childForFieldName('name')?.text ?? '')) return true; + const body = node.childForFieldName('body'); + pending.push(...node.namedChildren.filter((child) => child.id !== body?.id)); + continue; + } + if ( + node.type === 'identifier' && + (isPrivateName(node.text) || + (node.text === '__slots__' && bindsInClassBody(node, declaration))) + ) + return true; + pending.push(...node.namedChildren); + } + return false; +} + +function receiverWrite(token: Parser.SyntaxNode): boolean { + for (let node = token.parent; node; node = node.parent) { + if (node.type === 'attribute') return false; + if ( + node.type.includes('assignment') || + node.type === 'for_statement' || + node.type === 'for_in_clause' || + node.type === 'named_expression' + ) { + const left = node.childForFieldName('left') ?? node.childForFieldName('name'); + if (left && within(token, left)) return true; + } + if (node.type.includes('statement') || node.type.includes('definition')) return false; + } + return false; +} + +function checkModuleExports( + context: RenameSemanticContext, + targetName: string, +): RenameIssue | void { + const oldName = canonicalName(targetName); + const newName = canonicalName(context.newName); + for (const source of context.files.values()) { + // An unaliased import can publish the selected definition from another + // module. Unrelated modules and nested declarations do not share that name. + const moduleBinding = lookupNameClaim(source.parsed.moduleScope, oldName, context.indexes, { + purpose: 'value', + }); + if (!moduleBinding.bindings.some((binding) => binding.def.nodeId === context.target.nodeId)) + continue; + for (const occurrence of source.occurrences) { + const node = occurrence.node; + if (occurrence.name !== '__all__' || !node) continue; + const parent = node.parent; + if (parent?.type === 'attribute' && parent.childForFieldName('attribute')?.id === node.id) + continue; + const scope = innermostRenameScope(source.parsed, node); + if (!scope) continue; + // Class-body assignments become visible after their value is evaluated; + // looking up the left-hand token itself would select an outer __all__. + const assignment = + parent?.type === 'assignment' && parent.childForFieldName('left')?.id === node.id; + const claim = lookupNameClaim(scope, '__all__', context.indexes, { + position: assignment + ? { startLine: parent.endPosition.row + 1, startCol: parent.endPosition.column } + : nodeRange(node), + purpose: 'value', + }); + if ((claim.scope?.id ?? scope) !== source.parsed.moduleScope) continue; + const value = parent?.childForFieldName('right'); + if ( + assignment && + value && + ['list', 'tuple'].includes(value.type) && + value.namedChildren.every((item) => { + if (item.type === 'comment') return true; + const name = pythonStringLiteral(item); + return name !== undefined && name !== oldName && name !== newName; + }) + ) + continue; + // Mutations, aliases, and computed lists need a complete export-value + // model. Only a literal sequence with unrelated names proves safety. + return unsupported( + `A Python module export list may contain the selected symbol or its destination name: ${source.path}:${node.startPosition.row + 1}.`, + ); + } + } +} + +function checkMemberNames(context: RenameSemanticContext, targetName: string): RenameIssue | void { + const names = [canonicalName(targetName), canonicalName(context.newName)]; + if (names.some(isPrivateName)) + return unsupported( + 'A private Python member requires its mangled external spellings in the symbol family.', + 'unsupported_symbol_family', + ); + if (names.some(isSpecialName)) + return unsupported( + 'A Python special-method or data-model name has implicit uses that written references do not show.', + 'unsupported_symbol_family', + ); +} + +/** Two classes can share one instance when either lies on the other's method resolution order. */ +function relatedOwners(context: RenameSemanticContext, left: string, right: string): boolean { + const { methodDispatch } = context.indexes; + return ( + left === right || + methodDispatch.mroFor(left).includes(right) || + methodDispatch.mroFor(right).includes(left) + ); +} + +/** + * A keyword label is an ordinary token for the callee's parameter, but a + * synthesized constructor (dataclass, NamedTuple, ...) binds it to the field. + * Only a callee proven to be another function or an unrelated class keeps it unrelated. + */ +function checkKeywordLabels( + context: RenameSemanticContext, + targetName: string, +): RenameIssue | void { + const ownerId = context.target.ownerId; + if (!ownerId) return; + const names = new Set([canonicalName(targetName), canonicalName(context.newName)]); + for (const source of context.files.values()) { + let occurrencesByRange: Map | undefined; + for (const occurrence of source.occurrences) { + const label = occurrence.node; + if (occurrence.role !== 'unrelated' || !names.has(occurrence.name) || !label) continue; + const call = label.parent?.parent?.parent; + // Class keywords reach the metaclass and __init_subclass__, never instance fields. + if (call?.type === 'class_definition') continue; + const callee = call?.type === 'call' ? call.childForFieldName('function') : undefined; + const calleeName = + callee?.type === 'attribute' ? callee.childForFieldName('attribute') : callee; + occurrencesByRange ??= new Map( + source.occurrences.map((candidate) => [rangeKey(candidate.range), candidate]), + ); + const site = + calleeName?.type === 'identifier' + ? occurrencesByRange.get(rangeKey(nodeRange(calleeName)))?.referenceSite + : undefined; + const binding = site && context.referenceBindings.get(site); + const callables = + binding?.status === 'resolved' && binding.targetDefIds.length + ? binding.targetDefIds.map((id) => context.indexes.defs.get(id)) + : undefined; + const bindsOtherCallable = (def: SymbolDefinition | undefined): boolean => + def !== undefined && + (CALLABLE_KINDS.includes(def.type) || + (CLASS_KINDS.includes(def.type) && !relatedOwners(context, def.nodeId, ownerId))); + if (callables?.every(bindsOtherCallable)) continue; + return unsupported( + `A keyword argument label may bind the selected field or its destination name through a synthesized constructor: ${source.path}:${label.startPosition.row + 1}.`, + 'ambiguous_reference', + ); + } + } +} + +function pythonSymbolFamily( + context: RenameSemanticContext, +): readonly SymbolDefinition[] | RenameIssue { + return graphRenameFamily(context, { + matches: (left, right) => + left.type === 'Method' && + right.type === 'Method' && + canonicalName(simpleQualifiedName(left) ?? '') === + canonicalName(simpleQualifiedName(right) ?? ''), + }); +} + +export const pythonRenameProvider: RenameProvider = { + canonicalName, + symbolFamily: pythonSymbolFamily, + validateName(name) { + if ( + !/^[_\p{ID_Start}][_\p{ID_Continue}]*$/u.test(name) || + !/^[_\p{ID_Start}][_\p{ID_Continue}]*$/u.test(canonicalName(name)) || + keywords.has(canonicalName(name)) + ) + return { code: 'invalid_name', message: 'Choose a valid nonreserved Python identifier.' }; + }, + parseSource: createTreeSitterRenameSource({ + isName: (node) => node.type === 'identifier', + skip: (node) => node.type === 'comment', + role, + canonicalName, + transformParsed: (parsed) => canonicalizeRenameBindings(parsed, canonicalName), + reference: bareReference, + }), + checkSafety(context) { + const targetName = simpleQualifiedName(context.target) ?? ''; + const exportIssue = checkModuleExports(context, targetName); + if (exportIssue) return exportIssue; + if (context.target.ownerId) { + const nameIssue = checkMemberNames(context, targetName); + if (nameIssue) return nameIssue; + if ( + !CALLABLE_KINDS.includes(context.target.type) && + !CLASS_KINDS.includes(context.target.type) + ) { + const labelIssue = checkKeywordLabels(context, targetName); + if (labelIssue) return labelIssue; + } + } + if (CLASS_KINDS.includes(context.target.type)) { + const source = context.files.get(context.target.filePath); + const name = source?.occurrences.find( + (occurrence) => occurrence.definitionId === context.target.nodeId, + )?.node; + if (name && hasMangledNames(name)) + return unsupported( + 'Renaming a Python class with private-name or slot coupling requires a complete symbol-family plan.', + 'unsupported_symbol_family', + ); + } + const ownerId = context.target.ownerId; + if (!ownerId) return; + let familyOwners: ReadonlySet | undefined; + // A string literal attribute name is meaningful only on the relevant receiver. + for (const source of context.files.values()) { + if (!source.tree) continue; + for (const node of walkNamedNodes(source.tree.rootNode)) { + if ( + node.type !== 'call' || + !['getattr', 'setattr', 'delattr', 'hasattr'].includes( + node.childForFieldName('function')?.text ?? '', + ) + ) + continue; + const args = node + .childForFieldName('arguments') + ?.namedChildren.filter((child) => child.type !== 'comment'); + const attribute = args?.[1] && pythonStringLiteral(args[1]); + // Runtime attribute strings are exact; Python only normalizes identifiers. + // The destination name can collide with an existing dynamic attribute. + if ( + attribute !== undefined && + attribute !== canonicalName(targetName) && + attribute !== canonicalName(context.newName) + ) + continue; + const receiver = args?.[0]; + if (!receiver) continue; + const scope = innermostRenameScope(source.parsed, receiver); + const claim = + scope && + lookupNameClaim(scope, receiver.text, context.indexes, { + position: nodeRange(receiver), + purpose: 'value', + }); + const type = claim && claim.typeBinding; + if (!type) return unsupported('A dynamic attribute receiver cannot be proven unrelated.'); + const owners = lookupNameClaim(type.declaredAtScope, type.rawName, context.indexes, { + position: nodeRange(receiver), + purpose: 'type', + }).bindings; + // A receiver that inherits the declaring class (or may hold a subclass instance) + // reaches the selected member without naming its declaring owner. + if ( + !owners.length || + owners.some((owner) => relatedOwners(context, owner.def.nodeId, ownerId)) + ) + return unsupported( + 'Dynamic attribute access may denote the selected member or its destination name.', + ); + if (!familyOwners) { + // An override family can select a member that the receiver inherits from a sibling. + const family = pythonSymbolFamily(context); + if ('code' in family) return family; + familyOwners = new Set(family.flatMap((member) => member.ownerId ?? [])); + } + const selectedOwners = familyOwners; + if ( + owners.some((owner) => + [...selectedOwners].some((id) => relatedOwners(context, owner.def.nodeId, id)), + ) + ) + return unsupported( + 'Dynamic attribute access may denote a member of the selected override family.', + ); + } + } + }, + checkOccurrence(context) { + const { site } = context; + if (!site?.explicitReceiver) return; + const name = site.explicitReceiver.name; + const claim = lookupNameClaim(site.inScope, name, context.indexes, { + position: site.atRange, + purpose: 'value', + }); + const type = claim.typeBinding; + if ( + type?.source === 'decorator-unknown' || + (type?.declaredSpelling && type.declaredSpelling !== type.rawName) + ) + return unsupported( + 'The receiver type does not prove an object identity.', + 'ambiguous_reference', + ); + const source = claim.scope && context.files.get(claim.scope.filePath); + if (!source) return; + for (const occurrence of source.occurrences) { + const node = occurrence.node; + if (occurrence.name !== name || !node || !receiverWrite(node)) continue; + if ( + type?.source === 'constructor-inferred' && + type.bindingRange && + containsRange(type.bindingRange, nodeRange(node)) + ) + continue; + const scope = innermostRenameScope(source.parsed, node); + const written = + scope && + lookupNameClaim(scope, name, context.indexes, { + position: nodeRange(node), + purpose: 'value', + }); + if ( + !written || + !written.scope || + written.claims.length === 0 || + written.scope.id === claim.scope?.id + ) + return unsupported('The receiver binding is reassigned.', 'ambiguous_reference'); + } + }, +}; diff --git a/gitnexus/src/core/ingestion/languages/python/string-literal.ts b/gitnexus/src/core/ingestion/languages/python/string-literal.ts new file mode 100644 index 000000000..f9239f21a --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/python/string-literal.ts @@ -0,0 +1,69 @@ +import type Parser from 'tree-sitter'; + +/** Decode only source-proven strings; interpolation and named escapes stay unknown. */ +export function pythonStringLiteral(node: Parser.SyntaxNode): string | undefined { + if (node.hasError) return undefined; + if (node.type === 'concatenated_string') { + let value = ''; + for (const child of node.namedChildren) { + if (child.type === 'comment') continue; + const part = pythonStringLiteral(child); + if (part === undefined) return undefined; + value += part; + } + return value; + } + if (node.type !== 'string') return undefined; + const start = /^([ru]?)("""|'''|"|')/i.exec(node.text); + const delimiter = start?.[2]; + if (!start || !delimiter || !node.text.endsWith(delimiter)) return undefined; + const body = node.text.slice(start[0].length, -delimiter.length); + if (start[1]?.toLowerCase() === 'r') return body; + const escapes: Readonly> = { + '\\': '\\', + "'": "'", + '"': '"', + a: '\x07', + b: '\b', + f: '\f', + n: '\n', + r: '\r', + t: '\t', + v: '\v', + }; + let value = ''; + for (let cursor = 0; cursor < body.length; cursor++) { + const token = body.charAt(cursor); + if (token !== '\\') { + value += token; + continue; + } + const escape = body[++cursor]; + if (escape === undefined) return undefined; + if (escape === '\n') continue; + if (escape === '\r') { + if (body[cursor + 1] === '\n') cursor++; + continue; + } + if (escapes[escape] !== undefined) { + value += escapes[escape]; + continue; + } + if (/[0-7]/.test(escape)) { + const digits = /^[0-7]{1,3}/.exec(body.slice(cursor))?.[0]; + if (!digits) return undefined; + value += String.fromCodePoint(Number.parseInt(digits, 8)); + cursor += digits.length - 1; + continue; + } + const width = escape === 'x' ? 2 : escape === 'u' ? 4 : escape === 'U' ? 8 : undefined; + if (width === undefined) return undefined; + const digits = body.slice(cursor + 1, cursor + 1 + width); + if (digits.length !== width || !/^[\da-f]+$/i.test(digits)) return undefined; + const point = Number.parseInt(digits, 16); + if (point > 0x10ffff) return undefined; + value += String.fromCodePoint(point); + cursor += width; + } + return value; +} diff --git a/gitnexus/src/core/ingestion/languages/rename-family.ts b/gitnexus/src/core/ingestion/languages/rename-family.ts new file mode 100644 index 000000000..24fb7d299 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/rename-family.ts @@ -0,0 +1,54 @@ +import { graphRenameFamily as proveRenameFamily, type RenameFamilyPolicy } from 'gitnexus-shared'; +export type { RenameFamilyPolicy } from 'gitnexus-shared'; +import type { SymbolDefinition } from 'gitnexus-shared'; +import type { RenameIssue, RenameSemanticContext } from '../rename-provider.js'; +import { buildGraphNodeLookup } from '../scope-resolution/graph-bridge/node-lookup.js'; +import { resolveDefGraphId } from '../scope-resolution/graph-bridge/ids.js'; +import { findEnclosingClassDef } from '../scope-resolution/scope/walkers.js'; + +/** Follow explicit method identity edges; an owner hierarchy alone never proves an override. */ +export function graphRenameFamily( + context: RenameSemanticContext, + policy: RenameFamilyPolicy, +): readonly SymbolDefinition[] | RenameIssue { + const { definitions, target, graph, indexes } = context; + // A missing base cannot prove the absence of an override. Preserve the + // unresolved heritage facts, including those on a transitively indexed base. + const incompleteOwners = new Set(); + const derivedOwners = new Map(); + for (const site of indexes.referenceSites) { + if (site.kind !== 'inherits') continue; + const owner = findEnclosingClassDef(site.inScope, indexes); + if (!owner) continue; + const binding = context.referenceBindings.get(site); + if (binding?.status !== 'resolved' || binding.targetDefIds.length !== 1) { + incompleteOwners.add(owner.nodeId); + continue; + } + const baseId = binding.targetDefIds[0]!; + const derived = derivedOwners.get(baseId) ?? []; + derived.push(owner.nodeId); + derivedOwners.set(baseId, derived); + } + for (const ownerId of incompleteOwners) { + for (const derivedId of derivedOwners.get(ownerId) ?? []) incompleteOwners.add(derivedId); + } + const lookup = buildGraphNodeLookup(graph); + const graphDefs = new Map(); + for (const def of definitions) { + const graphId = resolveDefGraphId(def.filePath, def, lookup); + if (!graphId) continue; + graphDefs.set(graphId, [...(graphDefs.get(graphId) ?? []), def]); + } + return proveRenameFamily( + { + target, + definitions, + graphDefs, + relationships: graph.iterRelationships(), + incompleteOwners, + methodDispatch: indexes.methodDispatch, + }, + policy, + ); +} diff --git a/gitnexus/src/core/ingestion/languages/rename-tree-sitter.ts b/gitnexus/src/core/ingestion/languages/rename-tree-sitter.ts new file mode 100644 index 000000000..8d40cfcee --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/rename-tree-sitter.ts @@ -0,0 +1,162 @@ +import type Parser from 'tree-sitter'; +import type { ParsedFile, Range, ReferenceSite } from 'gitnexus-shared'; +import { createParserForLanguage } from '../../tree-sitter/parser-loader.js'; +import { parseSourceSafe } from '../../tree-sitter/safe-parse.js'; +import { extractParsedFile } from '../scope-extractor-bridge.js'; +import type { RenameOccurrence, RenameProvider, RenameSource } from '../rename-provider.js'; + +/** Grammar policy is supplied by the language; this adapter only maps structure. */ +export interface TreeSitterRenameOptions { + readonly isName: (node: Parser.SyntaxNode) => boolean; + readonly skip?: (node: Parser.SyntaxNode) => boolean; + readonly role?: (node: Parser.SyntaxNode) => RenameOccurrence['role']; + readonly canonicalName?: (name: string) => string; + readonly transformParsed?: (parsed: ParsedFile) => ParsedFile; + readonly reference?: (node: Parser.SyntaxNode, source: RenameSource) => ReferenceSite | undefined; +} + +export function createTreeSitterRenameSource( + options: TreeSitterRenameOptions, +): RenameProvider['parseSource'] { + return async ({ filePath, content, languageProvider, scopeResolver, tree: providedTree }) => { + const parseText = languageProvider.preprocessSource?.(content, filePath) ?? content; + // Equal length alone is insufficient: moving line breaks moves semantic ranges. + if ( + parseText.length !== content.length || + [...content.matchAll(/\r|\n/g)].some((match) => parseText[match.index!] !== match[0]) || + [...parseText.matchAll(/\r|\n/g)].some((match) => content[match.index!] !== match[0]) + ) { + throw Object.assign( + new Error(`Source preprocessing changes source coordinates: ${filePath}`), + { + code: 'unsupported_source', + status: 'unsupported', + }, + ); + } + const tree = + providedTree ?? + parseSourceSafe(await createParserForLanguage(languageProvider.id, filePath), parseText); + if (!tree || tree.rootNode.hasError) + throw Object.assign(new Error(`Cannot establish semantic coverage for ${filePath}.`), { + code: 'parse_error', + }); + let parsed = extractParsedFile(languageProvider, content, filePath, undefined, tree); + if (!parsed) + throw Object.assign(new Error(`Scope extraction is unavailable for ${filePath}.`), { + code: 'incomplete_semantics', + }); + // The resolution pass resets provider state before restoring pre-extracted + // files, just as it does for worker output. Carry capture metadata with it. + const captureSideChannel = languageProvider.collectCaptureSideChannel?.(filePath); + if (captureSideChannel !== undefined) parsed = { ...parsed, captureSideChannel }; + scopeResolver.populateOwners(parsed); + parsed = options.transformParsed?.(parsed) ?? parsed; + const definitions = new Map( + parsed.localDefs + .filter((def) => def.nameRange) + .map((def) => [rangeKey(def.nameRange!), def.nodeId]), + ); + const references = new Map( + parsed.referenceSites + .filter((site) => site.nameRange) + .map((site) => [rangeKey(site.nameRange!), site]), + ); + const supplementalReferences: ReferenceSite[] = []; + const occurrences: RenameOccurrence[] = []; + const source: RenameSource = { path: filePath, content, tree, parsed, occurrences }; + for (const node of walkNamedNodes(tree.rootNode, options.skip)) { + if (!options.isName(node)) continue; + const range = nodeRange(node); + const start = node.startIndex; + const length = node.endIndex - start; + // A preprocessor may mask tokens, but must not substitute a written name. + if (content.slice(start, start + length) !== node.text) + throw Object.assign( + new Error(`A transformed identifier has no original spelling: ${filePath}`), + { + code: 'unsupported_source', + status: 'unsupported', + }, + ); + const role = options.role?.(node) ?? 'name'; + const definitionId = definitions.get(rangeKey(range)); + let referenceSite = references.get(rangeKey(range)); + if (!definitionId && !referenceSite && role === 'name') { + referenceSite = options.reference?.(node, source); + if (referenceSite) { + references.set(rangeKey(range), referenceSite); + supplementalReferences.push(referenceSite); + } + } + occurrences.push({ + name: options.canonicalName?.(node.text) ?? node.text, + range, + spans: [{ start, length }], + node, + role, + definitionId, + referenceSite, + }); + } + return { + ...source, + parsed: { ...parsed, referenceSites: [...parsed.referenceSites, ...supplementalReferences] }, + }; + }; +} + +export function* walkNamedNodes( + root: Parser.SyntaxNode, + skip?: (node: Parser.SyntaxNode) => boolean, +): Generator { + const pending = [root]; + while (pending.length) { + const node = pending.pop()!; + if (skip?.(node)) continue; + yield node; + for (let i = node.namedChildCount - 1; i >= 0; i--) pending.push(node.namedChild(i)!); + } +} + +export function nodeRange(node: Parser.SyntaxNode): Range { + return { + startLine: node.startPosition.row + 1, + startCol: node.startPosition.column, + endLine: node.endPosition.row + 1, + endCol: node.endPosition.column, + }; +} + +export function rangeKey(range: Range): string { + return `${range.startLine}:${range.startCol}:${range.endLine}:${range.endCol}`; +} + +export function containsRange(container: Range, range: Range): boolean { + return ( + (range.startLine > container.startLine || + (range.startLine === container.startLine && range.startCol >= container.startCol)) && + (range.endLine < container.endLine || + (range.endLine === container.endLine && range.endCol <= container.endCol)) + ); +} + +/** The tightest scope enclosing a source range. The semantic planner shares this + * with providers so both agree on what a position's lexical scope is. */ +export function innermostRenameScopeAt(parsed: ParsedFile, range: Range): string | undefined { + let result: ParsedFile['scopes'][number] | undefined; + for (const scope of parsed.scopes) { + if (containsRange(scope.range, range) && (!result || containsRange(result.range, scope.range))) + result = scope; + } + return result?.id; +} + +export function innermostRenameScope( + parsed: ParsedFile, + node: Parser.SyntaxNode, +): string | undefined { + return innermostRenameScopeAt(parsed, nodeRange(node)); +} + +export { canonicalizeBindings as canonicalizeRenameBindings } from '../scope-resolution/scope/canonicalize-bindings.js'; diff --git a/gitnexus/src/core/ingestion/languages/ruby.ts b/gitnexus/src/core/ingestion/languages/ruby.ts index 63bd9b3a6..7daebcf0c 100644 --- a/gitnexus/src/core/ingestion/languages/ruby.ts +++ b/gitnexus/src/core/ingestion/languages/ruby.ts @@ -8,6 +8,8 @@ */ import { SupportedLanguages } from 'gitnexus-shared'; +import { rubyRenameProvider } from './ruby/rename.js'; +import { collectRubyMemberDispatch } from './ruby/member-dispatch.js'; import type { NodeLabel } from 'gitnexus-shared'; import { createClassExtractor } from '../class-extractors/generic.js'; import { @@ -178,6 +180,7 @@ const rubyResolveContainerTypeOwner = ( }; export const rubyProvider = defineLanguage({ + rename: rubyRenameProvider, id: SupportedLanguages.Ruby, extensions: ['.rb', '.rake', '.gemspec'], entryPointPatterns: [/^call$/, /^perform$/, /^execute$/], @@ -205,7 +208,10 @@ export const rubyProvider = defineLanguage({ patterns: ['Sinatra::Base', 'Sinatra::Application'], }, ] satisfies AstFrameworkPatternConfig[], - treeSitterQueries: RUBY_QUERIES, + treeSitterQueries: `${RUBY_QUERIES} + (method name: (setter) @name) @definition.method + (singleton_method name: (setter) @name) @definition.method + `, typeConfig: rubyConfig, exportChecker: rubyExportChecker, importResolver: createImportResolver(rubyImportConfig), @@ -243,6 +249,7 @@ export const rubyProvider = defineLanguage({ builtInNames: BUILT_INS, // ── RFC #909 Ring 3: scope-based resolution hooks ────────── emitScopeCaptures: emitRubyScopeCaptures, + collectCaptureSideChannel: collectRubyMemberDispatch, cfgVisitor: createRubyCfgVisitor(), interpretImport: interpretRubyImport, interpretTypeBinding: interpretRubyTypeBinding, diff --git a/gitnexus/src/core/ingestion/languages/ruby/captures.ts b/gitnexus/src/core/ingestion/languages/ruby/captures.ts index 71809f3de..7a2b5bdb3 100644 --- a/gitnexus/src/core/ingestion/languages/ruby/captures.ts +++ b/gitnexus/src/core/ingestion/languages/ruby/captures.ts @@ -1,4 +1,5 @@ import type { Capture, CaptureMatch } from 'gitnexus-shared'; +import { recordRubyMemberDispatch } from './member-dispatch.js'; import { findChild, nodeIfType, @@ -295,6 +296,7 @@ export function emitRubyScopeCaptures( throw scopeExtractionError('scope query', _filePath, err); } + recordRubyMemberDispatch(tree.rootNode, _filePath); const out: CaptureMatch[] = []; for (const m of rawMatches) { @@ -856,6 +858,17 @@ function decomposeRubyImport(callNode: SyntaxNode, anchor: Capture): CaptureMatc if (sourcePath === '') return null; + // require_relative always resolves from this file, including a bare sibling + // spelling. Preserve that distinction for the provider's import resolver. + if ( + methodName === 'require_relative' && + !sourcePath.startsWith('./') && + !sourcePath.startsWith('../') && + !sourcePath.startsWith('/') + ) { + sourcePath = `./${sourcePath}`; + } + const segments = sourcePath.replace(/\\/g, '/').split('/'); const lastSegment = segments[segments.length - 1]!; const moduleName = lastSegment.replace(/\.rb$/, ''); diff --git a/gitnexus/src/core/ingestion/languages/ruby/member-dispatch.ts b/gitnexus/src/core/ingestion/languages/ruby/member-dispatch.ts new file mode 100644 index 000000000..af197c750 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/ruby/member-dispatch.ts @@ -0,0 +1,187 @@ +import type { ParsedFile, SymbolDefinition } from 'gitnexus-shared'; +import type { SyntaxNode } from '../../utils/ast-helpers.js'; +import { walkNamedTree } from '../../utils/ast-helpers.js'; +import { assertCloneable } from '../../workers/clone-safety.js'; +import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js'; +import { lookupNameClaim } from '../../scope-resolution/scope/walkers.js'; +import { narrowOverloadCandidates } from '../../scope-resolution/passes/overload-narrowing.js'; +import { definitionIdPosition } from '../../scope-resolution/utils/definition-id.js'; + +interface RubyDispatchFacts { + readonly kind: 'ruby'; + readonly methods: ReadonlyArray<{ + readonly line: number; + readonly column: number; + readonly singleton: boolean; + }>; + readonly properties?: ReadonlyArray<{ + readonly line: number; + readonly column: number; + readonly singleton: boolean; + readonly readable: boolean; + }>; +} + +const factsByFile = new Map(); +const singletonAncestors = new Map>(); + +export function clearRubyMemberDispatch(): void { + factsByFile.clear(); + singletonAncestors.clear(); +} + +/** Singleton inheritance uses class ancestors and the instance methods of extended modules. */ +export function configureRubySingletonAncestors( + classAncestors: ReadonlyMap, + extendedModules: ReadonlyMap, + instanceAncestors: ReadonlyMap, +): void { + singletonAncestors.clear(); + for (const ownerId of new Set([...classAncestors.keys(), ...extendedModules.keys()])) { + const ancestors: Array<{ ownerId: string; singleton: boolean }> = []; + for (const classId of [ownerId, ...(classAncestors.get(ownerId) ?? [])]) { + ancestors.push({ ownerId: classId, singleton: true }); + for (const moduleId of [...(extendedModules.get(classId) ?? [])].reverse()) + for (const id of [moduleId, ...(instanceAncestors.get(moduleId) ?? [])]) + ancestors.push({ ownerId: id, singleton: false }); + } + singletonAncestors.set(ownerId, ancestors); + } +} + +/** Only plain declaration facts cross the worker boundary. */ +export function recordRubyMemberDispatch(root: SyntaxNode, filePath: string): void { + const methods: Array<{ line: number; column: number; singleton: boolean }> = []; + const properties: NonNullable[number][] = []; + walkNamedTree(root, (node) => { + const accessor = node.type === 'call' ? node.childForFieldName('method')?.text : undefined; + if ( + node.type !== 'method' && + node.type !== 'singleton_method' && + !['attr_reader', 'attr_writer', 'attr_accessor'].includes(accessor ?? '') + ) + return; + let singleton = node.type === 'singleton_method'; + for (let parent = node.parent; parent; parent = parent.parent) { + if ( + parent.type === 'class' || + parent.type === 'module' || + parent.type === 'method' || + parent.type === 'singleton_method' + ) + break; + if (parent.type === 'singleton_class') { + singleton = true; + break; + } + } + if (accessor) { + for (const argument of node.childForFieldName('arguments')?.namedChildren ?? []) { + if (argument.type !== 'simple_symbol') continue; + properties.push({ + line: argument.startPosition.row + 1, + column: argument.startPosition.column, + singleton, + readable: accessor !== 'attr_writer', + }); + } + } else { + methods.push({ + line: node.startPosition.row + 1, + column: node.startPosition.column, + singleton, + }); + } + }); + factsByFile.set(filePath, { kind: 'ruby', methods, properties }); +} + +export function collectRubyMemberDispatch(filePath: string): RubyDispatchFacts | undefined { + return assertCloneable(factsByFile.get(filePath)); +} + +export function applyRubyMemberDispatch(parsed: ParsedFile): void { + const facts = parsed.captureSideChannel as RubyDispatchFacts | undefined; + if (facts?.kind === 'ruby') factsByFile.set(parsed.filePath, facts); +} + +export function isRubySingleton(definition: SymbolDefinition): boolean | undefined { + const position = definitionIdPosition(definition.nodeId, definition.filePath); + const facts = factsByFile.get(definition.filePath); + if (!position || !facts) return undefined; + const atDefinition = (entry: { line: number; column: number }) => + entry.line === position.line && entry.column === position.column; + return (facts.methods.find(atDefinition) ?? facts.properties?.find(atDefinition))?.singleton; +} + +export const resolveRubyReceiverMember: NonNullable = ( + owner, + name, + site, + indexes, + model, +) => { + if (site.kind !== 'call' || !site.explicitReceiver) return undefined; + const receiver = site.explicitReceiver.name; + let singleton = false; + if (receiver === 'self') { + for ( + let scope = indexes.scopeTree.getScope(site.inScope); + scope; + scope = scope.parent ? indexes.scopeTree.getScope(scope.parent) : undefined + ) { + const method = scope.ownedDefs.find( + (def) => def.type === 'Method' || def.type === 'Function', + ); + if (method) { + singleton = isRubySingleton(method) === true; + break; + } + if (scope.kind === 'Class') { + singleton = true; + break; + } + } + } else { + singleton = lookupNameClaim(site.inScope, receiver, indexes, { + position: site.atRange, + purpose: 'value', + }).bindings.some((binding) => binding.def.nodeId === owner.nodeId); + } + const ancestors = singleton + ? (singletonAncestors.get(owner.nodeId) ?? [{ ownerId: owner.nodeId, singleton: true }]) + : [owner.nodeId, ...indexes.methodDispatch.mroFor(owner.nodeId)].map((ownerId) => ({ + ownerId, + singleton: false, + })); + for (const ancestor of ancestors) { + const candidates = model.methods + .lookupAllByOwner(ancestor.ownerId, name) + .filter((def) => isRubySingleton(def) === ancestor.singleton); + if (!candidates.length) { + // Attribute macros create methods, but ingestion stores their declarations + // in the field registry. Check each owner before advancing to its parent. + const property = model.fields.lookupAllByOwner(ancestor.ownerId, name).find((def) => { + const position = definitionIdPosition(def.nodeId, def.filePath); + const facts = + position && + factsByFile + .get(def.filePath) + ?.properties?.find( + (entry) => entry.line === position.line && entry.column === position.column, + ); + return facts?.singleton === ancestor.singleton && facts.readable; + }); + if (property) + return site.arity === undefined || site.arity === 0 + ? { kind: 'resolved', definition: property } + : { kind: 'ambiguous', candidateIds: [] }; + continue; + } + const compatible = narrowOverloadCandidates(candidates, site.arity, site.argumentTypes); + return compatible.length === 1 + ? { kind: 'resolved', definition: compatible[0]! } + : { kind: 'ambiguous', candidateIds: compatible.map((def) => def.nodeId) }; + } + return { kind: 'ambiguous', candidateIds: [] }; +}; diff --git a/gitnexus/src/core/ingestion/languages/ruby/query.ts b/gitnexus/src/core/ingestion/languages/ruby/query.ts index f341fb36d..152fcf1c3 100644 --- a/gitnexus/src/core/ingestion/languages/ruby/query.ts +++ b/gitnexus/src/core/ingestion/languages/ruby/query.ts @@ -72,12 +72,12 @@ const RUBY_SCOPE_QUERY = ` ;; ── Declarations — method (instance) ───────────────────────────────────── (method - name: (identifier) @declaration.name) @declaration.function + name: [(identifier) (setter)] @declaration.name) @declaration.function ;; ── Declarations — singleton method (class-level: def self.foo) ────────── (singleton_method - name: (identifier) @declaration.name) @declaration.function + name: [(identifier) (setter)] @declaration.name) @declaration.function ;; ── Declarations — closure bound to a local ────────────────────────────── ;; diff --git a/gitnexus/src/core/ingestion/languages/ruby/rename.ts b/gitnexus/src/core/ingestion/languages/ruby/rename.ts new file mode 100644 index 000000000..8b9a14d31 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/ruby/rename.ts @@ -0,0 +1,234 @@ +import type Parser from 'tree-sitter'; +import { CLASS_KINDS, type NameClaim, type ReferenceSite } from 'gitnexus-shared'; +import type { RenameOccurrence, RenameProvider, RenameSource } from '../../rename-provider.js'; +import { simpleQualifiedName } from '../../scope-resolution/graph-bridge/ids.js'; +import { lookupNameClaim } from '../../scope-resolution/scope/walkers.js'; +import { graphRenameFamily } from '../rename-family.js'; +import { isRubySingleton } from './member-dispatch.js'; +import { + createTreeSitterRenameSource, + innermostRenameScope, + nodeRange, + rangeKey, + walkNamedNodes, +} from '../rename-tree-sitter.js'; + +const keywords = new Set( + 'BEGIN END alias and begin break case class def defined? do else elsif end ensure false for if in module next nil not or redo rescue retry return self super then true undef unless until when while yield'.split( + ' ', + ), +); + +function role(node: Parser.SyntaxNode): RenameOccurrence['role'] { + const parent = node.parent; + if ( + parent && + ['method_parameters', 'block_parameters', 'lambda_parameters'].includes(parent.type) + ) + return 'binding'; + if ( + parent && + [ + 'optional_parameter', + 'keyword_parameter', + 'splat_parameter', + 'hash_splat_parameter', + 'block_parameter', + ].includes(parent.type) && + parent.childForFieldName('name')?.id === node.id + ) + return 'binding'; + return 'name'; +} + +function reference(node: Parser.SyntaxNode, source: RenameSource): ReferenceSite | undefined { + const parent = node.parent; + if ( + !parent || + (['method', 'singleton_method', 'class', 'module'].includes(parent.type) && + parent.childForFieldName('name')?.id === node.id) + ) + return undefined; + const inScope = innermostRenameScope(source.parsed, node); + if (!inScope) return undefined; + const range = nodeRange(node); + const qualified = + parent.type === 'scope_resolution' && parent.childForFieldName('name')?.id === node.id + ? parent.text + : undefined; + return { + name: node.text, + nameRange: range, + atRange: range, + inScope, + kind: node.type === 'constant' ? 'type-reference' : 'read', + ...(qualified ? { rawQualifiedName: qualified } : {}), + }; +} + +const parseSource = createTreeSitterRenameSource({ + isName: (node) => + node.type === 'setter' || + (['identifier', 'constant', 'instance_variable', 'class_variable', 'global_variable'].includes( + node.type, + ) && + node.parent?.type !== 'setter'), + skip: (node) => node.type === 'comment', + role, + reference, +}); + +export const rubyRenameProvider: RenameProvider = { + validateName(name) { + if (!/^[A-Za-z_\p{L}][A-Za-z0-9_\p{L}\p{M}]*[!?=]?$/u.test(name) || keywords.has(name)) + return { code: 'invalid_name', message: 'Choose a valid nonreserved Ruby identifier.' }; + }, + async parseSource(input) { + const source = await parseSource(input); + const setters = new Map( + source.occurrences.flatMap((occurrence) => { + const call = occurrence.node?.parent; + const assignment = call?.parent; + return call?.type === 'call' && + assignment?.type === 'assignment' && + assignment.childForFieldName('left')?.id === call.id && + call.childForFieldName('method')?.id === occurrence.node?.id + ? [[rangeKey(occurrence.range), occurrence] as const] + : []; + }), + ); + const referenceSites = source.parsed.referenceSites.flatMap((site) => { + const setter = site.nameRange && setters.get(rangeKey(site.nameRange)); + if (!setter) return [site]; + if (site.kind === 'call') return []; + return [ + { + ...site, + name: `${site.name}=`, + kind: 'call' as const, + callForm: 'member' as const, + arity: 1, + }, + ]; + }); + const claims = new Map(); + for (const occurrence of source.occurrences) { + if (occurrence.role !== 'binding' || !occurrence.node) continue; + const scope = innermostRenameScope(source.parsed, occurrence.node); + if (!scope) continue; + claims.set(scope, [ + ...(claims.get(scope) ?? []), + { + name: occurrence.name, + range: occurrence.range, + kind: 'binding', + hoisted: true, + purpose: 'value', + }, + ]); + } + return { + ...source, + occurrences: source.occurrences.map((occurrence) => + setters.has(rangeKey(occurrence.range)) + ? { + ...occurrence, + name: `${occurrence.name}=`, + referenceSite: referenceSites.find( + (site) => site.nameRange && rangeKey(site.nameRange) === rangeKey(occurrence.range), + ), + } + : occurrence, + ), + parsed: { + ...source.parsed, + referenceSites, + scopes: source.parsed.scopes.map((scope) => ({ + ...scope, + nameClaims: [...(scope.nameClaims ?? []), ...(claims.get(scope.id) ?? [])], + })), + }, + }; + }, + symbolFamily(context) { + return graphRenameFamily(context, { + matches: (left, right) => + left.type === 'Method' && + right.type === 'Method' && + simpleQualifiedName(left) === simpleQualifiedName(right) && + isRubySingleton(left) === isRubySingleton(right), + }); + }, + checkSafety({ target, newName, files, indexes }) { + if (CLASS_KINDS.includes(target.type) && !/^[A-Z]/.test(newName)) + return { + code: 'invalid_name', + message: 'Ruby constants must begin with an uppercase letter.', + }; + if (simpleQualifiedName(target)?.endsWith('=') !== newName.endsWith('=')) + return { code: 'invalid_name', message: 'A Ruby setter must keep its trailing equals sign.' }; + if (target.type !== 'Method' && target.type !== 'Function') return; + for (const source of files.values()) { + if (!source.tree) continue; + for (const node of walkNamedNodes(source.tree.rootNode)) { + if ( + node.type !== 'call' || + !['send', '__send__', 'public_send', 'method', 'public_method'].includes( + node.childForFieldName('method')?.text ?? '', + ) + ) + continue; + const selector = node.childForFieldName('arguments')?.firstNamedChild; + const literal = + selector?.type === 'simple_symbol' + ? selector.text.slice(1) + : selector?.type === 'string' && selector.firstNamedChild?.type === 'string_content' + ? selector.firstNamedChild.text + : undefined; + if (literal && literal !== simpleQualifiedName(target)) continue; + const receiver = node.childForFieldName('receiver'); + const scope = innermostRenameScope(source.parsed, receiver ?? node); + const type = + scope && + lookupNameClaim(scope, receiver?.text ?? 'self', indexes, { + position: nodeRange(node), + purpose: 'value', + }).typeBinding; + const owners = type + ? lookupNameClaim(type.declaredAtScope, type.rawName, indexes, { + position: nodeRange(node), + purpose: 'type', + }).bindings + : []; + if ( + target.ownerId && + owners.length && + owners.every( + ({ def }) => + def.nodeId !== target.ownerId && + !indexes.methodDispatch.mroFor(def.nodeId).includes(target.ownerId!) && + !indexes.methodDispatch.mroFor(target.ownerId!).includes(def.nodeId), + ) + ) + continue; + return { + code: 'unsupported_occurrence', + status: 'unsupported', + message: 'Dynamic Ruby dispatch may refer to the selected declaration.', + }; + } + } + }, + rewrite({ source, occurrence, newName }) { + const assignment = occurrence.node?.parent?.parent; + const implicitSetter = + occurrence.name.endsWith('=') && + occurrence.node?.type !== 'setter' && + assignment?.type === 'assignment'; + return occurrence.spans.map((span) => ({ + ...span, + oldText: source.content.slice(span.start, span.start + span.length), + newText: implicitSetter ? newName.replace(/=$/, '') : newName, + })); + }, +}; diff --git a/gitnexus/src/core/ingestion/languages/ruby/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/ruby/scope-resolver.ts index 2ce047dbd..f0d6f445a 100644 --- a/gitnexus/src/core/ingestion/languages/ruby/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/ruby/scope-resolver.ts @@ -3,6 +3,12 @@ import { SupportedLanguages } from 'gitnexus-shared'; import { buildMro, defaultLinearize } from '../../scope-resolution/passes/mro.js'; import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js'; import { rubyProvider } from '../ruby.js'; +import { + applyRubyMemberDispatch, + clearRubyMemberDispatch, + configureRubySingletonAncestors, + resolveRubyReceiverMember, +} from './member-dispatch.js'; import { rubyArityCompatibility, rubyMergeBindings, resolveRubyImportTarget } from './index.js'; import { populateClassOwnedMembers, isClassLike } from '../../scope-resolution/scope/walkers.js'; import { resolveDefGraphId } from '../../scope-resolution/graph-bridge/ids.js'; @@ -165,6 +171,7 @@ function buildRubyMro( ): Map { // Step 1: EXTENDS chain via the generic MRO builder (direct class inheritance). const baseMro = buildMro(graph, parsedFiles, nodeLookup, defaultLinearize); + const classAncestors = new Map(baseMro); // Step 2: Build defId ↔ graphId bridge for class-like defs. const defIdByGraphId = new Map(); @@ -179,6 +186,7 @@ function buildRubyMro( // Step 3: Collect IMPLEMENTS edges, partitioned by reason. const prependByChild = new Map(); const includeByChild = new Map(); + const extendByChild = new Map(); for (const rel of graph.iterRelationshipsByType('IMPLEMENTS')) { const childDefId = defIdByGraphId.get(rel.sourceId); @@ -193,6 +201,8 @@ function buildRubyMro( prependByChild.set(childDefId, list); } list.push(parentDefId); + } else if (reason === 'extend') { + extendByChild.set(childDefId, [...(extendByChild.get(childDefId) ?? []), parentDefId]); } else if (reason === 'include') { let list = includeByChild.get(childDefId); if (list === undefined) { @@ -227,6 +237,7 @@ function buildRubyMro( baseMro.set(defId, reordered); } + configureRubySingletonAncestors(classAncestors, extendByChild, baseMro); return baseMro; } @@ -261,6 +272,8 @@ function expandRubyWildcardNames( } export const rubyScopeResolver: ScopeResolver = { + applyCaptureSideChannel: applyRubyMemberDispatch, + resolveReceiverMember: resolveRubyReceiverMember, // Construction is a selector on the class: `Service.new.do_work` (#2708). constructionSyntax: { selector: 'new' }, language: SupportedLanguages.Ruby, @@ -270,7 +283,10 @@ export const rubyScopeResolver: ScopeResolver = { resolveImportTarget: (targetRaw, fromFile, allFilePaths, resolutionConfig) => resolveRubyImportTarget(targetRaw, fromFile, allFilePaths, resolutionConfig), - loadResolutionConfig: (repoPath) => loadRubyResolutionConfig(repoPath), + loadResolutionConfig: (repoPath) => { + clearRubyMemberDispatch(); + return loadRubyResolutionConfig(repoPath); + }, expandsWildcardTo: (targetModuleScope, parsedFiles) => expandRubyWildcardNames(targetModuleScope, parsedFiles), diff --git a/gitnexus/src/core/ingestion/languages/rust.ts b/gitnexus/src/core/ingestion/languages/rust.ts index 0659e3f29..85a4ad1a0 100644 --- a/gitnexus/src/core/ingestion/languages/rust.ts +++ b/gitnexus/src/core/ingestion/languages/rust.ts @@ -1,3 +1,4 @@ +import { rustRenameProvider } from './rust/rename.js'; /** * Rust Language Provider * @@ -126,6 +127,7 @@ const BUILT_INS: ReadonlySet = new Set([ ]); export const rustProvider = defineLanguage({ + rename: rustRenameProvider, id: SupportedLanguages.Rust, extensions: ['.rs'], entryPointPatterns: [/^(get|post|put|delete)_handler$/i, /^handle_/, /^new$/, /^run$/, /^spawn/], diff --git a/gitnexus/src/core/ingestion/languages/rust/interpret.ts b/gitnexus/src/core/ingestion/languages/rust/interpret.ts index 73ecd264e..547114eac 100644 --- a/gitnexus/src/core/ingestion/languages/rust/interpret.ts +++ b/gitnexus/src/core/ingestion/languages/rust/interpret.ts @@ -46,9 +46,17 @@ export function interpretRustImport(captures: CaptureMatch): ParsedImport | null // kind === 'named' if (name === undefined) return null; const originalName = captures['@import.original-name']?.text; + if (alias !== undefined) + return { + kind: 'alias', + localName: alias, + importedName: originalName ?? name, + alias, + targetRaw: source, + }; return { kind: 'named', - localName: alias ?? name, + localName: name, importedName: originalName ?? name, targetRaw: source, }; diff --git a/gitnexus/src/core/ingestion/languages/rust/rename.ts b/gitnexus/src/core/ingestion/languages/rust/rename.ts new file mode 100644 index 000000000..be7afb6ec --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/rust/rename.ts @@ -0,0 +1,126 @@ +import type Parser from 'tree-sitter'; +import { SupportedLanguages } from 'gitnexus-shared'; +import type { RenameOccurrence, RenameProvider, RenameSource } from '../../rename-provider.js'; +import { + canonicalizeRenameBindings, + createTreeSitterRenameSource, + innermostRenameScope, + nodeRange, +} from '../rename-tree-sitter.js'; +import { graphRenameFamily } from '../rename-family.js'; +import { simpleQualifiedName } from '../../scope-resolution/graph-bridge/ids.js'; +import { + buildGraphNodeLookup, + qualifiedKey, +} from '../../scope-resolution/graph-bridge/node-lookup.js'; + +const canonicalName = (name: string): string => name.replace(/^r#/, '').normalize('NFC'); +const keywords = new Set( + 'as async await break const continue crate dyn else enum extern false fn for if impl in let loop match mod move mut pub ref return self Self static struct super trait true type unsafe use where while abstract become box do final gen macro override priv typeof unsized virtual yield try'.split( + ' ', + ), +); +const names = new Set([ + 'identifier', + 'type_identifier', + 'field_identifier', + 'shorthand_field_identifier', +]); + +function role(node: Parser.SyntaxNode): RenameOccurrence['role'] { + for (let parent = node.parent; parent; parent = parent.parent) { + if (parent.type === 'use_as_clause') + return parent.childForFieldName('alias')?.id === node.id ? 'alias' : 'imported'; + if (parent.type === 'use_declaration') return 'imported'; + if ( + (parent.type === 'parameter' && parent.childForFieldName('pattern')?.id === node.id) || + parent.type === 'closure_parameters' + ) + return 'binding'; + if ( + !['scoped_identifier', 'scoped_type_identifier', 'use_list', 'scoped_use_list'].includes( + parent.type, + ) + ) + break; + } + if (node.parent?.type === 'label') return 'unrelated'; + return 'name'; +} + +function reference(node: Parser.SyntaxNode, source: RenameSource) { + const parent = node.parent; + if ( + !parent || + node.type === 'field_identifier' || + parent.type === 'lifetime' || + parent.type === 'label' + ) + return undefined; + for (let ancestor: Parser.SyntaxNode | null = parent; ancestor; ancestor = ancestor.parent) + if (ancestor.type === 'token_tree' || ancestor.type === 'token_tree_pattern') return undefined; + if ( + (parent.childForFieldName('name')?.id === node.id && parent.type.endsWith('_item')) || + parent.childForFieldName('pattern')?.id === node.id + ) + return undefined; + const inScope = innermostRenameScope(source.parsed, node); + if (!inScope) return undefined; + const range = nodeRange(node); + return { + name: canonicalName(node.text), + nameRange: range, + atRange: range, + inScope, + kind: 'read' as const, + ...(node.type === 'type_identifier' ? { lookupPurpose: 'type' as const } : {}), + }; +} + +export const rustRenameProvider: RenameProvider = { + canonicalName, + // An indexed C source may call a function exported with `#[no_mangle] extern "C"`. + interoperatesWithSourceProvider: (provider) => provider.id === SupportedLanguages.C, + matchesGraphIdentity({ definition, definitions, node, graph }) { + let name = definition.qualifiedName; + if (!name) return false; + const owner = + definition.ownerId && definitions.find((def) => def.nodeId === definition.ownerId); + if (owner?.qualifiedName && !name.startsWith(`${owner.qualifiedName}.`)) + name = `${owner.qualifiedName}.${name}`; + if (definition.namespacePrefix && !name.startsWith(`${definition.namespacePrefix}.`)) + name = `${definition.namespacePrefix}.${name}`; + return ( + buildGraphNodeLookup(graph).get(qualifiedKey(definition.filePath, node.label, name)) === + node.id + ); + }, + symbolFamily(context) { + return graphRenameFamily(context, { + matches: (left, right) => + !!left.ownerId && + !!right.ownerId && + canonicalName(simpleQualifiedName(left) ?? '') === + canonicalName(simpleQualifiedName(right) ?? ''), + }); + }, + validateName(name) { + const raw = name.startsWith('r#'); + const normalized = canonicalName(name); + if ( + !/^[_\p{XID_Start}][_\p{XID_Continue}]*$/u.test(normalized) || + normalized === '_' || + (!raw && keywords.has(normalized)) || + (raw && ['crate', 'self', 'Self', 'super'].includes(normalized)) + ) + return { code: 'invalid_name', message: 'Choose a valid Rust identifier or raw identifier.' }; + }, + parseSource: createTreeSitterRenameSource({ + isName: (node) => names.has(node.type), + skip: (node) => ['line_comment', 'block_comment'].includes(node.type), + role, + canonicalName, + transformParsed: (parsed) => canonicalizeRenameBindings(parsed, canonicalName), + reference, + }), +}; diff --git a/gitnexus/src/core/ingestion/languages/swift.ts b/gitnexus/src/core/ingestion/languages/swift.ts index 1ae31679d..d0092c47b 100644 --- a/gitnexus/src/core/ingestion/languages/swift.ts +++ b/gitnexus/src/core/ingestion/languages/swift.ts @@ -6,6 +6,7 @@ */ import { SupportedLanguages } from 'gitnexus-shared'; +import { swiftRenameProvider } from './swift/rename.js'; import type { NodeLabel, SymbolDefinition } from 'gitnexus-shared'; import { createClassExtractor } from '../class-extractors/generic.js'; import { swiftClassConfig } from '../class-extractors/configs/swift.js'; @@ -177,6 +178,7 @@ const BUILT_INS: ReadonlySet = new Set([ ]); export const swiftProvider = defineLanguage({ + rename: swiftRenameProvider, id: SupportedLanguages.Swift, extensions: ['.swift'], preprocessSource: preprocessSwiftConditionalDirectives, diff --git a/gitnexus/src/core/ingestion/languages/swift/captures.ts b/gitnexus/src/core/ingestion/languages/swift/captures.ts index d555e634f..852d9c965 100644 --- a/gitnexus/src/core/ingestion/languages/swift/captures.ts +++ b/gitnexus/src/core/ingestion/languages/swift/captures.ts @@ -180,6 +180,26 @@ export function emitSwiftScopeCaptures( } if (Object.keys(grouped).length === 0) continue; + // The grammar gives an additive RHS call the whole binary-expression + // anchor. Start at its own callee so it cannot share the LHS call identity. + const additiveCall = grouped['@reference.additive-call']; + const additiveName = nodeMap['@reference.name']; + if (additiveCall !== undefined && additiveName !== undefined) { + const callNode = nodeMap['@reference.additive-call']; + grouped['@reference.call.free'] = { + ...additiveCall, + name: '@reference.call.free', + text: sourceText.slice(additiveName.startIndex, callNode.endIndex), + range: { + ...additiveCall.range, + startLine: additiveName.startPosition.row + 1, + startCol: additiveName.startPosition.column, + }, + }; + nodeMap['@reference.call.free'] = callNode; + delete grouped['@reference.additive-call']; + } + // ── Imports ────────────────────────────────────────────────────── if (grouped['@import.statement'] !== undefined) { const stmtNode = nodeIfType(nodeMap['@import.statement'], 'import_declaration'); diff --git a/gitnexus/src/core/ingestion/languages/swift/identifiers.ts b/gitnexus/src/core/ingestion/languages/swift/identifiers.ts new file mode 100644 index 000000000..675277e15 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/swift/identifiers.ts @@ -0,0 +1,3 @@ +/** Semantic identifier spelling; source graph names and ranges remain unchanged. */ +export const canonicalName = (name: string): string => + name.startsWith('`') && name.endsWith('`') ? name.slice(1, -1) : name; diff --git a/gitnexus/src/core/ingestion/languages/swift/query.ts b/gitnexus/src/core/ingestion/languages/swift/query.ts index 805329903..349aef08d 100644 --- a/gitnexus/src/core/ingestion/languages/swift/query.ts +++ b/gitnexus/src/core/ingestion/languages/swift/query.ts @@ -188,6 +188,14 @@ const SWIFT_SCOPE_QUERY = ` (call_expression (simple_identifier) @reference.name) @reference.call.free +;; The grammar attaches the final call suffix of \`left() + right(args)\` +;; to the additive expression. Swift applies that suffix to the RHS name. +;; Parenthesized callable expressions have a separate node and do not match. +(call_expression + (additive_expression + rhs: (simple_identifier) @reference.name) + (call_suffix)) @reference.additive-call + ;; Exact identity for \`let lhs = callee()\`. The call-expression anchor is ;; byte-identical to @reference.call.free / member for downstream position join. (property_declaration diff --git a/gitnexus/src/core/ingestion/languages/swift/rename.ts b/gitnexus/src/core/ingestion/languages/swift/rename.ts new file mode 100644 index 000000000..303697234 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/swift/rename.ts @@ -0,0 +1,273 @@ +import type Parser from 'tree-sitter'; +import { CLASS_KINDS, SupportedLanguages, type SymbolDefinition } from 'gitnexus-shared'; +import type { + RenameIssue, + RenameOccurrence, + RenameProvider, + RenameSemanticContext, + RenameSource, +} from '../../rename-provider.js'; +import { simpleQualifiedName } from '../../scope-resolution/graph-bridge/ids.js'; +import { + canonicalizeRenameBindings, + createTreeSitterRenameSource, + innermostRenameScope, + nodeRange, + rangeKey, +} from '../rename-tree-sitter.js'; +import { graphRenameFamily } from '../rename-family.js'; +import { canonicalName } from './identifiers.js'; + +const names = new Set(['simple_identifier', 'type_identifier']); +const keywords = new Set( + 'associatedtype class deinit enum extension fileprivate func import init inout internal let open operator private precedencegroup protocol public rethrows static struct subscript typealias var break case continue default defer do else fallthrough for guard if in repeat return switch where while as Any catch false is nil super self Self throw throws true try'.split( + ' ', + ), +); + +function role(node: Parser.SyntaxNode): RenameOccurrence['role'] { + const parent = node.parent; + if (!parent) return 'name'; + if (parent.type === 'parameter' && node.type === 'simple_identifier') return 'binding'; + if (parent.type === 'value_argument_label' || parent.type === 'statement_label') + return 'unrelated'; + for (let ancestor: Parser.SyntaxNode | null = parent; ancestor; ancestor = ancestor.parent) + if (ancestor.type === 'import_declaration') return 'imported'; + return 'name'; +} + +// Callees whose labels bind their own parameters, never a property. +const CALLABLE_KINDS: readonly string[] = ['Function', 'Method']; +// An alias or namespace hides the initializer a label would bind. +const OPAQUE_TYPE_KINDS: readonly string[] = [ + 'TypeAlias', + 'Typedef', + 'Namespace', + 'Template', + 'Category', +]; + +/** Two types can share one instance when either lies on the other's method resolution order. */ +function relatedTypes(context: RenameSemanticContext, left: string, right: string): boolean { + const { methodDispatch } = context.indexes; + return ( + left === right || + methodDispatch.mroFor(left).includes(right) || + methodDispatch.mroFor(right).includes(left) + ); +} + +/** The identifier an argument list calls; undefined when its syntax shape is not understood. */ +function calleeName(label: Parser.SyntaxNode): Parser.SyntaxNode | undefined { + const invocation = label.parent?.parent?.parent?.parent; + if (invocation?.type !== 'call_suffix') return undefined; + const callee = invocation.parent?.firstNamedChild; + const name = + callee?.type === 'navigation_expression' ? callee.lastNamedChild?.lastNamedChild : callee; + return name?.type === 'simple_identifier' ? name : undefined; +} + +/** + * Swift synthesizes a memberwise initializer, labeled by the stored properties, for a + * struct unless its own body declares an initializer (an extension does not suppress it). + */ +function synthesizesMemberwiseInit(declaration: Parser.SyntaxNode | undefined): boolean { + // An unreadable declaration cannot prove that the label binds only an explicit initializer. + if (!declaration) return true; + return ( + declaration.childForFieldName('declaration_kind')?.text === 'struct' && + !declaration + .childForFieldName('body') + ?.namedChildren.some((member) => member.type === 'init_declaration') + ); +} + +/** + * An argument label is an ordinary token for the callee's parameter, but the memberwise + * initializer of a struct labels its parameters with the stored property names. Only a + * callee proven to be another function, or a type without such an initializer, keeps + * the label unrelated. + */ +function checkArgumentLabels(context: RenameSemanticContext): RenameIssue | void { + const { target } = context; + const ownerId = target.ownerId; + if (!ownerId || target.type !== 'Property') return; + const names = new Set([ + canonicalName(simpleQualifiedName(target) ?? ''), + canonicalName(context.newName), + ]); + const declarations = new Map(); + const declarationOf = (type: SymbolDefinition): Parser.SyntaxNode | undefined => { + if (!declarations.has(type.nodeId)) + declarations.set( + type.nodeId, + context.files + .get(type.filePath) + ?.occurrences.find((occurrence) => occurrence.definitionId === type.nodeId)?.node + ?.parent ?? undefined, + ); + return declarations.get(type.nodeId); + }; + for (const source of context.files.values()) { + let occurrencesByRange: Map | undefined; + for (const occurrence of source.occurrences) { + const label = occurrence.node; + if ( + occurrence.role !== 'unrelated' || + !names.has(occurrence.name) || + label?.parent?.type !== 'value_argument_label' + ) + continue; + occurrencesByRange ??= new Map( + source.occurrences.map((candidate) => [rangeKey(candidate.range), candidate]), + ); + const name = calleeName(label); + const site = name && occurrencesByRange.get(rangeKey(nodeRange(name)))?.referenceSite; + const binding = site && context.referenceBindings.get(site); + const callees = + binding?.status === 'resolved' && binding.targetDefIds.length + ? binding.targetDefIds.map((id) => context.indexes.defs.get(id)) + : undefined; + const bindsProperty = (callee: SymbolDefinition | undefined): boolean => { + if (callee && CALLABLE_KINDS.includes(callee.type)) return false; + const type = + callee?.type === 'Constructor' && callee.ownerId + ? context.indexes.defs.get(callee.ownerId) + : callee; + return ( + !type || + !CLASS_KINDS.includes(type.type) || + OPAQUE_TYPE_KINDS.includes(type.type) || + (relatedTypes(context, type.nodeId, ownerId) && + synthesizesMemberwiseInit(declarationOf(type))) + ); + }; + if (callees && !callees.some(bindsProperty)) continue; + return { + code: 'ambiguous_reference', + message: `An argument label may bind the selected property or its destination name through a memberwise initializer: ${source.path}:${label.startPosition.row + 1}.`, + status: 'unsupported', + }; + } + } +} + +function reference(node: Parser.SyntaxNode, source: RenameSource) { + const parent = node.parent; + if ( + !parent || + (parent.childForFieldName('name')?.id === node.id && + /declaration|definition|signature|declarator/.test(parent.type)) + ) + return undefined; + // A qualified member has its own semantic receiver; never reinterpret it as a bare lexical read. + if (parent.type === 'navigation_suffix') return undefined; + if ( + ['navigation_expression'].includes(parent.type) && + parent.namedChildCount > 1 && + parent.lastNamedChild?.id === node.id + ) + return undefined; + const inScope = innermostRenameScope(source.parsed, node); + if (!inScope) return undefined; + const range = nodeRange(node); + return { + name: canonicalName(node.text), + atRange: range, + nameRange: range, + inScope, + kind: + node.type === 'type_identifier' || parent.type === 'user_type' + ? ('type-reference' as const) + : ('read' as const), + }; +} + +export const swiftRenameProvider: RenameProvider = { + canonicalName, + // Objective-C calls a Swift member exposed with `@objc` by its own name. + interoperatesWithSourceProvider: (provider) => provider.id === SupportedLanguages.ObjectiveC, + checkSafety: checkArgumentLabels, + validateName(name) { + const canonical = canonicalName(name); + if ( + !( + /^[_\p{ID_Start}][_\p{ID_Continue}]*$/u.test(canonical) && + (!keywords.has(canonical) || name.startsWith('`')) + ) + ) + return { code: 'invalid_name', message: 'Choose a valid nonreserved identifier.' }; + }, + parseSource: createTreeSitterRenameSource({ + isName: (node) => names.has(node.type), + skip: (node) => node.type.includes('comment'), + role, + canonicalName, + transformParsed: (parsed) => canonicalizeRenameBindings(parsed, canonicalName), + reference, + }), + rewrite({ source, occurrence, newName }) { + return occurrence.spans.map((span) => { + const oldText = source.content.slice(span.start, span.start + span.length); + return { + ...span, + oldText, + newText: oldText.startsWith('`') ? '`' + canonicalName(newName) + '`' : newName, + }; + }); + }, + symbolFamily(context) { + const name = (def: typeof context.target) => canonicalName(simpleQualifiedName(def) ?? ''); + const method = (def: typeof context.target) => + ['Method', 'Function'].includes(def.type) && !!def.ownerId; + const declarationGroup = (def: typeof context.target) => + context.files + .get(def.filePath) + ?.occurrences.find((occurrence) => occurrence.definitionId === def.nodeId)?.node?.parent + ?.parent?.id; + const declaration = (def: typeof context.target) => + context.files + .get(def.filePath) + ?.occurrences.find((occurrence) => occurrence.definitionId === def.nodeId)?.node?.parent; + const isInstance = (def: typeof context.target) => + !declaration(def)?.namedChildren.some( + (node) => + node.type === 'modifiers' && + node.children.some((child) => ['private', 'static'].includes(child.text)), + ); + return graphRenameFamily(context, { + requiresBaseMember: (def) => + declaration(def)?.namedChildren.some( + (node) => + node.type === 'modifiers' && node.children.some((child) => child.text === 'override'), + ) === true, + matches: (left, right) => + method(left) && + method(right) && + isInstance(left) && + isInstance(right) && + name(left) === name(right), + sameDeclarationGroup: (left, right) => + (['Method', 'Function'].includes(left.type) && + ['Method', 'Function'].includes(right.type) && + !left.ownerId && + !right.ownerId && + left.filePath === right.filePath && + declarationGroup(left) !== undefined && + declarationGroup(left) === declarationGroup(right) && + name(left) === name(right)) || + (method(left) && + method(right) && + left.ownerId === right.ownerId && + name(left) === name(right)) || + (CLASS_KINDS.includes(left.type) && + right.type === 'Constructor' && + right.ownerId === left.nodeId && + name(left) === name(right)) || + (CLASS_KINDS.includes(right.type) && + left.type === 'Constructor' && + left.ownerId === right.nodeId && + name(left) === name(right)), + }); + }, +}; diff --git a/gitnexus/src/core/ingestion/languages/swift/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/swift/scope-resolver.ts index 7fe075abf..f554ef4d3 100644 --- a/gitnexus/src/core/ingestion/languages/swift/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/swift/scope-resolver.ts @@ -56,6 +56,11 @@ import { resolveDefGraphId } from '../../scope-resolution/graph-bridge/ids.js'; import type { GraphNodeLookup } from '../../scope-resolution/graph-bridge/node-lookup.js'; import type { KnowledgeGraph } from '../../../graph/types.js'; import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js'; +import { + canonicalizeWorkspaceBindings, + canonicalModuleExports, +} from '../../scope-resolution/scope/canonicalize-bindings.js'; +import { canonicalName } from './identifiers.js'; import { swiftProvider } from '../swift.js'; import { swiftArityCompatibility, @@ -75,6 +80,9 @@ import { swiftIsCallableVisibleFromCaller } from './callable-visibility.js'; const ZERO_RANGE = { startLine: 0, startCol: 0, endLine: 0, endCol: 0 } as const; const swiftScopeResolver: ScopeResolver = { + moduleExports: (parsed) => canonicalModuleExports(parsed, canonicalName), + populateWorkspaceReferences: (parsedFiles) => + canonicalizeWorkspaceBindings(parsedFiles, canonicalName), language: SupportedLanguages.Swift, languageProvider: swiftProvider, importEdgeReason: 'swift-scope: import', @@ -149,6 +157,10 @@ const swiftScopeResolver: ScopeResolver = { fieldFallbackOnMethodLookup: false, propagatesReturnTypesAcrossImports: true, + // Unqualified methods need a reachable owner; retain the full lexical + // candidate set so the shared resolver also narrows free overloads by arity. + freeCallsRequireInstanceOwnership: true, + // Swift has no `new` keyword: `UserService()` is a bare call that // resolves to the type's Constructor/Class. With whole-module sibling // visibility, the callee is reachable workspace-wide, so allow the diff --git a/gitnexus/src/core/ingestion/languages/typescript.ts b/gitnexus/src/core/ingestion/languages/typescript.ts index c3509ea5e..c5f9ff5d9 100644 --- a/gitnexus/src/core/ingestion/languages/typescript.ts +++ b/gitnexus/src/core/ingestion/languages/typescript.ts @@ -131,6 +131,7 @@ import { extractNestRoutes } from '../route-extractors/nest.js'; import { extractTrpcRoutes, shouldScanForTrpcRoutes } from '../route-extractors/trpc.js'; import { extractConvexEndpointProperties } from './typescript/convex-endpoint-metadata.js'; import { extractToolDefinitions } from './typescript/tool-definitions.js'; +import { typescriptRenameProvider } from './typescript/rename.js'; const extractJsTsRoutes = (...args: Parameters) => [ ...extractDispatchGuardRoutes(...args), @@ -402,6 +403,7 @@ const tsScopeOwnsReceivers = (match: CaptureMatch): ReadonlySet | undefi export const typescriptProvider = defineLanguage({ id: SupportedLanguages.TypeScript, + rename: typescriptRenameProvider, extensions: ['.ts', '.tsx', '.mts', '.cts'], entryPointPatterns: [/^use[A-Z]/], astFrameworkPatterns: [ @@ -500,6 +502,7 @@ export const typescriptProvider = defineLanguage({ export const javascriptProvider = defineLanguage({ id: SupportedLanguages.JavaScript, + rename: typescriptRenameProvider, extensions: ['.js', '.jsx', '.mjs', '.cjs'], entryPointPatterns: [/^use[A-Z]/], astFrameworkPatterns: [ diff --git a/gitnexus/src/core/ingestion/languages/typescript/identifiers.ts b/gitnexus/src/core/ingestion/languages/typescript/identifiers.ts new file mode 100644 index 000000000..b493810b9 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/typescript/identifiers.ts @@ -0,0 +1,9 @@ +/** Decode ECMAScript identifier escapes without changing written graph identities. */ +export const canonicalTypeScriptName = (name: string): string => + name.replace( + /\\u(?:\{([0-9a-fA-F]+)\}|([0-9a-fA-F]{4}))/g, + (escape, braced: string | undefined, fixed: string | undefined) => { + const value = Number.parseInt(braced ?? fixed!, 16); + return value <= 0x10ffff ? String.fromCodePoint(value) : escape; + }, + ); diff --git a/gitnexus/src/core/ingestion/languages/typescript/interpret.ts b/gitnexus/src/core/ingestion/languages/typescript/interpret.ts index ad26309f7..056dbe7dc 100644 --- a/gitnexus/src/core/ingestion/languages/typescript/interpret.ts +++ b/gitnexus/src/core/ingestion/languages/typescript/interpret.ts @@ -22,6 +22,7 @@ import type { ScopeId, TypeRef, } from 'gitnexus-shared'; +import { canonicalTypeScriptName } from './identifiers.js'; /** Shared empty result for the non-type-only path — see `typeOnly` below. */ const NO_TYPE_ONLY: { typeOnly?: true } = Object.freeze({}); @@ -72,13 +73,15 @@ export function interpretTsImport(captures: CaptureMatch): ParsedImport | null { }; } case 'named': { + // Module keys use their semantic identifier spelling; local bindings keep + // their written spelling to match ordinary scope captures and graph IDs. // `import { X } from './m'` (plus type-only forms). if (sourceCap === undefined || nameCap === undefined) return null; return { kind: 'named', ...lexicalOwner, localName: nameCap.text, - importedName: nameCap.text, + importedName: canonicalTypeScriptName(nameCap.text), targetRaw: sourceCap.text, ...typeOnly, }; @@ -92,7 +95,7 @@ export function interpretTsImport(captures: CaptureMatch): ParsedImport | null { kind: 'alias', ...lexicalOwner, localName: aliasCap.text, - importedName: nameCap.text, + importedName: canonicalTypeScriptName(nameCap.text), alias: aliasCap.text, targetRaw: sourceCap.text, ...typeOnly, @@ -111,12 +114,14 @@ export function interpretTsImport(captures: CaptureMatch): ParsedImport | null { }; } case 'reexport': { + // Re-export names are public module keys, not local lexical bindings. + // Decode them just like the names requested from the source module. // `export { X } from './m'`. if (sourceCap === undefined || nameCap === undefined) return null; return { kind: 'reexport', - localName: nameCap.text, - importedName: nameCap.text, + localName: canonicalTypeScriptName(nameCap.text), + importedName: canonicalTypeScriptName(nameCap.text), targetRaw: sourceCap.text, ...typeOnly, }; @@ -128,9 +133,9 @@ export function interpretTsImport(captures: CaptureMatch): ParsedImport | null { } return { kind: 'reexport', - localName: aliasCap.text, - importedName: nameCap.text, - alias: aliasCap.text, + localName: canonicalTypeScriptName(aliasCap.text), + importedName: canonicalTypeScriptName(nameCap.text), + alias: canonicalTypeScriptName(aliasCap.text), targetRaw: sourceCap.text, ...typeOnly, }; diff --git a/gitnexus/src/core/ingestion/languages/typescript/module-exports.ts b/gitnexus/src/core/ingestion/languages/typescript/module-exports.ts new file mode 100644 index 000000000..15d10e851 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/typescript/module-exports.ts @@ -0,0 +1,17 @@ +import type { ParsedFile, SymbolDefinition } from 'gitnexus-shared'; +import { indexTopLevelExportsByName } from 'gitnexus-shared'; +import { canonicalTypeScriptName } from './identifiers.js'; + +/** Keep written graph identities while exposing canonical ECMAScript export names. */ +export function typescriptModuleExports(parsed: ParsedFile): ReadonlyMap { + const exports = new Map(); + const ambiguous = new Set(); + for (const [spelling, definition] of indexTopLevelExportsByName(parsed.localDefs)) { + const name = canonicalTypeScriptName(spelling); + const previous = exports.get(name); + if (previous && previous.nodeId !== definition.nodeId) ambiguous.add(name); + else exports.set(name, definition); + } + for (const name of ambiguous) exports.delete(name); + return exports; +} diff --git a/gitnexus/src/core/ingestion/languages/typescript/rename.ts b/gitnexus/src/core/ingestion/languages/typescript/rename.ts new file mode 100644 index 000000000..a443cc3cd --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/typescript/rename.ts @@ -0,0 +1,1001 @@ +import type Parser from 'tree-sitter'; +import { + CLASS_KINDS, + SupportedLanguages, + type Range, + type ReferenceSite, + type SymbolDefinition, + type TypeRef, +} from 'gitnexus-shared'; +import type { + RenameIssue, + RenameOccurrence, + RenameOccurrenceContext, + RenameProvider, + RenameSemanticContext, + RenameSource, +} from '../../rename-provider.js'; +import { lookupNameClaim } from '../../scope-resolution/scope/walkers.js'; +import { + canonicalizeRenameBindings, + containsRange, + createTreeSitterRenameSource, + innermostRenameScope, + nodeRange, + rangeKey, + walkNamedNodes, +} from '../rename-tree-sitter.js'; +import { graphRenameFamily } from '../rename-family.js'; +import { resolveDefGraphId, simpleQualifiedName } from '../../scope-resolution/graph-bridge/ids.js'; +import { buildGraphNodeLookup } from '../../scope-resolution/graph-bridge/node-lookup.js'; +import type { KnowledgeGraph } from '../../../graph/types.js'; +import { canonicalTypeScriptName } from './identifiers.js'; +import { decodeReceiverChain } from '../../utils/receiver-chain-codec.js'; + +export { canonicalTypeScriptName } from './identifiers.js'; + +const receiverGraphLookups = new WeakMap>(); +// Each rename plan owns one immutable source workspace and its lexical indexes. +const importedReceiverWrites = new WeakMap< + RenameOccurrenceContext['files'], + Map +>(); + +const intermediateFieldSafety = new WeakMap< + RenameOccurrenceContext['files'], + Map +>(); + +const names = new Set([ + 'identifier', + 'type_identifier', + 'property_identifier', + 'private_property_identifier', + 'shorthand_property_identifier', + 'shorthand_property_identifier_pattern', + 'statement_identifier', +]); +const keywordNames = new Set( + 'break case catch class const continue debugger default delete do else enum export extends false finally for function if import in instanceof new null return super switch this throw true try typeof var void while with yield implements interface let package private protected public static await'.split( + ' ', + ), +); +const issue = (message: string, code = 'unsupported_occurrence'): RenameIssue => ({ + code, + message, + status: 'unsupported', +}); +const isReceiverIssue = (value: SymbolDefinition | RenameIssue | undefined): value is RenameIssue => + value !== undefined && 'code' in value; + +function jsxTagRole(node: Parser.SyntaxNode): 'intrinsic' | 'component' | undefined { + const parent = node.parent; + if ( + parent && + ['jsx_opening_element', 'jsx_closing_element', 'jsx_self_closing_element'].includes( + parent.type, + ) && + parent.childForFieldName('name')?.id === node.id + ) + return /^[a-z]/.test(node.text) ? 'intrinsic' : 'component'; + return undefined; +} + +function role(node: Parser.SyntaxNode): RenameOccurrence['role'] { + const parent = node.parent; + if (!parent) return 'name'; + if (parent.type === 'import_specifier') + return parent.childForFieldName('alias')?.id === node.id ? 'alias' : 'imported'; + if (parent.type === 'export_specifier' && parent.parent?.parent?.childForFieldName('source')) + return parent.childForFieldName('alias')?.id === node.id ? 'alias' : 'imported'; + if ( + node.type === 'statement_identifier' || + jsxTagRole(node) === 'intrinsic' || + (parent.type === 'jsx_attribute' && parent.firstNamedChild?.id === node.id) || + parent.childForFieldName('key')?.id === node.id || + (parent.type === 'export_specifier' && parent.childForFieldName('alias')?.id === node.id) + ) + return 'unrelated'; + if ( + ((parent.type === 'required_parameter' || parent.type === 'optional_parameter') && + parent.childForFieldName('pattern')?.id === node.id) || + parent.type === 'formal_parameters' || + (parent.type === 'arrow_function' && parent.childForFieldName('parameter')?.id === node.id) + ) + return 'binding'; + return 'name'; +} + +function bareReference(node: Parser.SyntaxNode, source: RenameSource) { + if (node.type !== 'identifier' && node.type !== 'shorthand_property_identifier') return undefined; + const parent = node.parent; + if ( + !parent || + parent.type.includes('import') || + parent.type.includes('pattern') || + (parent.childForFieldName('name')?.id === node.id && + /declaration|definition|declarator/.test(parent.type)) + ) + return undefined; + // An export specifier with a source names an imported symbol, handled by imports. + if (parent.type === 'export_specifier' && parent.parent?.parent?.childForFieldName('source')) + return undefined; + const scope = innermostRenameScope(source.parsed, node); + if (!scope) return undefined; + const range = nodeRange(node); + return { + name: canonicalTypeScriptName(node.text), + atRange: range, + nameRange: range, + inScope: scope, + kind: 'read' as const, + }; +} + +// A parse tree is immutable, so its declarators are indexed by range once +// instead of being rediscovered by a full walk for every checked call. +const declaratorsByRange = new WeakMap>(); + +function declaratorsAt(tree: Parser.Tree, range: Range): readonly Parser.SyntaxNode[] { + let index = declaratorsByRange.get(tree); + if (!index) { + index = new Map(); + for (const node of walkNamedNodes(tree.rootNode)) { + if (node.type !== 'variable_declarator') continue; + const key = rangeKey(nodeRange(node)); + const group = index.get(key); + if (group) group.push(node); + else index.set(key, [node]); + } + declaratorsByRange.set(tree, index); + } + return index.get(rangeKey(range)) ?? []; +} + +/** The constructor expression that initializes a receiver, with the facts every + * call through that receiver would otherwise recompute from the syntax tree. */ +interface ReceiverConstructor { + readonly node: Parser.SyntaxNode; + /** The declarator name this constructor initializes. */ + readonly declaredNameId: number | undefined; + readonly hoistedScope: number | undefined; +} + +const receiverConstructors = new WeakMap>(); + +function receiverConstructor( + tree: Parser.Tree, + name: string, + type: TypeRef, +): ReceiverConstructor | undefined { + if (!type.bindingRange) return undefined; + let known = receiverConstructors.get(tree); + if (!known) { + known = new Map(); + receiverConstructors.set(tree, known); + } + const key = `${rangeKey(type.bindingRange)}\0${name}`; + const cached = known.get(key); + if (cached !== undefined) return cached ?? undefined; + let found: ReceiverConstructor | undefined; + for (const declarator of declaratorsAt(tree, type.bindingRange)) { + const value = declarator.childForFieldName('value'); + if ( + canonicalTypeScriptName(declarator.childForFieldName('name')?.text ?? '') !== name || + value?.type !== 'new_expression' + ) + continue; + const node = value.childForFieldName('constructor'); + if (node) + found = { + node, + declaredNameId: node.parent?.parent?.childForFieldName('name')?.id, + hoistedScope: hoistedVarScope(node), + }; + break; + } + known.set(key, found ?? null); + return found; +} + +function receiverWrite(token: Parser.SyntaxNode): boolean { + for (let node = token.parent; node; node = node.parent) { + if (node.type === 'member_expression' || node.type === 'subscript_expression') return false; + if (node.type === 'variable_declarator') { + const name = node.childForFieldName('name'); + return ( + !!name && + containsRange(nodeRange(name), nodeRange(token)) && + node.childForFieldName('value') !== null + ); + } + if (node.type.includes('assignment') || node.type === 'for_in_statement') { + const left = node.childForFieldName('left'); + if (left && containsRange(nodeRange(left), nodeRange(token))) return true; + } + if (node.type === 'update_expression') return true; + if (node.type.includes('statement') || node.type.includes('declaration')) return false; + } + return false; +} + +function hoistedVarScope(token: Parser.SyntaxNode): number | undefined { + let declaration: Parser.SyntaxNode | null = token; + while (declaration && declaration.type !== 'variable_declarator') { + if (/statement|declaration/.test(declaration.type)) return undefined; + declaration = declaration.parent; + } + if (!declaration?.parent?.children.some((child) => child.type === 'var')) return undefined; + for (let node: Parser.SyntaxNode | null = declaration.parent; node; node = node.parent) + if ( + [ + 'program', + 'function_declaration', + 'function_expression', + 'arrow_function', + 'generator_function_declaration', + 'generator_function', + 'method_definition', + 'class_static_block', + ].includes(node.type) + ) + return node.id; + return undefined; +} + +/** What one write to a receiver name means, whichever binding asks about it. */ +interface ReceiverWrite { + readonly nodeId: number; + readonly hoistedScope: number | undefined; + /** The scope owning the written binding; undefined when no declaration claims it. */ + readonly writtenScope: string | undefined; +} + +interface ReceiverWriteIndex { + readonly occurrencesByName: ReadonlyMap; + readonly writesByName: Map; +} + +// One immutable source and its lexical indexes serve a whole plan, so every +// checked call shares these instead of rescanning each occurrence of the file. +const receiverWriteIndexes = new WeakMap< + RenameSemanticContext['indexes'], + WeakMap +>(); + +function receiverWritesOf( + context: RenameSemanticContext, + source: RenameSource, + name: string, +): readonly ReceiverWrite[] { + let bySource = receiverWriteIndexes.get(context.indexes); + if (!bySource) { + bySource = new WeakMap(); + receiverWriteIndexes.set(context.indexes, bySource); + } + let index = bySource.get(source); + if (!index) { + const occurrencesByName = new Map(); + for (const occurrence of source.occurrences) { + const group = occurrencesByName.get(occurrence.name); + if (group) group.push(occurrence); + else occurrencesByName.set(occurrence.name, [occurrence]); + } + index = { occurrencesByName, writesByName: new Map() }; + bySource.set(source, index); + } + const known = index.writesByName.get(name); + if (known) return known; + const writes: ReceiverWrite[] = []; + for (const occurrence of index.occurrencesByName.get(name) ?? []) { + const node = occurrence.node; + if (!node || !receiverWrite(node)) continue; + const scope = innermostRenameScope(source.parsed, node); + const written = + scope && + lookupNameClaim(scope, name, context.indexes, { + position: nodeRange(node), + purpose: 'value', + }); + writes.push({ + nodeId: node.id, + hoistedScope: hoistedVarScope(node), + writtenScope: + written && written.scope && written.claims.length > 0 ? written.scope.id : undefined, + }); + } + index.writesByName.set(name, writes); + return writes; +} + +function importedReceiverIsReassigned( + context: RenameSemanticContext & { readonly site?: ReferenceSite }, + source: RenameSource, + declarationId: string, +): boolean { + let cache = importedReceiverWrites.get(context.files); + if (!cache) { + cache = new Map(); + importedReceiverWrites.set(context.files, cache); + } + const cached = cache.get(declarationId); + if (cached !== undefined) return cached; + for (const other of context.files.values()) { + if (other === source) continue; + for (const occurrence of other.occurrences) { + const node = occurrence.node; + if (!node || !receiverWrite(node)) continue; + const scope = innermostRenameScope(other.parsed, node); + let written = scope + ? lookupNameClaim(scope, occurrence.name, context.indexes, { + position: nodeRange(node), + purpose: 'value', + }) + : undefined; + // Assignment inference describes a value, not a new lexical declaration. + // Continue only when the provider's complete claims prove this scope + // declares no binding; the eventual import must still resolve by identity. + while ( + written?.scope?.lookupPolicy?.nameClaimsComplete && + written.claims.length === 0 && + written.bindings.length === 0 && + written.imports.length === 0 && + (written.typeBinding?.source === 'constructor-inferred' || + written.typeBinding?.source === 'assignment-inferred') + ) { + const parent = written.scope.lookupPolicy.parentScope ?? written.scope.parent; + if (!parent) break; + written = lookupNameClaim(parent, occurrence.name, context.indexes, { + // Resolve the binding being written, before this write's blocker. + position: nodeRange(node), + purpose: 'value', + }); + } + if (written && written.bindings.some(({ def }) => def.nodeId === declarationId)) { + cache.set(declarationId, true); + return true; + } + } + } + cache.set(declarationId, false); + return false; +} + +function constructorOwner( + context: RenameSemanticContext & { readonly site?: ReferenceSite }, + source: RenameSource, + constructor: Parser.SyntaxNode | undefined, +): SymbolDefinition | undefined { + const scope = constructor && innermostRenameScope(source.parsed, constructor); + const bindings = + constructor?.type === 'identifier' && scope + ? lookupNameClaim(scope, canonicalTypeScriptName(constructor.text), context.indexes, { + position: nodeRange(constructor), + purpose: 'value', + }).bindings + : []; + const owners = new Map( + bindings + .filter(({ def }) => CLASS_KINDS.includes(def.type)) + .map(({ def }) => [def.nodeId, def]), + ); + return owners.size === 1 ? owners.values().next().value : undefined; +} + +function provesReceiverType( + context: RenameSemanticContext & { readonly site?: ReferenceSite }, + constructedOwner: SymbolDefinition, + type: TypeRef, + position = context.site?.atRange, +): boolean { + const expected = lookupNameClaim(type.declaredAtScope, type.rawName, context.indexes, { + position: type.lookupPosition ?? position, + purpose: 'type', + }).bindings; + const ancestry = new Set([ + constructedOwner.nodeId, + ...context.indexes.methodDispatch.mroFor(constructedOwner.nodeId), + ]); + let provenAnnotation = expected.some( + ({ def }) => + ancestry.has(def.nodeId) || + context.indexes.methodDispatch + .implementorsOf(def.nodeId) + .some((owner) => ancestry.has(owner)), + ); + if (!provenAnnotation) { + let lookup = receiverGraphLookups.get(context.graph); + if (!lookup) { + lookup = buildGraphNodeLookup(context.graph); + receiverGraphLookups.set(context.graph, lookup); + } + const ancestors = new Set( + context.definitions + .filter((definition) => ancestry.has(definition.nodeId)) + .map((definition) => resolveDefGraphId(definition.filePath, definition, lookup)), + ); + const declaredOwners = new Set( + expected.map(({ def }) => resolveDefGraphId(def.filePath, def, lookup)), + ); + ancestors.delete(undefined); + declaredOwners.delete(undefined); + // An annotation alone is structural. A constructor plus an explicit + // implementation edge proves which declaration family owns this value. + for (const edge of context.graph.iterRelationshipsByType('IMPLEMENTS')) { + if (ancestors.has(edge.sourceId) && declaredOwners.has(edge.targetId)) { + provenAnnotation = true; + break; + } + } + } + return provenAnnotation; +} + +function checkReceiverBinding( + context: RenameSemanticContext & { readonly site?: ReferenceSite }, + name: string, + site: Pick | undefined = context.site, +): SymbolDefinition | RenameIssue | undefined { + const { indexes, files } = context; + if (!site) return; + const claim = lookupNameClaim(site.inScope, name, indexes, { + position: site.atRange, + purpose: 'value', + }); + const type = claim.typeBinding; + if (!type) return; + const imported = + claim.imports.length > 0 + ? new Map(claim.bindings.map(({ def }) => [def.nodeId, def])) + : undefined; + if (imported && imported.size !== 1) + return issue('The imported receiver has no unique declaration.', 'ambiguous_reference'); + const importedDefinition = imported?.values().next().value; + const bindingName = importedDefinition + ? canonicalTypeScriptName(simpleQualifiedName(importedDefinition) ?? '') + : name; + const sourcePath = importedDefinition?.filePath ?? claim.scope?.filePath; + const source = sourcePath && files.get(sourcePath); + if (!source?.tree) + return issue('The receiver has no inspectable source binding.', 'ambiguous_reference'); + if ( + type.source === 'decorator-unknown' || + (type.declaredSpelling && type.declaredSpelling !== type.rawName) + ) + return issue('The receiver type does not prove an object identity.', 'ambiguous_reference'); + const receiver = receiverConstructor(source.tree, bindingName, type); + const constructor = receiver?.node; + if ( + importedDefinition && + !constructor?.parent?.parent?.parent?.children.some((child) => child.type === 'const') + ) + return issue('The imported receiver is not a constructor-proven const.', 'ambiguous_reference'); + const owner = + type.source === 'self' + ? lookupNameClaim(type.declaredAtScope, type.rawName, indexes, { + position: type.lookupPosition ?? site.atRange, + purpose: 'type', + }).bindings.find(({ def }) => CLASS_KINDS.includes(def.type))?.def + : constructorOwner(context, source, constructor); + if (type.source !== 'self' && (!owner || !provesReceiverType(context, owner, type, site.atRange))) + return issue( + 'A structural type annotation cannot prove the receiver constructor.', + 'ambiguous_reference', + ); + const bindingScope = constructor + ? innermostRenameScope(source.parsed, constructor) + : claim.scope?.id; + for (const write of receiverWritesOf(context, source, bindingName)) { + if (write.nodeId === receiver?.declaredNameId) continue; + if ( + constructor && + write.hoistedScope !== undefined && + write.hoistedScope === receiver?.hoistedScope + ) + return issue('A hoisted receiver binding is reassigned.', 'ambiguous_reference'); + if (write.writtenScope === undefined || write.writtenScope === bindingScope) + return issue('The receiver binding is reassigned.', 'ambiguous_reference'); + } + if ( + importedDefinition && + importedReceiverIsReassigned(context, source, importedDefinition.nodeId) + ) + return issue('The imported receiver binding is reassigned.', 'ambiguous_reference'); + return owner; +} + +function ownedMembers( + context: RenameSemanticContext & { readonly site?: ReferenceSite }, + owner: SymbolDefinition, + name: string, +): readonly SymbolDefinition[] { + for (const ownerId of [owner.nodeId, ...context.indexes.methodDispatch.mroFor(owner.nodeId)]) { + const members = new Map( + [ + ...context.model.fields.lookupAllByOwner(ownerId, name), + ...context.model.methods.lookupAllByOwner(ownerId, name), + ] + .filter((def) => context.indexes.defs.has(def.nodeId)) + .map((def) => [def.nodeId, def]), + ); + if (members.size) return [...members.values()]; + } + return []; +} + +function isMemberWrite(member: Parser.SyntaxNode): boolean { + for (let parent = member.parent; parent; parent = parent.parent) { + // Writing a descendant does not replace this receiver slot. + if (parent.type === 'member_expression' || parent.type === 'subscript_expression') return false; + if (parent.type.includes('assignment') || parent.type === 'for_in_statement') { + const left = parent.childForFieldName('left'); + return !!left && containsRange(nodeRange(left), nodeRange(member)); + } + if (parent.type === 'update_expression') return true; + if (parent.type === 'unary_expression') + return parent.children.some((child) => child.text === 'delete'); + if (parent.type.includes('statement') || parent.type.includes('declaration')) return false; + } + return false; +} + +function hasUnrelatedConstructedReceiver( + context: RenameOccurrenceContext, + source: RenameSource, + member: Parser.SyntaxNode, + field: SymbolDefinition, +): boolean { + const receiver = member.childForFieldName('object'); + if (!receiver || !['identifier', 'this', 'super'].includes(receiver.type)) return false; + const inScope = innermostRenameScope(source.parsed, receiver); + if (!inScope) return false; + const owner = checkReceiverBinding(context, canonicalTypeScriptName(receiver.text), { + inScope, + atRange: nodeRange(receiver), + }); + if (!owner || isReceiverIssue(owner)) return false; + const members = ownedMembers( + context, + owner, + canonicalTypeScriptName(simpleQualifiedName(field) ?? ''), + ); + return members.length > 0 && members.every((candidate) => candidate.nodeId !== field.nodeId); +} + +function checkIntermediateField( + context: RenameOccurrenceContext, + field: SymbolDefinition, +): RenameIssue | void { + let cache = intermediateFieldSafety.get(context.files); + if (!cache) { + cache = new Map(); + intermediateFieldSafety.set(context.files, cache); + } + if (cache.has(field.nodeId)) return cache.get(field.nodeId) ?? undefined; + const name = canonicalTypeScriptName(simpleQualifiedName(field) ?? ''); + // The same provider policy protects both selected members and the mutable + // slots that establish a selected member's receiver identity. + let problem = typescriptRenameProvider.checkSafety?.({ + ...context, + target: field, + newName: name, + }); + for (const source of context.files.values()) { + if (problem) break; + if (source.tree) { + for (const node of walkNamedNodes(source.tree.rootNode)) { + if (node.type !== 'subscript_expression' || !isMemberWrite(node)) continue; + const key = node.childForFieldName('index'); + if (key?.type === 'number') continue; + if (key?.type === 'string' && !key.text.includes('\\') && key.text.slice(1, -1) !== name) + continue; + if (hasUnrelatedConstructedReceiver(context, source, node, field)) continue; + problem = issue('A computed access may replace an intermediate receiver field.'); + break; + } + } + if (problem) break; + for (const occurrence of source.occurrences) { + const member = occurrence.node?.parent; + if ( + occurrence.name !== name || + member?.type !== 'member_expression' || + member.childForFieldName('property')?.id !== occurrence.node?.id || + !isMemberWrite(member) + ) + continue; + const binding = + occurrence.referenceSite && context.referenceBindings.get(occurrence.referenceSite); + if ( + binding?.status === 'resolved' && + binding.targetDefIds.length > 0 && + binding.targetDefIds.every((id) => id !== field.nodeId) && + hasUnrelatedConstructedReceiver(context, source, member, field) + ) + continue; + problem = issue( + 'An intermediate receiver field is reassigned or has an unproven write.', + 'ambiguous_reference', + ); + break; + } + } + cache.set(field.nodeId, problem ? problem : null); + return problem; +} + +function checkReceiver(context: RenameOccurrenceContext): RenameIssue | void { + const { site } = context; + if (!site?.explicitReceiver) return; + const chain = decodeReceiverChain(site.receiverChain); + const base = checkReceiverBinding( + context, + chain ? canonicalTypeScriptName(chain.baseReceiverName) : site.explicitReceiver.name, + ); + if (isReceiverIssue(base)) return base; + if (!chain) { + const receiver = context.occurrence.node?.parent?.childForFieldName('object'); + if ( + site.receiverChain || + (receiver && !['identifier', 'this', 'super'].includes(receiver.type)) + ) + return issue('The receiver chain has no complete constructor proof.', 'ambiguous_reference'); + return; + } + if (!base || chain.truncated || chain.steps.some((step) => step.kind !== 'field')) + return issue('The receiver chain has no complete constructor proof.', 'ambiguous_reference'); + let owner = base; + for (const step of chain.steps) { + if (step.kind !== 'field') + return issue('The receiver chain has no complete constructor proof.', 'ambiguous_reference'); + const members = ownedMembers(context, owner, canonicalTypeScriptName(step.name)); + const field = members.length === 1 ? members[0] : undefined; + const source = field && context.files.get(field.filePath); + const declaration = source?.occurrences.find( + (occurrence) => occurrence.definitionId === field?.nodeId, + )?.node?.parent; + const value = declaration?.childForFieldName('value'); + const constructor = + value?.type === 'new_expression' ? value.childForFieldName('constructor') : undefined; + const constructed = source && constructorOwner(context, source, constructor ?? undefined); + const scope = source && declaration && innermostRenameScope(source.parsed, declaration); + const type = + scope && + context.indexes.scopeTree + .getScope(scope) + ?.typeBindings.get(canonicalTypeScriptName(step.name)); + if ( + !field || + !constructed || + !type || + declaration?.children.some((node) => node.text === 'static') || + (type.declaredSpelling && type.declaredSpelling !== type.rawName) || + !provesReceiverType(context, constructed, type) + ) + return issue( + 'An intermediate receiver field has no proven constructor identity.', + 'ambiguous_reference', + ); + if ( + step === chain.steps[0] && + (chain.baseReceiverName === 'this' || chain.baseReceiverName === 'super') + ) { + // An inherited method can run on a descendant whose field initializer + // replaces this slot. Only a concrete constructor fixes the root owner. + for (const [descendantId, ancestors] of context.indexes.methodDispatch.mroByOwnerDefId) { + if (!ancestors.includes(base.nodeId)) continue; + const descendant = context.indexes.defs.get(descendantId); + const inherited = + descendant && ownedMembers(context, descendant, canonicalTypeScriptName(step.name)); + if (!inherited || inherited.length !== 1 || inherited[0]?.nodeId !== field.nodeId) + return issue( + 'A descendant replaces a polymorphic intermediate receiver field.', + 'ambiguous_reference', + ); + } + } + const problem = checkIntermediateField(context, field); + if (problem) return problem; + owner = constructed; + } + const members = ownedMembers(context, owner, site.name); + if (members.length > 0 && members.every((member) => context.targetDefIds.includes(member.nodeId))) + return; + // An explicit override/implementation family can have a different written + // declaration from the concrete constructor's member. + const family = context.selected && typescriptRenameProvider.symbolFamily?.(context); + if ( + Array.isArray(family) && + members.length > 0 && + members.every((member) => family.some((def) => def.nodeId === member.nodeId)) + ) + return; + return issue( + 'The receiver chain does not prove the written member identity.', + 'ambiguous_reference', + ); +} + +function isConstantLiteralReceiver( + node: Parser.SyntaxNode, + source: RenameSource, + context: Parameters>[0], +): boolean { + const scope = innermostRenameScope(source.parsed, node); + if (!scope) return false; + const claim = lookupNameClaim(scope, canonicalTypeScriptName(node.text), context.indexes, { + position: nodeRange(node), + purpose: 'value', + }); + if (claim.bindings.length !== 1) return false; + const def = claim.bindings[0]!.def; + const bindingSource = context.files.get(def.filePath); + const declaration = bindingSource?.occurrences.find( + (occurrence) => occurrence.definitionId === def.nodeId, + )?.node?.parent; + return ( + declaration?.type === 'variable_declarator' && + declaration.parent?.children.some((child) => child.type === 'const') === true && + ['array', 'object', 'string', 'number', 'true', 'false', 'null'].includes( + declaration.childForFieldName('value')?.type ?? '', + ) + ); +} + +/** Keys carry a member identity independently of any value or local binding. */ +function memberKey(node: Parser.SyntaxNode): Parser.SyntaxNode | undefined { + if (node.type === 'pair' || node.type === 'pair_pattern') + return node.childForFieldName('key') ?? undefined; + if (node.type === 'method_definition' && node.parent?.type === 'object') + return node.childForFieldName('name') ?? undefined; + if ( + node.type === 'shorthand_property_identifier' || + node.type === 'shorthand_property_identifier_pattern' + ) + return node; + return undefined; +} + +function keyMayMatch(key: Parser.SyntaxNode, names: ReadonlySet): boolean { + if (key.type === 'computed_property_name') + return !key.firstNamedChild || keyMayMatch(key.firstNamedChild, names); + if (key.type === 'number') return false; + if (key.type === 'string') return key.text.includes('\\') || names.has(key.text.slice(1, -1)); + if ( + key.type === 'property_identifier' || + key.type === 'shorthand_property_identifier' || + key.type === 'shorthand_property_identifier_pattern' + ) + return names.has(canonicalTypeScriptName(key.text)); + // A computed identifier/expression describes a runtime key, not its spelling. + return true; +} + +function unrelatedKeyOwner( + context: RenameSemanticContext, + source: RenameSource, + key: Parser.SyntaxNode, + family: ReadonlySet, +): boolean { + let container = key.parent; + if (container?.type === 'computed_property_name') container = container.parent; + if ( + ['pair', 'pair_pattern', 'method_definition', 'object_assignment_pattern'].includes( + container?.type ?? '', + ) + ) + container = container!.parent; + const parent = container?.parent; + let owner: SymbolDefinition | RenameIssue | undefined; + if (container?.type === 'object_pattern') { + const receiver = + parent?.type === 'variable_declarator' + ? parent.childForFieldName('value') + : parent?.type === 'assignment_expression' + ? parent.childForFieldName('right') + : undefined; + if (!receiver || !['identifier', 'this', 'super'].includes(receiver.type)) return false; + const inScope = innermostRenameScope(source.parsed, receiver); + if (!inScope) return false; + owner = checkReceiverBinding(context, canonicalTypeScriptName(receiver.text), { + inScope, + atRange: nodeRange(receiver), + }); + } else if ( + container?.type === 'object' && + parent?.childForFieldName('value')?.id === container.id + ) { + // A direct contextual annotation can establish an independent member owner. + // Untyped literals and values passed through calls or aliases stay unproven. + const type = parent.childForFieldName('type')?.firstNamedChild; + const inScope = type && innermostRenameScope(source.parsed, type); + if (!type || type.type !== 'type_identifier' || !inScope) return false; + const owners = new Map( + lookupNameClaim(inScope, canonicalTypeScriptName(type.text), context.indexes, { + position: nodeRange(type), + purpose: 'type', + }) + .bindings.filter(({ def }) => CLASS_KINDS.includes(def.type)) + .map(({ def }) => [def.nodeId, def]), + ); + if (owners.size !== 1) return false; + owner = owners.values().next().value; + } + if (!owner || isReceiverIssue(owner)) return false; + const members = ownedMembers( + context, + owner, + canonicalTypeScriptName(simpleQualifiedName(context.target) ?? ''), + ); + return members.length > 0 && members.every((member) => !family.has(member.nodeId)); +} + +/** The first occurrence with a span at each offset, as a linear-time `find`. */ +function occurrencesBySpanStart(source: RenameSource): ReadonlyMap { + const byStart = new Map(); + for (const occurrence of source.occurrences) + for (const span of occurrence.spans) + if (!byStart.has(span.start)) byStart.set(span.start, occurrence); + return byStart; +} + +export const typescriptRenameProvider: RenameProvider = { + acceptsSourceProvider: (provider) => + [SupportedLanguages.TypeScript, SupportedLanguages.JavaScript, SupportedLanguages.Vue].includes( + provider.id, + ), + canonicalName: canonicalTypeScriptName, + symbolFamily(context) { + const method = (def: typeof context.target) => + context.files + .get(def.filePath) + ?.occurrences.find((occurrence) => occurrence.definitionId === def.nodeId)?.node?.parent; + const isPrivate = (def: typeof context.target) => + (simpleQualifiedName(def) ?? '').startsWith('#') || + method(def)?.children.some((node) => node.text === 'private') === true; + return graphRenameFamily(context, { + matches: (left, right) => + left.type === 'Method' && + right.type === 'Method' && + !isPrivate(left) && + !isPrivate(right) && + canonicalTypeScriptName(simpleQualifiedName(left) ?? '') === + canonicalTypeScriptName(simpleQualifiedName(right) ?? ''), + canCouple: (left, right) => + !!method(left) && + !!method(right) && + !method(left)!.children.some((node) => node.type === 'static') && + !method(right)!.children.some((node) => node.type === 'static'), + }); + }, + validateName(name) { + const canonical = canonicalTypeScriptName(name); + if ( + !/^[$_\p{ID_Start}][$_\u200c\u200d\p{ID_Continue}]*$/u.test(canonical) || + keywordNames.has(canonical) + ) + return { code: 'invalid_name', message: 'Choose a valid nonreserved identifier.' }; + }, + parseSource: createTreeSitterRenameSource({ + isName: (node) => names.has(node.type), + skip: (node) => node.type === 'comment', + role, + canonicalName: canonicalTypeScriptName, + transformParsed: (parsed) => canonicalizeRenameBindings(parsed, canonicalTypeScriptName), + reference: bareReference, + }), + checkOccurrence(context) { + for (let node = context.occurrence.node?.parent; node; node = node.parent) + if (node.type === 'with_statement') + return issue('A dynamic with environment prevents lexical rename proof.'); + return checkReceiver(context); + }, + checkSafety(context) { + const targetSource = context.files.get(context.target.filePath); + const declaration = targetSource?.occurrences.find( + (occurrence) => occurrence.definitionId === context.target.nodeId, + )?.node?.parent; + // Script-level function declarations also create global-object properties. + const scriptGlobal = + context.target.type === 'Function' && + declaration?.parent?.type === 'program' && + !targetSource?.tree?.rootNode.namedChildren.some( + (node) => node.type === 'import_statement' || node.type === 'export_statement', + ); + if (!context.target.ownerId && !scriptGlobal) return; + const memberNames = new Set([ + canonicalTypeScriptName(simpleQualifiedName(context.target) ?? ''), + canonicalTypeScriptName(context.newName), + ]); + let family: ReadonlySet | undefined; + for (const source of context.files.values()) { + if (!source.tree) continue; + for (const node of walkNamedNodes(source.tree.rootNode)) { + const memberName = context.target.ownerId ? memberKey(node) : undefined; + if (memberName && keyMayMatch(memberName, memberNames)) { + if (!family) { + const result = typescriptRenameProvider.symbolFamily!(context); + if ('code' in result) return result; + family = new Set(result.map((member) => member.nodeId)); + } + if (!unrelatedKeyOwner(context, source, memberName, family)) + return issue( + 'An object or destructuring key has no proven unrelated member owner.', + 'ambiguous_reference', + ); + } + if (node.type !== 'subscript_expression') continue; + const key = node.childForFieldName('index'); + if (key?.type === 'number') continue; + if (key?.type === 'string' && !key.text.includes('\\')) { + const value = key.text.slice(1, -1); + if ( + value !== simpleQualifiedName(context.target) && + value !== canonicalTypeScriptName(context.newName) + ) + continue; + } + const receiver = node.childForFieldName('object'); + if (!receiver || !['identifier', 'this'].includes(receiver.type)) + return issue('A computed member receiver cannot be proven unrelated.'); + if (isConstantLiteralReceiver(receiver, source, context)) continue; + const scope = innermostRenameScope(source.parsed, receiver); + const claim = + scope && + lookupNameClaim(scope, receiver.text, context.indexes, { + position: nodeRange(receiver), + purpose: 'value', + }); + const type = claim && claim.typeBinding; + if (!type) return issue('A computed member receiver cannot be proven unrelated.'); + const owners = lookupNameClaim(type.declaredAtScope, type.rawName, context.indexes, { + position: nodeRange(receiver), + purpose: 'type', + }).bindings; + if ( + !owners.length || + owners.some( + (owner) => + owner.def.nodeId === context.target.ownerId || + context.indexes.methodDispatch + .mroFor(owner.def.nodeId) + .some((ancestor) => ancestor === context.target.ownerId), + ) + ) + return issue('A computed access may denote the selected member.'); + } + } + }, + rewrite({ source, occurrence, newName }) { + return occurrence.spans.map((span) => ({ + ...span, + oldText: source.content.slice(span.start, span.start + span.length), + newText: + occurrence.node?.type === 'private_property_identifier' + ? `#${newName}` + : occurrence.node?.type === 'shorthand_property_identifier' || + occurrence.node?.type === 'shorthand_property_identifier_pattern' + ? `${source.content.slice(span.start, span.start + span.length)}: ${newName}` + : newName, + })); + }, + validateRewrite({ source, updated, edits }) { + const originalAt = occurrencesBySpanStart(source); + const updatedAt = occurrencesBySpanStart(updated); + let delta = 0; + for (const edit of edits) { + const original = originalAt.get(edit.start); + const replacement = updatedAt.get(edit.start + delta); + if ( + original?.node && + jsxTagRole(original.node) && + (!replacement?.node || jsxTagRole(original.node) !== jsxTagRole(replacement.node)) + ) + return { + code: 'invalid_name', + message: 'The name changes a JSX component into an intrinsic tag.', + }; + delta += edit.newText.length - edit.length; + } + }, +}; diff --git a/gitnexus/src/core/ingestion/languages/typescript/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/typescript/scope-resolver.ts index 40de35d32..92f25f1d2 100644 --- a/gitnexus/src/core/ingestion/languages/typescript/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/typescript/scope-resolver.ts @@ -18,9 +18,12 @@ import { generateId } from '../../../../lib/utils.js'; import { buildMro, defaultLinearize } from '../../scope-resolution/passes/mro.js'; import { populateClassOwnedMembers } from '../../scope-resolution/scope/walkers.js'; import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js'; +import { canonicalizeWorkspaceBindings } from '../../scope-resolution/scope/canonicalize-bindings.js'; +import { canonicalTypeScriptName } from './identifiers.js'; import { simpleKey } from '../../scope-resolution/graph-bridge/node-lookup.js'; import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; import { typescriptProvider } from '../typescript.js'; +import { typescriptModuleExports } from './module-exports.js'; import { loadTsconfigIndex, type TsconfigIndex } from './tsconfig.js'; import { loadNodeWorkspacePackages, @@ -76,6 +79,8 @@ function makeTsResolveImportTarget(): ScopeResolver['resolveImportTarget'] { } const typescriptScopeResolver: ScopeResolver = { + populateWorkspaceReferences: (parsedFiles) => + canonicalizeWorkspaceBindings(parsedFiles, canonicalTypeScriptName), // One hook, both routes. TypeScript exposes collection views as METHOD calls // (`.values()`), which the call-expression branch already handles, so the // accessor route yields nothing here — but it is now the same hook rather @@ -91,6 +96,7 @@ const typescriptScopeResolver: ScopeResolver = { ownedMembersBindAtModuleScope: false, resolveImportTarget: makeTsResolveImportTarget(), + moduleExports: typescriptModuleExports, // Threaded into `resolveImportTarget` so tsconfig path aliases // (`@/services/user`, `~/x`, ...) resolve through the same standard diff --git a/gitnexus/src/core/ingestion/languages/vue.ts b/gitnexus/src/core/ingestion/languages/vue.ts index d0a21767f..9475d8a7b 100644 --- a/gitnexus/src/core/ingestion/languages/vue.ts +++ b/gitnexus/src/core/ingestion/languages/vue.ts @@ -39,6 +39,7 @@ import { import { emitVueScopeCaptures } from './vue/captures.js'; import { getTsParser } from './typescript/query.js'; import { createTypeScriptCfgVisitor } from '../cfg/visitors/typescript.js'; +import { vueRenameProvider } from './vue/rename.js'; const VUE_SPECIFIC_BUILT_INS = [ 'ref', @@ -78,6 +79,7 @@ const vueClassExtractor = createClassExtractor(vueClassConfig); export const vueProvider = defineLanguage({ id: SupportedLanguages.Vue, + rename: vueRenameProvider, extensions: ['.vue'], entryPointPatterns: [], astFrameworkPatterns: [], diff --git a/gitnexus/src/core/ingestion/languages/vue/rename.ts b/gitnexus/src/core/ingestion/languages/vue/rename.ts new file mode 100644 index 000000000..5acccaf68 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/vue/rename.ts @@ -0,0 +1,606 @@ +import type { RenameOccurrence, RenameProvider, RenameSource } from '../../rename-provider.js'; +import { SupportedLanguages, type Range, type Scope, type ReferenceSite } from 'gitnexus-shared'; +import { emitTsScopeCaptures } from '../typescript/captures.js'; +import { typescriptRenameProvider } from '../typescript/rename.js'; +import { getTsParser } from '../typescript/query.js'; +import type Parser from 'tree-sitter'; +import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js'; + +interface Tag { + readonly start: number; + readonly end: number; + readonly name: string; + readonly nameStart: number; + readonly closing: boolean; + readonly selfClosing: boolean; + readonly attributes: readonly Attribute[]; +} + +interface Attribute { + readonly name: string; + readonly nameStart: number; + readonly value?: string; + readonly start?: number; +} + +const unsupported = (message: string): never => { + throw Object.assign(new Error(message), { code: 'unsupported_source', status: 'unsupported' }); +}; + +/** SFC markup lexer. Quoted values and raw script/style bodies are never scanned as tags. */ +function tagAt(content: string, start: number): Tag | undefined { + const head = /^<(\/)?([A-Za-z][\w:.-]*)/.exec(content.slice(start)); + if (!head) return undefined; + let cursor = start + head[0].length; + const boundary = content[cursor]; + if (boundary !== undefined && !/[\s/>]/.test(boundary)) unsupported('Unsupported Vue tag name.'); + const attributes: Attribute[] = []; + while (cursor < content.length) { + while (/\s/.test(content[cursor] ?? '') && cursor < content.length) cursor++; + if (content[cursor] === '>' || content.slice(cursor, cursor + 2) === '/>') { + const selfClosing = content[cursor] === '/'; + return { + start, + end: cursor + (selfClosing ? 2 : 1), + name: head[2]!, + nameStart: start + (head[1] ? 2 : 1), + closing: !!head[1], + selfClosing, + attributes, + }; + } + const attribute = /^[^\s=/>]+/.exec(content.slice(cursor)); + if (!attribute) unsupported('Cannot establish the Vue template attribute boundaries.'); + const name = attribute![0]; + const nameStart = cursor; + cursor += name.length; + while (/\s/.test(content[cursor] ?? '') && cursor < content.length) cursor++; + if (content[cursor] !== '=') { + attributes.push({ name, nameStart }); + continue; + } + cursor++; + while (/\s/.test(content[cursor] ?? '') && cursor < content.length) cursor++; + const quote = content[cursor]; + if (quote !== '"' && quote !== "'") { + const value = /^[^\s>]+/.exec(content.slice(cursor)); + if (!value) unsupported('Cannot establish the Vue attribute value.'); + attributes.push({ name, nameStart, value: value![0], start: cursor }); + cursor += value![0].length; + continue; + } + const valueStart = ++cursor; + const end = content.indexOf(quote!, cursor); + if (end < 0) unsupported('Unclosed Vue attribute value.'); + attributes.push({ name, nameStart, value: content.slice(valueStart, end), start: valueStart }); + cursor = end + 1; + } + return unsupported('Unclosed Vue tag.'); +} + +interface Projection { + readonly text: string; + readonly templateRanges: readonly { start: number; end: number; scope?: TemplateScope }[]; + readonly scopes: readonly TemplateScope[]; + readonly setup: boolean; + readonly jsx: boolean; + readonly componentTags: readonly { + start: number; + end: number; + name: string; + kebab: boolean; + scope?: TemplateScope; + }[]; + readonly shorthandAttributes: readonly number[]; +} + +interface TemplateScope { + readonly start: number; + end: number; + readonly parent?: TemplateScope; + readonly bindings: readonly { name: string; start: number; synthetic?: boolean }[]; + readonly deferred?: boolean; +} + +const VOID_TAGS = new Set( + 'area base br col embed hr img input link meta param source track wbr'.split(' '), +); +const BUILTIN_TAGS = new Set( + 'router-view router-link transition transition-group keep-alive teleport suspense component slot'.split( + ' ', + ), +); +type VueOccurrence = RenameOccurrence & { + readonly templateTag?: 'pascal' | 'kebab'; + readonly shorthandAttribute?: boolean; +}; + +function templateBindings(pattern: string, start: number): { name: string; start: number }[] { + let leading = pattern.length - pattern.trimStart().length; + let text = pattern.trim(); + if (text.startsWith('(') && text.endsWith(')')) { + text = text.slice(1, -1); + leading++; + } + const tree = parseSourceSafe(getTsParser('pattern.ts'), `(${text}) => {}`); + if (tree.rootNode.hasError) unsupported('Cannot establish the Vue template binding pattern.'); + const arrow = tree.rootNode.firstNamedChild?.firstNamedChild; + const parameters = arrow?.childForFieldName('parameters'); + if (!parameters) unsupported('Cannot establish the Vue template binding parameters.'); + const result: { name: string; start: number }[] = []; + const visit = (node: Parser.SyntaxNode): void => { + if (node.type === 'identifier' || node.type === 'shorthand_property_identifier_pattern') { + result.push({ name: node.text, start: start + leading + node.startIndex - 1 }); + return; + } + if ( + ['assignment_pattern', 'object_assignment_pattern', 'computed_property_name'].includes( + node.type, + ) + ) + unsupported('Default or computed template bindings need additional expression evidence.'); + if (node.type === 'pair_pattern') { + const value = node.childForFieldName('value'); + if (value) visit(value); + return; + } + for (const child of node.namedChildren) visit(child); + }; + visit(parameters!); + return result; +} + +/** Keep CSS literals opaque; only v-bind arguments execute in the component scope. */ +function* cssBindingRanges(content: string, start: number, end: number) { + for (let cursor = start; cursor < end; ) { + if (content.startsWith('/*', cursor)) { + const close = content.indexOf('*/', cursor + 2); + if (close < 0 || close >= end) unsupported('Unclosed Vue CSS comment.'); + cursor = close + 2; + continue; + } + const char = content[cursor]; + if (char === '"' || char === "'") { + const quote = char; + for (cursor++; cursor < end && content[cursor] !== quote; cursor++) { + if (content[cursor] === '\\') cursor++; + } + if (cursor >= end) unsupported('Unclosed Vue CSS string.'); + cursor++; + continue; + } + const binding = + char === 'v' && !/[\w-]/.test(content[cursor - 1] ?? '') + ? /^v-bind\s*\(/.exec(content.slice(cursor, end)) + : undefined; + if (!binding) { + cursor++; + continue; + } + let expressionStart = cursor + binding[0].length; + let depth = 0; + let quote: string | undefined; + for (cursor = expressionStart; cursor < end; cursor++) { + const token = content[cursor]; + if (token === '\\') unsupported('Escaped Vue CSS bindings need a source mapping.'); + if (quote) { + if (token === quote) quote = undefined; + } else if (token === '"' || token === "'") { + quote = token; + } else if (token === '/' || token === '`') { + unsupported('This Vue CSS expression needs quotes to establish its boundaries.'); + } else if (token === '(') { + depth++; + } else if (token === ')') { + if (depth === 0) break; + depth--; + } + } + if (cursor >= end) unsupported('Unclosed Vue CSS binding.'); + let expressionEnd = cursor; + while (/\s/.test(content[expressionStart] ?? '') && expressionStart < expressionEnd) + expressionStart++; + while (/\s/.test(content[expressionEnd - 1] ?? '') && expressionEnd > expressionStart) + expressionEnd--; + const first = content[expressionStart]; + if ((first === '"' || first === "'") && content[expressionEnd - 1] === first) { + expressionStart++; + expressionEnd--; + } + if (!content.slice(expressionStart, expressionEnd).trim()) + unsupported('Empty Vue CSS binding.'); + yield { start: expressionStart, end: expressionEnd }; + cursor++; + } +} + +/** + * Retain original UTF-16 offsets and line breaks while parsing script, CSS, and template + * expressions through the same TypeScript capture/resolution pipeline. Markup is + * only an expression boundary; no written identifier is invented or substituted. + */ +function projectSfc(content: string): Projection { + const text = content.replace(/[^\r\n]/g, ' ').split(''); + const templateRanges: { start: number; end: number; scope?: TemplateScope }[] = []; + const scopes: TemplateScope[] = []; + const componentTags: { + start: number; + end: number; + name: string; + kebab: boolean; + scope?: TemplateScope; + }[] = []; + const shorthandAttributes: number[] = []; + const stack: { name: string; scope?: TemplateScope; verbatim: boolean }[] = []; + let templateDepth = 0; + let setup = false; + let jsx = false; + const copy = (start: number, end: number) => { + for (let i = start; i < end; i++) text[i] = content[i]!; + }; + const expression = (start: number, end: number, scope?: TemplateScope, statements = false) => { + if (/&(?:#\d+|#x[\da-f]+|\w+);/i.test(content.slice(start, end))) + unsupported('HTML entities in Vue expressions need a source mapping.'); + // The two opening punctuation characters and closing quote/braces belong to + // markup, and provide room for a standalone parenthesized expression. + text[start - 2] = ';'; + text[start - 1] = statements ? '{' : '('; + copy(start, end); + text[end] = statements ? '}' : ')'; + if (text[end + 1] === ' ') text[end + 1] = ';'; + templateRanges.push({ start, end, scope }); + }; + for (let cursor = 0; cursor < content.length; ) { + if (content.startsWith('', cursor + 4); + if (end < 0) unsupported('Unclosed Vue comment.'); + cursor = end + 3; + continue; + } + if (templateDepth && !stack.at(-1)?.verbatim && content.startsWith('{{', cursor)) { + const end = content.indexOf('}}', cursor + 2); + if (end < 0) unsupported('Unclosed Vue interpolation.'); + expression(cursor + 2, end, stack.at(-1)?.scope); + cursor = end + 2; + continue; + } + if (content[cursor] !== '<') { + cursor++; + continue; + } + const tag = tagAt(content, cursor); + if (!tag) { + cursor++; + continue; + } + if ( + !tag.closing && + (['script', 'style'].includes(tag.name.toLowerCase()) || + (!templateDepth && tag.name !== 'template')) + ) { + if (tag.name.toLowerCase() === 'style') { + if (tag.attributes.some((attribute) => attribute.name === 'src')) + unsupported('External Vue styles need their own source binding.'); + if ( + tag.attributes.some((attribute) => attribute.name === 'lang' && attribute.value !== 'css') + ) + unsupported('The Vue style language needs its own source mapping.'); + } + if (tag.selfClosing) { + cursor = tag.end; + continue; + } + const escapedName = tag.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const close = new RegExp(``, 'ig'); + close.lastIndex = tag.end; + const end = close.exec(content); + if (!end) unsupported('Unclosed Vue raw block.'); + if (tag.name.toLowerCase() === 'script') { + if (tag.attributes.some((attribute) => attribute.name === 'src')) + unsupported('External Vue scripts need their own source binding.'); + setup ||= tag.attributes.some((attribute) => attribute.name === 'setup'); + jsx ||= tag.attributes.some( + (attribute) => + attribute.name === 'lang' && ['jsx', 'tsx'].includes(attribute.value ?? ''), + ); + copy(tag.end, end!.index); + } else if (tag.name.toLowerCase() === 'style') { + const scope: TemplateScope = { + start: tag.start, + end: end!.index, + bindings: [], + deferred: true, + }; + for (const binding of cssBindingRanges(content, tag.end, end!.index)) { + if (!scopes.includes(scope)) scopes.push(scope); + expression(binding.start, binding.end, scope); + } + } + cursor = end!.index + end![0].length; + continue; + } + let scope = stack.at(-1)?.scope; + const verbatim = + stack.at(-1)?.verbatim || tag.attributes.some((attribute) => attribute.name === 'v-pre'); + if (tag.name === 'template') templateDepth += tag.closing ? -1 : 1; + if (tag.name === 'template' && tag.attributes.some((attribute) => attribute.name === 'src')) + unsupported('External Vue templates need their own source binding.'); + if ( + tag.name === 'template' && + tag.attributes.some((attribute) => attribute.name === 'lang' && attribute.value !== 'html') + ) + unsupported('The Vue template language needs its own source mapping.'); + if (templateDepth && !tag.closing && !verbatim) { + const parent = scope; + const localBindings: { name: string; start: number }[] = []; + for (const attribute of tag.attributes) { + const loop = attribute.name === 'v-for'; + const slot = + attribute.name === 'v-slot' || + attribute.name.startsWith('v-slot:') || + attribute.name.startsWith('#'); + if (!loop && !slot) continue; + if (attribute.start === undefined) continue; + const match = loop ? /^(.*?)\s+(?:in|of)\s+([\s\S]+)$/.exec(attribute.value!) : undefined; + if (loop && !match) unsupported('Cannot establish the Vue loop binding range.'); + const pattern = loop ? match![1]! : attribute.value!; + localBindings.push(...templateBindings(pattern, attribute.start)); + if (loop) { + const start = attribute.start + attribute.value!.length - match![2]!.length; + expression(start, attribute.start + attribute.value!.length, parent); + } + } + if (localBindings.length || (tag.name === 'template' && templateDepth === 1)) { + scope = { + start: tag.start, + end: content.length, + parent, + bindings: localBindings, + deferred: tag.name === 'template' && templateDepth === 1, + }; + scopes.push(scope); + } + for (const attribute of tag.attributes) { + const dynamic = /\[([^\]]+)\]/.exec(attribute.name); + if (dynamic) { + const start = attribute.nameStart + dynamic.index + 1; + expression(start, start + dynamic[1]!.length, scope); + } + if (attribute.name === 'v-for' || /^(?:v-slot|#)/.test(attribute.name)) continue; + if (attribute.start !== undefined && /^(?:[:@]|v-)/.test(attribute.name)) { + const event = /^(?:@|v-on:)/.test(attribute.name); + let expressionScope = attribute.name === 'v-if' ? parent : scope; + if (event) { + expressionScope = { + start: attribute.nameStart, + end: attribute.start + attribute.value!.length + 1, + parent: scope, + bindings: [{ name: '$event', start: attribute.nameStart, synthetic: true }], + }; + scopes.push(expressionScope); + } + expression( + attribute.start, + attribute.start + attribute.value!.length, + expressionScope, + event, + ); + } + if (attribute.start === undefined && /^(?::|v-bind:)/.test(attribute.name)) { + const name = attribute.name.replace(/^(?::|v-bind:)/, ''); + if (!/^[$_\p{ID_Start}][$_\p{ID_Continue}]*$/u.test(name)) + unsupported('The Vue shorthand attribute needs an explicit binding value.'); + const start = attribute.nameStart + attribute.name.length - name.length; + expression(start, start + name.length, scope); + shorthandAttributes.push(start); + } + } + } + if (templateDepth) { + const pascal = /^[A-Z][\w$]*$/.test(tag.name); + const namespaced = /^[A-Z][\w$]*(?:\.[$\w]+)+$/.test(tag.name); + const kebab = /^[a-z][\w]*(?:-[\w]+)+$/.test(tag.name) && !BUILTIN_TAGS.has(tag.name); + if (!verbatim && (pascal || kebab || namespaced)) { + // PascalCase component tags are lexical reads of the setup import/binding. + if (pascal || namespaced) { + text[tag.nameStart - 1] = ';'; + copy(tag.nameStart, tag.nameStart + tag.name.length); + text[tag.nameStart + tag.name.length] = ';'; + } + templateRanges.push({ start: tag.nameStart, end: tag.nameStart + tag.name.length, scope }); + if (!namespaced) + componentTags.push({ + start: tag.nameStart, + end: tag.nameStart + tag.name.length, + scope, + kebab, + name: kebab + ? tag.name + .split('-') + .map((word) => word[0]!.toUpperCase() + word.slice(1)) + .join('') + : tag.name, + }); + } + if (!tag.closing && !tag.selfClosing && !VOID_TAGS.has(tag.name)) + stack.push({ name: tag.name, scope, verbatim }); + if (tag.selfClosing && scope !== stack.at(-1)?.scope && scope) scope.end = tag.end; + } + if (tag.closing && stack.at(-1)?.name === tag.name) { + const entry = stack.pop()!; + if (entry.scope && entry.scope !== stack.at(-1)?.scope) entry.scope.end = tag.end; + } + cursor = tag.end; + } + return { + text: text.join(''), + templateRanges, + scopes, + setup, + jsx, + componentTags, + shorthandAttributes, + }; +} + +function withTemplateScopes(source: RenameSource, projection: Projection): RenameSource { + const range = (start: number, end: number): Range => { + const before = source.content.slice(0, start).split('\n'); + const through = source.content.slice(0, end).split('\n'); + return { + startLine: before.length, + startCol: before.at(-1)!.length, + endLine: through.length, + endCol: through.at(-1)!.length, + }; + }; + const offset = (line: number, col: number) => { + let start = 0; + for (let i = 1; i < line; i++) start = source.content.indexOf('\n', start) + 1; + return start + col; + }; + const id = (scope?: TemplateScope) => + scope ? `scope:${source.path}#vue-template:${scope.start}` : source.parsed.moduleScope; + const scopeAt = (start: number) => + projection.templateRanges.find((entry) => entry.start - 1 <= start && entry.end > start)?.scope; + const templateScopes: Scope[] = projection.scopes.map((scope) => ({ + id: id(scope), + parent: id(scope.parent), + kind: 'Block', + range: range(scope.start, scope.end), + filePath: source.path, + bindings: new Map(), + ownedDefs: [], + imports: [], + typeBindings: new Map(), + lexicalNames: new Set(scope.bindings.map((binding) => binding.name)), + nameClaims: scope.bindings.map((binding) => ({ + name: binding.name, + kind: 'binding', + range: binding.synthetic + ? range(scope.start, scope.end) + : range(binding.start, binding.start + binding.name.length), + })), + lookupPolicy: { nameClaimsComplete: true, deferParentActivation: scope.deferred }, + })); + const references = new Map( + source.parsed.referenceSites.map((site) => [ + site, + site.inScope === source.parsed.moduleScope + ? { ...site, inScope: id(scopeAt(offset(site.atRange.startLine, site.atRange.startCol))) } + : site, + ]), + ); + const bindings: RenameOccurrence[] = projection.scopes.flatMap((scope) => + scope.bindings + .filter((binding) => !binding.synthetic) + .map((binding) => ({ + name: binding.name, + role: 'binding', + range: range(binding.start, binding.start + binding.name.length), + spans: [{ start: binding.start, length: binding.name.length }], + })), + ); + const kebabTags: VueOccurrence[] = projection.componentTags + .filter((tag) => tag.kebab) + .map((tag) => { + const nameRange = range(tag.start, tag.end); + const referenceSite: ReferenceSite = { + name: tag.name, + nameRange, + atRange: nameRange, + kind: 'read', + inScope: id(tag.scope), + }; + return { + name: tag.name, + range: nameRange, + spans: [{ start: tag.start, length: tag.end - tag.start }], + role: 'name', + referenceSite, + templateTag: 'kebab', + }; + }); + return { + ...source, + occurrences: [ + ...source.occurrences.map((occurrence) => ({ + ...occurrence, + ...(occurrence.referenceSite + ? { referenceSite: references.get(occurrence.referenceSite) } + : {}), + ...(projection.componentTags.some((tag) => tag.start === occurrence.spans[0]?.start) + ? { templateTag: 'pascal' as const } + : {}), + ...(projection.shorthandAttributes.includes(occurrence.spans[0]!.start) + ? { shorthandAttribute: true } + : {}), + })), + ...bindings, + ...kebabTags, + ], + parsed: { + ...source.parsed, + referenceSites: [...references.values(), ...kebabTags.map((tag) => tag.referenceSite!)], + scopes: [ + ...source.parsed.scopes.map((scope) => + scope.id === source.parsed.moduleScope + ? { ...scope, range: range(0, source.content.length) } + : scope.parent === source.parsed.moduleScope + ? { + ...scope, + parent: id(scopeAt(offset(scope.range.startLine, scope.range.startCol))), + } + : scope, + ), + ...templateScopes, + ], + }, + }; +} + +export const vueRenameProvider: RenameProvider = { + ...typescriptRenameProvider, + rewrite(context) { + if ((context.occurrence as VueOccurrence).shorthandAttribute) + return context.occurrence.spans.map((span) => ({ + ...span, + oldText: context.source.content.slice(span.start, span.start + span.length), + newText: `${context.source.content.slice(span.start, span.start + span.length)}="${context.newName}"`, + })); + const kind = (context.occurrence as VueOccurrence).templateTag; + if (!kind) return typescriptRenameProvider.rewrite!(context); + if (!/^[A-Z][\w$]*$/.test(context.newName)) + return { code: 'invalid_name', message: 'Vue component bindings require a PascalCase name.' }; + return context.occurrence.spans.map((span) => ({ + ...span, + oldText: context.source.content.slice(span.start, span.start + span.length), + newText: + kind === 'kebab' + ? context.newName.replace(/\B([A-Z])/g, '-$1').toLowerCase() + : context.newName, + })); + }, + async parseSource(input) { + const projection = projectSfc(input.content); + if (!projection.setup && projection.templateRanges.length) + unsupported('Options API template and CSS bindings need component-instance scope evidence.'); + const source = await typescriptRenameProvider.parseSource({ + ...input, + tree: parseSourceSafe( + getTsParser(projection.jsx ? 'embedded.tsx' : 'embedded.ts'), + projection.text, + ), + languageProvider: { + ...input.languageProvider, + id: SupportedLanguages.TypeScript, + preprocessSource: () => projection.text, + emitScopeCaptures: (_content, path, tree) => + emitTsScopeCaptures(projection.text, path, tree, { + scriptLanguage: projection.jsx ? 'tsx' : 'ts', + }), + }, + }); + return withTemplateScopes(source, projection); + }, +}; diff --git a/gitnexus/src/core/ingestion/languages/vue/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/vue/scope-resolver.ts index 9bd977c55..bd2d0f31a 100644 --- a/gitnexus/src/core/ingestion/languages/vue/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/vue/scope-resolver.ts @@ -57,9 +57,12 @@ import { generateId } from '../../../../lib/utils.js'; import { buildMro, defaultLinearize } from '../../scope-resolution/passes/mro.js'; import { populateClassOwnedMembers } from '../../scope-resolution/scope/walkers.js'; import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js'; +import { canonicalizeWorkspaceBindings } from '../../scope-resolution/scope/canonicalize-bindings.js'; +import { canonicalTypeScriptName } from '../typescript/identifiers.js'; import { simpleKey } from '../../scope-resolution/graph-bridge/node-lookup.js'; import { vueProvider } from '../vue.js'; import { loadTsconfigIndex } from '../typescript/tsconfig.js'; +import { typescriptModuleExports } from '../typescript/module-exports.js'; import { loadNodeWorkspacePackages } from '../../import-resolvers/node-workspace-packages.js'; import { typescriptArityCompatibility, typescriptMergeBindings } from '../typescript/index.js'; import { makeVueResolveImportTarget } from './import-target.js'; @@ -79,6 +82,8 @@ function isVueScopeContextLanguage(lang: SupportedLanguages | null): boolean { } const vueScopeResolver: ScopeResolver = { + populateWorkspaceReferences: (parsedFiles) => + canonicalizeWorkspaceBindings(parsedFiles, canonicalTypeScriptName), language: SupportedLanguages.Vue, languageProvider: vueProvider, importEdgeReason: 'vue-scope: import', @@ -86,6 +91,7 @@ const vueScopeResolver: ScopeResolver = { ownedMembersBindAtModuleScope: false, resolveImportTarget: makeVueResolveImportTarget(), + moduleExports: typescriptModuleExports, // Vue projects universally use TypeScript — load tsconfig so path // aliases (`@/`, `~/`, `#/`) resolve through the standard branch. diff --git a/gitnexus/src/core/ingestion/languages/zig.ts b/gitnexus/src/core/ingestion/languages/zig.ts index 63d70f217..9b5c9eff9 100644 --- a/gitnexus/src/core/ingestion/languages/zig.ts +++ b/gitnexus/src/core/ingestion/languages/zig.ts @@ -1,3 +1,4 @@ +import { zigRenameProvider } from './zig/rename.js'; /** * Zig Language Provider. * @@ -47,6 +48,7 @@ import { zigMethodConfig } from '../method-extractors/configs/zig.js'; import { createVariableExtractor } from '../variable-extractors/generic.js'; import { zigVariableConfig } from '../variable-extractors/configs/zig.js'; import { zigTypeConfig } from '../type-extractors/zig.js'; +import { createZigCfgVisitor } from '../cfg/visitors/zig.js'; import { emitZigScopeCaptures, interpretZigImport, @@ -65,6 +67,7 @@ import { } from './zig/index.js'; export const zigProvider = defineLanguage({ + rename: zigRenameProvider, id: SupportedLanguages.Zig, extensions: ['.zig'], entryPointPatterns: [ @@ -74,6 +77,7 @@ export const zigProvider = defineLanguage({ astFrameworkPatterns: [], treeSitterQueries: ZIG_QUERIES, typeConfig: zigTypeConfig, + cfgVisitor: createZigCfgVisitor(), exportChecker: zigExportChecker, importResolver: createImportResolver(zigImportConfig), callExtractor: createCallExtractor(zigCallConfig), diff --git a/gitnexus/src/core/ingestion/languages/zig/captures.ts b/gitnexus/src/core/ingestion/languages/zig/captures.ts index 2d29b38c6..1e6b1c03d 100644 --- a/gitnexus/src/core/ingestion/languages/zig/captures.ts +++ b/gitnexus/src/core/ingestion/languages/zig/captures.ts @@ -1287,6 +1287,18 @@ export function emitZigScopeCaptures( } if (Object.keys(grouped).length === 0) continue; + const fieldRead = nodeMap['@reference.read']; + if ( + fieldRead?.parent?.type === 'call_expression' && + fieldRead.parent.childForFieldName('function')?.id === fieldRead.id + ) { + grouped['@reference.callee-position'] = { + name: '@reference.callee-position', + range: ZERO_RANGE, + text: '', + }; + } + // `_ = @import("x.zig");` and any other keyword-less ` = // @import(…)`: a statement (tree-sitter-zig reuses variable_declaration // for assignments), not a declaration. It references the file without diff --git a/gitnexus/src/core/ingestion/languages/zig/query.ts b/gitnexus/src/core/ingestion/languages/zig/query.ts index e47b88865..3fec8baff 100644 --- a/gitnexus/src/core/ingestion/languages/zig/query.ts +++ b/gitnexus/src/core/ingestion/languages/zig/query.ts @@ -420,6 +420,12 @@ export const ZIG_SCOPE_QUERY = ` (call_expression function: (identifier) @reference.name) @reference.call.free +;; Field accesses retain the complete receiver so the shared typed-chain +;; resolver can follow container fields outside call/argument positions too. +(field_expression + object: (_) @reference.receiver + member: (identifier) @reference.name) @reference.read + ;; References — member calls: obj.method(...) / mod.fn(...) (call_expression function: (field_expression diff --git a/gitnexus/src/core/ingestion/languages/zig/rename.ts b/gitnexus/src/core/ingestion/languages/zig/rename.ts new file mode 100644 index 000000000..ded88896d --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/zig/rename.ts @@ -0,0 +1,166 @@ +import type Parser from 'tree-sitter'; +import { SupportedLanguages, type BindingRef } from 'gitnexus-shared'; +import type { RenameOccurrence, RenameProvider, RenameSource } from '../../rename-provider.js'; +import { + canonicalizeRenameBindings, + createTreeSitterRenameSource, + innermostRenameScope, + nodeRange, + walkNamedNodes, +} from '../rename-tree-sitter.js'; +import { simpleQualifiedName } from '../../scope-resolution/graph-bridge/ids.js'; +import { lookupNameClaim } from '../../scope-resolution/scope/walkers.js'; + +const keywords = new Set( + 'addrspace align allowzero and anyframe anytype asm async await break callconv catch comptime const continue defer else enum errdefer error export extern fn for if inline linksection noalias noinline nosuspend opaque or orelse packed pub resume return section struct suspend switch test threadlocal try union unreachable usingnamespace var volatile while'.split( + ' ', + ), +); +function canonicalName(name: string): string { + if (!name.startsWith('@"') || !name.endsWith('"')) return name; + return name + .slice(2, -1) + .replace( + /\\(?:x([0-9a-fA-F]{2})|u\{([0-9a-fA-F]+)\}|([nrt\\'"]))/g, + (escape, hex: string | undefined, unicode: string | undefined, simple: string | undefined) => + hex + ? String.fromCharCode(Number.parseInt(hex, 16)) + : unicode + ? String.fromCodePoint(Number.parseInt(unicode, 16)) + : ({ n: '\n', r: '\r', t: '\t', '\\': '\\', "'": "'", '"': '"' }[simple!] ?? escape), + ); +} +function role(node: Parser.SyntaxNode): RenameOccurrence['role'] { + const parent = node.parent; + if (parent && ['break_label', 'block_label'].includes(parent.type)) return 'unrelated'; + if ( + (parent?.type === 'parameter' && parent.childForFieldName('name')?.id === node.id) || + parent?.type === 'payload' + ) + return 'binding'; + return 'name'; +} +function reference(node: Parser.SyntaxNode, source: RenameSource) { + const parent = node.parent; + if ( + !parent || + (parent.type === 'field_expression' && parent.childForFieldName('member')?.id === node.id) || + (parent.childForFieldName('name')?.id === node.id && + ['function_declaration', 'variable_declaration', 'container_field'].includes(parent.type)) + ) + return undefined; + const inScope = innermostRenameScope(source.parsed, node); + if (!inScope) return undefined; + const range = nodeRange(node); + return { + name: canonicalName(node.text), + nameRange: range, + atRange: range, + inScope, + kind: 'read' as const, + }; +} + +/** + * Builtins that look a member up by a spelled name, mapped to what their first + * argument denotes: `@field(value, name)` takes a value, while `@hasDecl(T, name)` + * and `@hasField(T, name)` take a type. Renaming a member changes every such + * lookup, so the literal can neither stay nor be rewritten without proof. + */ +const reflectionSubjects: ReadonlyMap = new Map([ + ['@field', 'value'], + ['@hasDecl', 'type'], + ['@hasField', 'type'], +]); +/** Only a written container declaration proves an owner; an alias or parameter does not. */ +const containerTypes: ReadonlySet = new Set(['Struct', 'Enum', 'Union']); + +export const zigRenameProvider: RenameProvider = { + canonicalName, + // An indexed C source may call a function exported with `export fn`. + interoperatesWithSourceProvider: (provider) => provider.id === SupportedLanguages.C, + checkSafety(context) { + // A literal equal to the destination flips `@hasDecl`/`@hasField` from false to true. + const names = new Set([ + canonicalName(simpleQualifiedName(context.target) ?? ''), + canonicalName(context.newName), + ]); + const lookups: Array<{ + source: RenameSource; + node: Parser.SyntaxNode; + subject: 'value' | 'type'; + }> = []; + let mixesInMembers = false; + for (const source of context.files.values()) { + if (!source.tree) continue; + for (const node of walkNamedNodes(source.tree.rootNode)) { + if (node.type === 'using_namespace_declaration') mixesInMembers = true; + const subject = + node.type === 'builtin_function' + ? reflectionSubjects.get(node.firstNamedChild?.text ?? '') + : undefined; + if (subject) lookups.push({ source, node, subject }); + } + } + for (const { source, node, subject } of lookups) { + const args = node.namedChildren.find((child) => child.type === 'arguments')?.namedChildren; + const [receiver, field] = args ?? []; + if (field?.type === 'string' && !names.has(canonicalName(`@${field.text}`))) continue; + const scope = receiver && innermostRenameScope(source.parsed, receiver); + let owners: readonly BindingRef[] | undefined; + if (scope && receiver && subject === 'type') { + owners = + receiver.type === 'identifier' + ? lookupNameClaim(scope, canonicalName(receiver.text), context.indexes, { + position: nodeRange(receiver), + purpose: 'type', + }).bindings + : undefined; + } else if (scope && receiver) { + const type = lookupNameClaim(scope, canonicalName(receiver.text), context.indexes, { + position: nodeRange(receiver), + purpose: 'value', + }).typeBinding; + owners = + type && + lookupNameClaim(type.declaredAtScope, type.rawName, context.indexes, { + position: nodeRange(node), + purpose: 'type', + }).bindings; + } + // `usingnamespace` republishes one container's declarations under another. + if ( + !mixesInMembers && + context.target.ownerId && + owners?.length && + owners.every( + (owner) => + containerTypes.has(owner.def.type) && owner.def.nodeId !== context.target.ownerId, + ) + ) + continue; + return { + code: 'unsupported_occurrence', + status: 'unsupported', + message: 'A reflected member lookup may denote the selected or destination name.', + }; + } + }, + validateName(name) { + const quoted = + /^@"(?:[^"\\\r\n]|\\(?:[nrt\\'"]|x[0-9a-fA-F]{2}|u\{[0-9a-fA-F]{1,6}\}))+"$/.test(name); + if (!quoted && (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name) || name === '_' || keywords.has(name))) + return { + code: 'invalid_name', + message: 'Choose a valid Zig identifier or quoted identifier.', + }; + }, + parseSource: createTreeSitterRenameSource({ + isName: (node) => node.type === 'identifier', + skip: (node) => node.type === 'comment', + role, + canonicalName, + transformParsed: (parsed) => canonicalizeRenameBindings(parsed, canonicalName), + reference, + }), +}; diff --git a/gitnexus/src/core/ingestion/model/symbol-table.ts b/gitnexus/src/core/ingestion/model/symbol-table.ts index 58165bacf..27d76b8a4 100644 --- a/gitnexus/src/core/ingestion/model/symbol-table.ts +++ b/gitnexus/src/core/ingestion/model/symbol-table.ts @@ -116,7 +116,7 @@ export const CALL_TARGET_TYPES: ReadonlySet = new Set([ // `SymbolDefinition` moved to `gitnexus-shared` as part of RFC #909 Ring 1 // (see #910). It is imported at the top of this file from `gitnexus-shared` -// and re-used unchanged throughout. Consumers should import +// and reused as the canonical symbol contract. Consumers should import // `SymbolDefinition` directly from `gitnexus-shared`, not via this file. /** diff --git a/gitnexus/src/core/ingestion/rename-provider.ts b/gitnexus/src/core/ingestion/rename-provider.ts new file mode 100644 index 000000000..13046aa0b --- /dev/null +++ b/gitnexus/src/core/ingestion/rename-provider.ts @@ -0,0 +1,136 @@ +import type { RenameSpan, RenameIssue, RenameReplacement } from 'gitnexus-shared'; +export type { RenameSpan, RenameIssue, RenameReplacement } from 'gitnexus-shared'; +import type Parser from 'tree-sitter'; +import type { + GraphNode, + ParsedFile, + Range, + ReferenceSite, + SymbolDefinition, +} from 'gitnexus-shared'; +import type { KnowledgeGraph } from '../graph/types.js'; +import type { LanguageProvider } from './language-provider.js'; +import type { SemanticModel } from './model/semantic-model.js'; +import type { ScopeResolutionIndexes } from './model/scope-resolution-indexes.js'; +import type { ScopeResolver } from './scope-resolution/contract/scope-resolver.js'; +import type { SourceBindingResolution } from './scope-resolution/source-binding-resolution.js'; + +/** One written name may have several spans (for example a multipart selector). */ +export interface RenameOccurrence { + readonly name: string; + readonly range: Range; + readonly spans: readonly RenameSpan[]; + /** Optional syntax evidence owned and interpreted only by the provider. */ + readonly node?: Parser.SyntaxNode; + readonly role?: 'name' | 'unrelated' | 'binding' | 'imported' | 'alias'; + readonly definitionId?: string; + readonly referenceSite?: ReferenceSite; +} + +/** Providers return real semantic captures; standalone sources need no fake AST. */ +export interface RenameSource { + readonly path: string; + readonly content: string; + readonly parsed: ParsedFile; + readonly tree?: Parser.Tree; + readonly occurrences: readonly RenameOccurrence[]; +} + +export interface RenameParseInput { + readonly filePath: string; + readonly content: string; + readonly languageProvider: LanguageProvider; + readonly scopeResolver: ScopeResolver; + /** A provider may delegate using its already selected embedded grammar. */ + readonly tree?: Parser.Tree; +} + +export interface RenameSemanticContext { + readonly target: SymbolDefinition; + readonly definitions: readonly SymbolDefinition[]; + readonly files: ReadonlyMap; + readonly indexes: ScopeResolutionIndexes; + readonly referenceBindings: ReadonlyMap; + readonly model: SemanticModel; + readonly graph: KnowledgeGraph; + readonly newName: string; +} + +export interface RenameOccurrenceContext extends RenameSemanticContext { + readonly source: RenameSource; + readonly occurrence: RenameOccurrence; + readonly site?: ReferenceSite; + readonly targetDefIds: readonly string[]; + readonly selected: boolean; +} + +/** + * Source spelling, syntax, coupled declarations and rewrites belong to the + * language. The rename engine owns graph identity, semantic proof, and the + * filesystem transaction; it never classifies a language's syntax. + */ +export interface RenameProvider { + /** Related source providers that share this provider's resolution workspace. */ + readonly acceptsSourceProvider?: (provider: LanguageProvider, filePath: string) => boolean; + /** + * Providers whose indexed sources can refer to this language but whose bindings + * are not yet proven by the rename workspace. Their name inventory is refusal + * evidence only; it must never supply edits or inferred reference identities. + */ + readonly interoperatesWithSourceProvider?: ( + provider: LanguageProvider, + filePath: string, + ) => boolean; + /** + * Additional spellings, such as generated accessor names, under which an interoperating + * source in `peer` can write one of this language's names, for refusal checks. + */ + readonly interopNames?: (name: string, peer: LanguageProvider) => readonly string[]; + /** + * The inverse bridge, applied to this language's own written names when `peer` is the + * renamed language: a written name (for example a full selector) that denotes an entity + * `peer` declares under a different spelling (its base name) returns that spelling, so the + * inventory of this interoperating source meets the selected names. Refusal evidence only; + * return nothing for a peer without a known bridge. + */ + readonly boundaryNames?: (name: string, peer: LanguageProvider) => readonly string[]; + /** Opaque source names unavailable as semantic occurrences; refusal evidence only. */ + readonly additionalInteropNames?: (source: RenameSource) => readonly string[]; + /** Validate provider qualification after the graph bridge and source range agree. */ + readonly matchesGraphIdentity?: (context: { + readonly definition: SymbolDefinition; + readonly node: GraphNode; + readonly definitions: readonly SymbolDefinition[]; + readonly graph: KnowledgeGraph; + readonly files: ReadonlyMap; + }) => boolean; + readonly parseSource: (input: RenameParseInput) => RenameSource | Promise; + readonly canonicalName?: ( + name: string, + context?: { + readonly definition?: SymbolDefinition; + readonly site?: ReferenceSite; + readonly occurrence?: RenameOccurrence; + }, + ) => string; + readonly validateName?: (name: string) => RenameIssue | void; + readonly symbolFamily?: ( + context: RenameSemanticContext, + ) => readonly SymbolDefinition[] | RenameIssue; + readonly checkSafety?: (context: RenameSemanticContext) => RenameIssue | void; + readonly checkOccurrence?: (context: RenameOccurrenceContext) => RenameIssue | void; + readonly rewrite?: ( + context: RenameSemanticContext & { + readonly source: RenameSource; + readonly occurrence: RenameOccurrence; + readonly newName: string; + }, + ) => readonly RenameReplacement[] | RenameIssue; + readonly validateRewrite?: ( + context: RenameSemanticContext & { + readonly source: RenameSource; + readonly updated: RenameSource; + readonly edits: readonly RenameReplacement[]; + }, + ) => RenameIssue | void; +} diff --git a/gitnexus/src/core/ingestion/resolve-references.ts b/gitnexus/src/core/ingestion/resolve-references.ts index 604eeb1d1..808ca7cd9 100644 --- a/gitnexus/src/core/ingestion/resolve-references.ts +++ b/gitnexus/src/core/ingestion/resolve-references.ts @@ -44,12 +44,15 @@ import { buildMacroRegistry, buildMethodRegistry, CLASS_KINDS, + EvidenceWeights, FIELD_KINDS, + lookupLexicalName, METHOD_KINDS, type ClassRegistry, type FieldRegistry, type MacroRegistry, type MethodRegistry, + type NameClaimResult, type Reference, type ReferenceIndex, type ReferenceSite, @@ -57,9 +60,21 @@ import { type RegistryProviders, type Resolution, type ScopeId, + type SymbolDefinition, } from 'gitnexus-shared'; import type { ScopeResolutionIndexes } from './model/scope-resolution-indexes.js'; -import { bindsTypeParameter } from './scope-resolution/scope/walkers.js'; +import { + bindsTypeParameter, + findClassBindingInScope, + findEnclosingClassDef, + lookupNameClaim, + resolveQualifiedInheritanceBase, +} from './scope-resolution/scope/walkers.js'; +import { normalizeQualifiedName } from './utils/qualified-name.js'; +import { + sourceBindingForDefinition, + type SourceBindingRecorder, +} from './scope-resolution/source-binding-resolution.js'; // ─── Public API ───────────────────────────────────────────────────────────── @@ -69,6 +84,14 @@ export interface ResolveReferencesInput { readonly providers?: RegistryProviders; /** Required owner-keyed member lookup used by Step 2 receiver/MRO walks. */ readonly ownedMembersByOwner: RegistryContext['ownedMembersByOwner']; + readonly recordSourceBinding?: SourceBindingRecorder; + /** + * `ScopeResolver.isNamespaceMemberVisible`, bound to the provider. Consulted + * for a type written through a namespace import (`x: ns.Type`): a declaration + * the language will not let that qualifier reach yields no resolution. Absent + * (the default) accepts whatever the namespace's target files declare. + */ + readonly isNamespaceMemberVisible?: (def: SymbolDefinition) => boolean; } export interface ResolveStats { @@ -88,6 +111,181 @@ export interface ResolveReferencesOutput { readonly stats: ResolveStats; } +/** Preserve every candidate for callers that must reject ambiguous edits. */ +export function createReferenceLookup( + input: ResolveReferencesInput, +): (site: ReferenceSite) => readonly Resolution[] { + const scopes = input.scopes; + let currentSite: ReferenceSite | undefined; + let lexicalClaim: NameClaimResult | undefined; + const ctx: RegistryContext = { + // Finalized call candidates belong to the provider-aware free-call and + // receiver passes, which enforce ownership and edge deduplication. Keep + // the primary call registry lexical; value/type reads still need imports. + lexicalLookup: (scope, name, options) => + currentSite?.kind === 'call' + ? lookupLexicalName(scope, name, { scopes: scopes.scopeTree }, options) + : lookupNameClaim(scope, name, scopes, options), + onLexicalClaim: + input.recordSourceBinding === undefined + ? undefined + : (name, claim) => { + if (name === currentSite?.name) lexicalClaim = claim; + }, + scopes: scopes.scopeTree, + defs: scopes.defs, + qualifiedNames: scopes.qualifiedNames, + moduleScopes: scopes.moduleScopes, + ownedMembersByOwner: input.ownedMembersByOwner, + methodDispatch: scopes.methodDispatch, + providers: input.providers ?? {}, + }; + const classes = buildClassRegistry(ctx); + const methods = buildMethodRegistry(ctx); + const fields = buildFieldRegistry(ctx); + const macros = buildMacroRegistry(ctx); + return (site) => { + if (site.kind === 'type-reference' && site.rawQualifiedName !== undefined) { + const scope = site.lookupScope ?? site.inScope; + const qualified = normalizeQualifiedName(site.rawQualifiedName); + const root = qualified.split('.', 1)[0]!; + const options = { position: site.atRange, purpose: site.lookupPurpose ?? ('type' as const) }; + const claim = lookupNameClaim(scope, root, scopes, options); + if ( + claim.status === 'blocked' || + bindsTypeParameter(scope, root, scopes) || + (claim.bindings.length > 0 && + claim.bindings.every( + ({ def }) => + !CLASS_KINDS.includes(def.type) && def.type !== 'Namespace' && def.type !== 'Module', + )) + ) { + input.recordSourceBinding?.(site, { + status: 'blocked', + targetDefIds: [], + basis: 'qualified-type-root', + }); + return []; + } + // Namespace aliases are file handles; imported type/module definitions + // are owners. Preserve the former's file constraint and require the + // latter to own the exact qualified declaration. + const namespaceImport = claim.imports.some((edge) => edge.kind === 'namespace'); + const definition = namespaceImport + ? findClassBindingInScope(scope, qualified, scopes, undefined, { + ...options, + uniqueQualifiedNameFallback: false, + namespaceMemberVisible: input.isNamespaceMemberVisible, + }) + : resolveQualifiedInheritanceBase( + scope, + site.rawQualifiedName, + scopes, + findEnclosingClassDef(scope, scopes), + ); + if ( + definition === undefined || + (!namespaceImport && + ((claim.imports.length > 0 && claim.bindings.length === 0) || + !claim.bindings.every(({ def }) => qualifierOwnsDefinition(def, definition, scopes)))) + ) { + input.recordSourceBinding?.(site, { + status: 'unresolved', + targetDefIds: [], + basis: 'qualified-type', + }); + return []; + } + const resolution: Resolution = { + def: definition, + confidence: Math.min(1, EvidenceWeights.globalQualified + EvidenceWeights.kindMatch), + evidence: [ + { kind: 'global-qualified', weight: EvidenceWeights.globalQualified }, + { kind: 'kind-match', weight: EvidenceWeights.kindMatch }, + ], + }; + input.recordSourceBinding?.(site, { + ...sourceBindingForDefinition(definition, 'qualified-type'), + evidence: resolution.evidence, + }); + return [resolution]; + } + currentSite = site; + lexicalClaim = undefined; + const resolutions = lookupForSite( + site, + classes, + methods, + fields, + macros, + scopes, + input.recordSourceBinding, + ); + const claim = lexicalClaim as NameClaimResult | undefined; + if (input.recordSourceBinding !== undefined) { + if (resolutions.length > 0) { + const targetDefIds = [...new Set(resolutions.map((resolution) => resolution.def.nodeId))]; + const evidence = resolutions.flatMap((resolution) => resolution.evidence); + const hasFallback = evidence.some( + (item) => + item.kind === 'global-name' || + item.kind === 'global-qualified' || + item.kind === 'dynamic-import-unresolved' || + (item.kind === 'arity-match' && item.weight < 0), + ); + input.recordSourceBinding(site, { + ...sourceBindingForDefinition( + resolutions[0]!.def, + hasFallback ? 'registry-fallback' : 'registry', + claim?.bindings, + claim?.scope?.id, + ), + status: hasFallback ? 'unresolved' : targetDefIds.length === 1 ? 'resolved' : 'ambiguous', + targetDefIds, + ...(!hasFallback && + targetDefIds.length > 1 && + targetDefIds.every((id) => claim?.bindings.some((binding) => binding.def.nodeId === id)) + ? { candidatesComplete: true as const } + : {}), + evidence, + }); + } else if (claim !== undefined && claim.status !== 'absent') { + input.recordSourceBinding(site, { + status: 'blocked', + targetDefIds: [], + basis: 'lexical-name-claim', + }); + } + } + currentSite = undefined; + return resolutions; + }; +} + +function qualifierOwnsDefinition( + qualifier: SymbolDefinition, + definition: SymbolDefinition, + scopes: ScopeResolutionIndexes, +): boolean { + const visited = new Set(); + for ( + let owner: SymbolDefinition | undefined = definition; + owner !== undefined && !visited.has(owner.nodeId); + owner = owner.ownerId === undefined ? undefined : scopes.defs.get(owner.ownerId) + ) { + if (owner.nodeId === qualifier.nodeId) return true; + visited.add(owner.nodeId); + } + // Namespace declarations can be distributed across files. Their full semantic + // qualification, unlike a class-valued local binding, identifies the owner. + if (qualifier.type !== 'Namespace' && qualifier.type !== 'Module') return false; + const prefix = normalizeQualifiedName(qualifier.qualifiedName ?? ''); + return ( + prefix.length > 0 && + normalizeQualifiedName(definition.qualifiedName ?? '').startsWith(`${prefix}.`) + ); +} + /** * Resolve every `ReferenceSite` in `scopes.referenceSites` against the * matching registry and produce a `ReferenceIndex` keyed by source scope @@ -95,22 +293,7 @@ export interface ResolveReferencesOutput { */ export function resolveReferenceSites(input: ResolveReferencesInput): ResolveReferencesOutput { const { scopes } = input; - const providers: RegistryProviders = input.providers ?? {}; - - const ctx: RegistryContext = { - scopes: scopes.scopeTree, - defs: scopes.defs, - qualifiedNames: scopes.qualifiedNames, - moduleScopes: scopes.moduleScopes, - ownedMembersByOwner: input.ownedMembersByOwner, - methodDispatch: scopes.methodDispatch, - providers, - }; - - const classRegistry = buildClassRegistry(ctx); - const methodRegistry = buildMethodRegistry(ctx); - const fieldRegistry = buildFieldRegistry(ctx); - const macroRegistry = buildMacroRegistry(ctx); + const lookup = createReferenceLookup(input); // bySourceScope is the canonical index; byTargetDef is derived from it. const bySourceScope = new Map(); @@ -128,14 +311,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; @@ -209,6 +385,7 @@ function lookupForSite( fieldRegistry: FieldRegistry, macroRegistry: MacroRegistry, scopes: ScopeResolutionIndexes, + recordSourceBinding?: SourceBindingRecorder, ): readonly Resolution[] { const lookupScope = site.lookupScope ?? site.inScope; const lookupOptions = { @@ -270,7 +447,14 @@ function lookupForSite( // `typeParameters` is populated only by languages whose captures were // extended for it, so a POSITIVE match declines and an absent list changes // nothing — which is what keeps every unconverted language unchanged. - if (bindsTypeParameter(lookupScope, site.name, scopes)) return []; + if (bindsTypeParameter(lookupScope, site.name, scopes)) { + recordSourceBinding?.(site, { + status: 'blocked', + targetDefIds: [], + basis: 'type-parameter', + }); + return []; + } return classRegistry.lookup(site.name, lookupScope, lookupOptions); } case 'read': 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/contract/scope-resolver.ts b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts index 4a303b9fb..b450532e1 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts @@ -279,6 +279,7 @@ */ import type { DecorationStripper } from '../scope/walkers.js'; +import type { SourceBindingRecorder } from '../source-binding-resolution.js'; import type { NameLookupOptions, BindingRef, @@ -1072,6 +1073,40 @@ export interface ScopeResolver { */ readonly namespaceExportsIncludeImportedNames?: boolean; + /** + * Whether `member`, a declaration reached through a namespace-qualified + * reference (`ns.member`, `ns.Type`, `x: ns.Type`) written OUTSIDE the + * namespace that declares it, is nameable from there. + * + * The namespace lookups (`findExportedDef` and the receiver-bound / qualified + * type paths built on it) read the target module's local bindings and treat + * every one as a public member; they carry no notion of a module-private + * declaration. A language whose visibility is decidable from the declaration + * alone states that rule here, so `ns.hidden` yields no binding instead of a + * confident `import-resolved` edge to a declaration the language forbids + * reaching. Returning `false` makes the namespace lookups behave as if the + * name were not declared. + * + * Consulted by: the namespace-member lookup (`findExportedDef`) behind the + * receiver-bound member / chain / typed-receiver paths and the compound + * receiver's construction branch, and the namespace-import branch of + * `findClassBindingInScope` (qualified type references, qualified constructor + * calls). NOT consulted by the name-guess tiers (that is + * `isGlobalNameFallbackPlausible`), by member selectors on a value, or by the + * `value-ref` registration lookup. + * + * A bare name bound through an import or a same-namespace sibling is the + * finalized-binding channel's business (it already decides what an import + * publishes), and this hook is deliberately not applied there: a sibling + * file's private declaration IS visible without qualification. + * + * **Only answer `false` when the reference is impossible, not when it is + * merely unproven** — a wrongly-refused member is a lost real edge. Must be + * pure. Undefined (the default) keeps every namespace member visible, which + * is the behaviour of every language that does not implement it. + */ + readonly isNamespaceMemberVisible?: (ctx: { readonly member: SymbolDefinition }) => boolean; + /** * When true, a qualified receiver is walked SEGMENT BY SEGMENT from its * verified namespace root instead of being split once at the last dot: @@ -1479,7 +1514,7 @@ export interface ScopeResolver { readonly resolveReceiverMember?: ( ownerDef: SymbolDefinition, memberName: string, - callsite: Callsite, + callsite: ReferenceSite, scopes: ScopeResolutionIndexes, model: SemanticModel, ) => ReceiverMemberResolution | undefined; @@ -1804,6 +1839,9 @@ export interface ScopeResolver { ctx: { readonly fileContents: ReadonlyMap; readonly resolutionConfig?: unknown; + /** Observe the source declaration at the provider's actual decision, + * before runtime target expansion or graph edge deduplication. */ + readonly recordSourceBinding?: SourceBindingRecorder; }, ) => void; diff --git a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/node-lookup.ts b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/node-lookup.ts index 73506aad6..b781c2a2d 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/node-lookup.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/node-lookup.ts @@ -43,8 +43,9 @@ function parseQualifiedFromId(id: string, label: NodeLabel, filePath: string): s if (!id.startsWith(prefix)) return undefined; const suffix = id.slice(prefix.length); if (suffix.length === 0) return undefined; - const hash = suffix.indexOf('#'); - return hash === -1 ? suffix : suffix.slice(0, hash); + // A hash may belong to the written name. Only the terminal numeric arity + // tag (and its following signature tags) is a graph disambiguator. + return suffix.replace(/#\d+(?:~.*)?$/, ''); } function stripCallableDisambiguatorTags(qualifiedName: string): string { @@ -133,7 +134,10 @@ export function localNameKey(filePath: string, label: NodeLabel, name: string): /** Tombstone for a position claimed by two nodes — see `positionKey`. */ export const AMBIGUOUS_POSITION = ''; -export function buildGraphNodeLookup(graph: KnowledgeGraph): GraphNodeLookup { +export function buildGraphNodeLookup( + graph: KnowledgeGraph, + labels: ReadonlySet = LINKABLE_LABELS, +): GraphNodeLookup { const lookup = new Map(); for (const node of graph.iterNodes()) { const props = node.properties as { @@ -144,7 +148,7 @@ export function buildGraphNodeLookup(graph: KnowledgeGraph): GraphNodeLookup { startColumn?: number; }; if (props.filePath === undefined || props.name === undefined) continue; - if (!isLinkableLabel(node.label)) continue; + if (!labels.has(node.label)) continue; // Position key (#2699) — see `positionKey`. Second write on a key marks it // ambiguous rather than letting source order decide. diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/callable-value-flow.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/callable-value-flow.ts index 38d33e34e..85efc93cf 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/callable-value-flow.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/callable-value-flow.ts @@ -899,6 +899,49 @@ interface CanonicalCallableTargets { readonly aliasesByTargetId: ReadonlyMap; } +/** Reuse the flow pass's proven prototype/definition identities for source edits. */ +export function callableDeclarationFamilies( + scopes: ScopeResolutionIndexes, + graph: KnowledgeGraph, + nodeLookup: GraphNodeLookup, + hasFileLocalCallableLinkage: (def: SymbolDefinition) => boolean, + sourceQualifiedNames?: ReadonlyMap, +): ReadonlyMap { + const original = buildGraphTargetIndex(scopes, nodeLookup, undefined, graph); + // A provider may retain a written declarator qualifier absent from the + // legacy graph identity. Keep that source fact local to this identity join. + const raw = sourceQualifiedNames + ? new Map( + [...original].map(([id, target]) => { + const qualifiedName = sourceQualifiedNames.get(id); + return [ + id, + qualifiedName ? { ...target, def: { ...target.def, qualifiedName } } : target, + ]; + }), + ) + : original; + const canonical = canonicalizeCallableDeclarations( + raw, + buildCallableTargetIndexes(raw.values(), scopes), + scopes, + graph, + hasFileLocalCallableLinkage, + ); + const groups = new Map(); + for (const [id, target] of canonical.targets) { + const definition = original.get(id)?.def; + if (!definition) continue; + const group = groups.get(target.def.nodeId) ?? []; + group.push(definition); + groups.set(target.def.nodeId, group); + } + const families = new Map(); + for (const group of groups.values()) + for (const definition of group) families.set(definition.nodeId, group); + return families; +} + /** * Associate declaration-only callable nodes with their unique out-of-file * definition. Function scopes are the body/definition signal; prototypes do diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts index 77402cb8b..69b1c1145 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts @@ -90,6 +90,7 @@ function parseMapTupleSentinel(text: string): { tupleIdx: number; rhs: string } type ReceiverTypeRecorder = (spelling: string, defId: string) => void; interface ResolveCompoundReceiverOptions { + readonly onNameFallback?: () => void; readonly lookupPosition?: TypeRef['lookupPosition']; readonly namespaceTargetsAt?: ( scope: ScopeId, @@ -139,6 +140,9 @@ interface ResolveCompoundReceiverOptions { /** A namespace member may be a name the target module imported and * publishes (hub modules). See `ScopeResolver.namespaceExportsIncludeImportedNames`. */ readonly namespaceExportsIncludeImportedNames?: boolean; + /** `ScopeResolver.isNamespaceMemberVisible`, bound to the provider. Absent ⇒ + * every namespace member is visible, exactly as before. */ + readonly namespaceMemberVisible?: (def: SymbolDefinition) => boolean; readonly namespaceSkipsEnclosingClasses?: boolean; /** Resolve a qualified CLASS name (`opmod.Op`, `hub.sub.Thing`, * `mod.Outer.Inner`) through the language's namespace chain walk @@ -276,8 +280,14 @@ function resolveConstructionExpressionClass( const namespaceMatches = namespaceFiles .map((targetFile) => options.namespaceExportsIncludeImportedNames === true - ? findExportedDefIncludingImportedNames(targetFile, exportedName, index, scopes) - : findExportedDef(targetFile, exportedName, index), + ? findExportedDefIncludingImportedNames( + targetFile, + exportedName, + index, + scopes, + options.namespaceMemberVisible, + ) + : findExportedDef(targetFile, exportedName, index, options.namespaceMemberVisible), ) .filter((def): def is SymbolDefinition => def !== undefined && isClassLike(def.type)); return namespaceMatches.length === 1 ? namespaceMatches[0] : undefined; @@ -285,6 +295,7 @@ function resolveConstructionExpressionClass( } const direct = findClassBindingInScope(inScope, calleeName, scopes, undefined, { + onNameFallback: options.onNameFallback, position: options.lookupPosition, purpose: 'value', }); @@ -292,6 +303,7 @@ function resolveConstructionExpressionClass( if (baseName.length > 0 && baseName !== calleeName) { const viaBaseName = findClassBindingInScope(inScope, baseName, scopes, undefined, { + onNameFallback: options.onNameFallback, position: options.lookupPosition, purpose: 'value', }); @@ -312,11 +324,15 @@ function resolveConstructionExpressionClass( const qualifiedIds = scopes.qualifiedNames.get(baseName); if (qualifiedIds.length === 1) { const qualified = scopes.defs.get(qualifiedIds[0]!); - if (qualified !== undefined && isClassLike(qualified.type)) return qualified; + if (qualified !== undefined && isClassLike(qualified.type)) { + options.onNameFallback?.(); + return qualified; + } } const simpleName = baseName.slice(lastDot + 1); if (simpleName.length === 0) return undefined; const viaSimpleName = findClassBindingInScope(inScope, simpleName, scopes, undefined, { + onNameFallback: options.onNameFallback, position: options.lookupPosition, purpose: 'value', }); @@ -421,6 +437,7 @@ function classOfDeclaredType( scopes: ScopeResolutionIndexes, stripDecoration?: DecorationStripper, recordReceiverType?: ReceiverTypeRecorder, + onNameFallback?: () => void, ): SymbolDefinition | undefined { // `declaredAtScope`, never a scope the caller chose: all five sites passed // exactly this `TypeRef`'s own anchor, and taking it as a parameter is what @@ -432,7 +449,7 @@ function classOfDeclaredType( spelling, scopes, stripDecoration, - { position: typeRef.lookupPosition, purpose: typeRef.lookupPurpose ?? 'type' }, + { position: typeRef.lookupPosition, purpose: typeRef.lookupPurpose ?? 'type', onNameFallback }, ); return noteReceiverType(recordReceiverType, spelling, def); } @@ -476,10 +493,12 @@ function classOfReturnType( retType: TypeRef, scopes: ScopeResolutionIndexes, record: ReceiverTypeRecorder | undefined, + onNameFallback?: () => void, ): SymbolDefinition | undefined { const def = findClassBindingInScope(retType.declaredAtScope, retType.rawName, scopes, undefined, { position: retType.lookupPosition, purpose: retType.lookupPurpose ?? 'type', + onNameFallback, }); if (def === undefined || record === undefined) return def; return noteReceiverType(record, erasedTypeApplication(retType) ?? retType.rawName, def); @@ -503,6 +522,7 @@ function typeOfMemberOnClass( scopes, options.stripTypePreservingDecoration, options.recordReceiverType, + options.onNameFallback, ); // The declared type is reported even when it resolved to no class: // `Promise` and `[]Repo` name nothing in the workspace, and an @@ -539,6 +559,7 @@ function typeOfMemberOnClass( scopes, options.stripTypePreservingDecoration, options.recordReceiverType, + options.onNameFallback, ); // Identical to the primary branch: a declared type that named no // class is still a usable position when the next step unwraps it. @@ -672,7 +693,11 @@ export function foldReceiverChain( element, scopes, options.stripTypePreservingDecoration, - { position: current.lookupPosition, purpose: 'type' }, + { + onNameFallback: options.onNameFallback, + position: current.lookupPosition, + purpose: 'type', + }, ); if (elementClass === undefined) return undefined; current = { @@ -833,6 +858,7 @@ export function resolveCompoundReceiverClass( // resolve to the CAST type, not the underlying expression's type. if (stripped.castType !== undefined) { const cls = findClassBindingInScope(inScope, stripped.castType, scopes, undefined, { + onNameFallback: options.onNameFallback, position: options.lookupPosition, purpose: 'value', }); @@ -856,6 +882,7 @@ export function resolveCompoundReceiverClass( const arg = extractShallowMapTypeArgByIndex(rhsTb.rawName, mapTuple.tupleIdx); if (arg === undefined) return undefined; return findClassBindingInScope(rhsTb.declaredAtScope, arg, scopes, undefined, { + onNameFallback: options.onNameFallback, position: rhsTb.lookupPosition, purpose: rhsTb.lookupPurpose ?? 'type', }); @@ -891,6 +918,7 @@ export function resolveCompoundReceiverClass( const arg = extractShallowMapTypeArgByIndex(rhsTb.rawName, boundMapTuple.tupleIdx); if (arg === undefined) return undefined; return findClassBindingInScope(rhsTb.declaredAtScope, arg, scopes, undefined, { + onNameFallback: options.onNameFallback, position: rhsTb.lookupPosition, purpose: rhsTb.lookupPurpose ?? 'type', }); @@ -901,6 +929,7 @@ export function resolveCompoundReceiverClass( scopes, options.stripTypePreservingDecoration, options.recordReceiverType, + options.onNameFallback, ); if (viaTb !== undefined) return viaTb; @@ -963,6 +992,7 @@ export function resolveCompoundReceiverClass( // cascade keeps its historical fallthrough so no existing edge moves. if (tb !== undefined && options.strictBaseBinding === true) return undefined; return findClassBindingInScope(inScope, workingText, scopes, undefined, { + onNameFallback: options.onNameFallback, position: options.lookupPosition, purpose: 'value', }); @@ -1002,7 +1032,12 @@ export function resolveCompoundReceiverClass( const viaReturn = retType === undefined ? undefined - : classOfReturnType(retType, scopes, options.recordReceiverType); + : classOfReturnType( + retType, + scopes, + options.recordReceiverType, + options.onNameFallback, + ); if (viaReturn !== undefined) return viaReturn; } // Inline construction — `Service(db).m()` / `new Service(db).m()`. @@ -1044,6 +1079,7 @@ export function resolveCompoundReceiverClass( !objExpr.includes('(') && !objExpr.includes('.') && findClassBindingInScope(inScope, objExpr, scopes, undefined, { + onNameFallback: options.onNameFallback, position: options.lookupPosition, purpose: 'value', })?.nodeId === objClass.nodeId; @@ -1100,6 +1136,8 @@ export function resolveCompoundReceiverClass( fieldType.declaredAtScope, fieldType.rawName, scopes, + undefined, + { onNameFallback: options.onNameFallback }, ); if (fieldClass === undefined) continue; const fcs = classScopeByDefId.get(fieldClass.nodeId); @@ -1127,7 +1165,7 @@ export function resolveCompoundReceiverClass( } if (retType === undefined) return undefined; - return classOfReturnType(retType, scopes, options.recordReceiverType); + return classOfReturnType(retType, scopes, options.recordReceiverType, options.onNameFallback); } // Mixed dotted + call chain: `obj.field.method().field.method()…`. @@ -1168,6 +1206,7 @@ export function resolveCompoundReceiverClass( const segment = parts[i]; if (segment === undefined) break; const cls = findClassBindingInScope(cur.declaredAtScope, cur.rawName, scopes, undefined, { + onNameFallback: options.onNameFallback, position: cur.lookupPosition, purpose: cur.lookupPurpose ?? 'type', }); @@ -1189,6 +1228,7 @@ export function resolveCompoundReceiverClass( const elemName = options.elementTypeOf(prefixType.rawName, { kind: 'accessor', name: last }); if (elemName !== undefined) { return findClassBindingInScope(prefixType.declaredAtScope, elemName, scopes, undefined, { + onNameFallback: options.onNameFallback, position: prefixType.lookupPosition, purpose: prefixType.lookupPurpose ?? 'type', }); @@ -1215,8 +1255,15 @@ export function resolveCompoundReceiverClass( // two had a fixture. See `classOfDeclaredType` for why this cannot change a // `TypeRef` that was never reduced. let currentClass: SymbolDefinition | undefined = headType - ? classOfDeclaredType(headType, scopes, undefined, options.recordReceiverType) + ? classOfDeclaredType( + headType, + scopes, + undefined, + options.recordReceiverType, + options.onNameFallback, + ) : findClassBindingInScope(inScope, headMemberName, scopes, undefined, { + onNameFallback: options.onNameFallback, position: options.lookupPosition, purpose: 'value', }); @@ -1370,9 +1417,15 @@ export function resolveCompoundReceiverClass( // grounds fell through here — a declined fold is documented as "no answer", // never a veto — and this walk re-minted `other.py:Mapped` from the // workspace index. Same rule, same lookup, so the two routes now agree. - let nextClass = classOfDeclaredType(memberType, scopes, undefined, options.recordReceiverType); + let nextClass = classOfDeclaredType( + memberType, + scopes, + undefined, + options.recordReceiverType, + options.onNameFallback, + ); if (nextClass === undefined) { - const fromMap = unwrapMapValueToClass(memberType, scopes); + const fromMap = unwrapMapValueToClass(memberType, scopes, options.onNameFallback); if (fromMap !== undefined) nextClass = fromMap; } currentClass = nextClass; @@ -1578,12 +1631,14 @@ function extractShallowMapTypeArgByIndex(mapText: string, wantIndex: number): st function unwrapMapValueToClass( memberType: TypeRef, scopes: ScopeResolutionIndexes, + onNameFallback?: () => void, ): SymbolDefinition | undefined { const v = extractShallowMapTypeArgByIndex(memberType.rawName, 1); if (v === undefined) return undefined; return findClassBindingInScope(memberType.declaredAtScope, v, scopes, undefined, { position: memberType.lookupPosition, purpose: memberType.lookupPurpose ?? 'type', + onNameFallback, }); } @@ -1609,10 +1664,12 @@ function resolveMapValueTypeNameFromPrefix( }); let currentClass: SymbolDefinition | undefined = headType ? findClassBindingInScope(headType.declaredAtScope, headType.rawName, scopes, undefined, { + onNameFallback: options.onNameFallback, position: headType.lookupPosition, purpose: headType.lookupPurpose ?? 'type', }) : findClassBindingInScope(inScope, headMemberName, scopes, undefined, { + onNameFallback: options.onNameFallback, position: options.lookupPosition, purpose: 'value', }); @@ -1659,10 +1716,14 @@ function resolveMapValueTypeNameFromPrefix( memberType.rawName, scopes, undefined, - { position: memberType.lookupPosition, purpose: memberType.lookupPurpose ?? 'type' }, + { + onNameFallback: options.onNameFallback, + position: memberType.lookupPosition, + purpose: memberType.lookupPurpose ?? 'type', + }, ); if (nextClass === undefined) { - const fromMap = unwrapMapValueToClass(memberType, scopes); + const fromMap = unwrapMapValueToClass(memberType, scopes, options.onNameFallback); if (fromMap !== undefined) nextClass = fromMap; } currentClass = nextClass; diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts index ad94c2c9a..c3982d4c9 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts @@ -39,12 +39,15 @@ import type { import { GLOBAL_NAME_FALLBACK_REASON } from '../../../graph/edge-reasons.js'; import { resolveCallerGraphId, resolveDefGraphId } from '../graph-bridge/ids.js'; import type { CalleeIdSink } from '../graph-bridge/callee-id-sink.js'; +import { + sourceBindingForDefinition, + type SourceBindingRecorder, +} from '../source-binding-resolution.js'; import { lookupNameClaim, hasExplicitNameClaim, findAllCallableBindingCandidatesInScope, findAllCallableBindingsInScope, - findCallableBindingInScope, findClassBindingInScope, findEnclosingClassDef, isClassFileImportGrounded, @@ -86,6 +89,9 @@ export function emitFreeCallFallback( readonly markConstructionSites?: boolean; readonly isFileLocalDef?: (def: SymbolDefinition) => boolean; readonly isCallableVisibleFromCaller?: ScopeResolver['isCallableVisibleFromCaller']; + /** Whether a type named through a written qualifier (`ns.Type{}`) is + * nameable from outside its namespace — `ScopeResolver.isNamespaceMemberVisible`. */ + readonly isNamespaceMemberVisible?: ScopeResolver['isNamespaceMemberVisible']; readonly resolveOrdinaryCallables?: ScopeResolver['resolveOrdinaryCallables']; readonly resolveAdlCandidates?: ( site: { @@ -120,6 +126,7 @@ export function emitFreeCallFallback( readonly freeCallsRequireInstanceOwnership?: boolean; readonly implicitThisWalksMro?: boolean; readonly recordResolutionOutcome?: ResolutionOutcomeRecorder; + readonly recordSourceBinding?: SourceBindingRecorder; /** Call sites owned by a later precise pass (for example callable-value-flow). */ readonly skipSites?: ReadonlySet; /** Resolved-callee-id capture sink (#2227 U2). Threaded in under `--pdg` @@ -143,6 +150,13 @@ export function emitFreeCallFallback( // defs.byId.values() at every constructor call. Same simple-name keying // and class-like kind filter the previous per-site scan applied. const globalClassesBySimpleName = buildGlobalClassIndex(scopes); + // `ScopeResolver.isNamespaceMemberVisible` bound once; undefined for every + // language that does not implement it, which leaves the class lookups as-is. + const memberVisibleToQualifier = options.isNamespaceMemberVisible; + const namespaceMemberVisible = + memberVisibleToQualifier === undefined + ? undefined + : (member: SymbolDefinition): boolean => memberVisibleToQualifier({ member }); // Workspace file-path set for `resolveQualifiedFreeCall` (a module path maps // to a file by language convention). Built lazily and once: languages without // the hook never pay for it. @@ -222,6 +236,11 @@ export function emitFreeCallFallback( if (site.kind !== 'call') continue; if (site.explicitReceiver !== undefined) continue; if (options.skipSites?.has(siteKey(parsed.filePath, site)) === true) continue; + options.recordSourceBinding?.(site, { + status: 'unresolved', + targetDefIds: [], + basis: 'free-call', + }); const lookupOptions = { position: site.atRange, purpose: 'value' as const }; const nameClaim = lookupNameClaim(site.inScope, site.name, scopes, lookupOptions); const strictClaim = hasExplicitNameClaim(nameClaim, site.name); @@ -239,8 +258,15 @@ export function emitFreeCallFallback( b.def.type === 'Record', ))); if (blocksCall) { + options.recordSourceBinding?.(site, { + status: 'blocked', + targetDefIds: [], + basis: 'lexical-name-claim', + }); if (options.resolveAdlCandidates !== undefined) { recordSuppressedOutcome(options.recordResolutionOutcome, { + recordSourceBinding: options.recordSourceBinding, + site, phase: 'free-call-fallback', filePath: parsed.filePath, name: site.name, @@ -258,6 +284,8 @@ export function emitFreeCallFallback( // to the Class node itself (implicit constructor). Legacy emits // the same two targets; see test expectations. let fnDef: SymbolDefinition | undefined; + let writtenDefinition: SymbolDefinition | undefined; + let sourceCandidates: readonly SymbolDefinition[] | undefined; // Guess flag starts here so the constructor unique-class pick can mark // the same class of guess as `pickUniqueGlobalCallable` below. The // veto/label path keys off this flag; declaring it after that pick left @@ -267,6 +295,13 @@ export function emitFreeCallFallback( // Constructor child — Go refuses any `qualifiedName` containing `.`. let globalFallbackVetoTarget: SymbolDefinition | undefined; if (site.callForm === 'constructor') { + // `ns.Type{}` names its type through a namespace import: a type the + // language will not let that qualifier reach is not found, rather than + // rediscovered below by its simple name (`namespaceMemberVisible`). + const classLookupOptions = { + ...lookupOptions, + ...(namespaceMemberVisible !== undefined ? { namespaceMemberVisible } : {}), + }; // Lexical / written-qualifier first. A bare unique type with no // import/#include evidence is a guess (JS `new UniqueWidget()`, // Go unexported `uniqueWidget{}`). A unique type whose file is @@ -280,12 +315,15 @@ export function emitFreeCallFallback( scopes, site.rawQualifiedName, undefined, - { ...lookupOptions, uniqueQualifiedNameFallback: site.rawQualifiedName !== undefined }, + { + ...classLookupOptions, + uniqueQualifiedNameFallback: site.rawQualifiedName !== undefined, + }, ); if (classDef === undefined) { classDef = findClassBindingInScope(site.inScope, site.name, scopes, undefined, { - ...lookupOptions, + ...classLookupOptions, uniqueQualifiedNameFallback: true, }) ?? (options.allowGlobalFallback === true && !strictClaim @@ -302,6 +340,7 @@ export function emitFreeCallFallback( } } if (classDef !== undefined && classDef.type !== 'Interface') { + writtenDefinition = classDef; // Most languages link `Type(...)` to the explicit Constructor def // when one exists (else the Class). Languages that model the call // as a reference to the type itself opt into @@ -383,7 +422,13 @@ export function emitFreeCallFallback( } let eligibleBindingCandidates: readonly CallableBindingCandidate[] | undefined; if (bindingCandidates === undefined) { - fnDef = findCallableBindingInScope(site.inScope, site.name, scopes, lookupOptions); + sourceCandidates = findAllCallableBindingsInScope( + site.inScope, + site.name, + scopes, + lookupOptions, + ); + fnDef = sourceCandidates[0]; } else { eligibleBindingCandidates = bindingCandidates.filter((candidate) => { const def = candidate.def; @@ -401,8 +446,11 @@ export function emitFreeCallFallback( return ownerReachable || staticallyImported; }); fnDef = eligibleBindingCandidates[0]?.def; + sourceCandidates = eligibleBindingCandidates.map((candidate) => candidate.def); if (fnDef === undefined && bindingCandidates.length > 0) { recordSuppressedOutcome(options.recordResolutionOutcome, { + recordSourceBinding: options.recordSourceBinding, + site, phase: 'free-call-fallback', filePath: parsed.filePath, name: site.name, @@ -468,6 +516,7 @@ export function emitFreeCallFallback( ); if (narrowed.length === 1) { fnDef = narrowed[0]; + sourceCandidates = narrowed; } else if (narrowed.length > 1) { // Multiple survivors after conversion-rank scoring. // Suppress when all candidates share the same file (true @@ -476,6 +525,8 @@ export function emitFreeCallFallback( const sameFile = narrowed.every((d) => d.filePath === narrowed[0]!.filePath); if (sameFile) { recordSuppressedOutcome(options.recordResolutionOutcome, { + recordSourceBinding: options.recordSourceBinding, + site, phase: 'free-call-fallback', filePath: parsed.filePath, name: site.name, @@ -492,6 +543,7 @@ export function emitFreeCallFallback( } // narrowed.length === 0: keep the first-match fnDef — // preserves local-shadows-import. + if (narrowed.length === 0) sourceCandidates = []; } } } else { @@ -520,6 +572,8 @@ export function emitFreeCallFallback( const key = siteKey(parsed.filePath, site); if (adlSuppressed && ordinary.length === 0) { recordSuppressedOutcome(options.recordResolutionOutcome, { + recordSourceBinding: options.recordSourceBinding, + site, phase: 'free-call-fallback', filePath: parsed.filePath, name: site.name, @@ -544,6 +598,7 @@ export function emitFreeCallFallback( const canNarrow = hasConstraints || options.conversionRankFn !== undefined; if (ordinary.length === 0 || !canNarrow) { fnDef = ordinary[0]; + sourceCandidates = ordinary; } else { const narrowed = narrowOverloadCandidates(ordinary, site.arity, site.argumentTypes, { argumentTypeClasses: site.argumentTypeClasses, @@ -554,6 +609,7 @@ export function emitFreeCallFallback( }); if (narrowed.length === 1) { fnDef = narrowed[0]; + sourceCandidates = narrowed; } else if (narrowed.length === 0) { handledSites.add(key); continue; @@ -561,6 +617,8 @@ export function emitFreeCallFallback( // These candidates share the selected lexical tier. Different // files do not establish precedence between imported overloads. recordSuppressedOutcome(options.recordResolutionOutcome, { + recordSourceBinding: options.recordSourceBinding, + site, phase: 'free-call-fallback', filePath: parsed.filePath, name: site.name, @@ -596,11 +654,14 @@ export function emitFreeCallFallback( }); if (narrowed.length === 1) { fnDef = narrowed[0]; + sourceCandidates = narrowed; } else if (narrowed.length === 0) { handledSites.add(key); continue; } else if (narrowed.length > 1) { recordSuppressedOutcome(options.recordResolutionOutcome, { + recordSourceBinding: options.recordSourceBinding, + site, phase: 'free-call-fallback', filePath: parsed.filePath, name: site.name, @@ -707,9 +768,45 @@ export function emitFreeCallFallback( continue; } } - if (fnDef === undefined) continue; + if (fnDef === undefined) { + // A class called with ordinary call syntax still names its type, even + // when no constructor method exists for a runtime CALLS edge. + if (options.recordSourceBinding !== undefined) { + const classes = [ + ...new Map( + nameClaim.bindings + .filter( + (binding) => + binding.def.type === 'Class' || + binding.def.type === 'Struct' || + binding.def.type === 'Record', + ) + .map((binding) => [binding.def.nodeId, binding.def]), + ).values(), + ]; + if (classes.length === 1) + options.recordSourceBinding( + site, + sourceBindingForDefinition( + classes[0]!, + 'construction', + nameClaim.bindings, + nameClaim.scope?.id, + ), + ); + else if (classes.length > 1) + options.recordSourceBinding(site, { + status: 'ambiguous', + targetDefIds: classes.map((candidate) => candidate.nodeId), + basis: 'construction', + }); + } + continue; + } if (fnDef.isDeleted === true) { recordSuppressedOutcome(options.recordResolutionOutcome, { + recordSourceBinding: options.recordSourceBinding, + site, phase: 'free-call-fallback', filePath: parsed.filePath, name: site.name, @@ -731,9 +828,61 @@ export function emitFreeCallFallback( scopes, }) ) { + options.recordSourceBinding?.(site, { + status: 'blocked', + targetDefIds: [fnDef.nodeId], + basis: 'callable-visibility', + }); handledSites.add(siteKey(parsed.filePath, site)); continue; } + // Observe the source declaration BEFORE constructor retargeting, caller + // mapping and edge collapse can discard or change its identity. + if (options.recordSourceBinding !== undefined) { + const sourceDef = writtenDefinition ?? fnDef; + const candidateIds = [ + ...new Set(sourceCandidates?.map((candidate) => candidate.nodeId) ?? [sourceDef.nodeId]), + ]; + options.recordSourceBinding( + site, + fnDefFromGlobalNameFallback + ? { + status: 'unresolved', + targetDefIds: [sourceDef.nodeId], + basis: 'global-name-fallback', + } + : candidateIds.length === 0 + ? { + status: 'unresolved', + targetDefIds: [sourceDef.nodeId], + basis: 'free-call-incompatible', + } + : candidateIds.length > 1 + ? { + ...sourceBindingForDefinition( + sourceDef, + 'free-call-candidates', + nameClaim.bindings, + nameClaim.scope?.id, + ), + status: 'ambiguous', + targetDefIds: candidateIds, + basis: 'free-call-candidates', + ...(sourceCandidates !== undefined && + candidateIds.every((id) => + nameClaim.bindings.some((binding) => binding.def.nodeId === id), + ) + ? { candidatesComplete: true as const } + : {}), + } + : sourceBindingForDefinition( + sourceDef, + writtenDefinition === undefined ? 'free-call' : 'construction', + nameClaim.bindings, + nameClaim.scope?.id, + ), + ); + } const callerGraphId = resolveCallerGraphId( site.callerScope ?? site.inScope, scopes, @@ -891,8 +1040,16 @@ function recordSuppressedOutcome( }; readonly reason: ResolutionSuppressionReason; readonly candidates: readonly SymbolDefinition[]; + readonly site?: ReferenceSite; + readonly recordSourceBinding?: SourceBindingRecorder; }, ): void { + if (input.site !== undefined) + input.recordSourceBinding?.(input.site, { + status: input.candidates.length > 1 ? 'ambiguous' : 'blocked', + targetDefIds: input.candidates.map((candidate) => candidate.nodeId), + basis: input.reason, + }); record?.({ kind: 'suppressed', phase: input.phase, diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/imported-value-refs.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/imported-value-refs.ts index 6e47b8b88..596be37e0 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/imported-value-refs.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/imported-value-refs.ts @@ -29,6 +29,10 @@ import type { GraphNodeLookup } from '../graph-bridge/node-lookup.js'; import { tryEmitEdge } from '../graph-bridge/edges.js'; import { findValueBindingInScope } from '../scope/walkers.js'; import { callableFlowSiteKey } from './callable-value-flow.js'; +import { + sourceBindingForDefinition, + type SourceBindingRecorder, +} from '../source-binding-resolution.js'; /** * Confidence for a reference resolved through a finalized import binding. @@ -49,6 +53,7 @@ export function emitImportedValueReferences( nodeLookup: GraphNodeLookup, /** Sites an earlier pass already owns — never re-resolved here. */ skipSites: ReadonlySet, + recordSourceBinding?: SourceBindingRecorder, ): ImportedValueRefStats { let emitted = 0; const seen = new Set(); @@ -62,7 +67,41 @@ export function emitImportedValueReferences( const siteKey = callableFlowSiteKey(parsed.filePath, site.atRange); if (skipSites.has(siteKey)) continue; - const def = findValueBindingInScope(site.inScope, site.name, indexes); + const def = findValueBindingInScope( + site.inScope, + site.name, + indexes, + recordSourceBinding === undefined + ? undefined + : (claim) => { + // The written import may name a callable or a type even when + // this pass cannot emit a value edge for that declaration kind. + const candidates = [ + ...new Map( + claim.bindings.map((binding) => [binding.def.nodeId, binding.def]), + ).values(), + ]; + const first = candidates[0]; + if (first === undefined || first.filePath === parsed.filePath) return; + const binding = sourceBindingForDefinition( + first, + 'imported-value', + claim.bindings, + claim.scope?.id, + ); + recordSourceBinding( + site, + candidates.length === 1 + ? binding + : { + ...binding, + status: 'ambiguous', + targetDefIds: candidates.map((candidate) => candidate.nodeId), + candidatesComplete: true, + }, + ); + }, + ); if (def === undefined) continue; // See the header: same-file hits are already resolved, and accepting // them here would start emitting edges to block-local values. diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/property-dispatch.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/property-dispatch.ts index ef3f51943..c8f0ec915 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/property-dispatch.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/property-dispatch.ts @@ -51,7 +51,7 @@ import { tryEmitEdge, type CalleeIdCaptureCtx } from '../graph-bridge/edges.js'; import type { GraphNodeLookup } from '../graph-bridge/node-lookup.js'; import type { CalleeIdSink } from '../graph-bridge/callee-id-sink.js'; import { - findCallableBindingInScope, + findAllCallableBindingCandidatesInScope, findClassBindingInScope, findOwnedMember, isNamespaceNameShadowed, @@ -65,6 +65,10 @@ import { import { VALUE_REF_EDGE_REASON } from '../value-ref-edges.js'; import type { SemanticModel } from '../../model/semantic-model.js'; import { CALL_TARGET_TYPES } from '../../model/symbol-table.js'; +import { + sourceBindingForDefinition, + type SourceBindingRecorder, +} from '../source-binding-resolution.js'; /** * Keys registered by more than this many distinct functions are skipped — @@ -156,13 +160,26 @@ export function resolveValueRefTarget( model: SemanticModel, publishesImportedNames: boolean, namespaceOptions?: NamespaceTargetOptions, + recordSourceBinding?: SourceBindingRecorder, ): SymbolDefinition | undefined { const receiverName = site.explicitReceiver?.name; if (receiverName === undefined) { - return findCallableBindingInScope(site.inScope, site.name, scopes, { + const candidates = findAllCallableBindingCandidatesInScope(site.inScope, site.name, scopes, { position: site.atRange, purpose: 'value', }); + const first = candidates[0]; + recordSourceBinding?.( + site, + candidates.length === 1 && first !== undefined + ? sourceBindingForDefinition(first.def, 'value-reference', first.bindings, site.inScope) + : { + status: candidates.length > 1 ? 'ambiguous' : 'unresolved', + targetDefIds: candidates.map((candidate) => candidate.def.nodeId), + basis: 'value-reference', + }, + ); + return first?.def; } // NAMESPACE FIRST, and the order is load-bearing. `findClassBindingInScope` // does not stop at the scope chain: when its `isClassLike` walk misses — and a @@ -189,6 +206,12 @@ export function resolveValueRefTarget( namespaceOptions, ); if (viaNamespace !== undefined) { + recordSourceBinding?.( + site, + viaNamespace === 'owned' + ? { status: 'blocked', targetDefIds: [], basis: 'namespace-value-reference' } + : sourceBindingForDefinition(viaNamespace, 'namespace-value-reference'), + ); // `'owned'` is NOT "no answer" — it is "this receiver is a namespace handle // this file wrote, and it names no callable member". The two must not be // conflated, because falling through from the second one reaches @@ -208,7 +231,14 @@ export function resolveValueRefTarget( return viaNamespace === 'owned' ? undefined : viaNamespace; } + let groundedOwner = true; const owner = findClassBindingInScope(site.inScope, receiverName, scopes, undefined, { + onNameFallback: + recordSourceBinding === undefined + ? undefined + : () => { + groundedOwner = false; + }, position: site.atRange, purpose: 'value', }); @@ -234,6 +264,16 @@ export function resolveValueRefTarget( return undefined; const member = findOwnedMember(owner.nodeId, site.name, model); if (member === undefined || !CALL_TARGET_TYPES.has(member.type)) return undefined; + recordSourceBinding?.( + site, + groundedOwner + ? sourceBindingForDefinition(member, 'qualified-value-reference') + : { + status: 'unresolved', + targetDefIds: [member.nodeId], + basis: 'value-reference-name-fallback', + }, + ); return member; } return undefined; @@ -385,6 +425,7 @@ export function emitPropertyDispatchCalls( */ publishesImportedNames = false, namespaceOptions?: NamespaceTargetOptions, + recordSourceBinding?: SourceBindingRecorder, ): { usesEmitted: number; callsEmitted: number; @@ -407,6 +448,7 @@ export function emitPropertyDispatchCalls( model, publishesImportedNames, namespaceOptions, + recordSourceBinding, ); if (def === undefined) continue; diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts index 6fa4fa920..614b0aea0 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts @@ -122,6 +122,10 @@ import { classifyReceiverShape } from '../resolution-outcome.js'; import type { ReceiverOrigin } from '../resolution-outcome.js'; import { decodeReceiverChain } from '../../utils/receiver-chain-codec.js'; import type { DecodedReceiverChain } from '../../utils/receiver-chain-codec.js'; +import { + sourceBindingForDefinition, + type SourceBindingRecorder, +} from '../source-binding-resolution.js'; /** Subset of `ScopeResolver` consumed by this pass. Accepting the * subset rather than the full provider keeps tests and partial @@ -202,6 +206,7 @@ type ReceiverBoundProviderSubset = Pick< | 'normalizeTypeArgument' | 'markConstructionSites' | 'namespaceExportsIncludeImportedNames' + | 'isNamespaceMemberVisible' | 'importsBindAtLexicalScope' | 'resolveNamespaceChains' >; @@ -396,6 +401,7 @@ export function emitReceiverBoundCalls( model: SemanticModel, options: { readonly recordResolutionOutcome?: ResolutionOutcomeRecorder; + readonly recordSourceBinding?: SourceBindingRecorder; /** Resolved-callee-id capture sink (#2227 U2). Threaded in under `--pdg` * OR for callable-flow's direct-target index (#2437, position-filtered); * `undefined` ⇒ zero overhead, byte-identity (R4). Per-file capture @@ -429,16 +435,34 @@ export function emitReceiverBoundCalls( // them resolve the same site to the same target. NEVER pre-seed // from the reference index — see Contract Invariant I5. const seen = new Set(); + // Active only while a source site is being resolved; nested class/chain + // lookups report a name guess at the actual decision that made it. + let onNameFallback: (() => void) | undefined; const fieldFallback = provider.fieldFallbackOnMethodLookup ?? true; const collapse = provider.collapseMemberCallsByCallerTarget === true; const hoistTypeBindingsToModule = provider.hoistTypeBindingsToModule === true; // Namespace-member lookup for Case 1 / Case 3: local exports only, unless // the provider publishes imported names too (hub modules — see // `ScopeResolver.namespaceExportsIncludeImportedNames`). + // + // A language that states which module members a qualifier can reach + // (`ScopeResolver.isNamespaceMemberVisible`) has the rest filtered here, so + // Case 1, the chain walk and Case 3 all see one answer. + const memberVisibleToQualifier = provider.isNamespaceMemberVisible; + const namespaceMemberVisible = + memberVisibleToQualifier === undefined + ? undefined + : (member: SymbolDefinition): boolean => memberVisibleToQualifier({ member }); const lookupNamespaceMember = (targetFile: string, name: string): SymbolDefinition | undefined => provider.namespaceExportsIncludeImportedNames === true - ? findExportedDefIncludingImportedNames(targetFile, name, index, scopes) - : findExportedDef(targetFile, name, index); + ? findExportedDefIncludingImportedNames( + targetFile, + name, + index, + scopes, + namespaceMemberVisible, + ) + : findExportedDef(targetFile, name, index, namespaceMemberVisible); // A class-like member `name` unique across `files`, or nothing — two // same-named classes behind one handle would mint a confident wrong edge. const uniqueClassAcross = ( @@ -526,7 +550,9 @@ export function emitReceiverBoundCalls( break; } if (cursor === undefined) { - const head = findClassBindingInScope(inScope, segments[0]!, scopes); + const head = findClassBindingInScope(inScope, segments[0]!, scopes, undefined, { + onNameFallback, + }); if (head === undefined || !isClassLike(head.type)) return undefined; cursor = { classDef: head }; rest = segments.slice(1); @@ -592,6 +618,7 @@ export function emitReceiverBoundCalls( fieldFallback, elementTypeOf: provider.elementTypeOf, namespaceExportsIncludeImportedNames: provider.namespaceExportsIncludeImportedNames === true, + namespaceMemberVisible, namespaceSkipsEnclosingClasses: provider.namespaceSkipsEnclosingClasses, hoistTypeBindingsToModule, stripReceiverCastExpressions: provider.stripReceiverCastExpressions === true, @@ -1120,11 +1147,41 @@ export function emitReceiverBoundCalls( for (const site of parsed.referenceSites) { if (site.kind !== 'call' && site.kind !== 'read' && site.kind !== 'write') continue; if (site.explicitReceiver === undefined) continue; + // A receiver-specific decision supersedes the provisional registry + // answer even when it deliberately declines to emit an edge. + options.recordSourceBinding?.(site, { + status: 'unresolved', + targetDefIds: [], + basis: 'receiver', + }); + let sourceBindingGrounded = true; + onNameFallback = + options.recordSourceBinding === undefined + ? undefined + : () => { + sourceBindingGrounded = false; + }; + const recordMember = (definition: SymbolDefinition): void => { + options.recordSourceBinding?.( + site, + sourceBindingGrounded + ? sourceBindingForDefinition(definition, 'receiver') + : { + status: 'unresolved', + targetDefIds: [definition.nodeId], + basis: 'receiver-name-fallback', + }, + ); + }; const namespaces = namespaceCache.requiresLexicalLookup ? namespaceContext(site.inScope, site.atRange) : fileNamespaces; - const { namespaceTargets, fileCompoundOpts } = namespaces; + const { namespaceTargets } = namespaces; + const fileCompoundOpts = + onNameFallback === undefined + ? namespaces.fileCompoundOpts + : { ...namespaces.fileCompoundOpts, onNameFallback }; const receiverName = site.explicitReceiver.name; const memberName = site.name; @@ -1163,6 +1220,11 @@ export function emitReceiverBoundCalls( // `lookupCore` match the enclosing class's member by name. // No-op for every language that leaves `Scope.ownsReceivers` unset. if (isReceiverOwnedButUnbound(site.inScope, receiverName, scopes)) { + options.recordSourceBinding?.(site, { + status: 'blocked', + targetDefIds: [], + basis: 'receiver-owned-but-unbound', + }); options.recordResolutionOutcome?.({ kind: 'suppressed', phase: 'receiver-bound-calls', @@ -1222,6 +1284,7 @@ export function emitReceiverBoundCalls( memberName, model, provider, + options.recordSourceBinding, ); handledSites.add(siteKey); continue; @@ -1233,6 +1296,7 @@ export function emitReceiverBoundCalls( parsed.filePath, site, memberDef, + options.recordSourceBinding, ) ) { handledSites.add(siteKey); @@ -1254,6 +1318,7 @@ export function emitReceiverBoundCalls( // superclass via its `import`/`from … import …` binding. // Closing that gap requires realigning the legacy tier // classifier and is tracked separately. + recordMember(memberDef); const ok = tryEmitEdge( graph, scopes, @@ -1350,6 +1415,7 @@ export function emitReceiverBoundCalls( memberName, model, provider, + options.recordSourceBinding, ); handledSites.add(siteKey); continue; @@ -1361,11 +1427,13 @@ export function emitReceiverBoundCalls( parsed.filePath, site, memberDef, + options.recordSourceBinding, ) ) { handledSites.add(siteKey); continue; } + recordMember(memberDef); const ok = tryEmitEdge( graph, scopes, @@ -1452,6 +1520,7 @@ export function emitReceiverBoundCalls( // AND `isStaticOnly`, the chain-walk below MUST adopt the // skip-and-walk-on filter pattern used by Cases 0, 3b, and 4. if (provider.resolveThisViaEnclosingClass === true && receiverName === 'this') { + sourceBindingGrounded = true; const enclosingClass = findEnclosingClassDef(site.inScope, scopes); if (enclosingClass !== undefined) { const languageResolution = provider.resolveReceiverMember?.( @@ -1462,6 +1531,11 @@ export function emitReceiverBoundCalls( model, ); if (languageResolution?.kind === 'ambiguous') { + options.recordSourceBinding?.(site, { + status: 'ambiguous', + targetDefIds: languageResolution.candidateIds, + basis: 'member-lookup-ambiguous', + }); options.recordResolutionOutcome?.({ kind: 'suppressed', phase: 'receiver-bound-calls', @@ -1482,6 +1556,7 @@ export function emitReceiverBoundCalls( parsed.filePath, site, memberDef, + options.recordSourceBinding, ) ) { handledSites.add(siteKey); @@ -1494,6 +1569,7 @@ export function emitReceiverBoundCalls( ? 'import-resolved' : 'global'; const confidence = site.kind === 'write' || site.kind === 'read' ? 1.0 : 0.85; + recordMember(memberDef); const ok = tryEmitEdge( graph, scopes, @@ -1565,10 +1641,20 @@ export function emitReceiverBoundCalls( } } if (ambiguous) { + options.recordSourceBinding?.(site, { + status: 'ambiguous', + targetDefIds: [], + basis: 'member-lookup-ambiguous', + }); handledSites.add(siteKey); continue; } if (hiddenByName) { + options.recordSourceBinding?.(site, { + status: 'blocked', + targetDefIds: [], + basis: 'member-name-hiding', + }); handledSites.add(siteKey); continue; } @@ -1579,6 +1665,7 @@ export function emitReceiverBoundCalls( parsed.filePath, site, memberDef, + options.recordSourceBinding, ) ) { handledSites.add(siteKey); @@ -1591,6 +1678,7 @@ export function emitReceiverBoundCalls( ? 'import-resolved' : 'global'; const confidence = site.kind === 'write' || site.kind === 'read' ? 1.0 : 0.85; + recordMember(memberDef); const ok = tryEmitEdge( graph, scopes, @@ -1651,9 +1739,34 @@ export function emitReceiverBoundCalls( if (cursor !== undefined && 'files' in cursor) targetFiles = cursor.files; } if (targetFiles !== undefined && provider.resolveQualifiedReceiverMember === undefined) { + sourceBindingGrounded = true; + // Graph emission historically picks the first package-file hit. An + // edit needs the complete candidate set at this same namespace tier. + const namespaceMembers = + options.recordSourceBinding === undefined + ? undefined + : new Map( + targetFiles.map((targetFile) => [ + targetFile, + lookupNamespaceMember(targetFile, memberName), + ]), + ); + const namespaceCandidateIds = + namespaceMembers === undefined + ? [] + : [ + ...new Set( + [...namespaceMembers.values()].flatMap((definition) => + definition === undefined ? [] : [definition.nodeId], + ), + ), + ]; let found = false; for (const targetFile of targetFiles) { - const memberDef = lookupNamespaceMember(targetFile, memberName); + const memberDef = + namespaceMembers === undefined + ? lookupNamespaceMember(targetFile, memberName) + : namespaceMembers.get(targetFile); if (memberDef !== undefined) { if ( suppressDeletedCallTarget( @@ -1661,12 +1774,20 @@ export function emitReceiverBoundCalls( parsed.filePath, site, memberDef, + options.recordSourceBinding, ) ) { handledSites.add(siteKey); found = true; break; } + if (namespaceCandidateIds.length > 1) + options.recordSourceBinding?.(site, { + status: 'ambiguous', + targetDefIds: namespaceCandidateIds, + basis: 'namespace-member', + }); + else recordMember(memberDef); const ok = tryEmitEdge( graph, scopes, @@ -1703,6 +1824,7 @@ export function emitReceiverBoundCalls( // Runs before Case 2 so namespace receivers don't accidentally match a // class with the same simple name. if (provider.resolveQualifiedReceiverMember !== undefined) { + sourceBindingGrounded = true; const memberDef = provider.resolveQualifiedReceiverMember( receiverName, memberName, @@ -1712,6 +1834,11 @@ export function emitReceiverBoundCalls( site, ); if (memberDef === 'ambiguous') { + options.recordSourceBinding?.(site, { + status: 'ambiguous', + targetDefIds: [], + basis: 'qualified-member-ambiguous', + }); // Same-name ambiguity across inline-namespace children (#1564): // suppress edge emission, mark site handled. options.recordResolutionOutcome?.({ @@ -1733,11 +1860,13 @@ export function emitReceiverBoundCalls( parsed.filePath, site, memberDef, + options.recordSourceBinding, ) ) { handledSites.add(siteKey); continue; } + recordMember(memberDef); const ok = tryEmitEdge( graph, scopes, @@ -1764,17 +1893,45 @@ export function emitReceiverBoundCalls( // hub module (a file made only of re-exports) answers when the provider // opted in. Only a bare tail is walked here; `ns.Type.field.m()` is the // compound resolver's shape. + sourceBindingGrounded = true; const classDef = findClassBindingInScope(site.inScope, receiverName, scopes, undefined, { + onNameFallback, position: site.atRange, purpose: 'value', }) ?? resolveNamespaceQualifiedClass(receiverName, site.inScope, namespaceTargets, site.atRange); if (classDef !== undefined) { + const languageResolution = provider.resolveReceiverMember?.( + classDef, + memberName, + site, + scopes, + model, + ); + if (languageResolution?.kind === 'ambiguous') { + options.recordSourceBinding?.(site, { + status: 'ambiguous', + targetDefIds: languageResolution.candidateIds, + basis: 'member-lookup-ambiguous', + }); + options.recordResolutionOutcome?.({ + kind: 'suppressed', + phase: 'receiver-bound-calls', + filePath: parsed.filePath, + name: site.name, + range: site.atRange, + reason: 'member-lookup-ambiguous', + candidateIds: languageResolution.candidateIds, + }); + handledSites.add(siteKey); + continue; + } const chain = [classDef.nodeId, ...scopes.methodDispatch.mroFor(classDef.nodeId)]; - let memberDef: SymbolDefinition | undefined; + let memberDef: SymbolDefinition | undefined = + languageResolution?.kind === 'resolved' ? languageResolution.definition : undefined; let ambiguousOwnerId: string | undefined; - for (const ownerId of chain) { + for (const ownerId of memberDef ? [] : chain) { const picked = site.kind === 'call' ? pickOverload(ownerId, memberName, site, model, provider) @@ -1809,6 +1966,7 @@ export function emitReceiverBoundCalls( memberName, model, provider, + options.recordSourceBinding, ); handledSites.add(siteKey); continue; @@ -1826,11 +1984,13 @@ export function emitReceiverBoundCalls( parsed.filePath, site, nested, + options.recordSourceBinding, ) ) { handledSites.add(siteKey); continue; } + recordMember(nested); const ok = tryEmitEdge( graph, scopes, @@ -1859,6 +2019,7 @@ export function emitReceiverBoundCalls( parsed.filePath, site, memberDef, + options.recordSourceBinding, ) ) { handledSites.add(siteKey); @@ -1871,6 +2032,7 @@ export function emitReceiverBoundCalls( ? 'import-resolved' : 'global'; const confidence = site.kind === 'write' || site.kind === 'read' ? 1.0 : 0.85; + recordMember(memberDef); const ok = tryEmitEdge( graph, scopes, @@ -1894,6 +2056,7 @@ export function emitReceiverBoundCalls( position: site.atRange, }); if (typeRef !== undefined && typeRef.rawName.includes('.')) { + sourceBindingGrounded = true; const [nsName, ...classNameParts] = typeRef.rawName.split('.'); const className = classNameParts.join('.'); // With the chain walk the dotted type is resolved as a whole @@ -1932,6 +2095,7 @@ export function emitReceiverBoundCalls( memberName, model, provider, + options.recordSourceBinding, ); handledSites.add(siteKey); found3 = true; @@ -1945,12 +2109,14 @@ export function emitReceiverBoundCalls( parsed.filePath, site, memberDef, + options.recordSourceBinding, ) ) { handledSites.add(siteKey); found3 = true; break; } + recordMember(memberDef); const ok = tryEmitEdge( graph, scopes, @@ -1995,6 +2161,7 @@ export function emitReceiverBoundCalls( ? (typeRef.rawName.split('.', 1)[0] ?? '') : undefined; if (typeRef !== undefined && chainHead !== undefined && !namespaceTargets.has(chainHead)) { + sourceBindingGrounded = true; // Try the plain dotted-field walk first — covers property / // collection-accessor shapes (`.Values`, Kotlin `.size`) and // field chains. Fall back to call-form (`x()`) which treats @@ -2058,6 +2225,7 @@ export function emitReceiverBoundCalls( memberName, model, provider, + options.recordSourceBinding, ); handledSites.add(siteKey); continue; @@ -2069,11 +2237,13 @@ export function emitReceiverBoundCalls( parsed.filePath, site, memberDef, + options.recordSourceBinding, ) ) { handledSites.add(siteKey); continue; } + recordMember(memberDef); const ok = tryEmitEdge( graph, scopes, @@ -2145,6 +2315,7 @@ export function emitReceiverBoundCalls( // ── Case 4: simple typeBinding (`u: U`) ────────────────────── if (typeRef !== undefined && !typeRef.rawName.includes('.')) { + sourceBindingGrounded = true; // A `rawName` the capture layer reduced from a type application is // resolved through the application it was written as, so the erasure // takes the GROUNDED route rather than binding whatever the workspace @@ -2155,7 +2326,11 @@ export function emitReceiverBoundCalls( typeApplication ?? typeRef.rawName, scopes, undefined, - { position: typeRef.lookupPosition, purpose: typeRef.lookupPurpose ?? 'type' }, + { + position: typeRef.lookupPosition, + purpose: typeRef.lookupPurpose ?? 'type', + onNameFallback, + }, ); // `findClassBindingInScope(..., typeRef.rawName)` only works when // rawName is itself a class symbol reachable through scope bindings. @@ -2183,7 +2358,10 @@ export function emitReceiverBoundCalls( const qnameIds = scopes.qualifiedNames.get(typeRef.rawName); if (qnameIds.length === 1) { const qdef = scopes.defs.get(qnameIds[0]!); - if (qdef !== undefined && isClassLike(qdef.type)) ownerDef = qdef; + if (qdef !== undefined && isClassLike(qdef.type)) { + ownerDef = qdef; + sourceBindingGrounded = false; + } } } // Map for-of tuple bindings (`__MAP_TUPLE_i__:mapId`), callable @@ -2250,6 +2428,11 @@ export function emitReceiverBoundCalls( model, ); if (languageResolution?.kind === 'ambiguous') { + options.recordSourceBinding?.(site, { + status: 'ambiguous', + targetDefIds: languageResolution.candidateIds, + basis: 'member-lookup-ambiguous', + }); options.recordResolutionOutcome?.({ kind: 'suppressed', phase: 'receiver-bound-calls', @@ -2270,6 +2453,7 @@ export function emitReceiverBoundCalls( parsed.filePath, site, memberDef, + options.recordSourceBinding, ) ) { handledSites.add(siteKey); @@ -2282,6 +2466,7 @@ export function emitReceiverBoundCalls( ? 'import-resolved' : 'global'; const confidence = site.kind === 'write' || site.kind === 'read' ? 1.0 : 0.85; + recordMember(memberDef); const ok = tryEmitEdge( graph, scopes, @@ -2364,6 +2549,7 @@ export function emitReceiverBoundCalls( memberName, model, provider, + options.recordSourceBinding, ); handledSites.add(siteKey); continue; @@ -2384,6 +2570,7 @@ export function emitReceiverBoundCalls( parsed.filePath, site, memberDef, + options.recordSourceBinding, ) ) { handledSites.add(siteKey); @@ -2399,6 +2586,7 @@ export function emitReceiverBoundCalls( ? 'import-resolved' : 'global'; const confidence = site.kind === 'write' || site.kind === 'read' ? 1.0 : 0.85; + recordMember(memberDef); const ok = tryEmitEdge( graph, scopes, @@ -2683,6 +2871,7 @@ export function emitReceiverBoundCalls( // `tryEmitEdgeWithExplicitTargetId` instead, passing `picked.nodeId` // directly — same dedup-key shape, collapse-flag honoring, and // caller resolution as `tryEmitEdge`. + sourceBindingGrounded = true; const valueDef = findValueBindingInScope(site.inScope, receiverName, scopes); if (valueDef !== undefined) { const ownerGraphId = @@ -2697,6 +2886,7 @@ export function emitReceiverBoundCalls( memberName, model, provider, + options.recordSourceBinding, ); handledSites.add(siteKey); continue; @@ -2708,6 +2898,7 @@ export function emitReceiverBoundCalls( parsed.filePath, site, picked, + options.recordSourceBinding, ) ) { handledSites.add(siteKey); @@ -2733,6 +2924,7 @@ export function emitReceiverBoundCalls( ? 'import-resolved' : 'global'; const confidence = site.kind === 'write' || site.kind === 'read' ? 1.0 : 0.85; + recordMember(picked); const ok = tryEmitEdgeWithExplicitTargetId( graph, scopes, @@ -2799,6 +2991,7 @@ export function emitReceiverBoundCalls( // and never retarget an existing one. Contract Invariant I4 holds — // nothing above moved. if (compoundReceiverUnresolved) { + sourceBindingGrounded = true; const staticMemberReceiver = splitClassLevelMemberReceiver( receiverName, site.receiverChain, @@ -2811,7 +3004,7 @@ export function emitReceiverBoundCalls( staticMemberReceiver.headName, scopes, undefined, - { position: site.atRange, purpose: 'value' }, + { position: site.atRange, purpose: 'value', onNameFallback }, ); // The head must be the CLASS ITSELF, not a value that happens to // share its name — the same `currentIsClassConstant` test the text @@ -2859,6 +3052,7 @@ export function emitReceiverBoundCalls( fieldDeclaredType, scopes, provider.stripTypePreservingDecoration, + { onNameFallback }, ); if (receiverClass !== undefined) { const chain = [ @@ -2888,6 +3082,7 @@ export function emitReceiverBoundCalls( memberName, model, provider, + options.recordSourceBinding, ); handledSites.add(siteKey); continue; @@ -2899,6 +3094,7 @@ export function emitReceiverBoundCalls( parsed.filePath, site, memberDef, + options.recordSourceBinding, ) ) { handledSites.add(siteKey); @@ -2911,6 +3107,7 @@ export function emitReceiverBoundCalls( ? 'import-resolved' : 'global'; const confidence = site.kind === 'write' || site.kind === 'read' ? 1.0 : 0.85; + recordMember(memberDef); const ok = tryEmitEdge( graph, scopes, @@ -3195,8 +3392,14 @@ function suppressDeletedCallTarget( filePath: string, site: ParsedFile['referenceSites'][number], target: SymbolDefinition, + recordSourceBinding?: SourceBindingRecorder, ): boolean { if (site.kind !== 'call' || target.isDeleted !== true) return false; + recordSourceBinding?.(site, { + status: 'blocked', + targetDefIds: [target.nodeId], + basis: 'selected-callable-deleted', + }); record?.({ kind: 'suppressed', phase: 'receiver-bound-calls', @@ -3217,8 +3420,9 @@ function recordReceiverOverloadSuppression( memberName: string, model: SemanticModel, provider: ReceiverBoundProviderSubset, + recordSourceBinding?: SourceBindingRecorder, ): void { - if (record === undefined) return; + if (record === undefined && recordSourceBinding === undefined) return; const overloads = model.methods.lookupAllByOwner(ownerId, memberName); const candidates = narrowOverloadCandidates(overloads, site.arity, site.argumentTypes, { argumentTypeClasses: site.argumentTypeClasses, @@ -3234,7 +3438,12 @@ function recordReceiverOverloadSuppression( : hasConversionRankingSignal(site, provider) ? 'conversion-rank-tied' : 'overload-ambiguous'; - record({ + recordSourceBinding?.(site, { + status: 'ambiguous', + targetDefIds: candidates.map((candidate) => candidate.nodeId), + basis: reason, + }); + record?.({ kind: 'suppressed', phase: 'receiver-bound-calls', filePath, diff --git a/gitnexus/src/core/ingestion/scope-resolution/pipeline/phase.ts b/gitnexus/src/core/ingestion/scope-resolution/pipeline/phase.ts index 624e87937..474671d44 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/pipeline/phase.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/pipeline/phase.ts @@ -405,7 +405,8 @@ export const scopeResolutionPhase: PipelinePhase = { // evicts these again before the next language, so only one language's // ParsedFiles are resident at a time. const loadStoreFor = async (paths: ReadonlySet): Promise => { - if (!parsedFileStorePath) return; + if (!parsedFileStorePath || provider.languageProvider.parseStrategy === 'standalone') + return; const fromDisk = await loadParsedFilesForPaths(parsedFileStorePath, paths); for (const [fp, pf] of fromDisk) preExtractedByPath.set(fp, pf); }; @@ -491,6 +492,7 @@ export const scopeResolutionPhase: PipelinePhase = { scopeIndexStorePath: parsedFileStorePath, // CFG/PDG emission (#2081 M1) — opt-in; off ⇒ byte-identical graph. pdg: ctx.options?.pdg === true, + pdgMaxFunctionLines: ctx.options?.pdgMaxFunctionLines, pdgMaxEdgesPerFunction: ctx.options?.pdgMaxEdgesPerFunction, pdgMaxReachingDefEdgesPerFunction: ctx.options?.pdgMaxReachingDefEdgesPerFunction, pdgMaxCdgEdgesPerFunction: ctx.options?.pdgMaxCdgEdgesPerFunction, diff --git a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts index ef5ad246d..2c4168d44 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts @@ -23,17 +23,23 @@ * Plan: `docs/plans/2026-04-20-001-refactor-emit-pipeline-generalization-plan.md`. */ -import type { ParsedFile, RegistryProviders, Scope } from 'gitnexus-shared'; +import type { ParsedFile, ReferenceSite, RegistryProviders, Scope } from 'gitnexus-shared'; import type { TypeRef } from 'gitnexus-shared'; 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'; import { finalizeScopeModel } from '../../finalize-orchestrator.js'; import { resolveReferenceSites, type ResolveStats } from '../../resolve-references.js'; +import { + sourceBindingForDefinition, + type SourceBindingRecorder, + type SourceBindingResolution, +} from '../source-binding-resolution.js'; import { buildGraphNodeLookup } from '../graph-bridge/node-lookup.js'; import { emitFileCfgs, @@ -47,6 +53,7 @@ import { REACHING_DEF_FACTS_PER_EDGE_CAP, } from '../../cfg/emit.js'; import { createMemoizedReachingDefs } from '../../cfg/reaching-defs.js'; +import { DEFAULT_PDG_MAX_FUNCTION_LINES } from '../../cfg/collect.js'; import { emitFileTaint, DEFAULT_PDG_MAX_TAINT_FINDINGS_PER_FUNCTION, @@ -224,6 +231,7 @@ function preEmitInheritanceEdges( nodeLookup: ReturnType, recordTypeArguments: HeritageTypeArgumentSink, unresolvedInheritanceByClass: Set, + recordSourceBinding?: SourceBindingRecorder, ): Set { const handledSites = new Set(); const seen = new Set(); @@ -272,6 +280,7 @@ function preEmitInheritanceEdges( unresolvedInheritanceByClass.add(callerClass.nodeId); continue; } + recordSourceBinding?.(site, sourceBindingForDefinition(targetDef, 'inheritance')); const callerGraphId = resolveDefGraphId(callerClass.filePath, callerClass, nodeLookup); const targetGraphId = resolveDefGraphId(targetDef.filePath, targetDef, nodeLookup); if ( @@ -382,6 +391,14 @@ export type ScopeResolutionSubPhase = | 'linking symbols'; interface RunScopeResolutionInput { + /** Source-backed providers use the same executable-region cap as workers. */ + readonly pdgMaxFunctionLines?: number; + /** Observe the completed semantic pass without changing its graph output. */ + readonly onResolved?: (result: { + readonly parsedFiles: readonly ParsedFile[]; + readonly indexes: ScopeResolutionIndexes; + readonly referenceBindings: ReadonlyMap; + }) => void; readonly graph: KnowledgeGraph; /** * Semantic model populated by the legacy `parse` phase. Scope- @@ -617,6 +634,14 @@ export function runScopeResolution( const callableFlowOnly = provider.scopeResolutionEdgeMode === 'callable-flow-only'; const onWarn = input.onWarn ?? (() => {}); const resolutionOutcomes: ResolutionOutcome[] = []; + const referenceBindings = + input.onResolved === undefined ? undefined : new Map(); + const recordSourceBinding: SourceBindingRecorder | undefined = + referenceBindings === undefined + ? undefined + : (site, binding) => { + referenceBindings.set(site, binding); + }; const undecidedSatisfaction: UndecidedSatisfaction[] = []; const recordResolutionOutcome: ResolutionOutcomeRecorder = (outcome) => { resolutionOutcomes.push(outcome); @@ -674,6 +699,28 @@ export function runScopeResolution( if (extractionWarned) scopeExtractionFailedPaths.push(file.path); continue; } + if (input.pdg === true && provider.languageProvider.collectSourceCfgs) { + const warnCfg = (message: string): void => { + logger.warn(message); + input.onWarn?.(message); + }; + try { + const { cfgs, skipped } = provider.languageProvider.collectSourceCfgs( + file.content, + file.path, + input.pdgMaxFunctionLines ?? DEFAULT_PDG_MAX_FUNCTION_LINES, + ); + if (cfgs.length) parsed = { ...parsed, cfgSideChannel: cfgs }; + if (skipped.tooManyLines || skipped.tooDeeplyNested || skipped.buildError) { + warnCfg( + `[cfg] ${file.path}: skipped source regions (tooManyLines=${skipped.tooManyLines}, ` + + `tooDeeplyNested=${skipped.tooDeeplyNested}, buildError=${skipped.buildError})`, + ); + } + } catch (error) { + warnCfg(`[cfg] ${file.path}: source CFG extraction failed: ${String(error)}`); + } + } } // Worker-boundary restore: a pre-extracted ParsedFile was produced by // `extractParsedFile` running INSIDE a worker, so any capture-time @@ -722,6 +769,8 @@ export function runScopeResolution( // for repositories that use the provider but no first-class callables. if ( callableFlowOnly && + input.pdg !== true && + input.onResolved === undefined && !parsedFiles.some((parsed) => (parsed.callableFlowSites?.length ?? 0) > 0) ) { return { @@ -846,6 +895,14 @@ export function runScopeResolution( namedImportsBindTopLevelOnly: provider.namedImportsBindTopLevelOnly === true, }, }); + if (referenceBindings !== undefined) { + for (const site of finalized.referenceSites) + referenceBindings.set(site, { + status: 'unresolved', + targetDefIds: [], + basis: 'unresolved', + }); + } logHeapProbe('sr-post-finalize', `lang=${provider.language}`); // `export *` collisions the shared finalize refused to bind (WS1 C2). Recorded // as outcomes so the refusal is auditable next to the name-fallback census — @@ -884,6 +941,7 @@ export function runScopeResolution( nodeLookup, recordHeritageTypeArguments, unresolvedInheritanceByClass, + recordSourceBinding, ); // Call-based heritage hook (e.g., Ruby include/extend/prepend) — emits // IMPLEMENTS edges that `preEmitInheritanceEdges` cannot produce because @@ -1013,9 +1071,21 @@ export function runScopeResolution( const registryProviders: RegistryProviders = { arityCompatibility: provider.arityCompatibility, }; + const memberVisibleToQualifier = provider.isNamespaceMemberVisible; const { referenceIndex, stats: resolveStats } = resolveReferenceSites({ scopes: indexes, providers: registryProviders, + isNamespaceMemberVisible: + memberVisibleToQualifier === undefined + ? undefined + : (member) => memberVisibleToQualifier({ member }), + recordSourceBinding: + recordSourceBinding === undefined + ? undefined + : (site, binding) => { + // Inheritance already passed through its qualified/provider-aware seam. + if (site.kind !== 'inherits') recordSourceBinding(site, binding); + }, ownedMembersByOwner: (ownerDefId, memberName) => lookupOwnedMembersByOwner(readonlyModel, ownerDefId, memberName), }); @@ -1171,6 +1241,7 @@ export function runScopeResolution( readonlyModel, { recordResolutionOutcome, + recordSourceBinding, calleeIdSink: calleeIdAccumulator, // The pass's only source of positive EXTERNAL evidence for a dropped // receiver (`console.log`, `fetch(...)`). Same hook, same spelling as @@ -1237,6 +1308,7 @@ export function runScopeResolution( freeCallsRequireInstanceOwnership: provider.freeCallsRequireInstanceOwnership === true, implicitThisWalksMro: provider.implicitThisWalksMro === true, isCallableVisibleFromCaller: provider.isCallableVisibleFromCaller, + isNamespaceMemberVisible: provider.isNamespaceMemberVisible, resolveAdlCandidates: provider.resolveAdlCandidates, resolveOrdinaryCallables: provider.resolveOrdinaryCallables, resolveQualifiedFreeCall: provider.resolveQualifiedFreeCall, @@ -1244,6 +1316,7 @@ export function runScopeResolution( conversionOnlyArgTypePrefixes: provider.conversionOnlyArgTypePrefixes, constraintCompatibility: provider.constraintCompatibility, recordResolutionOutcome, + recordSourceBinding, calleeIdSink: calleeIdAccumulator, skipSites: deferredIndirectSites, }, @@ -1271,6 +1344,7 @@ export function runScopeResolution( readonlyModel, { calleeIdSink: calleeIdAccumulator, + recordSourceBinding, isBuiltInName: provider.languageProvider.isBuiltInName, heritageTypeArguments, unresolvedInheritanceByClass, @@ -1355,6 +1429,7 @@ export function runScopeResolution( emitParsedFiles, postHeritageNodeLookup, uniqueNameSkipSites, + recordSourceBinding, ); // A language that opts out of name fallback still gets DETECTION. Skipping @@ -1427,6 +1502,7 @@ export function runScopeResolution( skipEnclosingClasses: provider.namespaceSkipsEnclosingClasses, moduleFileExists: (filePath) => indexes.moduleScopes.get(filePath) !== undefined, }, + recordSourceBinding, ); if (propertyDispatch.skippedKeys > 0) { // Never drop dispatch coverage silently: a hook table larger than the @@ -1476,10 +1552,14 @@ export function runScopeResolution( // Language-specific supplementary edges (e.g. Vue template-derived // BINDS_EVENT_HANDLER / EMITS_EVENT / CALLS / ACCESSES edges). // Runs last so the full graph — including import edges — is visible. - if (!callableFlowOnly && provider.emitPostResolutionEdges !== undefined) { + if ( + (!callableFlowOnly || recordSourceBinding !== undefined) && + provider.emitPostResolutionEdges !== undefined + ) { provider.emitPostResolutionEdges(graph, emitParsedFiles, postHeritageNodeLookup, indexes, { fileContents: getFileContents(), resolutionConfig, + recordSourceBinding, }); } @@ -1896,6 +1976,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, referenceBindings: referenceBindings! }); return { filesProcessed: parsedFiles.length, diff --git a/gitnexus/src/core/ingestion/scope-resolution/scope/canonicalize-bindings.ts b/gitnexus/src/core/ingestion/scope-resolution/scope/canonicalize-bindings.ts new file mode 100644 index 000000000..0f74ddd0e --- /dev/null +++ b/gitnexus/src/core/ingestion/scope-resolution/scope/canonicalize-bindings.ts @@ -0,0 +1,160 @@ +import type { + BindingRef, + CallableFlowOperand, + CallableFlowSite, + ParsedFile, + SymbolDefinition, +} from 'gitnexus-shared'; +import { indexExportsByName } from 'gitnexus-shared'; +import { decodeReceiverChain, encodeReceiverChain } from '../../utils/receiver-chain-codec.js'; + +/** Normalize lookup spellings while retaining source identities and coordinates. */ +export function canonicalizeBindings( + parsed: ParsedFile, + canonicalName: (name: string) => string, +): ParsedFile { + const bindingsOf = (scope: ParsedFile['scopes'][number]) => { + const bindings = new Map(); + for (const [name, values] of scope.bindings) { + const key = canonicalName(name); + bindings.set(key, [...(bindings.get(key) ?? []), ...values]); + } + return bindings; + }; + const operand = (value: CallableFlowOperand): CallableFlowOperand => ({ + ...value, + // Qualified designators retain their written member identity, just like + // rawQualifiedName on a reference; plain operands are lexical scope keys. + name: value.qualifiedName ? value.name : canonicalName(value.name), + }); + const flow = (site: CallableFlowSite): CallableFlowSite => { + switch (site.kind) { + case 'seed': + return { + ...site, + destination: operand(site.destination), + targetName: site.targetQualifiedName ? site.targetName : canonicalName(site.targetName), + }; + case 'copy': + case 'alias': + case 'address': + return { ...site, source: operand(site.source), destination: operand(site.destination) }; + case 'store': + return { ...site, source: operand(site.source), pointer: operand(site.pointer) }; + case 'load': + return { ...site, pointer: operand(site.pointer), destination: operand(site.destination) }; + case 'formal': + // ownerName joins the written definition/graph identity, not a binding. + return { ...site, binding: operand(site.binding) }; + case 'argument': + return { + ...site, + source: operand(site.source), + ...(site.directCalleeName + ? { directCalleeName: canonicalName(site.directCalleeName) } + : {}), + }; + case 'invoke': + return { + ...site, + callee: operand(site.callee), + ...(site.receiver ? { receiver: operand(site.receiver) } : {}), + }; + } + }; + const receiverChain = (value: string): string | undefined => { + const decoded = decodeReceiverChain(value); + if (!decoded) return value; + return encodeReceiverChain( + canonicalName(decoded.baseReceiverName), + // Steps join raw member declarations; only the base is a lexical key. + decoded.steps, + { truncated: decoded.truncated }, + ); + }; + return { + ...parsed, + ...(parsed.callableFlowSites ? { callableFlowSites: parsed.callableFlowSites.map(flow) } : {}), + ...(parsed.callResultAssignmentSites + ? { + callResultAssignmentSites: parsed.callResultAssignmentSites.map((site) => ({ + ...site, + lhs: canonicalName(site.lhs), + })), + } + : {}), + scopes: parsed.scopes.map((scope) => ({ + ...scope, + bindings: bindingsOf(scope), + lexicalNames: scope.lexicalNames && new Set([...scope.lexicalNames].map(canonicalName)), + nameClaims: scope.nameClaims?.map((claim) => ({ ...claim, name: canonicalName(claim.name) })), + typeBindings: new Map( + [...scope.typeBindings].map(([name, type]) => [ + canonicalName(name), + { + ...type, + rawName: canonicalName(type.rawName), + ...(type.declaredSpelling + ? { declaredSpelling: canonicalName(type.declaredSpelling) } + : {}), + }, + ]), + ), + })), + parsedImports: parsed.parsedImports.map((entry) => ({ + ...entry, + ...('localName' in entry ? { localName: canonicalName(entry.localName) } : {}), + ...('importedName' in entry ? { importedName: canonicalName(entry.importedName) } : {}), + ...('alias' in entry && entry.alias ? { alias: canonicalName(entry.alias) } : {}), + })), + referenceSites: parsed.referenceSites.map((site) => { + // Member registries use the written declaration name. Receiver bases + // and free names instead address the canonical lexical binding maps. + const name = + site.explicitReceiver || site.callForm === 'member' ? site.name : canonicalName(site.name); + const receiverName = site.explicitReceiver && canonicalName(site.explicitReceiver.name); + const chain = site.receiverChain && receiverChain(site.receiverChain); + // Rename occurrences retain these objects as keys in referenceBindings. + // A second, already-normalized workspace pass must preserve that identity. + if ( + name === site.name && + receiverName === site.explicitReceiver?.name && + chain === site.receiverChain + ) + return site; + return { + ...site, + name, + ...(site.receiverChain ? { receiverChain: chain } : {}), + ...(site.explicitReceiver ? { explicitReceiver: { name: receiverName! } } : {}), + }; + }), + }; +} + +/** Use the same semantic spellings for ordinary indexing and rename planning. */ +export function canonicalizeWorkspaceBindings( + parsedFiles: ParsedFile[], + canonicalName: (name: string) => string, +): void { + for (let index = 0; index < parsedFiles.length; index++) { + parsedFiles[index] = canonicalizeBindings(parsedFiles[index], canonicalName); + } +} + +/** Preserve the default export winner policy while exposing semantic name keys. */ +export function canonicalModuleExports( + parsed: ParsedFile, + canonicalName: (name: string) => string, +): ReadonlyMap { + const exports = new Map(); + const ambiguous = new Set(); + for (const [spelling, definition] of indexExportsByName(parsed.localDefs)) { + const name = canonicalName(spelling); + const previous = exports.get(name); + if (previous && previous.nodeId !== definition.nodeId) ambiguous.add(name); + else exports.set(name, definition); + } + for (const name of ambiguous) exports.delete(name); + return exports; +} diff --git a/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts b/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts index 1b3a4e10f..8f81c5e1c 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts @@ -879,6 +879,9 @@ function soleBoundBaseName(bound: string): string | undefined { } export type ClassBindingLookup = NameLookupOptions & { + /** A selected class came from a workspace-name guess, rather than a + * binding/namespace decision. Observers must not treat it as edit proof. */ + readonly onNameFallback?: () => void; /** * When false, skip the workspace-unique QualifiedNameIndex hit (and the * dotted-tail variant). Default true — Go inheritance still needs that @@ -890,6 +893,14 @@ export type ClassBindingLookup = NameLookupOptions & { * `isGlobalNameFallbackPlausible`. */ readonly uniqueQualifiedNameFallback?: boolean; + /** + * `ScopeResolver.isNamespaceMemberVisible`, bound to the provider. A type + * reached through a namespace import (`ns.Type`) is dropped when this refuses + * it, as though the namespace's target files did not declare it. Absent (the + * default) keeps every declaration of those files, so a caller that does not + * pass it is unchanged. + */ + readonly namespaceMemberVisible?: (def: SymbolDefinition) => boolean; }; export function findClassBindingInScope( @@ -954,7 +965,12 @@ export function findClassBindingInScope( for (const spelling of [receiverName, memberName]) { for (const id of scopes.qualifiedNames.get(spelling)) { const def = scopes.defs.get(id); - if (def !== undefined && isClassLike(def.type) && targetFiles.has(def.filePath)) + if ( + def !== undefined && + isClassLike(def.type) && + targetFiles.has(def.filePath) && + (lookup?.namespaceMemberVisible?.(def) ?? true) + ) candidates.set(def.nodeId, def); } } @@ -971,7 +987,10 @@ export function findClassBindingInScope( const qnames = scopes.qualifiedNames.get(receiverName); if (qnames.length === 1) { const def = scopes.defs.get(qnames[0]!); - if (def !== undefined && isClassLike(def.type)) return def; + if (def !== undefined && isClassLike(def.type)) { + lookup?.onNameFallback?.(); + return def; + } } // Second fallback: dotted names like "models.User" — try the simple // name (tail after last dot) for languages where defs are indexed by @@ -982,7 +1001,10 @@ export function findClassBindingInScope( const simpleIds = scopes.qualifiedNames.get(simple); if (simpleIds.length === 1) { const def = scopes.defs.get(simpleIds[0]!); - if (def !== undefined && isClassLike(def.type)) return def; + if (def !== undefined && isClassLike(def.type)) { + lookup?.onNameFallback?.(); + return def; + } } } } @@ -1489,7 +1511,7 @@ export function resolveInheritanceBaseInScope( * unknown, or genuinely ambiguous at a key (refuse-on-tie — never guess; a * wrong EXTENDS edge silently corrupts impact analysis). */ -function resolveQualifiedInheritanceBase( +export function resolveQualifiedInheritanceBase( startScope: ScopeId, rawQualifiedName: string, scopes: ScopeResolutionIndexes, @@ -1770,8 +1792,16 @@ export function findValueBindingInScope( startScope: ScopeId, receiverName: string, scopes: ScopeResolutionIndexes, + observeClaim?: (claim: NameClaimResult) => void, ): SymbolDefinition | undefined { - return walkScopeChain(startScope, receiverName, scopes, (def) => isOwnableValueLabel(def.type)); + return walkScopeChain( + startScope, + receiverName, + scopes, + (def) => isOwnableValueLabel(def.type), + undefined, + observeClaim, + ); } /** @@ -1810,8 +1840,10 @@ function walkScopeChain( scopes: ScopeResolutionIndexes, predicate: (def: SymbolDefinition) => boolean, options?: NameLookupOptions, + observeClaim?: (claim: NameClaimResult) => void, ): SymbolDefinition | undefined { const claim = lookupMatchingNameClaim(startScope, name, scopes, predicate, options); + observeClaim?.(claim); return claim.bindings.find((binding) => predicate(binding.def))?.def; } @@ -2239,13 +2271,20 @@ export function findExportedDef( targetFile: string, memberName: string, index: WorkspaceResolutionIndex, + /** + * `ScopeResolver.isNamespaceMemberVisible`, already bound to the provider. + * Absent (the default) accepts every local binding, byte-identical to the + * pre-hook behaviour; present, a local def it refuses is skipped as though the + * target file did not declare it. + */ + isVisible?: (def: SymbolDefinition) => boolean, ): SymbolDefinition | undefined { const moduleScope = index.moduleScopeByFile.get(targetFile); if (moduleScope === undefined) return undefined; const refs = moduleScope.bindings.get(memberName); if (refs === undefined) return undefined; for (const ref of refs) { - if (ref.origin === 'local') return ref.def; + if (ref.origin === 'local' && (isVisible === undefined || isVisible(ref.def))) return ref.def; } return undefined; } @@ -2280,9 +2319,15 @@ export function findExportedDefIncludingImportedNames( memberName: string, index: WorkspaceResolutionIndex, scopes: ScopeResolutionIndexes, + /** See `findExportedDef`. A local declaration the predicate refuses still OWNS + * the name: it must not fall through to a re-published name of the same + * spelling, which would answer for a member the module itself declared. */ + isVisible?: (def: SymbolDefinition) => boolean, ): SymbolDefinition | undefined { - const local = findExportedDef(targetFile, memberName, index); + const local = findExportedDef(targetFile, memberName, index, isVisible); if (local !== undefined) return local; + if (isVisible !== undefined && findExportedDef(targetFile, memberName, index) !== undefined) + return undefined; const moduleScope = index.moduleScopeByFile.get(targetFile); if (moduleScope === undefined) return undefined; let picked: SymbolDefinition | undefined; diff --git a/gitnexus/src/core/ingestion/scope-resolution/source-binding-resolution.ts b/gitnexus/src/core/ingestion/scope-resolution/source-binding-resolution.ts new file mode 100644 index 000000000..a6fea4986 --- /dev/null +++ b/gitnexus/src/core/ingestion/scope-resolution/source-binding-resolution.ts @@ -0,0 +1,61 @@ +import type { + BindingRef, + ReferenceSite, + ResolutionEvidence, + ScopeId, + SymbolDefinition, +} from 'gitnexus-shared'; + +/** The declaration named by a source token, independently of graph-edge targets. + * `resolved` identifies a declaration. An ambiguous but complete candidate set + * can identify one provider-proven rename family. Confidence, runtime dispatch + * and a globally unique spelling never establish either proof. */ +export interface SourceBindingResolution { + readonly status: 'resolved' | 'blocked' | 'ambiguous' | 'unresolved'; + readonly targetDefIds: readonly string[]; + /** Every possible written binding is present; never set for fallback guesses. */ + readonly candidatesComplete?: true; + readonly basis: string; + readonly evidence?: readonly ResolutionEvidence[]; + readonly bindingKind?: 'direct' | 'import-alias'; + readonly importAlias?: { + readonly localName: string; + readonly importedName: string; + readonly targetFile: string | null; + readonly scopeId: ScopeId; + }; +} + +export type SourceBindingRecorder = ( + site: ReferenceSite, + resolution: SourceBindingResolution, +) => void; + +/** Keep the selected binding's provenance; an explicit alias may have the + * same spelling as its export and still owns a distinct source name. */ +export function sourceBindingForDefinition( + definition: SymbolDefinition, + basis: string, + bindings?: readonly BindingRef[], + scopeId?: ScopeId, +): SourceBindingResolution { + const binding = bindings?.find((candidate) => candidate.def.nodeId === definition.nodeId); + const via = binding?.via; + const alias = via?.kind === 'alias' && scopeId !== undefined; + return { + status: 'resolved', + targetDefIds: [definition.nodeId], + basis, + bindingKind: alias ? 'import-alias' : 'direct', + ...(alias + ? { + importAlias: { + localName: via.localName, + importedName: via.targetExportedName, + targetFile: via.targetFile, + scopeId, + }, + } + : {}), + }; +} diff --git a/gitnexus/src/core/ingestion/utils/receiver-chain-captures.ts b/gitnexus/src/core/ingestion/utils/receiver-chain-captures.ts index 687e687b2..0386c85c9 100644 --- a/gitnexus/src/core/ingestion/utils/receiver-chain-captures.ts +++ b/gitnexus/src/core/ingestion/utils/receiver-chain-captures.ts @@ -1,9 +1,9 @@ /** - * Synthesize `@reference.receiver-chain` for a call match whose receiver is + * Synthesize `@reference.receiver-chain` for a reference match whose receiver is * itself an expression. * * Shared across every language emitter, and deliberately language-free (AGENTS.md - * R6): the call gate reads the `@reference.call.*` tag vocabulary, which is + * R6): the gate reads the `@reference.call.*`, read and write tag vocabulary, which is * shared by every language's `.scm` query, rather than a per-language tag list. * * Lives in the emitter layer rather than the query because a `.scm` pattern @@ -22,22 +22,28 @@ import { encodeReceiverChain } from './receiver-chain-codec.js'; * excluded by the trailing dot — it marks a callable VALUE, not a call site. */ const CALL_TAG_PREFIX = '@reference.call.'; -/** Does this match represent a call? */ -function isCallMatch(grouped: Record): boolean { +/** Does this reference kind support compound receiver resolution? */ +function isReceiverMatch(grouped: Record): boolean { for (const tag of Object.keys(grouped)) { - if (tag.startsWith(CALL_TAG_PREFIX)) return true; + if ( + tag.startsWith(CALL_TAG_PREFIX) || + tag === '@reference.read' || + tag.startsWith('@reference.read.') || + tag === '@reference.write' || + tag.startsWith('@reference.write.') + ) + return true; } return false; } /** - * Add `@reference.receiver-chain` to `grouped` when the match is a call whose - * receiver decodes to a chain over a nameable base. Mutates `grouped` in place + * Add `@reference.receiver-chain` to `grouped` when its receiver decodes to a + * chain over a nameable base. Mutates `grouped` in place * and returns nothing — mirroring how the other synthesized captures are added. * - * Gated on the call tags: without that gate this fires on read, write and JSX - * receivers too, which pay the AST walk and the persisted bytes for a field no - * call-site resolver reads. + * Calls and field reads/writes share the compound-receiver resolver. Exclude + * other capture kinds whose consumers do not interpret a receiver chain. * * A chain is minted ONLY when the walk reached a nameable base. * `extractMixedChain` reports `baseReceiverName: undefined` whenever it stopped @@ -52,7 +58,7 @@ export function synthesizeReceiverChainCapture( ): void { if (receiverNode === undefined || receiverNode === null) return; if (grouped['@reference.receiver-chain'] !== undefined) return; - if (!isCallMatch(grouped)) return; + if (!isReceiverMatch(grouped)) return; const extracted = extractMixedChain(receiverNode); if (extracted === undefined || extracted.baseReceiverName === undefined) return; diff --git a/gitnexus/src/core/ingestion/workers/parse-worker.ts b/gitnexus/src/core/ingestion/workers/parse-worker.ts index dffea704b..5ccc71135 100644 --- a/gitnexus/src/core/ingestion/workers/parse-worker.ts +++ b/gitnexus/src/core/ingestion/workers/parse-worker.ts @@ -2921,15 +2921,23 @@ const processFileGroup = ( } } - // Append # to owned callable IDs to disambiguate overloads. - // Top-level Function IDs stay stable; functions inside an owner may overload. + // Providers opt free functions into the same signature identity scheme + // used by owned callables. A signature stays stable when overloads are added. + const freeFunctionOverloads = + nodeLabel === 'Function' && + enclosingClassId === null && + provider.overloadsFreeFunctions === true; // When same-arity collisions exist, append ~type1,type2 for further disambiguation. const needsAritySuffix = nodeLabel === 'Method' || nodeLabel === 'Constructor' || - (nodeLabel === 'Function' && enclosingClassId !== null); + (nodeLabel === 'Function' && enclosingClassId !== null) || + freeFunctionOverloads; let arityTag = needsAritySuffix && arityForId !== undefined ? `#${arityForId}` : ''; - if (arityTag && defMethodMap && defMethodInfo) { + if (freeFunctionOverloads) { + const parameterTypes = methodProps.parameterTypes as string[] | undefined; + if (parameterTypes?.length) arityTag += `~${parameterTypes.join(',')}`; + } else if (arityTag && defMethodMap && defMethodInfo) { const groups = buildCollisionGroups(defMethodMap); arityTag += typeTagForId( defMethodMap, diff --git a/gitnexus/src/core/ingestion/workers/result-merge.ts b/gitnexus/src/core/ingestion/workers/result-merge.ts index fc6a24ac6..7631189b8 100644 --- a/gitnexus/src/core/ingestion/workers/result-merge.ts +++ b/gitnexus/src/core/ingestion/workers/result-merge.ts @@ -64,6 +64,15 @@ export const mergeResult = (target: ParseWorkerResult, src: ParseWorkerResult): for (const [lang, count] of Object.entries(src.skippedLanguages)) { target.skippedLanguages[lang] = (target.skippedLanguages[lang] || 0) + count; } + for (const [lang, counts] of Object.entries(src.cfgSkipped ?? {})) { + const skipped = (target.cfgSkipped ??= {}); + const previous = skipped[lang]; + skipped[lang] = { + tooManyLines: (previous?.tooManyLines ?? 0) + counts.tooManyLines, + tooDeeplyNested: (previous?.tooDeeplyNested ?? 0) + counts.tooDeeplyNested, + buildError: (previous?.buildError ?? 0) + counts.buildError, + }; + } if (src.skippedPaths && src.skippedPaths.length > 0) { appendAll((target.skippedPaths ??= []), src.skippedPaths); } diff --git a/gitnexus/src/core/lbug/csv-generator.ts b/gitnexus/src/core/lbug/csv-generator.ts index 5230ec713..31b8fac8a 100644 --- a/gitnexus/src/core/lbug/csv-generator.ts +++ b/gitnexus/src/core/lbug/csv-generator.ts @@ -443,7 +443,7 @@ export const buildRelRow = (rel: GraphRelationship): string => * No `name` column; blocks are identified by id + source span. Shared by the * whole-graph emit pass and the streaming PDG emit sink (issue #2202) so the * two paths produce byte-identical BasicBlock rows by construction. */ -export const BASICBLOCK_CSV_HEADER = 'id,filePath,startLine,endLine,text,callees,calleeIds'; +export const BASICBLOCK_CSV_HEADER = 'id,filePath,startLine,endLine,text,callees,calleeIds,ownerId'; /** Build the escaped CSV row (no trailing newline) for one BasicBlock node. * Single source of the BasicBlock row bytes — used by `streamAllCSVsToDisk` @@ -462,6 +462,7 @@ export const buildBasicBlockRow = (node: GraphNode): string => escapeCSVField(node.properties.text || ''), escapeCSVField(String(node.properties.callees ?? '')), escapeCSVField(String(node.properties.calleeIds ?? '')), + escapeCSVField(String(node.properties.ownerId ?? '')), ].join(','); export interface StreamedCSVResult { diff --git a/gitnexus/src/core/lbug/lbug-adapter.ts b/gitnexus/src/core/lbug/lbug-adapter.ts index bc3f58607..7a12357d9 100644 --- a/gitnexus/src/core/lbug/lbug-adapter.ts +++ b/gitnexus/src/core/lbug/lbug-adapter.ts @@ -1740,7 +1740,7 @@ export const getCopyQuery = (table: NodeTableName, filePath: string): string => // Taint/PDG substrate (issue #2080) — no name column. `callees` is the // statement-precise inter-procedural reach substrate (space-joined leaf names); // `calleeIds` is its SOUND parallel (space-joined resolved callee ids, #2227). - return `COPY ${t}(id, filePath, startLine, endLine, text, callees, calleeIds) FROM "${filePath}" ${COPY_CSV_OPTS}`; + return `COPY ${t}(id, filePath, startLine, endLine, text, callees, calleeIds, ownerId) FROM "${filePath}" ${COPY_CSV_OPTS}`; } if (table === 'Class') { return `COPY ${t}(id, name, filePath, startLine, endLine, isExported, content, description, frameworkAnnotations) FROM "${filePath}" ${COPY_CSV_OPTS}`; @@ -1803,7 +1803,7 @@ export const insertNodeToLbug = async ( } else if (label === 'BasicBlock') { // Taint/PDG substrate (issue #2080) — no name column. `calleeIds` (#2227) // is the sound resolved-id parallel to the leaf-name `callees` set. - query = `CREATE (n:BasicBlock {id: ${formatCypherValue(properties.id)}, filePath: ${formatCypherValue(properties.filePath)}, startLine: ${properties.startLine || 0}, endLine: ${properties.endLine || 0}, text: ${formatCypherValue(properties.text || '')}, callees: ${formatCypherValue(properties.callees || '')}, calleeIds: ${formatCypherValue(properties.calleeIds || '')}})`; + query = `CREATE (n:BasicBlock {id: ${formatCypherValue(properties.id)}, filePath: ${formatCypherValue(properties.filePath)}, startLine: ${properties.startLine || 0}, endLine: ${properties.endLine || 0}, text: ${formatCypherValue(properties.text || '')}, callees: ${formatCypherValue(properties.callees || '')}, calleeIds: ${formatCypherValue(properties.calleeIds || '')}, ownerId: ${formatCypherValue(properties.ownerId || '')}})`; } else if (label === 'Class') { const descPart = properties.description ? `, description: ${formatCypherValue(properties.description)}` @@ -1903,7 +1903,7 @@ export const batchInsertNodesToLbug = async ( } else if (label === 'BasicBlock') { // Taint/PDG substrate (issue #2080) — no name column. `calleeIds` // (#2227) is the sound resolved-id parallel to the `callees` set. - query = `MERGE (n:BasicBlock {id: ${formatCypherValue(properties.id)}}) SET n.filePath = ${formatCypherValue(properties.filePath)}, n.startLine = ${properties.startLine || 0}, n.endLine = ${properties.endLine || 0}, n.text = ${formatCypherValue(properties.text || '')}, n.callees = ${formatCypherValue(properties.callees || '')}, n.calleeIds = ${formatCypherValue(properties.calleeIds || '')}`; + query = `MERGE (n:BasicBlock {id: ${formatCypherValue(properties.id)}}) SET n.filePath = ${formatCypherValue(properties.filePath)}, n.startLine = ${properties.startLine || 0}, n.endLine = ${properties.endLine || 0}, n.text = ${formatCypherValue(properties.text || '')}, n.callees = ${formatCypherValue(properties.callees || '')}, n.calleeIds = ${formatCypherValue(properties.calleeIds || '')}, n.ownerId = ${formatCypherValue(properties.ownerId || '')}`; } else if (label === 'Class') { const descPart = properties.description ? `, n.description = ${formatCypherValue(properties.description)}` diff --git a/gitnexus/src/core/lbug/schema.ts b/gitnexus/src/core/lbug/schema.ts index 3d3edd8ac..c70b087fe 100644 --- a/gitnexus/src/core/lbug/schema.ts +++ b/gitnexus/src/core/lbug/schema.ts @@ -339,6 +339,7 @@ CREATE NODE TABLE BasicBlock ( text STRING, callees STRING, calleeIds STRING, + ownerId STRING, PRIMARY KEY (id) )`; diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts index e8c42cfbf..4997012f2 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, @@ -151,6 +153,7 @@ import { CHECKOUT_SOURCE_TOOLS, READ_FILE_DEFAULT_MAX_LINES, } from '../tools.js'; +import { renameSymbol } from './rename/rename-plan.js'; import { foldNumericToolArgumentAliases } from '../tool-arguments.js'; import { findImportCycles, IMPORT_CYCLE_LIMIT } from '../../core/graph/import-cycles.js'; import { decodeTaintPath } from '../../core/ingestion/taint/path-codec.js'; @@ -178,6 +181,7 @@ import { makePdgLayerDegradedResult, pdgLayerStatus, pdgStampForMode, + resolvePdgBlockAnchor, runImpactPDG, validateImpactMode, pdgBridgeEvidenceForImpact, @@ -299,12 +303,15 @@ function ambiguityReport( const SOURCE_FILE_EXTENSIONS: readonly string[] = EXTENSIONS.filter( (e) => e.startsWith('.') && !e.includes('/'), ); -/** A target is path-ish if it has a path separator or ends in a known source - * extension. A bare dotted symbol (`UserController.create`) is NOT path-ish. */ +/** Recognize indexed source files and existing import suffixes; a bare dotted + * symbol (`UserController.create`) remains a symbol target. */ function looksLikeFilePath(target: string): boolean { if (/[\\/]/.test(target)) return true; const lower = target.toLowerCase(); - return SOURCE_FILE_EXTENSIONS.some((ext) => lower.endsWith(ext)); + return ( + getLanguageFromFilename(target) !== null || + SOURCE_FILE_EXTENSIONS.some((ext) => lower.endsWith(ext)) + ); } /** @@ -2985,10 +2992,14 @@ export class LocalBackend { // Resolve repo from optional param (re-reads registry on miss). An optional // `branch` param scopes the resolved handle to that branch's index (#2106). + // `rename` follows the read-tool omission rule (cwd-derived default) only for + // a call that cannot write: dry_run omitted or literally true. dry_run:false - + // and any malformed value, which the planner then rejects - keeps the stricter + // mutating rule: an explicit repo, one indexed repo, or an MCP default. const repo = await this.selectToolRepository( p.repo as string | undefined, p.branch as string | undefined, - { allowCwdDefault: method !== 'rename' }, + { allowCwdDefault: method !== 'rename' || p.dry_run === undefined || p.dry_run === true }, ); switch (method) { @@ -5709,40 +5720,19 @@ export class LocalBackend { }; } const sym = outcome.symbol; - const idPrefix = `BasicBlock:${sym.filePath}:`; - if ( - typeof sym.startLine === 'number' && - typeof sym.endLine === 'number' && - sym.endLine >= sym.startLine - ) { - // BasicBlock startLine is 1-based; the symbol span is 0-based. Shift BOTH - // bounds +1 so the window is the function's true block span: the lower +1 - // excludes a neighbor's block on the line directly above, the upper +1 - // keeps a guard/def/use on the final line (#2188 review). - return { - anchorClause: - 'a.id STARTS WITH $idPrefix AND a.startLine >= $symStart AND a.startLine <= $symEnd', - queryParams: { - idPrefix, - symStart: toOneBasedLine(sym.startLine), - symEnd: toOneBasedLine(sym.endLine), - }, - // Display anchor is 1-based, matching the ambiguous-candidate branch and - // the context/query/impact tools (#2380). This is display-only — the - // BasicBlock join above targets the CFG's own 1-based id space. - anchor: { - file: sym.filePath, - symbol: sym.name, - startLine: toDisplayLine(sym.startLine), - endLine: toDisplayLine(sym.endLine), - }, - }; + const resolved = await resolvePdgBlockAnchor(repo.lbugPath, sym, executeParameterized); + if ('error' in resolved) { + return { anchorClause: '', queryParams: {}, anchor: { file: sym.filePath }, early: resolved }; } - // No usable span — degrade to the file-level filter (documented). return { - anchorClause: 'a.id STARTS WITH $idPrefix', - queryParams: { idPrefix }, - anchor: { file: sym.filePath, symbol: sym.name }, + anchorClause: resolved.anchorClause, + queryParams: resolved.queryParams, + anchor: { + file: sym.filePath, + symbol: sym.name, + startLine: toDisplayLine(sym.startLine), + endLine: toDisplayLine(sym.endLine), + }, }; } @@ -6757,11 +6747,7 @@ export class LocalBackend { }; } - /** - * Rename tool — multi-file coordinated rename using graph + text search. - * Graph refs are tagged "graph" (high confidence). - * Additional refs found via text search are tagged "text_search" (lower confidence). - */ + /** Resolve graph identity and indexed scope through GitNexus's semantic layer. */ private async rename( repo: RepoHandle, params: { @@ -6773,209 +6759,60 @@ export class LocalBackend { }, ): Promise { await this.ensureInitialized(repo); - - const { new_name, file_path } = params; - const dry_run = params.dry_run ?? true; - if (!params.symbol_name && !params.symbol_uid) { return { error: 'Either symbol_name or symbol_uid is required.' }; } - - /** Guard: ensure a file path resolves within the repo root (prevents path traversal) */ - const assertSafePath = (filePath: string): string => { - const full = path.resolve(repo.repoPath, filePath); - const safePrefix = repo.repoPath.endsWith(path.sep) - ? repo.repoPath - : repo.repoPath + path.sep; - if (!full.startsWith(safePrefix) && full !== repo.repoPath) { - throw new Error(`Path traversal blocked: ${filePath}`); - } - return full; - }; - - // Step 1: Find the target symbol (reuse context's lookup) const lookupResult = await this.context(repo, { name: params.symbol_name, uid: params.symbol_uid, - file_path, + file_path: params.file_path, }); - - if (lookupResult.status === 'ambiguous') { - return lookupResult; // pass disambiguation through - } - if (lookupResult.error) { - return lookupResult; - } - - const sym = lookupResult.symbol; - const oldName = sym.name; - - if (oldName === new_name) { - return { error: 'New name is the same as the current name.' }; - } - - // Steps 2+3: Determine the set of files the apply step will rewrite, then - // enumerate every occurrence in each. The apply step (Step 4) does a - // whole-file `\boldName\b` global replace on every file in `changes`, so the - // reported edit list MUST enumerate every matching line in every such file — - // otherwise the preview under-reports what lands, and the same partial list - // comes back after apply (#2605). Building `changes` from one file set makes - // the preview enumerate exactly the files the apply loop rewrites, using the - // same word-boundary regex. (This is per-call consistency; the apply loop - // still re-reads each file, so an external write landing between preview and - // apply is a pre-existing gap this method does not lock against.) - type RenameEdit = { - line: number; - old_text: string; - new_text: string; - confidence: 'graph' | 'text_search'; - }; - const escapedOldName = oldName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - - // Classify each file to rewrite by how it was discovered. Definition and - // graph-ref files carry graph confidence; files found only by text search - // carry text_search confidence. A graph-classified file is never downgraded. - const fileConfidence = new Map(); - - if (sym.filePath) { - fileConfidence.set(sym.filePath, 'graph'); - } - - // All incoming refs from graph (callers, importers, etc.) - const allIncoming = [ - ...(lookupResult.incoming.calls || []), - ...(lookupResult.incoming.imports || []), - ...(lookupResult.incoming.extends || []), - ...(lookupResult.incoming.implements || []), - ]; - for (const ref of allIncoming) { - if (ref.filePath) { - fileConfidence.set(ref.filePath, 'graph'); - } - } - - // Text search for files the graph might have missed entirely. - try { - const { execFileSync } = await import('child_process'); - const rgArgs = [ - '-l', - '--type-add', - 'code:*.{ts,tsx,js,jsx,py,go,rs,java,c,h,cpp,cc,cxx,hpp,hxx,hh,cs,php,swift}', - '-t', - 'code', - `\\b${oldName}\\b`, - '.', - ]; - const output = execFileSync('rg', rgArgs, { - cwd: repo.repoPath, - encoding: 'utf-8', - timeout: 5000, - // Avoid ENOBUFS on large repos: rg -l can list many files. - maxBuffer: 256 * 1024 * 1024, - windowsHide: true, - }); - const files = output - .trim() - .split('\n') - .filter((f) => f.length > 0); - - for (const file of files) { - const normalizedFile = file.replace(/\\/g, '/').replace(/^\.\//, ''); - // Never downgrade a graph-classified file to text_search. - if (!fileConfidence.has(normalizedFile)) { - fileConfidence.set(normalizedFile, 'text_search'); - } - } - } catch (e) { - logQueryError('rename:ripgrep', e); - } - - // Enumerate every `\boldName\b` line in each file to rewrite, so the previewed - // file set is exactly the set the apply loop below rewrites. A file with no - // matching line is dropped (apply would write nothing to it). `wordTest` - // (non-global) probes each line; `wordReplace` (global) rewrites it and is - // reused by the apply loop — compiled once each rather than once per line, - // and one escaping formula serves both passes. - const wordTest = new RegExp(`\\b${escapedOldName}\\b`); - const wordReplace = new RegExp(`\\b${escapedOldName}\\b`, 'g'); - const changes = new Map(); - - for (const [filePath, confidence] of fileConfidence) { - try { - const content = await fs.readFile(assertSafePath(filePath), 'utf-8'); - const lines = content.split('\n'); - const edits: RenameEdit[] = []; - for (let i = 0; i < lines.length; i++) { - if (!wordTest.test(lines[i])) { - continue; - } - edits.push({ - line: i + 1, - old_text: lines[i].trim(), - new_text: lines[i].replace(wordReplace, new_name).trim(), - confidence, - }); - } - if (edits.length > 0) { - changes.set(filePath, { file_path: filePath, edits }); - } - } catch (e) { - logQueryError('rename:enumerate', e); - } - } - - // Step 4: Apply or preview. - const failedFiles: string[] = []; - if (!dry_run) { - for (const change of changes.values()) { - try { - const fullPath = assertSafePath(change.file_path); - const content = await fs.readFile(fullPath, 'utf-8'); - await fs.writeFile(fullPath, content.replace(wordReplace, new_name), 'utf-8'); - } catch (e) { - // A swallowed write failure must not be reported as success (#2283): - // record the file so the result degrades to 'partial'. - logQueryError('rename:apply-edit', e); - failedFiles.push(change.file_path); - } - } - // A file whose write threw did not land, so drop its edits from the - // reported result — total_edits/changes must describe what actually - // reached disk, not what was attempted (#2605: the report matches reality - // even on partial failure). failed_files still names every dropped file. - for (const f of failedFiles) { - changes.delete(f); - } - } - - // Counts derive from the reported set (dry-run: every enumerated file; - // apply: only files that landed), so the graph/text_search split always - // sums to total_edits and never overstates a partial apply. - const reported = Array.from(changes.values()); - let graphEdits = 0; - let astSearchEdits = 0; - for (const change of reported) { - for (const edit of change.edits) { - if (edit.confidence === 'graph') { - graphEdits++; - } else { - astSearchEdits++; - } - } - } - - return { - status: failedFiles.length > 0 ? 'partial' : 'success', - old_name: oldName, - new_name, - files_affected: reported.length, - total_edits: graphEdits + astSearchEdits, - graph_edits: graphEdits, - text_search_edits: astSearchEdits, - changes: reported, - applied: !dry_run, - ...(failedFiles.length > 0 && { failed_files: failedFiles }), - }; + if (lookupResult.status === 'ambiguous' || lookupResult.error) return lookupResult; + const unavailable = await this.fullSourceUnavailable(repo); + if (unavailable) return unavailable; + // 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. + // BasicBlock (the --pdg statement substrate) also carries filePath but is + // never a rename declaration: it would load one row per CFG block into every + // fresh scope-resolution graph. Excluded by exact label rather than allow-listing + // the consumed labels, so a definition label added later still reaches the + // planner. labels(n) is the node's single label as a string on LadybugDB + // (labels(n)[0] is unreliable); it also tolerates an index with no BasicBlock table. + const rows = await executeQuery( + repo.lbugPath, + `MATCH (n) WHERE n.filePath IS NOT NULL AND n.filePath <> '' + AND labels(n) <> 'BasicBlock' + 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/pdg-impact.ts b/gitnexus/src/mcp/local/pdg-impact.ts index 4b7321925..1bb6a657f 100644 --- a/gitnexus/src/mcp/local/pdg-impact.ts +++ b/gitnexus/src/mcp/local/pdg-impact.ts @@ -142,10 +142,9 @@ export interface PdgStatement { /** The statement's source text (BasicBlock.text), trimmed. */ text: string; /** - * Whether the statement belongs to the criterion's OWN function (`'intra'`) - * or was reached across a call boundary (`'inter'`). A block is `'intra'` - * iff its owning-fn file AND 1-based owning-fn start line both match the - * criterion's — `fnFileOf(id) === criterionFile && fnLineOf(id) === ownerFnLine`. + * Whether the statement belongs to a selected seed's execution region + * (`'intra'`) or another owner (`'inter'`). Explicit provider ownership wins; + * blocks without an owner use the file and function-start-line anchor. * Projection-only tag (FU-A): NOT persisted, NOT a schema field. The bench * intra axis scopes to `'intra'` statements so U1's cross-function reach * stops being counted as intra-axis false positives; existing consumers @@ -272,6 +271,7 @@ async function pdgStatementsForBlocks( exec: typeof executeParameterized, criterionFile: string, ownerFnLine: number, + criterionOwnerIds: ReadonlySet, /** * Blocks whose interior should be expanded to statement granularity via the * self-edge def→use line walk, each mapped to the entry line(s) the walk @@ -296,7 +296,7 @@ async function pdgStatementsForBlocks( exec( lbugPath, `MATCH (b:BasicBlock) WHERE b.id IN $ids - RETURN b.id AS id, b.startLine AS line, b.endLine AS endLine, b.text AS text`, + RETURN b.id AS id, b.startLine AS line, b.endLine AS endLine, b.text AS text, b.ownerId AS ownerId`, { ids: fetchIds }, ), chainWalkBlocks.size > 0 @@ -314,11 +314,14 @@ async function pdgStatementsForBlocks( if (!id || !Number.isFinite(line) || line <= 0) continue; const filePath = fnFileOf(id); const text = String(r['text'] ?? '').trim(); - // INTRA iff this block's owning function (file + 1-based start line) is the - // criterion's own function; otherwise it was reached across a call boundary - // (INTER). Pure key comparison parsed from the block id — no extra DB query. + // Explicit execution ownership takes precedence over the legacy source anchor. const scope: 'intra' | 'inter' = - filePath === criterionFile && fnLineOf(id) === ownerFnLine ? 'intra' : 'inter'; + filePath === criterionFile && + (typeof r.ownerId === 'string' && r.ownerId.length > 0 + ? criterionOwnerIds.has(r.ownerId) + : fnLineOf(id) === ownerFnLine) + ? 'intra' + : 'inter'; const textLines = String(r['text'] ?? '').split('\n'); // ── FU-B-2 chain-walk block: expand to its interior dependent lines ─────── // Walk the block's self REACHING_DEF def→use LINE chain forward from the @@ -433,10 +436,32 @@ async function projectBlocksToSymbols(deps: { }): Promise<{ symbols: OwningSymbol[]; unresolvedCount: number; ambiguousCount: number }> { const { lbugPath, blockIds, executeParameterized: exec } = deps; + // Some providers declare a larger executable region (for example a program + // containing several labels). Its exact owner takes precedence over the + // conventional function-start-line join; unrelated modules remain excluded. + const explicitRows = await exec( + lbugPath, + `MATCH (b:BasicBlock) WHERE b.id IN $ids RETURN b.id AS id, b.ownerId AS ownerId`, + { ids: blockIds }, + ); + const requestedBlocks = new Set(blockIds); + const explicitByBlock = new Map(); + for (const row of explicitRows as Array>) { + if ( + typeof row.id === 'string' && + requestedBlocks.has(row.id) && + typeof row.ownerId === 'string' && + row.ownerId.length > 0 + ) { + explicitByBlock.set(row.id, row.ownerId); + } + } + // Group blocks by their (filePath, fnLine) owning-function key so each owning // function is resolved with a single query regardless of block count. const byFnKey = new Map(); for (const id of blockIds) { + if (explicitByBlock.has(id)) continue; const filePath = fnFileOf(id); const fnLine = fnLineOf(id); // 1-based if (!filePath || !Number.isFinite(fnLine)) { @@ -453,6 +478,41 @@ async function projectBlocksToSymbols(deps: { let unresolvedCount = 0; let ambiguousCount = 0; + if (explicitByBlock.size > 0) { + const ownerIds = [...new Set(explicitByBlock.values())]; + const rows = await exec( + lbugPath, + `MATCH (s) WHERE s.id IN $ownerIds + RETURN s.id AS id, s.name AS name, + s.filePath AS filePath, s.startLine AS startLine`, + { ownerIds }, + ); + const byId = new Map((rows as Array>).map((row) => [row.id, row])); + const seen = new Set(); + for (const [blockId, ownerId] of explicitByBlock) { + const filePath = fnFileOf(blockId); + const key = `${filePath}\u0000${ownerId}`; + if (seen.has(key)) continue; + seen.add(key); + const row = byId.get(ownerId); + if (!row || row.filePath !== filePath) { + resolved.push({ id: null, name: '(unresolved)', type: 'unresolved', filePath }); + unresolvedCount++; + continue; + } + assertQueryIdentity(row, 'id', 0, [['name', 1]]); + resolved.push({ + // Graph IDs carry the node label; indexed labels(n)[0] is not reliable + // for a cross-table match in the storage engine. + id: ownerId, + name: String(row.name), + type: ownerId.split(':')[0]!, + filePath, + ...(typeof row.startLine === 'number' ? { startLine: row.startLine } : {}), + }); + } + } + await Promise.all( Array.from(byFnKey.values()).map(async ({ filePath, symStart }) => { if (!Number.isFinite(symStart)) { @@ -1635,7 +1695,7 @@ function blockAnchorForResolvedSymbol(sym: { * {@link blockAnchorForResolvedSymbol} already is. */ const seedBlockQuery = (anchorClause: string, probeLimit: number): string => - `MATCH (a:BasicBlock) WHERE ${anchorClause} RETURN a.id AS id ORDER BY a.startLine, id LIMIT ${probeLimit}`; + `MATCH (a:BasicBlock) WHERE ${anchorClause} RETURN a.id AS id, a.ownerId AS ownerId ORDER BY a.startLine, id LIMIT ${probeLimit}`; /** * Build a STATEMENT seed anchor: the BasicBlock(s) starting at a specific @@ -1677,6 +1737,61 @@ function blockAnchorForStatement( }; } +/** Select a provider-owned execution region, falling back to the symbol span. */ +export async function resolvePdgBlockAnchor( + lbugPath: string, + sym: { id: string; filePath: string; startLine?: number; endLine?: number }, + exec: typeof executeParameterized, + line?: number, +): Promise< + | { anchorClause: string; queryParams: Record; ownerId?: string } + | { error: string; recoverySuggestion: string } +> { + // Probe independently of the requested line: a missing statement in an owned + // region must not fall back to a nested, independently executing region. + let rows: Awaited>; + try { + // determinism: probe — after identity validation, only row presence is used; + // the selected block id does not affect the execution-region anchor. + rows = await exec( + lbugPath, + `MATCH (owner:BasicBlock) WHERE owner.filePath = $ownerFile AND owner.ownerId = $ownerId + RETURN owner.id AS id LIMIT 1`, + { ownerFile: sym.filePath, ownerId: sym.id }, + ); + } catch (error) { + // A pre-ownerId index can carry complete PDG stamps. Only this missing + // column is a compatibility failure; locks, corruption and other binder + // errors retain their normal error paths. Schema changes belong to analyze. + const message = error instanceof Error ? error.message : String(error); + if (!/\bBinder exception: Cannot find property ownerId for owner\.\s*$/.test(message)) { + throw error; + } + return { + error: 'The PDG index schema predates executable ownership.', + recoverySuggestion: + 'Run gitnexus analyze --pdg to rebuild the index before querying PDG symbols.', + }; + } + for (const row of rows) assertQueryIdentity(row, 'id', 0); + if (rows.length === 0) { + return line === undefined + ? blockAnchorForResolvedSymbol(sym) + : blockAnchorForStatement(sym, line); + } + return { + anchorClause: + 'a.filePath = $ownerFile AND a.ownerId = $ownerId' + + (line === undefined ? '' : ' AND a.startLine = $line'), + queryParams: { + ownerFile: sym.filePath, + ownerId: sym.id, + ...(line === undefined ? {} : { line }), + }, + ownerId: sym.id, + }; +} + /** * One bounded, direction-aware BFS over CDG + REACHING_DEF starting from a set * of seed blocks. Extracted (U1) from `runImpactPDG`'s intra loop so the @@ -2367,9 +2482,22 @@ export async function runImpactPDG(deps: RunPdgImpactDeps): Promise fnLineOf(id) === ownerFnLine); if (owned.length > 0) seedBlocks = owned; @@ -2401,6 +2529,17 @@ export async function runImpactPDG(deps: RunPdgImpactDeps): Promise stepLimit; + // A span-selected label may execute inside a larger provider-owned region. + // Compare reached statements with the selected blocks' execution owners, + // not with the label symbol. Owner-less blocks retain the source-anchor join. + const criterionOwnerIds = new Set(ownerId ? [ownerId] : []); + const selectedSeeds = new Set(seedBlocks); + for (const row of seedRows) { + if (selectedSeeds.has(String(row.id)) && typeof row.ownerId === 'string' && row.ownerId) { + assertSymbolIdentity(row.ownerId); + criterionOwnerIds.add(row.ownerId); + } + } // ── KTD6 no-body contract: distinguish "no PDG body" from "no dependence" ── // A symbol that resolves but produces ZERO anchored blocks has no CFG body @@ -2613,6 +2752,7 @@ export async function runImpactPDG(deps: RunPdgImpactDeps): Promise[0]['onResolved']> +>[0]; + +/** + * `SourceBindingResolution.basis` values, emitted with `status: 'blocked'` and + * no target, that PROVE the written name denotes some other declaration: + * a lexical claim (shadowing, non-callable binding), a qualified-type root that + * is not a type owner, a type parameter, or C++ ordinary lookup stopping at a + * non-function. Every other blocked basis (an unbound `this`, member-name + * hiding, an absent namespace member) records that resolution stopped without + * saying what the name means, so it can never clear a same-spelled site. + */ +const DISTINCT_DECLARATION_BASES: ReadonlySet = new Set([ + 'lexical-name-claim', + 'qualified-type-root', + 'type-parameter', + 'adl-ordinary-lookup-blocked', +]); + +/** A fresh provider-owned semantic pass uses graph identities, never text-search edits. */ +export async function planGraphRename( + repoPath: string, + symbol: RenameSymbol, + options: RenameOptions, + graphNodes: readonly GraphNode[], + referenceFiles: readonly string[] = [], +): Promise { + 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.', + ); + } + const root = await fs.realpath(repoPath); + const contents = new Map(); + const readSource = async (filePath: string): Promise => { + const existing = contents.get(filePath); + if (existing !== undefined) return existing; + 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', + ); + } + contents.set(filePath, content); + return content; + }; + const targetProvider = getProviderForFileContent( + symbol.filePath, + await readSource(symbol.filePath), + ); + const resolver = targetProvider && SCOPE_RESOLVERS.get(targetProvider.id); + const provider = resolver?.languageProvider.rename; + if (!targetProvider || !resolver || !provider) { + throw new RenameFailure( + 'unsupported_language', + 'This language has no semantic rename provider.', + 'unsupported', + ); + } + const language = targetProvider.id; + const canonical = ( + name: string, + context?: Parameters>[1], + ) => provider.canonicalName?.(name, context) ?? name; + if ( + typeof options.new_name !== 'string' || + !options.new_name || + options.new_name === symbol.name + ) { + throw new RenameFailure('invalid_name', 'Choose a different, nonempty identifier.'); + } + reject(provider.validateName?.(options.new_name)); + const sourceResolvers = new Map(); + const boundarySnapshots = new Map(); + for (const node of graphNodes) { + if (node.label !== 'File') continue; + const filenameProvider = getProviderForFile(node.properties.filePath); + if ( + !needsContentLanguageClassification(node.properties.filePath) && + (!filenameProvider || + (filenameProvider.id !== language && + !provider.acceptsSourceProvider?.(filenameProvider, node.properties.filePath) && + !provider.interoperatesWithSourceProvider?.(filenameProvider, node.properties.filePath))) + ) + continue; + const sourceProvider = getProviderForFileContent( + node.properties.filePath, + await readSource(node.properties.filePath), + ); + if (!sourceProvider) continue; + if ( + sourceProvider.id !== language && + !provider.acceptsSourceProvider?.(sourceProvider, node.properties.filePath) + ) { + if (provider.interoperatesWithSourceProvider?.(sourceProvider, node.properties.filePath)) { + const filePath = node.properties.filePath; + const content = contents.get(filePath)!; + if (!content.trim()) { + boundarySnapshots.set(repositoryPath(repoPath, filePath), content); + continue; + } + const sourceResolver = SCOPE_RESOLVERS.get(sourceProvider.id); + const sourceRename = sourceResolver?.languageProvider.rename; + if (!sourceResolver || !sourceRename) + throw new RenameFailure( + 'unsupported_boundary', + `An interoperating indexed source has no name inventory: ${filePath}`, + 'unsupported', + ); + let boundary: RenameSource; + try { + boundary = await parseSource(sourceRename, sourceResolver, filePath, content); + } catch { + throw new RenameFailure( + 'unsupported_boundary', + `Cannot establish the name inventory of an interoperating indexed source: ${filePath}`, + 'unsupported', + ); + } + const spellings = new Set( + [canonical(symbol.name), canonical(options.new_name)].flatMap((name) => [ + name, + ...(provider.interopNames?.(name, sourceProvider) ?? []), + ]), + ); + // Normalise both sides: the consumer may write the selected entity under a + // different spelling (a full selector for a base name) than its declaration. + const writtenNames = (occurrence: RenameOccurrence): readonly string[] => { + const written = + sourceRename.canonicalName?.(occurrence.name, { occurrence }) ?? occurrence.name; + return [written, ...(sourceRename.boundaryNames?.(written, targetProvider) ?? [])]; + }; + if ( + boundary.occurrences.some((occurrence) => + writtenNames(occurrence).some((name) => spellings.has(name)), + ) || + sourceRename.additionalInteropNames?.(boundary).some((name) => spellings.has(name)) + ) + throw new RenameFailure( + 'unsupported_boundary', + `An interoperating indexed source contains the selected or replacement name without a proven binding: ${filePath}`, + 'unsupported', + ); + // Absence of names is part of the safety proof and must survive apply. + boundarySnapshots.set(repositoryPath(repoPath, filePath), content); + } + continue; + } + const sourceResolver = SCOPE_RESOLVERS.get(sourceProvider.id); + if (!sourceResolver?.languageProvider.rename) { + throw new RenameFailure( + 'unsupported_boundary', + `A workspace source has no rename provider: ${node.properties.filePath}`, + 'unsupported', + ); + } + sourceResolvers.set(node.properties.filePath, sourceResolver); + } + const filePaths = [...sourceResolvers.keys()].sort(); + 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 snapshots = new Map(boundarySnapshots); + const files = new Map(); + for (const filePath of filePaths) { + const absolute = repositoryPath(repoPath, filePath); + const content = contents.get(filePath)!; + snapshots.set(absolute, content); + const sourceResolver = sourceResolvers.get(filePath)!; + if (content.trim()) + files.set( + filePath, + await parseSource( + sourceResolver.languageProvider.rename!, + sourceResolver, + filePath, + content, + ), + ); + } + const resolutionConfig = resolver.captureResolutionConfig + ? await resolver.captureResolutionConfig(repoPath, filePaths) + : await resolver.loadResolutionConfig?.(repoPath); + const before = resolveSources(files, graphNodes, resolver, resolutionConfig); + const { parsedFiles, indexes, referenceBindings } = before.observation; + const definitions = parsedFiles.flatMap((file) => [...file.localDefs]); + const nodeLookup = buildGraphNodeLookup( + before.graph, + new Set([...LINKABLE_LABELS, ...definitions.map((def) => def.type)]), + ); + const targetDefs = definitions.filter((def) => { + const position = definitionIdPosition(def.nodeId, def.filePath); + if ( + def.filePath !== symbol.filePath || + canonical(simpleQualifiedName(def) ?? '', { definition: def }) !== + canonical(symbol.name, { definition: def }) || + (position?.line ?? def.declarationRange?.startLine) !== symbol.startLine || + (symbol.endLine !== undefined && def.declarationRange?.endLine !== symbol.endLine) || + resolveDefGraphId(def.filePath, def, nodeLookup) !== targetNode.id + ) + return false; + if (provider.matchesGraphIdentity) + return provider.matchesGraphIdentity({ + definition: def, + node: targetNode, + definitions, + graph: before.graph, + files, + }); + if (def.qualifiedName === undefined) return def.nodeId === targetNode.id; + const qualifiedName = + def.namespacePrefix && !def.qualifiedName.startsWith(`${def.namespacePrefix}.`) + ? `${def.namespacePrefix}.${def.qualifiedName}` + : def.qualifiedName; + return ( + nodeLookup.get(qualifiedKey(def.filePath, targetNode.label, qualifiedName)) === 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]!; + const context: RenameSemanticContext = { + target, + definitions, + files, + indexes, + referenceBindings, + model: before.model, + graph: before.graph, + newName: options.new_name, + }; + reject(provider.checkSafety?.(context)); + const familyResult = provider.symbolFamily?.(context) ?? [target]; + if (isIssue(familyResult)) reject(familyResult); + const family = familyResult as readonly SymbolDefinition[]; + const familyIds = new Set(family.map((def) => def.nodeId)); + if ( + !familyIds.has(target.nodeId) || + family.some((def) => !definitions.includes(def) || def.isSynthetic) + ) { + throw new RenameFailure( + 'incomplete_semantics', + 'The provider did not identify a complete source symbol family.', + ); + } + assertDeclarationCollisions(context, familyIds, provider); + const edits = new Map(); + const accounted = new Set(); + const add = ( + source: RenameSource, + occurrence: RenameOccurrence, + selected: boolean, + targetDefIds: readonly string[], + site?: ReferenceSite, + ) => { + const sourceProvider = sourceResolvers.get(source.path)!.languageProvider.rename!; + const key = occurrenceKey(source.path, occurrence); + reject( + sourceProvider.checkOccurrence?.({ + ...context, + source, + occurrence, + selected, + targetDefIds, + site, + }), + ); + accounted.add(key); + if (!selected) return; + const replacementResult = + sourceProvider.rewrite?.({ ...context, source, occurrence, newName: options.new_name }) ?? + (occurrence.spans.length === 1 + ? occurrence.spans.map((span) => ({ + ...span, + oldText: source.content.slice(span.start, span.start + span.length), + newText: options.new_name, + })) + : { + code: 'unsupported_occurrence', + message: `The provider has no multipart name rewrite in ${source.path}.`, + status: 'unsupported' as const, + }); + if (isIssue(replacementResult)) reject(replacementResult); + for (const replacement of replacementResult as readonly RenameReplacement[]) { + if ( + replacement.start < 0 || + replacement.length <= 0 || + source.content.slice(replacement.start, replacement.start + replacement.length) !== + replacement.oldText + ) + throw new RenameFailure( + 'unsupported_occurrence', + `The provider returned an invalid source span in ${source.path}.`, + 'unsupported', + ); + const edit: OccurrenceEdit = { + file_path: source.path, + start: replacement.start, + length: replacement.length, + old_text: replacement.oldText, + new_text: replacement.newText, + }; + const id = `${source.path}:${edit.start}:${edit.length}`; + const previous = edits.get(id); + if (previous && previous.new_text !== edit.new_text) { + throw new RenameFailure( + 'unsupported_occurrence', + 'Two symbol identities require incompatible source rewrites.', + 'unsupported', + ); + } + edits.set(id, edit); + } + }; + for (const def of definitions) { + const selected = familyIds.has(def.nodeId); + if ( + !selected && + canonical(simpleQualifiedName(def) ?? '', { definition: def }) !== + canonical(symbol.name, { definition: def }) + ) + continue; + const source = files.get(def.filePath); + if (!source) + throw new RenameFailure( + 'incomplete_semantics', + 'A declaration lies outside the indexed source set.', + ); + const occurrence = + occurrenceIndex(source).byDefinitionId.get(def.nodeId) ?? occurrenceAt(source, def.nameRange); + add(source, occurrence, selected, [def.nodeId]); + } + for (const [scopeId, imports] of indexes.imports) { + const scope = indexes.scopeTree.getScope(scopeId); + const source = scope && files.get(scope.filePath); + if (!source) continue; + for (const edge of imports) { + const selected = edge.targetDefId !== undefined && familyIds.has(edge.targetDefId); + const matchesExport = + canonical(edge.targetExportedName, { definition: target }) === + canonical(symbol.name, { definition: target }); + const matchesLocal = + canonical(edge.localName, { definition: target }) === + canonical(symbol.name, { definition: target }); + if (!selected && !matchesExport && !matchesLocal) continue; + if (!edge.atRange || edge.linkStatus === 'unresolved') { + throw new RenameFailure( + 'incomplete_semantics', + `An import of ${symbol.name} is unresolved in ${source.path}.`, + ); + } + const candidates = source.occurrences.filter( + (candidate) => + containsRange(edge.atRange!, candidate.range) && + canonical(candidate.name, { occurrence: candidate }) === + canonical(symbol.name, { occurrence: candidate }), + ); + if ( + candidates.length === 0 && + (edge.kind === 'wildcard-expanded' || edge.kind === 'namespace') + ) + continue; + // Distinct imported/local identities are supplied by the provider. A + // shared span needs an explicit rewrite; guessing by source order is unsafe. + const exported = candidates.filter( + (candidate) => + candidate.role === 'imported' || + (candidate.definitionId !== undefined && candidate.definitionId === edge.targetDefId), + ); + if (exported.length) { + for (const occurrence of exported) + add(source, occurrence, selected, edge.targetDefId ? [edge.targetDefId] : []); + for (const occurrence of candidates) { + if (!exported.includes(occurrence)) add(source, occurrence, false, []); + } + } else if (candidates.length === 1) { + add( + source, + candidates[0]!, + selected && (matchesExport || !matchesLocal), + edge.targetDefId ? [edge.targetDefId] : [], + ); + } else if (candidates.length !== 0 || matchesExport) { + throw new RenameFailure( + 'unsupported_occurrence', + `The import has no unambiguous source identity in ${source.path}.`, + 'unsupported', + ); + } + } + } + for (const site of indexes.referenceSites) { + const binding = referenceBindings.get(site); + const matches = canonical(site.name, { site }) === canonical(symbol.name, { site }); + const selected = binding?.targetDefIds.some((id) => familyIds.has(id)) === true; + if (!matches && !selected) continue; + const scope = indexes.scopeTree.getScope(site.inScope); + const source = scope && files.get(scope.filePath); + if (!source) + throw new RenameFailure( + 'incomplete_semantics', + 'A reference lies outside the indexed source set.', + ); + const occurrence = + occurrenceIndex(source).byReferenceSite.get(site) ?? occurrenceAt(source, site.nameRange); + if (accounted.has(occurrenceKey(source.path, occurrence))) continue; + const completeFamily = + binding?.status === 'ambiguous' && + binding.candidatesComplete === true && + binding.targetDefIds.length > 0 && + binding.targetDefIds.every((id) => familyIds.has(id)); + if ( + !binding || + (binding.status !== 'resolved' && binding.status !== 'blocked' && !completeFamily) + ) { + throw new RenameFailure( + 'ambiguous_reference', + `Cannot prove the written binding of ${site.name} at ${source.path}:${site.atRange.startLine}.`, + ); + } + if (selected && binding.targetDefIds.some((id) => !familyIds.has(id))) { + throw new RenameFailure( + 'ambiguous_reference', + `The written name denotes several symbol families in ${source.path}.`, + ); + } + add( + source, + occurrence, + selected && binding.importAlias === undefined, + binding.targetDefIds, + site, + ); + // `blocked` only says the resolver stopped. It is a negative proof only when + // it names another declaration, or its basis shows the written name denotes + // one; a site whose receiver or member lookup stayed unknown may still be a + // real reference. The provider's own occurrence checks above take precedence. + if ( + binding.status === 'blocked' && + !selected && + binding.targetDefIds.length === 0 && + !DISTINCT_DECLARATION_BASES.has(binding.basis) + ) { + throw new RenameFailure( + 'ambiguous_reference', + `Cannot prove that ${site.name} at ${source.path}:${site.atRange.startLine} is not a reference to ${symbol.name}: the binding was blocked (${binding.basis}) without identifying another declaration.`, + ); + } + } + for (const source of files.values()) { + for (const occurrence of source.occurrences) { + if ( + canonical(occurrence.name, { occurrence }) !== canonical(symbol.name, { occurrence }) || + accounted.has(occurrenceKey(source.path, occurrence)) + ) + continue; + if (occurrence.role === 'unrelated') { + add(source, occurrence, false, []); + continue; + } + if (occurrence.role === 'binding') { + const scope = innermostRenameScopeAt(source.parsed, occurrence.range); + if ( + scope && + lookupNameClaim(scope, occurrence.name, indexes, { + position: occurrence.range, + purpose: 'value', + }).status === 'blocked' + ) { + add(source, occurrence, false, []); + continue; + } + } + throw new RenameFailure( + 'incomplete_semantics', + `Uncovered occurrence in ${source.path}:${occurrence.range.startLine}.`, + ); + } + } + 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) ?? []; + if ( + group.length && + group[group.length - 1]!.start + group[group.length - 1]!.length > edit.start + ) { + throw new RenameFailure( + 'unsupported_occurrence', + 'Provider source rewrites overlap.', + 'unsupported', + ); + } + group.push(edit); + editsByFile.set(edit.file_path, group); + } + const updatedFiles = new Map(); + // Re-extract EVERY source: resolution mutates semantic captures and unchanged + // callers can be captured by a new name without receiving an edit themselves. + for (const source of files.values()) { + const fileEdits = editsByFile.get(source.path) ?? []; + const sourceResolver = sourceResolvers.get(source.path)!; + const sourceProvider = sourceResolver.languageProvider.rename!; + const updated = await parseSource( + sourceProvider, + sourceResolver, + source.path, + applyEdits(source.content, fileEdits), + ); + reject( + sourceProvider.validateRewrite?.({ + ...context, + source, + updated, + edits: fileEdits.map(asReplacement), + }), + ); + updatedFiles.set(source.path, updated); + } + const updatedDefinitions = [...updatedFiles.values()].flatMap((file) => [ + ...file.parsed.localDefs, + ]); + const offsetOf = createOffsetLookup(); + const editIndexes = new Map( + [...editsByFile].map(([filePath, fileEdits]) => [filePath, indexEdits(fileEdits)] as const), + ); + const definitionMap = matchDefinitions( + definitions, + updatedDefinitions, + files, + updatedFiles, + editIndexes, + offsetOf, + ); + const definitionsByGraphId = new Map( + definitions.flatMap((def) => { + const id = resolveDefGraphId(def.filePath, def, nodeLookup); + return id ? [[id, def] as const] : []; + }), + ); + const updatedDefinitionsById = new Map(updatedDefinitions.map((def) => [def.nodeId, def])); + const updatedNodes = graphNodes.map((node) => { + const original = definitionsByGraphId.get(node.id); + const replacementId = original && definitionMap.get(original.nodeId); + const replacement = replacementId && updatedDefinitionsById.get(replacementId); + const updatedSource = replacement && updatedFiles.get(replacement.filePath); + const replacementName = + replacement && updatedSource && familyIds.has(original!.nodeId) + ? ( + occurrenceIndex(updatedSource).byDefinitionId.get(replacement.nodeId) ?? + occurrenceAt(updatedSource, replacement.nameRange) + ).name + : node.properties.name; + return replacement + ? { + ...node, + properties: { + ...node.properties, + name: replacementName, + qualifiedName: replacement.qualifiedName, + startLine: + (replacement.declarationRange?.startLine ?? node.properties.startLine! + 1) - 1, + endLine: (replacement.declarationRange?.endLine ?? node.properties.endLine! + 1) - 1, + }, + } + : node; + }); + const after = resolveSources(updatedFiles, updatedNodes, resolver, resolutionConfig); + const afterDefinitions = after.observation.parsedFiles.flatMap((file) => [...file.localDefs]); + const afterTarget = afterDefinitions.find( + (def) => def.nodeId === definitionMap.get(target.nodeId), + ); + if (!afterTarget) + throw new RenameFailure( + 'name_collision', + 'The proposed name changes the selected declaration identity.', + ); + const afterFamily = provider.symbolFamily?.({ + target: afterTarget, + definitions: afterDefinitions, + files: updatedFiles, + indexes: after.observation.indexes, + referenceBindings: after.observation.referenceBindings, + model: after.model, + graph: after.graph, + newName: options.new_name, + }) ?? [afterTarget]; + const expectedFamily = new Set(family.map((def) => definitionMap.get(def.nodeId))); + if ( + isIssue(afterFamily) || + afterFamily.length !== expectedFamily.size || + afterFamily.some((def) => !expectedFamily.has(def.nodeId)) + ) { + throw new RenameFailure( + 'name_collision', + 'The proposed name changes the declaration or override family.', + ); + } + assertBindingsPreserved( + before.observation, + after.observation, + files, + updatedFiles, + editIndexes, + definitionMap, + offsetOf, + ); + return { + symbol, + new_name: options.new_name, + snapshots, + edits: ordered, + coverage: { + name: 'gitnexus-semantic', + scope: 'indexed-repository', + source_file_count: filePaths.length, + languages: [ + ...new Set([...sourceResolvers.values()].map((sourceResolver) => sourceResolver.language)), + ], + limitations: [ + 'Only indexed source files covered by this language provider are included.', + 'Source files added after the last analyze are not indexed and are not checked; re-run analyze before renaming if files were added.', + ], + }, + }; +} + +function isIssue(value: unknown): value is RenameIssue { + return value !== null && typeof value === 'object' && 'code' in value && 'message' in value; +} + +function reject(issue: RenameIssue | void): void { + if (issue) + throw new RenameFailure( + issue.code, + issue.message, + issue.status === 'unsupported' ? 'unsupported' : 'blocked', + ); +} + +async function parseSource( + provider: RenameProvider, + resolver: ScopeResolver, + path: string, + content: string, +): Promise { + let source: RenameSource; + try { + source = await provider.parseSource({ + filePath: path, + content, + languageProvider: resolver.languageProvider, + scopeResolver: resolver, + }); + } catch (error) { + if (isIssue(error)) reject(error); + throw error; + } + if (source.path !== path || source.content !== content || source.parsed.filePath !== path) { + throw new RenameFailure( + 'incomplete_semantics', + `The provider returned a different source identity for ${path}.`, + ); + } + return source; +} + +function resolveSources( + files: ReadonlyMap, + nodes: readonly GraphNode[], + resolver: ScopeResolver, + resolutionConfig: unknown, +) { + const graph = createKnowledgeGraph(); + const languages = new Map( + [...files].map(([path, source]) => [path, getProviderForFileContent(path, source.content)?.id]), + ); + for (const node of nodes) + graph.addNode({ + ...node, + properties: { + ...node.properties, + language: languages.get(node.properties.filePath) ?? node.properties.language, + }, + }); + const model = createSemanticModel(); + for (const source of files.values()) { + resolver.populateOwners(source.parsed); + const sourceProvider = getProviderForFileContent(source.path, source.content)?.rename; + for (const def of source.parsed.localDefs) { + const rawName = simpleQualifiedName(def); + const name = + rawName && (sourceProvider?.canonicalName?.(rawName, { definition: def }) ?? rawName); + if (name) model.symbols.add(source.path, name, def.nodeId, def.type, def); + } + } + let observation: SemanticObservation | undefined; + const warnings: string[] = []; + const stats = runScopeResolution( + { + graph, + model, + files: [...files.values()], + resolutionConfig, + treeCache: new Map( + [...files].flatMap(([path, source]) => (source.tree ? [[path, source.tree] as const] : [])), + ), + preExtractedParsedFiles: new Map([...files].map(([path, source]) => [path, source.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.', + ); + } + // The ordinary ingestion MRO phase consumes source ownership edges emitted + // by parsing. Reconstruct those exact edges from the fresh owned definitions + // before invoking the same phase here; inheritance alone is not an override. + const definitions = new Map( + observation.parsedFiles.flatMap((file) => + file.localDefs.map((def) => [def.nodeId, def] as const), + ), + ); + const lookup = buildGraphNodeLookup(graph); + for (const def of definitions.values()) { + const graphId = resolveDefGraphId(def.filePath, def, lookup); + const node = graphId && graph.getNode(graphId); + if (!node) continue; + node.properties.parameterCount = def.parameterCount; + node.properties.parameterTypes = def.parameterTypes ? [...def.parameterTypes] : undefined; + const owner = def.ownerId && definitions.get(def.ownerId); + const ownerGraphId = + owner && CLASS_KINDS.includes(owner.type) && resolveDefGraphId(owner.filePath, owner, lookup); + if (ownerGraphId && METHOD_KINDS.includes(def.type)) + graph.addRelationship({ + id: `HAS_METHOD:${ownerGraphId}->${node.id}`, + sourceId: ownerGraphId, + targetId: node.id, + type: 'HAS_METHOD', + confidence: 1, + reason: 'fresh-source-ownership', + }); + } + computeMRO(graph); + return { observation, graph, model }; +} + +function occurrenceKey(path: string, occurrence: RenameOccurrence): string { + return `${path}:${occurrence.spans.map((span) => `${span.start}:${span.length}`).join(',')}`; +} + +/** Lookup tables over one immutable provider source, built on first use. Each + * holds the FIRST occurrence for a key, exactly what `Array.find` returned. */ +interface OccurrenceIndex { + readonly byReferenceSite: ReadonlyMap; + readonly byDefinitionId: ReadonlyMap; + readonly byRange: ReadonlyMap; +} + +const occurrenceIndexes = new WeakMap(); + +function occurrenceIndex(source: RenameSource): OccurrenceIndex { + const existing = occurrenceIndexes.get(source); + if (existing) return existing; + const byReferenceSite = new Map(); + const byDefinitionId = new Map(); + const byRange = new Map(); + for (const occurrence of source.occurrences) { + if (occurrence.referenceSite && !byReferenceSite.has(occurrence.referenceSite)) + byReferenceSite.set(occurrence.referenceSite, occurrence); + if (occurrence.definitionId !== undefined && !byDefinitionId.has(occurrence.definitionId)) + byDefinitionId.set(occurrence.definitionId, occurrence); + const key = rangeKey(occurrence.range); + const group = byRange.get(key); + if (group) group.push(occurrence); + else byRange.set(key, [occurrence]); + } + const built = { byReferenceSite, byDefinitionId, byRange }; + occurrenceIndexes.set(source, built); + return built; +} + +function occurrenceAt(source: RenameSource, range: Range | undefined): RenameOccurrence { + const matches = range && occurrenceIndex(source).byRange.get(rangeKey(range)); + if (!matches || matches.length !== 1) { + throw new RenameFailure( + 'unsupported_occurrence', + `The provider has no exact source-name span in ${source.path}.`, + 'unsupported', + ); + } + return matches[0]!; +} + +/** Edits of one file with the cumulative length change before each, so a + * position can be relocated or located inside a replacement in O(log n). */ +interface EditIndex { + readonly edits: readonly OccurrenceEdit[]; + /** `shifts[i]` is the net length change of `edits[0..i)`. */ + readonly shifts: readonly number[]; +} + +function indexEdits(edits: readonly OccurrenceEdit[]): EditIndex { + const shifts = [0]; + for (const edit of edits) + shifts.push(shifts[shifts.length - 1]! + edit.new_text.length - edit.length); + return { edits, shifts }; +} + +const NO_EDITS = indexEdits([]); + +/** Count of leading edits whose start is below (or, with `inclusive`, at) `offset`. */ +function editsBefore(index: EditIndex, offset: number, inclusive: boolean): number { + let low = 0; + let high = index.edits.length; + while (low < high) { + const middle = (low + high) >>> 1; + const start = index.edits[middle]!.start; + if (start < offset || (inclusive && start === offset)) low = middle + 1; + else high = middle; + } + return low; +} + +/** The edit whose replaced span holds `offset`, if any. Edits never overlap. */ +function editContaining(index: EditIndex, offset: number): OccurrenceEdit | undefined { + const count = editsBefore(index, offset, true); + const edit = count > 0 ? index.edits[count - 1] : undefined; + return edit && offset < edit.start + edit.length ? edit : undefined; +} + +/** Where an original-source offset lands after the edits (see `relocated`). */ +function relocate(index: EditIndex, offset: number): number { + const count = editsBefore(index, offset, false); + const edit = count > 0 ? index.edits[count - 1] : undefined; + if (edit && offset < edit.start + edit.length) return edit.start + index.shifts[count - 1]!; + return offset + index.shifts[count]!; +} + +function assertDeclarationCollisions( + context: RenameSemanticContext, + family: ReadonlySet, + provider: RenameProvider, +): void { + const canonical = (name: string, definition: SymbolDefinition) => + provider.canonicalName?.(name, { definition }) ?? name; + for (const source of context.files.values()) { + for (const scope of source.parsed.scopes) { + const selected = [...scope.bindings.values()] + .flat() + .find((binding) => family.has(binding.def.nodeId) && binding.origin === 'local'); + if (!selected) continue; + for (const [name, bindings] of scope.bindings) { + if (canonical(name, selected.def) !== canonical(context.newName, selected.def)) continue; + if (bindings.some((binding) => !family.has(binding.def.nodeId))) { + throw new RenameFailure( + 'name_collision', + 'The proposed name already belongs to another declaration in this scope.', + ); + } + } + } + } +} + +function asReplacement(edit: OccurrenceEdit): RenameReplacement { + return { start: edit.start, length: edit.length, oldText: edit.old_text, newText: edit.new_text }; +} + +function declarationPosition(def: SymbolDefinition): { line: number; column: number } | undefined { + const position = definitionIdPosition(def.nodeId, def.filePath); + return ( + position ?? + (def.declarationRange && { + line: def.declarationRange.startLine, + column: def.declarationRange.startCol, + }) + ); +} + +function matchDefinitions( + before: readonly SymbolDefinition[], + after: readonly SymbolDefinition[], + files: ReadonlyMap, + updated: ReadonlyMap, + edits: ReadonlyMap, + offsetOf: ReturnType, +): ReadonlyMap { + const candidates = new Map(); + for (const def of after) { + const position = declarationPosition(def); + const source = updated.get(def.filePath); + if (!position || !source) continue; + const key = `${def.filePath}:${def.type}:${offsetOf(source, position.line, position.column)}`; + const group = candidates.get(key) ?? []; + group.push(def); + candidates.set(key, group); + } + const matches = new Map(); + for (const def of before) { + const position = declarationPosition(def); + const source = files.get(def.filePath); + if (!position || !source) continue; + const offset = relocate( + edits.get(def.filePath) ?? NO_EDITS, + offsetOf(source, position.line, position.column), + ); + const atPosition = candidates.get(`${def.filePath}:${def.type}:${offset}`); + // Synthetic accessors share their property's range but remain distinct + // declarations. Their signatures survive renaming and distinguish them. + const group = + atPosition && atPosition.length > 1 + ? atPosition.filter( + (candidate) => + candidate.parameterCount === def.parameterCount && + candidate.requiredParameterCount === def.requiredParameterCount && + JSON.stringify(candidate.parameterTypes) === JSON.stringify(def.parameterTypes), + ) + : atPosition; + if (group?.length !== 1) { + throw new RenameFailure( + 'name_collision', + `The proposed rewrite changes declaration identity in ${def.filePath}.`, + ); + } + matches.set(def.nodeId, group[0]!.nodeId); + } + return matches; +} + +function bindingIds(binding: SourceBindingResolution, map?: ReadonlyMap): string { + return [...new Set(binding.targetDefIds.map((id) => map?.get(id) ?? id))].sort().join('\0'); +} + +/** The sites at `[from, to)` in a list sorted by offset. */ +function sitesWithin( + sorted: readonly { offset: number; site: ReferenceSite }[], + from: number, + to: number, +): ReferenceSite[] { + let low = 0; + let high = sorted.length; + while (low < high) { + const middle = (low + high) >>> 1; + if (sorted[middle]!.offset < from) low = middle + 1; + else high = middle; + } + const within: ReferenceSite[] = []; + for (let i = low; i < sorted.length && sorted[i]!.offset < to; i++) within.push(sorted[i]!.site); + return within; +} + +function assertBindingsPreserved( + before: SemanticObservation, + after: SemanticObservation, + files: ReadonlyMap, + updated: ReadonlyMap, + edits: ReadonlyMap, + definitions: ReadonlyMap, + offsetOf: ReturnType, +): void { + const nextSites = new Map(); + const nextPositions = new Map(); + for (const site of after.indexes.referenceSites) { + const path = after.indexes.scopeTree.getScope(site.inScope)?.filePath; + const source = path && updated.get(path); + const range = site.nameRange ?? site.atRange; + if (!source) continue; + const offset = offsetOf(source, range.startLine, range.startCol); + const prefix = `${source.path}:${site.kind}:${site.callForm ?? ''}`; + const key = `${prefix}:${offset}`; + const group = nextSites.get(key) ?? []; + group.push(site); + nextSites.set(key, group); + const positions = nextPositions.get(prefix) ?? []; + positions.push({ offset, site }); + nextPositions.set(prefix, positions); + } + // Sorted once, so the sites inside one expanded replacement are a contiguous run. + for (const positions of nextPositions.values()) positions.sort((a, b) => a.offset - b.offset); + for (const site of before.indexes.referenceSites) { + const path = before.indexes.scopeTree.getScope(site.inScope)?.filePath; + const source = path && files.get(path); + const range = site.nameRange ?? site.atRange; + if (!source) + throw new RenameFailure( + 'incomplete_semantics', + 'A reference lies outside the validated source set.', + ); + const originalOffset = offsetOf(source, range.startLine, range.startCol); + const fileEdits = edits.get(source.path) ?? NO_EDITS; + const offset = relocate(fileEdits, originalOffset); + const replacement = editContaining(fileEdits, originalOffset); + // A provider may expand a shared token into several names. Match the + // original binding inside that exact replacement, never by global spelling. + const replacementStart = replacement && relocate(fileEdits, replacement.start); + const candidates = replacement + ? sitesWithin( + nextPositions.get(`${source.path}:${site.kind}:${site.callForm ?? ''}`) ?? [], + replacementStart!, + replacementStart! + replacement.new_text.length, + ) + : nextSites.get(`${source.path}:${site.kind}:${site.callForm ?? ''}:${offset}`); + const previous = before.referenceBindings.get(site); + const matches = previous + ? (candidates ?? []).filter((candidate) => { + const next = after.referenceBindings.get(candidate); + return ( + next && + previous.status === next.status && + previous.candidatesComplete === next.candidatesComplete && + bindingIds(previous, definitions) === bindingIds(next) && + (previous.importAlias !== undefined) === (next.importAlias !== undefined) + ); + }) + : []; + const positionOf = (candidate: ReferenceSite): number => { + const nextRange = candidate.nameRange ?? candidate.atRange; + return offsetOf(updated.get(source.path)!, nextRange.startLine, nextRange.startCol); + }; + const matchedPositions = new Set(matches.map(positionOf)); + // Capture queries can describe the same reference more than once. Accept + // equivalent evidence at one token, but never conflicting bindings there + // or multiple matching tokens inside an expanded shorthand replacement. + if ( + matchedPositions.size !== 1 || + candidates?.filter((candidate) => matchedPositions.has(positionOf(candidate))).length !== + matches.length + ) { + throw new RenameFailure( + 'name_collision', + `The proposed name changes a source binding in ${source.path}:${range.startLine}.`, + ); + } + } +} diff --git a/gitnexus/src/mcp/local/rename/rename-plan.ts b/gitnexus/src/mcp/local/rename/rename-plan.ts new file mode 100644 index 000000000..9512f539b --- /dev/null +++ b/gitnexus/src/mcp/local/rename/rename-plan.ts @@ -0,0 +1,214 @@ +import { + RenameFailure, + applyEdits, + prepareRenameEdits, + renameResult as result, + blockedRenameResult as blocked, +} from 'gitnexus-shared'; +import type { + RenameSymbol, + RenameOptions, + RenameResult, + RenamePlan, + FileChange, + OccurrenceEdit, +} from 'gitnexus-shared'; +export { RenameFailure } from 'gitnexus-shared'; +export type { + RenameSymbol, + RenameOptions, + RenameResult, + RenamePlan, + RenameCoverage, + OccurrenceEdit, +} from 'gitnexus-shared'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import type { GraphNode } from 'gitnexus-shared'; +import { logger } from '../../../core/logger.js'; + +/** Lexical containment must be checked before resolving symlinks. */ +export function repositoryPath(repoPath: string, filePath: string): string { + const root = path.resolve(repoPath); + const absolute = path.resolve(root, filePath); + const relative = path.relative(root, absolute); + if ( + !relative || + relative === '..' || + relative.startsWith(`..${path.sep}`) || + path.isAbsolute(relative) + ) { + throw new RenameFailure( + 'outside_repository', + `Rename path is outside the repository: ${filePath}`, + ); + } + return absolute; +} + +async function checkedRealPath(repoPath: string, filePath: string): Promise { + const absolute = repositoryPath(repoPath, filePath); + const [root, real] = await Promise.all([fs.realpath(repoPath), fs.realpath(absolute)]); + repositoryPath(root, real); + return real; +} + +async function validateSnapshots(snapshots: ReadonlyMap): Promise { + const entries = [...snapshots]; + // Bound concurrent reads so large repositories cannot exhaust descriptors. + // Every source is still checked before each replacement, including unedited files. + for (let start = 0; start < entries.length; start += 8) { + const batch = entries.slice(start, start + 8); + const contents = await Promise.all(batch.map(([file]) => fs.readFile(file))); + for (let index = 0; index < batch.length; index++) { + const [file, snapshot] = batch[index]!; + if (contents[index]!.equals(snapshot)) continue; + throw new RenameFailure( + 'source_changed', + `Source changed while planning rename: ${file}. Retry from current sources.`, + ); + } + } +} + +/** Validates the complete edit set and source versions before any write. */ +export async function executeRenamePlan( + repoPath: string, + plan: RenamePlan, + dryRun = true, +): Promise { + const changes: FileChange[] = []; + // Reuse encoded snapshots across the full-scope checks instead of encoding + // every unchanged source again for every file that is written. + const expectedSnapshots = new Map( + [...plan.snapshots].map(([file, snapshot]) => [file, Buffer.from(snapshot, 'utf8')]), + ); + const files = new Map(); + try { + if (!plan.edits.length) + throw new RenameFailure('no_locations', 'The provider returned no rename locations.'); + const byFile = new Map(); + for (const edit of plan.edits) { + const absolute = repositoryPath(repoPath, edit.file_path); + const key = path.relative(path.resolve(repoPath), absolute).split(path.sep).join('/'); + const edits = byFile.get(key) ?? []; + edits.push(edit); + byFile.set(key, edits); + } + const realPaths = new Set(); + for (const [file, unsorted] of [...byFile.entries()].sort(([a], [b]) => a.localeCompare(b))) { + const absolute = repositoryPath(repoPath, file); + const real = await checkedRealPath(repoPath, file); + if (realPaths.has(real)) + throw new RenameFailure( + 'duplicate_file', + `Multiple rename paths refer to the same file: ${file}`, + ); + realPaths.add(real); + const snapshot = plan.snapshots.get(absolute); + if (snapshot === undefined) + throw new RenameFailure('missing_snapshot', `No source snapshot for ${file}.`); + const { edits, reports } = prepareRenameEdits(snapshot, unsorted, file); + const output = applyEdits(snapshot, edits); + files.set(file, { absolute, real, output }); + changes.push({ file_path: file, edits: reports }); + } + await validateSnapshots(expectedSnapshots); + } catch (error) { + return { ...blocked(plan.symbol, plan.new_name, error), coverage: plan.coverage }; + } + + if (dryRun) return { ...result(plan.symbol, plan.new_name, changes), coverage: plan.coverage }; + const landed: FileChange[] = []; + for (const change of changes) { + const file = files.get(change.file_path)!; + let temporaryDirectory: string | undefined; + try { + // Recheck each destination before staging its replacement. A detected source + // change after an earlier write is reported as a partial operation. + if ( + (await checkedRealPath(repoPath, change.file_path)) !== file.real || + !(await fs.readFile(file.absolute)).equals(expectedSnapshots.get(file.absolute)!) + ) { + throw new RenameFailure( + 'source_changed', + `Source changed before writing ${change.file_path}.`, + ); + } + // A rejected in-place write may already have truncated the source. Finish + // a replacement beside the destination before installing it atomically. + const { mode } = await fs.stat(file.real); + temporaryDirectory = await fs.mkdtemp( + path.join(path.dirname(file.real), '.gitnexus-rename-'), + ); + const replacement = path.join(temporaryDirectory, 'replacement'); + await fs.writeFile(replacement, file.output, 'utf8'); + await fs.chmod(replacement, mode); + // Unedited sources also contribute semantic evidence. Already installed + // files must match their landed outputs rather than their old snapshots. + await validateSnapshots(expectedSnapshots); + // A later evidence read can race with an edit to this destination. Recheck + // last; validation and replacement remain separate filesystem operations. + if ( + (await checkedRealPath(repoPath, change.file_path)) !== file.real || + !(await fs.readFile(file.absolute)).equals(expectedSnapshots.get(file.absolute)!) + ) { + throw new RenameFailure( + 'source_changed', + `Source changed before replacing ${change.file_path}.`, + ); + } + await fs.rename(replacement, file.real); + expectedSnapshots.set(file.absolute, Buffer.from(file.output, 'utf8')); + landed.push(change); + } catch (error) { + return { + ...result(plan.symbol, plan.new_name, landed), + coverage: plan.coverage, + status: landed.length ? 'partial' : 'error', + applied: landed.length > 0, + application_status: landed.length ? 'partial' : 'failed', + code: error instanceof RenameFailure ? error.code : 'write_failed', + error: `${error instanceof Error ? error.message : String(error)} Inspect failed_files and unattempted_files before retrying; writes are not transactional.`, + // Landed files are exactly changes[0..landed.length); the current change is the + // one whose staging, validation or install threw, and the rest were never touched. + failed_files: [change.file_path], + unattempted_files: changes.slice(landed.length + 1).map((c) => c.file_path), + }; + } finally { + // Cleanup must not turn an installed replacement into a reported failure. + if (temporaryDirectory) + await fs.rm(temporaryDirectory, { recursive: true, force: true }).catch(() => {}); + } + } + return { + ...result(plan.symbol, plan.new_name, landed), + coverage: plan.coverage, + applied: true, + application_status: 'applied', + }; +} + +/** One planner for preview and application; unsupported providers never fall back to text search. */ +export async function renameSymbol( + repoPath: string, + symbol: RenameSymbol, + options: RenameOptions, + graphNodes: readonly GraphNode[], + referenceFiles: readonly string[] = [], +): Promise { + try { + if (options.dry_run !== undefined && typeof options.dry_run !== 'boolean') + throw new RenameFailure('invalid_argument', 'dry_run must be a boolean.'); + 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) { + if (!(error instanceof RenameFailure)) + logger.error( + { err: error, symbolUid: symbol.uid, filePath: symbol.filePath }, + 'Semantic rename planning failed', + ); + return blocked(symbol, options.new_name, error); + } +} diff --git a/gitnexus/src/mcp/read-only-policy.ts b/gitnexus/src/mcp/read-only-policy.ts index 24c550cca..2450fe490 100644 --- a/gitnexus/src/mcp/read-only-policy.ts +++ b/gitnexus/src/mcp/read-only-policy.ts @@ -1,4 +1,4 @@ -import type { GITNEXUS_TOOLS } from './tools.js'; +import { READ_ONLY_TOOL_ANNOTATIONS, type GITNEXUS_TOOLS } from './tools.js'; type GitNexusTool = (typeof GITNEXUS_TOOLS)[number]; @@ -10,6 +10,9 @@ export const MCP_READ_ONLY_TOOLS = new Set([ 'grep', 'detect_changes', 'check', + // Listed with a preview-only schema (see toolForReadOnlyMcp) and gated by + // assertMcpReadOnlyToolCall, which rejects every dry_run except omitted/true. + 'rename', 'impact', 'explain', 'pdg_query', @@ -20,6 +23,14 @@ export const MCP_READ_ONLY_TOOLS = new Set([ 'trace', ]); +const READ_ONLY_RENAME_SUMMARY = + 'Read-only semantic rename preview (dry_run must be omitted or true; edits cannot be applied)'; + +const READ_ONLY_RENAME_DESCRIPTION = `Preview a rename using the indexed graph and GitNexus's own semantic layer in the current checkout. Preview-only in MCP read-only mode: dry_run must be omitted or true, and any other value is rejected before dispatch. No file is ever written. +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 with semantic_edits, exact UTF-16 spans, and coverage describing the indexed source scope and limitations. Ambiguous symbol_name returns context()'s ranked candidates; re-call with symbol_uid.`; + const MCP_READ_ONLY_ALIASES = new Set(['search', 'explore', 'overview']); export function resolveMcpReadOnlyMode(env: NodeJS.ProcessEnv = process.env): boolean { @@ -38,6 +49,15 @@ export function assertMcpReadOnlyToolCall( if (!MCP_READ_ONLY_TOOLS.has(toolName) && !MCP_READ_ONLY_ALIASES.has(toolName)) { throw new Error(`Tool "${toolName}" is not available in GitNexus MCP read-only mode.`); } + // `rename` is the one tool here that can write. Its read-only listing is + // preview-only, so the dispatch contract must agree: only an omitted or + // literal `true` reaches the backend. Anything else - `false`, `"false"`, `0`, + // `null` - is rejected here rather than left to the planner's own type check. + if (toolName === 'rename' && args?.dry_run !== undefined && args.dry_run !== true) { + throw new Error( + 'Parameter "dry_run" must be omitted or true for "rename" in GitNexus MCP read-only mode; applying edits is not available.', + ); + } if (typeof args?.repo === 'string' && args.repo.trim().startsWith('@')) { throw new Error('Group routing is not available in GitNexus MCP read-only mode.'); } @@ -85,10 +105,17 @@ export function filterMcpReadOnlyResourceContent(content: string, readOnly: bool .split('\n') .filter( (line) => - !/^\s*-\s+(?:rename|cypher|group_sync|group_list):/u.test(line) && - !/^\|\s*`(?:rename|cypher|group_sync|group_list)`\s*\|/u.test(line) && + !/^\s*-\s+(?:cypher|group_sync|group_list):/u.test(line) && + !/^\|\s*`(?:cypher|group_sync|group_list)`\s*\|/u.test(line) && !line.includes('gitnexus://group/'), ) + .map((line) => { + // rename stays listed, but the generated lines advertise apply; restate + // them as the preview-only contract this mode actually enforces. + if (/^\s*-\s+rename:/u.test(line)) return ` - rename: ${READ_ONLY_RENAME_SUMMARY}`; + if (/^\|\s*`rename`\s*\|/u.test(line)) return `| \`rename\` | ${READ_ONLY_RENAME_SUMMARY} |`; + return line; + }) .join('\n'); } @@ -115,6 +142,24 @@ export function toolForReadOnlyMcp(tool: GitNexusTool, readOnly: boolean): GitNe delete properties.subgroup; delete properties.crossDepth; + if (tool.name === 'rename') { + // Preview-only listing: a schema-validating client cannot even form a + // dry_run:false call, and the annotations stop advertising a destructive tool. + properties.dry_run = { + type: 'boolean', + const: true, + default: true, + description: + 'Preview only in MCP read-only mode: omit or pass true. false and non-boolean values are rejected.', + }; + return { + ...tool, + description: `${READ_ONLY_RENAME_DESCRIPTION}\n\nGitNexus MCP read-only mode excludes raw Cypher, mutation, and group routing.`, + annotations: READ_ONLY_TOOL_ANNOTATIONS, + inputSchema: { ...tool.inputSchema, properties }, + }; + } + return { ...tool, description: `${scrubGroupDescription(tool.description)}\n\nGitNexus MCP read-only mode excludes raw Cypher, mutation, and group routing.`, diff --git a/gitnexus/src/mcp/resources.ts b/gitnexus/src/mcp/resources.ts index 49687fa9b..d048f30d8 100644 --- a/gitnexus/src/mcp/resources.ts +++ b/gitnexus/src/mcp/resources.ts @@ -415,7 +415,9 @@ 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: Multi-file coordinated rename with confidence tags'); + lines.push( + ' - rename: Semantic rename; previews by default (dry_run omitted/true), dry_run: false applies exact occurrences in the selected project', + ); lines.push(' - cypher: Raw graph queries'); lines.push(' - list_repos: Discover all indexed repositories'); lines.push(''); @@ -700,7 +702,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` | Multi-file coordinated rename with confidence-tagged edits |', + '| `rename` | Semantic rename: previews by default (dry_run omitted/true), dry_run: false applies it in the selected project |', '| `cypher` | Raw graph queries |', '| `list_repos` | Discover indexed repos |', '', diff --git a/gitnexus/src/mcp/server.ts b/gitnexus/src/mcp/server.ts index 6bfb6d478..e7da8393c 100644 --- a/gitnexus/src/mcp/server.ts +++ b/gitnexus/src/mcp/server.ts @@ -7,7 +7,7 @@ * * Supports multiple indexed repositories via the global registry. * - * Tools: list_repos, query, cypher, context, read_file, grep, impact, detect_changes, rename + * Tools: list_repos, query, cypher, context, read_file, grep, impact, detect_changes, rename (preview by default; dry_run:false applies) * Resources: repos, repo/{name}/context, repo/{name}/clusters, ... */ @@ -53,7 +53,11 @@ import { assertKnownMcpToolArguments, schemaSourceToolName } from './tool-argume * Design: Each hint is a short, actionable instruction (not a suggestion). * The hint references the specific tool/resource to use next. */ -function getNextStepHint(toolName: string, args: Record | undefined): string { +export function getNextStepHint( + toolName: string, + args: Record | undefined, + readOnly = false, +): string { const repo = args?.repo; const repoParam = repo ? `, repo: "${repo}"` : ''; const repoPath = repo || '{name}'; @@ -74,8 +78,21 @@ function getNextStepHint(toolName: string, args: Record | undefined case 'detect_changes': return `\n\n---\n**Next:** Review affected processes. Use context() on high-risk changed symbols. READ gitnexus://repo/${repoPath}/process/{name} for full execution traces.`; - case 'rename': - return `\n\n---\n**Next:** Run detect_changes(${repoParam ? `{repo: "${repo}"}` : ''}) to verify no unexpected side effects from the rename.`; + case 'rename': { + // Only an explicit dry_run:false can have written files. Everything else + // is a preview (omitted defaults to true), so the next step is to apply it, + // not to verify side effects that cannot exist yet. + if (args?.dry_run === false) { + return `\n\n---\n**Next:** Run detect_changes(${repoParam ? `{repo: "${repo}"}` : ''}) to verify no unexpected side effects from the rename.`; + } + if (readOnly) { + return `\n\n---\n**Next:** This is a preview; MCP read-only mode cannot apply it. Review semantic_edits and coverage, and apply from a client that is not read-only.`; + } + const target = args?.symbol_uid + ? `symbol_uid: ${JSON.stringify(args.symbol_uid)}` + : `symbol_name: ${JSON.stringify(args?.symbol_name ?? '')}`; + return `\n\n---\n**Next:** Preview only; no files were changed. If status is "success", review semantic_edits and coverage, then apply with rename({${target}, new_name: ${JSON.stringify(args?.new_name ?? '')}, dry_run: false${repoParam}}). If it failed closed, act on its code and error rather than retrying unchanged.`; + } case 'cypher': return `\n\n---\n**Next:** To explore a result symbol, use context({name: ""${repoParam}}). For schema reference, READ gitnexus://repo/${repoPath}/schema.`; @@ -216,7 +233,8 @@ export function createMCPServer( name: tool.name, description: tool.description, inputSchema: - (tool.name === 'rename' ? mutatingRequiresRepo : readOnlyRequiresRepo) && + // Read-only `rename` is preview-only, so it follows the read-tool rule. + (tool.name === 'rename' && !readOnly ? mutatingRequiresRepo : readOnlyRequiresRepo) && REPO_SCOPED_TOOLS.has(tool.name) ? { ...tool.inputSchema, @@ -245,7 +263,7 @@ export function createMCPServer( maxTokens = resolveMcpMaxTokens(name, typedArgs); const result = await scopedBackend.callTool(name, withoutMcpBudgetArg(typedArgs)); const resultText = typeof result === 'string' ? result : JSON.stringify(result, null, 2); - const hint = getNextStepHint(name, args as Record | undefined); + const hint = getNextStepHint(name, args as Record | undefined, readOnly); return { content: [ diff --git a/gitnexus/src/mcp/tools.ts b/gitnexus/src/mcp/tools.ts index 77b527e00..0f56d3689 100644 --- a/gitnexus/src/mcp/tools.ts +++ b/gitnexus/src/mcp/tools.ts @@ -22,6 +22,7 @@ export interface ToolDefinition { default?: unknown; items?: { type: string }; enum?: string[]; + const?: boolean; minimum?: number; maximum?: number; minLength?: number; @@ -32,7 +33,7 @@ export interface ToolDefinition { }; } -const READ_ONLY_TOOL_ANNOTATIONS: ToolAnnotations = { +export const READ_ONLY_TOOL_ANNOTATIONS: ToolAnnotations = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, @@ -97,12 +98,19 @@ export const CONTEXT_CHAIN_MAX_DEPTH = 3; const CWD_AWARE_REPO_OMISSION = 'Omit when only one repo is indexed, an MCP default is configured, or the GitNexus process cwd is inside a registered path without crossing an unindexed nested Git checkout; otherwise specify it explicitly.'; const MUTATING_REPO_OMISSION = - 'Omit only when one repo is indexed or an MCP default is configured; otherwise mutating tools require an explicit repo.'; + 'Omit only when one repo is indexed or an MCP default is configured; otherwise mutating tools require an explicit repo. A preview (dry_run omitted or true) may also omit it when the GitNexus process cwd is inside a registered path; dry_run:false always needs one of the first two.'; /** Always-on identity+freshness field on query/context/impact/cypher object results (#3291). */ const HOT_READ_STALENESS_NOTE = "Object results attach `staleness` even when current. Read `staleness.branch`/`lastCommit` for which index answered and `status` for freshness. Re-analyze only for `behind` or `diverged` — `current` is this clone's HEAD, not necessarily the default branch; `unknown` is unmeasurable, not stale. Field is only on object results (not raw-array cypher, error envelopes, or `@group` calls)."; +const RENAME_PROPERTIES: ToolDefinition['inputSchema']['properties'] = { + symbol_name: { type: 'string', description: 'Current symbol name to rename' }, + 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' }, +}; + export const GITNEXUS_TOOLS: ToolDefinition[] = [ { name: 'list_repos', @@ -470,37 +478,22 @@ A graph too large to analyze at all returns \`{ error, truncated: true }\` with }, { name: 'rename', - description: `Multi-file coordinated rename using the knowledge graph + text search. -Finds all references via graph (high confidence) and regex text search (lower confidence). Preview by default. - -WHEN TO USE: Renaming a function, class, method, or variable across the codebase. Safer than find-and-replace. -AFTER THIS: Run detect_changes() to verify no unexpected side effects. - -Each edit is tagged with confidence: -- "graph": found via knowledge graph relationships (high confidence, safe to accept) -- "text_search": found via regex text search (lower confidence, review carefully) - -Handles disambiguation via context()'s payload verbatim: an ambiguous symbol_name returns status "ambiguous" with ranked candidates and totalCandidates — the TRUE match count, not candidates[].length — plus candidatesTruncated:true and a "(showing M)" suffix on message when candidates[] is the shorter window. Re-call with symbol_uid.`, + description: `Rename using the indexed graph and GitNexus's own semantic layer. Previews by default: dry_run omitted or true never writes; dry_run:false applies exact resolved name spans after checking source snapshots. Preview first, review semantic_edits and coverage, then re-call with dry_run:false. +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 are not transactional: changes lists only landed edits, failed_files the file that failed, and unattempted_files later files never touched; review 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: { type: 'object', properties: { - symbol_name: { type: 'string', description: 'Current symbol name to rename' }, - symbol_uid: { - type: 'string', - description: 'Direct symbol UID from prior tool results (zero-ambiguity)', - }, - new_name: { type: 'string', description: 'The new name for the symbol' }, - file_path: { type: 'string', description: 'File path to disambiguate common names' }, + ...RENAME_PROPERTIES, dry_run: { type: 'boolean', - description: 'Preview edits without modifying files (default: true)', + description: + 'Preview edits without modifying files (default: true). Pass false to apply.', default: true, }, - repo: { - type: 'string', - description: `Repository name or path. ${MUTATING_REPO_OMISSION}`, - }, + repo: { type: 'string', description: `Repository name or path. ${MUTATING_REPO_OMISSION}` }, }, required: ['new_name'], }, @@ -1122,7 +1115,7 @@ Optional caseSensitive and literal match HTTP /api/grep (default: case-insensiti * loop below skips `branch` for `CHECKOUT_SOURCE_TOOLS` — a pin would label * checkout bytes with another commit. */ -export const CHECKOUT_SOURCE_TOOLS = new Set(['read_file', 'grep']); +export const CHECKOUT_SOURCE_TOOLS = new Set(['read_file', 'grep', 'rename']); export const REPO_SCOPED_TOOLS = new Set([ 'read_file', diff --git a/gitnexus/src/storage/parse-cache.ts b/gitnexus/src/storage/parse-cache.ts index c4a56ed56..ab6bff2b6 100644 --- a/gitnexus/src/storage/parse-cache.ts +++ b/gitnexus/src/storage/parse-cache.ts @@ -858,7 +858,13 @@ import { copyV8CacheIfPresent, tryLoadV8Cache, writeV8CacheFile } from './v8-sid // and original graph positions. Both stores must re-extract v135 captures. // v137: Python module-accessor syntax proofs and exact assigned-call provenance // join the existing subtype capture payload. Re-extract v136 in both stores. -const SCHEMA_BUMP = 137; +// v138: provider-owned source occurrences and new flow visitors/explicit region +// ownership must not reuse older capture or CFG side channels. +// v139: real rename fixtures expose missing field/read/write chains, callable +// scopes and declaration metadata. Re-extract both stores with provider fixes. +// v140: Ruby accessor captures retain reader visibility and singleton ownership. +// Older worker and durable captures cannot prove property-reader dispatch. +const SCHEMA_BUMP = 140; const GITNEXUS_PKG_VERSION = (() => { try { // package.json sits at gitnexus/package.json — two levels up from diff --git a/gitnexus/test/fixtures/csharp-captures-golden/expected-captures.json b/gitnexus/test/fixtures/csharp-captures-golden/expected-captures.json index 427509c04..02d621116 100644 --- a/gitnexus/test/fixtures/csharp-captures-golden/expected-captures.json +++ b/gitnexus/test/fixtures/csharp-captures-golden/expected-captures.json @@ -1,11 +1,11 @@ { "csharp-alias-imports/Models/Repo.cs": { - "captureGroups": 15, - "digest": "044a25113f7b90a72ad0e42cd7f747848222a21c4093775f08c133188dc25687" + "captureGroups": 16, + "digest": "478fd5dc5396be1826b6f62976460443184097f7cd1d6ab388a8726477d6d8ff" }, "csharp-alias-imports/Models/User.cs": { - "captureGroups": 15, - "digest": "a3fe19d6bf25831c2e1afc836e0ed82cc903a43e006d7428a2386b0bc7ea0c47" + "captureGroups": 16, + "digest": "a2f0c4b32a34404b8649f37a1096feb449c4a263951857283ef8d126730bdd4e" }, "csharp-alias-imports/Services/Main.cs": { "captureGroups": 18, @@ -48,8 +48,8 @@ "digest": "c2d5ef9688ff2fce345fe8d8148ee0d8281032b55083f70ac925e5407b3c0d0c" }, "csharp-async-binding/OrderService.cs": { - "captureGroups": 14, - "digest": "492b2ceffaeae03b6a9d673f961dc5386bf08496ef4a8ae70dae68a604801c2f" + "captureGroups": 15, + "digest": "81b17a923e6cffb4dc087024844f8e5e83a4ea4aa0d1bfb8e57f92d4a29da71e" }, "csharp-async-binding/Program.cs": { "captureGroups": 37, @@ -60,20 +60,20 @@ "digest": "d771395595ea17b22e3f7b448bb10463086aa89fe49f54fe24ee514e2392a880" }, "csharp-async-binding/UserService.cs": { - "captureGroups": 14, - "digest": "e68423fbb601a61a100d01ef070e0d37f817be4a4c6555500b56fa2ed290adce" + "captureGroups": 15, + "digest": "2cbed3a07aa031cde84f8f3afd2c7574273e107eb45c6d17290d2ea5c4001235" }, "csharp-call-result-binding/App.cs": { - "captureGroups": 27, - "digest": "7439cd5fada77ae186fb76594404a8650e21b4892ff268dfbcad6c3ec741c478" + "captureGroups": 29, + "digest": "90cf80e48027f116ea15c5405b5724eff6a2c30c03c0a23cf0efcd302b8516a9" }, "csharp-calls/Services/UserService.cs": { "captureGroups": 11, "digest": "d527d47a183733c34d56227c383267f25acc951413639f1e6cc6cb7eabd2440a" }, "csharp-calls/Utils/OneArg.cs": { - "captureGroups": 8, - "digest": "65bf57fabe6074033a64e5e5adca6aa433e5087576eac96b13172934f55f0c82" + "captureGroups": 9, + "digest": "0f54dc9fb028fd71a6c548459113aaf1507302e567e2e183e9f9a23b05a9978d" }, "csharp-calls/Utils/ZeroArg.cs": { "captureGroups": 7, @@ -117,7 +117,7 @@ }, "csharp-collection-accessor/Services/Renderer.cs": { "captureGroups": 16, - "digest": "b116c898071aab598ab151b9619ec8517fed67ad96b5bfd575138f62c5369f82" + "digest": "36bfb545a75a942e4e03512fef1f6033478459640a28bb05e3ef78e286fd8d54" }, "csharp-deep-field-chain/Models.cs": { "captureGroups": 25, @@ -125,7 +125,7 @@ }, "csharp-deep-field-chain/Service.cs": { "captureGroups": 14, - "digest": "daeb42918323c3f79de9b72ffc72d2c61bb085a65ffb1f67ca3a0d9a78ec06e8" + "digest": "d4570a2711736e865b58b38de6cdb33cf99fe37cc3de3a964b13bd3a03528caa" }, "csharp-dictionary-keys-values/App.cs": { "captureGroups": 21, @@ -140,12 +140,12 @@ "digest": "49ec4930562a8e12040010b12535a1620aa5ab86e2aab3cad2623e8aec106518" }, "csharp-explicit-ctor-init/Models/Base.cs": { - "captureGroups": 9, - "digest": "6adf4ac489b35e51161a4d67a0ab2fe3c7191e08a1c0c6bf051cb653a1cb4b31" + "captureGroups": 10, + "digest": "a395d4f736784b4fd453273bc8d2081b39b5dc9a1b9531fed351b8845d297f6c" }, "csharp-explicit-ctor-init/Models/Child.cs": { - "captureGroups": 17, - "digest": "f056fbf3be0445392d0f67e35dff5e7690c6244d650322d20a2d35ca9f04c9aa" + "captureGroups": 18, + "digest": "a89dc2b9f02fd7a528d3e2152e18f143ad948b9ad98370a957c1edca22ad76b6" }, "csharp-field-types/Models.cs": { "captureGroups": 17, @@ -212,8 +212,8 @@ "digest": "29a9220fcd1726794e0335200048b297d770a2feef9303431097537f8b07eaba" }, "csharp-hello/Hello.cs": { - "captureGroups": 22, - "digest": "8952ec5a995332bc204cab3a9132370dfdf78594f592d569aca3c08db2da74a6" + "captureGroups": 23, + "digest": "b67606e0b429ecbf9b4e285193d9780f8f952da8a9001c515df702dd5495c9ce" }, "csharp-inline-constructor-receiver/src/Runner.cs": { "captureGroups": 22, @@ -228,8 +228,8 @@ "digest": "1df65065f20b8d316238bd3f7c43f95cc0e2527c8f7cca88e95a872016a04ed2" }, "csharp-interface-default-method/User.cs": { - "captureGroups": 14, - "digest": "ef10faa8e723eab836331e5c2d29c4b167cfd2c1e15c391989ea394eb1cab5c4" + "captureGroups": 15, + "digest": "ab6874964b5afac9d75fff23cc5df27c34d64e2f70d98ac8b51c112fa36d6f13" }, "csharp-interface-default-method/Validator.cs": { "captureGroups": 8, @@ -240,12 +240,12 @@ "digest": "ca1d074837f867a78d9b5e1f197ab967128e89b20d62acdba34bfaa08b01ef6d" }, "csharp-interface-dispatch/IRepository.cs": { - "captureGroups": 9, - "digest": "88c0a5b4146a78b07cfe361bfb20ced73c05be707997fbfa4bcefb8dd60f8b50" + "captureGroups": 11, + "digest": "a7504daa5b79f2abe2a568864e83ddbb543aed1d2d37f3f4361b62f8d4f2006c" }, "csharp-interface-dispatch/SqlRepository.cs": { - "captureGroups": 14, - "digest": "4ec288844786e2b45a1cb7421474b3c1cc5606e42c34f2364fce642c60f1aae2" + "captureGroups": 16, + "digest": "cebddbd9c98dc69699d800a11af40f4d06e961ba1e9ec4a8ea8beb7872256746" }, "csharp-interface-heritage/src/IAuditableService.cs": { "captureGroups": 8, @@ -272,8 +272,8 @@ "digest": "c5a6484f74d530463f3028036f401323d84ec2a825fd52450326d95c8088ccb2" }, "csharp-interface-receiver-static/src/ILogger.cs": { - "captureGroups": 8, - "digest": "57032d77c03d756e71307aab5fc993754b4972598b6362689bf905786c4a32ac" + "captureGroups": 9, + "digest": "a56a2dd2725b387dcbe59080c8ec6d010c147952ddf556b346e40ea20083b9a4" }, "csharp-interface-receiver-static/src/Runner.cs": { "captureGroups": 9, @@ -288,8 +288,8 @@ "digest": "2c97632b3beb8f5500e4d02b1150d2845fa6d6bc26ee1838e4e1118ec6a8e847" }, "csharp-is-pattern/services/App.cs": { - "captureGroups": 12, - "digest": "4ad57edbcf5462d7a254fe63f59e5e513ab4032c18d437cc1154cde5c857897d" + "captureGroups": 13, + "digest": "a46b34a2bf98bdb99d3a44e23ab9d4f5a75dac4f9331124f3b0c6098949696c2" }, "csharp-large-cache-miss-resolution/Models/User.cs": { "captureGroups": 8, @@ -304,12 +304,12 @@ "digest": "ac7dcf56199eb29174cfbfb775e956247f72abc476cc8e28751d94084d1b5c7a" }, "csharp-local-shadow/App/Main.cs": { - "captureGroups": 14, - "digest": "b326e2800d010f3f0daa3ffa75444c59de53dbe4962ce4af4e90c76f8b6271c9" + "captureGroups": 15, + "digest": "96cb5b50e835b9ab6dfb5295c2dd0cab99b3a83980b6ae55bb1b1278dcf7bc5c" }, "csharp-local-shadow/Utils/Logger.cs": { - "captureGroups": 10, - "digest": "8089cf6d43685f35d479d58106d2a1a7a35994ddd52522cb256e009943bc7844" + "captureGroups": 11, + "digest": "b948480418881cb00f526c995995c316351f5154a1762eaf07989a50fa835a48" }, "csharp-member-calls/Models/User.cs": { "captureGroups": 8, @@ -320,12 +320,12 @@ "digest": "662512b485c31efe8b5a37df38e31bccf130e7625c7ef7594ea44e27b0b79d2c" }, "csharp-method-chain-binding/App.cs": { - "captureGroups": 60, - "digest": "4cdccde81efbe41cac6bc82e33b365ccd79481a440e65012f78cb543421c9873" + "captureGroups": 61, + "digest": "7d08da704c897f0294d89709f4f3fe064c2e278bf01f5e3296f767f99b221160" }, "csharp-method-enrichment/Animal.cs": { - "captureGroups": 18, - "digest": "ad7890731caf219c98664630dfe886f00ed33a4a3f6a4a30f945c889835eae1e" + "captureGroups": 19, + "digest": "af6d457b0f533e329e011b5f219f31c11c53289c584875e734279a9c53c0d3c4" }, "csharp-method-enrichment/App.cs": { "captureGroups": 14, @@ -340,8 +340,8 @@ "digest": "be74672b6af6192d0b7872dcad9a6ba4af08156aad5cc01c6fe3eb2ee02b4127" }, "csharp-mixed-decl-chain/Program.cs": { - "captureGroups": 28, - "digest": "466f29bc93a0293011c8c43f4655d87a26a57166261aaf85df3d08c2d38cf10b" + "captureGroups": 29, + "digest": "aed7707d6696322c44d75cf102f043a138a4eba0100b23b37e6d646ba8310e79" }, "csharp-namespace-as-root-no-trailing-newline/App/Program.cs": { "captureGroups": 13, @@ -365,7 +365,7 @@ }, "csharp-nested-member-foreach/App.cs": { "captureGroups": 19, - "digest": "e4a193bdab13753422287500c3933ddf8fde1290c2250c3fecc838968f3092e9" + "digest": "2525d8e28eb807bd1d716c14372470bfdefa5c377af7de06e28c59f26fc50b89" }, "csharp-nested-member-foreach/Repo.cs": { "captureGroups": 7, @@ -408,28 +408,28 @@ "digest": "064640e172244bee5078073d4eed69df177681cba8c0a8105a391a65864612f9" }, "csharp-optional-params/Services/App.cs": { - "captureGroups": 17, - "digest": "5fb11a605cac8fe2abf017e1724bf8881eacbc147f9a29052e432bf3ca26b284" + "captureGroups": 19, + "digest": "b78454518b177b4be6b13929cf166cbace8dbc57545628c4e79200c98430860e" }, "csharp-overload-dispatch/App.cs": { "captureGroups": 12, "digest": "c0ac495ae6dc34db5cdb9f508be050fab06920e8f110b14a8c9f095613a269c5" }, "csharp-overload-dispatch/IRepository.cs": { - "captureGroups": 13, - "digest": "9f7fc8cfd4c0e3b67581f96945760ffe96183f55ce93809d1fc9e0abb9d42b67" + "captureGroups": 17, + "digest": "994192d29436f6932c121a210b313134fd0eb8befc1e9a08ad085ac5be56ec79" }, "csharp-overload-dispatch/SqlRepository.cs": { - "captureGroups": 22, - "digest": "d4d7156fc95fa515126011bffed810ecdcf94b2f4ea6e537b1d8dfa5d8d7abe9" + "captureGroups": 26, + "digest": "31c667138dfe7cb38c8954c5a036d2c2de50933aca6b73a1383132dbced40c3b" }, "csharp-overload-interface/App/Caller.cs": { "captureGroups": 15, - "digest": "5769b1eda360cd588192335e47035683dae751b9f67e0528cccc066b1e4ed887" + "digest": "1f184c5beba9ecd5a414583fbf678f4e2f596b6870bb8e7037e32f6b9bcf1637" }, "csharp-overload-interface/App/Logger.cs": { - "captureGroups": 14, - "digest": "71eb7a633b950c765dcfb50a28047b4156a121c653ee189a9288c1998c3c71a7" + "captureGroups": 17, + "digest": "15d03a3e89e08f9682723133525c86372b87a78b021019c9f2148b534e64a436" }, "csharp-overload-interface/Greeting/EnGreeter.cs": { "captureGroups": 10, @@ -444,8 +444,8 @@ "digest": "4a42025fdcb4db0bf1b77e1e4fa024af5c2a4dc36fe6806ce397b980047790d0" }, "csharp-overload-param-types/Models/UserService.cs": { - "captureGroups": 30, - "digest": "ba08bc90619c578582465cb9f640fd0bf0640a5a6a84fdfed0be987802086bb3" + "captureGroups": 32, + "digest": "5e7cb19378ce3b6d3e694546a46c42f538959187ee1c8e974735b753beed6251" }, "csharp-parent-resolution/src/Models/BaseModel.cs": { "captureGroups": 8, @@ -484,24 +484,24 @@ "digest": "7ef5f84d5ab5764ee08c2de893f172af36fe9e3d264ed4b812ff67c8fa824453" }, "csharp-primary-ctor-heritage/src/User.cs": { - "captureGroups": 12, - "digest": "d00fce2f19f4bd9138bbcf799dcd2c549ac014c202fafb35bfc3b03235a40852" + "captureGroups": 13, + "digest": "fe7ada7029067c956188e27d6d8fc1e6d08251020ceb7ba6d48d607fa505c39c" }, "csharp-primary-ctors/App.cs": { "captureGroups": 14, "digest": "4c2e190ed675ac78244e0a4230d79e9e4c74f2ef794dd9b8126f6b57a4242f4c" }, "csharp-primary-ctors/Models/Person.cs": { - "captureGroups": 6, - "digest": "07f166295f77ad0b1bb122971344e089dd76d3e277ed7994b927364e7880ba07" + "captureGroups": 8, + "digest": "6f6e0e489794a7ba626606b34821ab94387e2fcc09d0e1b49782703421127b26" }, "csharp-primary-ctors/Models/User.cs": { - "captureGroups": 11, - "digest": "01bb40f83fca48db34d1c1c970317cf0fece5af7eb7e02836ef9e64304154b3a" + "captureGroups": 13, + "digest": "3b5791facd95716f8a1a7b269b40967a6203b40de5169add3885e00b3e4d49bc" }, "csharp-proj/Interfaces/IRepository.cs": { - "captureGroups": 14, - "digest": "902f871892b4b772b60d7e852d1a4a85b3ad4b12358d73a0a946e17c8943942c" + "captureGroups": 15, + "digest": "aa82d23281f7eaea8aac1b510aaf010699587009a017d7e3856092bf75177f70" }, "csharp-proj/Models/BaseEntity.cs": { "captureGroups": 9, @@ -512,16 +512,16 @@ "digest": "6c1b2e9adee1a7cf34ecd647be966c28aab7a8ac57c0fcb14373f2e562f3f456" }, "csharp-proj/Services/UserService.cs": { - "captureGroups": 21, - "digest": "8004a914844e0a5ff0630b019889cca8db09612678683ade419d4c40a9f60e15" + "captureGroups": 22, + "digest": "9dd22fc875cac241bcb6bd6f4fd63ec00c3f451f4093d194dbf26ae7865b270e" }, "csharp-qualified-base/src/Domain.cs": { "captureGroups": 16, "digest": "44e6d4b1da7a703f71ed04436946bd2a2d7d5695b62e4ba247573be3a2ad1d7e" }, "csharp-qualified-base/src/Shapes.cs": { - "captureGroups": 39, - "digest": "f0596aeaf7f703a890f34a425c09d0b551ffd126933ff9bb9f326381bf7f9314" + "captureGroups": 41, + "digest": "a205b4650187ba95ff8ba1aa7385eeac911cf6795970d9fb9d92b5b7e66e9d99" }, "csharp-qualified-constructor/App.cs": { "captureGroups": 12, @@ -556,12 +556,12 @@ "digest": "064640e172244bee5078073d4eed69df177681cba8c0a8105a391a65864612f9" }, "csharp-record-base/src/Models/BaseEntity.cs": { - "captureGroups": 9, - "digest": "1a4fc04784f1de00213fedbfdf103acd095c6f9a64437951a831c5182c45d073" + "captureGroups": 10, + "digest": "46501f0dd4c44bea1cb411fe1cede10136067bf8a6fa3a615da0862e655fe5a3" }, "csharp-record-base/src/Models/UserRecord.cs": { - "captureGroups": 12, - "digest": "bbb90a4b0e8854f12cf686174d28cc5af0e88603a7cdeedddb13c90b1f39dbf6" + "captureGroups": 13, + "digest": "c9fbb00f900e06fa40db9dd87e74b324261b71c635477f414388ff7589a4891e" }, "csharp-recursive-pattern/Models/Repo.cs": { "captureGroups": 9, @@ -572,16 +572,16 @@ "digest": "80b9061d53bd0ceba23699c828100ebce5af9a7c006fff7e4a99d2f8e006b92e" }, "csharp-recursive-pattern/Program.cs": { - "captureGroups": 15, - "digest": "ef252310d50287971d87e96e72b64575b9ee691602b62c74ea5b23811d2080a6" + "captureGroups": 16, + "digest": "1e37af0e3cea82b0bda8b20221e1bf0096b4cc416b03c4dbb1c05a5ba0324c6e" }, "csharp-return-type/Models/Repo.cs": { "captureGroups": 8, "digest": "4953327fdde44fada4df0c55bd1dab9c2e96b28f42a1fbfb224c374f75ce0fbb" }, "csharp-return-type/Models/User.cs": { - "captureGroups": 23, - "digest": "8ca5e28d14a29fb1f29ca6f19300b26fd99d20bc99bd9f537787a41cd9fe6196" + "captureGroups": 25, + "digest": "e020f06c4d79be4bd08111b68a44eee0948ca461712524543f7dae3687486ceb" }, "csharp-return-type/Services/App.cs": { "captureGroups": 16, @@ -592,16 +592,16 @@ "digest": "899ca0e32e844b1d347eb0e7558da99644892dc894fcf7e3f632071bbd9cfcd7" }, "csharp-same-arity-cross-file/DbLookup.cs": { - "captureGroups": 14, - "digest": "a33d9e4c3e1f7bc4f54e3cadbbf5cb044669ba5cc5912b19836cf58d8b9d5910" + "captureGroups": 16, + "digest": "e6b3d7a5ec00527775c3f7df8c014666a8d61bdd4c4d7aaada3b86add6f197c7" }, "csharp-same-arity-cross-file/Formatter.cs": { - "captureGroups": 13, - "digest": "e2305d9beeb8c7d4452bacfa7cc078dba02a17f34a993d2986cd55f03bdcfc41" + "captureGroups": 15, + "digest": "ef13050ca8ceff4e60ead6da1906a55b3245fdc836f2e5d526575c481af2a810" }, "csharp-same-arity-cross-file/ILookup.cs": { - "captureGroups": 9, - "digest": "f700d06ecdcb6d140c3c9514f85d30d04cfd19d131e570213ed166eaba691ec7" + "captureGroups": 11, + "digest": "daf34e132bd27b86fe012d64e6cc6fcf4f5e39dd168e9bd30dc1151644235d45" }, "csharp-self-this-resolution/src/Models/Repo.cs": { "captureGroups": 8, @@ -636,8 +636,8 @@ "digest": "77a89bc40ea022b9e795adcc1bf5e8a1bc67d8d1c5061c2fe990ec3d72248de0" }, "csharp-struct-overloads/src/Calc.cs": { - "captureGroups": 19, - "digest": "b45f1440bd589f070e2adfa99872d93f357a8c3831daecb1b9da31476115914e" + "captureGroups": 22, + "digest": "e54fc77240c589cd2b6cd98158e93d2c2958f9c014d20af0cc0fa061cb53a143" }, "csharp-super-resolution/src/Models/BaseModel.cs": { "captureGroups": 8, @@ -660,24 +660,24 @@ "digest": "c51e7c7e0423087ddd3a439b99c95101a3bb8611bb6677d7bd1630a464a8cacb" }, "csharp-switch-pattern/Program.cs": { - "captureGroups": 14, - "digest": "a090eff3ff572948716c931da3d62eeb137e36f08397b60748d4177293b10dc1" + "captureGroups": 15, + "digest": "cb3393844103419d54e091ea1cef778db2dc116be78d8700d416ddb417046889" }, "csharp-using-static/App/Calculator.cs": { - "captureGroups": 12, - "digest": "b6f9dd906e1309338f21633d71e663cdfd95a8707d38b9a8bf74813415ee5d13" + "captureGroups": 13, + "digest": "d1f8a37ee0d9b81760c80da3748c641c896caa072746b78a45e4294662191c6a" }, "csharp-using-static/App/NamespaceOwnerCollision.cs": { "captureGroups": 16, "digest": "d78082d240d14417ad3f502ef1e96e8e3575dd4e9cec00cfc15c7230c596ca53" }, "csharp-using-static/App/SameFileCases.cs": { - "captureGroups": 67, - "digest": "50f41faf386131ecbf6cf9c22b3594cbafee8291f385252aad7c10202881d27e" + "captureGroups": 73, + "digest": "3c0190a75af33f4ccab8f40745298483b642d9a9c4c1222b6280f93705d941d1" }, "csharp-using-static/Helpers/MathUtils.cs": { - "captureGroups": 8, - "digest": "32c174cbaade4e2d6e0aa7e95a2b5addd138441deb43ced286c9cf5cd30750aa" + "captureGroups": 9, + "digest": "10887bf60d5c19220e564ab4d5ad2313d30c04a11b10c6ae9c5f1b0533925b49" }, "csharp-var-foreach/Models/Repo.cs": { "captureGroups": 8, @@ -708,7 +708,7 @@ "digest": "f97be4b109be6bdaa583e2e7b3268b2fb90ac1ce3cfaf0291a7873f09103a2f9" }, "synthetic:dao-20": { - "captureGroups": 263, - "digest": "f082745a8fcb7a0acb364f3e3739b70f96fd2cf821ada7c4e743090348a06dcc" + "captureGroups": 283, + "digest": "3a94a40a8c6e4ec915981b9d1dafae9e2f104a3582e5f03bfea25bb16569255d" } } diff --git a/gitnexus/test/fixtures/go-captures-golden/expected-captures.json b/gitnexus/test/fixtures/go-captures-golden/expected-captures.json index ec6c29dfa..b73a0fa51 100644 --- a/gitnexus/test/fixtures/go-captures-golden/expected-captures.json +++ b/gitnexus/test/fixtures/go-captures-golden/expected-captures.json @@ -53,7 +53,7 @@ }, "go-chain-call/cmd/main.go": { "captureGroups": 20, - "digest": "5ee61ed7f61f3a1c5d1b1ceaa2a08f51422e01def6e03ef3eb0be1d49343e396" + "digest": "edf26b5fc77506b365307c8face2a2bb0da40e4b25003b39a54f9bfdaab2ffcd" }, "go-chain-call/models/repo.go": { "captureGroups": 10, @@ -97,7 +97,7 @@ }, "go-deep-field-chain/cmd/main.go": { "captureGroups": 14, - "digest": "0c58d64b8f1cbd8aa30197a97eeada471215e70290e150f16ff3d7684f21009b" + "digest": "eccee6aa99cc53541b6475c3173c1169b4ad0a58b0a30659ec0bfe73136414df" }, "go-deep-field-chain/models/models.go": { "captureGroups": 33, @@ -109,7 +109,7 @@ }, "go-extern-qualified-signatures/app/app.go": { "captureGroups": 21, - "digest": "5638e8a84cb862359620c8c5a1078e08e355d1a85883d6b166f5fab12bb33370" + "digest": "ec60476a34c733a535ec6202ae6f1597462e4c12ed23bd78d43c1294bd9abbd7" }, "go-extern-qualified-signatures/beta/beta.go": { "captureGroups": 11, @@ -125,7 +125,7 @@ }, "go-field-types/cmd/main.go": { "captureGroups": 10, - "digest": "dfe8fb6cff7e28cc209ad1237b28e11ee25ca34e89d7101dfa3a69de3186cac9" + "digest": "64713730483579d388e21644be67b5a90b6fb87dc4201cf71b69a13a00aebc66" }, "go-field-types/models/models.go": { "captureGroups": 22, @@ -157,15 +157,15 @@ }, "go-grouped-type-decl/internal/services/pick_service.go": { "captureGroups": 32, - "digest": "1bb068b1ca6c3baa6ca6ef3c97f3a50777b05e5c6d80408be8084c13ea7dde0b" + "digest": "12dff3a0d9cf1a620a74fbe085c15076296d91202aea5b6cad438aa39534601b" }, "go-grouped-type-decl/internal/services/sort_service.go": { "captureGroups": 21, - "digest": "75f8e4b967d249d5d0bef69812ed1ec040a06db7359ec691aba9165340930c98" + "digest": "af6c57421c85e619cf8cc7471181ad247bef7ad5d28e601f687265b79dca4805" }, "go-grouped-type-decl/internal/services/wave_service.go": { "captureGroups": 17, - "digest": "3bc5ef120e5c4648ac31d1e80ade828990521513b8380d2a7f45875ba990dc9c" + "digest": "6dd50da1b632665a5ebcb0d1dbd19f5168fa20cbec2419ef5fbad95aec64b75d" }, "go-inc-dec-write-access/main.go": { "captureGroups": 23, @@ -173,11 +173,11 @@ }, "go-interface-field-dispatch/internal/handlers/orders.go": { "captureGroups": 22, - "digest": "922a4f0220ba8e4363e455b2cf4185e3f83c0e5593b3296f575f5900a612f0f5" + "digest": "014b586417e7951ec678f34722bc11b07e3f176505d6d61995ccdde8c69e2750" }, "go-interface-field-dispatch/internal/handlers/picking.go": { "captureGroups": 28, - "digest": "9ce10f70ed1e54e9fbc9affbbf36976046f432b70049807abe88189ad1d19f10" + "digest": "41cf1b236be9b8ad031a1b3df068d3c527293c738fbf37afdf4530e9c8ec46be" }, "go-interface-field-dispatch/internal/repository/audit_repo.go": { "captureGroups": 18, @@ -197,11 +197,11 @@ }, "go-interface-field-dispatch/internal/services/pick_service.go": { "captureGroups": 39, - "digest": "907ecf7608704c44a2ef84ff10e2ada7d3c998d933c7af14e9c967b11ac4cb10" + "digest": "d5d668350616b695be815a702957ec91c4f9328315ce500fb9d471068843cced" }, "go-interface-field-dispatch/internal/services/wave_service.go": { "captureGroups": 46, - "digest": "99ce17d5757170222d98bde469d0ae0d1a41f386cdd4894d4f0d246debb90894" + "digest": "8f087401a4700376961db677c0cfd881df207b2aa8d41e97b5aed38593d19714" }, "go-local-shadow/cmd/main.go": { "captureGroups": 14, @@ -213,7 +213,7 @@ }, "go-make-builtin/main.go": { "captureGroups": 18, - "digest": "e4ab42c7af140f1322325c69f9f0cbc69ea48ed14714642981bb25895455c6c4" + "digest": "955658e93076422a5851fa64a74660b3f5e062c738fe4a99afb2490314e088d1" }, "go-make-builtin/models.go": { "captureGroups": 15, @@ -257,7 +257,7 @@ }, "go-mixed-chain/cmd/main.go": { "captureGroups": 22, - "digest": "1c927e49a4271c1bd088b504051a9f02f2f30ec13fdec14b37c85e1afb5bb721" + "digest": "0fdbc2fc96771670b1d96c73194701d6a8e6f78516e90f00598a5bc291e45a07" }, "go-mixed-chain/models/models.go": { "captureGroups": 41, @@ -345,7 +345,7 @@ }, "go-pointer-receiver-field-chain/handlers/handler.go": { "captureGroups": 116, - "digest": "5d691e53c06ee365863626aea70c968eabc8c6e71f1ae53e4f93be26a685538b" + "digest": "254658321a3f168f67e9f9d83887f45413daba82da279a3168f5880acd68aca3" }, "go-pointer-receiver-field-chain/repository/repo.go": { "captureGroups": 20, diff --git a/gitnexus/test/fixtures/index-lock-child.mjs b/gitnexus/test/fixtures/index-lock-child.mjs index 85c472bf9..f69fe2831 100644 --- a/gitnexus/test/fixtures/index-lock-child.mjs +++ b/gitnexus/test/fixtures/index-lock-child.mjs @@ -14,7 +14,7 @@ * exit 4 for other sentinel-create failures, with stderr diagnostics. * Used by the multi-reclaimer test where ≥2 children reclaim one dead holder. */ -import { writeFileSync, writeSync, openSync, closeSync, unlinkSync } from 'node:fs'; +import { writeFileSync, writeSync, openSync, closeSync, unlinkSync, renameSync } from 'node:fs'; import { pathToFileURL } from 'node:url'; // LOCK_MODULE is an absolute path. On Windows `import('C:\\…')` throws @@ -51,7 +51,10 @@ if (process.env.MODE === 'EXCLUSIVE') { process.exit(0); } else { const lock = await acquireIndexLock(process.env.LOCK_DIR, { timeoutMs: 30_000, pollMs: 25 }); - writeFileSync(process.env.MARKER, String(process.pid)); + // The parent treats existence as readiness, so publish only the complete PID. + const pendingMarker = `${process.env.MARKER}.${process.pid}.tmp`; + writeFileSync(pendingMarker, String(process.pid)); + renameSync(pendingMarker, process.env.MARKER); // Hold the lock until the parent kills us. setInterval(() => {}, 1000); // Release on a graceful signal (the SIGKILL path in the test never reaches this). diff --git a/gitnexus/test/fixtures/lang-resolution/ruby-accessor-dispatch/app.rb b/gitnexus/test/fixtures/lang-resolution/ruby-accessor-dispatch/app.rb new file mode 100644 index 000000000..b79d766c4 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ruby-accessor-dispatch/app.rb @@ -0,0 +1,25 @@ +require_relative 'model' + +def read_attributes + child = Child.new + child.name + child.inherited + child.slot + child.module_label + child.class_name + child.write_only + Child.class_name + Child.name +end + +def write_attributes + child = Child.new + child.name = "ok" + child.inherited = 1 +end + +def invalid_reader_arity + child = Child.new + child.name(1) + child.slot(1) +end diff --git a/gitnexus/test/fixtures/lang-resolution/ruby-accessor-dispatch/model.rb b/gitnexus/test/fixtures/lang-resolution/ruby-accessor-dispatch/model.rb new file mode 100644 index 000000000..4f42295c5 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ruby-accessor-dispatch/model.rb @@ -0,0 +1,32 @@ +module Labels + attr_reader :module_label +end + +class Base + attr_accessor :inherited + + def slot + 1 + end +end + +class Child < Base + include Labels + attr_accessor :name, :slot + attr_writer :write_only + + class << self + attr_reader :class_name + end + + def instance_access + self.name + self.inherited + self.class_name + end + + def self.class_access + self.class_name + self.name + end +end diff --git a/gitnexus/test/fixtures/pdg-explicit-owner/program.cbl b/gitnexus/test/fixtures/pdg-explicit-owner/program.cbl new file mode 100644 index 000000000..00c5c67df --- /dev/null +++ b/gitnexus/test/fixtures/pdg-explicit-owner/program.cbl @@ -0,0 +1,33 @@ +>>SOURCE FORMAT FREE +identification division. +program-id. Outer. +data division. +working-storage section. +01 OUTER-TOTAL PIC 9. +01 OUTER-FLAG PIC 9. +procedure division. +main. + move 0 to OUTER-TOTAL + if OUTER-FLAG = 1 + move 1 to OUTER-TOTAL + display OUTER-TOTAL + end-if + display OUTER-TOTAL + goback. +identification division. +program-id. Inner. +data division. +working-storage section. +01 INNER-TOTAL PIC 9. +01 INNER-FLAG PIC 9. +procedure division. +inner-main. + move 0 to INNER-TOTAL + if INNER-FLAG = 1 + move 2 to INNER-TOTAL + display INNER-TOTAL + end-if + display INNER-TOTAL + goback. +end program Inner. +end program Outer. diff --git a/gitnexus/test/fixtures/php-captures-golden/expected-captures.json b/gitnexus/test/fixtures/php-captures-golden/expected-captures.json index 240048273..861c315d2 100644 --- a/gitnexus/test/fixtures/php-captures-golden/expected-captures.json +++ b/gitnexus/test/fixtures/php-captures-golden/expected-captures.json @@ -528,8 +528,8 @@ "digest": "474a7bac44ce6026b3cb4489d8d3d8e359959c32f43a10b32e006a1869ff0995" }, "php-use-function-const/app/Config/constants.php": { - "captureGroups": 2, - "digest": "c045ce0a40c3396cdfbbf0fbbe24792fe2bba6dfba14f739b15a4bb5ae90a778" + "captureGroups": 3, + "digest": "f140ba6989fa70597842fb7f3115563a863c8542adcd4a5dac9ee79d6f0e3afc" }, "php-use-function-const/app/Models/User.php": { "captureGroups": 10, diff --git a/gitnexus/test/fixtures/pipeline-golden/mini-repo/expected-graph.json b/gitnexus/test/fixtures/pipeline-golden/mini-repo/expected-graph.json index 14fa41596..f11e04c99 100644 --- a/gitnexus/test/fixtures/pipeline-golden/mini-repo/expected-graph.json +++ b/gitnexus/test/fixtures/pipeline-golden/mini-repo/expected-graph.json @@ -3,7 +3,7 @@ "fixture": "mini-repo", "totalFileCount": 7, "symbols": 51, - "relationships": 95, + "relationships": 97, "processes": 4, "byType": { "Class": 1, @@ -26,7 +26,7 @@ "IMPORTS": 12, "MEMBER_OF": 12, "STEP_IN_PROCESS": 12, - "USES": 5 + "USES": 7 }, - "edgeDigest": "5dddd1f466deeda1197eb61b480a4f3a5da67dfc0d00ace22377158366e87d00" + "edgeDigest": "5281b11b8709007fc77f58fc5ee7713f58708b1e9c7d2f4bbe6159b3337d94b4" } diff --git a/gitnexus/test/fixtures/rename-integration/README.md b/gitnexus/test/fixtures/rename-integration/README.md new file mode 100644 index 000000000..14acab845 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/README.md @@ -0,0 +1,93 @@ +# Semantic rename integration fixtures + +These projects exercise real indexing and the public `rename` tool in both modes +(`dry_run: true` to preview, `dry_run: false` to apply). The integration test and its +subprocess helper do not mock the parser, semantic resolver, registry, database, +graph, filesystem, or rename functions. + +Run from `gitnexus/`: + +```sh +npm run build +npm test -- test/integration/rename-provider-matrix +``` + +Four test files partition the sorted cases evenly so CI can distribute real +indexing across its existing shards. Each case runs exactly once. + +Use `-t 'cpp/'` (for example) to select one provider. Rebuild after production +changes: the helper loads the built application, including real parse workers. +Do not rebuild while this suite is running. The analyzer verifies that its build +does not change during indexing. + +Each case contains a `case.json`, an independently authored `before/` project, +and the complete expected `after/` project. Unchanged files are included too. +Expected files are never generated by applying the implementation's edits. + +For each case, the harness: + +1. Copies the project into an isolated temporary repository and creates a real + index, registry entry, and database with PDG enabled. +2. Checks indexing completeness and selects the target uniquely from persisted + declarations. It asserts the specified call, access, or family edges. +3. Calls `rename` with `dry_run: true` and verifies that all source bytes are unchanged. +4. Calls `rename` with `dry_run: false`, checks the exact edit count and agreement + with the preview, and compares every source file against `after/`. +5. Reindexes successful edits and checks the renamed declaration and the same + graph relationships at their source positions. + +Refusal cases assert their specific error and planning status and verify that +both preview and apply preserve every source byte. They supplement successful +cases; the suite requires successful fixtures for every supported language. + +`target` and edge endpoints use `name`, `file`, optional `kind`, and optional +zero-based `startLine`. Add `startLine` when declarations share a name. An optional +`expectedTargetName` records an escaped graph spelling after rename, such as +`@Serialize`; the generic harness contains no language-specific name rules. + +An optional `exactOutgoing: true` requires every listed source/type pair to have +exactly the target set specified in `edges`, rejecting extra overload or wrong-owner +edges. This applies to cold, warm-cache, and post-rename graphs. + +An optional `warmParseCache: true` performs a second forced index before rename, +using captures cached by the cold index. It requires cache hits, zero reparsed +files and no parse workers, then verifies the same persisted target identity and +specified graph edges before public preview/apply. The C++ overload fixture uses +this path to preserve signature-specific call edges across cache serialization. + +| Provider | Language behavior exercised | +| ----------- | -------------------------------------------------------------------------------------------------- | +| C | Header/definition families, function pointers, call chains, deep pointer fields | +| C++ | Overloaded free functions across headers, virtual families, deep pointer fields, language keywords | +| C# | Interface/override families, public fields, escaped overloads, named arguments | +| COBOL | Cross-program calls, paragraph chains, nested qualified data | +| Dart | Interface/override and super calls, deep fields, imports, contextual identifiers | +| Go | Structural interfaces, exported pointer fields, package import aliases, generic type shadowing | +| Java | Interface/override families, overloads, deep public fields, reserved names | +| JavaScript | Inheritance, inferred deep fields, escaped exports, aliases and shorthand | +| Kotlin | Override families, typed callable references, escaped overloads, property writes | +| Objective-C | Protocol selectors, accessors, deep chains, ivars and same-named C symbols | +| PHP | Case-insensitive methods/functions, case-sensitive properties, grouped imports | +| Python | Override families, deep fields, aliases, Unicode names, dynamic attribute safety | +| Ruby | Mixins, predicate methods, chained setters, required qualified constants | +| Rust | Trait families, borrowed field receivers, cross-file import aliases | +| Swift | Protocol/override families, deep properties, escaped names and argument labels | +| TypeScript | Constructor-proven families/chains, aliases/shorthand, structural safety refusals | +| Vue | Script/template/CSS references, components, cross-file calls, opaque raw blocks | +| Zig | Typed container fields, import aliases and call chains | + +C, COBOL and Zig do not have class polymorphism matching the OO cases. Their +fixtures exercise their own callable, program, or container semantics. Vue +fixtures additionally cover its embedded and template name surfaces. + +TypeScript's structurally typed parameters do not prove a receiver's constructor: +unrelated objects can satisfy the same annotation. Open-parameter refusal cases +remain unchanged, alongside successful constructor-proven cases. These fixtures +are targeted regression coverage, not a claim of complete IDE refactoring parity. + +C++ permits `restrict` as an identifier ([C++ keyword table](https://eel.is/c++draft/lex.key), +[GCC's explicit clarification](https://gcc.gnu.org/onlinedocs/gcc/Restricted-Pointers.html)). +The successful type fixture exercises this spelling. The vendored tree-sitter +grammar currently parses an unparenthesized `int restrict(...)` declarator as a +C-style type qualifier, so the separate function fixture expects `parse_error` +and unchanged sources. The parser limitation remains fail-closed. diff --git a/gitnexus/test/fixtures/rename-integration/c/ffi-unrelated/after/Other.swift b/gitnexus/test/fixtures/rename-integration/c/ffi-unrelated/after/Other.swift new file mode 100644 index 000000000..7b2faa050 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/c/ffi-unrelated/after/Other.swift @@ -0,0 +1,4 @@ +// compute calculate remain comments. +func note() -> String { + return "compute calculate" +} diff --git a/gitnexus/test/fixtures/rename-integration/c/ffi-unrelated/after/api.c b/gitnexus/test/fixtures/rename-integration/c/ffi-unrelated/after/api.c new file mode 100644 index 000000000..6819a4555 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/c/ffi-unrelated/after/api.c @@ -0,0 +1,3 @@ +#include "api.h" + +int calculate(int value) { return value; } diff --git a/gitnexus/test/fixtures/rename-integration/c/ffi-unrelated/after/api.h b/gitnexus/test/fixtures/rename-integration/c/ffi-unrelated/after/api.h new file mode 100644 index 000000000..fe5849859 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/c/ffi-unrelated/after/api.h @@ -0,0 +1 @@ +int calculate(int value); diff --git a/gitnexus/test/fixtures/rename-integration/c/ffi-unrelated/after/other.go b/gitnexus/test/fixtures/rename-integration/c/ffi-unrelated/after/other.go new file mode 100644 index 000000000..6a221b5d9 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/c/ffi-unrelated/after/other.go @@ -0,0 +1,6 @@ +package main + +// compute calculate remain comments. +func note() string { + return "compute calculate" +} diff --git a/gitnexus/test/fixtures/rename-integration/c/ffi-unrelated/after/other.rs b/gitnexus/test/fixtures/rename-integration/c/ffi-unrelated/after/other.rs new file mode 100644 index 000000000..61194fef5 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/c/ffi-unrelated/after/other.rs @@ -0,0 +1,4 @@ +// compute calculate remain comments. +pub fn note() -> &'static str { + "compute calculate" +} diff --git a/gitnexus/test/fixtures/rename-integration/c/ffi-unrelated/after/other.zig b/gitnexus/test/fixtures/rename-integration/c/ffi-unrelated/after/other.zig new file mode 100644 index 000000000..da830f561 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/c/ffi-unrelated/after/other.zig @@ -0,0 +1,4 @@ +// compute calculate remain comments. +pub fn note() []const u8 { + return "compute calculate"; +} diff --git a/gitnexus/test/fixtures/rename-integration/c/ffi-unrelated/before/Other.swift b/gitnexus/test/fixtures/rename-integration/c/ffi-unrelated/before/Other.swift new file mode 100644 index 000000000..7b2faa050 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/c/ffi-unrelated/before/Other.swift @@ -0,0 +1,4 @@ +// compute calculate remain comments. +func note() -> String { + return "compute calculate" +} diff --git a/gitnexus/test/fixtures/rename-integration/c/ffi-unrelated/before/api.c b/gitnexus/test/fixtures/rename-integration/c/ffi-unrelated/before/api.c new file mode 100644 index 000000000..61ab81bae --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/c/ffi-unrelated/before/api.c @@ -0,0 +1,3 @@ +#include "api.h" + +int compute(int value) { return value; } diff --git a/gitnexus/test/fixtures/rename-integration/c/ffi-unrelated/before/api.h b/gitnexus/test/fixtures/rename-integration/c/ffi-unrelated/before/api.h new file mode 100644 index 000000000..12e307c83 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/c/ffi-unrelated/before/api.h @@ -0,0 +1 @@ +int compute(int value); diff --git a/gitnexus/test/fixtures/rename-integration/c/ffi-unrelated/before/other.go b/gitnexus/test/fixtures/rename-integration/c/ffi-unrelated/before/other.go new file mode 100644 index 000000000..6a221b5d9 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/c/ffi-unrelated/before/other.go @@ -0,0 +1,6 @@ +package main + +// compute calculate remain comments. +func note() string { + return "compute calculate" +} diff --git a/gitnexus/test/fixtures/rename-integration/c/ffi-unrelated/before/other.rs b/gitnexus/test/fixtures/rename-integration/c/ffi-unrelated/before/other.rs new file mode 100644 index 000000000..61194fef5 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/c/ffi-unrelated/before/other.rs @@ -0,0 +1,4 @@ +// compute calculate remain comments. +pub fn note() -> &'static str { + "compute calculate" +} diff --git a/gitnexus/test/fixtures/rename-integration/c/ffi-unrelated/before/other.zig b/gitnexus/test/fixtures/rename-integration/c/ffi-unrelated/before/other.zig new file mode 100644 index 000000000..da830f561 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/c/ffi-unrelated/before/other.zig @@ -0,0 +1,4 @@ +// compute calculate remain comments. +pub fn note() []const u8 { + return "compute calculate"; +} diff --git a/gitnexus/test/fixtures/rename-integration/c/ffi-unrelated/case.json b/gitnexus/test/fixtures/rename-integration/c/ffi-unrelated/case.json new file mode 100644 index 000000000..77b77335a --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/c/ffi-unrelated/case.json @@ -0,0 +1,19 @@ +{ + "language": "c", + "description": "Rust, Go, Zig and Swift comments, strings and unrelated names do not block a proven C rename.", + "categories": [ + "cross-language", + "interop", + "comments-and-strings" + ], + "target": { + "name": "compute", + "kind": "Function", + "file": "api.c" + }, + "newName": "calculate", + "expect": { + "status": "success", + "edits": 2 + } +} diff --git a/gitnexus/test/fixtures/rename-integration/c/function-pointer-call-chain/after/callback.c b/gitnexus/test/fixtures/rename-integration/c/function-pointer-call-chain/after/callback.c new file mode 100644 index 000000000..2b60eb445 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/c/function-pointer-call-chain/after/callback.c @@ -0,0 +1,9 @@ +int calculate(int value) { return value + 1; } +int through_pointer(int value) { + int (*operation)(int) = calculate; + return operation(value); +} +int direct(int value) { return calculate(value); } +int outer(int value) { return direct(value) + through_pointer(value); } +const char *label = "compute"; +// compute is also a first-class function pointer value. diff --git a/gitnexus/test/fixtures/rename-integration/c/function-pointer-call-chain/before/callback.c b/gitnexus/test/fixtures/rename-integration/c/function-pointer-call-chain/before/callback.c new file mode 100644 index 000000000..d3e016391 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/c/function-pointer-call-chain/before/callback.c @@ -0,0 +1,9 @@ +int compute(int value) { return value + 1; } +int through_pointer(int value) { + int (*operation)(int) = compute; + return operation(value); +} +int direct(int value) { return compute(value); } +int outer(int value) { return direct(value) + through_pointer(value); } +const char *label = "compute"; +// compute is also a first-class function pointer value. diff --git a/gitnexus/test/fixtures/rename-integration/c/function-pointer-call-chain/case.json b/gitnexus/test/fixtures/rename-integration/c/function-pointer-call-chain/case.json new file mode 100644 index 000000000..21e59e187 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/c/function-pointer-call-chain/case.json @@ -0,0 +1,43 @@ +{ + "language": "c", + "description": "Rename direct calls and a function-pointer initializer while retaining the callback variable; exercise a call graph without inventing class polymorphism in C.", + "categories": ["call-graph", "callable-value", "function-pointer"], + "target": { + "name": "compute", + "kind": "Function", + "file": "callback.c" + }, + "newName": "calculate", + "expect": { + "status": "success", + "edits": 3 + }, + "edges": [ + { + "type": "CALLS", + "from": { + "name": "outer", + "kind": "Function", + "file": "callback.c" + }, + "to": { + "name": "direct", + "kind": "Function", + "file": "callback.c" + } + }, + { + "type": "CALLS", + "from": { + "name": "through_pointer", + "kind": "Function", + "file": "callback.c" + }, + "to": { + "name": "compute", + "kind": "Function", + "file": "callback.c" + } + } + ] +} diff --git a/gitnexus/test/fixtures/rename-integration/c/go-cgo-struct-tag-unrelated/after/api.c b/gitnexus/test/fixtures/rename-integration/c/go-cgo-struct-tag-unrelated/after/api.c new file mode 100644 index 000000000..d01055ba7 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/c/go-cgo-struct-tag-unrelated/after/api.c @@ -0,0 +1,3 @@ +struct vector { int x; }; + +int norm(struct vector *value) { return value->x; } diff --git a/gitnexus/test/fixtures/rename-integration/c/go-cgo-struct-tag-unrelated/after/main.go b/gitnexus/test/fixtures/rename-integration/c/go-cgo-struct-tag-unrelated/after/main.go new file mode 100644 index 000000000..d4ce9d93d --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/c/go-cgo-struct-tag-unrelated/after/main.go @@ -0,0 +1,10 @@ +package main + +import "C" + +// point and vector remain comments. +func other() C.struct_other { + var value C.struct_other + _ = "point vector" + return value +} diff --git a/gitnexus/test/fixtures/rename-integration/c/go-cgo-struct-tag-unrelated/before/api.c b/gitnexus/test/fixtures/rename-integration/c/go-cgo-struct-tag-unrelated/before/api.c new file mode 100644 index 000000000..348aa2eac --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/c/go-cgo-struct-tag-unrelated/before/api.c @@ -0,0 +1,3 @@ +struct point { int x; }; + +int norm(struct point *value) { return value->x; } diff --git a/gitnexus/test/fixtures/rename-integration/c/go-cgo-struct-tag-unrelated/before/main.go b/gitnexus/test/fixtures/rename-integration/c/go-cgo-struct-tag-unrelated/before/main.go new file mode 100644 index 000000000..d4ce9d93d --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/c/go-cgo-struct-tag-unrelated/before/main.go @@ -0,0 +1,10 @@ +package main + +import "C" + +// point and vector remain comments. +func other() C.struct_other { + var value C.struct_other + _ = "point vector" + return value +} diff --git a/gitnexus/test/fixtures/rename-integration/c/go-cgo-struct-tag-unrelated/case.json b/gitnexus/test/fixtures/rename-integration/c/go-cgo-struct-tag-unrelated/case.json new file mode 100644 index 000000000..b570b9924 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/c/go-cgo-struct-tag-unrelated/case.json @@ -0,0 +1,19 @@ +{ + "language": "c", + "description": "A Go source that names only a different cgo tag, and mentions the selected tag in comments and strings, does not block a proven C struct rename.", + "categories": [ + "cross-language", + "interop", + "comments-and-strings" + ], + "target": { + "name": "point", + "kind": "Struct", + "file": "api.c" + }, + "newName": "vector", + "expect": { + "status": "success", + "edits": 2 + } +} diff --git a/gitnexus/test/fixtures/rename-integration/c/go-cgo-struct-tag/after/api.c b/gitnexus/test/fixtures/rename-integration/c/go-cgo-struct-tag/after/api.c new file mode 100644 index 000000000..348aa2eac --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/c/go-cgo-struct-tag/after/api.c @@ -0,0 +1,3 @@ +struct point { int x; }; + +int norm(struct point *value) { return value->x; } diff --git a/gitnexus/test/fixtures/rename-integration/c/go-cgo-struct-tag/after/main.go b/gitnexus/test/fixtures/rename-integration/c/go-cgo-struct-tag/after/main.go new file mode 100644 index 000000000..17f20b72a --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/c/go-cgo-struct-tag/after/main.go @@ -0,0 +1,8 @@ +package main + +import "C" + +func origin() C.struct_point { + var value C.struct_point + return value +} diff --git a/gitnexus/test/fixtures/rename-integration/c/go-cgo-struct-tag/before/api.c b/gitnexus/test/fixtures/rename-integration/c/go-cgo-struct-tag/before/api.c new file mode 100644 index 000000000..348aa2eac --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/c/go-cgo-struct-tag/before/api.c @@ -0,0 +1,3 @@ +struct point { int x; }; + +int norm(struct point *value) { return value->x; } diff --git a/gitnexus/test/fixtures/rename-integration/c/go-cgo-struct-tag/before/main.go b/gitnexus/test/fixtures/rename-integration/c/go-cgo-struct-tag/before/main.go new file mode 100644 index 000000000..17f20b72a --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/c/go-cgo-struct-tag/before/main.go @@ -0,0 +1,8 @@ +package main + +import "C" + +func origin() C.struct_point { + var value C.struct_point + return value +} diff --git a/gitnexus/test/fixtures/rename-integration/c/go-cgo-struct-tag/case.json b/gitnexus/test/fixtures/rename-integration/c/go-cgo-struct-tag/case.json new file mode 100644 index 000000000..5978ff2a7 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/c/go-cgo-struct-tag/case.json @@ -0,0 +1,21 @@ +{ + "language": "c", + "description": "Refuse a C struct tag that an indexed Go cgo caller names as C.struct_point, until the Go binding is proven.", + "categories": [ + "cross-language", + "interop", + "negative", + "unchanged-on-error" + ], + "target": { + "name": "point", + "kind": "Struct", + "file": "api.c" + }, + "newName": "vector", + "expect": { + "status": "error", + "code": "unsupported_boundary", + "planningStatus": "unsupported" + } +} diff --git a/gitnexus/test/fixtures/rename-integration/c/go-cgo/after/api.c b/gitnexus/test/fixtures/rename-integration/c/go-cgo/after/api.c new file mode 100644 index 000000000..61ab81bae --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/c/go-cgo/after/api.c @@ -0,0 +1,3 @@ +#include "api.h" + +int compute(int value) { return value; } diff --git a/gitnexus/test/fixtures/rename-integration/c/go-cgo/after/api.h b/gitnexus/test/fixtures/rename-integration/c/go-cgo/after/api.h new file mode 100644 index 000000000..12e307c83 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/c/go-cgo/after/api.h @@ -0,0 +1 @@ +int compute(int value); diff --git a/gitnexus/test/fixtures/rename-integration/c/go-cgo/after/main.go b/gitnexus/test/fixtures/rename-integration/c/go-cgo/after/main.go new file mode 100644 index 000000000..b7629b3ed --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/c/go-cgo/after/main.go @@ -0,0 +1,10 @@ +package main + +/* +#include "api.h" +*/ +import "C" + +func call() int { + return int(C.compute(1)) +} diff --git a/gitnexus/test/fixtures/rename-integration/c/go-cgo/before/api.c b/gitnexus/test/fixtures/rename-integration/c/go-cgo/before/api.c new file mode 100644 index 000000000..61ab81bae --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/c/go-cgo/before/api.c @@ -0,0 +1,3 @@ +#include "api.h" + +int compute(int value) { return value; } diff --git a/gitnexus/test/fixtures/rename-integration/c/go-cgo/before/api.h b/gitnexus/test/fixtures/rename-integration/c/go-cgo/before/api.h new file mode 100644 index 000000000..12e307c83 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/c/go-cgo/before/api.h @@ -0,0 +1 @@ +int compute(int value); diff --git a/gitnexus/test/fixtures/rename-integration/c/go-cgo/before/main.go b/gitnexus/test/fixtures/rename-integration/c/go-cgo/before/main.go new file mode 100644 index 000000000..b7629b3ed --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/c/go-cgo/before/main.go @@ -0,0 +1,10 @@ +package main + +/* +#include "api.h" +*/ +import "C" + +func call() int { + return int(C.compute(1)) +} diff --git a/gitnexus/test/fixtures/rename-integration/c/go-cgo/case.json b/gitnexus/test/fixtures/rename-integration/c/go-cgo/case.json new file mode 100644 index 000000000..9add5083c --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/c/go-cgo/case.json @@ -0,0 +1,21 @@ +{ + "language": "c", + "description": "Refuse a C function that an indexed Go cgo caller names, until the Go binding is proven.", + "categories": [ + "cross-language", + "interop", + "negative", + "unchanged-on-error" + ], + "target": { + "name": "compute", + "kind": "Function", + "file": "api.c" + }, + "newName": "calculate", + "expect": { + "status": "error", + "code": "unsupported_boundary", + "planningStatus": "unsupported" + } +} diff --git a/gitnexus/test/fixtures/rename-integration/c/interop-cpp-call/after/api.c b/gitnexus/test/fixtures/rename-integration/c/interop-cpp-call/after/api.c new file mode 100644 index 000000000..7123d281c --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/c/interop-cpp-call/after/api.c @@ -0,0 +1 @@ +int compute(void) { return 1; } diff --git a/gitnexus/test/fixtures/rename-integration/c/interop-cpp-call/after/caller.cpp b/gitnexus/test/fixtures/rename-integration/c/interop-cpp-call/after/caller.cpp new file mode 100644 index 000000000..3420dbb12 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/c/interop-cpp-call/after/caller.cpp @@ -0,0 +1 @@ +int consume() { return compute(); } diff --git a/gitnexus/test/fixtures/rename-integration/c/interop-cpp-call/before/api.c b/gitnexus/test/fixtures/rename-integration/c/interop-cpp-call/before/api.c new file mode 100644 index 000000000..7123d281c --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/c/interop-cpp-call/before/api.c @@ -0,0 +1 @@ +int compute(void) { return 1; } diff --git a/gitnexus/test/fixtures/rename-integration/c/interop-cpp-call/before/caller.cpp b/gitnexus/test/fixtures/rename-integration/c/interop-cpp-call/before/caller.cpp new file mode 100644 index 000000000..3420dbb12 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/c/interop-cpp-call/before/caller.cpp @@ -0,0 +1 @@ +int consume() { return compute(); } diff --git a/gitnexus/test/fixtures/rename-integration/c/interop-cpp-call/case.json b/gitnexus/test/fixtures/rename-integration/c/interop-cpp-call/case.json new file mode 100644 index 000000000..bb767419a --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/c/interop-cpp-call/case.json @@ -0,0 +1,20 @@ +{ + "language": "c", + "description": "Refuse an indexed interoperating source name until cross-provider binding coverage is proven.", + "categories": [ + "cross-language", + "interop", + "comments-and-strings" + ], + "target": { + "name": "compute", + "kind": "Function", + "file": "api.c" + }, + "newName": "calculate", + "expect": { + "status": "error", + "code": "unsupported_boundary", + "planningStatus": "unsupported" + } +} diff --git a/gitnexus/test/fixtures/rename-integration/c/interop-objective-c-call/after/api.c b/gitnexus/test/fixtures/rename-integration/c/interop-objective-c-call/after/api.c new file mode 100644 index 000000000..7123d281c --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/c/interop-objective-c-call/after/api.c @@ -0,0 +1 @@ +int compute(void) { return 1; } diff --git a/gitnexus/test/fixtures/rename-integration/c/interop-objective-c-call/after/caller.m b/gitnexus/test/fixtures/rename-integration/c/interop-objective-c-call/after/caller.m new file mode 100644 index 000000000..e380c6dae --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/c/interop-objective-c-call/after/caller.m @@ -0,0 +1 @@ +int consume(void) { return compute(); } diff --git a/gitnexus/test/fixtures/rename-integration/c/interop-objective-c-call/before/api.c b/gitnexus/test/fixtures/rename-integration/c/interop-objective-c-call/before/api.c new file mode 100644 index 000000000..7123d281c --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/c/interop-objective-c-call/before/api.c @@ -0,0 +1 @@ +int compute(void) { return 1; } diff --git a/gitnexus/test/fixtures/rename-integration/c/interop-objective-c-call/before/caller.m b/gitnexus/test/fixtures/rename-integration/c/interop-objective-c-call/before/caller.m new file mode 100644 index 000000000..e380c6dae --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/c/interop-objective-c-call/before/caller.m @@ -0,0 +1 @@ +int consume(void) { return compute(); } diff --git a/gitnexus/test/fixtures/rename-integration/c/interop-objective-c-call/case.json b/gitnexus/test/fixtures/rename-integration/c/interop-objective-c-call/case.json new file mode 100644 index 000000000..bb767419a --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/c/interop-objective-c-call/case.json @@ -0,0 +1,20 @@ +{ + "language": "c", + "description": "Refuse an indexed interoperating source name until cross-provider binding coverage is proven.", + "categories": [ + "cross-language", + "interop", + "comments-and-strings" + ], + "target": { + "name": "compute", + "kind": "Function", + "file": "api.c" + }, + "newName": "calculate", + "expect": { + "status": "error", + "code": "unsupported_boundary", + "planningStatus": "unsupported" + } +} diff --git a/gitnexus/test/fixtures/rename-integration/c/interop-objective-c-macro/after/api.c b/gitnexus/test/fixtures/rename-integration/c/interop-objective-c-macro/after/api.c new file mode 100644 index 000000000..7123d281c --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/c/interop-objective-c-macro/after/api.c @@ -0,0 +1 @@ +int compute(void) { return 1; } diff --git a/gitnexus/test/fixtures/rename-integration/c/interop-objective-c-macro/after/caller.m b/gitnexus/test/fixtures/rename-integration/c/interop-objective-c-macro/after/caller.m new file mode 100644 index 000000000..1dba4237e --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/c/interop-objective-c-macro/after/caller.m @@ -0,0 +1,2 @@ +#define CALL() compute() +int consume(void) { return CALL(); } diff --git a/gitnexus/test/fixtures/rename-integration/c/interop-objective-c-macro/before/api.c b/gitnexus/test/fixtures/rename-integration/c/interop-objective-c-macro/before/api.c new file mode 100644 index 000000000..7123d281c --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/c/interop-objective-c-macro/before/api.c @@ -0,0 +1 @@ +int compute(void) { return 1; } diff --git a/gitnexus/test/fixtures/rename-integration/c/interop-objective-c-macro/before/caller.m b/gitnexus/test/fixtures/rename-integration/c/interop-objective-c-macro/before/caller.m new file mode 100644 index 000000000..1dba4237e --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/c/interop-objective-c-macro/before/caller.m @@ -0,0 +1,2 @@ +#define CALL() compute() +int consume(void) { return CALL(); } diff --git a/gitnexus/test/fixtures/rename-integration/c/interop-objective-c-macro/case.json b/gitnexus/test/fixtures/rename-integration/c/interop-objective-c-macro/case.json new file mode 100644 index 000000000..04113843d --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/c/interop-objective-c-macro/case.json @@ -0,0 +1,20 @@ +{ + "language": "c", + "description": "Refuse an indexed Objective-C macro replacement containing the C target name, without treating token text as edit proof.", + "categories": [ + "cross-language", + "interop", + "macro-boundary" + ], + "target": { + "name": "compute", + "kind": "Function", + "file": "api.c" + }, + "newName": "calculate", + "expect": { + "status": "error", + "code": "unsupported_boundary", + "planningStatus": "unsupported" + } +} diff --git a/gitnexus/test/fixtures/rename-integration/c/interop-objective-c-unrelated/after/api.c b/gitnexus/test/fixtures/rename-integration/c/interop-objective-c-unrelated/after/api.c new file mode 100644 index 000000000..51cde9c4c --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/c/interop-objective-c-unrelated/after/api.c @@ -0,0 +1,2 @@ +int calculate(void) { return 1; } +int use(void) { return calculate(); } diff --git a/gitnexus/test/fixtures/rename-integration/c/interop-objective-c-unrelated/after/other.m b/gitnexus/test/fixtures/rename-integration/c/interop-objective-c-unrelated/after/other.m new file mode 100644 index 000000000..26813bd41 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/c/interop-objective-c-unrelated/after/other.m @@ -0,0 +1,3 @@ +#define NOTE "compute calculate" +#define VALUE 1 /* compute calculate */ +const char *note(void) { return "compute calculate"; } // compute calculate diff --git a/gitnexus/test/fixtures/rename-integration/c/interop-objective-c-unrelated/before/api.c b/gitnexus/test/fixtures/rename-integration/c/interop-objective-c-unrelated/before/api.c new file mode 100644 index 000000000..044c5c04d --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/c/interop-objective-c-unrelated/before/api.c @@ -0,0 +1,2 @@ +int compute(void) { return 1; } +int use(void) { return compute(); } diff --git a/gitnexus/test/fixtures/rename-integration/c/interop-objective-c-unrelated/before/other.m b/gitnexus/test/fixtures/rename-integration/c/interop-objective-c-unrelated/before/other.m new file mode 100644 index 000000000..26813bd41 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/c/interop-objective-c-unrelated/before/other.m @@ -0,0 +1,3 @@ +#define NOTE "compute calculate" +#define VALUE 1 /* compute calculate */ +const char *note(void) { return "compute calculate"; } // compute calculate diff --git a/gitnexus/test/fixtures/rename-integration/c/interop-objective-c-unrelated/case.json b/gitnexus/test/fixtures/rename-integration/c/interop-objective-c-unrelated/case.json new file mode 100644 index 000000000..ff52d0e10 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/c/interop-objective-c-unrelated/case.json @@ -0,0 +1,35 @@ +{ + "language": "c", + "description": "Unrelated interoperating source names, comments and strings do not block a proven rename.", + "categories": [ + "cross-language", + "interop", + "comments-and-strings" + ], + "target": { + "name": "compute", + "kind": "Function", + "file": "api.c" + }, + "newName": "calculate", + "expect": { + "status": "success", + "edits": 2 + }, + "edges": [ + { + "type": "CALLS", + "from": { + "name": "use", + "kind": "Function", + "file": "api.c" + }, + "to": { + "name": "compute", + "kind": "Function", + "file": "api.c" + } + } + ], + "exactOutgoing": true +} diff --git a/gitnexus/test/fixtures/rename-integration/c/prototype-call-chain/after/api.c b/gitnexus/test/fixtures/rename-integration/c/prototype-call-chain/after/api.c new file mode 100644 index 000000000..90ec41fb3 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/c/prototype-call-chain/after/api.c @@ -0,0 +1,2 @@ +#include "api.h" +int calculate(int value, float weight) { return value + weight; } diff --git a/gitnexus/test/fixtures/rename-integration/c/prototype-call-chain/after/api.h b/gitnexus/test/fixtures/rename-integration/c/prototype-call-chain/after/api.h new file mode 100644 index 000000000..5c5fcc0e9 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/c/prototype-call-chain/after/api.h @@ -0,0 +1 @@ +int calculate(int value, float weight); diff --git a/gitnexus/test/fixtures/rename-integration/c/prototype-call-chain/after/main.c b/gitnexus/test/fixtures/rename-integration/c/prototype-call-chain/after/main.c new file mode 100644 index 000000000..40e4a38da --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/c/prototype-call-chain/after/main.c @@ -0,0 +1,6 @@ +#include "api.h" +int middle(int value) { return calculate(value, 1.0f); } +int outer(int value) { return middle(value); } +int shadow(int compute) { return compute; } +const char *label = "compute"; +// compute must remain in this comment. diff --git a/gitnexus/test/fixtures/rename-integration/c/prototype-call-chain/before/api.c b/gitnexus/test/fixtures/rename-integration/c/prototype-call-chain/before/api.c new file mode 100644 index 000000000..10251cf89 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/c/prototype-call-chain/before/api.c @@ -0,0 +1,2 @@ +#include "api.h" +int compute(int value, float weight) { return value + weight; } diff --git a/gitnexus/test/fixtures/rename-integration/c/prototype-call-chain/before/api.h b/gitnexus/test/fixtures/rename-integration/c/prototype-call-chain/before/api.h new file mode 100644 index 000000000..8839c7dcb --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/c/prototype-call-chain/before/api.h @@ -0,0 +1 @@ +int compute(int value, float weight); diff --git a/gitnexus/test/fixtures/rename-integration/c/prototype-call-chain/before/main.c b/gitnexus/test/fixtures/rename-integration/c/prototype-call-chain/before/main.c new file mode 100644 index 000000000..fd6a63301 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/c/prototype-call-chain/before/main.c @@ -0,0 +1,6 @@ +#include "api.h" +int middle(int value) { return compute(value, 1.0f); } +int outer(int value) { return middle(value); } +int shadow(int compute) { return compute; } +const char *label = "compute"; +// compute must remain in this comment. diff --git a/gitnexus/test/fixtures/rename-integration/c/prototype-call-chain/case.json b/gitnexus/test/fixtures/rename-integration/c/prototype-call-chain/case.json new file mode 100644 index 000000000..483dc2410 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/c/prototype-call-chain/case.json @@ -0,0 +1,43 @@ +{ + "language": "c", + "description": "Rename a header prototype and external definition through a cross-file call chain; keep a shadowing parameter and literal text. C has no native class polymorphism.", + "categories": ["cross-file", "call-graph", "shadowing", "declaration-family"], + "target": { + "name": "compute", + "kind": "Function", + "file": "api.c" + }, + "newName": "calculate", + "expect": { + "status": "success", + "edits": 3 + }, + "edges": [ + { + "type": "CALLS", + "from": { + "name": "outer", + "kind": "Function", + "file": "main.c" + }, + "to": { + "name": "middle", + "kind": "Function", + "file": "main.c" + } + }, + { + "type": "CALLS", + "from": { + "name": "middle", + "kind": "Function", + "file": "main.c" + }, + "to": { + "name": "compute", + "kind": "Function", + "file": "api.c" + } + } + ] +} diff --git a/gitnexus/test/fixtures/rename-integration/c/public-field-pointer-chain/after/model.c b/gitnexus/test/fixtures/rename-integration/c/public-field-pointer-chain/after/model.c new file mode 100644 index 000000000..91a28f1a3 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/c/public-field-pointer-chain/after/model.c @@ -0,0 +1,12 @@ +struct Leaf { int amount; }; +struct Branch { struct Leaf *leaf; }; +struct Root { struct Branch *branch; }; +struct Other { int value; }; +int read(struct Root *root, struct Other *other) { + return root->branch->leaf->amount + other->value; +} +void write(struct Root *root, int value) { + root->branch->leaf->amount = value; +} +const char *label = "value"; +// value belongs to Leaf, Other and a parameter, independently. diff --git a/gitnexus/test/fixtures/rename-integration/c/public-field-pointer-chain/before/model.c b/gitnexus/test/fixtures/rename-integration/c/public-field-pointer-chain/before/model.c new file mode 100644 index 000000000..1ef01f1c4 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/c/public-field-pointer-chain/before/model.c @@ -0,0 +1,12 @@ +struct Leaf { int value; }; +struct Branch { struct Leaf *leaf; }; +struct Root { struct Branch *branch; }; +struct Other { int value; }; +int read(struct Root *root, struct Other *other) { + return root->branch->leaf->value + other->value; +} +void write(struct Root *root, int value) { + root->branch->leaf->value = value; +} +const char *label = "value"; +// value belongs to Leaf, Other and a parameter, independently. diff --git a/gitnexus/test/fixtures/rename-integration/c/public-field-pointer-chain/case.json b/gitnexus/test/fixtures/rename-integration/c/public-field-pointer-chain/case.json new file mode 100644 index 000000000..a534dc93c --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/c/public-field-pointer-chain/case.json @@ -0,0 +1,48 @@ +{ + "language": "c", + "description": "Follow a three-hop typed pointer receiver to a public struct field on reads and writes, preserving an unrelated owner and parameter.", + "categories": ["public-field", "deep-chain", "pointer-receiver", "homonyms", "writes"], + "target": { + "name": "value", + "kind": "Property", + "file": "model.c", + "startLine": 0 + }, + "newName": "amount", + "expect": { + "status": "success", + "edits": 3 + }, + "edges": [ + { + "type": "ACCESSES", + "from": { + "name": "read", + "kind": "Function", + "file": "model.c", + "startLine": 4 + }, + "to": { + "name": "value", + "kind": "Property", + "file": "model.c", + "startLine": 0 + } + }, + { + "type": "ACCESSES", + "from": { + "name": "write", + "kind": "Function", + "file": "model.c", + "startLine": 7 + }, + "to": { + "name": "value", + "kind": "Property", + "file": "model.c", + "startLine": 0 + } + } + ] +} diff --git a/gitnexus/test/fixtures/rename-integration/c/rust-ffi/after/api.c b/gitnexus/test/fixtures/rename-integration/c/rust-ffi/after/api.c new file mode 100644 index 000000000..61ab81bae --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/c/rust-ffi/after/api.c @@ -0,0 +1,3 @@ +#include "api.h" + +int compute(int value) { return value; } diff --git a/gitnexus/test/fixtures/rename-integration/c/rust-ffi/after/api.h b/gitnexus/test/fixtures/rename-integration/c/rust-ffi/after/api.h new file mode 100644 index 000000000..12e307c83 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/c/rust-ffi/after/api.h @@ -0,0 +1 @@ +int compute(int value); diff --git a/gitnexus/test/fixtures/rename-integration/c/rust-ffi/after/ffi.rs b/gitnexus/test/fixtures/rename-integration/c/rust-ffi/after/ffi.rs new file mode 100644 index 000000000..dd1407db6 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/c/rust-ffi/after/ffi.rs @@ -0,0 +1,7 @@ +extern "C" { + fn compute(value: i32) -> i32; +} + +pub fn call() -> i32 { + unsafe { compute(1) } +} diff --git a/gitnexus/test/fixtures/rename-integration/c/rust-ffi/before/api.c b/gitnexus/test/fixtures/rename-integration/c/rust-ffi/before/api.c new file mode 100644 index 000000000..61ab81bae --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/c/rust-ffi/before/api.c @@ -0,0 +1,3 @@ +#include "api.h" + +int compute(int value) { return value; } diff --git a/gitnexus/test/fixtures/rename-integration/c/rust-ffi/before/api.h b/gitnexus/test/fixtures/rename-integration/c/rust-ffi/before/api.h new file mode 100644 index 000000000..12e307c83 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/c/rust-ffi/before/api.h @@ -0,0 +1 @@ +int compute(int value); diff --git a/gitnexus/test/fixtures/rename-integration/c/rust-ffi/before/ffi.rs b/gitnexus/test/fixtures/rename-integration/c/rust-ffi/before/ffi.rs new file mode 100644 index 000000000..dd1407db6 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/c/rust-ffi/before/ffi.rs @@ -0,0 +1,7 @@ +extern "C" { + fn compute(value: i32) -> i32; +} + +pub fn call() -> i32 { + unsafe { compute(1) } +} diff --git a/gitnexus/test/fixtures/rename-integration/c/rust-ffi/case.json b/gitnexus/test/fixtures/rename-integration/c/rust-ffi/case.json new file mode 100644 index 000000000..0184684fa --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/c/rust-ffi/case.json @@ -0,0 +1,21 @@ +{ + "language": "c", + "description": "Refuse a C function that an indexed Rust extern block declares and calls, until the Rust binding is proven.", + "categories": [ + "cross-language", + "interop", + "negative", + "unchanged-on-error" + ], + "target": { + "name": "compute", + "kind": "Function", + "file": "api.c" + }, + "newName": "calculate", + "expect": { + "status": "error", + "code": "unsupported_boundary", + "planningStatus": "unsupported" + } +} diff --git a/gitnexus/test/fixtures/rename-integration/c/swift-consumer/after/App.swift b/gitnexus/test/fixtures/rename-integration/c/swift-consumer/after/App.swift new file mode 100644 index 000000000..9f4d25122 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/c/swift-consumer/after/App.swift @@ -0,0 +1,3 @@ +func call() -> Int32 { + return compute(1) +} diff --git a/gitnexus/test/fixtures/rename-integration/c/swift-consumer/after/api.c b/gitnexus/test/fixtures/rename-integration/c/swift-consumer/after/api.c new file mode 100644 index 000000000..61ab81bae --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/c/swift-consumer/after/api.c @@ -0,0 +1,3 @@ +#include "api.h" + +int compute(int value) { return value; } diff --git a/gitnexus/test/fixtures/rename-integration/c/swift-consumer/after/api.h b/gitnexus/test/fixtures/rename-integration/c/swift-consumer/after/api.h new file mode 100644 index 000000000..12e307c83 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/c/swift-consumer/after/api.h @@ -0,0 +1 @@ +int compute(int value); diff --git a/gitnexus/test/fixtures/rename-integration/c/swift-consumer/before/App.swift b/gitnexus/test/fixtures/rename-integration/c/swift-consumer/before/App.swift new file mode 100644 index 000000000..9f4d25122 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/c/swift-consumer/before/App.swift @@ -0,0 +1,3 @@ +func call() -> Int32 { + return compute(1) +} diff --git a/gitnexus/test/fixtures/rename-integration/c/swift-consumer/before/api.c b/gitnexus/test/fixtures/rename-integration/c/swift-consumer/before/api.c new file mode 100644 index 000000000..61ab81bae --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/c/swift-consumer/before/api.c @@ -0,0 +1,3 @@ +#include "api.h" + +int compute(int value) { return value; } diff --git a/gitnexus/test/fixtures/rename-integration/c/swift-consumer/before/api.h b/gitnexus/test/fixtures/rename-integration/c/swift-consumer/before/api.h new file mode 100644 index 000000000..12e307c83 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/c/swift-consumer/before/api.h @@ -0,0 +1 @@ +int compute(int value); diff --git a/gitnexus/test/fixtures/rename-integration/c/swift-consumer/case.json b/gitnexus/test/fixtures/rename-integration/c/swift-consumer/case.json new file mode 100644 index 000000000..1756505fb --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/c/swift-consumer/case.json @@ -0,0 +1,21 @@ +{ + "language": "c", + "description": "Refuse a C function that an indexed Swift caller names through a bridging header, until the Swift binding is proven.", + "categories": [ + "cross-language", + "interop", + "negative", + "unchanged-on-error" + ], + "target": { + "name": "compute", + "kind": "Function", + "file": "api.c" + }, + "newName": "calculate", + "expect": { + "status": "error", + "code": "unsupported_boundary", + "planningStatus": "unsupported" + } +} diff --git a/gitnexus/test/fixtures/rename-integration/c/zig-cimport/after/api.c b/gitnexus/test/fixtures/rename-integration/c/zig-cimport/after/api.c new file mode 100644 index 000000000..61ab81bae --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/c/zig-cimport/after/api.c @@ -0,0 +1,3 @@ +#include "api.h" + +int compute(int value) { return value; } diff --git a/gitnexus/test/fixtures/rename-integration/c/zig-cimport/after/api.h b/gitnexus/test/fixtures/rename-integration/c/zig-cimport/after/api.h new file mode 100644 index 000000000..12e307c83 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/c/zig-cimport/after/api.h @@ -0,0 +1 @@ +int compute(int value); diff --git a/gitnexus/test/fixtures/rename-integration/c/zig-cimport/after/main.zig b/gitnexus/test/fixtures/rename-integration/c/zig-cimport/after/main.zig new file mode 100644 index 000000000..a12ece449 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/c/zig-cimport/after/main.zig @@ -0,0 +1,7 @@ +const c = @cImport({ + @cInclude("api.h"); +}); + +pub fn call() c_int { + return c.compute(1); +} diff --git a/gitnexus/test/fixtures/rename-integration/c/zig-cimport/before/api.c b/gitnexus/test/fixtures/rename-integration/c/zig-cimport/before/api.c new file mode 100644 index 000000000..61ab81bae --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/c/zig-cimport/before/api.c @@ -0,0 +1,3 @@ +#include "api.h" + +int compute(int value) { return value; } diff --git a/gitnexus/test/fixtures/rename-integration/c/zig-cimport/before/api.h b/gitnexus/test/fixtures/rename-integration/c/zig-cimport/before/api.h new file mode 100644 index 000000000..12e307c83 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/c/zig-cimport/before/api.h @@ -0,0 +1 @@ +int compute(int value); diff --git a/gitnexus/test/fixtures/rename-integration/c/zig-cimport/before/main.zig b/gitnexus/test/fixtures/rename-integration/c/zig-cimport/before/main.zig new file mode 100644 index 000000000..a12ece449 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/c/zig-cimport/before/main.zig @@ -0,0 +1,7 @@ +const c = @cImport({ + @cInclude("api.h"); +}); + +pub fn call() c_int { + return c.compute(1); +} diff --git a/gitnexus/test/fixtures/rename-integration/c/zig-cimport/case.json b/gitnexus/test/fixtures/rename-integration/c/zig-cimport/case.json new file mode 100644 index 000000000..585801c07 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/c/zig-cimport/case.json @@ -0,0 +1,21 @@ +{ + "language": "c", + "description": "Refuse a C function that an indexed Zig @cImport caller names, until the Zig binding is proven.", + "categories": [ + "cross-language", + "interop", + "negative", + "unchanged-on-error" + ], + "target": { + "name": "compute", + "kind": "Function", + "file": "api.c" + }, + "newName": "calculate", + "expect": { + "status": "error", + "code": "unsupported_boundary", + "planningStatus": "unsupported" + } +} diff --git a/gitnexus/test/fixtures/rename-integration/cobol/cross-program-call/after/driver.cbl b/gitnexus/test/fixtures/rename-integration/cobol/cross-program-call/after/driver.cbl new file mode 100644 index 000000000..7fb6cf92d --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/cobol/cross-program-call/after/driver.cbl @@ -0,0 +1,9 @@ +>>SOURCE FORMAT FREE +IDENTIFICATION DIVISION. +PROGRAM-ID. DRIVER. +PROCEDURE DIVISION. +MAIN-PARA. + CALL "SERVICE" + DISPLAY "WORKER". + GOBACK. +END PROGRAM DRIVER. diff --git a/gitnexus/test/fixtures/rename-integration/cobol/cross-program-call/after/worker.cbl b/gitnexus/test/fixtures/rename-integration/cobol/cross-program-call/after/worker.cbl new file mode 100644 index 000000000..add5834e6 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/cobol/cross-program-call/after/worker.cbl @@ -0,0 +1,7 @@ +>>SOURCE FORMAT FREE +IDENTIFICATION DIVISION. +PROGRAM-ID. SERVICE. +PROCEDURE DIVISION. +WORK-PARA. + GOBACK. +END PROGRAM SERVICE. diff --git a/gitnexus/test/fixtures/rename-integration/cobol/cross-program-call/before/driver.cbl b/gitnexus/test/fixtures/rename-integration/cobol/cross-program-call/before/driver.cbl new file mode 100644 index 000000000..dcb3e5e8a --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/cobol/cross-program-call/before/driver.cbl @@ -0,0 +1,9 @@ +>>SOURCE FORMAT FREE +IDENTIFICATION DIVISION. +PROGRAM-ID. DRIVER. +PROCEDURE DIVISION. +MAIN-PARA. + CALL "worker" + DISPLAY "WORKER". + GOBACK. +END PROGRAM DRIVER. diff --git a/gitnexus/test/fixtures/rename-integration/cobol/cross-program-call/before/worker.cbl b/gitnexus/test/fixtures/rename-integration/cobol/cross-program-call/before/worker.cbl new file mode 100644 index 000000000..49a2ff857 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/cobol/cross-program-call/before/worker.cbl @@ -0,0 +1,7 @@ +>>SOURCE FORMAT FREE +IDENTIFICATION DIVISION. +PROGRAM-ID. WORKER. +PROCEDURE DIVISION. +WORK-PARA. + GOBACK. +END PROGRAM worker. diff --git a/gitnexus/test/fixtures/rename-integration/cobol/cross-program-call/case.json b/gitnexus/test/fixtures/rename-integration/cobol/cross-program-call/case.json new file mode 100644 index 000000000..58aa29cca --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/cobol/cross-program-call/case.json @@ -0,0 +1,32 @@ +{ + "language": "cobol", + "description": "Rename a program declaration and end marker across a static CALL literal while preserving an ordinary string of the same spelling.", + "categories": ["call-graph", "cross-file", "program-scope"], + "target": { + "name": "WORKER", + "kind": "Module", + "file": "worker.cbl" + }, + "newName": "SERVICE", + "expect": { + "status": "success", + "edits": 3 + }, + "edges": [ + { + "type": "CALLS", + "from": { + "name": "DRIVER", + "kind": "Module", + "file": "driver.cbl", + "startLine": 0 + }, + "to": { + "name": "WORKER", + "kind": "Module", + "file": "worker.cbl", + "startLine": 0 + } + } + ] +} diff --git a/gitnexus/test/fixtures/rename-integration/cobol/nested-qualified-data/after/demo.cbl b/gitnexus/test/fixtures/rename-integration/cobol/nested-qualified-data/after/demo.cbl new file mode 100644 index 000000000..386c40223 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/cobol/nested-qualified-data/after/demo.cbl @@ -0,0 +1,16 @@ +>>SOURCE FORMAT FREE +IDENTIFICATION DIVISION. +PROGRAM-ID. DEMO. +DATA DIVISION. +WORKING-STORAGE SECTION. +01 FIRST-GROUP. + 05 DETAIL-GROUP. + 10 PRODUCT-CODE PIC 99. +01 SECOND-GROUP. + 05 DETAIL-GROUP. + 10 ITEM-CODE PIC 99. +PROCEDURE DIVISION. +MAIN-PARA. + MOVE PRODUCT-CODE OF detail-group OF first-group TO ITEM-CODE OF DETAIL-GROUP OF SECOND-GROUP + DISPLAY PRODUCT-CODE IN DETAIL-GROUP IN FIRST-GROUP "ITEM-CODE". + GOBACK. diff --git a/gitnexus/test/fixtures/rename-integration/cobol/nested-qualified-data/before/demo.cbl b/gitnexus/test/fixtures/rename-integration/cobol/nested-qualified-data/before/demo.cbl new file mode 100644 index 000000000..73c126679 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/cobol/nested-qualified-data/before/demo.cbl @@ -0,0 +1,16 @@ +>>SOURCE FORMAT FREE +IDENTIFICATION DIVISION. +PROGRAM-ID. DEMO. +DATA DIVISION. +WORKING-STORAGE SECTION. +01 FIRST-GROUP. + 05 DETAIL-GROUP. + 10 ITEM-CODE PIC 99. +01 SECOND-GROUP. + 05 DETAIL-GROUP. + 10 ITEM-CODE PIC 99. +PROCEDURE DIVISION. +MAIN-PARA. + MOVE item-code OF detail-group OF first-group TO ITEM-CODE OF DETAIL-GROUP OF SECOND-GROUP + DISPLAY ITEM-CODE IN DETAIL-GROUP IN FIRST-GROUP "ITEM-CODE". + GOBACK. diff --git a/gitnexus/test/fixtures/rename-integration/cobol/nested-qualified-data/case.json b/gitnexus/test/fixtures/rename-integration/cobol/nested-qualified-data/case.json new file mode 100644 index 000000000..7aa587885 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/cobol/nested-qualified-data/case.json @@ -0,0 +1,16 @@ +{ + "language": "cobol", + "description": "Rename a nested data item through OF/IN qualifier chains, distinguishing same-named sibling groups and case-insensitive references.", + "categories": ["public-field", "deep-chain", "qualified-data"], + "target": { + "name": "ITEM-CODE", + "kind": "Property", + "file": "demo.cbl", + "startLine": 7 + }, + "newName": "PRODUCT-CODE", + "expect": { + "status": "success", + "edits": 3 + } +} diff --git a/gitnexus/test/fixtures/rename-integration/cobol/paragraph-call-chain/after/demo.cbl b/gitnexus/test/fixtures/rename-integration/cobol/paragraph-call-chain/after/demo.cbl new file mode 100644 index 000000000..3e4ebe746 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/cobol/paragraph-call-chain/after/demo.cbl @@ -0,0 +1,14 @@ +>>SOURCE FORMAT FREE +IDENTIFICATION DIVISION. +PROGRAM-ID. DEMO. +PROCEDURE DIVISION. +ENTRY-PARA. + PERFORM MIDDLE-PARA. +MIDDLE-PARA. + PERFORM LEAF-PARA. +LEAF-PARA. + PERFORM FINISH-PARA. +FINISH-PARA. + DISPLAY "WORK-PARA". + GOBACK. +END PROGRAM DEMO. diff --git a/gitnexus/test/fixtures/rename-integration/cobol/paragraph-call-chain/after/other.cbl b/gitnexus/test/fixtures/rename-integration/cobol/paragraph-call-chain/after/other.cbl new file mode 100644 index 000000000..0af0ccd8f --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/cobol/paragraph-call-chain/after/other.cbl @@ -0,0 +1,9 @@ +>>SOURCE FORMAT FREE +IDENTIFICATION DIVISION. +PROGRAM-ID. OTHER. +PROCEDURE DIVISION. +MAIN-PARA. + PERFORM WORK-PARA. +WORK-PARA. + GOBACK. +END PROGRAM OTHER. diff --git a/gitnexus/test/fixtures/rename-integration/cobol/paragraph-call-chain/before/demo.cbl b/gitnexus/test/fixtures/rename-integration/cobol/paragraph-call-chain/before/demo.cbl new file mode 100644 index 000000000..0e457d23c --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/cobol/paragraph-call-chain/before/demo.cbl @@ -0,0 +1,14 @@ +>>SOURCE FORMAT FREE +IDENTIFICATION DIVISION. +PROGRAM-ID. DEMO. +PROCEDURE DIVISION. +ENTRY-PARA. + PERFORM MIDDLE-PARA. +MIDDLE-PARA. + PERFORM LEAF-PARA. +LEAF-PARA. + PERFORM work-para. +WORK-PARA. + DISPLAY "WORK-PARA". + GOBACK. +END PROGRAM DEMO. diff --git a/gitnexus/test/fixtures/rename-integration/cobol/paragraph-call-chain/before/other.cbl b/gitnexus/test/fixtures/rename-integration/cobol/paragraph-call-chain/before/other.cbl new file mode 100644 index 000000000..0af0ccd8f --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/cobol/paragraph-call-chain/before/other.cbl @@ -0,0 +1,9 @@ +>>SOURCE FORMAT FREE +IDENTIFICATION DIVISION. +PROGRAM-ID. OTHER. +PROCEDURE DIVISION. +MAIN-PARA. + PERFORM WORK-PARA. +WORK-PARA. + GOBACK. +END PROGRAM OTHER. diff --git a/gitnexus/test/fixtures/rename-integration/cobol/paragraph-call-chain/case.json b/gitnexus/test/fixtures/rename-integration/cobol/paragraph-call-chain/case.json new file mode 100644 index 000000000..7a1be8223 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/cobol/paragraph-call-chain/case.json @@ -0,0 +1,77 @@ +{ + "language": "cobol", + "description": "Rename a paragraph at the end of three PERFORM calls case-insensitively, preserving an unrelated program paragraph. These procedural calls are the COBOL analogue of a call graph, not OO polymorphism.", + "categories": ["call-graph", "call-chain", "program-scope"], + "target": { + "name": "WORK-PARA", + "kind": "Function", + "file": "demo.cbl" + }, + "newName": "FINISH-PARA", + "expect": { + "status": "success", + "edits": 2 + }, + "edges": [ + { + "type": "CALLS", + "from": { + "name": "ENTRY-PARA", + "kind": "Function", + "file": "demo.cbl", + "startLine": 4 + }, + "to": { + "name": "MIDDLE-PARA", + "kind": "Function", + "file": "demo.cbl", + "startLine": 6 + } + }, + { + "type": "CALLS", + "from": { + "name": "MIDDLE-PARA", + "kind": "Function", + "file": "demo.cbl", + "startLine": 6 + }, + "to": { + "name": "LEAF-PARA", + "kind": "Function", + "file": "demo.cbl", + "startLine": 8 + } + }, + { + "type": "CALLS", + "from": { + "name": "LEAF-PARA", + "kind": "Function", + "file": "demo.cbl", + "startLine": 8 + }, + "to": { + "name": "WORK-PARA", + "kind": "Function", + "file": "demo.cbl", + "startLine": 10 + } + }, + { + "type": "CALLS", + "from": { + "name": "MAIN-PARA", + "kind": "Function", + "file": "other.cbl", + "startLine": 4 + }, + "to": { + "name": "WORK-PARA", + "kind": "Function", + "file": "other.cbl", + "startLine": 6 + } + } + ] +} diff --git a/gitnexus/test/fixtures/rename-integration/cpp/external-base-family/after/model.cpp b/gitnexus/test/fixtures/rename-integration/cpp/external-base-family/after/model.cpp new file mode 100644 index 000000000..ee672c905 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/cpp/external-base-family/after/model.cpp @@ -0,0 +1,6 @@ +#include "external.hpp" + +class Worker : public External { +public: + int target() override { return 1; } +}; diff --git a/gitnexus/test/fixtures/rename-integration/cpp/external-base-family/after/notes.txt b/gitnexus/test/fixtures/rename-integration/cpp/external-base-family/after/notes.txt new file mode 100644 index 000000000..81db7fc3a --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/cpp/external-base-family/after/notes.txt @@ -0,0 +1 @@ +External contract and target spelling remain unchanged. diff --git a/gitnexus/test/fixtures/rename-integration/cpp/external-base-family/before/model.cpp b/gitnexus/test/fixtures/rename-integration/cpp/external-base-family/before/model.cpp new file mode 100644 index 000000000..ee672c905 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/cpp/external-base-family/before/model.cpp @@ -0,0 +1,6 @@ +#include "external.hpp" + +class Worker : public External { +public: + int target() override { return 1; } +}; diff --git a/gitnexus/test/fixtures/rename-integration/cpp/external-base-family/before/notes.txt b/gitnexus/test/fixtures/rename-integration/cpp/external-base-family/before/notes.txt new file mode 100644 index 000000000..81db7fc3a --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/cpp/external-base-family/before/notes.txt @@ -0,0 +1 @@ +External contract and target spelling remain unchanged. diff --git a/gitnexus/test/fixtures/rename-integration/cpp/external-base-family/case.json b/gitnexus/test/fixtures/rename-integration/cpp/external-base-family/case.json new file mode 100644 index 000000000..73a6fc317 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/cpp/external-base-family/case.json @@ -0,0 +1,22 @@ +{ + "language": "cpp", + "description": "Refuse a potentially polymorphic member whose external base is not indexed.", + "categories": [ + "polymorphism", + "external-base", + "negative", + "unchanged-on-error" + ], + "target": { + "name": "target", + "kind": "Method", + "file": "model.cpp", + "startLine": 4 + }, + "newName": "renamed", + "expect": { + "status": "error", + "code": "unsupported_symbol_family", + "planningStatus": "unsupported" + } +} diff --git a/gitnexus/test/fixtures/rename-integration/cpp/go-cgo-consumer/after/api.cpp b/gitnexus/test/fixtures/rename-integration/cpp/go-cgo-consumer/after/api.cpp new file mode 100644 index 000000000..615714c7b --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/cpp/go-cgo-consumer/after/api.cpp @@ -0,0 +1 @@ +extern "C" int compute(int value) { return value; } diff --git a/gitnexus/test/fixtures/rename-integration/cpp/go-cgo-consumer/after/main.go b/gitnexus/test/fixtures/rename-integration/cpp/go-cgo-consumer/after/main.go new file mode 100644 index 000000000..e7eb80205 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/cpp/go-cgo-consumer/after/main.go @@ -0,0 +1,7 @@ +package main + +import "C" + +func call() int { + return int(C.compute(1)) +} diff --git a/gitnexus/test/fixtures/rename-integration/cpp/go-cgo-consumer/before/api.cpp b/gitnexus/test/fixtures/rename-integration/cpp/go-cgo-consumer/before/api.cpp new file mode 100644 index 000000000..615714c7b --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/cpp/go-cgo-consumer/before/api.cpp @@ -0,0 +1 @@ +extern "C" int compute(int value) { return value; } diff --git a/gitnexus/test/fixtures/rename-integration/cpp/go-cgo-consumer/before/main.go b/gitnexus/test/fixtures/rename-integration/cpp/go-cgo-consumer/before/main.go new file mode 100644 index 000000000..e7eb80205 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/cpp/go-cgo-consumer/before/main.go @@ -0,0 +1,7 @@ +package main + +import "C" + +func call() int { + return int(C.compute(1)) +} diff --git a/gitnexus/test/fixtures/rename-integration/cpp/go-cgo-consumer/case.json b/gitnexus/test/fixtures/rename-integration/cpp/go-cgo-consumer/case.json new file mode 100644 index 000000000..29b0b186f --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/cpp/go-cgo-consumer/case.json @@ -0,0 +1,21 @@ +{ + "language": "cpp", + "description": "Refuse a C++ extern \"C\" function that an indexed Go cgo caller names, until the Go binding is proven.", + "categories": [ + "cross-language", + "interop", + "negative", + "unchanged-on-error" + ], + "target": { + "name": "compute", + "kind": "Function", + "file": "api.cpp" + }, + "newName": "calculate", + "expect": { + "status": "error", + "code": "unsupported_boundary", + "planningStatus": "unsupported" + } +} diff --git a/gitnexus/test/fixtures/rename-integration/cpp/go-cgo-unrelated/after/api.cpp b/gitnexus/test/fixtures/rename-integration/cpp/go-cgo-unrelated/after/api.cpp new file mode 100644 index 000000000..3ebba72e4 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/cpp/go-cgo-unrelated/after/api.cpp @@ -0,0 +1,2 @@ +int calculate() { return 1; } +int consume(void) { return calculate(); } diff --git a/gitnexus/test/fixtures/rename-integration/cpp/go-cgo-unrelated/after/main.go b/gitnexus/test/fixtures/rename-integration/cpp/go-cgo-unrelated/after/main.go new file mode 100644 index 000000000..ec1099a32 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/cpp/go-cgo-unrelated/after/main.go @@ -0,0 +1,4 @@ +package main + +// compute calculate remain comments. +func note() string { return "compute calculate" } diff --git a/gitnexus/test/fixtures/rename-integration/cpp/go-cgo-unrelated/before/api.cpp b/gitnexus/test/fixtures/rename-integration/cpp/go-cgo-unrelated/before/api.cpp new file mode 100644 index 000000000..678567c3d --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/cpp/go-cgo-unrelated/before/api.cpp @@ -0,0 +1,2 @@ +int compute() { return 1; } +int consume(void) { return compute(); } diff --git a/gitnexus/test/fixtures/rename-integration/cpp/go-cgo-unrelated/before/main.go b/gitnexus/test/fixtures/rename-integration/cpp/go-cgo-unrelated/before/main.go new file mode 100644 index 000000000..ec1099a32 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/cpp/go-cgo-unrelated/before/main.go @@ -0,0 +1,4 @@ +package main + +// compute calculate remain comments. +func note() string { return "compute calculate" } diff --git a/gitnexus/test/fixtures/rename-integration/cpp/go-cgo-unrelated/case.json b/gitnexus/test/fixtures/rename-integration/cpp/go-cgo-unrelated/case.json new file mode 100644 index 000000000..6df6c0afa --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/cpp/go-cgo-unrelated/case.json @@ -0,0 +1,19 @@ +{ + "language": "cpp", + "description": "A Go source that mentions the selected C++ name only in comments and strings does not block a proven C++ rename.", + "categories": [ + "cross-language", + "interop", + "comments-and-strings" + ], + "target": { + "name": "compute", + "kind": "Function", + "file": "api.cpp" + }, + "newName": "calculate", + "expect": { + "status": "success", + "edits": 2 + } +} diff --git a/gitnexus/test/fixtures/rename-integration/cpp/interop-objective-cpp-call/after/api.cpp b/gitnexus/test/fixtures/rename-integration/cpp/interop-objective-cpp-call/after/api.cpp new file mode 100644 index 000000000..d166c9e8d --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/cpp/interop-objective-cpp-call/after/api.cpp @@ -0,0 +1 @@ +int compute() { return 1; } diff --git a/gitnexus/test/fixtures/rename-integration/cpp/interop-objective-cpp-call/after/caller.mm b/gitnexus/test/fixtures/rename-integration/cpp/interop-objective-cpp-call/after/caller.mm new file mode 100644 index 000000000..e380c6dae --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/cpp/interop-objective-cpp-call/after/caller.mm @@ -0,0 +1 @@ +int consume(void) { return compute(); } diff --git a/gitnexus/test/fixtures/rename-integration/cpp/interop-objective-cpp-call/before/api.cpp b/gitnexus/test/fixtures/rename-integration/cpp/interop-objective-cpp-call/before/api.cpp new file mode 100644 index 000000000..d166c9e8d --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/cpp/interop-objective-cpp-call/before/api.cpp @@ -0,0 +1 @@ +int compute() { return 1; } diff --git a/gitnexus/test/fixtures/rename-integration/cpp/interop-objective-cpp-call/before/caller.mm b/gitnexus/test/fixtures/rename-integration/cpp/interop-objective-cpp-call/before/caller.mm new file mode 100644 index 000000000..e380c6dae --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/cpp/interop-objective-cpp-call/before/caller.mm @@ -0,0 +1 @@ +int consume(void) { return compute(); } diff --git a/gitnexus/test/fixtures/rename-integration/cpp/interop-objective-cpp-call/case.json b/gitnexus/test/fixtures/rename-integration/cpp/interop-objective-cpp-call/case.json new file mode 100644 index 000000000..b3fb5e7a8 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/cpp/interop-objective-cpp-call/case.json @@ -0,0 +1,20 @@ +{ + "language": "cpp", + "description": "Refuse an indexed interoperating source name until cross-provider binding coverage is proven.", + "categories": [ + "cross-language", + "interop", + "comments-and-strings" + ], + "target": { + "name": "compute", + "kind": "Function", + "file": "api.cpp" + }, + "newName": "calculate", + "expect": { + "status": "error", + "code": "unsupported_boundary", + "planningStatus": "unsupported" + } +} diff --git a/gitnexus/test/fixtures/rename-integration/cpp/overload-header-call-chain/after/api.cpp b/gitnexus/test/fixtures/rename-integration/cpp/overload-header-call-chain/after/api.cpp new file mode 100644 index 000000000..a23eeaef9 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/cpp/overload-header-call-chain/after/api.cpp @@ -0,0 +1,3 @@ +#include "api.h" +int calculate(int value) { return value + 1; } +double compute(double value) { return value + 0.5; } diff --git a/gitnexus/test/fixtures/rename-integration/cpp/overload-header-call-chain/after/api.h b/gitnexus/test/fixtures/rename-integration/cpp/overload-header-call-chain/after/api.h new file mode 100644 index 000000000..205e5d0ec --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/cpp/overload-header-call-chain/after/api.h @@ -0,0 +1,2 @@ +int calculate(int value); +double compute(double value); diff --git a/gitnexus/test/fixtures/rename-integration/cpp/overload-header-call-chain/after/main.cpp b/gitnexus/test/fixtures/rename-integration/cpp/overload-header-call-chain/after/main.cpp new file mode 100644 index 000000000..36e66242f --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/cpp/overload-header-call-chain/after/main.cpp @@ -0,0 +1,8 @@ +#include "api.h" +bool compute(bool flag) { return flag; } +int middle() { return calculate(1); } +int outer() { return middle(); } +double floating() { return compute(1.5); } +bool boolean() { return compute(true); } +const char *label = "compute"; +// Only the integer compute overload is renamed. diff --git a/gitnexus/test/fixtures/rename-integration/cpp/overload-header-call-chain/before/api.cpp b/gitnexus/test/fixtures/rename-integration/cpp/overload-header-call-chain/before/api.cpp new file mode 100644 index 000000000..71468dd57 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/cpp/overload-header-call-chain/before/api.cpp @@ -0,0 +1,3 @@ +#include "api.h" +int compute(int value) { return value + 1; } +double compute(double value) { return value + 0.5; } diff --git a/gitnexus/test/fixtures/rename-integration/cpp/overload-header-call-chain/before/api.h b/gitnexus/test/fixtures/rename-integration/cpp/overload-header-call-chain/before/api.h new file mode 100644 index 000000000..702ede293 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/cpp/overload-header-call-chain/before/api.h @@ -0,0 +1,2 @@ +int compute(int value); +double compute(double value); diff --git a/gitnexus/test/fixtures/rename-integration/cpp/overload-header-call-chain/before/main.cpp b/gitnexus/test/fixtures/rename-integration/cpp/overload-header-call-chain/before/main.cpp new file mode 100644 index 000000000..827bc0ab7 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/cpp/overload-header-call-chain/before/main.cpp @@ -0,0 +1,8 @@ +#include "api.h" +bool compute(bool flag) { return flag; } +int middle() { return compute(1); } +int outer() { return middle(); } +double floating() { return compute(1.5); } +bool boolean() { return compute(true); } +const char *label = "compute"; +// Only the integer compute overload is renamed. diff --git a/gitnexus/test/fixtures/rename-integration/cpp/overload-header-call-chain/case.json b/gitnexus/test/fixtures/rename-integration/cpp/overload-header-call-chain/case.json new file mode 100644 index 000000000..519a39787 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/cpp/overload-header-call-chain/case.json @@ -0,0 +1,80 @@ +{ + "language": "cpp", + "warmParseCache": true, + "description": "Rename one overload across its header declaration, definition and typed call, preserving both an imported floating-point overload and a same-scope boolean overload.", + "categories": [ + "cross-file", + "overloads", + "call-graph", + "declaration-family" + ], + "target": { + "name": "compute", + "kind": "Function", + "file": "api.cpp", + "startLine": 1 + }, + "newName": "calculate", + "expect": { + "status": "success", + "edits": 3 + }, + "edges": [ + { + "type": "CALLS", + "from": { + "name": "outer", + "kind": "Function", + "file": "main.cpp" + }, + "to": { + "name": "middle", + "kind": "Function", + "file": "main.cpp" + } + }, + { + "type": "CALLS", + "from": { + "name": "middle", + "kind": "Function", + "file": "main.cpp" + }, + "to": { + "name": "compute", + "kind": "Function", + "file": "api.h", + "startLine": 0 + } + }, + { + "type": "CALLS", + "from": { + "name": "floating", + "kind": "Function", + "file": "main.cpp" + }, + "to": { + "name": "compute", + "kind": "Function", + "file": "api.h", + "startLine": 1 + } + }, + { + "type": "CALLS", + "from": { + "name": "boolean", + "kind": "Function", + "file": "main.cpp" + }, + "to": { + "name": "compute", + "kind": "Function", + "file": "main.cpp", + "startLine": 1 + } + } + ], + "exactOutgoing": true +} diff --git a/gitnexus/test/fixtures/rename-integration/cpp/public-field-pointer-chain/after/model.cpp b/gitnexus/test/fixtures/rename-integration/cpp/public-field-pointer-chain/after/model.cpp new file mode 100644 index 000000000..014d04104 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/cpp/public-field-pointer-chain/after/model.cpp @@ -0,0 +1,12 @@ +struct Leaf { int amount; }; +struct Branch { Leaf* leaf; }; +struct Root { Branch* branch; }; +struct Other { int value; }; +int read(Root& root, Other& other) { + return root.branch->leaf->amount + other.value; +} +void write(Root& root, int value) { + root.branch->leaf->amount = value; +} +const char *label = "value"; +// value on Other must remain unchanged. diff --git a/gitnexus/test/fixtures/rename-integration/cpp/public-field-pointer-chain/before/model.cpp b/gitnexus/test/fixtures/rename-integration/cpp/public-field-pointer-chain/before/model.cpp new file mode 100644 index 000000000..f91ea41a1 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/cpp/public-field-pointer-chain/before/model.cpp @@ -0,0 +1,12 @@ +struct Leaf { int value; }; +struct Branch { Leaf* leaf; }; +struct Root { Branch* branch; }; +struct Other { int value; }; +int read(Root& root, Other& other) { + return root.branch->leaf->value + other.value; +} +void write(Root& root, int value) { + root.branch->leaf->value = value; +} +const char *label = "value"; +// value on Other must remain unchanged. diff --git a/gitnexus/test/fixtures/rename-integration/cpp/public-field-pointer-chain/case.json b/gitnexus/test/fixtures/rename-integration/cpp/public-field-pointer-chain/case.json new file mode 100644 index 000000000..345ebf11d --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/cpp/public-field-pointer-chain/case.json @@ -0,0 +1,48 @@ +{ + "language": "cpp", + "description": "Resolve a public struct member through mixed reference, dot and pointer access over three receiver hops.", + "categories": ["public-field", "deep-chain", "pointer-receiver", "homonyms", "writes"], + "target": { + "name": "value", + "kind": "Property", + "file": "model.cpp", + "startLine": 0 + }, + "newName": "amount", + "expect": { + "status": "success", + "edits": 3 + }, + "edges": [ + { + "type": "ACCESSES", + "from": { + "name": "read", + "kind": "Function", + "file": "model.cpp", + "startLine": 4 + }, + "to": { + "name": "value", + "kind": "Property", + "file": "model.cpp", + "startLine": 0 + } + }, + { + "type": "ACCESSES", + "from": { + "name": "write", + "kind": "Function", + "file": "model.cpp", + "startLine": 7 + }, + "to": { + "name": "value", + "kind": "Property", + "file": "model.cpp", + "startLine": 0 + } + } + ] +} diff --git a/gitnexus/test/fixtures/rename-integration/cpp/reserved-constructor-type-underscore/after/model.cpp b/gitnexus/test/fixtures/rename-integration/cpp/reserved-constructor-type-underscore/after/model.cpp new file mode 100644 index 000000000..acda8dec1 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/cpp/reserved-constructor-type-underscore/after/model.cpp @@ -0,0 +1,4 @@ +struct Target { + Target() {} +}; +Target caller() { return Target(); } diff --git a/gitnexus/test/fixtures/rename-integration/cpp/reserved-constructor-type-underscore/before/model.cpp b/gitnexus/test/fixtures/rename-integration/cpp/reserved-constructor-type-underscore/before/model.cpp new file mode 100644 index 000000000..acda8dec1 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/cpp/reserved-constructor-type-underscore/before/model.cpp @@ -0,0 +1,4 @@ +struct Target { + Target() {} +}; +Target caller() { return Target(); } diff --git a/gitnexus/test/fixtures/rename-integration/cpp/reserved-constructor-type-underscore/case.json b/gitnexus/test/fixtures/rename-integration/cpp/reserved-constructor-type-underscore/case.json new file mode 100644 index 000000000..95cc58ed8 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/cpp/reserved-constructor-type-underscore/case.json @@ -0,0 +1,21 @@ +{ + "language": "cpp", + "description": "Selecting a constructor must validate the reserved identifier against the coupled global type declaration.", + "categories": [ + "identifier-rules", + "reserved-words", + "constructor-family" + ], + "target": { + "name": "Target", + "kind": "Method", + "file": "model.cpp", + "startLine": 1 + }, + "newName": "_value", + "expect": { + "status": "error", + "code": "invalid_name", + "planningStatus": "blocked" + } +} diff --git a/gitnexus/test/fixtures/rename-integration/cpp/reserved-global-type-underscore/after/model.cpp b/gitnexus/test/fixtures/rename-integration/cpp/reserved-global-type-underscore/after/model.cpp new file mode 100644 index 000000000..063f4401a --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/cpp/reserved-global-type-underscore/after/model.cpp @@ -0,0 +1,2 @@ +struct Target {}; +void caller(Target value) {} diff --git a/gitnexus/test/fixtures/rename-integration/cpp/reserved-global-type-underscore/before/model.cpp b/gitnexus/test/fixtures/rename-integration/cpp/reserved-global-type-underscore/before/model.cpp new file mode 100644 index 000000000..063f4401a --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/cpp/reserved-global-type-underscore/before/model.cpp @@ -0,0 +1,2 @@ +struct Target {}; +void caller(Target value) {} diff --git a/gitnexus/test/fixtures/rename-integration/cpp/reserved-global-type-underscore/case.json b/gitnexus/test/fixtures/rename-integration/cpp/reserved-global-type-underscore/case.json new file mode 100644 index 000000000..2206e0832 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/cpp/reserved-global-type-underscore/case.json @@ -0,0 +1,20 @@ +{ + "language": "cpp", + "description": "A global type identifier beginning with an underscore is reserved even though its own declaration has a class scope node.", + "categories": [ + "identifier-rules", + "reserved-words" + ], + "target": { + "name": "Target", + "kind": "Struct", + "file": "model.cpp", + "startLine": 0 + }, + "newName": "_value", + "expect": { + "status": "error", + "code": "invalid_name", + "planningStatus": "blocked" + } +} diff --git a/gitnexus/test/fixtures/rename-integration/cpp/reserved-global-underscore/after/model.cpp b/gitnexus/test/fixtures/rename-integration/cpp/reserved-global-underscore/after/model.cpp new file mode 100644 index 000000000..ddaf4417d --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/cpp/reserved-global-underscore/after/model.cpp @@ -0,0 +1,2 @@ +int target() { return 1; } +int caller() { return target(); } diff --git a/gitnexus/test/fixtures/rename-integration/cpp/reserved-global-underscore/before/model.cpp b/gitnexus/test/fixtures/rename-integration/cpp/reserved-global-underscore/before/model.cpp new file mode 100644 index 000000000..ddaf4417d --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/cpp/reserved-global-underscore/before/model.cpp @@ -0,0 +1,2 @@ +int target() { return 1; } +int caller() { return target(); } diff --git a/gitnexus/test/fixtures/rename-integration/cpp/reserved-global-underscore/case.json b/gitnexus/test/fixtures/rename-integration/cpp/reserved-global-underscore/case.json new file mode 100644 index 000000000..8d2ee08fc --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/cpp/reserved-global-underscore/case.json @@ -0,0 +1,20 @@ +{ + "language": "cpp", + "description": "Refuse contextual or reserved identifier _value without modifying sources.", + "categories": [ + "identifier-rules", + "reserved-words" + ], + "target": { + "name": "target", + "kind": "Function", + "file": "model.cpp", + "startLine": 0 + }, + "newName": "_value", + "expect": { + "status": "error", + "code": "invalid_name", + "planningStatus": "blocked" + } +} diff --git a/gitnexus/test/fixtures/rename-integration/cpp/restrict-function-grammar-refusal/after/main.cpp b/gitnexus/test/fixtures/rename-integration/cpp/restrict-function-grammar-refusal/after/main.cpp new file mode 100644 index 000000000..875ce152a --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/cpp/restrict-function-grammar-refusal/after/main.cpp @@ -0,0 +1,4 @@ +int compute(int value) { return value + 1; } +int caller() { return compute(2); } +const char *label = "compute"; +// compute is renamed only at semantic references. diff --git a/gitnexus/test/fixtures/rename-integration/cpp/restrict-function-grammar-refusal/before/main.cpp b/gitnexus/test/fixtures/rename-integration/cpp/restrict-function-grammar-refusal/before/main.cpp new file mode 100644 index 000000000..875ce152a --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/cpp/restrict-function-grammar-refusal/before/main.cpp @@ -0,0 +1,4 @@ +int compute(int value) { return value + 1; } +int caller() { return compute(2); } +const char *label = "compute"; +// compute is renamed only at semantic references. diff --git a/gitnexus/test/fixtures/rename-integration/cpp/restrict-function-grammar-refusal/case.json b/gitnexus/test/fixtures/rename-integration/cpp/restrict-function-grammar-refusal/case.json new file mode 100644 index 000000000..94b0d17c4 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/cpp/restrict-function-grammar-refusal/case.json @@ -0,0 +1,31 @@ +{ + "language": "cpp", + "description": "The C++ validator accepts restrict, but the vendored grammar cannot parse it as an unparenthesized function declarator; refuse without edits.", + "categories": ["language-keywords", "grammar-limitation", "safe-refusal"], + "target": { + "name": "compute", + "kind": "Function", + "file": "main.cpp" + }, + "newName": "restrict", + "expect": { + "status": "error", + "code": "parse_error", + "planningStatus": "blocked" + }, + "edges": [ + { + "type": "CALLS", + "from": { + "name": "caller", + "kind": "Function", + "file": "main.cpp" + }, + "to": { + "name": "compute", + "kind": "Function", + "file": "main.cpp" + } + } + ] +} diff --git a/gitnexus/test/fixtures/rename-integration/cpp/restrict-type-identifier/after/model.cpp b/gitnexus/test/fixtures/rename-integration/cpp/restrict-type-identifier/after/model.cpp new file mode 100644 index 000000000..36a91eee5 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/cpp/restrict-type-identifier/after/model.cpp @@ -0,0 +1,4 @@ +struct restrict {}; +void caller(struct restrict item) {} +void outer() { caller({}); } +const char *label = "Target"; diff --git a/gitnexus/test/fixtures/rename-integration/cpp/restrict-type-identifier/before/model.cpp b/gitnexus/test/fixtures/rename-integration/cpp/restrict-type-identifier/before/model.cpp new file mode 100644 index 000000000..2e17c7d02 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/cpp/restrict-type-identifier/before/model.cpp @@ -0,0 +1,4 @@ +struct Target {}; +void caller(struct Target item) {} +void outer() { caller({}); } +const char *label = "Target"; diff --git a/gitnexus/test/fixtures/rename-integration/cpp/restrict-type-identifier/case.json b/gitnexus/test/fixtures/rename-integration/cpp/restrict-type-identifier/case.json new file mode 100644 index 000000000..d9cb6c710 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/cpp/restrict-type-identifier/case.json @@ -0,0 +1,30 @@ +{ + "language": "cpp", + "description": "Rename a struct type to restrict, a legal C++ identifier that is a reserved C keyword.", + "categories": ["language-keywords", "types", "call-graph"], + "target": { + "name": "Target", + "kind": "Struct", + "file": "model.cpp" + }, + "newName": "restrict", + "expect": { + "status": "success", + "edits": 2 + }, + "edges": [ + { + "type": "CALLS", + "from": { + "name": "outer", + "kind": "Function", + "file": "model.cpp" + }, + "to": { + "name": "caller", + "kind": "Function", + "file": "model.cpp" + } + } + ] +} diff --git a/gitnexus/test/fixtures/rename-integration/cpp/virtual-method-family/after/model.cpp b/gitnexus/test/fixtures/rename-integration/cpp/virtual-method-family/after/model.cpp new file mode 100644 index 000000000..e9ed3263d --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/cpp/virtual-method-family/after/model.cpp @@ -0,0 +1,9 @@ +struct Base { virtual int calculate() { return 1; } }; +struct Child : Base { int calculate() override { return 2; } }; +struct Grandchild : Child { int calculate() override { return 3; } }; +struct Other { int compute() { return 4; } }; +int dispatch(Base& base, Child& child, Grandchild& grandchild, Other& other) { + return base.calculate() + child.calculate() + grandchild.calculate() + other.compute(); +} +const char *label = "compute"; +// compute on Other is unrelated to the virtual family. diff --git a/gitnexus/test/fixtures/rename-integration/cpp/virtual-method-family/before/model.cpp b/gitnexus/test/fixtures/rename-integration/cpp/virtual-method-family/before/model.cpp new file mode 100644 index 000000000..b3f4ab493 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/cpp/virtual-method-family/before/model.cpp @@ -0,0 +1,9 @@ +struct Base { virtual int compute() { return 1; } }; +struct Child : Base { int compute() override { return 2; } }; +struct Grandchild : Child { int compute() override { return 3; } }; +struct Other { int compute() { return 4; } }; +int dispatch(Base& base, Child& child, Grandchild& grandchild, Other& other) { + return base.compute() + child.compute() + grandchild.compute() + other.compute(); +} +const char *label = "compute"; +// compute on Other is unrelated to the virtual family. diff --git a/gitnexus/test/fixtures/rename-integration/cpp/virtual-method-family/case.json b/gitnexus/test/fixtures/rename-integration/cpp/virtual-method-family/case.json new file mode 100644 index 000000000..b4bf7c39b --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/cpp/virtual-method-family/case.json @@ -0,0 +1,62 @@ +{ + "language": "cpp", + "description": "Rename a virtual method through two override levels and statically typed receiver calls while retaining an unrelated homonym.", + "categories": ["polymorphism", "method-family", "inheritance", "homonyms"], + "target": { + "name": "compute", + "kind": "Method", + "file": "model.cpp", + "startLine": 0 + }, + "newName": "calculate", + "expect": { + "status": "success", + "edits": 6 + }, + "edges": [ + { + "type": "METHOD_OVERRIDES", + "from": { + "name": "Child", + "kind": "Struct", + "file": "model.cpp", + "startLine": 1 + }, + "to": { + "name": "compute", + "kind": "Method", + "file": "model.cpp", + "startLine": 0 + } + }, + { + "type": "METHOD_OVERRIDES", + "from": { + "name": "Grandchild", + "kind": "Struct", + "file": "model.cpp", + "startLine": 2 + }, + "to": { + "name": "compute", + "kind": "Method", + "file": "model.cpp", + "startLine": 1 + } + }, + { + "type": "CALLS", + "from": { + "name": "dispatch", + "kind": "Function", + "file": "model.cpp" + }, + "to": { + "name": "compute", + "kind": "Method", + "file": "model.cpp", + "startLine": 0 + } + } + ] +} diff --git a/gitnexus/test/fixtures/rename-integration/csharp/escaped-class-import/after/App/Main.cs b/gitnexus/test/fixtures/rename-integration/csharp/escaped-class-import/after/App/Main.cs new file mode 100644 index 000000000..8ba616718 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/csharp/escaped-class-import/after/App/Main.cs @@ -0,0 +1,2 @@ +using W = Models.@Worker; +class App { public int Run() { var item = new W(); return item.Fetch(); } } diff --git a/gitnexus/test/fixtures/rename-integration/csharp/escaped-class-import/after/Models/Worker.cs b/gitnexus/test/fixtures/rename-integration/csharp/escaped-class-import/after/Models/Worker.cs new file mode 100644 index 000000000..c8c18e36d --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/csharp/escaped-class-import/after/Models/Worker.cs @@ -0,0 +1 @@ +namespace Models { public class @Worker { public int Fetch() { return 7; } } } diff --git a/gitnexus/test/fixtures/rename-integration/csharp/escaped-class-import/before/App/Main.cs b/gitnexus/test/fixtures/rename-integration/csharp/escaped-class-import/before/App/Main.cs new file mode 100644 index 000000000..77eef0605 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/csharp/escaped-class-import/before/App/Main.cs @@ -0,0 +1,2 @@ +using W = Models.@Worker; +class App { public int Run() { var item = new W(); return item.Read(); } } diff --git a/gitnexus/test/fixtures/rename-integration/csharp/escaped-class-import/before/Models/Worker.cs b/gitnexus/test/fixtures/rename-integration/csharp/escaped-class-import/before/Models/Worker.cs new file mode 100644 index 000000000..adb690ca7 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/csharp/escaped-class-import/before/Models/Worker.cs @@ -0,0 +1 @@ +namespace Models { public class @Worker { public int Read() { return 7; } } } diff --git a/gitnexus/test/fixtures/rename-integration/csharp/escaped-class-import/case.json b/gitnexus/test/fixtures/rename-integration/csharp/escaped-class-import/case.json new file mode 100644 index 000000000..ba19dcb83 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/csharp/escaped-class-import/case.json @@ -0,0 +1,49 @@ +{ + "language": "csharp", + "description": "Keep escaped semantic keys aligned through persisted indexing, cached reload, and exact rename.", + "categories": [ + "escaped-identifier", + "call-graph", + "cross-file" + ], + "target": { + "name": "Read", + "kind": "Method", + "file": "Models/Worker.cs" + }, + "newName": "Fetch", + "expect": { + "status": "success", + "edits": 2 + }, + "edges": [ + { + "type": "CALLS", + "from": { + "name": "Run", + "kind": "Method", + "file": "App/Main.cs" + }, + "to": { + "name": "Read", + "kind": "Method", + "file": "Models/Worker.cs" + } + }, + { + "type": "CALLS", + "from": { + "name": "Run", + "kind": "Method", + "file": "App/Main.cs" + }, + "to": { + "name": "@Worker", + "kind": "Class", + "file": "Models/Worker.cs" + } + } + ], + "exactOutgoing": true, + "warmParseCache": true +} diff --git a/gitnexus/test/fixtures/rename-integration/csharp/escaped-member-identity/after/Worker.cs b/gitnexus/test/fixtures/rename-integration/csharp/escaped-member-identity/after/Worker.cs new file mode 100644 index 000000000..183591544 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/csharp/escaped-member-identity/after/Worker.cs @@ -0,0 +1,2 @@ +class Worker { public int @Fetch() { return 7; } } +class Client { public int Run() { var @worker = new Worker(); return @worker.@Fetch(); } } diff --git a/gitnexus/test/fixtures/rename-integration/csharp/escaped-member-identity/before/Worker.cs b/gitnexus/test/fixtures/rename-integration/csharp/escaped-member-identity/before/Worker.cs new file mode 100644 index 000000000..f10cd5bf9 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/csharp/escaped-member-identity/before/Worker.cs @@ -0,0 +1,2 @@ +class Worker { public int @Read() { return 7; } } +class Client { public int Run() { var @worker = new Worker(); return @worker.@Read(); } } diff --git a/gitnexus/test/fixtures/rename-integration/csharp/escaped-member-identity/case.json b/gitnexus/test/fixtures/rename-integration/csharp/escaped-member-identity/case.json new file mode 100644 index 000000000..e9298a0c2 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/csharp/escaped-member-identity/case.json @@ -0,0 +1,49 @@ +{ + "language": "csharp", + "description": "Retain raw member declaration keys alongside canonical escaped lexical receivers.", + "categories": [ + "escaped-identifier", + "call-graph" + ], + "target": { + "name": "@Read", + "kind": "Method", + "file": "Worker.cs" + }, + "newName": "Fetch", + "expectedTargetName": "@Fetch", + "expect": { + "status": "success", + "edits": 2 + }, + "edges": [ + { + "type": "CALLS", + "from": { + "name": "Run", + "kind": "Method", + "file": "Worker.cs" + }, + "to": { + "name": "@Read", + "kind": "Method", + "file": "Worker.cs" + } + }, + { + "type": "CALLS", + "from": { + "name": "Run", + "kind": "Method", + "file": "Worker.cs" + }, + "to": { + "name": "Worker", + "kind": "Class", + "file": "Worker.cs" + } + } + ], + "exactOutgoing": true, + "warmParseCache": true +} diff --git a/gitnexus/test/fixtures/rename-integration/csharp/escaped-overload-call-graph/after/Encoder.cs b/gitnexus/test/fixtures/rename-integration/csharp/escaped-overload-call-graph/after/Encoder.cs new file mode 100644 index 000000000..ba3bc6715 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/csharp/escaped-overload-call-graph/after/Encoder.cs @@ -0,0 +1,17 @@ +class Encoder { + public static string @Serialize() { return "Encode"; } + public static string Serialize(int value) { return "number"; } + public static string Middle() { + System.Func callback = Serialize; + return Serialize() + Serialize(7); + } + public static string Entry() { return Middle(); } + public static string Local(string Encode) { return Encode; } + public static string Named(string Encode) { return Encode; } + public static string Label() { return Named(Encode: "literal"); } + // Encode remains documentation +} +class OtherEncoder { + public string Encode() { return "other"; } + public string Use() { return Encode(); } +} diff --git a/gitnexus/test/fixtures/rename-integration/csharp/escaped-overload-call-graph/before/Encoder.cs b/gitnexus/test/fixtures/rename-integration/csharp/escaped-overload-call-graph/before/Encoder.cs new file mode 100644 index 000000000..07def2a9b --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/csharp/escaped-overload-call-graph/before/Encoder.cs @@ -0,0 +1,17 @@ +class Encoder { + public static string @Encode() { return "Encode"; } + public static string Encode(int value) { return "number"; } + public static string Middle() { + System.Func callback = Encode; + return Encode() + Encode(7); + } + public static string Entry() { return Middle(); } + public static string Local(string Encode) { return Encode; } + public static string Named(string Encode) { return Encode; } + public static string Label() { return Named(Encode: "literal"); } + // Encode remains documentation +} +class OtherEncoder { + public string Encode() { return "other"; } + public string Use() { return Encode(); } +} diff --git a/gitnexus/test/fixtures/rename-integration/csharp/escaped-overload-call-graph/case.json b/gitnexus/test/fixtures/rename-integration/csharp/escaped-overload-call-graph/case.json new file mode 100644 index 000000000..8ce747d18 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/csharp/escaped-overload-call-graph/case.json @@ -0,0 +1,102 @@ +{ + "language": "csharp", + "description": "Rename C# escaped overloads, method groups and calls while preserving named-argument labels and shadow parameters.", + "categories": [ + "escaped-identifier", + "overloads", + "call-graph", + "named-arguments", + "shadowing", + "unrelated-owner" + ], + "target": { + "name": "@Encode", + "kind": "Method", + "file": "Encoder.cs", + "startLine": 1 + }, + "newName": "Serialize", + "expect": { + "status": "success", + "edits": 5 + }, + "edges": [ + { + "type": "CALLS", + "from": { + "name": "Entry", + "kind": "Method", + "file": "Encoder.cs", + "startLine": 7 + }, + "to": { + "name": "Middle", + "kind": "Method", + "file": "Encoder.cs", + "startLine": 3 + } + }, + { + "type": "CALLS", + "from": { + "name": "Middle", + "kind": "Method", + "file": "Encoder.cs", + "startLine": 3 + }, + "to": { + "name": "@Encode", + "kind": "Method", + "file": "Encoder.cs", + "startLine": 1 + } + }, + { + "type": "CALLS", + "from": { + "name": "Middle", + "kind": "Method", + "file": "Encoder.cs", + "startLine": 3 + }, + "to": { + "name": "Encode", + "kind": "Method", + "file": "Encoder.cs", + "startLine": 2 + } + }, + { + "type": "CALLS", + "from": { + "name": "Use", + "kind": "Method", + "file": "Encoder.cs", + "startLine": 15 + }, + "to": { + "name": "Encode", + "kind": "Method", + "file": "Encoder.cs", + "startLine": 14 + } + }, + { + "type": "CALLS", + "from": { + "name": "Label", + "kind": "Method", + "file": "Encoder.cs", + "startLine": 10 + }, + "to": { + "name": "Named", + "kind": "Method", + "file": "Encoder.cs", + "startLine": 9 + } + } + ], + "expectedTargetName": "@Serialize", + "exactOutgoing": true +} diff --git a/gitnexus/test/fixtures/rename-integration/csharp/escaped-receiver-identity/after/Worker.cs b/gitnexus/test/fixtures/rename-integration/csharp/escaped-receiver-identity/after/Worker.cs new file mode 100644 index 000000000..8bf7034aa --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/csharp/escaped-receiver-identity/after/Worker.cs @@ -0,0 +1,9 @@ +class @Worker { + public int ReadValue() { return 1; } +} +class Client { + public int Run() { + @Worker @worker = new @Worker(); + return @worker.ReadValue(); + } +} diff --git a/gitnexus/test/fixtures/rename-integration/csharp/escaped-receiver-identity/before/Worker.cs b/gitnexus/test/fixtures/rename-integration/csharp/escaped-receiver-identity/before/Worker.cs new file mode 100644 index 000000000..44bd52704 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/csharp/escaped-receiver-identity/before/Worker.cs @@ -0,0 +1,9 @@ +class @Worker { + public int Read() { return 1; } +} +class Client { + public int Run() { + @Worker @worker = new @Worker(); + return @worker.Read(); + } +} diff --git a/gitnexus/test/fixtures/rename-integration/csharp/escaped-receiver-identity/case.json b/gitnexus/test/fixtures/rename-integration/csharp/escaped-receiver-identity/case.json new file mode 100644 index 000000000..91649ccdd --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/csharp/escaped-receiver-identity/case.json @@ -0,0 +1,37 @@ +{ + "language": "csharp", + "description": "Preserve member calls through matching escaped type and local receiver spellings while renaming the member.", + "categories": [ + "escaped-identifier", + "receiver", + "call-graph" + ], + "target": { + "name": "Read", + "kind": "Method", + "file": "Worker.cs", + "startLine": 1 + }, + "newName": "ReadValue", + "expect": { + "status": "success", + "edits": 2 + }, + "edges": [ + { + "type": "CALLS", + "from": { + "name": "Run", + "kind": "Method", + "file": "Worker.cs", + "startLine": 4 + }, + "to": { + "name": "Read", + "kind": "Method", + "file": "Worker.cs", + "startLine": 1 + } + } + ] +} diff --git a/gitnexus/test/fixtures/rename-integration/csharp/external-base-family/after/Worker.cs b/gitnexus/test/fixtures/rename-integration/csharp/external-base-family/after/Worker.cs new file mode 100644 index 000000000..e27e4e8db --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/csharp/external-base-family/after/Worker.cs @@ -0,0 +1,5 @@ +using Vendor; + +class Worker : External { + public override int target() { return 1; } +} diff --git a/gitnexus/test/fixtures/rename-integration/csharp/external-base-family/after/notes.txt b/gitnexus/test/fixtures/rename-integration/csharp/external-base-family/after/notes.txt new file mode 100644 index 000000000..81db7fc3a --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/csharp/external-base-family/after/notes.txt @@ -0,0 +1 @@ +External contract and target spelling remain unchanged. diff --git a/gitnexus/test/fixtures/rename-integration/csharp/external-base-family/before/Worker.cs b/gitnexus/test/fixtures/rename-integration/csharp/external-base-family/before/Worker.cs new file mode 100644 index 000000000..e27e4e8db --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/csharp/external-base-family/before/Worker.cs @@ -0,0 +1,5 @@ +using Vendor; + +class Worker : External { + public override int target() { return 1; } +} diff --git a/gitnexus/test/fixtures/rename-integration/csharp/external-base-family/before/notes.txt b/gitnexus/test/fixtures/rename-integration/csharp/external-base-family/before/notes.txt new file mode 100644 index 000000000..81db7fc3a --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/csharp/external-base-family/before/notes.txt @@ -0,0 +1 @@ +External contract and target spelling remain unchanged. diff --git a/gitnexus/test/fixtures/rename-integration/csharp/external-base-family/case.json b/gitnexus/test/fixtures/rename-integration/csharp/external-base-family/case.json new file mode 100644 index 000000000..2719962c4 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/csharp/external-base-family/case.json @@ -0,0 +1,22 @@ +{ + "language": "csharp", + "description": "Refuse a potentially polymorphic member whose external base is not indexed.", + "categories": [ + "polymorphism", + "external-base", + "negative", + "unchanged-on-error" + ], + "target": { + "name": "target", + "kind": "Method", + "file": "Worker.cs", + "startLine": 3 + }, + "newName": "renamed", + "expect": { + "status": "error", + "code": "unsupported_symbol_family", + "planningStatus": "unsupported" + } +} diff --git a/gitnexus/test/fixtures/rename-integration/csharp/implicit-external-base-family/after/Worker.cs b/gitnexus/test/fixtures/rename-integration/csharp/implicit-external-base-family/after/Worker.cs new file mode 100644 index 000000000..9502b6ecc --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/csharp/implicit-external-base-family/after/Worker.cs @@ -0,0 +1,3 @@ +class Worker { + public override string ToString() { return "worker"; } +} diff --git a/gitnexus/test/fixtures/rename-integration/csharp/implicit-external-base-family/after/notes.txt b/gitnexus/test/fixtures/rename-integration/csharp/implicit-external-base-family/after/notes.txt new file mode 100644 index 000000000..41df97c4b --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/csharp/implicit-external-base-family/after/notes.txt @@ -0,0 +1 @@ +An explicit override still requires its external base member. diff --git a/gitnexus/test/fixtures/rename-integration/csharp/implicit-external-base-family/before/Worker.cs b/gitnexus/test/fixtures/rename-integration/csharp/implicit-external-base-family/before/Worker.cs new file mode 100644 index 000000000..9502b6ecc --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/csharp/implicit-external-base-family/before/Worker.cs @@ -0,0 +1,3 @@ +class Worker { + public override string ToString() { return "worker"; } +} diff --git a/gitnexus/test/fixtures/rename-integration/csharp/implicit-external-base-family/before/notes.txt b/gitnexus/test/fixtures/rename-integration/csharp/implicit-external-base-family/before/notes.txt new file mode 100644 index 000000000..41df97c4b --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/csharp/implicit-external-base-family/before/notes.txt @@ -0,0 +1 @@ +An explicit override still requires its external base member. diff --git a/gitnexus/test/fixtures/rename-integration/csharp/implicit-external-base-family/case.json b/gitnexus/test/fixtures/rename-integration/csharp/implicit-external-base-family/case.json new file mode 100644 index 000000000..97b640ba7 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/csharp/implicit-external-base-family/case.json @@ -0,0 +1,23 @@ +{ + "language": "csharp", + "description": "Refuse an explicit override of an unindexed implicit base member.", + "categories": [ + "polymorphism", + "external-base", + "implicit-base", + "negative", + "unchanged-on-error" + ], + "target": { + "name": "ToString", + "kind": "Method", + "file": "Worker.cs", + "startLine": 1 + }, + "newName": "description", + "expect": { + "status": "error", + "code": "unsupported_symbol_family", + "planningStatus": "unsupported" + } +} diff --git a/gitnexus/test/fixtures/rename-integration/csharp/named-argument-binds-parameter/after/Counter.cs b/gitnexus/test/fixtures/rename-integration/csharp/named-argument-binds-parameter/after/Counter.cs new file mode 100644 index 000000000..9cbefadc8 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/csharp/named-argument-binds-parameter/after/Counter.cs @@ -0,0 +1,10 @@ +public class Counter +{ + public int Total { get; set; } + public Counter(int Count) { this.Total = Count; } +} + +public class Maker +{ + public Counter Make() { return new Counter(Count: 1); } +} diff --git a/gitnexus/test/fixtures/rename-integration/csharp/named-argument-binds-parameter/before/Counter.cs b/gitnexus/test/fixtures/rename-integration/csharp/named-argument-binds-parameter/before/Counter.cs new file mode 100644 index 000000000..d89a3ca81 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/csharp/named-argument-binds-parameter/before/Counter.cs @@ -0,0 +1,10 @@ +public class Counter +{ + public int Count { get; set; } + public Counter(int Count) { this.Count = Count; } +} + +public class Maker +{ + public Counter Make() { return new Counter(Count: 1); } +} diff --git a/gitnexus/test/fixtures/rename-integration/csharp/named-argument-binds-parameter/case.json b/gitnexus/test/fixtures/rename-integration/csharp/named-argument-binds-parameter/case.json new file mode 100644 index 000000000..1e86a29f1 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/csharp/named-argument-binds-parameter/case.json @@ -0,0 +1,19 @@ +{ + "language": "csharp", + "description": "A C# named argument binds the constructor parameter, so renaming the property it initializes leaves the label unchanged.", + "categories": [ + "named-argument", + "unrelated-name" + ], + "target": { + "name": "Count", + "kind": "Property", + "file": "Counter.cs", + "startLine": 2 + }, + "newName": "Total", + "expect": { + "status": "success", + "edits": 2 + } +} diff --git a/gitnexus/test/fixtures/rename-integration/csharp/polymorphic-delivery/after/Delivery.cs b/gitnexus/test/fixtures/rename-integration/csharp/polymorphic-delivery/after/Delivery.cs new file mode 100644 index 000000000..ac28f6673 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/csharp/polymorphic-delivery/after/Delivery.cs @@ -0,0 +1,21 @@ +interface ICourier { + void Deliver(); +} +class StandardCourier : ICourier { + public virtual void Deliver() {} +} +class ExpressCourier : StandardCourier { + public override void Deliver() { base.Deliver(); } +} +class Delivery { + public void Send(ICourier contract, StandardCourier standard, ExpressCourier express) { + contract.Deliver(); + standard.Deliver(); + express.Deliver(); + } + string note = "Dispatch"; // Dispatch remains documentation +} +class UnrelatedCourier { + public void Dispatch() {} + public void Use() { Dispatch(); } +} diff --git a/gitnexus/test/fixtures/rename-integration/csharp/polymorphic-delivery/before/Delivery.cs b/gitnexus/test/fixtures/rename-integration/csharp/polymorphic-delivery/before/Delivery.cs new file mode 100644 index 000000000..35942b441 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/csharp/polymorphic-delivery/before/Delivery.cs @@ -0,0 +1,21 @@ +interface ICourier { + void Dispatch(); +} +class StandardCourier : ICourier { + public virtual void Dispatch() {} +} +class ExpressCourier : StandardCourier { + public override void Dispatch() { base.Dispatch(); } +} +class Delivery { + public void Send(ICourier contract, StandardCourier standard, ExpressCourier express) { + contract.Dispatch(); + standard.Dispatch(); + express.Dispatch(); + } + string note = "Dispatch"; // Dispatch remains documentation +} +class UnrelatedCourier { + public void Dispatch() {} + public void Use() { Dispatch(); } +} diff --git a/gitnexus/test/fixtures/rename-integration/csharp/polymorphic-delivery/case.json b/gitnexus/test/fixtures/rename-integration/csharp/polymorphic-delivery/case.json new file mode 100644 index 000000000..f0dbb90e1 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/csharp/polymorphic-delivery/case.json @@ -0,0 +1,68 @@ +{ + "language": "csharp", + "description": "Rename a C# interface family through virtual overrides, base calls and three statically typed dispatch paths.", + "categories": [ + "polymorphism", + "call-graph", + "unrelated-owner", + "comments-and-strings" + ], + "target": { + "name": "Dispatch", + "kind": "Method", + "file": "Delivery.cs", + "startLine": 1 + }, + "newName": "Deliver", + "expect": { + "status": "success", + "edits": 7 + }, + "edges": [ + { + "type": "METHOD_IMPLEMENTS", + "from": { + "name": "Dispatch", + "kind": "Method", + "file": "Delivery.cs", + "startLine": 4 + }, + "to": { + "name": "Dispatch", + "kind": "Method", + "file": "Delivery.cs", + "startLine": 1 + } + }, + { + "type": "METHOD_OVERRIDES", + "from": { + "name": "ExpressCourier", + "kind": "Class", + "file": "Delivery.cs", + "startLine": 6 + }, + "to": { + "name": "Dispatch", + "kind": "Method", + "file": "Delivery.cs", + "startLine": 4 + } + }, + { + "type": "CALLS", + "from": { + "name": "Send", + "kind": "Method", + "file": "Delivery.cs", + "startLine": 10 + }, + "to": { + "name": "Dispatch", + "kind": "Method", + "file": "Delivery.cs", + "startLine": 1 + } + } + ] +} diff --git a/gitnexus/test/fixtures/rename-integration/csharp/positional-record-property-label/after/Pair.cs b/gitnexus/test/fixtures/rename-integration/csharp/positional-record-property-label/after/Pair.cs new file mode 100644 index 000000000..41adfc654 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/csharp/positional-record-property-label/after/Pair.cs @@ -0,0 +1,9 @@ +public record Pair(int First) +{ + public int First { get; init; } = First; +} + +public class Maker +{ + public Pair Make() { return new Pair(First: 1); } +} diff --git a/gitnexus/test/fixtures/rename-integration/csharp/positional-record-property-label/before/Pair.cs b/gitnexus/test/fixtures/rename-integration/csharp/positional-record-property-label/before/Pair.cs new file mode 100644 index 000000000..41adfc654 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/csharp/positional-record-property-label/before/Pair.cs @@ -0,0 +1,9 @@ +public record Pair(int First) +{ + public int First { get; init; } = First; +} + +public class Maker +{ + public Pair Make() { return new Pair(First: 1); } +} diff --git a/gitnexus/test/fixtures/rename-integration/csharp/positional-record-property-label/case.json b/gitnexus/test/fixtures/rename-integration/csharp/positional-record-property-label/case.json new file mode 100644 index 000000000..4a827b4e1 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/csharp/positional-record-property-label/case.json @@ -0,0 +1,22 @@ +{ + "language": "csharp", + "description": "Refuse a positional record property that its named argument label and parameter also spell.", + "categories": [ + "named-argument", + "positional-record", + "negative", + "unchanged-on-error" + ], + "target": { + "name": "First", + "kind": "Property", + "file": "Pair.cs", + "startLine": 2 + }, + "newName": "Head", + "expect": { + "status": "error", + "code": "incomplete_semantics", + "planningStatus": "blocked" + } +} diff --git a/gitnexus/test/fixtures/rename-integration/csharp/public-field-chain/after/Profile.cs b/gitnexus/test/fixtures/rename-integration/csharp/public-field-chain/after/Profile.cs new file mode 100644 index 000000000..054968136 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/csharp/public-field-chain/after/Profile.cs @@ -0,0 +1,19 @@ +class Profile { + public string DisplayBadge = "Badge"; +} +class Account { + public Profile Profile; +} +class Session { + public Account Account; +} +class Dashboard { + public string Read(Session session) { return session.Account.Profile.DisplayBadge; } + public void Update(Session session, string Badge) { session.Account.Profile.DisplayBadge = Badge; } + public string Direct(Profile profile) { return profile.DisplayBadge; } + // Badge is a public field, not this comment +} +class OtherProfile { + public string Badge = "Badge"; + public string Read(OtherProfile other) { return other.Badge; } +} diff --git a/gitnexus/test/fixtures/rename-integration/csharp/public-field-chain/before/Profile.cs b/gitnexus/test/fixtures/rename-integration/csharp/public-field-chain/before/Profile.cs new file mode 100644 index 000000000..1962fb08f --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/csharp/public-field-chain/before/Profile.cs @@ -0,0 +1,19 @@ +class Profile { + public string Badge = "Badge"; +} +class Account { + public Profile Profile; +} +class Session { + public Account Account; +} +class Dashboard { + public string Read(Session session) { return session.Account.Profile.Badge; } + public void Update(Session session, string Badge) { session.Account.Profile.Badge = Badge; } + public string Direct(Profile profile) { return profile.Badge; } + // Badge is a public field, not this comment +} +class OtherProfile { + public string Badge = "Badge"; + public string Read(OtherProfile other) { return other.Badge; } +} diff --git a/gitnexus/test/fixtures/rename-integration/csharp/public-field-chain/case.json b/gitnexus/test/fixtures/rename-integration/csharp/public-field-chain/case.json new file mode 100644 index 000000000..2232a5f0f --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/csharp/public-field-chain/case.json @@ -0,0 +1,53 @@ +{ + "language": "csharp", + "description": "Rename a C# public field through three-hop field receivers and distinguish the write from its same-name parameter.", + "categories": [ + "public-field", + "deep-chain", + "shadowing", + "unrelated-owner" + ], + "target": { + "name": "Badge", + "kind": "Property", + "file": "Profile.cs", + "startLine": 1 + }, + "newName": "DisplayBadge", + "expect": { + "status": "success", + "edits": 4 + }, + "edges": [ + { + "type": "ACCESSES", + "from": { + "name": "Read", + "kind": "Method", + "file": "Profile.cs", + "startLine": 10 + }, + "to": { + "name": "Badge", + "kind": "Property", + "file": "Profile.cs", + "startLine": 1 + } + }, + { + "type": "ACCESSES", + "from": { + "name": "Update", + "kind": "Method", + "file": "Profile.cs", + "startLine": 11 + }, + "to": { + "name": "Badge", + "kind": "Property", + "file": "Profile.cs", + "startLine": 1 + } + } + ] +} diff --git a/gitnexus/test/fixtures/rename-integration/dart/builtin-constructor-import/after/payload.dart b/gitnexus/test/fixtures/rename-integration/dart/builtin-constructor-import/after/payload.dart new file mode 100644 index 000000000..66c9f47f2 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/dart/builtin-constructor-import/after/payload.dart @@ -0,0 +1,6 @@ +class Payload { + Payload(); + int size = 0; +} +Payload make() => Payload(); +String label = 'Payload'; diff --git a/gitnexus/test/fixtures/rename-integration/dart/builtin-constructor-import/before/payload.dart b/gitnexus/test/fixtures/rename-integration/dart/builtin-constructor-import/before/payload.dart new file mode 100644 index 000000000..66c9f47f2 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/dart/builtin-constructor-import/before/payload.dart @@ -0,0 +1,6 @@ +class Payload { + Payload(); + int size = 0; +} +Payload make() => Payload(); +String label = 'Payload'; diff --git a/gitnexus/test/fixtures/rename-integration/dart/builtin-constructor-import/case.json b/gitnexus/test/fixtures/rename-integration/dart/builtin-constructor-import/case.json new file mode 100644 index 000000000..c5a96ce56 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/dart/builtin-constructor-import/case.json @@ -0,0 +1,21 @@ +{ + "language": "dart", + "description": "Refuse a built-in type name when renaming the default constructor and its owning class family.", + "categories": [ + "identifier-rules", + "reserved-words", + "declaration-family" + ], + "target": { + "name": "Payload", + "kind": "Constructor", + "file": "payload.dart", + "startLine": 1 + }, + "newName": "import", + "expect": { + "status": "error", + "code": "invalid_name", + "planningStatus": "blocked" + } +} diff --git a/gitnexus/test/fixtures/rename-integration/dart/builtin-field-import/after/report.dart b/gitnexus/test/fixtures/rename-integration/dart/builtin-field-import/after/report.dart new file mode 100644 index 000000000..53cc7d59b --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/dart/builtin-field-import/after/report.dart @@ -0,0 +1,6 @@ +class Report { + int import = 0; +} +int read(Report report) => report.import; +void write(Report report, int value) { report.import = value; } +String label = 'value'; diff --git a/gitnexus/test/fixtures/rename-integration/dart/builtin-field-import/before/report.dart b/gitnexus/test/fixtures/rename-integration/dart/builtin-field-import/before/report.dart new file mode 100644 index 000000000..a34b8fe52 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/dart/builtin-field-import/before/report.dart @@ -0,0 +1,6 @@ +class Report { + int value = 0; +} +int read(Report report) => report.value; +void write(Report report, int value) { report.value = value; } +String label = 'value'; diff --git a/gitnexus/test/fixtures/rename-integration/dart/builtin-field-import/case.json b/gitnexus/test/fixtures/rename-integration/dart/builtin-field-import/case.json new file mode 100644 index 000000000..d7f9b9711 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/dart/builtin-field-import/case.json @@ -0,0 +1,19 @@ +{ + "language": "dart", + "description": "Allow import as a public field name across reads and writes while preserving a shadow parameter.", + "categories": [ + "identifier-rules", + "contextual-identifiers" + ], + "target": { + "name": "value", + "kind": "Property", + "file": "report.dart", + "startLine": 1 + }, + "newName": "import", + "expect": { + "status": "success", + "edits": 3 + } +} diff --git a/gitnexus/test/fixtures/rename-integration/dart/builtin-method-abstract/after/encoder.dart b/gitnexus/test/fixtures/rename-integration/dart/builtin-method-abstract/after/encoder.dart new file mode 100644 index 000000000..2d20a1fea --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/dart/builtin-method-abstract/after/encoder.dart @@ -0,0 +1,6 @@ +class Encoder { + int abstract(int value) => value + 1; +} +int run(Encoder encoder) => encoder.abstract(3); +String label = 'encode'; +// encode stays in this comment. diff --git a/gitnexus/test/fixtures/rename-integration/dart/builtin-method-abstract/before/encoder.dart b/gitnexus/test/fixtures/rename-integration/dart/builtin-method-abstract/before/encoder.dart new file mode 100644 index 000000000..be33917bf --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/dart/builtin-method-abstract/before/encoder.dart @@ -0,0 +1,6 @@ +class Encoder { + int encode(int value) => value + 1; +} +int run(Encoder encoder) => encoder.encode(3); +String label = 'encode'; +// encode stays in this comment. diff --git a/gitnexus/test/fixtures/rename-integration/dart/builtin-method-abstract/case.json b/gitnexus/test/fixtures/rename-integration/dart/builtin-method-abstract/case.json new file mode 100644 index 000000000..a67cbf990 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/dart/builtin-method-abstract/case.json @@ -0,0 +1,19 @@ +{ + "language": "dart", + "description": "Allow the built-in identifier abstract as a method name through a typed receiver call.", + "categories": [ + "identifier-rules", + "contextual-identifiers" + ], + "target": { + "name": "encode", + "kind": "Method", + "file": "encoder.dart", + "startLine": 1 + }, + "newName": "abstract", + "expect": { + "status": "success", + "edits": 2 + } +} diff --git a/gitnexus/test/fixtures/rename-integration/dart/builtin-method-import/after/encoder.dart b/gitnexus/test/fixtures/rename-integration/dart/builtin-method-import/after/encoder.dart new file mode 100644 index 000000000..dff14aeb8 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/dart/builtin-method-import/after/encoder.dart @@ -0,0 +1,6 @@ +class Encoder { + int import(int value) => value + 1; +} +int run(Encoder encoder) => encoder.import(3); +String label = 'encode'; +// encode stays in this comment. diff --git a/gitnexus/test/fixtures/rename-integration/dart/builtin-method-import/before/encoder.dart b/gitnexus/test/fixtures/rename-integration/dart/builtin-method-import/before/encoder.dart new file mode 100644 index 000000000..be33917bf --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/dart/builtin-method-import/before/encoder.dart @@ -0,0 +1,6 @@ +class Encoder { + int encode(int value) => value + 1; +} +int run(Encoder encoder) => encoder.encode(3); +String label = 'encode'; +// encode stays in this comment. diff --git a/gitnexus/test/fixtures/rename-integration/dart/builtin-method-import/case.json b/gitnexus/test/fixtures/rename-integration/dart/builtin-method-import/case.json new file mode 100644 index 000000000..3b75e84cf --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/dart/builtin-method-import/case.json @@ -0,0 +1,19 @@ +{ + "language": "dart", + "description": "Allow the built-in identifier import as a method name through a typed receiver call.", + "categories": [ + "identifier-rules", + "contextual-identifiers" + ], + "target": { + "name": "encode", + "kind": "Method", + "file": "encoder.dart", + "startLine": 1 + }, + "newName": "import", + "expect": { + "status": "success", + "edits": 2 + } +} diff --git a/gitnexus/test/fixtures/rename-integration/dart/builtin-type-abstract/after/payload.dart b/gitnexus/test/fixtures/rename-integration/dart/builtin-type-abstract/after/payload.dart new file mode 100644 index 000000000..8d232933e --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/dart/builtin-type-abstract/after/payload.dart @@ -0,0 +1,6 @@ +class Payload { + int size = 0; +} +Payload preserve(Payload value) => value; +String label = 'Payload'; +// Payload remains a type throughout the rejected rename. diff --git a/gitnexus/test/fixtures/rename-integration/dart/builtin-type-abstract/before/payload.dart b/gitnexus/test/fixtures/rename-integration/dart/builtin-type-abstract/before/payload.dart new file mode 100644 index 000000000..8d232933e --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/dart/builtin-type-abstract/before/payload.dart @@ -0,0 +1,6 @@ +class Payload { + int size = 0; +} +Payload preserve(Payload value) => value; +String label = 'Payload'; +// Payload remains a type throughout the rejected rename. diff --git a/gitnexus/test/fixtures/rename-integration/dart/builtin-type-abstract/case.json b/gitnexus/test/fixtures/rename-integration/dart/builtin-type-abstract/case.json new file mode 100644 index 000000000..faa9b4feb --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/dart/builtin-type-abstract/case.json @@ -0,0 +1,20 @@ +{ + "language": "dart", + "description": "Refuse the built-in identifier abstract as a Dart type name while preserving source bytes.", + "categories": [ + "identifier-rules", + "reserved-words" + ], + "target": { + "name": "Payload", + "kind": "Class", + "file": "payload.dart", + "startLine": 0 + }, + "newName": "abstract", + "expect": { + "status": "error", + "code": "invalid_name", + "planningStatus": "blocked" + } +} diff --git a/gitnexus/test/fixtures/rename-integration/dart/builtin-type-import/after/payload.dart b/gitnexus/test/fixtures/rename-integration/dart/builtin-type-import/after/payload.dart new file mode 100644 index 000000000..8d232933e --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/dart/builtin-type-import/after/payload.dart @@ -0,0 +1,6 @@ +class Payload { + int size = 0; +} +Payload preserve(Payload value) => value; +String label = 'Payload'; +// Payload remains a type throughout the rejected rename. diff --git a/gitnexus/test/fixtures/rename-integration/dart/builtin-type-import/before/payload.dart b/gitnexus/test/fixtures/rename-integration/dart/builtin-type-import/before/payload.dart new file mode 100644 index 000000000..8d232933e --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/dart/builtin-type-import/before/payload.dart @@ -0,0 +1,6 @@ +class Payload { + int size = 0; +} +Payload preserve(Payload value) => value; +String label = 'Payload'; +// Payload remains a type throughout the rejected rename. diff --git a/gitnexus/test/fixtures/rename-integration/dart/builtin-type-import/case.json b/gitnexus/test/fixtures/rename-integration/dart/builtin-type-import/case.json new file mode 100644 index 000000000..2aec590ff --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/dart/builtin-type-import/case.json @@ -0,0 +1,20 @@ +{ + "language": "dart", + "description": "Refuse the built-in identifier import as a Dart type name while preserving source bytes.", + "categories": [ + "identifier-rules", + "reserved-words" + ], + "target": { + "name": "Payload", + "kind": "Class", + "file": "payload.dart", + "startLine": 0 + }, + "newName": "import", + "expect": { + "status": "error", + "code": "invalid_name", + "planningStatus": "blocked" + } +} diff --git a/gitnexus/test/fixtures/rename-integration/dart/contextual-await-async-star/after/model.dart b/gitnexus/test/fixtures/rename-integration/dart/contextual-await-async-star/after/model.dart new file mode 100644 index 000000000..a7d7b343d --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/dart/contextual-await-async-star/after/model.dart @@ -0,0 +1,2 @@ +int target() => 1; +Stream caller() async* { yield target(); } diff --git a/gitnexus/test/fixtures/rename-integration/dart/contextual-await-async-star/before/model.dart b/gitnexus/test/fixtures/rename-integration/dart/contextual-await-async-star/before/model.dart new file mode 100644 index 000000000..a7d7b343d --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/dart/contextual-await-async-star/before/model.dart @@ -0,0 +1,2 @@ +int target() => 1; +Stream caller() async* { yield target(); } diff --git a/gitnexus/test/fixtures/rename-integration/dart/contextual-await-async-star/case.json b/gitnexus/test/fixtures/rename-integration/dart/contextual-await-async-star/case.json new file mode 100644 index 000000000..3b6c8bbbb --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/dart/contextual-await-async-star/case.json @@ -0,0 +1,20 @@ +{ + "language": "dart", + "description": "Refuse contextual or reserved identifier await without modifying sources.", + "categories": [ + "identifier-rules", + "reserved-words" + ], + "target": { + "name": "target", + "kind": "Function", + "file": "model.dart", + "startLine": 0 + }, + "newName": "await", + "expect": { + "status": "error", + "code": "invalid_name", + "planningStatus": "blocked" + } +} diff --git a/gitnexus/test/fixtures/rename-integration/dart/contextual-await-async/after/model.dart b/gitnexus/test/fixtures/rename-integration/dart/contextual-await-async/after/model.dart new file mode 100644 index 000000000..74ebdec02 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/dart/contextual-await-async/after/model.dart @@ -0,0 +1,2 @@ +int target() => 1; +Future caller() async { return target(); } diff --git a/gitnexus/test/fixtures/rename-integration/dart/contextual-await-async/before/model.dart b/gitnexus/test/fixtures/rename-integration/dart/contextual-await-async/before/model.dart new file mode 100644 index 000000000..74ebdec02 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/dart/contextual-await-async/before/model.dart @@ -0,0 +1,2 @@ +int target() => 1; +Future caller() async { return target(); } diff --git a/gitnexus/test/fixtures/rename-integration/dart/contextual-await-async/case.json b/gitnexus/test/fixtures/rename-integration/dart/contextual-await-async/case.json new file mode 100644 index 000000000..3b6c8bbbb --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/dart/contextual-await-async/case.json @@ -0,0 +1,20 @@ +{ + "language": "dart", + "description": "Refuse contextual or reserved identifier await without modifying sources.", + "categories": [ + "identifier-rules", + "reserved-words" + ], + "target": { + "name": "target", + "kind": "Function", + "file": "model.dart", + "startLine": 0 + }, + "newName": "await", + "expect": { + "status": "error", + "code": "invalid_name", + "planningStatus": "blocked" + } +} diff --git a/gitnexus/test/fixtures/rename-integration/dart/contextual-await-sync-star/after/model.dart b/gitnexus/test/fixtures/rename-integration/dart/contextual-await-sync-star/after/model.dart new file mode 100644 index 000000000..e77cc2e61 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/dart/contextual-await-sync-star/after/model.dart @@ -0,0 +1,2 @@ +int target() => 1; +Iterable caller() sync* { yield target(); } diff --git a/gitnexus/test/fixtures/rename-integration/dart/contextual-await-sync-star/before/model.dart b/gitnexus/test/fixtures/rename-integration/dart/contextual-await-sync-star/before/model.dart new file mode 100644 index 000000000..e77cc2e61 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/dart/contextual-await-sync-star/before/model.dart @@ -0,0 +1,2 @@ +int target() => 1; +Iterable caller() sync* { yield target(); } diff --git a/gitnexus/test/fixtures/rename-integration/dart/contextual-await-sync-star/case.json b/gitnexus/test/fixtures/rename-integration/dart/contextual-await-sync-star/case.json new file mode 100644 index 000000000..3b6c8bbbb --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/dart/contextual-await-sync-star/case.json @@ -0,0 +1,20 @@ +{ + "language": "dart", + "description": "Refuse contextual or reserved identifier await without modifying sources.", + "categories": [ + "identifier-rules", + "reserved-words" + ], + "target": { + "name": "target", + "kind": "Function", + "file": "model.dart", + "startLine": 0 + }, + "newName": "await", + "expect": { + "status": "error", + "code": "invalid_name", + "planningStatus": "blocked" + } +} diff --git a/gitnexus/test/fixtures/rename-integration/dart/contextual-yield-async-star/after/model.dart b/gitnexus/test/fixtures/rename-integration/dart/contextual-yield-async-star/after/model.dart new file mode 100644 index 000000000..a7d7b343d --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/dart/contextual-yield-async-star/after/model.dart @@ -0,0 +1,2 @@ +int target() => 1; +Stream caller() async* { yield target(); } diff --git a/gitnexus/test/fixtures/rename-integration/dart/contextual-yield-async-star/before/model.dart b/gitnexus/test/fixtures/rename-integration/dart/contextual-yield-async-star/before/model.dart new file mode 100644 index 000000000..a7d7b343d --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/dart/contextual-yield-async-star/before/model.dart @@ -0,0 +1,2 @@ +int target() => 1; +Stream caller() async* { yield target(); } diff --git a/gitnexus/test/fixtures/rename-integration/dart/contextual-yield-async-star/case.json b/gitnexus/test/fixtures/rename-integration/dart/contextual-yield-async-star/case.json new file mode 100644 index 000000000..cad1a3704 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/dart/contextual-yield-async-star/case.json @@ -0,0 +1,20 @@ +{ + "language": "dart", + "description": "Refuse contextual or reserved identifier yield without modifying sources.", + "categories": [ + "identifier-rules", + "reserved-words" + ], + "target": { + "name": "target", + "kind": "Function", + "file": "model.dart", + "startLine": 0 + }, + "newName": "yield", + "expect": { + "status": "error", + "code": "invalid_name", + "planningStatus": "blocked" + } +} diff --git a/gitnexus/test/fixtures/rename-integration/dart/contextual-yield-async/after/model.dart b/gitnexus/test/fixtures/rename-integration/dart/contextual-yield-async/after/model.dart new file mode 100644 index 000000000..74ebdec02 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/dart/contextual-yield-async/after/model.dart @@ -0,0 +1,2 @@ +int target() => 1; +Future caller() async { return target(); } diff --git a/gitnexus/test/fixtures/rename-integration/dart/contextual-yield-async/before/model.dart b/gitnexus/test/fixtures/rename-integration/dart/contextual-yield-async/before/model.dart new file mode 100644 index 000000000..74ebdec02 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/dart/contextual-yield-async/before/model.dart @@ -0,0 +1,2 @@ +int target() => 1; +Future caller() async { return target(); } diff --git a/gitnexus/test/fixtures/rename-integration/dart/contextual-yield-async/case.json b/gitnexus/test/fixtures/rename-integration/dart/contextual-yield-async/case.json new file mode 100644 index 000000000..cad1a3704 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/dart/contextual-yield-async/case.json @@ -0,0 +1,20 @@ +{ + "language": "dart", + "description": "Refuse contextual or reserved identifier yield without modifying sources.", + "categories": [ + "identifier-rules", + "reserved-words" + ], + "target": { + "name": "target", + "kind": "Function", + "file": "model.dart", + "startLine": 0 + }, + "newName": "yield", + "expect": { + "status": "error", + "code": "invalid_name", + "planningStatus": "blocked" + } +} diff --git a/gitnexus/test/fixtures/rename-integration/dart/contextual-yield-sync-star/after/model.dart b/gitnexus/test/fixtures/rename-integration/dart/contextual-yield-sync-star/after/model.dart new file mode 100644 index 000000000..e77cc2e61 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/dart/contextual-yield-sync-star/after/model.dart @@ -0,0 +1,2 @@ +int target() => 1; +Iterable caller() sync* { yield target(); } diff --git a/gitnexus/test/fixtures/rename-integration/dart/contextual-yield-sync-star/before/model.dart b/gitnexus/test/fixtures/rename-integration/dart/contextual-yield-sync-star/before/model.dart new file mode 100644 index 000000000..e77cc2e61 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/dart/contextual-yield-sync-star/before/model.dart @@ -0,0 +1,2 @@ +int target() => 1; +Iterable caller() sync* { yield target(); } diff --git a/gitnexus/test/fixtures/rename-integration/dart/contextual-yield-sync-star/case.json b/gitnexus/test/fixtures/rename-integration/dart/contextual-yield-sync-star/case.json new file mode 100644 index 000000000..cad1a3704 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/dart/contextual-yield-sync-star/case.json @@ -0,0 +1,20 @@ +{ + "language": "dart", + "description": "Refuse contextual or reserved identifier yield without modifying sources.", + "categories": [ + "identifier-rules", + "reserved-words" + ], + "target": { + "name": "target", + "kind": "Function", + "file": "model.dart", + "startLine": 0 + }, + "newName": "yield", + "expect": { + "status": "error", + "code": "invalid_name", + "planningStatus": "blocked" + } +} diff --git a/gitnexus/test/fixtures/rename-integration/dart/external-base-family/after/model.dart b/gitnexus/test/fixtures/rename-integration/dart/external-base-family/after/model.dart new file mode 100644 index 000000000..4acecb499 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/dart/external-base-family/after/model.dart @@ -0,0 +1,6 @@ +import 'package:vendor/external.dart'; + +class Worker extends External { + @override + int target() { return 1; } +} diff --git a/gitnexus/test/fixtures/rename-integration/dart/external-base-family/after/notes.txt b/gitnexus/test/fixtures/rename-integration/dart/external-base-family/after/notes.txt new file mode 100644 index 000000000..81db7fc3a --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/dart/external-base-family/after/notes.txt @@ -0,0 +1 @@ +External contract and target spelling remain unchanged. diff --git a/gitnexus/test/fixtures/rename-integration/dart/external-base-family/before/model.dart b/gitnexus/test/fixtures/rename-integration/dart/external-base-family/before/model.dart new file mode 100644 index 000000000..4acecb499 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/dart/external-base-family/before/model.dart @@ -0,0 +1,6 @@ +import 'package:vendor/external.dart'; + +class Worker extends External { + @override + int target() { return 1; } +} diff --git a/gitnexus/test/fixtures/rename-integration/dart/external-base-family/before/notes.txt b/gitnexus/test/fixtures/rename-integration/dart/external-base-family/before/notes.txt new file mode 100644 index 000000000..81db7fc3a --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/dart/external-base-family/before/notes.txt @@ -0,0 +1 @@ +External contract and target spelling remain unchanged. diff --git a/gitnexus/test/fixtures/rename-integration/dart/external-base-family/case.json b/gitnexus/test/fixtures/rename-integration/dart/external-base-family/case.json new file mode 100644 index 000000000..c25e0e515 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/dart/external-base-family/case.json @@ -0,0 +1,22 @@ +{ + "language": "dart", + "description": "Refuse a potentially polymorphic member whose external base is not indexed.", + "categories": [ + "polymorphism", + "external-base", + "negative", + "unchanged-on-error" + ], + "target": { + "name": "target", + "kind": "Method", + "file": "model.dart", + "startLine": 4 + }, + "newName": "renamed", + "expect": { + "status": "error", + "code": "unsupported_symbol_family", + "planningStatus": "unsupported" + } +} diff --git a/gitnexus/test/fixtures/rename-integration/dart/field-initializing-formal-label/after/point.dart b/gitnexus/test/fixtures/rename-integration/dart/field-initializing-formal-label/after/point.dart new file mode 100644 index 000000000..4d3dc7454 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/dart/field-initializing-formal-label/after/point.dart @@ -0,0 +1,8 @@ +class Point { + int x; + Point({required this.x}); +} + +void make() { + Point(x: 1); +} diff --git a/gitnexus/test/fixtures/rename-integration/dart/field-initializing-formal-label/before/point.dart b/gitnexus/test/fixtures/rename-integration/dart/field-initializing-formal-label/before/point.dart new file mode 100644 index 000000000..4d3dc7454 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/dart/field-initializing-formal-label/before/point.dart @@ -0,0 +1,8 @@ +class Point { + int x; + Point({required this.x}); +} + +void make() { + Point(x: 1); +} diff --git a/gitnexus/test/fixtures/rename-integration/dart/field-initializing-formal-label/case.json b/gitnexus/test/fixtures/rename-integration/dart/field-initializing-formal-label/case.json new file mode 100644 index 000000000..95261dda6 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/dart/field-initializing-formal-label/case.json @@ -0,0 +1,22 @@ +{ + "language": "dart", + "description": "Refuse a field whose named constructor argument label binds its initializing formal parameter.", + "categories": [ + "named-argument", + "initializing-formal", + "negative", + "unchanged-on-error" + ], + "target": { + "name": "x", + "kind": "Property", + "file": "point.dart", + "startLine": 1 + }, + "newName": "px", + "expect": { + "status": "error", + "code": "name_collision", + "planningStatus": "blocked" + } +} diff --git a/gitnexus/test/fixtures/rename-integration/dart/implicit-external-base-family/after/model.dart b/gitnexus/test/fixtures/rename-integration/dart/implicit-external-base-family/after/model.dart new file mode 100644 index 000000000..2e6493b5d --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/dart/implicit-external-base-family/after/model.dart @@ -0,0 +1,4 @@ +class Worker { + @override + String toString() { return "worker"; } +} diff --git a/gitnexus/test/fixtures/rename-integration/dart/implicit-external-base-family/after/notes.txt b/gitnexus/test/fixtures/rename-integration/dart/implicit-external-base-family/after/notes.txt new file mode 100644 index 000000000..41df97c4b --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/dart/implicit-external-base-family/after/notes.txt @@ -0,0 +1 @@ +An explicit override still requires its external base member. diff --git a/gitnexus/test/fixtures/rename-integration/dart/implicit-external-base-family/before/model.dart b/gitnexus/test/fixtures/rename-integration/dart/implicit-external-base-family/before/model.dart new file mode 100644 index 000000000..2e6493b5d --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/dart/implicit-external-base-family/before/model.dart @@ -0,0 +1,4 @@ +class Worker { + @override + String toString() { return "worker"; } +} diff --git a/gitnexus/test/fixtures/rename-integration/dart/implicit-external-base-family/before/notes.txt b/gitnexus/test/fixtures/rename-integration/dart/implicit-external-base-family/before/notes.txt new file mode 100644 index 000000000..41df97c4b --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/dart/implicit-external-base-family/before/notes.txt @@ -0,0 +1 @@ +An explicit override still requires its external base member. diff --git a/gitnexus/test/fixtures/rename-integration/dart/implicit-external-base-family/case.json b/gitnexus/test/fixtures/rename-integration/dart/implicit-external-base-family/case.json new file mode 100644 index 000000000..cbb85fbcf --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/dart/implicit-external-base-family/case.json @@ -0,0 +1,23 @@ +{ + "language": "dart", + "description": "Refuse an explicit override of an unindexed implicit base member.", + "categories": [ + "polymorphism", + "external-base", + "implicit-base", + "negative", + "unchanged-on-error" + ], + "target": { + "name": "toString", + "kind": "Method", + "file": "model.dart", + "startLine": 2 + }, + "newName": "description", + "expect": { + "status": "error", + "code": "unsupported_symbol_family", + "planningStatus": "unsupported" + } +} diff --git a/gitnexus/test/fixtures/rename-integration/dart/polymorphic-delivery/after/delivery.dart b/gitnexus/test/fixtures/rename-integration/dart/polymorphic-delivery/after/delivery.dart new file mode 100644 index 000000000..4cfec5d3d --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/dart/polymorphic-delivery/after/delivery.dart @@ -0,0 +1,20 @@ +abstract class Courier { + void deliver(); +} +class StandardCourier implements Courier { + void deliver() {} +} +class ExpressCourier extends StandardCourier { + @override + void deliver() { super.deliver(); } +} +void send(Courier contract, StandardCourier standard, ExpressCourier express) { + contract.deliver(); + standard.deliver(); + express.deliver(); +} +class UnrelatedCourier { + void dispatch() {} + void use() { dispatch(); } +} +String note = "dispatch"; // dispatch remains documentation diff --git a/gitnexus/test/fixtures/rename-integration/dart/polymorphic-delivery/before/delivery.dart b/gitnexus/test/fixtures/rename-integration/dart/polymorphic-delivery/before/delivery.dart new file mode 100644 index 000000000..678d73a7c --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/dart/polymorphic-delivery/before/delivery.dart @@ -0,0 +1,20 @@ +abstract class Courier { + void dispatch(); +} +class StandardCourier implements Courier { + void dispatch() {} +} +class ExpressCourier extends StandardCourier { + @override + void dispatch() { super.dispatch(); } +} +void send(Courier contract, StandardCourier standard, ExpressCourier express) { + contract.dispatch(); + standard.dispatch(); + express.dispatch(); +} +class UnrelatedCourier { + void dispatch() {} + void use() { dispatch(); } +} +String note = "dispatch"; // dispatch remains documentation diff --git a/gitnexus/test/fixtures/rename-integration/dart/polymorphic-delivery/case.json b/gitnexus/test/fixtures/rename-integration/dart/polymorphic-delivery/case.json new file mode 100644 index 000000000..285bd8c30 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/dart/polymorphic-delivery/case.json @@ -0,0 +1,83 @@ +{ + "language": "dart", + "description": "Rename an abstract Dart contract through implementation, overriding method, super call and typed dispatch.", + "categories": [ + "polymorphism", + "call-graph", + "unrelated-owner", + "comments-and-strings" + ], + "target": { + "name": "dispatch", + "kind": "Method", + "file": "delivery.dart", + "startLine": 1 + }, + "newName": "deliver", + "expect": { + "status": "success", + "edits": 7 + }, + "edges": [ + { + "type": "METHOD_IMPLEMENTS", + "from": { + "name": "dispatch", + "kind": "Method", + "file": "delivery.dart", + "startLine": 4 + }, + "to": { + "name": "dispatch", + "kind": "Method", + "file": "delivery.dart", + "startLine": 1 + } + }, + { + "type": "METHOD_OVERRIDES", + "from": { + "name": "ExpressCourier", + "kind": "Class", + "file": "delivery.dart", + "startLine": 6 + }, + "to": { + "name": "dispatch", + "kind": "Method", + "file": "delivery.dart", + "startLine": 4 + } + }, + { + "type": "CALLS", + "from": { + "name": "send", + "kind": "Function", + "file": "delivery.dart", + "startLine": 10 + }, + "to": { + "name": "dispatch", + "kind": "Method", + "file": "delivery.dart", + "startLine": 1 + } + }, + { + "type": "CALLS", + "from": { + "name": "dispatch", + "kind": "Method", + "file": "delivery.dart", + "startLine": 8 + }, + "to": { + "name": "dispatch", + "kind": "Method", + "file": "delivery.dart", + "startLine": 4 + } + } + ] +} diff --git a/gitnexus/test/fixtures/rename-integration/dart/prefixed-call-graph/after/client.dart b/gitnexus/test/fixtures/rename-integration/dart/prefixed-call-graph/after/client.dart new file mode 100644 index 000000000..1f4c9046a --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/dart/prefixed-call-graph/after/client.dart @@ -0,0 +1,6 @@ +import 'encoder.dart' as codec; +String render() { + var callback = codec.serialize; + return codec.serialize() + callback(); +} +// codec.encode is documentation diff --git a/gitnexus/test/fixtures/rename-integration/dart/prefixed-call-graph/after/encoder.dart b/gitnexus/test/fixtures/rename-integration/dart/prefixed-call-graph/after/encoder.dart new file mode 100644 index 000000000..402b73578 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/dart/prefixed-call-graph/after/encoder.dart @@ -0,0 +1,8 @@ +String serialize() { return "encode"; } +String middle() { + var callback = serialize; + return callback() + serialize(); +} +String entry() { return middle(); } +String local(String encode) { return encode; } +// encode remains documentation diff --git a/gitnexus/test/fixtures/rename-integration/dart/prefixed-call-graph/after/other.dart b/gitnexus/test/fixtures/rename-integration/dart/prefixed-call-graph/after/other.dart new file mode 100644 index 000000000..a4048c7d2 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/dart/prefixed-call-graph/after/other.dart @@ -0,0 +1,4 @@ +class OtherEncoder { + String encode() { return "other"; } + String use() { return encode(); } +} diff --git a/gitnexus/test/fixtures/rename-integration/dart/prefixed-call-graph/before/client.dart b/gitnexus/test/fixtures/rename-integration/dart/prefixed-call-graph/before/client.dart new file mode 100644 index 000000000..aba1e736d --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/dart/prefixed-call-graph/before/client.dart @@ -0,0 +1,6 @@ +import 'encoder.dart' as codec; +String render() { + var callback = codec.encode; + return codec.encode() + callback(); +} +// codec.encode is documentation diff --git a/gitnexus/test/fixtures/rename-integration/dart/prefixed-call-graph/before/encoder.dart b/gitnexus/test/fixtures/rename-integration/dart/prefixed-call-graph/before/encoder.dart new file mode 100644 index 000000000..e2feaec08 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/dart/prefixed-call-graph/before/encoder.dart @@ -0,0 +1,8 @@ +String encode() { return "encode"; } +String middle() { + var callback = encode; + return callback() + encode(); +} +String entry() { return middle(); } +String local(String encode) { return encode; } +// encode remains documentation diff --git a/gitnexus/test/fixtures/rename-integration/dart/prefixed-call-graph/before/other.dart b/gitnexus/test/fixtures/rename-integration/dart/prefixed-call-graph/before/other.dart new file mode 100644 index 000000000..a4048c7d2 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/dart/prefixed-call-graph/before/other.dart @@ -0,0 +1,4 @@ +class OtherEncoder { + String encode() { return "other"; } + String use() { return encode(); } +} diff --git a/gitnexus/test/fixtures/rename-integration/dart/prefixed-call-graph/case.json b/gitnexus/test/fixtures/rename-integration/dart/prefixed-call-graph/case.json new file mode 100644 index 000000000..1afc000fa --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/dart/prefixed-call-graph/case.json @@ -0,0 +1,39 @@ +{ + "language": "dart", + "description": "Rename a Dart exported function through a prefixed import and tear-offs while preserving the prefix, local shadow and another class.", + "categories": [ + "import-alias", + "call-graph", + "function-value", + "shadowing", + "unrelated-owner" + ], + "target": { + "name": "encode", + "kind": "Function", + "file": "encoder.dart", + "startLine": 0 + }, + "newName": "serialize", + "expect": { + "status": "success", + "edits": 5 + }, + "edges": [ + { + "type": "CALLS", + "from": { + "name": "entry", + "kind": "Function", + "file": "encoder.dart", + "startLine": 5 + }, + "to": { + "name": "middle", + "kind": "Function", + "file": "encoder.dart", + "startLine": 1 + } + } + ] +} diff --git a/gitnexus/test/fixtures/rename-integration/dart/public-field-chain/after/profile.dart b/gitnexus/test/fixtures/rename-integration/dart/public-field-chain/after/profile.dart new file mode 100644 index 000000000..c4a2c74be --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/dart/public-field-chain/after/profile.dart @@ -0,0 +1,17 @@ +class Profile { + String displayBadge = "badge"; +} +class Account { + Profile profile = Profile(); +} +class Session { + Account account = Account(); +} +String read(Session session) { return session.account.profile.displayBadge; } +void update(Session session, String badge) { session.account.profile.displayBadge = badge; } +String direct(Profile profile) { return profile.displayBadge; } +class OtherProfile { + String badge = "badge"; + String read(OtherProfile other) { return other.badge; } +} +// badge is public, not this comment diff --git a/gitnexus/test/fixtures/rename-integration/dart/public-field-chain/before/profile.dart b/gitnexus/test/fixtures/rename-integration/dart/public-field-chain/before/profile.dart new file mode 100644 index 000000000..e0916b128 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/dart/public-field-chain/before/profile.dart @@ -0,0 +1,17 @@ +class Profile { + String badge = "badge"; +} +class Account { + Profile profile = Profile(); +} +class Session { + Account account = Account(); +} +String read(Session session) { return session.account.profile.badge; } +void update(Session session, String badge) { session.account.profile.badge = badge; } +String direct(Profile profile) { return profile.badge; } +class OtherProfile { + String badge = "badge"; + String read(OtherProfile other) { return other.badge; } +} +// badge is public, not this comment diff --git a/gitnexus/test/fixtures/rename-integration/dart/public-field-chain/case.json b/gitnexus/test/fixtures/rename-integration/dart/public-field-chain/case.json new file mode 100644 index 000000000..70299c93b --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/dart/public-field-chain/case.json @@ -0,0 +1,53 @@ +{ + "language": "dart", + "description": "Rename a Dart public field through three-hop reads and writes while preserving a shadow parameter and unrelated class.", + "categories": [ + "public-field", + "deep-chain", + "shadowing", + "unrelated-owner" + ], + "target": { + "name": "badge", + "kind": "Property", + "file": "profile.dart", + "startLine": 1 + }, + "newName": "displayBadge", + "expect": { + "status": "success", + "edits": 4 + }, + "edges": [ + { + "type": "ACCESSES", + "from": { + "name": "read", + "kind": "Function", + "file": "profile.dart", + "startLine": 9 + }, + "to": { + "name": "badge", + "kind": "Property", + "file": "profile.dart", + "startLine": 1 + } + }, + { + "type": "ACCESSES", + "from": { + "name": "update", + "kind": "Function", + "file": "profile.dart", + "startLine": 10 + }, + "to": { + "name": "badge", + "kind": "Property", + "file": "profile.dart", + "startLine": 1 + } + } + ] +} diff --git a/gitnexus/test/fixtures/rename-integration/dart/unrelated-named-label/after/point.dart b/gitnexus/test/fixtures/rename-integration/dart/unrelated-named-label/after/point.dart new file mode 100644 index 000000000..92e0a4ffb --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/dart/unrelated-named-label/after/point.dart @@ -0,0 +1,10 @@ +class Point { + int px = 0; +} + +void configure({int x = 0}) {} + +void make(Point p) { + configure(x: 1); + p.px = 2; +} diff --git a/gitnexus/test/fixtures/rename-integration/dart/unrelated-named-label/before/point.dart b/gitnexus/test/fixtures/rename-integration/dart/unrelated-named-label/before/point.dart new file mode 100644 index 000000000..272166093 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/dart/unrelated-named-label/before/point.dart @@ -0,0 +1,10 @@ +class Point { + int x = 0; +} + +void configure({int x = 0}) {} + +void make(Point p) { + configure(x: 1); + p.x = 2; +} diff --git a/gitnexus/test/fixtures/rename-integration/dart/unrelated-named-label/case.json b/gitnexus/test/fixtures/rename-integration/dart/unrelated-named-label/case.json new file mode 100644 index 000000000..d8faf4518 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/dart/unrelated-named-label/case.json @@ -0,0 +1,19 @@ +{ + "language": "dart", + "description": "Rename a field beside a named argument label that binds another function's parameter.", + "categories": [ + "named-argument", + "unrelated-name" + ], + "target": { + "name": "x", + "kind": "Property", + "file": "point.dart", + "startLine": 1 + }, + "newName": "px", + "expect": { + "status": "success", + "edits": 2 + } +} diff --git a/gitnexus/test/fixtures/rename-integration/go/c-consumer/after/caller.c b/gitnexus/test/fixtures/rename-integration/go/c-consumer/after/caller.c new file mode 100644 index 000000000..cd39a79eb --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/go/c-consumer/after/caller.c @@ -0,0 +1,3 @@ +int compute(int value); + +int use(void) { return compute(1); } diff --git a/gitnexus/test/fixtures/rename-integration/go/c-consumer/after/lib.go b/gitnexus/test/fixtures/rename-integration/go/c-consumer/after/lib.go new file mode 100644 index 000000000..80605934f --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/go/c-consumer/after/lib.go @@ -0,0 +1,10 @@ +package main + +import "C" + +//export compute +func compute(value C.int) C.int { + return value +} + +func main() {} diff --git a/gitnexus/test/fixtures/rename-integration/go/c-consumer/before/caller.c b/gitnexus/test/fixtures/rename-integration/go/c-consumer/before/caller.c new file mode 100644 index 000000000..cd39a79eb --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/go/c-consumer/before/caller.c @@ -0,0 +1,3 @@ +int compute(int value); + +int use(void) { return compute(1); } diff --git a/gitnexus/test/fixtures/rename-integration/go/c-consumer/before/lib.go b/gitnexus/test/fixtures/rename-integration/go/c-consumer/before/lib.go new file mode 100644 index 000000000..80605934f --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/go/c-consumer/before/lib.go @@ -0,0 +1,10 @@ +package main + +import "C" + +//export compute +func compute(value C.int) C.int { + return value +} + +func main() {} diff --git a/gitnexus/test/fixtures/rename-integration/go/c-consumer/case.json b/gitnexus/test/fixtures/rename-integration/go/c-consumer/case.json new file mode 100644 index 000000000..2970e1624 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/go/c-consumer/case.json @@ -0,0 +1,21 @@ +{ + "language": "go", + "description": "Refuse a Go function exported with cgo that an indexed C caller names, until the C binding is proven.", + "categories": [ + "cross-language", + "interop", + "negative", + "unchanged-on-error" + ], + "target": { + "name": "compute", + "kind": "Function", + "file": "lib.go" + }, + "newName": "calculate", + "expect": { + "status": "error", + "code": "unsupported_boundary", + "planningStatus": "unsupported" + } +} diff --git a/gitnexus/test/fixtures/rename-integration/go/export-uncased-unexported-function/after/api/api.go b/gitnexus/test/fixtures/rename-integration/go/export-uncased-unexported-function/after/api/api.go new file mode 100644 index 000000000..7b45a5075 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/go/export-uncased-unexported-function/after/api/api.go @@ -0,0 +1,5 @@ +package api + +func Ünicode(value int) int { return value + 1 } + +func Other(value int) int { return Ünicode(value) } diff --git a/gitnexus/test/fixtures/rename-integration/go/export-uncased-unexported-function/after/go.mod b/gitnexus/test/fixtures/rename-integration/go/export-uncased-unexported-function/after/go.mod new file mode 100644 index 000000000..047567b68 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/go/export-uncased-unexported-function/after/go.mod @@ -0,0 +1,3 @@ +module example.com/renamefixture + +go 1.22 diff --git a/gitnexus/test/fixtures/rename-integration/go/export-uncased-unexported-function/after/main.go b/gitnexus/test/fixtures/rename-integration/go/export-uncased-unexported-function/after/main.go new file mode 100644 index 000000000..47603f110 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/go/export-uncased-unexported-function/after/main.go @@ -0,0 +1,7 @@ +package main + +import "example.com/renamefixture/api" + +func run(value int) int { return api.Other(value) } + +func main() { _ = run(1) } diff --git a/gitnexus/test/fixtures/rename-integration/go/export-uncased-unexported-function/before/api/api.go b/gitnexus/test/fixtures/rename-integration/go/export-uncased-unexported-function/before/api/api.go new file mode 100644 index 000000000..058b5adf6 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/go/export-uncased-unexported-function/before/api/api.go @@ -0,0 +1,5 @@ +package api + +func 日本(value int) int { return value + 1 } + +func Other(value int) int { return 日本(value) } diff --git a/gitnexus/test/fixtures/rename-integration/go/export-uncased-unexported-function/before/go.mod b/gitnexus/test/fixtures/rename-integration/go/export-uncased-unexported-function/before/go.mod new file mode 100644 index 000000000..047567b68 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/go/export-uncased-unexported-function/before/go.mod @@ -0,0 +1,3 @@ +module example.com/renamefixture + +go 1.22 diff --git a/gitnexus/test/fixtures/rename-integration/go/export-uncased-unexported-function/before/main.go b/gitnexus/test/fixtures/rename-integration/go/export-uncased-unexported-function/before/main.go new file mode 100644 index 000000000..47603f110 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/go/export-uncased-unexported-function/before/main.go @@ -0,0 +1,7 @@ +package main + +import "example.com/renamefixture/api" + +func run(value int) int { return api.Other(value) } + +func main() { _ = run(1) } diff --git a/gitnexus/test/fixtures/rename-integration/go/export-uncased-unexported-function/case.json b/gitnexus/test/fixtures/rename-integration/go/export-uncased-unexported-function/case.json new file mode 100644 index 000000000..5e9cba650 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/go/export-uncased-unexported-function/case.json @@ -0,0 +1,30 @@ +{ + "language": "go", + "description": "A caseless letter (CJK) is unexported, so widening it to a Unicode uppercase initial is allowed and every in-package use follows.", + "categories": ["visibility", "unicode", "same-package"], + "target": { + "name": "日本", + "kind": "Function", + "file": "api/api.go" + }, + "newName": "Ünicode", + "expect": { + "status": "success", + "edits": 2 + }, + "edges": [ + { + "type": "CALLS", + "from": { + "name": "Other", + "kind": "Function", + "file": "api/api.go" + }, + "to": { + "name": "日本", + "kind": "Function", + "file": "api/api.go" + } + } + ] +} diff --git a/gitnexus/test/fixtures/rename-integration/go/exported-field-pointer-chain/after/go.mod b/gitnexus/test/fixtures/rename-integration/go/exported-field-pointer-chain/after/go.mod new file mode 100644 index 000000000..047567b68 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/go/exported-field-pointer-chain/after/go.mod @@ -0,0 +1,3 @@ +module example.com/renamefixture + +go 1.22 diff --git a/gitnexus/test/fixtures/rename-integration/go/exported-field-pointer-chain/after/model.go b/gitnexus/test/fixtures/rename-integration/go/exported-field-pointer-chain/after/model.go new file mode 100644 index 000000000..584f6c743 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/go/exported-field-pointer-chain/after/model.go @@ -0,0 +1,14 @@ +package model + +type Leaf struct { Amount int } +type Branch struct { Leaf *Leaf } +type Root struct { Branch *Branch } +type Other struct { Value int } +func read(root *Root, other Other) int { + return root.Branch.Leaf.Amount + other.Value +} +func write(root *Root, Value int) { + root.Branch.Leaf.Amount = Value +} +const label = "Value" +// Value on Other and the parameter must remain unchanged. diff --git a/gitnexus/test/fixtures/rename-integration/go/exported-field-pointer-chain/before/go.mod b/gitnexus/test/fixtures/rename-integration/go/exported-field-pointer-chain/before/go.mod new file mode 100644 index 000000000..047567b68 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/go/exported-field-pointer-chain/before/go.mod @@ -0,0 +1,3 @@ +module example.com/renamefixture + +go 1.22 diff --git a/gitnexus/test/fixtures/rename-integration/go/exported-field-pointer-chain/before/model.go b/gitnexus/test/fixtures/rename-integration/go/exported-field-pointer-chain/before/model.go new file mode 100644 index 000000000..71030be5c --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/go/exported-field-pointer-chain/before/model.go @@ -0,0 +1,14 @@ +package model + +type Leaf struct { Value int } +type Branch struct { Leaf *Leaf } +type Root struct { Branch *Branch } +type Other struct { Value int } +func read(root *Root, other Other) int { + return root.Branch.Leaf.Value + other.Value +} +func write(root *Root, Value int) { + root.Branch.Leaf.Value = Value +} +const label = "Value" +// Value on Other and the parameter must remain unchanged. diff --git a/gitnexus/test/fixtures/rename-integration/go/exported-field-pointer-chain/case.json b/gitnexus/test/fixtures/rename-integration/go/exported-field-pointer-chain/case.json new file mode 100644 index 000000000..5ebb701a0 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/go/exported-field-pointer-chain/case.json @@ -0,0 +1,48 @@ +{ + "language": "go", + "description": "Rename an exported field through Go implicit pointer dereferences across three typed receiver hops, preserving an unrelated field and local binding.", + "categories": ["public-field", "deep-chain", "pointer-receiver", "homonyms", "writes"], + "target": { + "name": "Value", + "kind": "Property", + "file": "model.go", + "startLine": 2 + }, + "newName": "Amount", + "expect": { + "status": "success", + "edits": 3 + }, + "edges": [ + { + "type": "ACCESSES", + "from": { + "name": "read", + "kind": "Function", + "file": "model.go", + "startLine": 6 + }, + "to": { + "name": "Value", + "kind": "Property", + "file": "model.go", + "startLine": 2 + } + }, + { + "type": "ACCESSES", + "from": { + "name": "write", + "kind": "Function", + "file": "model.go", + "startLine": 9 + }, + "to": { + "name": "Value", + "kind": "Property", + "file": "model.go", + "startLine": 2 + } + } + ] +} diff --git a/gitnexus/test/fixtures/rename-integration/go/generic-type-collision/after/go.mod b/gitnexus/test/fixtures/rename-integration/go/generic-type-collision/after/go.mod new file mode 100644 index 000000000..047567b68 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/go/generic-type-collision/after/go.mod @@ -0,0 +1,3 @@ +module example.com/renamefixture + +go 1.22 diff --git a/gitnexus/test/fixtures/rename-integration/go/generic-type-collision/after/model.go b/gitnexus/test/fixtures/rename-integration/go/generic-type-collision/after/model.go new file mode 100644 index 000000000..236d97a98 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/go/generic-type-collision/after/model.go @@ -0,0 +1,4 @@ +package model + +type Target struct { Value int } +func generic[Element any](value Target, item Element) Target { return value } diff --git a/gitnexus/test/fixtures/rename-integration/go/generic-type-collision/before/go.mod b/gitnexus/test/fixtures/rename-integration/go/generic-type-collision/before/go.mod new file mode 100644 index 000000000..047567b68 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/go/generic-type-collision/before/go.mod @@ -0,0 +1,3 @@ +module example.com/renamefixture + +go 1.22 diff --git a/gitnexus/test/fixtures/rename-integration/go/generic-type-collision/before/model.go b/gitnexus/test/fixtures/rename-integration/go/generic-type-collision/before/model.go new file mode 100644 index 000000000..236d97a98 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/go/generic-type-collision/before/model.go @@ -0,0 +1,4 @@ +package model + +type Target struct { Value int } +func generic[Element any](value Target, item Element) Target { return value } diff --git a/gitnexus/test/fixtures/rename-integration/go/generic-type-collision/case.json b/gitnexus/test/fixtures/rename-integration/go/generic-type-collision/case.json new file mode 100644 index 000000000..237aad21e --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/go/generic-type-collision/case.json @@ -0,0 +1,16 @@ +{ + "language": "go", + "description": "Reject a package type rename that would capture annotations under a function type parameter.", + "categories": ["generics", "type-shadowing", "collision-refusal"], + "target": { + "name": "Target", + "kind": "Struct", + "file": "model.go" + }, + "newName": "Element", + "expect": { + "status": "error", + "code": "name_collision", + "planningStatus": "blocked" + } +} diff --git a/gitnexus/test/fixtures/rename-integration/go/generic-type-shadow/after/go.mod b/gitnexus/test/fixtures/rename-integration/go/generic-type-shadow/after/go.mod new file mode 100644 index 000000000..047567b68 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/go/generic-type-shadow/after/go.mod @@ -0,0 +1,3 @@ +module example.com/renamefixture + +go 1.22 diff --git a/gitnexus/test/fixtures/rename-integration/go/generic-type-shadow/after/model.go b/gitnexus/test/fixtures/rename-integration/go/generic-type-shadow/after/model.go new file mode 100644 index 000000000..475a0f96d --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/go/generic-type-shadow/after/model.go @@ -0,0 +1,9 @@ +package model + +type Renamed struct { Value int } +type Container[Target any] struct { Item Target } +func generic[Target any](value Target) Target { return value } +func concrete(value Renamed) Renamed { return value } +func caller(value Renamed) Renamed { return concrete(value) } +const label = "Target" +// Target in the generic function names its own type parameter. diff --git a/gitnexus/test/fixtures/rename-integration/go/generic-type-shadow/before/go.mod b/gitnexus/test/fixtures/rename-integration/go/generic-type-shadow/before/go.mod new file mode 100644 index 000000000..047567b68 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/go/generic-type-shadow/before/go.mod @@ -0,0 +1,3 @@ +module example.com/renamefixture + +go 1.22 diff --git a/gitnexus/test/fixtures/rename-integration/go/generic-type-shadow/before/model.go b/gitnexus/test/fixtures/rename-integration/go/generic-type-shadow/before/model.go new file mode 100644 index 000000000..048edac49 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/go/generic-type-shadow/before/model.go @@ -0,0 +1,9 @@ +package model + +type Target struct { Value int } +type Container[Target any] struct { Item Target } +func generic[Target any](value Target) Target { return value } +func concrete(value Target) Target { return value } +func caller(value Target) Target { return concrete(value) } +const label = "Target" +// Target in the generic function names its own type parameter. diff --git a/gitnexus/test/fixtures/rename-integration/go/generic-type-shadow/case.json b/gitnexus/test/fixtures/rename-integration/go/generic-type-shadow/case.json new file mode 100644 index 000000000..389ca4764 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/go/generic-type-shadow/case.json @@ -0,0 +1,30 @@ +{ + "language": "go", + "description": "Rename a package type while preserving a same-spelled function type parameter and its uses.", + "categories": ["generics", "type-shadowing", "call-graph", "comments-and-strings"], + "target": { + "name": "Target", + "kind": "Struct", + "file": "model.go" + }, + "newName": "Renamed", + "expect": { + "status": "success", + "edits": 5 + }, + "edges": [ + { + "type": "CALLS", + "from": { + "name": "caller", + "kind": "Function", + "file": "model.go" + }, + "to": { + "name": "concrete", + "kind": "Function", + "file": "model.go" + } + } + ] +} diff --git a/gitnexus/test/fixtures/rename-integration/go/package-alias-call-chain/after/api/api.go b/gitnexus/test/fixtures/rename-integration/go/package-alias-call-chain/after/api/api.go new file mode 100644 index 000000000..d2b53beec --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/go/package-alias-call-chain/after/api/api.go @@ -0,0 +1,3 @@ +package api + +func Calculate(value int) int { return value + 1 } diff --git a/gitnexus/test/fixtures/rename-integration/go/package-alias-call-chain/after/go.mod b/gitnexus/test/fixtures/rename-integration/go/package-alias-call-chain/after/go.mod new file mode 100644 index 000000000..047567b68 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/go/package-alias-call-chain/after/go.mod @@ -0,0 +1,3 @@ +module example.com/renamefixture + +go 1.22 diff --git a/gitnexus/test/fixtures/rename-integration/go/package-alias-call-chain/after/main.go b/gitnexus/test/fixtures/rename-integration/go/package-alias-call-chain/after/main.go new file mode 100644 index 000000000..1188aceb2 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/go/package-alias-call-chain/after/main.go @@ -0,0 +1,9 @@ +package main + +import operations "example.com/renamefixture/api" + +func middle(value int) int { return operations.Calculate(value) } +func outer(value int) int { return middle(value) } +func shadow(Compute int) int { return Compute } +const label = "Compute" +// Compute must remain in this comment and the package alias is stable. diff --git a/gitnexus/test/fixtures/rename-integration/go/package-alias-call-chain/before/api/api.go b/gitnexus/test/fixtures/rename-integration/go/package-alias-call-chain/before/api/api.go new file mode 100644 index 000000000..a34ce74cb --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/go/package-alias-call-chain/before/api/api.go @@ -0,0 +1,3 @@ +package api + +func Compute(value int) int { return value + 1 } diff --git a/gitnexus/test/fixtures/rename-integration/go/package-alias-call-chain/before/go.mod b/gitnexus/test/fixtures/rename-integration/go/package-alias-call-chain/before/go.mod new file mode 100644 index 000000000..047567b68 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/go/package-alias-call-chain/before/go.mod @@ -0,0 +1,3 @@ +module example.com/renamefixture + +go 1.22 diff --git a/gitnexus/test/fixtures/rename-integration/go/package-alias-call-chain/before/main.go b/gitnexus/test/fixtures/rename-integration/go/package-alias-call-chain/before/main.go new file mode 100644 index 000000000..0bec1066e --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/go/package-alias-call-chain/before/main.go @@ -0,0 +1,9 @@ +package main + +import operations "example.com/renamefixture/api" + +func middle(value int) int { return operations.Compute(value) } +func outer(value int) int { return middle(value) } +func shadow(Compute int) int { return Compute } +const label = "Compute" +// Compute must remain in this comment and the package alias is stable. diff --git a/gitnexus/test/fixtures/rename-integration/go/package-alias-call-chain/case.json b/gitnexus/test/fixtures/rename-integration/go/package-alias-call-chain/case.json new file mode 100644 index 000000000..952e803dc --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/go/package-alias-call-chain/case.json @@ -0,0 +1,43 @@ +{ + "language": "go", + "description": "Follow a module import with an explicit package alias to an exported function from an outer call chain.", + "categories": ["cross-file", "call-graph", "import-alias", "shadowing"], + "target": { + "name": "Compute", + "kind": "Function", + "file": "api/api.go" + }, + "newName": "Calculate", + "expect": { + "status": "success", + "edits": 2 + }, + "edges": [ + { + "type": "CALLS", + "from": { + "name": "middle", + "kind": "Function", + "file": "main.go" + }, + "to": { + "name": "Compute", + "kind": "Function", + "file": "api/api.go" + } + }, + { + "type": "CALLS", + "from": { + "name": "outer", + "kind": "Function", + "file": "main.go" + }, + "to": { + "name": "middle", + "kind": "Function", + "file": "main.go" + } + } + ] +} diff --git a/gitnexus/test/fixtures/rename-integration/go/structural-interface-family/after/go.mod b/gitnexus/test/fixtures/rename-integration/go/structural-interface-family/after/go.mod new file mode 100644 index 000000000..047567b68 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/go/structural-interface-family/after/go.mod @@ -0,0 +1,3 @@ +module example.com/renamefixture + +go 1.22 diff --git a/gitnexus/test/fixtures/rename-integration/go/structural-interface-family/after/model.go b/gitnexus/test/fixtures/rename-integration/go/structural-interface-family/after/model.go new file mode 100644 index 000000000..943c0cf40 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/go/structural-interface-family/after/model.go @@ -0,0 +1,12 @@ +package model + +type Work interface { Calculate() int } +type Worker struct {} +func (worker Worker) Calculate() int { return 1 } +type Other struct {} +func (other Other) Compute(value int) int { return value } +func dispatch(work Work, worker Worker, other Other) int { + return work.Calculate() + worker.Calculate() + other.Compute(2) +} +const label = "Compute" +// Compute on Other has a different signature and does not implement Work. diff --git a/gitnexus/test/fixtures/rename-integration/go/structural-interface-family/before/go.mod b/gitnexus/test/fixtures/rename-integration/go/structural-interface-family/before/go.mod new file mode 100644 index 000000000..047567b68 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/go/structural-interface-family/before/go.mod @@ -0,0 +1,3 @@ +module example.com/renamefixture + +go 1.22 diff --git a/gitnexus/test/fixtures/rename-integration/go/structural-interface-family/before/model.go b/gitnexus/test/fixtures/rename-integration/go/structural-interface-family/before/model.go new file mode 100644 index 000000000..b277ba652 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/go/structural-interface-family/before/model.go @@ -0,0 +1,12 @@ +package model + +type Work interface { Compute() int } +type Worker struct {} +func (worker Worker) Compute() int { return 1 } +type Other struct {} +func (other Other) Compute(value int) int { return value } +func dispatch(work Work, worker Worker, other Other) int { + return work.Compute() + worker.Compute() + other.Compute(2) +} +const label = "Compute" +// Compute on Other has a different signature and does not implement Work. diff --git a/gitnexus/test/fixtures/rename-integration/go/structural-interface-family/case.json b/gitnexus/test/fixtures/rename-integration/go/structural-interface-family/case.json new file mode 100644 index 000000000..d71c95b54 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/go/structural-interface-family/case.json @@ -0,0 +1,47 @@ +{ + "language": "go", + "description": "Rename a structural interface method and concrete implementation with interface and concrete dispatch; retain an incompatible same-name method.", + "categories": ["polymorphism", "method-family", "interface", "homonyms"], + "target": { + "name": "Compute", + "kind": "Method", + "file": "model.go", + "startLine": 2 + }, + "newName": "Calculate", + "expect": { + "status": "success", + "edits": 4 + }, + "edges": [ + { + "type": "METHOD_IMPLEMENTS", + "from": { + "name": "Compute", + "kind": "Method", + "file": "model.go", + "startLine": 4 + }, + "to": { + "name": "Compute", + "kind": "Method", + "file": "model.go", + "startLine": 2 + } + }, + { + "type": "CALLS", + "from": { + "name": "dispatch", + "kind": "Function", + "file": "model.go" + }, + "to": { + "name": "Compute", + "kind": "Method", + "file": "model.go", + "startLine": 2 + } + } + ] +} diff --git a/gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-field/after/api/config.go b/gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-field/after/api/config.go new file mode 100644 index 000000000..70cc918de --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-field/after/api/config.go @@ -0,0 +1,5 @@ +package api + +type Config struct { Name string } + +func NewConfig() *Config { return &Config{} } diff --git a/gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-field/after/go.mod b/gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-field/after/go.mod new file mode 100644 index 000000000..047567b68 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-field/after/go.mod @@ -0,0 +1,3 @@ +module example.com/renamefixture + +go 1.22 diff --git a/gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-field/after/main.go b/gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-field/after/main.go new file mode 100644 index 000000000..65c74d3d3 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-field/after/main.go @@ -0,0 +1,10 @@ +package main + +import "example.com/renamefixture/api" + +func read() string { + config := api.NewConfig() + return config.Name +} + +func main() { _ = read() } diff --git a/gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-field/before/api/config.go b/gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-field/before/api/config.go new file mode 100644 index 000000000..70cc918de --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-field/before/api/config.go @@ -0,0 +1,5 @@ +package api + +type Config struct { Name string } + +func NewConfig() *Config { return &Config{} } diff --git a/gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-field/before/go.mod b/gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-field/before/go.mod new file mode 100644 index 000000000..047567b68 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-field/before/go.mod @@ -0,0 +1,3 @@ +module example.com/renamefixture + +go 1.22 diff --git a/gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-field/before/main.go b/gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-field/before/main.go new file mode 100644 index 000000000..65c74d3d3 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-field/before/main.go @@ -0,0 +1,10 @@ +package main + +import "example.com/renamefixture/api" + +func read() string { + config := api.NewConfig() + return config.Name +} + +func main() { _ = read() } diff --git a/gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-field/case.json b/gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-field/case.json new file mode 100644 index 000000000..62977078f --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-field/case.json @@ -0,0 +1,16 @@ +{ + "language": "go", + "description": "Refuse to unexport a struct field that another package reads through a value it obtained from the declaring package.", + "categories": ["cross-file", "visibility", "public-field", "negative", "unchanged-on-error"], + "target": { + "name": "Name", + "kind": "Property", + "file": "api/config.go" + }, + "newName": "name", + "expect": { + "status": "error", + "code": "invalid_name", + "planningStatus": "blocked" + } +} diff --git a/gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-function/after/api/api.go b/gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-function/after/api/api.go new file mode 100644 index 000000000..9b0eb744d --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-function/after/api/api.go @@ -0,0 +1,3 @@ +package api + +func Target(value int) int { return value + 1 } diff --git a/gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-function/after/go.mod b/gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-function/after/go.mod new file mode 100644 index 000000000..047567b68 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-function/after/go.mod @@ -0,0 +1,3 @@ +module example.com/renamefixture + +go 1.22 diff --git a/gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-function/after/main.go b/gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-function/after/main.go new file mode 100644 index 000000000..28f17be30 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-function/after/main.go @@ -0,0 +1,7 @@ +package main + +import "example.com/renamefixture/api" + +func run(value int) int { return api.Target(value) } + +func main() { _ = run(1) } diff --git a/gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-function/before/api/api.go b/gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-function/before/api/api.go new file mode 100644 index 000000000..9b0eb744d --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-function/before/api/api.go @@ -0,0 +1,3 @@ +package api + +func Target(value int) int { return value + 1 } diff --git a/gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-function/before/go.mod b/gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-function/before/go.mod new file mode 100644 index 000000000..047567b68 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-function/before/go.mod @@ -0,0 +1,3 @@ +module example.com/renamefixture + +go 1.22 diff --git a/gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-function/before/main.go b/gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-function/before/main.go new file mode 100644 index 000000000..28f17be30 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-function/before/main.go @@ -0,0 +1,7 @@ +package main + +import "example.com/renamefixture/api" + +func run(value int) int { return api.Target(value) } + +func main() { _ = run(1) } diff --git a/gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-function/case.json b/gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-function/case.json new file mode 100644 index 000000000..f30837df4 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-function/case.json @@ -0,0 +1,16 @@ +{ + "language": "go", + "description": "Refuse to unexport a function that another package calls through its import; the caller could no longer compile.", + "categories": ["cross-file", "visibility", "negative", "unchanged-on-error"], + "target": { + "name": "Target", + "kind": "Function", + "file": "api/api.go" + }, + "newName": "target", + "expect": { + "status": "error", + "code": "invalid_name", + "planningStatus": "blocked" + } +} diff --git a/gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-method/after/api/service.go b/gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-method/after/api/service.go new file mode 100644 index 000000000..d149884a7 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-method/after/api/service.go @@ -0,0 +1,7 @@ +package api + +type Service struct{} + +func NewService() *Service { return &Service{} } + +func (service *Service) Run(value int) int { return value + 1 } diff --git a/gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-method/after/go.mod b/gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-method/after/go.mod new file mode 100644 index 000000000..047567b68 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-method/after/go.mod @@ -0,0 +1,3 @@ +module example.com/renamefixture + +go 1.22 diff --git a/gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-method/after/main.go b/gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-method/after/main.go new file mode 100644 index 000000000..5336650af --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-method/after/main.go @@ -0,0 +1,10 @@ +package main + +import "example.com/renamefixture/api" + +func run(value int) int { + service := api.NewService() + return service.Run(value) +} + +func main() { _ = run(1) } diff --git a/gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-method/before/api/service.go b/gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-method/before/api/service.go new file mode 100644 index 000000000..d149884a7 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-method/before/api/service.go @@ -0,0 +1,7 @@ +package api + +type Service struct{} + +func NewService() *Service { return &Service{} } + +func (service *Service) Run(value int) int { return value + 1 } diff --git a/gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-method/before/go.mod b/gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-method/before/go.mod new file mode 100644 index 000000000..047567b68 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-method/before/go.mod @@ -0,0 +1,3 @@ +module example.com/renamefixture + +go 1.22 diff --git a/gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-method/before/main.go b/gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-method/before/main.go new file mode 100644 index 000000000..5336650af --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-method/before/main.go @@ -0,0 +1,10 @@ +package main + +import "example.com/renamefixture/api" + +func run(value int) int { + service := api.NewService() + return service.Run(value) +} + +func main() { _ = run(1) } diff --git a/gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-method/case.json b/gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-method/case.json new file mode 100644 index 000000000..3b470a101 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-method/case.json @@ -0,0 +1,16 @@ +{ + "language": "go", + "description": "Refuse to unexport a method that another package calls on a value it obtained from the declaring package.", + "categories": ["cross-file", "visibility", "method-family", "negative", "unchanged-on-error"], + "target": { + "name": "Run", + "kind": "Method", + "file": "api/service.go" + }, + "newName": "run", + "expect": { + "status": "error", + "code": "invalid_name", + "planningStatus": "blocked" + } +} diff --git a/gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-promoted-method/after/api/api.go b/gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-promoted-method/after/api/api.go new file mode 100644 index 000000000..40c5f8a1e --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-promoted-method/after/api/api.go @@ -0,0 +1,5 @@ +package api + +type Base struct{} + +func (base Base) Run() int { return 1 } diff --git a/gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-promoted-method/after/go.mod b/gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-promoted-method/after/go.mod new file mode 100644 index 000000000..047567b68 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-promoted-method/after/go.mod @@ -0,0 +1,3 @@ +module example.com/renamefixture + +go 1.22 diff --git a/gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-promoted-method/after/main.go b/gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-promoted-method/after/main.go new file mode 100644 index 000000000..77ac5082f --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-promoted-method/after/main.go @@ -0,0 +1,9 @@ +package main + +import "example.com/renamefixture/api" + +type derived struct{ api.Base } + +func use(value derived) int { return value.Run() } + +func main() { _ = use(derived{}) } diff --git a/gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-promoted-method/before/api/api.go b/gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-promoted-method/before/api/api.go new file mode 100644 index 000000000..40c5f8a1e --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-promoted-method/before/api/api.go @@ -0,0 +1,5 @@ +package api + +type Base struct{} + +func (base Base) Run() int { return 1 } diff --git a/gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-promoted-method/before/go.mod b/gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-promoted-method/before/go.mod new file mode 100644 index 000000000..047567b68 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-promoted-method/before/go.mod @@ -0,0 +1,3 @@ +module example.com/renamefixture + +go 1.22 diff --git a/gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-promoted-method/before/main.go b/gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-promoted-method/before/main.go new file mode 100644 index 000000000..77ac5082f --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-promoted-method/before/main.go @@ -0,0 +1,9 @@ +package main + +import "example.com/renamefixture/api" + +type derived struct{ api.Base } + +func use(value derived) int { return value.Run() } + +func main() { _ = use(derived{}) } diff --git a/gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-promoted-method/case.json b/gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-promoted-method/case.json new file mode 100644 index 000000000..2db378b68 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-promoted-method/case.json @@ -0,0 +1,16 @@ +{ + "language": "go", + "description": "Refuse to unexport a method that another package reaches through an embedded struct (a promoted method selector).", + "categories": ["cross-file", "visibility", "embedding", "method-family", "negative", "unchanged-on-error"], + "target": { + "name": "Run", + "kind": "Method", + "file": "api/api.go" + }, + "newName": "run", + "expect": { + "status": "error", + "code": "invalid_name", + "planningStatus": "blocked" + } +} diff --git a/gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-unicode/after/api/api.go b/gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-unicode/after/api/api.go new file mode 100644 index 000000000..a7286b66b --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-unicode/after/api/api.go @@ -0,0 +1,3 @@ +package api + +func Ünicode(value int) int { return value + 1 } diff --git a/gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-unicode/after/go.mod b/gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-unicode/after/go.mod new file mode 100644 index 000000000..047567b68 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-unicode/after/go.mod @@ -0,0 +1,3 @@ +module example.com/renamefixture + +go 1.22 diff --git a/gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-unicode/after/main.go b/gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-unicode/after/main.go new file mode 100644 index 000000000..154c5d7ca --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-unicode/after/main.go @@ -0,0 +1,7 @@ +package main + +import "example.com/renamefixture/api" + +func run(value int) int { return api.Ünicode(value) } + +func main() { _ = run(1) } diff --git a/gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-unicode/before/api/api.go b/gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-unicode/before/api/api.go new file mode 100644 index 000000000..a7286b66b --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-unicode/before/api/api.go @@ -0,0 +1,3 @@ +package api + +func Ünicode(value int) int { return value + 1 } diff --git a/gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-unicode/before/go.mod b/gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-unicode/before/go.mod new file mode 100644 index 000000000..047567b68 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-unicode/before/go.mod @@ -0,0 +1,3 @@ +module example.com/renamefixture + +go 1.22 diff --git a/gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-unicode/before/main.go b/gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-unicode/before/main.go new file mode 100644 index 000000000..154c5d7ca --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-unicode/before/main.go @@ -0,0 +1,7 @@ +package main + +import "example.com/renamefixture/api" + +func run(value int) int { return api.Ünicode(value) } + +func main() { _ = run(1) } diff --git a/gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-unicode/case.json b/gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-unicode/case.json new file mode 100644 index 000000000..29c5c90b3 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/go/unexport-cross-package-unicode/case.json @@ -0,0 +1,16 @@ +{ + "language": "go", + "description": "Export is decided by the Unicode uppercase category, not ASCII A-Z: a non-ASCII uppercase initial is exported and a caseless letter (CJK) is not, so this rename hides the function from the caller.", + "categories": ["cross-file", "visibility", "unicode", "negative", "unchanged-on-error"], + "target": { + "name": "Ünicode", + "kind": "Function", + "file": "api/api.go" + }, + "newName": "日本", + "expect": { + "status": "error", + "code": "invalid_name", + "planningStatus": "blocked" + } +} diff --git a/gitnexus/test/fixtures/rename-integration/go/unexport-same-package-function/after/api/api.go b/gitnexus/test/fixtures/rename-integration/go/unexport-same-package-function/after/api/api.go new file mode 100644 index 000000000..78eaca573 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/go/unexport-same-package-function/after/api/api.go @@ -0,0 +1,5 @@ +package api + +func target(value int) int { return value + 1 } + +func Other(value int) int { return value * 2 } diff --git a/gitnexus/test/fixtures/rename-integration/go/unexport-same-package-function/after/api/helper.go b/gitnexus/test/fixtures/rename-integration/go/unexport-same-package-function/after/api/helper.go new file mode 100644 index 000000000..120f4feb0 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/go/unexport-same-package-function/after/api/helper.go @@ -0,0 +1,3 @@ +package api + +func helper(value int) int { return target(value) } diff --git a/gitnexus/test/fixtures/rename-integration/go/unexport-same-package-function/after/go.mod b/gitnexus/test/fixtures/rename-integration/go/unexport-same-package-function/after/go.mod new file mode 100644 index 000000000..047567b68 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/go/unexport-same-package-function/after/go.mod @@ -0,0 +1,3 @@ +module example.com/renamefixture + +go 1.22 diff --git a/gitnexus/test/fixtures/rename-integration/go/unexport-same-package-function/after/main.go b/gitnexus/test/fixtures/rename-integration/go/unexport-same-package-function/after/main.go new file mode 100644 index 000000000..47603f110 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/go/unexport-same-package-function/after/main.go @@ -0,0 +1,7 @@ +package main + +import "example.com/renamefixture/api" + +func run(value int) int { return api.Other(value) } + +func main() { _ = run(1) } diff --git a/gitnexus/test/fixtures/rename-integration/go/unexport-same-package-function/before/api/api.go b/gitnexus/test/fixtures/rename-integration/go/unexport-same-package-function/before/api/api.go new file mode 100644 index 000000000..d17e032ee --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/go/unexport-same-package-function/before/api/api.go @@ -0,0 +1,5 @@ +package api + +func Target(value int) int { return value + 1 } + +func Other(value int) int { return value * 2 } diff --git a/gitnexus/test/fixtures/rename-integration/go/unexport-same-package-function/before/api/helper.go b/gitnexus/test/fixtures/rename-integration/go/unexport-same-package-function/before/api/helper.go new file mode 100644 index 000000000..3ab7dedaf --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/go/unexport-same-package-function/before/api/helper.go @@ -0,0 +1,3 @@ +package api + +func helper(value int) int { return Target(value) } diff --git a/gitnexus/test/fixtures/rename-integration/go/unexport-same-package-function/before/go.mod b/gitnexus/test/fixtures/rename-integration/go/unexport-same-package-function/before/go.mod new file mode 100644 index 000000000..047567b68 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/go/unexport-same-package-function/before/go.mod @@ -0,0 +1,3 @@ +module example.com/renamefixture + +go 1.22 diff --git a/gitnexus/test/fixtures/rename-integration/go/unexport-same-package-function/before/main.go b/gitnexus/test/fixtures/rename-integration/go/unexport-same-package-function/before/main.go new file mode 100644 index 000000000..47603f110 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/go/unexport-same-package-function/before/main.go @@ -0,0 +1,7 @@ +package main + +import "example.com/renamefixture/api" + +func run(value int) int { return api.Other(value) } + +func main() { _ = run(1) } diff --git a/gitnexus/test/fixtures/rename-integration/go/unexport-same-package-function/case.json b/gitnexus/test/fixtures/rename-integration/go/unexport-same-package-function/case.json new file mode 100644 index 000000000..4effc92d8 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/go/unexport-same-package-function/case.json @@ -0,0 +1,30 @@ +{ + "language": "go", + "description": "Unexport a function whose every use stays in its declaring package, even though another package imports that package for unrelated symbols.", + "categories": ["cross-file", "visibility", "same-package"], + "target": { + "name": "Target", + "kind": "Function", + "file": "api/api.go" + }, + "newName": "target", + "expect": { + "status": "success", + "edits": 2 + }, + "edges": [ + { + "type": "CALLS", + "from": { + "name": "helper", + "kind": "Function", + "file": "api/helper.go" + }, + "to": { + "name": "Target", + "kind": "Function", + "file": "api/api.go" + } + } + ] +} diff --git a/gitnexus/test/fixtures/rename-integration/java/external-base-family/after/Worker.java b/gitnexus/test/fixtures/rename-integration/java/external-base-family/after/Worker.java new file mode 100644 index 000000000..467f23cd1 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/java/external-base-family/after/Worker.java @@ -0,0 +1,5 @@ +import vendor.External; + +class Worker extends External { + @Override public int target() { return 1; } +} diff --git a/gitnexus/test/fixtures/rename-integration/java/external-base-family/after/notes.txt b/gitnexus/test/fixtures/rename-integration/java/external-base-family/after/notes.txt new file mode 100644 index 000000000..81db7fc3a --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/java/external-base-family/after/notes.txt @@ -0,0 +1 @@ +External contract and target spelling remain unchanged. diff --git a/gitnexus/test/fixtures/rename-integration/java/external-base-family/before/Worker.java b/gitnexus/test/fixtures/rename-integration/java/external-base-family/before/Worker.java new file mode 100644 index 000000000..467f23cd1 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/java/external-base-family/before/Worker.java @@ -0,0 +1,5 @@ +import vendor.External; + +class Worker extends External { + @Override public int target() { return 1; } +} diff --git a/gitnexus/test/fixtures/rename-integration/java/external-base-family/before/notes.txt b/gitnexus/test/fixtures/rename-integration/java/external-base-family/before/notes.txt new file mode 100644 index 000000000..81db7fc3a --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/java/external-base-family/before/notes.txt @@ -0,0 +1 @@ +External contract and target spelling remain unchanged. diff --git a/gitnexus/test/fixtures/rename-integration/java/external-base-family/case.json b/gitnexus/test/fixtures/rename-integration/java/external-base-family/case.json new file mode 100644 index 000000000..c48d44e33 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/java/external-base-family/case.json @@ -0,0 +1,22 @@ +{ + "language": "java", + "description": "Refuse a potentially polymorphic member whose external base is not indexed.", + "categories": [ + "polymorphism", + "external-base", + "negative", + "unchanged-on-error" + ], + "target": { + "name": "target", + "kind": "Method", + "file": "Worker.java", + "startLine": 3 + }, + "newName": "renamed", + "expect": { + "status": "error", + "code": "unsupported_symbol_family", + "planningStatus": "unsupported" + } +} diff --git a/gitnexus/test/fixtures/rename-integration/java/implicit-external-base-family/after/Worker.java b/gitnexus/test/fixtures/rename-integration/java/implicit-external-base-family/after/Worker.java new file mode 100644 index 000000000..337443d20 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/java/implicit-external-base-family/after/Worker.java @@ -0,0 +1,3 @@ +class Worker { + @Override public String toString() { return "worker"; } +} diff --git a/gitnexus/test/fixtures/rename-integration/java/implicit-external-base-family/after/notes.txt b/gitnexus/test/fixtures/rename-integration/java/implicit-external-base-family/after/notes.txt new file mode 100644 index 000000000..41df97c4b --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/java/implicit-external-base-family/after/notes.txt @@ -0,0 +1 @@ +An explicit override still requires its external base member. diff --git a/gitnexus/test/fixtures/rename-integration/java/implicit-external-base-family/before/Worker.java b/gitnexus/test/fixtures/rename-integration/java/implicit-external-base-family/before/Worker.java new file mode 100644 index 000000000..337443d20 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/java/implicit-external-base-family/before/Worker.java @@ -0,0 +1,3 @@ +class Worker { + @Override public String toString() { return "worker"; } +} diff --git a/gitnexus/test/fixtures/rename-integration/java/implicit-external-base-family/before/notes.txt b/gitnexus/test/fixtures/rename-integration/java/implicit-external-base-family/before/notes.txt new file mode 100644 index 000000000..41df97c4b --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/java/implicit-external-base-family/before/notes.txt @@ -0,0 +1 @@ +An explicit override still requires its external base member. diff --git a/gitnexus/test/fixtures/rename-integration/java/implicit-external-base-family/case.json b/gitnexus/test/fixtures/rename-integration/java/implicit-external-base-family/case.json new file mode 100644 index 000000000..79ad81187 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/java/implicit-external-base-family/case.json @@ -0,0 +1,23 @@ +{ + "language": "java", + "description": "Refuse an explicit override of an unindexed implicit base member.", + "categories": [ + "polymorphism", + "external-base", + "implicit-base", + "negative", + "unchanged-on-error" + ], + "target": { + "name": "toString", + "kind": "Method", + "file": "Worker.java", + "startLine": 1 + }, + "newName": "description", + "expect": { + "status": "error", + "code": "unsupported_symbol_family", + "planningStatus": "unsupported" + } +} diff --git a/gitnexus/test/fixtures/rename-integration/java/implicit-external-base-parenthesized-override/after/Worker.java b/gitnexus/test/fixtures/rename-integration/java/implicit-external-base-parenthesized-override/after/Worker.java new file mode 100644 index 000000000..3ae7784fb --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/java/implicit-external-base-parenthesized-override/after/Worker.java @@ -0,0 +1,3 @@ +class Worker { + @Override() public String toString() { return "worker"; } +} diff --git a/gitnexus/test/fixtures/rename-integration/java/implicit-external-base-parenthesized-override/after/notes.txt b/gitnexus/test/fixtures/rename-integration/java/implicit-external-base-parenthesized-override/after/notes.txt new file mode 100644 index 000000000..41df97c4b --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/java/implicit-external-base-parenthesized-override/after/notes.txt @@ -0,0 +1 @@ +An explicit override still requires its external base member. diff --git a/gitnexus/test/fixtures/rename-integration/java/implicit-external-base-parenthesized-override/before/Worker.java b/gitnexus/test/fixtures/rename-integration/java/implicit-external-base-parenthesized-override/before/Worker.java new file mode 100644 index 000000000..3ae7784fb --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/java/implicit-external-base-parenthesized-override/before/Worker.java @@ -0,0 +1,3 @@ +class Worker { + @Override() public String toString() { return "worker"; } +} diff --git a/gitnexus/test/fixtures/rename-integration/java/implicit-external-base-parenthesized-override/before/notes.txt b/gitnexus/test/fixtures/rename-integration/java/implicit-external-base-parenthesized-override/before/notes.txt new file mode 100644 index 000000000..41df97c4b --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/java/implicit-external-base-parenthesized-override/before/notes.txt @@ -0,0 +1 @@ +An explicit override still requires its external base member. diff --git a/gitnexus/test/fixtures/rename-integration/java/implicit-external-base-parenthesized-override/case.json b/gitnexus/test/fixtures/rename-integration/java/implicit-external-base-parenthesized-override/case.json new file mode 100644 index 000000000..30739ed32 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/java/implicit-external-base-parenthesized-override/case.json @@ -0,0 +1,24 @@ +{ + "language": "java", + "description": "Refuse @Override() on an unindexed implicit base member.", + "categories": [ + "polymorphism", + "external-base", + "implicit-base", + "negative", + "unchanged-on-error", + "parenthesized-annotation" + ], + "target": { + "name": "toString", + "kind": "Method", + "file": "Worker.java", + "startLine": 1 + }, + "newName": "description", + "expect": { + "status": "error", + "code": "unsupported_symbol_family", + "planningStatus": "unsupported" + } +} diff --git a/gitnexus/test/fixtures/rename-integration/java/implicit-external-base-qualified-parenthesized-override/after/Worker.java b/gitnexus/test/fixtures/rename-integration/java/implicit-external-base-qualified-parenthesized-override/after/Worker.java new file mode 100644 index 000000000..99a770991 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/java/implicit-external-base-qualified-parenthesized-override/after/Worker.java @@ -0,0 +1,3 @@ +class Worker { + @java.lang.Override() public String toString() { return "worker"; } +} diff --git a/gitnexus/test/fixtures/rename-integration/java/implicit-external-base-qualified-parenthesized-override/after/notes.txt b/gitnexus/test/fixtures/rename-integration/java/implicit-external-base-qualified-parenthesized-override/after/notes.txt new file mode 100644 index 000000000..41df97c4b --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/java/implicit-external-base-qualified-parenthesized-override/after/notes.txt @@ -0,0 +1 @@ +An explicit override still requires its external base member. diff --git a/gitnexus/test/fixtures/rename-integration/java/implicit-external-base-qualified-parenthesized-override/before/Worker.java b/gitnexus/test/fixtures/rename-integration/java/implicit-external-base-qualified-parenthesized-override/before/Worker.java new file mode 100644 index 000000000..99a770991 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/java/implicit-external-base-qualified-parenthesized-override/before/Worker.java @@ -0,0 +1,3 @@ +class Worker { + @java.lang.Override() public String toString() { return "worker"; } +} diff --git a/gitnexus/test/fixtures/rename-integration/java/implicit-external-base-qualified-parenthesized-override/before/notes.txt b/gitnexus/test/fixtures/rename-integration/java/implicit-external-base-qualified-parenthesized-override/before/notes.txt new file mode 100644 index 000000000..41df97c4b --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/java/implicit-external-base-qualified-parenthesized-override/before/notes.txt @@ -0,0 +1 @@ +An explicit override still requires its external base member. diff --git a/gitnexus/test/fixtures/rename-integration/java/implicit-external-base-qualified-parenthesized-override/case.json b/gitnexus/test/fixtures/rename-integration/java/implicit-external-base-qualified-parenthesized-override/case.json new file mode 100644 index 000000000..a24fb8603 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/java/implicit-external-base-qualified-parenthesized-override/case.json @@ -0,0 +1,24 @@ +{ + "language": "java", + "description": "Refuse @java.lang.Override() on an unindexed implicit base member.", + "categories": [ + "polymorphism", + "external-base", + "implicit-base", + "negative", + "unchanged-on-error", + "parenthesized-annotation" + ], + "target": { + "name": "toString", + "kind": "Method", + "file": "Worker.java", + "startLine": 1 + }, + "newName": "description", + "expect": { + "status": "error", + "code": "unsupported_symbol_family", + "planningStatus": "unsupported" + } +} diff --git a/gitnexus/test/fixtures/rename-integration/java/interop-kotlin-call/after/Caller.kt b/gitnexus/test/fixtures/rename-integration/java/interop-kotlin-call/after/Caller.kt new file mode 100644 index 000000000..7531fa048 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/java/interop-kotlin-call/after/Caller.kt @@ -0,0 +1 @@ +fun call(writer: Writer) { writer.close() } diff --git a/gitnexus/test/fixtures/rename-integration/java/interop-kotlin-call/after/Writer.java b/gitnexus/test/fixtures/rename-integration/java/interop-kotlin-call/after/Writer.java new file mode 100644 index 000000000..284de4c81 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/java/interop-kotlin-call/after/Writer.java @@ -0,0 +1,3 @@ +class Writer { + void close() {} +} diff --git a/gitnexus/test/fixtures/rename-integration/java/interop-kotlin-call/before/Caller.kt b/gitnexus/test/fixtures/rename-integration/java/interop-kotlin-call/before/Caller.kt new file mode 100644 index 000000000..7531fa048 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/java/interop-kotlin-call/before/Caller.kt @@ -0,0 +1 @@ +fun call(writer: Writer) { writer.close() } diff --git a/gitnexus/test/fixtures/rename-integration/java/interop-kotlin-call/before/Writer.java b/gitnexus/test/fixtures/rename-integration/java/interop-kotlin-call/before/Writer.java new file mode 100644 index 000000000..284de4c81 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/java/interop-kotlin-call/before/Writer.java @@ -0,0 +1,3 @@ +class Writer { + void close() {} +} diff --git a/gitnexus/test/fixtures/rename-integration/java/interop-kotlin-call/case.json b/gitnexus/test/fixtures/rename-integration/java/interop-kotlin-call/case.json new file mode 100644 index 000000000..1ee5def4d --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/java/interop-kotlin-call/case.json @@ -0,0 +1,20 @@ +{ + "language": "java", + "description": "Refuse an indexed interoperating source name until cross-provider binding coverage is proven.", + "categories": [ + "cross-language", + "interop", + "comments-and-strings" + ], + "target": { + "name": "close", + "kind": "Method", + "file": "Writer.java" + }, + "newName": "finish", + "expect": { + "status": "error", + "code": "unsupported_boundary", + "planningStatus": "unsupported" + } +} diff --git a/gitnexus/test/fixtures/rename-integration/java/interop-kotlin-property/after/Caller.kt b/gitnexus/test/fixtures/rename-integration/java/interop-kotlin-property/after/Caller.kt new file mode 100644 index 000000000..0cf4486c6 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/java/interop-kotlin-property/after/Caller.kt @@ -0,0 +1 @@ +fun call(writer: Writer): String { return writer.title } diff --git a/gitnexus/test/fixtures/rename-integration/java/interop-kotlin-property/after/Writer.java b/gitnexus/test/fixtures/rename-integration/java/interop-kotlin-property/after/Writer.java new file mode 100644 index 000000000..ece804878 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/java/interop-kotlin-property/after/Writer.java @@ -0,0 +1,3 @@ +class Writer { + String getTitle() { return "draft"; } +} diff --git a/gitnexus/test/fixtures/rename-integration/java/interop-kotlin-property/before/Caller.kt b/gitnexus/test/fixtures/rename-integration/java/interop-kotlin-property/before/Caller.kt new file mode 100644 index 000000000..0cf4486c6 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/java/interop-kotlin-property/before/Caller.kt @@ -0,0 +1 @@ +fun call(writer: Writer): String { return writer.title } diff --git a/gitnexus/test/fixtures/rename-integration/java/interop-kotlin-property/before/Writer.java b/gitnexus/test/fixtures/rename-integration/java/interop-kotlin-property/before/Writer.java new file mode 100644 index 000000000..ece804878 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/java/interop-kotlin-property/before/Writer.java @@ -0,0 +1,3 @@ +class Writer { + String getTitle() { return "draft"; } +} diff --git a/gitnexus/test/fixtures/rename-integration/java/interop-kotlin-property/case.json b/gitnexus/test/fixtures/rename-integration/java/interop-kotlin-property/case.json new file mode 100644 index 000000000..127cc858c --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/java/interop-kotlin-property/case.json @@ -0,0 +1,20 @@ +{ + "language": "java", + "description": "Refuse an indexed interoperating source name until cross-provider binding coverage is proven.", + "categories": [ + "cross-language", + "interop", + "comments-and-strings" + ], + "target": { + "name": "getTitle", + "kind": "Method", + "file": "Writer.java" + }, + "newName": "getHeading", + "expect": { + "status": "error", + "code": "unsupported_boundary", + "planningStatus": "unsupported" + } +} diff --git a/gitnexus/test/fixtures/rename-integration/java/overload-call-graph/after/Encoder.java b/gitnexus/test/fixtures/rename-integration/java/overload-call-graph/after/Encoder.java new file mode 100644 index 000000000..d0f982c40 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/java/overload-call-graph/after/Encoder.java @@ -0,0 +1,12 @@ +class Encoder { + static String serialize() { return "encode"; } + static String serialize(int value) { return "number"; } + static String middle() { return serialize() + serialize(7); } + static String entry() { return middle(); } + static String local(String encode) { return encode; } + // encode is named in the documentation +} +class OtherEncoder { + static String encode() { return "other"; } + static String entry() { return encode(); } +} diff --git a/gitnexus/test/fixtures/rename-integration/java/overload-call-graph/before/Encoder.java b/gitnexus/test/fixtures/rename-integration/java/overload-call-graph/before/Encoder.java new file mode 100644 index 000000000..d1c5c30ee --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/java/overload-call-graph/before/Encoder.java @@ -0,0 +1,12 @@ +class Encoder { + static String encode() { return "encode"; } + static String encode(int value) { return "number"; } + static String middle() { return encode() + encode(7); } + static String entry() { return middle(); } + static String local(String encode) { return encode; } + // encode is named in the documentation +} +class OtherEncoder { + static String encode() { return "other"; } + static String entry() { return encode(); } +} diff --git a/gitnexus/test/fixtures/rename-integration/java/overload-call-graph/case.json b/gitnexus/test/fixtures/rename-integration/java/overload-call-graph/case.json new file mode 100644 index 000000000..3893c7624 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/java/overload-call-graph/case.json @@ -0,0 +1,84 @@ +{ + "language": "java", + "description": "Rename an overload group used by a multi-step call graph without touching a local binding or another class.", + "categories": [ + "overloads", + "call-graph", + "shadowing", + "unrelated-owner" + ], + "target": { + "name": "encode", + "kind": "Method", + "file": "Encoder.java", + "startLine": 1 + }, + "newName": "serialize", + "expect": { + "status": "success", + "edits": 4 + }, + "edges": [ + { + "type": "CALLS", + "from": { + "name": "entry", + "kind": "Method", + "file": "Encoder.java", + "startLine": 4 + }, + "to": { + "name": "middle", + "kind": "Method", + "file": "Encoder.java", + "startLine": 3 + } + }, + { + "type": "CALLS", + "from": { + "name": "middle", + "kind": "Method", + "file": "Encoder.java", + "startLine": 3 + }, + "to": { + "name": "encode", + "kind": "Method", + "file": "Encoder.java", + "startLine": 1 + } + }, + { + "type": "CALLS", + "from": { + "name": "middle", + "kind": "Method", + "file": "Encoder.java", + "startLine": 3 + }, + "to": { + "name": "encode", + "kind": "Method", + "file": "Encoder.java", + "startLine": 2 + } + }, + { + "type": "CALLS", + "from": { + "name": "entry", + "kind": "Method", + "file": "Encoder.java", + "startLine": 10 + }, + "to": { + "name": "encode", + "kind": "Method", + "file": "Encoder.java", + "startLine": 9 + } + } + ], + "exactOutgoing": true +} diff --git a/gitnexus/test/fixtures/rename-integration/java/polymorphic-delivery/after/Delivery.java b/gitnexus/test/fixtures/rename-integration/java/polymorphic-delivery/after/Delivery.java new file mode 100644 index 000000000..cba4efe1c --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/java/polymorphic-delivery/after/Delivery.java @@ -0,0 +1,21 @@ +interface Courier { + void deliver(); +} +class StandardCourier implements Courier { + public void deliver() {} +} +class ExpressCourier extends StandardCourier { + @Override public void deliver() { super.deliver(); } +} +class Delivery { + void send(Courier contract, StandardCourier standard, ExpressCourier express) { + contract.deliver(); + standard.deliver(); + express.deliver(); + } + String note = "dispatch"; // dispatch remains documentation +} +class UnrelatedCourier { + void dispatch() {} + void use() { dispatch(); } +} diff --git a/gitnexus/test/fixtures/rename-integration/java/polymorphic-delivery/before/Delivery.java b/gitnexus/test/fixtures/rename-integration/java/polymorphic-delivery/before/Delivery.java new file mode 100644 index 000000000..354b70ef9 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/java/polymorphic-delivery/before/Delivery.java @@ -0,0 +1,21 @@ +interface Courier { + void dispatch(); +} +class StandardCourier implements Courier { + public void dispatch() {} +} +class ExpressCourier extends StandardCourier { + @Override public void dispatch() { super.dispatch(); } +} +class Delivery { + void send(Courier contract, StandardCourier standard, ExpressCourier express) { + contract.dispatch(); + standard.dispatch(); + express.dispatch(); + } + String note = "dispatch"; // dispatch remains documentation +} +class UnrelatedCourier { + void dispatch() {} + void use() { dispatch(); } +} diff --git a/gitnexus/test/fixtures/rename-integration/java/polymorphic-delivery/case.json b/gitnexus/test/fixtures/rename-integration/java/polymorphic-delivery/case.json new file mode 100644 index 000000000..82c09e8d4 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/java/polymorphic-delivery/case.json @@ -0,0 +1,68 @@ +{ + "language": "java", + "description": "Rename an interface method through an implementation, override, super call and statically typed dispatch; preserve an unrelated owner.", + "categories": [ + "polymorphism", + "call-graph", + "unrelated-owner", + "comments-and-strings" + ], + "target": { + "name": "dispatch", + "kind": "Method", + "file": "Delivery.java", + "startLine": 1 + }, + "newName": "deliver", + "expect": { + "status": "success", + "edits": 7 + }, + "edges": [ + { + "type": "METHOD_IMPLEMENTS", + "from": { + "name": "dispatch", + "kind": "Method", + "file": "Delivery.java", + "startLine": 4 + }, + "to": { + "name": "dispatch", + "kind": "Method", + "file": "Delivery.java", + "startLine": 1 + } + }, + { + "type": "METHOD_OVERRIDES", + "from": { + "name": "ExpressCourier", + "kind": "Class", + "file": "Delivery.java", + "startLine": 6 + }, + "to": { + "name": "dispatch", + "kind": "Method", + "file": "Delivery.java", + "startLine": 4 + } + }, + { + "type": "CALLS", + "from": { + "name": "send", + "kind": "Method", + "file": "Delivery.java", + "startLine": 10 + }, + "to": { + "name": "dispatch", + "kind": "Method", + "file": "Delivery.java", + "startLine": 1 + } + } + ] +} diff --git a/gitnexus/test/fixtures/rename-integration/java/public-field-chain/after/Profile.java b/gitnexus/test/fixtures/rename-integration/java/public-field-chain/after/Profile.java new file mode 100644 index 000000000..e98b96855 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/java/public-field-chain/after/Profile.java @@ -0,0 +1,19 @@ +class Profile { + public String displayBadge = "badge"; +} +class Account { + public Profile profile; +} +class Session { + public Account account; +} +class Dashboard { + String read(Session session) { return session.account.profile.displayBadge; } + void update(Session session, String badge) { session.account.profile.displayBadge = badge; } + String direct(Profile profile) { return profile.displayBadge; } + // badge is a public API field, not this comment +} +class OtherProfile { + public String badge = "badge"; + String read(OtherProfile other) { return other.badge; } +} diff --git a/gitnexus/test/fixtures/rename-integration/java/public-field-chain/before/Profile.java b/gitnexus/test/fixtures/rename-integration/java/public-field-chain/before/Profile.java new file mode 100644 index 000000000..4af38e624 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/java/public-field-chain/before/Profile.java @@ -0,0 +1,19 @@ +class Profile { + public String badge = "badge"; +} +class Account { + public Profile profile; +} +class Session { + public Account account; +} +class Dashboard { + String read(Session session) { return session.account.profile.badge; } + void update(Session session, String badge) { session.account.profile.badge = badge; } + String direct(Profile profile) { return profile.badge; } + // badge is a public API field, not this comment +} +class OtherProfile { + public String badge = "badge"; + String read(OtherProfile other) { return other.badge; } +} diff --git a/gitnexus/test/fixtures/rename-integration/java/public-field-chain/case.json b/gitnexus/test/fixtures/rename-integration/java/public-field-chain/case.json new file mode 100644 index 000000000..8761a1f9b --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/java/public-field-chain/case.json @@ -0,0 +1,53 @@ +{ + "language": "java", + "description": "Rename a public field across three-hop reads and writes while preserving a shadow parameter and another owner.", + "categories": [ + "public-field", + "deep-chain", + "unrelated-owner", + "shadowing" + ], + "target": { + "name": "badge", + "kind": "Property", + "file": "Profile.java", + "startLine": 1 + }, + "newName": "displayBadge", + "expect": { + "status": "success", + "edits": 4 + }, + "edges": [ + { + "type": "ACCESSES", + "from": { + "name": "read", + "kind": "Method", + "file": "Profile.java", + "startLine": 10 + }, + "to": { + "name": "badge", + "kind": "Property", + "file": "Profile.java", + "startLine": 1 + } + }, + { + "type": "ACCESSES", + "from": { + "name": "update", + "kind": "Method", + "file": "Profile.java", + "startLine": 11 + }, + "to": { + "name": "badge", + "kind": "Property", + "file": "Profile.java", + "startLine": 1 + } + } + ] +} diff --git a/gitnexus/test/fixtures/rename-integration/java/reserved-escaped-underscore/after/Metric.java b/gitnexus/test/fixtures/rename-integration/java/reserved-escaped-underscore/after/Metric.java new file mode 100644 index 000000000..7e779f2a4 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/java/reserved-escaped-underscore/after/Metric.java @@ -0,0 +1,6 @@ +class Metric { + int measure(int value) { return value + 1; } + int report() { return measure(3); } + String label = "measure"; + // measure stays in this comment. +} diff --git a/gitnexus/test/fixtures/rename-integration/java/reserved-escaped-underscore/before/Metric.java b/gitnexus/test/fixtures/rename-integration/java/reserved-escaped-underscore/before/Metric.java new file mode 100644 index 000000000..7e779f2a4 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/java/reserved-escaped-underscore/before/Metric.java @@ -0,0 +1,6 @@ +class Metric { + int measure(int value) { return value + 1; } + int report() { return measure(3); } + String label = "measure"; + // measure stays in this comment. +} diff --git a/gitnexus/test/fixtures/rename-integration/java/reserved-escaped-underscore/case.json b/gitnexus/test/fixtures/rename-integration/java/reserved-escaped-underscore/case.json new file mode 100644 index 000000000..65488cbf0 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/java/reserved-escaped-underscore/case.json @@ -0,0 +1,20 @@ +{ + "language": "java", + "description": "Refuse a Unicode escape that canonicalizes to the reserved single underscore.", + "categories": [ + "identifier-rules", + "reserved-words" + ], + "target": { + "name": "measure", + "kind": "Method", + "file": "Metric.java", + "startLine": 1 + }, + "newName": "\\u005f", + "expect": { + "status": "error", + "code": "invalid_name", + "planningStatus": "blocked" + } +} diff --git a/gitnexus/test/fixtures/rename-integration/java/reserved-single-underscore/after/Metric.java b/gitnexus/test/fixtures/rename-integration/java/reserved-single-underscore/after/Metric.java new file mode 100644 index 000000000..7e779f2a4 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/java/reserved-single-underscore/after/Metric.java @@ -0,0 +1,6 @@ +class Metric { + int measure(int value) { return value + 1; } + int report() { return measure(3); } + String label = "measure"; + // measure stays in this comment. +} diff --git a/gitnexus/test/fixtures/rename-integration/java/reserved-single-underscore/before/Metric.java b/gitnexus/test/fixtures/rename-integration/java/reserved-single-underscore/before/Metric.java new file mode 100644 index 000000000..7e779f2a4 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/java/reserved-single-underscore/before/Metric.java @@ -0,0 +1,6 @@ +class Metric { + int measure(int value) { return value + 1; } + int report() { return measure(3); } + String label = "measure"; + // measure stays in this comment. +} diff --git a/gitnexus/test/fixtures/rename-integration/java/reserved-single-underscore/case.json b/gitnexus/test/fixtures/rename-integration/java/reserved-single-underscore/case.json new file mode 100644 index 000000000..e1b2c8398 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/java/reserved-single-underscore/case.json @@ -0,0 +1,20 @@ +{ + "language": "java", + "description": "Refuse the single underscore reserved since Java 9 without changing the method or its call.", + "categories": [ + "identifier-rules", + "reserved-words" + ], + "target": { + "name": "measure", + "kind": "Method", + "file": "Metric.java", + "startLine": 1 + }, + "newName": "_", + "expect": { + "status": "error", + "code": "invalid_name", + "planningStatus": "blocked" + } +} diff --git a/gitnexus/test/fixtures/rename-integration/java/underscore-prefix-method/after/Metric.java b/gitnexus/test/fixtures/rename-integration/java/underscore-prefix-method/after/Metric.java new file mode 100644 index 000000000..1cf51bd2d --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/java/underscore-prefix-method/after/Metric.java @@ -0,0 +1,6 @@ +class Metric { + int _measure(int value) { return value + 1; } + int report() { return _measure(3); } + String label = "measure"; + // measure stays in this comment. +} diff --git a/gitnexus/test/fixtures/rename-integration/java/underscore-prefix-method/before/Metric.java b/gitnexus/test/fixtures/rename-integration/java/underscore-prefix-method/before/Metric.java new file mode 100644 index 000000000..7e779f2a4 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/java/underscore-prefix-method/before/Metric.java @@ -0,0 +1,6 @@ +class Metric { + int measure(int value) { return value + 1; } + int report() { return measure(3); } + String label = "measure"; + // measure stays in this comment. +} diff --git a/gitnexus/test/fixtures/rename-integration/java/underscore-prefix-method/case.json b/gitnexus/test/fixtures/rename-integration/java/underscore-prefix-method/case.json new file mode 100644 index 000000000..c615fd814 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/java/underscore-prefix-method/case.json @@ -0,0 +1,19 @@ +{ + "language": "java", + "description": "Allow an underscore within a longer Java method identifier, preserving comments and strings.", + "categories": [ + "identifier-rules", + "contextual-identifiers" + ], + "target": { + "name": "measure", + "kind": "Method", + "file": "Metric.java", + "startLine": 1 + }, + "newName": "_measure", + "expect": { + "status": "success", + "edits": 2 + } +} diff --git a/gitnexus/test/fixtures/rename-integration/javascript/escaped-alias-shorthand/after/entry.js b/gitnexus/test/fixtures/rename-integration/javascript/escaped-alias-shorthand/after/entry.js new file mode 100644 index 000000000..8ce45e940 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/javascript/escaped-alias-shorthand/after/entry.js @@ -0,0 +1,6 @@ +import { compute } from "./source.js"; +import { middle } from "./middle.js"; +export const handlers = { calculate: compute }; +export function entry() { return compute() + middle(); } +function shadow(calculate) { return calculate; } +const label = "calculate"; diff --git a/gitnexus/test/fixtures/rename-integration/javascript/escaped-alias-shorthand/after/middle.js b/gitnexus/test/fixtures/rename-integration/javascript/escaped-alias-shorthand/after/middle.js new file mode 100644 index 000000000..85bd16548 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/javascript/escaped-alias-shorthand/after/middle.js @@ -0,0 +1,2 @@ +import { compute as local } from "./source.js"; +export function middle() { return local(); } diff --git a/gitnexus/test/fixtures/rename-integration/javascript/escaped-alias-shorthand/after/source.js b/gitnexus/test/fixtures/rename-integration/javascript/escaped-alias-shorthand/after/source.js new file mode 100644 index 000000000..96eb515b4 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/javascript/escaped-alias-shorthand/after/source.js @@ -0,0 +1,2 @@ +export function compute() { return 7; } +// calculate stays here. diff --git a/gitnexus/test/fixtures/rename-integration/javascript/escaped-alias-shorthand/before/entry.js b/gitnexus/test/fixtures/rename-integration/javascript/escaped-alias-shorthand/before/entry.js new file mode 100644 index 000000000..7db87692b --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/javascript/escaped-alias-shorthand/before/entry.js @@ -0,0 +1,6 @@ +import { calculate } from "./source.js"; +import { middle } from "./middle.js"; +export const handlers = { calculate }; +export function entry() { return calculate() + middle(); } +function shadow(calculate) { return calculate; } +const label = "calculate"; diff --git a/gitnexus/test/fixtures/rename-integration/javascript/escaped-alias-shorthand/before/middle.js b/gitnexus/test/fixtures/rename-integration/javascript/escaped-alias-shorthand/before/middle.js new file mode 100644 index 000000000..a5b1c12db --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/javascript/escaped-alias-shorthand/before/middle.js @@ -0,0 +1,2 @@ +import { calculate as local } from "./source.js"; +export function middle() { return local(); } diff --git a/gitnexus/test/fixtures/rename-integration/javascript/escaped-alias-shorthand/before/source.js b/gitnexus/test/fixtures/rename-integration/javascript/escaped-alias-shorthand/before/source.js new file mode 100644 index 000000000..b8fd9c52f --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/javascript/escaped-alias-shorthand/before/source.js @@ -0,0 +1,2 @@ +export function calc\u0075late() { return 7; } +// calculate stays here. diff --git a/gitnexus/test/fixtures/rename-integration/javascript/escaped-alias-shorthand/case.json b/gitnexus/test/fixtures/rename-integration/javascript/escaped-alias-shorthand/case.json new file mode 100644 index 000000000..a7107461a --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/javascript/escaped-alias-shorthand/case.json @@ -0,0 +1,37 @@ +{ + "language": "javascript", + "description": "Rename an escaped declaration across imports and shorthand while preserving explicit aliases, labels and local binders.", + "categories": [ + "cross-file", + "alias", + "call-graph", + "shorthand", + "escaped-identifier", + "shadowing" + ], + "target": { + "name": "calc\\u0075late", + "kind": "Function", + "file": "source.js" + }, + "newName": "compute", + "expect": { + "status": "success", + "edits": 5 + }, + "edges": [ + { + "type": "CALLS", + "from": { + "name": "entry", + "kind": "Function", + "file": "entry.js" + }, + "to": { + "name": "middle", + "kind": "Function", + "file": "middle.js" + } + } + ] +} diff --git a/gitnexus/test/fixtures/rename-integration/javascript/escaped-callable-flow/after/callbacks.js b/gitnexus/test/fixtures/rename-integration/javascript/escaped-callable-flow/after/callbacks.js new file mode 100644 index 000000000..6943367c9 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/javascript/escaped-callable-flow/after/callbacks.js @@ -0,0 +1,4 @@ +function compute() { return 7; } +function invoke(c\u0062) { return c\u0062(); } +export function run() { const c\u0062 = compute; const next = cb; return next(); } +export function throughParameter() { return invoke(compute); } diff --git a/gitnexus/test/fixtures/rename-integration/javascript/escaped-callable-flow/before/callbacks.js b/gitnexus/test/fixtures/rename-integration/javascript/escaped-callable-flow/before/callbacks.js new file mode 100644 index 000000000..8659ff937 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/javascript/escaped-callable-flow/before/callbacks.js @@ -0,0 +1,4 @@ +function calc\u0075late() { return 7; } +function invoke(c\u0062) { return c\u0062(); } +export function run() { const c\u0062 = calc\u0075late; const next = cb; return next(); } +export function throughParameter() { return invoke(calc\u0075late); } diff --git a/gitnexus/test/fixtures/rename-integration/javascript/escaped-callable-flow/case.json b/gitnexus/test/fixtures/rename-integration/javascript/escaped-callable-flow/case.json new file mode 100644 index 000000000..037990aee --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/javascript/escaped-callable-flow/case.json @@ -0,0 +1,62 @@ +{ + "language": "javascript", + "description": "Keep escaped semantic keys aligned through persisted indexing, cached reload, and exact rename.", + "categories": [ + "escaped-identifier", + "call-graph", + "cross-file" + ], + "target": { + "name": "calc\\u0075late", + "kind": "Function", + "file": "callbacks.js" + }, + "newName": "compute", + "expect": { + "status": "success", + "edits": 3 + }, + "edges": [ + { + "type": "CALLS", + "from": { + "name": "run", + "kind": "Function", + "file": "callbacks.js" + }, + "to": { + "name": "calc\\u0075late", + "kind": "Function", + "file": "callbacks.js" + } + }, + { + "type": "CALLS", + "from": { + "name": "throughParameter", + "kind": "Function", + "file": "callbacks.js" + }, + "to": { + "name": "invoke", + "kind": "Function", + "file": "callbacks.js" + } + }, + { + "type": "CALLS", + "from": { + "name": "invoke", + "kind": "Function", + "file": "callbacks.js" + }, + "to": { + "name": "calc\\u0075late", + "kind": "Function", + "file": "callbacks.js" + } + } + ], + "exactOutgoing": true, + "warmParseCache": true +} diff --git a/gitnexus/test/fixtures/rename-integration/javascript/escaped-import-local-spelling/after/entry.js b/gitnexus/test/fixtures/rename-integration/javascript/escaped-import-local-spelling/after/entry.js new file mode 100644 index 000000000..b08cae1d4 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/javascript/escaped-import-local-spelling/after/entry.js @@ -0,0 +1,2 @@ +import { compute } from "./source"; +export function run() { return compute(); } diff --git a/gitnexus/test/fixtures/rename-integration/javascript/escaped-import-local-spelling/after/source.js b/gitnexus/test/fixtures/rename-integration/javascript/escaped-import-local-spelling/after/source.js new file mode 100644 index 000000000..394b03271 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/javascript/escaped-import-local-spelling/after/source.js @@ -0,0 +1 @@ +export function compute() { return 7; } diff --git a/gitnexus/test/fixtures/rename-integration/javascript/escaped-import-local-spelling/before/entry.js b/gitnexus/test/fixtures/rename-integration/javascript/escaped-import-local-spelling/before/entry.js new file mode 100644 index 000000000..7e98d0616 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/javascript/escaped-import-local-spelling/before/entry.js @@ -0,0 +1,2 @@ +import { calc\u0075late } from "./source"; +export function run() { return calculate(); } diff --git a/gitnexus/test/fixtures/rename-integration/javascript/escaped-import-local-spelling/before/source.js b/gitnexus/test/fixtures/rename-integration/javascript/escaped-import-local-spelling/before/source.js new file mode 100644 index 000000000..57c202da5 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/javascript/escaped-import-local-spelling/before/source.js @@ -0,0 +1 @@ +export function calculate() { return 7; } diff --git a/gitnexus/test/fixtures/rename-integration/javascript/escaped-import-local-spelling/case.json b/gitnexus/test/fixtures/rename-integration/javascript/escaped-import-local-spelling/case.json new file mode 100644 index 000000000..eef8fa5ee --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/javascript/escaped-import-local-spelling/case.json @@ -0,0 +1,35 @@ +{ + "language": "javascript", + "description": "Bind an escaped import to an ordinarily spelled local call before and after rename.", + "categories": [ + "escaped-identifier", + "cross-file", + "call-graph" + ], + "target": { + "name": "calculate", + "kind": "Function", + "file": "source.js" + }, + "newName": "compute", + "expect": { + "status": "success", + "edits": 3 + }, + "edges": [ + { + "type": "CALLS", + "from": { + "name": "run", + "kind": "Function", + "file": "entry.js" + }, + "to": { + "name": "calculate", + "kind": "Function", + "file": "source.js" + } + } + ], + "exactOutgoing": true +} diff --git a/gitnexus/test/fixtures/rename-integration/javascript/escaped-import-spellings/after/barrel.js b/gitnexus/test/fixtures/rename-integration/javascript/escaped-import-spellings/after/barrel.js new file mode 100644 index 000000000..3bcca15ef --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/javascript/escaped-import-spellings/after/barrel.js @@ -0,0 +1 @@ +export { compute as public\u0043alculate } from "./source.js"; diff --git a/gitnexus/test/fixtures/rename-integration/javascript/escaped-import-spellings/after/different.js b/gitnexus/test/fixtures/rename-integration/javascript/escaped-import-spellings/after/different.js new file mode 100644 index 000000000..5cca70c00 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/javascript/escaped-import-spellings/after/different.js @@ -0,0 +1,2 @@ +import { compute as stable } from "./source.js"; +export function different() { return stable(); } diff --git a/gitnexus/test/fixtures/rename-integration/javascript/escaped-import-spellings/after/entry.js b/gitnexus/test/fixtures/rename-integration/javascript/escaped-import-spellings/after/entry.js new file mode 100644 index 000000000..4ff86a8ec --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/javascript/escaped-import-spellings/after/entry.js @@ -0,0 +1,2 @@ +import { publicC\u0061lculate as stable } from "./barrel.js"; +export function entry() { return stable(); } diff --git a/gitnexus/test/fixtures/rename-integration/javascript/escaped-import-spellings/after/plain.js b/gitnexus/test/fixtures/rename-integration/javascript/escaped-import-spellings/after/plain.js new file mode 100644 index 000000000..61df35b07 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/javascript/escaped-import-spellings/after/plain.js @@ -0,0 +1,2 @@ +import { compute } from "./source.js"; +export function plain() { return compute(); } diff --git a/gitnexus/test/fixtures/rename-integration/javascript/escaped-import-spellings/after/same.js b/gitnexus/test/fixtures/rename-integration/javascript/escaped-import-spellings/after/same.js new file mode 100644 index 000000000..061f40457 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/javascript/escaped-import-spellings/after/same.js @@ -0,0 +1,2 @@ +import { compute } from "./source.js"; +export function same() { return compute(); } diff --git a/gitnexus/test/fixtures/rename-integration/javascript/escaped-import-spellings/after/source.js b/gitnexus/test/fixtures/rename-integration/javascript/escaped-import-spellings/after/source.js new file mode 100644 index 000000000..394b03271 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/javascript/escaped-import-spellings/after/source.js @@ -0,0 +1 @@ +export function compute() { return 7; } diff --git a/gitnexus/test/fixtures/rename-integration/javascript/escaped-import-spellings/before/barrel.js b/gitnexus/test/fixtures/rename-integration/javascript/escaped-import-spellings/before/barrel.js new file mode 100644 index 000000000..fc2f6f8d7 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/javascript/escaped-import-spellings/before/barrel.js @@ -0,0 +1 @@ +export { calcu\u006cate as public\u0043alculate } from "./source.js"; diff --git a/gitnexus/test/fixtures/rename-integration/javascript/escaped-import-spellings/before/different.js b/gitnexus/test/fixtures/rename-integration/javascript/escaped-import-spellings/before/different.js new file mode 100644 index 000000000..442483dc8 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/javascript/escaped-import-spellings/before/different.js @@ -0,0 +1,2 @@ +import { calcu\u006cate as stable } from "./source.js"; +export function different() { return stable(); } diff --git a/gitnexus/test/fixtures/rename-integration/javascript/escaped-import-spellings/before/entry.js b/gitnexus/test/fixtures/rename-integration/javascript/escaped-import-spellings/before/entry.js new file mode 100644 index 000000000..4ff86a8ec --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/javascript/escaped-import-spellings/before/entry.js @@ -0,0 +1,2 @@ +import { publicC\u0061lculate as stable } from "./barrel.js"; +export function entry() { return stable(); } diff --git a/gitnexus/test/fixtures/rename-integration/javascript/escaped-import-spellings/before/plain.js b/gitnexus/test/fixtures/rename-integration/javascript/escaped-import-spellings/before/plain.js new file mode 100644 index 000000000..d62ad6b56 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/javascript/escaped-import-spellings/before/plain.js @@ -0,0 +1,2 @@ +import { calculate } from "./source.js"; +export function plain() { return calculate(); } diff --git a/gitnexus/test/fixtures/rename-integration/javascript/escaped-import-spellings/before/same.js b/gitnexus/test/fixtures/rename-integration/javascript/escaped-import-spellings/before/same.js new file mode 100644 index 000000000..4fc44e5ee --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/javascript/escaped-import-spellings/before/same.js @@ -0,0 +1,2 @@ +import { calc\u0075late } from "./source.js"; +export function same() { return calc\u0075late(); } diff --git a/gitnexus/test/fixtures/rename-integration/javascript/escaped-import-spellings/before/source.js b/gitnexus/test/fixtures/rename-integration/javascript/escaped-import-spellings/before/source.js new file mode 100644 index 000000000..42f29babb --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/javascript/escaped-import-spellings/before/source.js @@ -0,0 +1 @@ +export function calc\u0075late() { return 7; } diff --git a/gitnexus/test/fixtures/rename-integration/javascript/escaped-import-spellings/case.json b/gitnexus/test/fixtures/rename-integration/javascript/escaped-import-spellings/case.json new file mode 100644 index 000000000..be2223db0 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/javascript/escaped-import-spellings/case.json @@ -0,0 +1,74 @@ +{ + "language": "javascript", + "description": "Preserve calls through identical, different and canonical import spellings, including an escaped re-export alias.", + "categories": [ + "cross-file", + "escaped-identifier", + "alias", + "call-graph" + ], + "target": { + "name": "calc\\u0075late", + "kind": "Function", + "file": "source.js" + }, + "newName": "compute", + "expect": { + "status": "success", + "edits": 7 + }, + "edges": [ + { + "type": "CALLS", + "from": { + "name": "same", + "kind": "Function", + "file": "same.js" + }, + "to": { + "name": "calc\\u0075late", + "kind": "Function", + "file": "source.js" + } + }, + { + "type": "CALLS", + "from": { + "name": "different", + "kind": "Function", + "file": "different.js" + }, + "to": { + "name": "calc\\u0075late", + "kind": "Function", + "file": "source.js" + } + }, + { + "type": "CALLS", + "from": { + "name": "plain", + "kind": "Function", + "file": "plain.js" + }, + "to": { + "name": "calc\\u0075late", + "kind": "Function", + "file": "source.js" + } + }, + { + "type": "CALLS", + "from": { + "name": "entry", + "kind": "Function", + "file": "entry.js" + }, + "to": { + "name": "calc\\u0075late", + "kind": "Function", + "file": "source.js" + } + } + ] +} diff --git a/gitnexus/test/fixtures/rename-integration/javascript/external-base-family/after/model.js b/gitnexus/test/fixtures/rename-integration/javascript/external-base-family/after/model.js new file mode 100644 index 000000000..2b35ff903 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/javascript/external-base-family/after/model.js @@ -0,0 +1,5 @@ +import { External } from "vendor"; + +export class Worker extends External { + target() { return 1; } +} diff --git a/gitnexus/test/fixtures/rename-integration/javascript/external-base-family/after/notes.txt b/gitnexus/test/fixtures/rename-integration/javascript/external-base-family/after/notes.txt new file mode 100644 index 000000000..81db7fc3a --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/javascript/external-base-family/after/notes.txt @@ -0,0 +1 @@ +External contract and target spelling remain unchanged. diff --git a/gitnexus/test/fixtures/rename-integration/javascript/external-base-family/before/model.js b/gitnexus/test/fixtures/rename-integration/javascript/external-base-family/before/model.js new file mode 100644 index 000000000..2b35ff903 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/javascript/external-base-family/before/model.js @@ -0,0 +1,5 @@ +import { External } from "vendor"; + +export class Worker extends External { + target() { return 1; } +} diff --git a/gitnexus/test/fixtures/rename-integration/javascript/external-base-family/before/notes.txt b/gitnexus/test/fixtures/rename-integration/javascript/external-base-family/before/notes.txt new file mode 100644 index 000000000..81db7fc3a --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/javascript/external-base-family/before/notes.txt @@ -0,0 +1 @@ +External contract and target spelling remain unchanged. diff --git a/gitnexus/test/fixtures/rename-integration/javascript/external-base-family/case.json b/gitnexus/test/fixtures/rename-integration/javascript/external-base-family/case.json new file mode 100644 index 000000000..af9897276 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/javascript/external-base-family/case.json @@ -0,0 +1,22 @@ +{ + "language": "javascript", + "description": "Refuse a potentially polymorphic member whose external base is not indexed.", + "categories": [ + "polymorphism", + "external-base", + "negative", + "unchanged-on-error" + ], + "target": { + "name": "target", + "kind": "Method", + "file": "model.js", + "startLine": 3 + }, + "newName": "renamed", + "expect": { + "status": "error", + "code": "unsupported_symbol_family", + "planningStatus": "unsupported" + } +} diff --git a/gitnexus/test/fixtures/rename-integration/javascript/inherited-method-family/after/model.js b/gitnexus/test/fixtures/rename-integration/javascript/inherited-method-family/after/model.js new file mode 100644 index 000000000..d6e473f0b --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/javascript/inherited-method-family/after/model.js @@ -0,0 +1,16 @@ +class Base { + paint() { return "render"; } +} +class Child extends Base { + paint() { return super.paint(); } +} +class Other { + render() { return "other"; } +} +const base = new Base(); +const child = new Child(); +const other = new Other(); +base.paint(); +child.paint(); +other.render(); +// render is documentation. diff --git a/gitnexus/test/fixtures/rename-integration/javascript/inherited-method-family/before/model.js b/gitnexus/test/fixtures/rename-integration/javascript/inherited-method-family/before/model.js new file mode 100644 index 000000000..696fb4a0c --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/javascript/inherited-method-family/before/model.js @@ -0,0 +1,16 @@ +class Base { + render() { return "render"; } +} +class Child extends Base { + render() { return super.render(); } +} +class Other { + render() { return "other"; } +} +const base = new Base(); +const child = new Child(); +const other = new Other(); +base.render(); +child.render(); +other.render(); +// render is documentation. diff --git a/gitnexus/test/fixtures/rename-integration/javascript/inherited-method-family/case.json b/gitnexus/test/fixtures/rename-integration/javascript/inherited-method-family/case.json new file mode 100644 index 000000000..9b49196e0 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/javascript/inherited-method-family/case.json @@ -0,0 +1,51 @@ +{ + "language": "javascript", + "description": "Rename an overridden instance method and super dispatch, leaving an unrelated class untouched.", + "categories": [ + "polymorphism", + "call-graph", + "unrelated-owner" + ], + "target": { + "name": "render", + "kind": "Method", + "file": "model.js", + "startLine": 1 + }, + "newName": "paint", + "expect": { + "status": "success", + "edits": 5 + }, + "edges": [ + { + "type": "METHOD_OVERRIDES", + "from": { + "name": "Child", + "kind": "Class", + "file": "model.js" + }, + "to": { + "name": "render", + "kind": "Method", + "file": "model.js", + "startLine": 1 + } + }, + { + "type": "CALLS", + "from": { + "name": "render", + "kind": "Method", + "file": "model.js", + "startLine": 4 + }, + "to": { + "name": "render", + "kind": "Method", + "file": "model.js", + "startLine": 1 + } + } + ] +} diff --git a/gitnexus/test/fixtures/rename-integration/javascript/member-destructure-refusal/after/model.js b/gitnexus/test/fixtures/rename-integration/javascript/member-destructure-refusal/after/model.js new file mode 100644 index 000000000..21f9c1eb1 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/javascript/member-destructure-refusal/after/model.js @@ -0,0 +1,8 @@ +export class Writer { + close() { return 1; } +} +export function run() { + const writer = new Writer(); + const { close: closeWriter } = writer; + return writer.close(); +} diff --git a/gitnexus/test/fixtures/rename-integration/javascript/member-destructure-refusal/before/model.js b/gitnexus/test/fixtures/rename-integration/javascript/member-destructure-refusal/before/model.js new file mode 100644 index 000000000..21f9c1eb1 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/javascript/member-destructure-refusal/before/model.js @@ -0,0 +1,8 @@ +export class Writer { + close() { return 1; } +} +export function run() { + const writer = new Writer(); + const { close: closeWriter } = writer; + return writer.close(); +} diff --git a/gitnexus/test/fixtures/rename-integration/javascript/member-destructure-refusal/case.json b/gitnexus/test/fixtures/rename-integration/javascript/member-destructure-refusal/case.json new file mode 100644 index 000000000..d72037647 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/javascript/member-destructure-refusal/case.json @@ -0,0 +1,38 @@ +{ + "language": "javascript", + "description": "Refuse an unproven object or destructuring member key, preserving all source bytes.", + "categories": [ + "member-keys", + "call-graph", + "negative", + "unchanged-on-error" + ], + "target": { + "name": "close", + "kind": "Method", + "file": "model.js", + "startLine": 1 + }, + "newName": "finish", + "expect": { + "status": "error", + "code": "ambiguous_reference", + "planningStatus": "unsupported" + }, + "edges": [ + { + "type": "CALLS", + "from": { + "name": "run", + "kind": "Function", + "file": "model.js" + }, + "to": { + "name": "close", + "kind": "Method", + "file": "model.js", + "startLine": 1 + } + } + ] +} diff --git a/gitnexus/test/fixtures/rename-integration/javascript/member-object-key-refusal/after/model.js b/gitnexus/test/fixtures/rename-integration/javascript/member-object-key-refusal/after/model.js new file mode 100644 index 000000000..300fad501 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/javascript/member-object-key-refusal/after/model.js @@ -0,0 +1,8 @@ +export class Writer { + close() { return 1; } +} +export function run() { + const writer = new Writer(); + const backup = { close() { return 2; } }; + return writer.close(); +} diff --git a/gitnexus/test/fixtures/rename-integration/javascript/member-object-key-refusal/before/model.js b/gitnexus/test/fixtures/rename-integration/javascript/member-object-key-refusal/before/model.js new file mode 100644 index 000000000..300fad501 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/javascript/member-object-key-refusal/before/model.js @@ -0,0 +1,8 @@ +export class Writer { + close() { return 1; } +} +export function run() { + const writer = new Writer(); + const backup = { close() { return 2; } }; + return writer.close(); +} diff --git a/gitnexus/test/fixtures/rename-integration/javascript/member-object-key-refusal/case.json b/gitnexus/test/fixtures/rename-integration/javascript/member-object-key-refusal/case.json new file mode 100644 index 000000000..d72037647 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/javascript/member-object-key-refusal/case.json @@ -0,0 +1,38 @@ +{ + "language": "javascript", + "description": "Refuse an unproven object or destructuring member key, preserving all source bytes.", + "categories": [ + "member-keys", + "call-graph", + "negative", + "unchanged-on-error" + ], + "target": { + "name": "close", + "kind": "Method", + "file": "model.js", + "startLine": 1 + }, + "newName": "finish", + "expect": { + "status": "error", + "code": "ambiguous_reference", + "planningStatus": "unsupported" + }, + "edges": [ + { + "type": "CALLS", + "from": { + "name": "run", + "kind": "Function", + "file": "model.js" + }, + "to": { + "name": "close", + "kind": "Method", + "file": "model.js", + "startLine": 1 + } + } + ] +} diff --git a/gitnexus/test/fixtures/rename-integration/javascript/prototype-function-this-refusal/after/model.js b/gitnexus/test/fixtures/rename-integration/javascript/prototype-function-this-refusal/after/model.js new file mode 100644 index 000000000..bbb1c08b2 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/javascript/prototype-function-this-refusal/after/model.js @@ -0,0 +1,4 @@ +export class Writer { + closeWriter() { return 1; } +} +Writer.prototype.flush = function () { return this.closeWriter(); }; diff --git a/gitnexus/test/fixtures/rename-integration/javascript/prototype-function-this-refusal/before/model.js b/gitnexus/test/fixtures/rename-integration/javascript/prototype-function-this-refusal/before/model.js new file mode 100644 index 000000000..bbb1c08b2 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/javascript/prototype-function-this-refusal/before/model.js @@ -0,0 +1,4 @@ +export class Writer { + closeWriter() { return 1; } +} +Writer.prototype.flush = function () { return this.closeWriter(); }; diff --git a/gitnexus/test/fixtures/rename-integration/javascript/prototype-function-this-refusal/case.json b/gitnexus/test/fixtures/rename-integration/javascript/prototype-function-this-refusal/case.json new file mode 100644 index 000000000..3d699fa3b --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/javascript/prototype-function-this-refusal/case.json @@ -0,0 +1,17 @@ +{ + "language": "javascript", + "description": "Refuse an ordinary function whose `this` is bound by the call site; the written member call is not provably unrelated, so every file must stay unchanged.", + "categories": ["receiver", "this-binding", "negative", "unchanged-on-error"], + "target": { + "name": "closeWriter", + "kind": "Method", + "file": "model.js", + "startLine": 1 + }, + "newName": "shutdown", + "expect": { + "status": "error", + "code": "ambiguous_reference", + "planningStatus": "blocked" + } +} diff --git a/gitnexus/test/fixtures/rename-integration/javascript/public-field-chain/after/model.js b/gitnexus/test/fixtures/rename-integration/javascript/public-field-chain/after/model.js new file mode 100644 index 000000000..605f38ef6 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/javascript/public-field-chain/after/model.js @@ -0,0 +1,18 @@ +class Leaf { + points = 0; +} +class Branch { + leaf = new Leaf(); +} +class Root { + branch = new Branch(); +} +class Other { + score = 9; +} +const root = new Root(); +const other = new Other(); +root.branch.leaf.points = root.branch.leaf.points + 1; +const value = root.branch.leaf.points + other.score; +// score stays here. +const label = "score"; diff --git a/gitnexus/test/fixtures/rename-integration/javascript/public-field-chain/before/model.js b/gitnexus/test/fixtures/rename-integration/javascript/public-field-chain/before/model.js new file mode 100644 index 000000000..7fee0845e --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/javascript/public-field-chain/before/model.js @@ -0,0 +1,18 @@ +class Leaf { + score = 0; +} +class Branch { + leaf = new Leaf(); +} +class Root { + branch = new Branch(); +} +class Other { + score = 9; +} +const root = new Root(); +const other = new Other(); +root.branch.leaf.score = root.branch.leaf.score + 1; +const value = root.branch.leaf.score + other.score; +// score stays here. +const label = "score"; diff --git a/gitnexus/test/fixtures/rename-integration/javascript/public-field-chain/case.json b/gitnexus/test/fixtures/rename-integration/javascript/public-field-chain/case.json new file mode 100644 index 000000000..66a492142 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/javascript/public-field-chain/case.json @@ -0,0 +1,21 @@ +{ + "language": "javascript", + "description": "Rename an inferred public class field through a three-hop constructed receiver, preserving another owner.", + "categories": [ + "public-field", + "deep-chain", + "read-write" + ], + "target": { + "name": "score", + "kind": "Property", + "file": "model.js", + "startLine": 1 + }, + "newName": "points", + "expect": { + "status": "success", + "edits": 4 + }, + "edges": [] +} diff --git a/gitnexus/test/fixtures/rename-integration/kotlin/data-class-copy-label/after/Model.kt b/gitnexus/test/fixtures/rename-integration/kotlin/data-class-copy-label/after/Model.kt new file mode 100644 index 000000000..0d24e8b77 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/kotlin/data-class-copy-label/after/Model.kt @@ -0,0 +1,3 @@ +data class Pair2(val first: Int, val second: Int) + +fun move(pair: Pair2): Pair2 = pair.copy(first = 3) diff --git a/gitnexus/test/fixtures/rename-integration/kotlin/data-class-copy-label/before/Model.kt b/gitnexus/test/fixtures/rename-integration/kotlin/data-class-copy-label/before/Model.kt new file mode 100644 index 000000000..0d24e8b77 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/kotlin/data-class-copy-label/before/Model.kt @@ -0,0 +1,3 @@ +data class Pair2(val first: Int, val second: Int) + +fun move(pair: Pair2): Pair2 = pair.copy(first = 3) diff --git a/gitnexus/test/fixtures/rename-integration/kotlin/data-class-copy-label/case.json b/gitnexus/test/fixtures/rename-integration/kotlin/data-class-copy-label/case.json new file mode 100644 index 000000000..a008632d0 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/kotlin/data-class-copy-label/case.json @@ -0,0 +1,22 @@ +{ + "language": "kotlin", + "description": "Refuse a data class property whose label names the synthesized copy parameter.", + "categories": [ + "named-argument", + "synthesized-method", + "negative", + "unchanged-on-error" + ], + "target": { + "name": "first", + "kind": "Property", + "file": "Model.kt", + "startLine": 0 + }, + "newName": "head", + "expect": { + "status": "error", + "code": "ambiguous_reference", + "planningStatus": "unsupported" + } +} diff --git a/gitnexus/test/fixtures/rename-integration/kotlin/escaped-class-import/after/app/Main.kt b/gitnexus/test/fixtures/rename-integration/kotlin/escaped-class-import/after/app/Main.kt new file mode 100644 index 000000000..8bb894b18 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/kotlin/escaped-class-import/after/app/Main.kt @@ -0,0 +1,3 @@ +package app +import models.`Worker` as W +fun run(): Int { val item = W(); return item.fetch() } diff --git a/gitnexus/test/fixtures/rename-integration/kotlin/escaped-class-import/after/models/Models.kt b/gitnexus/test/fixtures/rename-integration/kotlin/escaped-class-import/after/models/Models.kt new file mode 100644 index 000000000..4db2ea290 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/kotlin/escaped-class-import/after/models/Models.kt @@ -0,0 +1,4 @@ +package models +class `Worker` { + fun fetch(): Int { return 7 } +} diff --git a/gitnexus/test/fixtures/rename-integration/kotlin/escaped-class-import/before/app/Main.kt b/gitnexus/test/fixtures/rename-integration/kotlin/escaped-class-import/before/app/Main.kt new file mode 100644 index 000000000..ecb9e8d32 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/kotlin/escaped-class-import/before/app/Main.kt @@ -0,0 +1,3 @@ +package app +import models.`Worker` as W +fun run(): Int { val item = W(); return item.read() } diff --git a/gitnexus/test/fixtures/rename-integration/kotlin/escaped-class-import/before/models/Models.kt b/gitnexus/test/fixtures/rename-integration/kotlin/escaped-class-import/before/models/Models.kt new file mode 100644 index 000000000..4530c85cd --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/kotlin/escaped-class-import/before/models/Models.kt @@ -0,0 +1,4 @@ +package models +class `Worker` { + fun read(): Int { return 7 } +} diff --git a/gitnexus/test/fixtures/rename-integration/kotlin/escaped-class-import/case.json b/gitnexus/test/fixtures/rename-integration/kotlin/escaped-class-import/case.json new file mode 100644 index 000000000..11b6ce56c --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/kotlin/escaped-class-import/case.json @@ -0,0 +1,36 @@ +{ + "language": "kotlin", + "description": "Keep escaped semantic keys aligned through persisted indexing, cached reload, and exact rename.", + "categories": [ + "escaped-identifier", + "call-graph", + "cross-file" + ], + "target": { + "name": "read", + "kind": "Method", + "file": "models/Models.kt" + }, + "newName": "fetch", + "expect": { + "status": "success", + "edits": 2 + }, + "edges": [ + { + "type": "CALLS", + "from": { + "name": "run", + "kind": "Function", + "file": "app/Main.kt" + }, + "to": { + "name": "read", + "kind": "Method", + "file": "models/Models.kt" + } + } + ], + "exactOutgoing": true, + "warmParseCache": true +} diff --git a/gitnexus/test/fixtures/rename-integration/kotlin/escaped-member-identity/after/worker.kt b/gitnexus/test/fixtures/rename-integration/kotlin/escaped-member-identity/after/worker.kt new file mode 100644 index 000000000..5a3e47a5c --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/kotlin/escaped-member-identity/after/worker.kt @@ -0,0 +1,4 @@ +class Worker { + fun `fetch`(): Int { return 7 } +} +fun run(): Int { val `worker` = Worker(); return `worker`.`fetch`() } diff --git a/gitnexus/test/fixtures/rename-integration/kotlin/escaped-member-identity/before/worker.kt b/gitnexus/test/fixtures/rename-integration/kotlin/escaped-member-identity/before/worker.kt new file mode 100644 index 000000000..737005655 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/kotlin/escaped-member-identity/before/worker.kt @@ -0,0 +1,4 @@ +class Worker { + fun `read`(): Int { return 7 } +} +fun run(): Int { val `worker` = Worker(); return `worker`.`read`() } diff --git a/gitnexus/test/fixtures/rename-integration/kotlin/escaped-member-identity/case.json b/gitnexus/test/fixtures/rename-integration/kotlin/escaped-member-identity/case.json new file mode 100644 index 000000000..4b01cc64d --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/kotlin/escaped-member-identity/case.json @@ -0,0 +1,36 @@ +{ + "language": "kotlin", + "description": "Retain raw member declaration keys alongside canonical escaped lexical receivers.", + "categories": [ + "escaped-identifier", + "call-graph" + ], + "target": { + "name": "`read`", + "kind": "Method", + "file": "worker.kt" + }, + "newName": "fetch", + "expectedTargetName": "`fetch`", + "expect": { + "status": "success", + "edits": 2 + }, + "edges": [ + { + "type": "CALLS", + "from": { + "name": "run", + "kind": "Function", + "file": "worker.kt" + }, + "to": { + "name": "`read`", + "kind": "Method", + "file": "worker.kt" + } + } + ], + "exactOutgoing": true, + "warmParseCache": true +} diff --git a/gitnexus/test/fixtures/rename-integration/kotlin/escaped-overload-call-graph/after/encoder.kt b/gitnexus/test/fixtures/rename-integration/kotlin/escaped-overload-call-graph/after/encoder.kt new file mode 100644 index 000000000..179d6d03b --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/kotlin/escaped-overload-call-graph/after/encoder.kt @@ -0,0 +1,17 @@ +fun `serialize`(): String { return "encode" } +fun serialize(value: Int): String { return "number" } +fun middle(): String { + val callback: () -> String = ::serialize + return serialize() + serialize(7) +} +fun entry(): String { return middle() } +fun local(encode: String): String { return encode } +class OtherEncoder { + fun encode(): String { return "other" } + fun use(): String { return encode() } +} +// encode is documented, not renamed +fun referenceOnly(): () -> String { + val callback: () -> String = ::serialize + return callback +} diff --git a/gitnexus/test/fixtures/rename-integration/kotlin/escaped-overload-call-graph/before/encoder.kt b/gitnexus/test/fixtures/rename-integration/kotlin/escaped-overload-call-graph/before/encoder.kt new file mode 100644 index 000000000..d7eba39ff --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/kotlin/escaped-overload-call-graph/before/encoder.kt @@ -0,0 +1,17 @@ +fun `encode`(): String { return "encode" } +fun encode(value: Int): String { return "number" } +fun middle(): String { + val callback: () -> String = ::encode + return encode() + encode(7) +} +fun entry(): String { return middle() } +fun local(encode: String): String { return encode } +class OtherEncoder { + fun encode(): String { return "other" } + fun use(): String { return encode() } +} +// encode is documented, not renamed +fun referenceOnly(): () -> String { + val callback: () -> String = ::encode + return callback +} diff --git a/gitnexus/test/fixtures/rename-integration/kotlin/escaped-overload-call-graph/case.json b/gitnexus/test/fixtures/rename-integration/kotlin/escaped-overload-call-graph/case.json new file mode 100644 index 000000000..767f4ba03 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/kotlin/escaped-overload-call-graph/case.json @@ -0,0 +1,101 @@ +{ + "language": "kotlin", + "description": "Rename escaped Kotlin overload declarations, callable references and calls while preserving local shadowing and unrelated members.", + "categories": [ + "escaped-identifier", + "overloads", + "call-graph", + "shadowing", + "unrelated-owner" + ], + "target": { + "name": "`encode`", + "kind": "Function", + "file": "encoder.kt", + "startLine": 0 + }, + "newName": "serialize", + "expect": { + "status": "success", + "edits": 6 + }, + "edges": [ + { + "type": "CALLS", + "from": { + "name": "entry", + "kind": "Function", + "file": "encoder.kt", + "startLine": 6 + }, + "to": { + "name": "middle", + "kind": "Function", + "file": "encoder.kt", + "startLine": 2 + } + }, + { + "type": "CALLS", + "from": { + "name": "middle", + "kind": "Function", + "file": "encoder.kt", + "startLine": 2 + }, + "to": { + "name": "`encode`", + "kind": "Function", + "file": "encoder.kt", + "startLine": 0 + } + }, + { + "type": "CALLS", + "from": { + "name": "middle", + "kind": "Function", + "file": "encoder.kt", + "startLine": 2 + }, + "to": { + "name": "encode", + "kind": "Function", + "file": "encoder.kt", + "startLine": 1 + } + }, + { + "type": "CALLS", + "from": { + "name": "use", + "kind": "Method", + "file": "encoder.kt", + "startLine": 10 + }, + "to": { + "name": "encode", + "kind": "Method", + "file": "encoder.kt", + "startLine": 9 + } + }, + { + "type": "CALLS", + "from": { + "name": "referenceOnly", + "kind": "Function", + "file": "encoder.kt", + "startLine": 13 + }, + "to": { + "name": "`encode`", + "kind": "Function", + "file": "encoder.kt", + "startLine": 0 + } + } + ], + "expectedTargetName": "`serialize`", + "exactOutgoing": true +} diff --git a/gitnexus/test/fixtures/rename-integration/kotlin/escaped-receiver-identity/after/worker.kt b/gitnexus/test/fixtures/rename-integration/kotlin/escaped-receiver-identity/after/worker.kt new file mode 100644 index 000000000..cbd8ff8b7 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/kotlin/escaped-receiver-identity/after/worker.kt @@ -0,0 +1,7 @@ +class `Worker` { + fun readValue(): Int { return 1 } +} +fun run(): Int { + val `worker`: `Worker` = `Worker`() + return `worker`.readValue() +} diff --git a/gitnexus/test/fixtures/rename-integration/kotlin/escaped-receiver-identity/before/worker.kt b/gitnexus/test/fixtures/rename-integration/kotlin/escaped-receiver-identity/before/worker.kt new file mode 100644 index 000000000..aff85fbc3 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/kotlin/escaped-receiver-identity/before/worker.kt @@ -0,0 +1,7 @@ +class `Worker` { + fun read(): Int { return 1 } +} +fun run(): Int { + val `worker`: `Worker` = `Worker`() + return `worker`.read() +} diff --git a/gitnexus/test/fixtures/rename-integration/kotlin/escaped-receiver-identity/case.json b/gitnexus/test/fixtures/rename-integration/kotlin/escaped-receiver-identity/case.json new file mode 100644 index 000000000..0d97712b3 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/kotlin/escaped-receiver-identity/case.json @@ -0,0 +1,37 @@ +{ + "language": "kotlin", + "description": "Preserve member calls through matching escaped type and local receiver spellings while renaming the member.", + "categories": [ + "escaped-identifier", + "receiver", + "call-graph" + ], + "target": { + "name": "read", + "kind": "Method", + "file": "worker.kt", + "startLine": 1 + }, + "newName": "readValue", + "expect": { + "status": "success", + "edits": 2 + }, + "edges": [ + { + "type": "CALLS", + "from": { + "name": "run", + "kind": "Function", + "file": "worker.kt", + "startLine": 3 + }, + "to": { + "name": "read", + "kind": "Method", + "file": "worker.kt", + "startLine": 1 + } + } + ] +} diff --git a/gitnexus/test/fixtures/rename-integration/kotlin/external-base-family/after/model.kt b/gitnexus/test/fixtures/rename-integration/kotlin/external-base-family/after/model.kt new file mode 100644 index 000000000..b8f8b8725 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/kotlin/external-base-family/after/model.kt @@ -0,0 +1,5 @@ +import vendor.External + +class Worker : External() { + override fun target(): Int = 1 +} diff --git a/gitnexus/test/fixtures/rename-integration/kotlin/external-base-family/after/notes.txt b/gitnexus/test/fixtures/rename-integration/kotlin/external-base-family/after/notes.txt new file mode 100644 index 000000000..81db7fc3a --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/kotlin/external-base-family/after/notes.txt @@ -0,0 +1 @@ +External contract and target spelling remain unchanged. diff --git a/gitnexus/test/fixtures/rename-integration/kotlin/external-base-family/before/model.kt b/gitnexus/test/fixtures/rename-integration/kotlin/external-base-family/before/model.kt new file mode 100644 index 000000000..b8f8b8725 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/kotlin/external-base-family/before/model.kt @@ -0,0 +1,5 @@ +import vendor.External + +class Worker : External() { + override fun target(): Int = 1 +} diff --git a/gitnexus/test/fixtures/rename-integration/kotlin/external-base-family/before/notes.txt b/gitnexus/test/fixtures/rename-integration/kotlin/external-base-family/before/notes.txt new file mode 100644 index 000000000..81db7fc3a --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/kotlin/external-base-family/before/notes.txt @@ -0,0 +1 @@ +External contract and target spelling remain unchanged. diff --git a/gitnexus/test/fixtures/rename-integration/kotlin/external-base-family/case.json b/gitnexus/test/fixtures/rename-integration/kotlin/external-base-family/case.json new file mode 100644 index 000000000..aa542d0c3 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/kotlin/external-base-family/case.json @@ -0,0 +1,22 @@ +{ + "language": "kotlin", + "description": "Refuse a potentially polymorphic member whose external base is not indexed.", + "categories": [ + "polymorphism", + "external-base", + "negative", + "unchanged-on-error" + ], + "target": { + "name": "target", + "kind": "Method", + "file": "model.kt", + "startLine": 3 + }, + "newName": "renamed", + "expect": { + "status": "error", + "code": "unsupported_symbol_family", + "planningStatus": "unsupported" + } +} diff --git a/gitnexus/test/fixtures/rename-integration/kotlin/implicit-external-base-family/after/model.kt b/gitnexus/test/fixtures/rename-integration/kotlin/implicit-external-base-family/after/model.kt new file mode 100644 index 000000000..00f72b917 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/kotlin/implicit-external-base-family/after/model.kt @@ -0,0 +1,3 @@ +class Worker { + override fun toString(): String = "worker" +} diff --git a/gitnexus/test/fixtures/rename-integration/kotlin/implicit-external-base-family/after/notes.txt b/gitnexus/test/fixtures/rename-integration/kotlin/implicit-external-base-family/after/notes.txt new file mode 100644 index 000000000..41df97c4b --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/kotlin/implicit-external-base-family/after/notes.txt @@ -0,0 +1 @@ +An explicit override still requires its external base member. diff --git a/gitnexus/test/fixtures/rename-integration/kotlin/implicit-external-base-family/before/model.kt b/gitnexus/test/fixtures/rename-integration/kotlin/implicit-external-base-family/before/model.kt new file mode 100644 index 000000000..00f72b917 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/kotlin/implicit-external-base-family/before/model.kt @@ -0,0 +1,3 @@ +class Worker { + override fun toString(): String = "worker" +} diff --git a/gitnexus/test/fixtures/rename-integration/kotlin/implicit-external-base-family/before/notes.txt b/gitnexus/test/fixtures/rename-integration/kotlin/implicit-external-base-family/before/notes.txt new file mode 100644 index 000000000..41df97c4b --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/kotlin/implicit-external-base-family/before/notes.txt @@ -0,0 +1 @@ +An explicit override still requires its external base member. diff --git a/gitnexus/test/fixtures/rename-integration/kotlin/implicit-external-base-family/case.json b/gitnexus/test/fixtures/rename-integration/kotlin/implicit-external-base-family/case.json new file mode 100644 index 000000000..0926d94be --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/kotlin/implicit-external-base-family/case.json @@ -0,0 +1,23 @@ +{ + "language": "kotlin", + "description": "Refuse an explicit override of an unindexed implicit base member.", + "categories": [ + "polymorphism", + "external-base", + "implicit-base", + "negative", + "unchanged-on-error" + ], + "target": { + "name": "toString", + "kind": "Method", + "file": "model.kt", + "startLine": 1 + }, + "newName": "description", + "expect": { + "status": "error", + "code": "unsupported_symbol_family", + "planningStatus": "unsupported" + } +} diff --git a/gitnexus/test/fixtures/rename-integration/kotlin/interop-java-accessor/after/Caller.java b/gitnexus/test/fixtures/rename-integration/kotlin/interop-java-accessor/after/Caller.java new file mode 100644 index 000000000..a40ba59f1 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/kotlin/interop-java-accessor/after/Caller.java @@ -0,0 +1,3 @@ +class Caller { + String call(Writer writer) { return writer.getTitle(); } +} diff --git a/gitnexus/test/fixtures/rename-integration/kotlin/interop-java-accessor/after/Writer.kt b/gitnexus/test/fixtures/rename-integration/kotlin/interop-java-accessor/after/Writer.kt new file mode 100644 index 000000000..d8997501b --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/kotlin/interop-java-accessor/after/Writer.kt @@ -0,0 +1,3 @@ +class Writer { + var title: String = "draft" +} diff --git a/gitnexus/test/fixtures/rename-integration/kotlin/interop-java-accessor/before/Caller.java b/gitnexus/test/fixtures/rename-integration/kotlin/interop-java-accessor/before/Caller.java new file mode 100644 index 000000000..a40ba59f1 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/kotlin/interop-java-accessor/before/Caller.java @@ -0,0 +1,3 @@ +class Caller { + String call(Writer writer) { return writer.getTitle(); } +} diff --git a/gitnexus/test/fixtures/rename-integration/kotlin/interop-java-accessor/before/Writer.kt b/gitnexus/test/fixtures/rename-integration/kotlin/interop-java-accessor/before/Writer.kt new file mode 100644 index 000000000..d8997501b --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/kotlin/interop-java-accessor/before/Writer.kt @@ -0,0 +1,3 @@ +class Writer { + var title: String = "draft" +} diff --git a/gitnexus/test/fixtures/rename-integration/kotlin/interop-java-accessor/case.json b/gitnexus/test/fixtures/rename-integration/kotlin/interop-java-accessor/case.json new file mode 100644 index 000000000..116e98158 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/kotlin/interop-java-accessor/case.json @@ -0,0 +1,20 @@ +{ + "language": "kotlin", + "description": "Refuse an indexed interoperating source name until cross-provider binding coverage is proven.", + "categories": [ + "cross-language", + "interop", + "comments-and-strings" + ], + "target": { + "name": "title", + "kind": "Property", + "file": "Writer.kt" + }, + "newName": "heading", + "expect": { + "status": "error", + "code": "unsupported_boundary", + "planningStatus": "unsupported" + } +} diff --git a/gitnexus/test/fixtures/rename-integration/kotlin/interop-java-call/after/Caller.java b/gitnexus/test/fixtures/rename-integration/kotlin/interop-java-call/after/Caller.java new file mode 100644 index 000000000..ed3fd26b1 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/kotlin/interop-java-call/after/Caller.java @@ -0,0 +1,3 @@ +class Caller { + void call(Writer writer) { writer.close(); } +} diff --git a/gitnexus/test/fixtures/rename-integration/kotlin/interop-java-call/after/Writer.kt b/gitnexus/test/fixtures/rename-integration/kotlin/interop-java-call/after/Writer.kt new file mode 100644 index 000000000..2551a43c2 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/kotlin/interop-java-call/after/Writer.kt @@ -0,0 +1,3 @@ +class Writer { + fun close() {} +} diff --git a/gitnexus/test/fixtures/rename-integration/kotlin/interop-java-call/before/Caller.java b/gitnexus/test/fixtures/rename-integration/kotlin/interop-java-call/before/Caller.java new file mode 100644 index 000000000..ed3fd26b1 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/kotlin/interop-java-call/before/Caller.java @@ -0,0 +1,3 @@ +class Caller { + void call(Writer writer) { writer.close(); } +} diff --git a/gitnexus/test/fixtures/rename-integration/kotlin/interop-java-call/before/Writer.kt b/gitnexus/test/fixtures/rename-integration/kotlin/interop-java-call/before/Writer.kt new file mode 100644 index 000000000..2551a43c2 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/kotlin/interop-java-call/before/Writer.kt @@ -0,0 +1,3 @@ +class Writer { + fun close() {} +} diff --git a/gitnexus/test/fixtures/rename-integration/kotlin/interop-java-call/case.json b/gitnexus/test/fixtures/rename-integration/kotlin/interop-java-call/case.json new file mode 100644 index 000000000..9262154c3 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/kotlin/interop-java-call/case.json @@ -0,0 +1,20 @@ +{ + "language": "kotlin", + "description": "Refuse an indexed interoperating source name until cross-provider binding coverage is proven.", + "categories": [ + "cross-language", + "interop", + "comments-and-strings" + ], + "target": { + "name": "close", + "kind": "Method", + "file": "Writer.kt" + }, + "newName": "finish", + "expect": { + "status": "error", + "code": "unsupported_boundary", + "planningStatus": "unsupported" + } +} diff --git a/gitnexus/test/fixtures/rename-integration/kotlin/interop-java-destination/after/Caller.java b/gitnexus/test/fixtures/rename-integration/kotlin/interop-java-destination/after/Caller.java new file mode 100644 index 000000000..5926692f1 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/kotlin/interop-java-destination/after/Caller.java @@ -0,0 +1,3 @@ +class Caller { + void call(Writer writer) { writer.finish(); } +} diff --git a/gitnexus/test/fixtures/rename-integration/kotlin/interop-java-destination/after/Writer.kt b/gitnexus/test/fixtures/rename-integration/kotlin/interop-java-destination/after/Writer.kt new file mode 100644 index 000000000..2551a43c2 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/kotlin/interop-java-destination/after/Writer.kt @@ -0,0 +1,3 @@ +class Writer { + fun close() {} +} diff --git a/gitnexus/test/fixtures/rename-integration/kotlin/interop-java-destination/before/Caller.java b/gitnexus/test/fixtures/rename-integration/kotlin/interop-java-destination/before/Caller.java new file mode 100644 index 000000000..5926692f1 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/kotlin/interop-java-destination/before/Caller.java @@ -0,0 +1,3 @@ +class Caller { + void call(Writer writer) { writer.finish(); } +} diff --git a/gitnexus/test/fixtures/rename-integration/kotlin/interop-java-destination/before/Writer.kt b/gitnexus/test/fixtures/rename-integration/kotlin/interop-java-destination/before/Writer.kt new file mode 100644 index 000000000..2551a43c2 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/kotlin/interop-java-destination/before/Writer.kt @@ -0,0 +1,3 @@ +class Writer { + fun close() {} +} diff --git a/gitnexus/test/fixtures/rename-integration/kotlin/interop-java-destination/case.json b/gitnexus/test/fixtures/rename-integration/kotlin/interop-java-destination/case.json new file mode 100644 index 000000000..9262154c3 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/kotlin/interop-java-destination/case.json @@ -0,0 +1,20 @@ +{ + "language": "kotlin", + "description": "Refuse an indexed interoperating source name until cross-provider binding coverage is proven.", + "categories": [ + "cross-language", + "interop", + "comments-and-strings" + ], + "target": { + "name": "close", + "kind": "Method", + "file": "Writer.kt" + }, + "newName": "finish", + "expect": { + "status": "error", + "code": "unsupported_boundary", + "planningStatus": "unsupported" + } +} diff --git a/gitnexus/test/fixtures/rename-integration/kotlin/interop-java-unrelated/after/Other.java b/gitnexus/test/fixtures/rename-integration/kotlin/interop-java-unrelated/after/Other.java new file mode 100644 index 000000000..8ccc61930 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/kotlin/interop-java-unrelated/after/Other.java @@ -0,0 +1,3 @@ +class Other { + String note() { return "close finish"; } // close finish +} diff --git a/gitnexus/test/fixtures/rename-integration/kotlin/interop-java-unrelated/after/Writer.kt b/gitnexus/test/fixtures/rename-integration/kotlin/interop-java-unrelated/after/Writer.kt new file mode 100644 index 000000000..e64a4d1b4 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/kotlin/interop-java-unrelated/after/Writer.kt @@ -0,0 +1,4 @@ +class Writer { + fun finish() {} + fun use() { finish() } +} diff --git a/gitnexus/test/fixtures/rename-integration/kotlin/interop-java-unrelated/before/Other.java b/gitnexus/test/fixtures/rename-integration/kotlin/interop-java-unrelated/before/Other.java new file mode 100644 index 000000000..8ccc61930 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/kotlin/interop-java-unrelated/before/Other.java @@ -0,0 +1,3 @@ +class Other { + String note() { return "close finish"; } // close finish +} diff --git a/gitnexus/test/fixtures/rename-integration/kotlin/interop-java-unrelated/before/Writer.kt b/gitnexus/test/fixtures/rename-integration/kotlin/interop-java-unrelated/before/Writer.kt new file mode 100644 index 000000000..9e6f9b1eb --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/kotlin/interop-java-unrelated/before/Writer.kt @@ -0,0 +1,4 @@ +class Writer { + fun close() {} + fun use() { close() } +} diff --git a/gitnexus/test/fixtures/rename-integration/kotlin/interop-java-unrelated/case.json b/gitnexus/test/fixtures/rename-integration/kotlin/interop-java-unrelated/case.json new file mode 100644 index 000000000..39611f2a0 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/kotlin/interop-java-unrelated/case.json @@ -0,0 +1,35 @@ +{ + "language": "kotlin", + "description": "Unrelated interoperating source names, comments and strings do not block a proven rename.", + "categories": [ + "cross-language", + "interop", + "comments-and-strings" + ], + "target": { + "name": "close", + "kind": "Method", + "file": "Writer.kt" + }, + "newName": "finish", + "expect": { + "status": "success", + "edits": 2 + }, + "edges": [ + { + "type": "CALLS", + "from": { + "name": "use", + "kind": "Method", + "file": "Writer.kt" + }, + "to": { + "name": "close", + "kind": "Method", + "file": "Writer.kt" + } + } + ], + "exactOutgoing": true +} diff --git a/gitnexus/test/fixtures/rename-integration/kotlin/polymorphic-delivery/after/delivery.kt b/gitnexus/test/fixtures/rename-integration/kotlin/polymorphic-delivery/after/delivery.kt new file mode 100644 index 000000000..a72805d3b --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/kotlin/polymorphic-delivery/after/delivery.kt @@ -0,0 +1,19 @@ +interface Courier { + fun deliver() +} +open class StandardCourier : Courier { + override fun deliver() {} +} +class ExpressCourier : StandardCourier() { + override fun deliver() { super.deliver() } +} +fun send(contract: Courier, standard: StandardCourier, express: ExpressCourier) { + contract.deliver() + standard.deliver() + express.deliver() +} +class UnrelatedCourier { + fun dispatch() {} + fun use() { dispatch() } +} +val note = "dispatch" // dispatch is documentation diff --git a/gitnexus/test/fixtures/rename-integration/kotlin/polymorphic-delivery/before/delivery.kt b/gitnexus/test/fixtures/rename-integration/kotlin/polymorphic-delivery/before/delivery.kt new file mode 100644 index 000000000..ffc5b8ad5 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/kotlin/polymorphic-delivery/before/delivery.kt @@ -0,0 +1,19 @@ +interface Courier { + fun dispatch() +} +open class StandardCourier : Courier { + override fun dispatch() {} +} +class ExpressCourier : StandardCourier() { + override fun dispatch() { super.dispatch() } +} +fun send(contract: Courier, standard: StandardCourier, express: ExpressCourier) { + contract.dispatch() + standard.dispatch() + express.dispatch() +} +class UnrelatedCourier { + fun dispatch() {} + fun use() { dispatch() } +} +val note = "dispatch" // dispatch is documentation diff --git a/gitnexus/test/fixtures/rename-integration/kotlin/polymorphic-delivery/case.json b/gitnexus/test/fixtures/rename-integration/kotlin/polymorphic-delivery/case.json new file mode 100644 index 000000000..814838072 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/kotlin/polymorphic-delivery/case.json @@ -0,0 +1,68 @@ +{ + "language": "kotlin", + "description": "Rename a Kotlin interface family across overrides and super calls while keeping another class independent.", + "categories": [ + "polymorphism", + "call-graph", + "unrelated-owner", + "comments-and-strings" + ], + "target": { + "name": "dispatch", + "kind": "Method", + "file": "delivery.kt", + "startLine": 1 + }, + "newName": "deliver", + "expect": { + "status": "success", + "edits": 7 + }, + "edges": [ + { + "type": "METHOD_IMPLEMENTS", + "from": { + "name": "dispatch", + "kind": "Method", + "file": "delivery.kt", + "startLine": 4 + }, + "to": { + "name": "dispatch", + "kind": "Method", + "file": "delivery.kt", + "startLine": 1 + } + }, + { + "type": "METHOD_OVERRIDES", + "from": { + "name": "ExpressCourier", + "kind": "Class", + "file": "delivery.kt", + "startLine": 6 + }, + "to": { + "name": "dispatch", + "kind": "Method", + "file": "delivery.kt", + "startLine": 4 + } + }, + { + "type": "CALLS", + "from": { + "name": "send", + "kind": "Function", + "file": "delivery.kt", + "startLine": 9 + }, + "to": { + "name": "dispatch", + "kind": "Method", + "file": "delivery.kt", + "startLine": 1 + } + } + ] +} diff --git a/gitnexus/test/fixtures/rename-integration/kotlin/primary-property-named-argument/after/Model.kt b/gitnexus/test/fixtures/rename-integration/kotlin/primary-property-named-argument/after/Model.kt new file mode 100644 index 000000000..208448623 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/kotlin/primary-property-named-argument/after/Model.kt @@ -0,0 +1,3 @@ +data class Pair2(val first: Int, val second: Int) + +fun make(): Pair2 = Pair2(first = 1, second = 2) diff --git a/gitnexus/test/fixtures/rename-integration/kotlin/primary-property-named-argument/before/Model.kt b/gitnexus/test/fixtures/rename-integration/kotlin/primary-property-named-argument/before/Model.kt new file mode 100644 index 000000000..208448623 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/kotlin/primary-property-named-argument/before/Model.kt @@ -0,0 +1,3 @@ +data class Pair2(val first: Int, val second: Int) + +fun make(): Pair2 = Pair2(first = 1, second = 2) diff --git a/gitnexus/test/fixtures/rename-integration/kotlin/primary-property-named-argument/case.json b/gitnexus/test/fixtures/rename-integration/kotlin/primary-property-named-argument/case.json new file mode 100644 index 000000000..45773d154 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/kotlin/primary-property-named-argument/case.json @@ -0,0 +1,22 @@ +{ + "language": "kotlin", + "description": "Refuse a primary-constructor property whose named argument label binds the constructor parameter.", + "categories": [ + "named-argument", + "synthesized-constructor", + "negative", + "unchanged-on-error" + ], + "target": { + "name": "first", + "kind": "Property", + "file": "Model.kt", + "startLine": 0 + }, + "newName": "head", + "expect": { + "status": "error", + "code": "ambiguous_reference", + "planningStatus": "unsupported" + } +} diff --git a/gitnexus/test/fixtures/rename-integration/kotlin/public-property-chain/after/profile.kt b/gitnexus/test/fixtures/rename-integration/kotlin/public-property-chain/after/profile.kt new file mode 100644 index 000000000..9f86bfe38 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/kotlin/public-property-chain/after/profile.kt @@ -0,0 +1,17 @@ +class Profile { + var displayBadge: String = "badge" +} +class Account { + lateinit var profile: Profile +} +class Session { + lateinit var account: Account +} +fun read(session: Session): String { return session.account.profile.displayBadge } +fun update(session: Session, badge: String) { session.account.profile.displayBadge = badge } +fun direct(profile: Profile): String { return profile.displayBadge } +class OtherProfile { + var badge: String = "badge" + fun read(other: OtherProfile): String { return other.badge } +} +// badge is public; this comment is unchanged diff --git a/gitnexus/test/fixtures/rename-integration/kotlin/public-property-chain/before/profile.kt b/gitnexus/test/fixtures/rename-integration/kotlin/public-property-chain/before/profile.kt new file mode 100644 index 000000000..6ff9ca7ec --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/kotlin/public-property-chain/before/profile.kt @@ -0,0 +1,17 @@ +class Profile { + var badge: String = "badge" +} +class Account { + lateinit var profile: Profile +} +class Session { + lateinit var account: Account +} +fun read(session: Session): String { return session.account.profile.badge } +fun update(session: Session, badge: String) { session.account.profile.badge = badge } +fun direct(profile: Profile): String { return profile.badge } +class OtherProfile { + var badge: String = "badge" + fun read(other: OtherProfile): String { return other.badge } +} +// badge is public; this comment is unchanged diff --git a/gitnexus/test/fixtures/rename-integration/kotlin/public-property-chain/case.json b/gitnexus/test/fixtures/rename-integration/kotlin/public-property-chain/case.json new file mode 100644 index 000000000..0da7ea517 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/kotlin/public-property-chain/case.json @@ -0,0 +1,53 @@ +{ + "language": "kotlin", + "description": "Rename a public Kotlin property across typed lateinit receiver chains, including a write with a shadow parameter.", + "categories": [ + "public-field", + "deep-chain", + "shadowing", + "unrelated-owner" + ], + "target": { + "name": "badge", + "kind": "Property", + "file": "profile.kt", + "startLine": 1 + }, + "newName": "displayBadge", + "expect": { + "status": "success", + "edits": 4 + }, + "edges": [ + { + "type": "ACCESSES", + "from": { + "name": "read", + "kind": "Function", + "file": "profile.kt", + "startLine": 9 + }, + "to": { + "name": "badge", + "kind": "Property", + "file": "profile.kt", + "startLine": 1 + } + }, + { + "type": "ACCESSES", + "from": { + "name": "update", + "kind": "Function", + "file": "profile.kt", + "startLine": 10 + }, + "to": { + "name": "badge", + "kind": "Property", + "file": "profile.kt", + "startLine": 1 + } + } + ] +} diff --git a/gitnexus/test/fixtures/rename-integration/kotlin/unrelated-named-argument/after/Model.kt b/gitnexus/test/fixtures/rename-integration/kotlin/unrelated-named-argument/after/Model.kt new file mode 100644 index 000000000..2d0b8f0d2 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/kotlin/unrelated-named-argument/after/Model.kt @@ -0,0 +1,12 @@ +data class Pair2(val head: Int, val second: Int) + +class Unrelated(val first: Int) + +fun configure(first: Int) {} + +fun make() { + Unrelated(first = 1) + configure(first = 2) +} + +fun read(pair: Pair2): Int = pair.head diff --git a/gitnexus/test/fixtures/rename-integration/kotlin/unrelated-named-argument/before/Model.kt b/gitnexus/test/fixtures/rename-integration/kotlin/unrelated-named-argument/before/Model.kt new file mode 100644 index 000000000..34dc02c96 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/kotlin/unrelated-named-argument/before/Model.kt @@ -0,0 +1,12 @@ +data class Pair2(val first: Int, val second: Int) + +class Unrelated(val first: Int) + +fun configure(first: Int) {} + +fun make() { + Unrelated(first = 1) + configure(first = 2) +} + +fun read(pair: Pair2): Int = pair.first diff --git a/gitnexus/test/fixtures/rename-integration/kotlin/unrelated-named-argument/case.json b/gitnexus/test/fixtures/rename-integration/kotlin/unrelated-named-argument/case.json new file mode 100644 index 000000000..f1b2e4701 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/kotlin/unrelated-named-argument/case.json @@ -0,0 +1,19 @@ +{ + "language": "kotlin", + "description": "Rename a primary-constructor property beside labels proven to bind another function or another type's property.", + "categories": [ + "named-argument", + "unrelated-name" + ], + "target": { + "name": "first", + "kind": "Property", + "file": "Model.kt", + "startLine": 0 + }, + "newName": "head", + "expect": { + "status": "success", + "edits": 2 + } +} diff --git a/gitnexus/test/fixtures/rename-integration/objective-c/c-helper-call-chain/after/Helpers.m b/gitnexus/test/fixtures/rename-integration/objective-c/c-helper-call-chain/after/Helpers.m new file mode 100644 index 000000000..0307cf61e --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/objective-c/c-helper-call-chain/after/Helpers.m @@ -0,0 +1,7 @@ +static int compute(int value) { return value; } +int leaf(void) { return compute(1); } +int middle(void) { return leaf(); } +int entry(void) { return middle(); } +int shadow(int calculate) { return calculate; } +const char *label = "calculate"; +// calculate remains a comment. diff --git a/gitnexus/test/fixtures/rename-integration/objective-c/c-helper-call-chain/before/Helpers.m b/gitnexus/test/fixtures/rename-integration/objective-c/c-helper-call-chain/before/Helpers.m new file mode 100644 index 000000000..adbc142fe --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/objective-c/c-helper-call-chain/before/Helpers.m @@ -0,0 +1,7 @@ +static int calculate(int value) { return value; } +int leaf(void) { return calculate(1); } +int middle(void) { return leaf(); } +int entry(void) { return middle(); } +int shadow(int calculate) { return calculate; } +const char *label = "calculate"; +// calculate remains a comment. diff --git a/gitnexus/test/fixtures/rename-integration/objective-c/c-helper-call-chain/case.json b/gitnexus/test/fixtures/rename-integration/objective-c/c-helper-call-chain/case.json new file mode 100644 index 000000000..4d28c289d --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/objective-c/c-helper-call-chain/case.json @@ -0,0 +1,62 @@ +{ + "language": "objective-c", + "description": "Rename an Objective-C file-local C helper at the leaf of a three-edge call graph without changing a shadowing parameter, comment or string.", + "categories": ["call-graph", "call-chain", "shadowing"], + "target": { + "name": "calculate", + "kind": "Function", + "file": "Helpers.m" + }, + "newName": "compute", + "expect": { + "status": "success", + "edits": 2 + }, + "edges": [ + { + "type": "CALLS", + "from": { + "name": "leaf", + "kind": "Function", + "file": "Helpers.m", + "startLine": 1 + }, + "to": { + "name": "calculate", + "kind": "Function", + "file": "Helpers.m", + "startLine": 0 + } + }, + { + "type": "CALLS", + "from": { + "name": "middle", + "kind": "Function", + "file": "Helpers.m", + "startLine": 2 + }, + "to": { + "name": "leaf", + "kind": "Function", + "file": "Helpers.m", + "startLine": 1 + } + }, + { + "type": "CALLS", + "from": { + "name": "entry", + "kind": "Function", + "file": "Helpers.m", + "startLine": 3 + }, + "to": { + "name": "middle", + "kind": "Function", + "file": "Helpers.m", + "startLine": 2 + } + } + ] +} diff --git a/gitnexus/test/fixtures/rename-integration/objective-c/interop-c-call/after/api.m b/gitnexus/test/fixtures/rename-integration/objective-c/interop-c-call/after/api.m new file mode 100644 index 000000000..7123d281c --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/objective-c/interop-c-call/after/api.m @@ -0,0 +1 @@ +int compute(void) { return 1; } diff --git a/gitnexus/test/fixtures/rename-integration/objective-c/interop-c-call/after/caller.c b/gitnexus/test/fixtures/rename-integration/objective-c/interop-c-call/after/caller.c new file mode 100644 index 000000000..e380c6dae --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/objective-c/interop-c-call/after/caller.c @@ -0,0 +1 @@ +int consume(void) { return compute(); } diff --git a/gitnexus/test/fixtures/rename-integration/objective-c/interop-c-call/before/api.m b/gitnexus/test/fixtures/rename-integration/objective-c/interop-c-call/before/api.m new file mode 100644 index 000000000..7123d281c --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/objective-c/interop-c-call/before/api.m @@ -0,0 +1 @@ +int compute(void) { return 1; } diff --git a/gitnexus/test/fixtures/rename-integration/objective-c/interop-c-call/before/caller.c b/gitnexus/test/fixtures/rename-integration/objective-c/interop-c-call/before/caller.c new file mode 100644 index 000000000..e380c6dae --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/objective-c/interop-c-call/before/caller.c @@ -0,0 +1 @@ +int consume(void) { return compute(); } diff --git a/gitnexus/test/fixtures/rename-integration/objective-c/interop-c-call/case.json b/gitnexus/test/fixtures/rename-integration/objective-c/interop-c-call/case.json new file mode 100644 index 000000000..422aaad9d --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/objective-c/interop-c-call/case.json @@ -0,0 +1,20 @@ +{ + "language": "objective-c", + "description": "Refuse an indexed interoperating source name until cross-provider binding coverage is proven.", + "categories": [ + "cross-language", + "interop", + "comments-and-strings" + ], + "target": { + "name": "compute", + "kind": "Function", + "file": "api.m" + }, + "newName": "calculate", + "expect": { + "status": "error", + "code": "unsupported_boundary", + "planningStatus": "unsupported" + } +} diff --git a/gitnexus/test/fixtures/rename-integration/objective-c/ivar-and-c-function/after/Worker.m b/gitnexus/test/fixtures/rename-integration/objective-c/ivar-and-c-function/after/Worker.m new file mode 100644 index 000000000..feae142eb --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/objective-c/ivar-and-c-function/after/Worker.m @@ -0,0 +1,13 @@ +static int compute(int value) { return value + 1; } +@interface Worker { +@public + int total; +} +@end +@implementation Worker ++ (int)callHelper { return compute(3); } +- (int)read { return total; } +- (int)readOther:(Worker *)other { return other->total; } +- (int)shadow:(int)compute { return compute; } +@end +int outside(void) { return compute(4); } diff --git a/gitnexus/test/fixtures/rename-integration/objective-c/ivar-and-c-function/before/Worker.m b/gitnexus/test/fixtures/rename-integration/objective-c/ivar-and-c-function/before/Worker.m new file mode 100644 index 000000000..13ff179fa --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/objective-c/ivar-and-c-function/before/Worker.m @@ -0,0 +1,13 @@ +static int compute(int value) { return value + 1; } +@interface Worker { +@public + int compute; +} +@end +@implementation Worker ++ (int)callHelper { return compute(3); } +- (int)read { return compute; } +- (int)readOther:(Worker *)other { return other->compute; } +- (int)shadow:(int)compute { return compute; } +@end +int outside(void) { return compute(4); } diff --git a/gitnexus/test/fixtures/rename-integration/objective-c/ivar-and-c-function/case.json b/gitnexus/test/fixtures/rename-integration/objective-c/ivar-and-c-function/case.json new file mode 100644 index 000000000..bf040fe1b --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/objective-c/ivar-and-c-function/case.json @@ -0,0 +1,44 @@ +{ + "language": "objective-c", + "description": "Rename an instance ivar through implicit self and explicit typed receivers while preserving same-name C function calls in class methods, free functions, and parameter shadows.", + "categories": ["public-fields", "lexical-shadowing", "class-instance-distinction"], + "target": { + "name": "compute", + "kind": "Variable", + "file": "Worker.m", + "startLine": 3 + }, + "newName": "total", + "expect": { + "status": "success", + "edits": 3 + }, + "edges": [ + { + "type": "CALLS", + "from": { + "name": "callHelper", + "kind": "Method", + "file": "Worker.m" + }, + "to": { + "name": "compute", + "kind": "Function", + "file": "Worker.m" + } + }, + { + "type": "CALLS", + "from": { + "name": "outside", + "kind": "Function", + "file": "Worker.m" + }, + "to": { + "name": "compute", + "kind": "Function", + "file": "Worker.m" + } + } + ] +} diff --git a/gitnexus/test/fixtures/rename-integration/objective-c/ivar-and-c-global/after/Worker.m b/gitnexus/test/fixtures/rename-integration/objective-c/ivar-and-c-global/after/Worker.m new file mode 100644 index 000000000..5c377bb66 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/objective-c/ivar-and-c-global/after/Worker.m @@ -0,0 +1,13 @@ +static int counter = 7; +@interface Worker { +@public + int total; +} +@end +@implementation Worker ++ (int)readGlobal { return counter; } +- (int)read { return total; } +- (int)readOther:(Worker *)other { return other->total; } +- (int)shadow:(int)counter { return counter; } +@end +int outside(void) { return counter; } diff --git a/gitnexus/test/fixtures/rename-integration/objective-c/ivar-and-c-global/before/Worker.m b/gitnexus/test/fixtures/rename-integration/objective-c/ivar-and-c-global/before/Worker.m new file mode 100644 index 000000000..265720d07 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/objective-c/ivar-and-c-global/before/Worker.m @@ -0,0 +1,13 @@ +static int counter = 7; +@interface Worker { +@public + int counter; +} +@end +@implementation Worker ++ (int)readGlobal { return counter; } +- (int)read { return counter; } +- (int)readOther:(Worker *)other { return other->counter; } +- (int)shadow:(int)counter { return counter; } +@end +int outside(void) { return counter; } diff --git a/gitnexus/test/fixtures/rename-integration/objective-c/ivar-and-c-global/case.json b/gitnexus/test/fixtures/rename-integration/objective-c/ivar-and-c-global/case.json new file mode 100644 index 000000000..6fb45a602 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/objective-c/ivar-and-c-global/case.json @@ -0,0 +1,16 @@ +{ + "language": "objective-c", + "description": "Rename an instance ivar through implicit self and explicit typed receivers while preserving same-name C global reads in class methods, free functions, and parameter shadows.", + "categories": ["public-fields", "lexical-shadowing", "class-instance-distinction"], + "target": { + "name": "counter", + "kind": "Variable", + "file": "Worker.m", + "startLine": 3 + }, + "newName": "total", + "expect": { + "status": "success", + "edits": 3 + } +} diff --git a/gitnexus/test/fixtures/rename-integration/objective-c/property-accessors-deep-chain/after/Model.m b/gitnexus/test/fixtures/rename-integration/objective-c/property-accessors-deep-chain/after/Model.m new file mode 100644 index 000000000..ec95b5992 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/objective-c/property-accessors-deep-chain/after/Model.m @@ -0,0 +1,18 @@ +@interface Leaf +@property int count; +@end +@interface Branch +@property Leaf *leaf; +@end +@interface Root +@property Branch *branch; +@end +@interface Other +@property int value; +@end +@implementation Leaf +@synthesize count = _count; +- (int)count { return _count; } +- (void)setCount:(int)value { _count = value; } +- (void)copy:(Root *)root other:(Other *)other { self.count = root.branch.leaf.count; other.value = 9; [self setCount:3]; } +@end diff --git a/gitnexus/test/fixtures/rename-integration/objective-c/property-accessors-deep-chain/before/Model.m b/gitnexus/test/fixtures/rename-integration/objective-c/property-accessors-deep-chain/before/Model.m new file mode 100644 index 000000000..244f17758 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/objective-c/property-accessors-deep-chain/before/Model.m @@ -0,0 +1,18 @@ +@interface Leaf +@property int value; +@end +@interface Branch +@property Leaf *leaf; +@end +@interface Root +@property Branch *branch; +@end +@interface Other +@property int value; +@end +@implementation Leaf +@synthesize value = _value; +- (int)value { return _value; } +- (void)setValue:(int)value { _value = value; } +- (void)copy:(Root *)root other:(Other *)other { self.value = root.branch.leaf.value; other.value = 9; [self setValue:3]; } +@end diff --git a/gitnexus/test/fixtures/rename-integration/objective-c/property-accessors-deep-chain/case.json b/gitnexus/test/fixtures/rename-integration/objective-c/property-accessors-deep-chain/case.json new file mode 100644 index 000000000..11e622176 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/objective-c/property-accessors-deep-chain/case.json @@ -0,0 +1,33 @@ +{ + "language": "objective-c", + "description": "Rename a property through a typed property chain, synthesized backing ivar, ordinary getter/setter and explicit accessor message.", + "categories": ["public-field", "deep-chain", "accessor-family"], + "target": { + "name": "value", + "kind": "Property", + "file": "Model.m", + "startLine": 1 + }, + "newName": "count", + "expect": { + "status": "success", + "edits": 10 + }, + "edges": [ + { + "type": "CALLS", + "from": { + "name": "copy:other:", + "kind": "Method", + "file": "Model.m", + "startLine": 16 + }, + "to": { + "name": "setValue:", + "kind": "Method", + "file": "Model.m", + "startLine": 15 + } + } + ] +} diff --git a/gitnexus/test/fixtures/rename-integration/objective-c/protocol-selector-family/after/Worker.m b/gitnexus/test/fixtures/rename-integration/objective-c/protocol-selector-family/after/Worker.m new file mode 100644 index 000000000..54e3db620 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/objective-c/protocol-selector-family/after/Worker.m @@ -0,0 +1,20 @@ +@protocol Runnable +- (void)execute; +@end +@interface Worker +- (void)execute; +@end +@interface Child : Worker +- (void)execute; +@end +@implementation Worker +- (void)execute {} +- (void)dispatch:(id)worker { [worker execute]; } +@end +@implementation Child +- (void)execute { [super execute]; [self execute]; } +@end +@interface Other +- (void)run; +@end +// run remains a comment. diff --git a/gitnexus/test/fixtures/rename-integration/objective-c/protocol-selector-family/before/Worker.m b/gitnexus/test/fixtures/rename-integration/objective-c/protocol-selector-family/before/Worker.m new file mode 100644 index 000000000..3497ae124 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/objective-c/protocol-selector-family/before/Worker.m @@ -0,0 +1,20 @@ +@protocol Runnable +- (void)run; +@end +@interface Worker +- (void)run; +@end +@interface Child : Worker +- (void)run; +@end +@implementation Worker +- (void)run {} +- (void)dispatch:(id)worker { [worker run]; } +@end +@implementation Child +- (void)run { [super run]; [self run]; } +@end +@interface Other +- (void)run; +@end +// run remains a comment. diff --git a/gitnexus/test/fixtures/rename-integration/objective-c/protocol-selector-family/case.json b/gitnexus/test/fixtures/rename-integration/objective-c/protocol-selector-family/case.json new file mode 100644 index 000000000..6a058f443 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/objective-c/protocol-selector-family/case.json @@ -0,0 +1,108 @@ +{ + "language": "objective-c", + "description": "Rename a protocol selector and its conforming implementation and inherited override, including protocol, self and super sends.", + "categories": ["polymorphism", "call-graph", "receiver-isolation"], + "target": { + "name": "run", + "kind": "Method", + "file": "Worker.m", + "startLine": 1 + }, + "newName": "execute", + "expect": { + "status": "success", + "edits": 8 + }, + "edges": [ + { + "type": "USES", + "from": { + "name": "[id run] protocol implementers", + "kind": "CodeElement", + "file": "Worker.m", + "startLine": 0 + }, + "to": { + "name": "run", + "kind": "Method", + "file": "Worker.m", + "startLine": 7 + } + }, + { + "type": "USES", + "from": { + "name": "[id run] protocol implementers", + "kind": "CodeElement", + "file": "Worker.m", + "startLine": 0 + }, + "to": { + "name": "run", + "kind": "Method", + "file": "Worker.m", + "startLine": 4 + } + }, + { + "type": "USES", + "from": { + "name": "dispatch:", + "kind": "Method", + "file": "Worker.m", + "startLine": 11 + }, + "to": { + "name": "[id run] protocol implementers", + "kind": "CodeElement", + "file": "Worker.m", + "startLine": 0 + } + }, + { + "type": "CALLS", + "from": { + "name": "dispatch:", + "kind": "Method", + "file": "Worker.m", + "startLine": 11 + }, + "to": { + "name": "run", + "kind": "Method", + "file": "Worker.m", + "startLine": 1 + } + }, + { + "type": "CALLS", + "from": { + "name": "run", + "kind": "Method", + "file": "Worker.m", + "startLine": 7 + }, + "to": { + "name": "run", + "kind": "Method", + "file": "Worker.m", + "startLine": 4 + } + }, + { + "type": "CALLS", + "from": { + "name": "run", + "kind": "Method", + "file": "Worker.m", + "startLine": 7 + }, + "to": { + "name": "run", + "kind": "Method", + "file": "Worker.m", + "startLine": 7 + } + } + ] +} diff --git a/gitnexus/test/fixtures/rename-integration/objective-c/swift-consumer/after/App.swift b/gitnexus/test/fixtures/rename-integration/objective-c/swift-consumer/after/App.swift new file mode 100644 index 000000000..bab54bc30 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/objective-c/swift-consumer/after/App.swift @@ -0,0 +1,3 @@ +func run(f: Foo) -> Int32 { + return f.fetchData() +} diff --git a/gitnexus/test/fixtures/rename-integration/objective-c/swift-consumer/after/Foo.h b/gitnexus/test/fixtures/rename-integration/objective-c/swift-consumer/after/Foo.h new file mode 100644 index 000000000..12aea3cbd --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/objective-c/swift-consumer/after/Foo.h @@ -0,0 +1,3 @@ +@interface Foo +- (int)fetchData; +@end diff --git a/gitnexus/test/fixtures/rename-integration/objective-c/swift-consumer/after/Foo.m b/gitnexus/test/fixtures/rename-integration/objective-c/swift-consumer/after/Foo.m new file mode 100644 index 000000000..621fcefe4 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/objective-c/swift-consumer/after/Foo.m @@ -0,0 +1,4 @@ +#import "Foo.h" +@implementation Foo +- (int)fetchData { return 1; } +@end diff --git a/gitnexus/test/fixtures/rename-integration/objective-c/swift-consumer/before/App.swift b/gitnexus/test/fixtures/rename-integration/objective-c/swift-consumer/before/App.swift new file mode 100644 index 000000000..bab54bc30 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/objective-c/swift-consumer/before/App.swift @@ -0,0 +1,3 @@ +func run(f: Foo) -> Int32 { + return f.fetchData() +} diff --git a/gitnexus/test/fixtures/rename-integration/objective-c/swift-consumer/before/Foo.h b/gitnexus/test/fixtures/rename-integration/objective-c/swift-consumer/before/Foo.h new file mode 100644 index 000000000..12aea3cbd --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/objective-c/swift-consumer/before/Foo.h @@ -0,0 +1,3 @@ +@interface Foo +- (int)fetchData; +@end diff --git a/gitnexus/test/fixtures/rename-integration/objective-c/swift-consumer/before/Foo.m b/gitnexus/test/fixtures/rename-integration/objective-c/swift-consumer/before/Foo.m new file mode 100644 index 000000000..621fcefe4 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/objective-c/swift-consumer/before/Foo.m @@ -0,0 +1,4 @@ +#import "Foo.h" +@implementation Foo +- (int)fetchData { return 1; } +@end diff --git a/gitnexus/test/fixtures/rename-integration/objective-c/swift-consumer/case.json b/gitnexus/test/fixtures/rename-integration/objective-c/swift-consumer/case.json new file mode 100644 index 000000000..e40b594db --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/objective-c/swift-consumer/case.json @@ -0,0 +1,21 @@ +{ + "language": "objective-c", + "description": "Refuse an Objective-C selector that an indexed Swift caller names, until the Swift binding is proven.", + "categories": [ + "cross-language", + "interop", + "negative", + "unchanged-on-error" + ], + "target": { + "name": "fetchData", + "kind": "Method", + "file": "Foo.h" + }, + "newName": "loadData", + "expect": { + "status": "error", + "code": "unsupported_boundary", + "planningStatus": "unsupported" + } +} diff --git a/gitnexus/test/fixtures/rename-integration/objective-c/swift-init-label-consumer/after/App.swift b/gitnexus/test/fixtures/rename-integration/objective-c/swift-init-label-consumer/after/App.swift new file mode 100644 index 000000000..7d0d50a43 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/objective-c/swift-init-label-consumer/after/App.swift @@ -0,0 +1,3 @@ +func make() -> Foo { + return Foo(name: 1) +} diff --git a/gitnexus/test/fixtures/rename-integration/objective-c/swift-init-label-consumer/after/Foo.h b/gitnexus/test/fixtures/rename-integration/objective-c/swift-init-label-consumer/after/Foo.h new file mode 100644 index 000000000..8cd0a19c6 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/objective-c/swift-init-label-consumer/after/Foo.h @@ -0,0 +1,3 @@ +@interface Foo +- (instancetype)initWithName:(int)name; +@end diff --git a/gitnexus/test/fixtures/rename-integration/objective-c/swift-init-label-consumer/after/Foo.m b/gitnexus/test/fixtures/rename-integration/objective-c/swift-init-label-consumer/after/Foo.m new file mode 100644 index 000000000..a743cbedc --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/objective-c/swift-init-label-consumer/after/Foo.m @@ -0,0 +1,4 @@ +#import "Foo.h" +@implementation Foo +- (instancetype)initWithName:(int)name { return self; } +@end diff --git a/gitnexus/test/fixtures/rename-integration/objective-c/swift-init-label-consumer/before/App.swift b/gitnexus/test/fixtures/rename-integration/objective-c/swift-init-label-consumer/before/App.swift new file mode 100644 index 000000000..7d0d50a43 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/objective-c/swift-init-label-consumer/before/App.swift @@ -0,0 +1,3 @@ +func make() -> Foo { + return Foo(name: 1) +} diff --git a/gitnexus/test/fixtures/rename-integration/objective-c/swift-init-label-consumer/before/Foo.h b/gitnexus/test/fixtures/rename-integration/objective-c/swift-init-label-consumer/before/Foo.h new file mode 100644 index 000000000..8cd0a19c6 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/objective-c/swift-init-label-consumer/before/Foo.h @@ -0,0 +1,3 @@ +@interface Foo +- (instancetype)initWithName:(int)name; +@end diff --git a/gitnexus/test/fixtures/rename-integration/objective-c/swift-init-label-consumer/before/Foo.m b/gitnexus/test/fixtures/rename-integration/objective-c/swift-init-label-consumer/before/Foo.m new file mode 100644 index 000000000..a743cbedc --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/objective-c/swift-init-label-consumer/before/Foo.m @@ -0,0 +1,4 @@ +#import "Foo.h" +@implementation Foo +- (instancetype)initWithName:(int)name { return self; } +@end diff --git a/gitnexus/test/fixtures/rename-integration/objective-c/swift-init-label-consumer/case.json b/gitnexus/test/fixtures/rename-integration/objective-c/swift-init-label-consumer/case.json new file mode 100644 index 000000000..bc7ca4a6d --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/objective-c/swift-init-label-consumer/case.json @@ -0,0 +1,21 @@ +{ + "language": "objective-c", + "description": "Refuse an Objective-C initWith selector that an indexed Swift caller constructs through argument labels, until the Swift binding is proven.", + "categories": [ + "cross-language", + "interop", + "negative", + "unchanged-on-error" + ], + "target": { + "name": "initWithName:", + "kind": "Method", + "file": "Foo.h" + }, + "newName": "initWithTitle:", + "expect": { + "status": "error", + "code": "unsupported_boundary", + "planningStatus": "unsupported" + } +} diff --git a/gitnexus/test/fixtures/rename-integration/objective-c/swift-multipart-consumer/after/App.swift b/gitnexus/test/fixtures/rename-integration/objective-c/swift-multipart-consumer/after/App.swift new file mode 100644 index 000000000..29e8c6797 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/objective-c/swift-multipart-consumer/after/App.swift @@ -0,0 +1,3 @@ +func run(foo: Foo) -> Int32 { + return foo.fetch(1, with: 2) +} diff --git a/gitnexus/test/fixtures/rename-integration/objective-c/swift-multipart-consumer/after/Foo.h b/gitnexus/test/fixtures/rename-integration/objective-c/swift-multipart-consumer/after/Foo.h new file mode 100644 index 000000000..92c029c77 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/objective-c/swift-multipart-consumer/after/Foo.h @@ -0,0 +1,3 @@ +@interface Foo +- (int)fetch:(int)key with:(int)value; +@end diff --git a/gitnexus/test/fixtures/rename-integration/objective-c/swift-multipart-consumer/after/Foo.m b/gitnexus/test/fixtures/rename-integration/objective-c/swift-multipart-consumer/after/Foo.m new file mode 100644 index 000000000..651001f67 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/objective-c/swift-multipart-consumer/after/Foo.m @@ -0,0 +1,4 @@ +#import "Foo.h" +@implementation Foo +- (int)fetch:(int)key with:(int)value { return key + value; } +@end diff --git a/gitnexus/test/fixtures/rename-integration/objective-c/swift-multipart-consumer/before/App.swift b/gitnexus/test/fixtures/rename-integration/objective-c/swift-multipart-consumer/before/App.swift new file mode 100644 index 000000000..29e8c6797 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/objective-c/swift-multipart-consumer/before/App.swift @@ -0,0 +1,3 @@ +func run(foo: Foo) -> Int32 { + return foo.fetch(1, with: 2) +} diff --git a/gitnexus/test/fixtures/rename-integration/objective-c/swift-multipart-consumer/before/Foo.h b/gitnexus/test/fixtures/rename-integration/objective-c/swift-multipart-consumer/before/Foo.h new file mode 100644 index 000000000..92c029c77 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/objective-c/swift-multipart-consumer/before/Foo.h @@ -0,0 +1,3 @@ +@interface Foo +- (int)fetch:(int)key with:(int)value; +@end diff --git a/gitnexus/test/fixtures/rename-integration/objective-c/swift-multipart-consumer/before/Foo.m b/gitnexus/test/fixtures/rename-integration/objective-c/swift-multipart-consumer/before/Foo.m new file mode 100644 index 000000000..651001f67 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/objective-c/swift-multipart-consumer/before/Foo.m @@ -0,0 +1,4 @@ +#import "Foo.h" +@implementation Foo +- (int)fetch:(int)key with:(int)value { return key + value; } +@end diff --git a/gitnexus/test/fixtures/rename-integration/objective-c/swift-multipart-consumer/case.json b/gitnexus/test/fixtures/rename-integration/objective-c/swift-multipart-consumer/case.json new file mode 100644 index 000000000..0da961d07 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/objective-c/swift-multipart-consumer/case.json @@ -0,0 +1,21 @@ +{ + "language": "objective-c", + "description": "Refuse a multipart Objective-C selector that an indexed Swift caller names by its base name, until the Swift binding is proven.", + "categories": [ + "cross-language", + "interop", + "negative", + "unchanged-on-error" + ], + "target": { + "name": "fetch:with:", + "kind": "Method", + "file": "Foo.h" + }, + "newName": "load:using:", + "expect": { + "status": "error", + "code": "unsupported_boundary", + "planningStatus": "unsupported" + } +} diff --git a/gitnexus/test/fixtures/rename-integration/objective-c/swift-name-attribute-embedded/after/Foo.h b/gitnexus/test/fixtures/rename-integration/objective-c/swift-name-attribute-embedded/after/Foo.h new file mode 100644 index 000000000..45b36a6be --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/objective-c/swift-name-attribute-embedded/after/Foo.h @@ -0,0 +1,4 @@ +@interface Foo +- (int)fetchData; +- (int)other NS_SWIFT_NAME(fetchData()); +@end diff --git a/gitnexus/test/fixtures/rename-integration/objective-c/swift-name-attribute-embedded/after/Foo.m b/gitnexus/test/fixtures/rename-integration/objective-c/swift-name-attribute-embedded/after/Foo.m new file mode 100644 index 000000000..26be59394 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/objective-c/swift-name-attribute-embedded/after/Foo.m @@ -0,0 +1,5 @@ +#import "Foo.h" +@implementation Foo +- (int)fetchData { return 1; } +- (int)other { return 2; } +@end diff --git a/gitnexus/test/fixtures/rename-integration/objective-c/swift-name-attribute-embedded/before/Foo.h b/gitnexus/test/fixtures/rename-integration/objective-c/swift-name-attribute-embedded/before/Foo.h new file mode 100644 index 000000000..45b36a6be --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/objective-c/swift-name-attribute-embedded/before/Foo.h @@ -0,0 +1,4 @@ +@interface Foo +- (int)fetchData; +- (int)other NS_SWIFT_NAME(fetchData()); +@end diff --git a/gitnexus/test/fixtures/rename-integration/objective-c/swift-name-attribute-embedded/before/Foo.m b/gitnexus/test/fixtures/rename-integration/objective-c/swift-name-attribute-embedded/before/Foo.m new file mode 100644 index 000000000..26be59394 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/objective-c/swift-name-attribute-embedded/before/Foo.m @@ -0,0 +1,5 @@ +#import "Foo.h" +@implementation Foo +- (int)fetchData { return 1; } +- (int)other { return 2; } +@end diff --git a/gitnexus/test/fixtures/rename-integration/objective-c/swift-name-attribute-embedded/case.json b/gitnexus/test/fixtures/rename-integration/objective-c/swift-name-attribute-embedded/case.json new file mode 100644 index 000000000..c185720aa --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/objective-c/swift-name-attribute-embedded/case.json @@ -0,0 +1,21 @@ +{ + "language": "objective-c", + "description": "Refuse a selector that another declaration's NS_SWIFT_NAME argument spells, because attribute text is a Swift-facing name, not an Objective-C reference.", + "categories": [ + "cross-language", + "interop", + "negative", + "unchanged-on-error" + ], + "target": { + "name": "fetchData", + "kind": "Method", + "file": "Foo.h" + }, + "newName": "loadData", + "expect": { + "status": "error", + "code": "unsupported_boundary", + "planningStatus": "unsupported" + } +} diff --git a/gitnexus/test/fixtures/rename-integration/objective-c/swift-name-attribute-independent/after/App.swift b/gitnexus/test/fixtures/rename-integration/objective-c/swift-name-attribute-independent/after/App.swift new file mode 100644 index 000000000..8df894144 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/objective-c/swift-name-attribute-independent/after/App.swift @@ -0,0 +1,3 @@ +func run(f: Foo) -> Int32 { + return f.retrieve() +} diff --git a/gitnexus/test/fixtures/rename-integration/objective-c/swift-name-attribute-independent/after/Foo.h b/gitnexus/test/fixtures/rename-integration/objective-c/swift-name-attribute-independent/after/Foo.h new file mode 100644 index 000000000..fdd6b8c65 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/objective-c/swift-name-attribute-independent/after/Foo.h @@ -0,0 +1,3 @@ +@interface Foo +- (int)loadData NS_SWIFT_NAME(retrieve()); +@end diff --git a/gitnexus/test/fixtures/rename-integration/objective-c/swift-name-attribute-independent/after/Foo.m b/gitnexus/test/fixtures/rename-integration/objective-c/swift-name-attribute-independent/after/Foo.m new file mode 100644 index 000000000..d41815b30 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/objective-c/swift-name-attribute-independent/after/Foo.m @@ -0,0 +1,4 @@ +#import "Foo.h" +@implementation Foo +- (int)loadData { return 1; } +@end diff --git a/gitnexus/test/fixtures/rename-integration/objective-c/swift-name-attribute-independent/before/App.swift b/gitnexus/test/fixtures/rename-integration/objective-c/swift-name-attribute-independent/before/App.swift new file mode 100644 index 000000000..8df894144 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/objective-c/swift-name-attribute-independent/before/App.swift @@ -0,0 +1,3 @@ +func run(f: Foo) -> Int32 { + return f.retrieve() +} diff --git a/gitnexus/test/fixtures/rename-integration/objective-c/swift-name-attribute-independent/before/Foo.h b/gitnexus/test/fixtures/rename-integration/objective-c/swift-name-attribute-independent/before/Foo.h new file mode 100644 index 000000000..8b9ed04ac --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/objective-c/swift-name-attribute-independent/before/Foo.h @@ -0,0 +1,3 @@ +@interface Foo +- (int)fetchData NS_SWIFT_NAME(retrieve()); +@end diff --git a/gitnexus/test/fixtures/rename-integration/objective-c/swift-name-attribute-independent/before/Foo.m b/gitnexus/test/fixtures/rename-integration/objective-c/swift-name-attribute-independent/before/Foo.m new file mode 100644 index 000000000..621fcefe4 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/objective-c/swift-name-attribute-independent/before/Foo.m @@ -0,0 +1,4 @@ +#import "Foo.h" +@implementation Foo +- (int)fetchData { return 1; } +@end diff --git a/gitnexus/test/fixtures/rename-integration/objective-c/swift-name-attribute-independent/case.json b/gitnexus/test/fixtures/rename-integration/objective-c/swift-name-attribute-independent/case.json new file mode 100644 index 000000000..6ced68476 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/objective-c/swift-name-attribute-independent/case.json @@ -0,0 +1,18 @@ +{ + "language": "objective-c", + "description": "An explicit NS_SWIFT_NAME keeps the Swift spelling independent of the selector, so a selector rename leaves the Swift caller and the attribute valid.", + "categories": [ + "cross-language", + "interop" + ], + "target": { + "name": "fetchData", + "kind": "Method", + "file": "Foo.h" + }, + "newName": "loadData", + "expect": { + "status": "success", + "edits": 2 + } +} diff --git a/gitnexus/test/fixtures/rename-integration/objective-c/swift-refined-consumer/after/App.swift b/gitnexus/test/fixtures/rename-integration/objective-c/swift-refined-consumer/after/App.swift new file mode 100644 index 000000000..2d4c83ca4 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/objective-c/swift-refined-consumer/after/App.swift @@ -0,0 +1,3 @@ +func run(f: Foo) -> Int32 { + return f.__fetchData() +} diff --git a/gitnexus/test/fixtures/rename-integration/objective-c/swift-refined-consumer/after/Foo.h b/gitnexus/test/fixtures/rename-integration/objective-c/swift-refined-consumer/after/Foo.h new file mode 100644 index 000000000..d138dc1af --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/objective-c/swift-refined-consumer/after/Foo.h @@ -0,0 +1,3 @@ +@interface Foo +- (int)fetchData NS_REFINED_FOR_SWIFT; +@end diff --git a/gitnexus/test/fixtures/rename-integration/objective-c/swift-refined-consumer/after/Foo.m b/gitnexus/test/fixtures/rename-integration/objective-c/swift-refined-consumer/after/Foo.m new file mode 100644 index 000000000..621fcefe4 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/objective-c/swift-refined-consumer/after/Foo.m @@ -0,0 +1,4 @@ +#import "Foo.h" +@implementation Foo +- (int)fetchData { return 1; } +@end diff --git a/gitnexus/test/fixtures/rename-integration/objective-c/swift-refined-consumer/before/App.swift b/gitnexus/test/fixtures/rename-integration/objective-c/swift-refined-consumer/before/App.swift new file mode 100644 index 000000000..2d4c83ca4 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/objective-c/swift-refined-consumer/before/App.swift @@ -0,0 +1,3 @@ +func run(f: Foo) -> Int32 { + return f.__fetchData() +} diff --git a/gitnexus/test/fixtures/rename-integration/objective-c/swift-refined-consumer/before/Foo.h b/gitnexus/test/fixtures/rename-integration/objective-c/swift-refined-consumer/before/Foo.h new file mode 100644 index 000000000..d138dc1af --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/objective-c/swift-refined-consumer/before/Foo.h @@ -0,0 +1,3 @@ +@interface Foo +- (int)fetchData NS_REFINED_FOR_SWIFT; +@end diff --git a/gitnexus/test/fixtures/rename-integration/objective-c/swift-refined-consumer/before/Foo.m b/gitnexus/test/fixtures/rename-integration/objective-c/swift-refined-consumer/before/Foo.m new file mode 100644 index 000000000..621fcefe4 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/objective-c/swift-refined-consumer/before/Foo.m @@ -0,0 +1,4 @@ +#import "Foo.h" +@implementation Foo +- (int)fetchData { return 1; } +@end diff --git a/gitnexus/test/fixtures/rename-integration/objective-c/swift-refined-consumer/case.json b/gitnexus/test/fixtures/rename-integration/objective-c/swift-refined-consumer/case.json new file mode 100644 index 000000000..3f3c0024e --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/objective-c/swift-refined-consumer/case.json @@ -0,0 +1,21 @@ +{ + "language": "objective-c", + "description": "Refuse an Objective-C selector refined for Swift when an indexed Swift caller names its double-underscore Swift spelling, until the Swift binding is proven.", + "categories": [ + "cross-language", + "interop", + "negative", + "unchanged-on-error" + ], + "target": { + "name": "fetchData", + "kind": "Method", + "file": "Foo.h" + }, + "newName": "loadData", + "expect": { + "status": "error", + "code": "unsupported_boundary", + "planningStatus": "unsupported" + } +} diff --git a/gitnexus/test/fixtures/rename-integration/objective-c/swift-unrelated/after/Foo.h b/gitnexus/test/fixtures/rename-integration/objective-c/swift-unrelated/after/Foo.h new file mode 100644 index 000000000..db036d5b6 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/objective-c/swift-unrelated/after/Foo.h @@ -0,0 +1,3 @@ +@interface Foo +- (int)loadData; +@end diff --git a/gitnexus/test/fixtures/rename-integration/objective-c/swift-unrelated/after/Foo.m b/gitnexus/test/fixtures/rename-integration/objective-c/swift-unrelated/after/Foo.m new file mode 100644 index 000000000..d41815b30 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/objective-c/swift-unrelated/after/Foo.m @@ -0,0 +1,4 @@ +#import "Foo.h" +@implementation Foo +- (int)loadData { return 1; } +@end diff --git a/gitnexus/test/fixtures/rename-integration/objective-c/swift-unrelated/after/Other.swift b/gitnexus/test/fixtures/rename-integration/objective-c/swift-unrelated/after/Other.swift new file mode 100644 index 000000000..da9df7210 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/objective-c/swift-unrelated/after/Other.swift @@ -0,0 +1,3 @@ +// fetchData loadData remain comments. +let note = "fetchData loadData" +func total() -> Int { return 1 } diff --git a/gitnexus/test/fixtures/rename-integration/objective-c/swift-unrelated/before/Foo.h b/gitnexus/test/fixtures/rename-integration/objective-c/swift-unrelated/before/Foo.h new file mode 100644 index 000000000..12aea3cbd --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/objective-c/swift-unrelated/before/Foo.h @@ -0,0 +1,3 @@ +@interface Foo +- (int)fetchData; +@end diff --git a/gitnexus/test/fixtures/rename-integration/objective-c/swift-unrelated/before/Foo.m b/gitnexus/test/fixtures/rename-integration/objective-c/swift-unrelated/before/Foo.m new file mode 100644 index 000000000..621fcefe4 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/objective-c/swift-unrelated/before/Foo.m @@ -0,0 +1,4 @@ +#import "Foo.h" +@implementation Foo +- (int)fetchData { return 1; } +@end diff --git a/gitnexus/test/fixtures/rename-integration/objective-c/swift-unrelated/before/Other.swift b/gitnexus/test/fixtures/rename-integration/objective-c/swift-unrelated/before/Other.swift new file mode 100644 index 000000000..da9df7210 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/objective-c/swift-unrelated/before/Other.swift @@ -0,0 +1,3 @@ +// fetchData loadData remain comments. +let note = "fetchData loadData" +func total() -> Int { return 1 } diff --git a/gitnexus/test/fixtures/rename-integration/objective-c/swift-unrelated/case.json b/gitnexus/test/fixtures/rename-integration/objective-c/swift-unrelated/case.json new file mode 100644 index 000000000..063d5fb2a --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/objective-c/swift-unrelated/case.json @@ -0,0 +1,19 @@ +{ + "language": "objective-c", + "description": "Swift comments, strings and unrelated names do not block a proven selector rename.", + "categories": [ + "cross-language", + "interop", + "comments-and-strings" + ], + "target": { + "name": "fetchData", + "kind": "Method", + "file": "Foo.h" + }, + "newName": "loadData", + "expect": { + "status": "success", + "edits": 2 + } +} diff --git a/gitnexus/test/fixtures/rename-integration/php/external-base-family/after/model.php b/gitnexus/test/fixtures/rename-integration/php/external-base-family/after/model.php new file mode 100644 index 000000000..7045fb517 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/php/external-base-family/after/model.php @@ -0,0 +1,4 @@ +render; } +} +class Child extends Base { + public function paint(): string { return parent::paint(); } +} +class Other { + public function render(): string { return "other"; } +} +function invoke(Renderer $value, Other $other) { + return $value->paint() . $other->render(); +} +// render stays here. diff --git a/gitnexus/test/fixtures/rename-integration/php/interface-family-case/before/model.php b/gitnexus/test/fixtures/rename-integration/php/interface-family-case/before/model.php new file mode 100644 index 000000000..ad107111a --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/php/interface-family-case/before/model.php @@ -0,0 +1,18 @@ +render; } +} +class Child extends Base { + public function render(): string { return parent::RENDER(); } +} +class Other { + public function render(): string { return "other"; } +} +function invoke(Renderer $value, Other $other) { + return $value->RENDER() . $other->render(); +} +// render stays here. diff --git a/gitnexus/test/fixtures/rename-integration/php/interface-family-case/case.json b/gitnexus/test/fixtures/rename-integration/php/interface-family-case/case.json new file mode 100644 index 000000000..4a5bb9a6a --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/php/interface-family-case/case.json @@ -0,0 +1,52 @@ +{ + "language": "php", + "description": "Rename an interface family through case-insensitive callable spellings while preserving a same-named public property and unrelated method.", + "categories": [ + "polymorphism", + "call-graph", + "case-insensitive-method", + "public-field" + ], + "target": { + "name": "render", + "kind": "Method", + "file": "model.php", + "startLine": 2 + }, + "newName": "paint", + "expect": { + "status": "success", + "edits": 5 + }, + "edges": [ + { + "type": "METHOD_IMPLEMENTS", + "from": { + "name": "render", + "kind": "Method", + "file": "model.php", + "startLine": 6 + }, + "to": { + "name": "render", + "kind": "Method", + "file": "model.php", + "startLine": 2 + } + }, + { + "type": "METHOD_OVERRIDES", + "from": { + "name": "Child", + "kind": "Class", + "file": "model.php" + }, + "to": { + "name": "render", + "kind": "Method", + "file": "model.php", + "startLine": 6 + } + } + ] +} diff --git a/gitnexus/test/fixtures/rename-integration/php/public-property-chain/after/model.php b/gitnexus/test/fixtures/rename-integration/php/public-property-chain/after/model.php new file mode 100644 index 000000000..78b7a12e2 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/php/public-property-chain/after/model.php @@ -0,0 +1,18 @@ +branch->leaf->points = $root->branch->leaf->points + $score; + return $root->branch->leaf->points + $root->branch->leaf->Score; +} +// score stays here. +$label = "score"; diff --git a/gitnexus/test/fixtures/rename-integration/php/public-property-chain/before/model.php b/gitnexus/test/fixtures/rename-integration/php/public-property-chain/before/model.php new file mode 100644 index 000000000..f8455585c --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/php/public-property-chain/before/model.php @@ -0,0 +1,18 @@ +branch->leaf->score = $root->branch->leaf->score + $score; + return $root->branch->leaf->score + $root->branch->leaf->Score; +} +// score stays here. +$label = "score"; diff --git a/gitnexus/test/fixtures/rename-integration/php/public-property-chain/case.json b/gitnexus/test/fixtures/rename-integration/php/public-property-chain/case.json new file mode 100644 index 000000000..7d625d0ad --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/php/public-property-chain/case.json @@ -0,0 +1,36 @@ +{ + "language": "php", + "description": "Rename a case-sensitive public property through three typed receiver hops, preserving differently cased properties and variables.", + "categories": [ + "public-field", + "deep-chain", + "read-write", + "case-sensitive-property" + ], + "target": { + "name": "score", + "kind": "Property", + "file": "model.php", + "startLine": 2 + }, + "newName": "points", + "expect": { + "status": "success", + "edits": 4 + }, + "edges": [ + { + "type": "ACCESSES", + "from": { + "name": "update", + "kind": "Function", + "file": "model.php" + }, + "to": { + "name": "score", + "kind": "Property", + "file": "model.php" + } + } + ] +} diff --git a/gitnexus/test/fixtures/rename-integration/python/dataclass-keyword-label/after/model.py b/gitnexus/test/fixtures/rename-integration/python/dataclass-keyword-label/after/model.py new file mode 100644 index 000000000..987e9ff35 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/python/dataclass-keyword-label/after/model.py @@ -0,0 +1,7 @@ +from dataclasses import dataclass + +@dataclass +class Point: + x: int + +point = Point(x=1) diff --git a/gitnexus/test/fixtures/rename-integration/python/dataclass-keyword-label/before/model.py b/gitnexus/test/fixtures/rename-integration/python/dataclass-keyword-label/before/model.py new file mode 100644 index 000000000..987e9ff35 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/python/dataclass-keyword-label/before/model.py @@ -0,0 +1,7 @@ +from dataclasses import dataclass + +@dataclass +class Point: + x: int + +point = Point(x=1) diff --git a/gitnexus/test/fixtures/rename-integration/python/dataclass-keyword-label/case.json b/gitnexus/test/fixtures/rename-integration/python/dataclass-keyword-label/case.json new file mode 100644 index 000000000..5da48c91a --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/python/dataclass-keyword-label/case.json @@ -0,0 +1,22 @@ +{ + "language": "python", + "description": "Refuse a dataclass field whose keyword label binds the synthesized constructor parameter.", + "categories": [ + "keyword-argument", + "synthesized-constructor", + "negative", + "unchanged-on-error" + ], + "target": { + "name": "x", + "kind": "Property", + "file": "model.py", + "startLine": 4 + }, + "newName": "renamed", + "expect": { + "status": "error", + "code": "ambiguous_reference", + "planningStatus": "unsupported" + } +} diff --git a/gitnexus/test/fixtures/rename-integration/python/destination-dynamic-attribute/after/model.py b/gitnexus/test/fixtures/rename-integration/python/destination-dynamic-attribute/after/model.py new file mode 100644 index 000000000..60c2f2604 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/python/destination-dynamic-attribute/after/model.py @@ -0,0 +1,8 @@ +class Worker: + def target(self): + return 7 + +def run(): + worker = Worker() + setattr(worker, "renamed", 42) + return worker.target() diff --git a/gitnexus/test/fixtures/rename-integration/python/destination-dynamic-attribute/before/model.py b/gitnexus/test/fixtures/rename-integration/python/destination-dynamic-attribute/before/model.py new file mode 100644 index 000000000..60c2f2604 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/python/destination-dynamic-attribute/before/model.py @@ -0,0 +1,8 @@ +class Worker: + def target(self): + return 7 + +def run(): + worker = Worker() + setattr(worker, "renamed", 42) + return worker.target() diff --git a/gitnexus/test/fixtures/rename-integration/python/destination-dynamic-attribute/case.json b/gitnexus/test/fixtures/rename-integration/python/destination-dynamic-attribute/case.json new file mode 100644 index 000000000..1db49132d --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/python/destination-dynamic-attribute/case.json @@ -0,0 +1,39 @@ +{ + "language": "python", + "description": "Refuse a destination-name dynamic attribute collision on the selected receiver.", + "categories": [ + "dynamic-attribute", + "destination-collision", + "negative", + "unchanged-on-error" + ], + "target": { + "name": "target", + "kind": "Method", + "file": "model.py", + "startLine": 1 + }, + "newName": "renamed", + "expect": { + "status": "error", + "code": "unsupported_occurrence", + "planningStatus": "unsupported" + }, + "edges": [ + { + "type": "CALLS", + "from": { + "name": "run", + "kind": "Function", + "file": "model.py", + "startLine": 4 + }, + "to": { + "name": "target", + "kind": "Method", + "file": "model.py", + "startLine": 1 + } + } + ] +} diff --git a/gitnexus/test/fixtures/rename-integration/python/destination-name-mangling/after/model.py b/gitnexus/test/fixtures/rename-integration/python/destination-name-mangling/after/model.py new file mode 100644 index 000000000..4ff3abf41 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/python/destination-name-mangling/after/model.py @@ -0,0 +1,6 @@ +class Worker: + def target(self): + return 7 + +worker = Worker() +worker.target() diff --git a/gitnexus/test/fixtures/rename-integration/python/destination-name-mangling/before/model.py b/gitnexus/test/fixtures/rename-integration/python/destination-name-mangling/before/model.py new file mode 100644 index 000000000..4ff3abf41 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/python/destination-name-mangling/before/model.py @@ -0,0 +1,6 @@ +class Worker: + def target(self): + return 7 + +worker = Worker() +worker.target() diff --git a/gitnexus/test/fixtures/rename-integration/python/destination-name-mangling/case.json b/gitnexus/test/fixtures/rename-integration/python/destination-name-mangling/case.json new file mode 100644 index 000000000..882a55c06 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/python/destination-name-mangling/case.json @@ -0,0 +1,22 @@ +{ + "language": "python", + "description": "Refuse a public member renamed to a private spelling that class-body access would mangle.", + "categories": [ + "name-mangling", + "destination-collision", + "negative", + "unchanged-on-error" + ], + "target": { + "name": "target", + "kind": "Method", + "file": "model.py", + "startLine": 1 + }, + "newName": "__secret", + "expect": { + "status": "error", + "code": "unsupported_symbol_family", + "planningStatus": "unsupported" + } +} diff --git a/gitnexus/test/fixtures/rename-integration/python/external-base-family/after/model.py b/gitnexus/test/fixtures/rename-integration/python/external-base-family/after/model.py new file mode 100644 index 000000000..a25fae150 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/python/external-base-family/after/model.py @@ -0,0 +1,5 @@ +import unittest + +class Worker(unittest.TestCase): + def setUp(self): + pass diff --git a/gitnexus/test/fixtures/rename-integration/python/external-base-family/after/notes.txt b/gitnexus/test/fixtures/rename-integration/python/external-base-family/after/notes.txt new file mode 100644 index 000000000..81db7fc3a --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/python/external-base-family/after/notes.txt @@ -0,0 +1 @@ +External contract and target spelling remain unchanged. diff --git a/gitnexus/test/fixtures/rename-integration/python/external-base-family/before/model.py b/gitnexus/test/fixtures/rename-integration/python/external-base-family/before/model.py new file mode 100644 index 000000000..a25fae150 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/python/external-base-family/before/model.py @@ -0,0 +1,5 @@ +import unittest + +class Worker(unittest.TestCase): + def setUp(self): + pass diff --git a/gitnexus/test/fixtures/rename-integration/python/external-base-family/before/notes.txt b/gitnexus/test/fixtures/rename-integration/python/external-base-family/before/notes.txt new file mode 100644 index 000000000..81db7fc3a --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/python/external-base-family/before/notes.txt @@ -0,0 +1 @@ +External contract and target spelling remain unchanged. diff --git a/gitnexus/test/fixtures/rename-integration/python/external-base-family/case.json b/gitnexus/test/fixtures/rename-integration/python/external-base-family/case.json new file mode 100644 index 000000000..db02ef65e --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/python/external-base-family/case.json @@ -0,0 +1,22 @@ +{ + "language": "python", + "description": "Refuse a potentially polymorphic member whose external base is not indexed.", + "categories": [ + "polymorphism", + "external-base", + "negative", + "unchanged-on-error" + ], + "target": { + "name": "setUp", + "kind": "Method", + "file": "model.py", + "startLine": 3 + }, + "newName": "renamed", + "expect": { + "status": "error", + "code": "unsupported_symbol_family", + "planningStatus": "unsupported" + } +} diff --git a/gitnexus/test/fixtures/rename-integration/python/inherited-dynamic-attribute/after/model.py b/gitnexus/test/fixtures/rename-integration/python/inherited-dynamic-attribute/after/model.py new file mode 100644 index 000000000..07630749c --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/python/inherited-dynamic-attribute/after/model.py @@ -0,0 +1,8 @@ +class Base: + def target(self): + return 7 +class Child(Base): + pass + +worker = Child() +getattr(worker, "target")() diff --git a/gitnexus/test/fixtures/rename-integration/python/inherited-dynamic-attribute/before/model.py b/gitnexus/test/fixtures/rename-integration/python/inherited-dynamic-attribute/before/model.py new file mode 100644 index 000000000..07630749c --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/python/inherited-dynamic-attribute/before/model.py @@ -0,0 +1,8 @@ +class Base: + def target(self): + return 7 +class Child(Base): + pass + +worker = Child() +getattr(worker, "target")() diff --git a/gitnexus/test/fixtures/rename-integration/python/inherited-dynamic-attribute/case.json b/gitnexus/test/fixtures/rename-integration/python/inherited-dynamic-attribute/case.json new file mode 100644 index 000000000..46b1cff22 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/python/inherited-dynamic-attribute/case.json @@ -0,0 +1,22 @@ +{ + "language": "python", + "description": "Refuse a literal attribute access on a receiver that inherits the selected member.", + "categories": [ + "dynamic-attribute", + "polymorphism", + "negative", + "unchanged-on-error" + ], + "target": { + "name": "target", + "kind": "Method", + "file": "model.py", + "startLine": 1 + }, + "newName": "renamed", + "expect": { + "status": "error", + "code": "unsupported_occurrence", + "planningStatus": "unsupported" + } +} diff --git a/gitnexus/test/fixtures/rename-integration/python/inherited-method-family/after/model.py b/gitnexus/test/fixtures/rename-integration/python/inherited-method-family/after/model.py new file mode 100644 index 000000000..db6c3afc8 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/python/inherited-method-family/after/model.py @@ -0,0 +1,14 @@ +class Base: + def paint(self): + return "render" +class Child(Base): + def paint(self): + return super().paint() +class Other: + def render(self): + return "other" +def invoke(base: Base, child: Child, other: Other): + return base.paint() + child.paint() + other.render() +value = Child() +value.paint() +# render stays here. diff --git a/gitnexus/test/fixtures/rename-integration/python/inherited-method-family/before/model.py b/gitnexus/test/fixtures/rename-integration/python/inherited-method-family/before/model.py new file mode 100644 index 000000000..2bd888ff8 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/python/inherited-method-family/before/model.py @@ -0,0 +1,14 @@ +class Base: + def render(self): + return "render" +class Child(Base): + def render(self): + return super().render() +class Other: + def render(self): + return "other" +def invoke(base: Base, child: Child, other: Other): + return base.render() + child.render() + other.render() +value = Child() +value.render() +# render stays here. diff --git a/gitnexus/test/fixtures/rename-integration/python/inherited-method-family/case.json b/gitnexus/test/fixtures/rename-integration/python/inherited-method-family/case.json new file mode 100644 index 000000000..4e15c2c30 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/python/inherited-method-family/case.json @@ -0,0 +1,50 @@ +{ + "language": "python", + "description": "Rename a base method, override and super calls while preserving an unrelated receiver.", + "categories": [ + "polymorphism", + "call-graph", + "unrelated-owner" + ], + "target": { + "name": "render", + "kind": "Method", + "file": "model.py", + "startLine": 1 + }, + "newName": "paint", + "expect": { + "status": "success", + "edits": 6 + }, + "edges": [ + { + "type": "METHOD_OVERRIDES", + "from": { + "name": "Child", + "kind": "Class", + "file": "model.py" + }, + "to": { + "name": "render", + "kind": "Method", + "file": "model.py", + "startLine": 1 + } + }, + { + "type": "CALLS", + "from": { + "name": "invoke", + "kind": "Function", + "file": "model.py" + }, + "to": { + "name": "render", + "kind": "Method", + "file": "model.py", + "startLine": 1 + } + } + ] +} diff --git a/gitnexus/test/fixtures/rename-integration/python/matching-dynamic-attribute/after/model.py b/gitnexus/test/fixtures/rename-integration/python/matching-dynamic-attribute/after/model.py new file mode 100644 index 000000000..01ab61084 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/python/matching-dynamic-attribute/after/model.py @@ -0,0 +1,9 @@ +class Worker: + def target(self): + return 1 + def other_member(self): + return 2 + +worker = Worker() +worker.target() +getattr(worker, "tar\u0067et")() diff --git a/gitnexus/test/fixtures/rename-integration/python/matching-dynamic-attribute/before/model.py b/gitnexus/test/fixtures/rename-integration/python/matching-dynamic-attribute/before/model.py new file mode 100644 index 000000000..01ab61084 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/python/matching-dynamic-attribute/before/model.py @@ -0,0 +1,9 @@ +class Worker: + def target(self): + return 1 + def other_member(self): + return 2 + +worker = Worker() +worker.target() +getattr(worker, "tar\u0067et")() diff --git a/gitnexus/test/fixtures/rename-integration/python/matching-dynamic-attribute/case.json b/gitnexus/test/fixtures/rename-integration/python/matching-dynamic-attribute/case.json new file mode 100644 index 000000000..523a86ed5 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/python/matching-dynamic-attribute/case.json @@ -0,0 +1,21 @@ +{ + "language": "python", + "description": "Refuse a string attribute that decodes to the selected member.", + "categories": [ + "dynamic-attribute", + "negative", + "unchanged-on-error" + ], + "target": { + "name": "target", + "kind": "Method", + "file": "model.py", + "startLine": 1 + }, + "newName": "renamed", + "expect": { + "status": "error", + "code": "unsupported_occurrence", + "planningStatus": "unsupported" + } +} diff --git a/gitnexus/test/fixtures/rename-integration/python/module-export-destination/after/model.py b/gitnexus/test/fixtures/rename-integration/python/module-export-destination/after/model.py new file mode 100644 index 000000000..a08406325 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/python/module-export-destination/after/model.py @@ -0,0 +1,3 @@ +__all__ = ("rena\u006ded",) +def target(): return 7 +def run(): return target() diff --git a/gitnexus/test/fixtures/rename-integration/python/module-export-destination/before/model.py b/gitnexus/test/fixtures/rename-integration/python/module-export-destination/before/model.py new file mode 100644 index 000000000..a08406325 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/python/module-export-destination/before/model.py @@ -0,0 +1,3 @@ +__all__ = ("rena\u006ded",) +def target(): return 7 +def run(): return target() diff --git a/gitnexus/test/fixtures/rename-integration/python/module-export-destination/case.json b/gitnexus/test/fixtures/rename-integration/python/module-export-destination/case.json new file mode 100644 index 000000000..1dc4aaccc --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/python/module-export-destination/case.json @@ -0,0 +1,37 @@ +{ + "language": "python", + "description": "Decoded destination export names must not silently change the public API.", + "categories": [ + "module-exports", + "export-list", + "call-graph", + "scope-ownership" + ], + "target": { + "name": "target", + "kind": "Function", + "file": "model.py" + }, + "newName": "renamed", + "expect": { + "status": "error", + "code": "unsupported_occurrence", + "planningStatus": "unsupported" + }, + "exactOutgoing": true, + "edges": [ + { + "type": "CALLS", + "from": { + "name": "run", + "kind": "Function", + "file": "model.py" + }, + "to": { + "name": "target", + "kind": "Function", + "file": "model.py" + } + } + ] +} diff --git a/gitnexus/test/fixtures/rename-integration/python/module-export-dynamic/after/model.py b/gitnexus/test/fixtures/rename-integration/python/module-export-dynamic/after/model.py new file mode 100644 index 000000000..b71ad0bd0 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/python/module-export-dynamic/after/model.py @@ -0,0 +1,3 @@ +__all__ = discover_exports() +def target(): return 7 +def run(): return target() diff --git a/gitnexus/test/fixtures/rename-integration/python/module-export-dynamic/before/model.py b/gitnexus/test/fixtures/rename-integration/python/module-export-dynamic/before/model.py new file mode 100644 index 000000000..b71ad0bd0 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/python/module-export-dynamic/before/model.py @@ -0,0 +1,3 @@ +__all__ = discover_exports() +def target(): return 7 +def run(): return target() diff --git a/gitnexus/test/fixtures/rename-integration/python/module-export-dynamic/case.json b/gitnexus/test/fixtures/rename-integration/python/module-export-dynamic/case.json new file mode 100644 index 000000000..85f389632 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/python/module-export-dynamic/case.json @@ -0,0 +1,37 @@ +{ + "language": "python", + "description": "Unknown module export lists cannot prove the old and destination names absent.", + "categories": [ + "module-exports", + "export-list", + "call-graph", + "scope-ownership" + ], + "target": { + "name": "target", + "kind": "Function", + "file": "model.py" + }, + "newName": "renamed", + "expect": { + "status": "error", + "code": "unsupported_occurrence", + "planningStatus": "unsupported" + }, + "exactOutgoing": true, + "edges": [ + { + "type": "CALLS", + "from": { + "name": "run", + "kind": "Function", + "file": "model.py" + }, + "to": { + "name": "target", + "kind": "Function", + "file": "model.py" + } + } + ] +} diff --git a/gitnexus/test/fixtures/rename-integration/python/module-export-list/after/model.py b/gitnexus/test/fixtures/rename-integration/python/module-export-list/after/model.py new file mode 100644 index 000000000..041923d09 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/python/module-export-list/after/model.py @@ -0,0 +1,3 @@ +__all__ = ["target"] +def target(): return 7 +def run(): return target() diff --git a/gitnexus/test/fixtures/rename-integration/python/module-export-list/before/model.py b/gitnexus/test/fixtures/rename-integration/python/module-export-list/before/model.py new file mode 100644 index 000000000..041923d09 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/python/module-export-list/before/model.py @@ -0,0 +1,3 @@ +__all__ = ["target"] +def target(): return 7 +def run(): return target() diff --git a/gitnexus/test/fixtures/rename-integration/python/module-export-list/case.json b/gitnexus/test/fixtures/rename-integration/python/module-export-list/case.json new file mode 100644 index 000000000..aca13d30e --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/python/module-export-list/case.json @@ -0,0 +1,37 @@ +{ + "language": "python", + "description": "Literal module export list retains the old name unless rename refuses.", + "categories": [ + "module-exports", + "export-list", + "call-graph", + "scope-ownership" + ], + "target": { + "name": "target", + "kind": "Function", + "file": "model.py" + }, + "newName": "renamed", + "expect": { + "status": "error", + "code": "unsupported_occurrence", + "planningStatus": "unsupported" + }, + "exactOutgoing": true, + "edges": [ + { + "type": "CALLS", + "from": { + "name": "run", + "kind": "Function", + "file": "model.py" + }, + "to": { + "name": "target", + "kind": "Function", + "file": "model.py" + } + } + ] +} diff --git a/gitnexus/test/fixtures/rename-integration/python/module-export-mutation/after/model.py b/gitnexus/test/fixtures/rename-integration/python/module-export-mutation/after/model.py new file mode 100644 index 000000000..6dbcb5de2 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/python/module-export-mutation/after/model.py @@ -0,0 +1,4 @@ +__all__ = ["run"] +__all__.append("target") +def target(): return 7 +def run(): return target() diff --git a/gitnexus/test/fixtures/rename-integration/python/module-export-mutation/before/model.py b/gitnexus/test/fixtures/rename-integration/python/module-export-mutation/before/model.py new file mode 100644 index 000000000..6dbcb5de2 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/python/module-export-mutation/before/model.py @@ -0,0 +1,4 @@ +__all__ = ["run"] +__all__.append("target") +def target(): return 7 +def run(): return target() diff --git a/gitnexus/test/fixtures/rename-integration/python/module-export-mutation/case.json b/gitnexus/test/fixtures/rename-integration/python/module-export-mutation/case.json new file mode 100644 index 000000000..e98c6b597 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/python/module-export-mutation/case.json @@ -0,0 +1,37 @@ +{ + "language": "python", + "description": "A list mutation can add a string export after its initial literal assignment.", + "categories": [ + "module-exports", + "export-list", + "call-graph", + "scope-ownership" + ], + "target": { + "name": "target", + "kind": "Function", + "file": "model.py" + }, + "newName": "renamed", + "expect": { + "status": "error", + "code": "unsupported_occurrence", + "planningStatus": "unsupported" + }, + "exactOutgoing": true, + "edges": [ + { + "type": "CALLS", + "from": { + "name": "run", + "kind": "Function", + "file": "model.py" + }, + "to": { + "name": "target", + "kind": "Function", + "file": "model.py" + } + } + ] +} diff --git a/gitnexus/test/fixtures/rename-integration/python/module-export-reexport/after/model.py b/gitnexus/test/fixtures/rename-integration/python/module-export-reexport/after/model.py new file mode 100644 index 000000000..b77306e86 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/python/module-export-reexport/after/model.py @@ -0,0 +1,2 @@ +def target(): return 7 +def run(): return target() diff --git a/gitnexus/test/fixtures/rename-integration/python/module-export-reexport/after/public.py b/gitnexus/test/fixtures/rename-integration/python/module-export-reexport/after/public.py new file mode 100644 index 000000000..04cedaaeb --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/python/module-export-reexport/after/public.py @@ -0,0 +1,2 @@ +from model import target +__all__ = ["target"] diff --git a/gitnexus/test/fixtures/rename-integration/python/module-export-reexport/before/model.py b/gitnexus/test/fixtures/rename-integration/python/module-export-reexport/before/model.py new file mode 100644 index 000000000..b77306e86 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/python/module-export-reexport/before/model.py @@ -0,0 +1,2 @@ +def target(): return 7 +def run(): return target() diff --git a/gitnexus/test/fixtures/rename-integration/python/module-export-reexport/before/public.py b/gitnexus/test/fixtures/rename-integration/python/module-export-reexport/before/public.py new file mode 100644 index 000000000..04cedaaeb --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/python/module-export-reexport/before/public.py @@ -0,0 +1,2 @@ +from model import target +__all__ = ["target"] diff --git a/gitnexus/test/fixtures/rename-integration/python/module-export-reexport/case.json b/gitnexus/test/fixtures/rename-integration/python/module-export-reexport/case.json new file mode 100644 index 000000000..7a861540c --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/python/module-export-reexport/case.json @@ -0,0 +1,37 @@ +{ + "language": "python", + "description": "An unaliased re-export must not leave its module export list broken.", + "categories": [ + "module-exports", + "export-list", + "call-graph", + "scope-ownership" + ], + "target": { + "name": "target", + "kind": "Function", + "file": "model.py" + }, + "newName": "renamed", + "expect": { + "status": "error", + "code": "unsupported_occurrence", + "planningStatus": "unsupported" + }, + "exactOutgoing": true, + "edges": [ + { + "type": "CALLS", + "from": { + "name": "run", + "kind": "Function", + "file": "model.py" + }, + "to": { + "name": "target", + "kind": "Function", + "file": "model.py" + } + } + ] +} diff --git a/gitnexus/test/fixtures/rename-integration/python/normalized-import-alias/after/entry.py b/gitnexus/test/fixtures/rename-integration/python/normalized-import-alias/after/entry.py new file mode 100644 index 000000000..159ea838d --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/python/normalized-import-alias/after/entry.py @@ -0,0 +1,8 @@ +from source import measure +from middle import middle + +def entry(): + return measure() + middle() +def shadow(Kelvin): + return Kelvin +label = "Kelvin" diff --git a/gitnexus/test/fixtures/rename-integration/python/normalized-import-alias/after/middle.py b/gitnexus/test/fixtures/rename-integration/python/normalized-import-alias/after/middle.py new file mode 100644 index 000000000..b1eda8354 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/python/normalized-import-alias/after/middle.py @@ -0,0 +1,4 @@ +from source import measure as local + +def middle(): + return local() diff --git a/gitnexus/test/fixtures/rename-integration/python/normalized-import-alias/after/source.py b/gitnexus/test/fixtures/rename-integration/python/normalized-import-alias/after/source.py new file mode 100644 index 000000000..e35fc52d1 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/python/normalized-import-alias/after/source.py @@ -0,0 +1,3 @@ +def measure(): + return 273 +# Kelvin stays here. diff --git a/gitnexus/test/fixtures/rename-integration/python/normalized-import-alias/before/entry.py b/gitnexus/test/fixtures/rename-integration/python/normalized-import-alias/before/entry.py new file mode 100644 index 000000000..fe5249111 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/python/normalized-import-alias/before/entry.py @@ -0,0 +1,8 @@ +from source import Kelvin +from middle import middle + +def entry(): + return Kelvin() + middle() +def shadow(Kelvin): + return Kelvin +label = "Kelvin" diff --git a/gitnexus/test/fixtures/rename-integration/python/normalized-import-alias/before/middle.py b/gitnexus/test/fixtures/rename-integration/python/normalized-import-alias/before/middle.py new file mode 100644 index 000000000..ae1303cc0 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/python/normalized-import-alias/before/middle.py @@ -0,0 +1,4 @@ +from source import Kelvin as local + +def middle(): + return local() diff --git a/gitnexus/test/fixtures/rename-integration/python/normalized-import-alias/before/source.py b/gitnexus/test/fixtures/rename-integration/python/normalized-import-alias/before/source.py new file mode 100644 index 000000000..7fc74ea40 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/python/normalized-import-alias/before/source.py @@ -0,0 +1,3 @@ +def Kelvin(): + return 273 +# Kelvin stays here. diff --git a/gitnexus/test/fixtures/rename-integration/python/normalized-import-alias/case.json b/gitnexus/test/fixtures/rename-integration/python/normalized-import-alias/case.json new file mode 100644 index 000000000..afb8885d4 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/python/normalized-import-alias/case.json @@ -0,0 +1,36 @@ +{ + "language": "python", + "description": "Rename a Unicode-normalized callable through import aliases and a call chain while preserving shadowing and strings.", + "categories": [ + "cross-file", + "alias", + "call-graph", + "unicode-normalization", + "shadowing" + ], + "target": { + "name": "\u212aelvin", + "kind": "Function", + "file": "source.py" + }, + "newName": "measure", + "expect": { + "status": "success", + "edits": 4 + }, + "edges": [ + { + "type": "CALLS", + "from": { + "name": "entry", + "kind": "Function", + "file": "entry.py" + }, + "to": { + "name": "middle", + "kind": "Function", + "file": "middle.py" + } + } + ] +} diff --git a/gitnexus/test/fixtures/rename-integration/python/private-mangled-member/after/model.py b/gitnexus/test/fixtures/rename-integration/python/private-mangled-member/after/model.py new file mode 100644 index 000000000..c4c0cdfe7 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/python/private-mangled-member/after/model.py @@ -0,0 +1,8 @@ +class Secret: + def __read(self): + return "__read" + def reveal(self): + return self.__read() +secret = Secret() +secret._Secret__read() +# __read has externally mangled spellings. diff --git a/gitnexus/test/fixtures/rename-integration/python/private-mangled-member/before/model.py b/gitnexus/test/fixtures/rename-integration/python/private-mangled-member/before/model.py new file mode 100644 index 000000000..c4c0cdfe7 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/python/private-mangled-member/before/model.py @@ -0,0 +1,8 @@ +class Secret: + def __read(self): + return "__read" + def reveal(self): + return self.__read() +secret = Secret() +secret._Secret__read() +# __read has externally mangled spellings. diff --git a/gitnexus/test/fixtures/rename-integration/python/private-mangled-member/case.json b/gitnexus/test/fixtures/rename-integration/python/private-mangled-member/case.json new file mode 100644 index 000000000..5eb34dbed --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/python/private-mangled-member/case.json @@ -0,0 +1,21 @@ +{ + "language": "python", + "description": "Refuse an incomplete private-name family before writing either the source spelling or its externally mangled spelling.", + "categories": [ + "name-mangling", + "negative", + "unchanged-on-error" + ], + "target": { + "name": "__read", + "kind": "Method", + "file": "model.py", + "startLine": 1 + }, + "newName": "__load", + "expect": { + "status": "error", + "code": "unsupported_symbol_family", + "planningStatus": "unsupported" + } +} diff --git a/gitnexus/test/fixtures/rename-integration/python/public-field-chain/after/model.py b/gitnexus/test/fixtures/rename-integration/python/public-field-chain/after/model.py new file mode 100644 index 000000000..4cbd5a698 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/python/public-field-chain/after/model.py @@ -0,0 +1,15 @@ +class Leaf: + points: int = 0 +class Branch: + leaf: Leaf +class Root: + branch: Branch +class Other: + score: int = 9 +def update(root: Root, other: Other): + root.branch.leaf.points = root.branch.leaf.points + 1 + return root.branch.leaf.points + other.score +def shadow(score): + return score +# score stays here. +label = "score" diff --git a/gitnexus/test/fixtures/rename-integration/python/public-field-chain/before/model.py b/gitnexus/test/fixtures/rename-integration/python/public-field-chain/before/model.py new file mode 100644 index 000000000..8a104fe31 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/python/public-field-chain/before/model.py @@ -0,0 +1,15 @@ +class Leaf: + score: int = 0 +class Branch: + leaf: Leaf +class Root: + branch: Branch +class Other: + score: int = 9 +def update(root: Root, other: Other): + root.branch.leaf.score = root.branch.leaf.score + 1 + return root.branch.leaf.score + other.score +def shadow(score): + return score +# score stays here. +label = "score" diff --git a/gitnexus/test/fixtures/rename-integration/python/public-field-chain/case.json b/gitnexus/test/fixtures/rename-integration/python/public-field-chain/case.json new file mode 100644 index 000000000..f6e3cb2a2 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/python/public-field-chain/case.json @@ -0,0 +1,36 @@ +{ + "language": "python", + "description": "Rename an annotated public field through typed nested receivers, preserving other owners and local bindings.", + "categories": [ + "public-field", + "deep-chain", + "read-write" + ], + "target": { + "name": "score", + "kind": "Property", + "file": "model.py", + "startLine": 1 + }, + "newName": "points", + "expect": { + "status": "success", + "edits": 4 + }, + "edges": [ + { + "type": "ACCESSES", + "from": { + "name": "update", + "kind": "Function", + "file": "model.py" + }, + "to": { + "name": "score", + "kind": "Property", + "file": "model.py", + "startLine": 1 + } + } + ] +} diff --git a/gitnexus/test/fixtures/rename-integration/python/special-method-protocol/after/model.py b/gitnexus/test/fixtures/rename-integration/python/special-method-protocol/after/model.py new file mode 100644 index 000000000..f9d10f83c --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/python/special-method-protocol/after/model.py @@ -0,0 +1,4 @@ +class Bag: + def __len__(self): + return 7 +print(len(Bag())) diff --git a/gitnexus/test/fixtures/rename-integration/python/special-method-protocol/before/model.py b/gitnexus/test/fixtures/rename-integration/python/special-method-protocol/before/model.py new file mode 100644 index 000000000..f9d10f83c --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/python/special-method-protocol/before/model.py @@ -0,0 +1,4 @@ +class Bag: + def __len__(self): + return 7 +print(len(Bag())) diff --git a/gitnexus/test/fixtures/rename-integration/python/special-method-protocol/case.json b/gitnexus/test/fixtures/rename-integration/python/special-method-protocol/case.json new file mode 100644 index 000000000..8e4095a32 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/python/special-method-protocol/case.json @@ -0,0 +1,22 @@ +{ + "language": "python", + "description": "Refuse a special method whose implicit protocol use (len) has no written reference.", + "categories": [ + "special-method", + "implicit-use", + "negative", + "unchanged-on-error" + ], + "target": { + "name": "__len__", + "kind": "Method", + "file": "model.py", + "startLine": 1 + }, + "newName": "renamed", + "expect": { + "status": "error", + "code": "unsupported_symbol_family", + "planningStatus": "unsupported" + } +} diff --git a/gitnexus/test/fixtures/rename-integration/python/unknown-dynamic-attribute/after/model.py b/gitnexus/test/fixtures/rename-integration/python/unknown-dynamic-attribute/after/model.py new file mode 100644 index 000000000..91fb85293 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/python/unknown-dynamic-attribute/after/model.py @@ -0,0 +1,10 @@ +class Worker: + def target(self): + return 1 + def other_member(self): + return 2 + +worker = Worker() +worker.target() +def dynamic(name): + return getattr(worker, name) diff --git a/gitnexus/test/fixtures/rename-integration/python/unknown-dynamic-attribute/before/model.py b/gitnexus/test/fixtures/rename-integration/python/unknown-dynamic-attribute/before/model.py new file mode 100644 index 000000000..91fb85293 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/python/unknown-dynamic-attribute/before/model.py @@ -0,0 +1,10 @@ +class Worker: + def target(self): + return 1 + def other_member(self): + return 2 + +worker = Worker() +worker.target() +def dynamic(name): + return getattr(worker, name) diff --git a/gitnexus/test/fixtures/rename-integration/python/unknown-dynamic-attribute/case.json b/gitnexus/test/fixtures/rename-integration/python/unknown-dynamic-attribute/case.json new file mode 100644 index 000000000..d79496773 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/python/unknown-dynamic-attribute/case.json @@ -0,0 +1,21 @@ +{ + "language": "python", + "description": "Refuse an unknown attribute expression on the selected receiver.", + "categories": [ + "dynamic-attribute", + "negative", + "unchanged-on-error" + ], + "target": { + "name": "target", + "kind": "Method", + "file": "model.py", + "startLine": 1 + }, + "newName": "renamed", + "expect": { + "status": "error", + "code": "unsupported_occurrence", + "planningStatus": "unsupported" + } +} diff --git a/gitnexus/test/fixtures/rename-integration/python/unrelated-export-literals/after/model.py b/gitnexus/test/fixtures/rename-integration/python/unrelated-export-literals/after/model.py new file mode 100644 index 000000000..728411ee3 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/python/unrelated-export-literals/after/model.py @@ -0,0 +1,9 @@ +__all__ = ("run",) +note = "target renamed" +def inspect(): + __all__ = ["target", "renamed"] + return __all__ +class Metadata: + __all__ = ["target", "renamed"] +def renamed(): return 7 +def run(): return renamed() diff --git a/gitnexus/test/fixtures/rename-integration/python/unrelated-export-literals/after/other.py b/gitnexus/test/fixtures/rename-integration/python/unrelated-export-literals/after/other.py new file mode 100644 index 000000000..38c047a3e --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/python/unrelated-export-literals/after/other.py @@ -0,0 +1,2 @@ +__all__ = ["target", "renamed"] +def target(): return 99 diff --git a/gitnexus/test/fixtures/rename-integration/python/unrelated-export-literals/before/model.py b/gitnexus/test/fixtures/rename-integration/python/unrelated-export-literals/before/model.py new file mode 100644 index 000000000..3cbd9b142 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/python/unrelated-export-literals/before/model.py @@ -0,0 +1,9 @@ +__all__ = ("run",) +note = "target renamed" +def inspect(): + __all__ = ["target", "renamed"] + return __all__ +class Metadata: + __all__ = ["target", "renamed"] +def target(): return 7 +def run(): return target() diff --git a/gitnexus/test/fixtures/rename-integration/python/unrelated-export-literals/before/other.py b/gitnexus/test/fixtures/rename-integration/python/unrelated-export-literals/before/other.py new file mode 100644 index 000000000..38c047a3e --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/python/unrelated-export-literals/before/other.py @@ -0,0 +1,2 @@ +__all__ = ["target", "renamed"] +def target(): return 99 diff --git a/gitnexus/test/fixtures/rename-integration/python/unrelated-export-literals/case.json b/gitnexus/test/fixtures/rename-integration/python/unrelated-export-literals/case.json new file mode 100644 index 000000000..e5cf7e7cc --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/python/unrelated-export-literals/case.json @@ -0,0 +1,36 @@ +{ + "language": "python", + "description": "Unrelated modules, local and class shadows, and ordinary strings remain unchanged.", + "categories": [ + "module-exports", + "export-list", + "call-graph", + "scope-ownership" + ], + "target": { + "name": "target", + "kind": "Function", + "file": "model.py" + }, + "newName": "renamed", + "expect": { + "status": "success", + "edits": 2 + }, + "exactOutgoing": true, + "edges": [ + { + "type": "CALLS", + "from": { + "name": "run", + "kind": "Function", + "file": "model.py" + }, + "to": { + "name": "target", + "kind": "Function", + "file": "model.py" + } + } + ] +} diff --git a/gitnexus/test/fixtures/rename-integration/python/unrelated-keyword-label/after/model.py b/gitnexus/test/fixtures/rename-integration/python/unrelated-keyword-label/after/model.py new file mode 100644 index 000000000..21e2d8a12 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/python/unrelated-keyword-label/after/model.py @@ -0,0 +1,20 @@ +from dataclasses import dataclass + +@dataclass +class Point: + renamed: int + +def configure(x): + return x + +class Runner: + def run(self, x): + return x + +class Other: + def __init__(self, x): + self.value = x + +configure(x=1) +Runner().run(x=2) +Other(x=3) diff --git a/gitnexus/test/fixtures/rename-integration/python/unrelated-keyword-label/before/model.py b/gitnexus/test/fixtures/rename-integration/python/unrelated-keyword-label/before/model.py new file mode 100644 index 000000000..0ae209316 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/python/unrelated-keyword-label/before/model.py @@ -0,0 +1,20 @@ +from dataclasses import dataclass + +@dataclass +class Point: + x: int + +def configure(x): + return x + +class Runner: + def run(self, x): + return x + +class Other: + def __init__(self, x): + self.value = x + +configure(x=1) +Runner().run(x=2) +Other(x=3) diff --git a/gitnexus/test/fixtures/rename-integration/python/unrelated-keyword-label/case.json b/gitnexus/test/fixtures/rename-integration/python/unrelated-keyword-label/case.json new file mode 100644 index 000000000..9a4399707 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/python/unrelated-keyword-label/case.json @@ -0,0 +1,19 @@ +{ + "language": "python", + "description": "Rename a dataclass field beside keyword labels proven to bind other callables.", + "categories": [ + "keyword-argument", + "unrelated-name" + ], + "target": { + "name": "x", + "kind": "Property", + "file": "model.py", + "startLine": 4 + }, + "newName": "renamed", + "expect": { + "status": "success", + "edits": 1 + } +} diff --git a/gitnexus/test/fixtures/rename-integration/python/unrelated-literal-attribute/after/model.py b/gitnexus/test/fixtures/rename-integration/python/unrelated-literal-attribute/after/model.py new file mode 100644 index 000000000..87e35fa97 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/python/unrelated-literal-attribute/after/model.py @@ -0,0 +1,16 @@ +class Worker: + def renamed(self): + return 1 + def other_member(self): + return 2 + +worker = Worker() +worker.renamed() +getattr(worker, "other_member")() +setattr(worker, "other_\x6dember", worker.other_member) +hasattr(worker, r"other_member") +hasattr(worker, u"other_\u006dember") +hasattr(worker, """other_member""") +delattr(worker, "other_" "member") +def inspect_unknown(receiver): + return getattr(receiver, "other_member") diff --git a/gitnexus/test/fixtures/rename-integration/python/unrelated-literal-attribute/before/model.py b/gitnexus/test/fixtures/rename-integration/python/unrelated-literal-attribute/before/model.py new file mode 100644 index 000000000..f9e4b382c --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/python/unrelated-literal-attribute/before/model.py @@ -0,0 +1,16 @@ +class Worker: + def target(self): + return 1 + def other_member(self): + return 2 + +worker = Worker() +worker.target() +getattr(worker, "other_member")() +setattr(worker, "other_\x6dember", worker.other_member) +hasattr(worker, r"other_member") +hasattr(worker, u"other_\u006dember") +hasattr(worker, """other_member""") +delattr(worker, "other_" "member") +def inspect_unknown(receiver): + return getattr(receiver, "other_member") diff --git a/gitnexus/test/fixtures/rename-integration/python/unrelated-literal-attribute/case.json b/gitnexus/test/fixtures/rename-integration/python/unrelated-literal-attribute/case.json new file mode 100644 index 000000000..d103587fa --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/python/unrelated-literal-attribute/case.json @@ -0,0 +1,20 @@ +{ + "language": "python", + "description": "Rename a member despite dynamic access to proven different literal attributes, including decoded escapes and concatenation.", + "categories": [ + "dynamic-attribute", + "string-literal", + "unrelated-name" + ], + "target": { + "name": "target", + "kind": "Method", + "file": "model.py", + "startLine": 1 + }, + "newName": "renamed", + "expect": { + "status": "success", + "edits": 2 + } +} diff --git a/gitnexus/test/fixtures/rename-integration/python/unrelated-owner-dynamic-attribute/after/model.py b/gitnexus/test/fixtures/rename-integration/python/unrelated-owner-dynamic-attribute/after/model.py new file mode 100644 index 000000000..9083e23df --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/python/unrelated-owner-dynamic-attribute/after/model.py @@ -0,0 +1,15 @@ +class Base: + def renamed(self): + return 7 +class Child(Base): + pass +class Other: + def keep(self): + return 1 + +child = Child() +other = Other() +child.renamed() +getattr(other, "target", None) +hasattr(other, "renamed") +setattr(other, "target", 2) diff --git a/gitnexus/test/fixtures/rename-integration/python/unrelated-owner-dynamic-attribute/before/model.py b/gitnexus/test/fixtures/rename-integration/python/unrelated-owner-dynamic-attribute/before/model.py new file mode 100644 index 000000000..486aeabe3 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/python/unrelated-owner-dynamic-attribute/before/model.py @@ -0,0 +1,15 @@ +class Base: + def target(self): + return 7 +class Child(Base): + pass +class Other: + def keep(self): + return 1 + +child = Child() +other = Other() +child.target() +getattr(other, "target", None) +hasattr(other, "renamed") +setattr(other, "target", 2) diff --git a/gitnexus/test/fixtures/rename-integration/python/unrelated-owner-dynamic-attribute/case.json b/gitnexus/test/fixtures/rename-integration/python/unrelated-owner-dynamic-attribute/case.json new file mode 100644 index 000000000..8392759bf --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/python/unrelated-owner-dynamic-attribute/case.json @@ -0,0 +1,20 @@ +{ + "language": "python", + "description": "Rename an inherited member despite literal attribute access on a proven unrelated owner.", + "categories": [ + "dynamic-attribute", + "polymorphism", + "unrelated-owner" + ], + "target": { + "name": "target", + "kind": "Method", + "file": "model.py", + "startLine": 1 + }, + "newName": "renamed", + "expect": { + "status": "success", + "edits": 2 + } +} diff --git a/gitnexus/test/fixtures/rename-integration/ruby/external-base-family/after/model.rb b/gitnexus/test/fixtures/rename-integration/ruby/external-base-family/after/model.rb new file mode 100644 index 000000000..5f1410fb7 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/ruby/external-base-family/after/model.rb @@ -0,0 +1,5 @@ +class Worker < External + def target + 1 + end +end diff --git a/gitnexus/test/fixtures/rename-integration/ruby/external-base-family/after/notes.txt b/gitnexus/test/fixtures/rename-integration/ruby/external-base-family/after/notes.txt new file mode 100644 index 000000000..81db7fc3a --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/ruby/external-base-family/after/notes.txt @@ -0,0 +1 @@ +External contract and target spelling remain unchanged. diff --git a/gitnexus/test/fixtures/rename-integration/ruby/external-base-family/before/model.rb b/gitnexus/test/fixtures/rename-integration/ruby/external-base-family/before/model.rb new file mode 100644 index 000000000..5f1410fb7 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/ruby/external-base-family/before/model.rb @@ -0,0 +1,5 @@ +class Worker < External + def target + 1 + end +end diff --git a/gitnexus/test/fixtures/rename-integration/ruby/external-base-family/before/notes.txt b/gitnexus/test/fixtures/rename-integration/ruby/external-base-family/before/notes.txt new file mode 100644 index 000000000..81db7fc3a --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/ruby/external-base-family/before/notes.txt @@ -0,0 +1 @@ +External contract and target spelling remain unchanged. diff --git a/gitnexus/test/fixtures/rename-integration/ruby/external-base-family/case.json b/gitnexus/test/fixtures/rename-integration/ruby/external-base-family/case.json new file mode 100644 index 000000000..43eb03d73 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/ruby/external-base-family/case.json @@ -0,0 +1,22 @@ +{ + "language": "ruby", + "description": "Refuse a potentially polymorphic member whose external base is not indexed.", + "categories": [ + "polymorphism", + "external-base", + "negative", + "unchanged-on-error" + ], + "target": { + "name": "target", + "kind": "Method", + "file": "model.rb", + "startLine": 1 + }, + "newName": "renamed", + "expect": { + "status": "error", + "code": "unsupported_symbol_family", + "planningStatus": "unsupported" + } +} diff --git a/gitnexus/test/fixtures/rename-integration/ruby/mixin-predicate-family/after/model.rb b/gitnexus/test/fixtures/rename-integration/ruby/mixin-predicate-family/after/model.rb new file mode 100644 index 000000000..31e657a99 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/ruby/mixin-predicate-family/after/model.rb @@ -0,0 +1,23 @@ +module Contract + def available? + true + end +end +class Worker + include Contract + def available? + true + end +end +class Other + def ready? + false + end +end +worker = Worker.new +other = Other.new +worker.available? +other.ready? +[1].each { |ready| puts ready } +# ready? stays here. +label = "ready?" diff --git a/gitnexus/test/fixtures/rename-integration/ruby/mixin-predicate-family/before/model.rb b/gitnexus/test/fixtures/rename-integration/ruby/mixin-predicate-family/before/model.rb new file mode 100644 index 000000000..b27332a06 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/ruby/mixin-predicate-family/before/model.rb @@ -0,0 +1,23 @@ +module Contract + def ready? + true + end +end +class Worker + include Contract + def ready? + true + end +end +class Other + def ready? + false + end +end +worker = Worker.new +other = Other.new +worker.ready? +other.ready? +[1].each { |ready| puts ready } +# ready? stays here. +label = "ready?" diff --git a/gitnexus/test/fixtures/rename-integration/ruby/mixin-predicate-family/case.json b/gitnexus/test/fixtures/rename-integration/ruby/mixin-predicate-family/case.json new file mode 100644 index 000000000..1b69588e9 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/ruby/mixin-predicate-family/case.json @@ -0,0 +1,39 @@ +{ + "language": "ruby", + "description": "Rename a predicate shared by an included module and overriding class while keeping an unrelated owner and block parameter.", + "categories": [ + "polymorphism", + "mixin", + "punctuation-method", + "unrelated-owner", + "shadowing" + ], + "target": { + "name": "ready?", + "kind": "Method", + "file": "model.rb", + "startLine": 1 + }, + "newName": "available?", + "expect": { + "status": "success", + "edits": 3 + }, + "edges": [ + { + "type": "METHOD_IMPLEMENTS", + "from": { + "name": "ready?", + "kind": "Method", + "file": "model.rb", + "startLine": 7 + }, + "to": { + "name": "ready?", + "kind": "Method", + "file": "model.rb", + "startLine": 1 + } + } + ] +} diff --git a/gitnexus/test/fixtures/rename-integration/ruby/required-qualified-constant/after/entry.rb b/gitnexus/test/fixtures/rename-integration/ruby/required-qualified-constant/after/entry.rb new file mode 100644 index 000000000..db57d6fb6 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/ruby/required-qualified-constant/after/entry.rb @@ -0,0 +1,8 @@ +require_relative "middle" +def entry + middle() +end +first = Source::Renamed.new +second = Other::Target.new +label = "Target" +# Target stays here. diff --git a/gitnexus/test/fixtures/rename-integration/ruby/required-qualified-constant/after/middle.rb b/gitnexus/test/fixtures/rename-integration/ruby/required-qualified-constant/after/middle.rb new file mode 100644 index 000000000..8305e95a0 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/ruby/required-qualified-constant/after/middle.rb @@ -0,0 +1,7 @@ +require_relative "source" +def build + Source::Renamed.new +end +def middle + build() +end diff --git a/gitnexus/test/fixtures/rename-integration/ruby/required-qualified-constant/after/source.rb b/gitnexus/test/fixtures/rename-integration/ruby/required-qualified-constant/after/source.rb new file mode 100644 index 000000000..beb079e2a --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/ruby/required-qualified-constant/after/source.rb @@ -0,0 +1,10 @@ +module Source + class Renamed + def initialize + end + end +end +module Other + class Target + end +end diff --git a/gitnexus/test/fixtures/rename-integration/ruby/required-qualified-constant/before/entry.rb b/gitnexus/test/fixtures/rename-integration/ruby/required-qualified-constant/before/entry.rb new file mode 100644 index 000000000..87c771026 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/ruby/required-qualified-constant/before/entry.rb @@ -0,0 +1,8 @@ +require_relative "middle" +def entry + middle() +end +first = Source::Target.new +second = Other::Target.new +label = "Target" +# Target stays here. diff --git a/gitnexus/test/fixtures/rename-integration/ruby/required-qualified-constant/before/middle.rb b/gitnexus/test/fixtures/rename-integration/ruby/required-qualified-constant/before/middle.rb new file mode 100644 index 000000000..cc6882b8e --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/ruby/required-qualified-constant/before/middle.rb @@ -0,0 +1,7 @@ +require_relative "source" +def build + Source::Target.new +end +def middle + build() +end diff --git a/gitnexus/test/fixtures/rename-integration/ruby/required-qualified-constant/before/source.rb b/gitnexus/test/fixtures/rename-integration/ruby/required-qualified-constant/before/source.rb new file mode 100644 index 000000000..73d980f2c --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/ruby/required-qualified-constant/before/source.rb @@ -0,0 +1,10 @@ +module Source + class Target + def initialize + end + end +end +module Other + class Target + end +end diff --git a/gitnexus/test/fixtures/rename-integration/ruby/required-qualified-constant/case.json b/gitnexus/test/fixtures/rename-integration/ruby/required-qualified-constant/case.json new file mode 100644 index 000000000..b7b6c7f13 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/ruby/required-qualified-constant/case.json @@ -0,0 +1,50 @@ +{ + "language": "ruby", + "description": "Rename a required namespaced class across a two-hop call graph, preserving the same constant under a different namespace and constructor names.", + "categories": [ + "cross-file", + "qualified-name", + "call-graph", + "constructor", + "unrelated-owner" + ], + "target": { + "name": "Target", + "kind": "Class", + "file": "source.rb", + "startLine": 1 + }, + "newName": "Renamed", + "expect": { + "status": "success", + "edits": 3 + }, + "edges": [ + { + "type": "CALLS", + "from": { + "name": "middle", + "kind": "Method", + "file": "middle.rb" + }, + "to": { + "name": "build", + "kind": "Method", + "file": "middle.rb" + } + }, + { + "type": "CALLS", + "from": { + "name": "entry", + "kind": "Method", + "file": "entry.rb" + }, + "to": { + "name": "middle", + "kind": "Method", + "file": "middle.rb" + } + } + ] +} diff --git a/gitnexus/test/fixtures/rename-integration/ruby/setter-receiver-chain/after/model.rb b/gitnexus/test/fixtures/rename-integration/ruby/setter-receiver-chain/after/model.rb new file mode 100644 index 000000000..39ffe27e0 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/ruby/setter-receiver-chain/after/model.rb @@ -0,0 +1,26 @@ +class Leaf + def points=(value) + @score = value + end +end +class Branch + def leaf + Leaf.new + end +end +class Root + def branch + Branch.new + end +end +class Other + def score=(value) + @score = value + end +end +root = Root.new +other = Other.new +root.branch.leaf.points = 1 +other.score = 2 +score = "score" +# score stays here. diff --git a/gitnexus/test/fixtures/rename-integration/ruby/setter-receiver-chain/before/model.rb b/gitnexus/test/fixtures/rename-integration/ruby/setter-receiver-chain/before/model.rb new file mode 100644 index 000000000..9f6886294 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/ruby/setter-receiver-chain/before/model.rb @@ -0,0 +1,26 @@ +class Leaf + def score=(value) + @score = value + end +end +class Branch + def leaf + Leaf.new + end +end +class Root + def branch + Branch.new + end +end +class Other + def score=(value) + @score = value + end +end +root = Root.new +other = Other.new +root.branch.leaf.score = 1 +other.score = 2 +score = "score" +# score stays here. diff --git a/gitnexus/test/fixtures/rename-integration/ruby/setter-receiver-chain/case.json b/gitnexus/test/fixtures/rename-integration/ruby/setter-receiver-chain/case.json new file mode 100644 index 000000000..29aa591ad --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/ruby/setter-receiver-chain/case.json @@ -0,0 +1,22 @@ +{ + "language": "ruby", + "description": "Rename a public setter through a chain of return-inferred methods while preserving an unrelated setter and local binding.", + "categories": [ + "public-field", + "deep-chain", + "setter", + "unrelated-owner" + ], + "target": { + "name": "score=", + "kind": "Method", + "file": "model.rb", + "startLine": 1 + }, + "newName": "points=", + "expect": { + "status": "success", + "edits": 2 + }, + "edges": [] +} diff --git a/gitnexus/test/fixtures/rename-integration/rust/c-consumer/after/caller.c b/gitnexus/test/fixtures/rename-integration/rust/c-consumer/after/caller.c new file mode 100644 index 000000000..cd39a79eb --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/rust/c-consumer/after/caller.c @@ -0,0 +1,3 @@ +int compute(int value); + +int use(void) { return compute(1); } diff --git a/gitnexus/test/fixtures/rename-integration/rust/c-consumer/after/lib.rs b/gitnexus/test/fixtures/rename-integration/rust/c-consumer/after/lib.rs new file mode 100644 index 000000000..1dd1a81cf --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/rust/c-consumer/after/lib.rs @@ -0,0 +1,4 @@ +#[no_mangle] +pub extern "C" fn compute(value: i32) -> i32 { + value +} diff --git a/gitnexus/test/fixtures/rename-integration/rust/c-consumer/before/caller.c b/gitnexus/test/fixtures/rename-integration/rust/c-consumer/before/caller.c new file mode 100644 index 000000000..cd39a79eb --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/rust/c-consumer/before/caller.c @@ -0,0 +1,3 @@ +int compute(int value); + +int use(void) { return compute(1); } diff --git a/gitnexus/test/fixtures/rename-integration/rust/c-consumer/before/lib.rs b/gitnexus/test/fixtures/rename-integration/rust/c-consumer/before/lib.rs new file mode 100644 index 000000000..1dd1a81cf --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/rust/c-consumer/before/lib.rs @@ -0,0 +1,4 @@ +#[no_mangle] +pub extern "C" fn compute(value: i32) -> i32 { + value +} diff --git a/gitnexus/test/fixtures/rename-integration/rust/c-consumer/case.json b/gitnexus/test/fixtures/rename-integration/rust/c-consumer/case.json new file mode 100644 index 000000000..349714242 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/rust/c-consumer/case.json @@ -0,0 +1,21 @@ +{ + "language": "rust", + "description": "Refuse a Rust function exported with the C ABI that an indexed C caller names, until the C binding is proven.", + "categories": [ + "cross-language", + "interop", + "negative", + "unchanged-on-error" + ], + "target": { + "name": "compute", + "kind": "Function", + "file": "lib.rs" + }, + "newName": "calculate", + "expect": { + "status": "error", + "code": "unsupported_boundary", + "planningStatus": "unsupported" + } +} diff --git a/gitnexus/test/fixtures/rename-integration/rust/module-alias-call-chain/after/lib.rs b/gitnexus/test/fixtures/rename-integration/rust/module-alias-call-chain/after/lib.rs new file mode 100644 index 000000000..5adabcc17 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/rust/module-alias-call-chain/after/lib.rs @@ -0,0 +1,8 @@ +mod operations; +use operations::calculate as local_compute; + +fn middle(value: i32) -> i32 { local_compute(value) } +fn outer(value: i32) -> i32 { middle(value) } +fn shadow(compute: i32) -> i32 { compute } +const LABEL: &str = "compute"; +// compute is renamed at its import, while local_compute is stable. diff --git a/gitnexus/test/fixtures/rename-integration/rust/module-alias-call-chain/after/operations.rs b/gitnexus/test/fixtures/rename-integration/rust/module-alias-call-chain/after/operations.rs new file mode 100644 index 000000000..d9bd0de90 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/rust/module-alias-call-chain/after/operations.rs @@ -0,0 +1 @@ +pub fn calculate(value: i32) -> i32 { value + 1 } diff --git a/gitnexus/test/fixtures/rename-integration/rust/module-alias-call-chain/before/lib.rs b/gitnexus/test/fixtures/rename-integration/rust/module-alias-call-chain/before/lib.rs new file mode 100644 index 000000000..288ade00f --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/rust/module-alias-call-chain/before/lib.rs @@ -0,0 +1,8 @@ +mod operations; +use operations::compute as local_compute; + +fn middle(value: i32) -> i32 { local_compute(value) } +fn outer(value: i32) -> i32 { middle(value) } +fn shadow(compute: i32) -> i32 { compute } +const LABEL: &str = "compute"; +// compute is renamed at its import, while local_compute is stable. diff --git a/gitnexus/test/fixtures/rename-integration/rust/module-alias-call-chain/before/operations.rs b/gitnexus/test/fixtures/rename-integration/rust/module-alias-call-chain/before/operations.rs new file mode 100644 index 000000000..d8313a2cd --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/rust/module-alias-call-chain/before/operations.rs @@ -0,0 +1 @@ +pub fn compute(value: i32) -> i32 { value + 1 } diff --git a/gitnexus/test/fixtures/rename-integration/rust/module-alias-call-chain/case.json b/gitnexus/test/fixtures/rename-integration/rust/module-alias-call-chain/case.json new file mode 100644 index 000000000..f72956bff --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/rust/module-alias-call-chain/case.json @@ -0,0 +1,43 @@ +{ + "language": "rust", + "description": "Rename a public module function through an explicit use alias, retaining the local alias and its call chain.", + "categories": ["cross-file", "call-graph", "import-alias", "shadowing"], + "target": { + "name": "compute", + "kind": "Function", + "file": "operations.rs" + }, + "newName": "calculate", + "expect": { + "status": "success", + "edits": 2 + }, + "edges": [ + { + "type": "CALLS", + "from": { + "name": "middle", + "kind": "Function", + "file": "lib.rs" + }, + "to": { + "name": "compute", + "kind": "Function", + "file": "operations.rs" + } + }, + { + "type": "CALLS", + "from": { + "name": "outer", + "kind": "Function", + "file": "lib.rs" + }, + "to": { + "name": "middle", + "kind": "Function", + "file": "lib.rs" + } + } + ] +} diff --git a/gitnexus/test/fixtures/rename-integration/rust/public-field-borrow-chain/after/model.rs b/gitnexus/test/fixtures/rename-integration/rust/public-field-borrow-chain/after/model.rs new file mode 100644 index 000000000..2ee165683 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/rust/public-field-borrow-chain/after/model.rs @@ -0,0 +1,12 @@ +pub struct Leaf { pub amount: i32 } +pub struct Branch { pub leaf: Leaf } +pub struct Root { pub branch: Branch } +pub struct Other { pub value: i32 } +fn read(root: &Root, other: &Other) -> i32 { + root.branch.leaf.amount + other.value +} +fn write(root: &mut Root, value: i32) { + root.branch.leaf.amount = value; +} +const LABEL: &str = "value"; +// value on Other and the parameter must remain unchanged. diff --git a/gitnexus/test/fixtures/rename-integration/rust/public-field-borrow-chain/before/model.rs b/gitnexus/test/fixtures/rename-integration/rust/public-field-borrow-chain/before/model.rs new file mode 100644 index 000000000..38d9f08cf --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/rust/public-field-borrow-chain/before/model.rs @@ -0,0 +1,12 @@ +pub struct Leaf { pub value: i32 } +pub struct Branch { pub leaf: Leaf } +pub struct Root { pub branch: Branch } +pub struct Other { pub value: i32 } +fn read(root: &Root, other: &Other) -> i32 { + root.branch.leaf.value + other.value +} +fn write(root: &mut Root, value: i32) { + root.branch.leaf.value = value; +} +const LABEL: &str = "value"; +// value on Other and the parameter must remain unchanged. diff --git a/gitnexus/test/fixtures/rename-integration/rust/public-field-borrow-chain/case.json b/gitnexus/test/fixtures/rename-integration/rust/public-field-borrow-chain/case.json new file mode 100644 index 000000000..ed4bcd998 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/rust/public-field-borrow-chain/case.json @@ -0,0 +1,48 @@ +{ + "language": "rust", + "description": "Resolve a public field through nested owners under shared and mutable borrows; preserve unrelated fields and shadowing parameters.", + "categories": ["public-field", "deep-chain", "borrowed-receiver", "homonyms", "writes"], + "target": { + "name": "value", + "kind": "Property", + "file": "model.rs", + "startLine": 0 + }, + "newName": "amount", + "expect": { + "status": "success", + "edits": 3 + }, + "edges": [ + { + "type": "ACCESSES", + "from": { + "name": "read", + "kind": "Function", + "file": "model.rs", + "startLine": 4 + }, + "to": { + "name": "value", + "kind": "Property", + "file": "model.rs", + "startLine": 0 + } + }, + { + "type": "ACCESSES", + "from": { + "name": "write", + "kind": "Function", + "file": "model.rs", + "startLine": 7 + }, + "to": { + "name": "value", + "kind": "Property", + "file": "model.rs", + "startLine": 0 + } + } + ] +} diff --git a/gitnexus/test/fixtures/rename-integration/rust/trait-method-family/after/model.rs b/gitnexus/test/fixtures/rename-integration/rust/trait-method-family/after/model.rs new file mode 100644 index 000000000..a41ac8ca3 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/rust/trait-method-family/after/model.rs @@ -0,0 +1,10 @@ +trait Work { fn calculate(&self) -> i32; } +struct Worker {} +impl Work for Worker { fn calculate(&self) -> i32 { 1 } } +struct Other {} +impl Other { fn compute(&self) -> i32 { 2 } } +fn dispatch(work: &dyn Work, worker: Worker, other: Other) -> i32 { + work.calculate() + worker.calculate() + other.compute() +} +const LABEL: &str = "compute"; +// compute on Other is not part of the trait implementation. diff --git a/gitnexus/test/fixtures/rename-integration/rust/trait-method-family/before/model.rs b/gitnexus/test/fixtures/rename-integration/rust/trait-method-family/before/model.rs new file mode 100644 index 000000000..058d14384 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/rust/trait-method-family/before/model.rs @@ -0,0 +1,10 @@ +trait Work { fn compute(&self) -> i32; } +struct Worker {} +impl Work for Worker { fn compute(&self) -> i32 { 1 } } +struct Other {} +impl Other { fn compute(&self) -> i32 { 2 } } +fn dispatch(work: &dyn Work, worker: Worker, other: Other) -> i32 { + work.compute() + worker.compute() + other.compute() +} +const LABEL: &str = "compute"; +// compute on Other is not part of the trait implementation. diff --git a/gitnexus/test/fixtures/rename-integration/rust/trait-method-family/case.json b/gitnexus/test/fixtures/rename-integration/rust/trait-method-family/case.json new file mode 100644 index 000000000..b55f7355d --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/rust/trait-method-family/case.json @@ -0,0 +1,47 @@ +{ + "language": "rust", + "description": "Rename a trait method and implementation, covering dynamic-trait and concrete dispatch while preserving an unrelated inherent method.", + "categories": ["polymorphism", "method-family", "trait", "homonyms"], + "target": { + "name": "compute", + "kind": "Function", + "file": "model.rs", + "startLine": 0 + }, + "newName": "calculate", + "expect": { + "status": "success", + "edits": 4 + }, + "edges": [ + { + "type": "METHOD_IMPLEMENTS", + "from": { + "name": "compute", + "kind": "Function", + "file": "model.rs", + "startLine": 2 + }, + "to": { + "name": "compute", + "kind": "Function", + "file": "model.rs", + "startLine": 0 + } + }, + { + "type": "CALLS", + "from": { + "name": "dispatch", + "kind": "Function", + "file": "model.rs" + }, + "to": { + "name": "compute", + "kind": "Function", + "file": "model.rs", + "startLine": 0 + } + } + ] +} diff --git a/gitnexus/test/fixtures/rename-integration/swift/escaped-member-identity/after/worker.swift b/gitnexus/test/fixtures/rename-integration/swift/escaped-member-identity/after/worker.swift new file mode 100644 index 000000000..46a21fa3f --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/swift/escaped-member-identity/after/worker.swift @@ -0,0 +1,4 @@ +class Worker { + func `fetch`() -> Int { return 7 } +} +func run() -> Int { let `worker` = Worker(); return `worker`.`fetch`() } diff --git a/gitnexus/test/fixtures/rename-integration/swift/escaped-member-identity/before/worker.swift b/gitnexus/test/fixtures/rename-integration/swift/escaped-member-identity/before/worker.swift new file mode 100644 index 000000000..ae4351b66 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/swift/escaped-member-identity/before/worker.swift @@ -0,0 +1,4 @@ +class Worker { + func `read`() -> Int { return 7 } +} +func run() -> Int { let `worker` = Worker(); return `worker`.`read`() } diff --git a/gitnexus/test/fixtures/rename-integration/swift/escaped-member-identity/case.json b/gitnexus/test/fixtures/rename-integration/swift/escaped-member-identity/case.json new file mode 100644 index 000000000..9b7d7ad25 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/swift/escaped-member-identity/case.json @@ -0,0 +1,49 @@ +{ + "language": "swift", + "description": "Retain raw member declaration keys alongside canonical escaped lexical receivers.", + "categories": [ + "escaped-identifier", + "call-graph" + ], + "target": { + "name": "`read`", + "kind": "Function", + "file": "worker.swift" + }, + "newName": "fetch", + "expectedTargetName": "`fetch`", + "expect": { + "status": "success", + "edits": 2 + }, + "edges": [ + { + "type": "CALLS", + "from": { + "name": "run", + "kind": "Function", + "file": "worker.swift" + }, + "to": { + "name": "`read`", + "kind": "Function", + "file": "worker.swift" + } + }, + { + "type": "CALLS", + "from": { + "name": "run", + "kind": "Function", + "file": "worker.swift" + }, + "to": { + "name": "Worker", + "kind": "Class", + "file": "worker.swift" + } + } + ], + "exactOutgoing": true, + "warmParseCache": true +} diff --git a/gitnexus/test/fixtures/rename-integration/swift/escaped-overload-call-graph/after/encoder.swift b/gitnexus/test/fixtures/rename-integration/swift/escaped-overload-call-graph/after/encoder.swift new file mode 100644 index 000000000..8bf0dd927 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/swift/escaped-overload-call-graph/after/encoder.swift @@ -0,0 +1,13 @@ +func `serialize`() -> String { return "encode" } +func serialize(value: Int) -> String { return "number" } +func middle() -> String { + let callback: () -> String = serialize + return serialize() + serialize(value: 7) +} +func entry() -> String { return middle() } +func local(encode: String) -> String { return encode } +class OtherEncoder { + func encode() -> String { return "other" } + func use() -> String { return encode() } +} +// encode remains documentation diff --git a/gitnexus/test/fixtures/rename-integration/swift/escaped-overload-call-graph/before/encoder.swift b/gitnexus/test/fixtures/rename-integration/swift/escaped-overload-call-graph/before/encoder.swift new file mode 100644 index 000000000..4faa93e13 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/swift/escaped-overload-call-graph/before/encoder.swift @@ -0,0 +1,13 @@ +func `encode`() -> String { return "encode" } +func encode(value: Int) -> String { return "number" } +func middle() -> String { + let callback: () -> String = encode + return encode() + encode(value: 7) +} +func entry() -> String { return middle() } +func local(encode: String) -> String { return encode } +class OtherEncoder { + func encode() -> String { return "other" } + func use() -> String { return encode() } +} +// encode remains documentation diff --git a/gitnexus/test/fixtures/rename-integration/swift/escaped-overload-call-graph/case.json b/gitnexus/test/fixtures/rename-integration/swift/escaped-overload-call-graph/case.json new file mode 100644 index 000000000..77722882d --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/swift/escaped-overload-call-graph/case.json @@ -0,0 +1,87 @@ +{ + "language": "swift", + "description": "Rename escaped Swift overloads, function values and labeled calls while preserving a shadowing argument and another owner.", + "categories": [ + "escaped-identifier", + "overloads", + "call-graph", + "named-arguments", + "shadowing", + "unrelated-owner" + ], + "target": { + "name": "`encode`", + "kind": "Function", + "file": "encoder.swift", + "startLine": 0 + }, + "newName": "serialize", + "expect": { + "status": "success", + "edits": 5 + }, + "edges": [ + { + "type": "CALLS", + "from": { + "name": "entry", + "kind": "Function", + "file": "encoder.swift", + "startLine": 6 + }, + "to": { + "name": "middle", + "kind": "Function", + "file": "encoder.swift", + "startLine": 2 + } + }, + { + "type": "CALLS", + "from": { + "name": "middle", + "kind": "Function", + "file": "encoder.swift", + "startLine": 2 + }, + "to": { + "name": "`encode`", + "kind": "Function", + "file": "encoder.swift", + "startLine": 0 + } + }, + { + "type": "CALLS", + "from": { + "name": "middle", + "kind": "Function", + "file": "encoder.swift", + "startLine": 2 + }, + "to": { + "name": "encode", + "kind": "Function", + "file": "encoder.swift", + "startLine": 1 + } + }, + { + "type": "CALLS", + "from": { + "name": "use", + "kind": "Function", + "file": "encoder.swift", + "startLine": 10 + }, + "to": { + "name": "encode", + "kind": "Function", + "file": "encoder.swift", + "startLine": 9 + } + } + ], + "expectedTargetName": "`serialize`", + "exactOutgoing": true +} diff --git a/gitnexus/test/fixtures/rename-integration/swift/escaped-receiver-identity/after/worker.swift b/gitnexus/test/fixtures/rename-integration/swift/escaped-receiver-identity/after/worker.swift new file mode 100644 index 000000000..289dffb8f --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/swift/escaped-receiver-identity/after/worker.swift @@ -0,0 +1,7 @@ +class `Worker` { + func readValue() -> Int { return 1 } +} +func run() -> Int { + let `worker`: `Worker` = `Worker`() + return `worker`.readValue() +} diff --git a/gitnexus/test/fixtures/rename-integration/swift/escaped-receiver-identity/before/worker.swift b/gitnexus/test/fixtures/rename-integration/swift/escaped-receiver-identity/before/worker.swift new file mode 100644 index 000000000..a7fa68325 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/swift/escaped-receiver-identity/before/worker.swift @@ -0,0 +1,7 @@ +class `Worker` { + func read() -> Int { return 1 } +} +func run() -> Int { + let `worker`: `Worker` = `Worker`() + return `worker`.read() +} diff --git a/gitnexus/test/fixtures/rename-integration/swift/escaped-receiver-identity/case.json b/gitnexus/test/fixtures/rename-integration/swift/escaped-receiver-identity/case.json new file mode 100644 index 000000000..28afca2d2 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/swift/escaped-receiver-identity/case.json @@ -0,0 +1,37 @@ +{ + "language": "swift", + "description": "Preserve member calls through matching escaped type and local receiver spellings while renaming the member.", + "categories": [ + "escaped-identifier", + "receiver", + "call-graph" + ], + "target": { + "name": "read", + "kind": "Function", + "file": "worker.swift", + "startLine": 1 + }, + "newName": "readValue", + "expect": { + "status": "success", + "edits": 2 + }, + "edges": [ + { + "type": "CALLS", + "from": { + "name": "run", + "kind": "Function", + "file": "worker.swift", + "startLine": 3 + }, + "to": { + "name": "read", + "kind": "Function", + "file": "worker.swift", + "startLine": 1 + } + } + ] +} diff --git a/gitnexus/test/fixtures/rename-integration/swift/escaped-selective-import/after/Package.swift b/gitnexus/test/fixtures/rename-integration/swift/escaped-selective-import/after/Package.swift new file mode 100644 index 000000000..361f40f64 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/swift/escaped-selective-import/after/Package.swift @@ -0,0 +1,3 @@ +// swift-tools-version: 5.9 +import PackageDescription +let package = Package(name: "Probe", targets: [.target(name: "Vendor"), .executableTarget(name: "App", dependencies: ["Vendor"])]) diff --git a/gitnexus/test/fixtures/rename-integration/swift/escaped-selective-import/after/Sources/App/Main.swift b/gitnexus/test/fixtures/rename-integration/swift/escaped-selective-import/after/Sources/App/Main.swift new file mode 100644 index 000000000..1131a85ae --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/swift/escaped-selective-import/after/Sources/App/Main.swift @@ -0,0 +1,2 @@ +import func Vendor.`serialize` +func run() -> Int { return `serialize`() } diff --git a/gitnexus/test/fixtures/rename-integration/swift/escaped-selective-import/after/Sources/Vendor/Tools.swift b/gitnexus/test/fixtures/rename-integration/swift/escaped-selective-import/after/Sources/Vendor/Tools.swift new file mode 100644 index 000000000..d30cf67cd --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/swift/escaped-selective-import/after/Sources/Vendor/Tools.swift @@ -0,0 +1 @@ +public func `serialize`() -> Int { return 7 } diff --git a/gitnexus/test/fixtures/rename-integration/swift/escaped-selective-import/before/Package.swift b/gitnexus/test/fixtures/rename-integration/swift/escaped-selective-import/before/Package.swift new file mode 100644 index 000000000..361f40f64 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/swift/escaped-selective-import/before/Package.swift @@ -0,0 +1,3 @@ +// swift-tools-version: 5.9 +import PackageDescription +let package = Package(name: "Probe", targets: [.target(name: "Vendor"), .executableTarget(name: "App", dependencies: ["Vendor"])]) diff --git a/gitnexus/test/fixtures/rename-integration/swift/escaped-selective-import/before/Sources/App/Main.swift b/gitnexus/test/fixtures/rename-integration/swift/escaped-selective-import/before/Sources/App/Main.swift new file mode 100644 index 000000000..24d4b9e67 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/swift/escaped-selective-import/before/Sources/App/Main.swift @@ -0,0 +1,2 @@ +import func Vendor.`encode` +func run() -> Int { return `encode`() } diff --git a/gitnexus/test/fixtures/rename-integration/swift/escaped-selective-import/before/Sources/Vendor/Tools.swift b/gitnexus/test/fixtures/rename-integration/swift/escaped-selective-import/before/Sources/Vendor/Tools.swift new file mode 100644 index 000000000..d61e492d8 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/swift/escaped-selective-import/before/Sources/Vendor/Tools.swift @@ -0,0 +1 @@ +public func `encode`() -> Int { return 7 } diff --git a/gitnexus/test/fixtures/rename-integration/swift/escaped-selective-import/case.json b/gitnexus/test/fixtures/rename-integration/swift/escaped-selective-import/case.json new file mode 100644 index 000000000..4a6023be1 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/swift/escaped-selective-import/case.json @@ -0,0 +1,37 @@ +{ + "language": "swift", + "description": "Keep escaped semantic keys aligned through persisted indexing, cached reload, and exact rename.", + "categories": [ + "escaped-identifier", + "call-graph", + "cross-file" + ], + "target": { + "name": "`encode`", + "kind": "Function", + "file": "Sources/Vendor/Tools.swift" + }, + "newName": "serialize", + "expect": { + "status": "success", + "edits": 3 + }, + "edges": [ + { + "type": "CALLS", + "from": { + "name": "run", + "kind": "Function", + "file": "Sources/App/Main.swift" + }, + "to": { + "name": "`encode`", + "kind": "Function", + "file": "Sources/Vendor/Tools.swift" + } + } + ], + "exactOutgoing": true, + "warmParseCache": true, + "expectedTargetName": "`serialize`" +} diff --git a/gitnexus/test/fixtures/rename-integration/swift/explicit-init-label/after/Shapes.swift b/gitnexus/test/fixtures/rename-integration/swift/explicit-init-label/after/Shapes.swift new file mode 100644 index 000000000..fc24292a0 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/swift/explicit-init-label/after/Shapes.swift @@ -0,0 +1,15 @@ +struct Custom { + var px: Int + init(x: Int) { self.px = x } +} + +struct Point { + var x: Int + var y: Int +} + +func configure(x: Int) {} + +let custom = Custom(x: 1) +let point = Point(x: 2, y: 3) +configure(x: 4) diff --git a/gitnexus/test/fixtures/rename-integration/swift/explicit-init-label/before/Shapes.swift b/gitnexus/test/fixtures/rename-integration/swift/explicit-init-label/before/Shapes.swift new file mode 100644 index 000000000..da99b87d6 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/swift/explicit-init-label/before/Shapes.swift @@ -0,0 +1,15 @@ +struct Custom { + var x: Int + init(x: Int) { self.x = x } +} + +struct Point { + var x: Int + var y: Int +} + +func configure(x: Int) {} + +let custom = Custom(x: 1) +let point = Point(x: 2, y: 3) +configure(x: 4) diff --git a/gitnexus/test/fixtures/rename-integration/swift/explicit-init-label/case.json b/gitnexus/test/fixtures/rename-integration/swift/explicit-init-label/case.json new file mode 100644 index 000000000..264f3d66d --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/swift/explicit-init-label/case.json @@ -0,0 +1,19 @@ +{ + "language": "swift", + "description": "Rename a property of a struct with an explicit initializer beside labels of another memberwise type and another function.", + "categories": [ + "argument-label", + "unrelated-name" + ], + "target": { + "name": "x", + "kind": "Property", + "file": "Shapes.swift", + "startLine": 1 + }, + "newName": "px", + "expect": { + "status": "success", + "edits": 2 + } +} diff --git a/gitnexus/test/fixtures/rename-integration/swift/external-base-family/after/model.swift b/gitnexus/test/fixtures/rename-integration/swift/external-base-family/after/model.swift new file mode 100644 index 000000000..68b3f11d9 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/swift/external-base-family/after/model.swift @@ -0,0 +1,5 @@ +import Vendor + +class Worker: External { + override func target() -> Int { return 1 } +} diff --git a/gitnexus/test/fixtures/rename-integration/swift/external-base-family/after/notes.txt b/gitnexus/test/fixtures/rename-integration/swift/external-base-family/after/notes.txt new file mode 100644 index 000000000..81db7fc3a --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/swift/external-base-family/after/notes.txt @@ -0,0 +1 @@ +External contract and target spelling remain unchanged. diff --git a/gitnexus/test/fixtures/rename-integration/swift/external-base-family/before/model.swift b/gitnexus/test/fixtures/rename-integration/swift/external-base-family/before/model.swift new file mode 100644 index 000000000..68b3f11d9 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/swift/external-base-family/before/model.swift @@ -0,0 +1,5 @@ +import Vendor + +class Worker: External { + override func target() -> Int { return 1 } +} diff --git a/gitnexus/test/fixtures/rename-integration/swift/external-base-family/before/notes.txt b/gitnexus/test/fixtures/rename-integration/swift/external-base-family/before/notes.txt new file mode 100644 index 000000000..81db7fc3a --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/swift/external-base-family/before/notes.txt @@ -0,0 +1 @@ +External contract and target spelling remain unchanged. diff --git a/gitnexus/test/fixtures/rename-integration/swift/external-base-family/case.json b/gitnexus/test/fixtures/rename-integration/swift/external-base-family/case.json new file mode 100644 index 000000000..f7acbf672 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/swift/external-base-family/case.json @@ -0,0 +1,22 @@ +{ + "language": "swift", + "description": "Refuse a potentially polymorphic member whose external base is not indexed.", + "categories": [ + "polymorphism", + "external-base", + "negative", + "unchanged-on-error" + ], + "target": { + "name": "target", + "kind": "Function", + "file": "model.swift", + "startLine": 3 + }, + "newName": "renamed", + "expect": { + "status": "error", + "code": "unsupported_symbol_family", + "planningStatus": "unsupported" + } +} diff --git a/gitnexus/test/fixtures/rename-integration/swift/memberwise-init-label/after/Point.swift b/gitnexus/test/fixtures/rename-integration/swift/memberwise-init-label/after/Point.swift new file mode 100644 index 000000000..707af2aa4 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/swift/memberwise-init-label/after/Point.swift @@ -0,0 +1,6 @@ +struct Point { + var x: Int + var y: Int +} + +let origin = Point(x: 0, y: 0) diff --git a/gitnexus/test/fixtures/rename-integration/swift/memberwise-init-label/before/Point.swift b/gitnexus/test/fixtures/rename-integration/swift/memberwise-init-label/before/Point.swift new file mode 100644 index 000000000..707af2aa4 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/swift/memberwise-init-label/before/Point.swift @@ -0,0 +1,6 @@ +struct Point { + var x: Int + var y: Int +} + +let origin = Point(x: 0, y: 0) diff --git a/gitnexus/test/fixtures/rename-integration/swift/memberwise-init-label/case.json b/gitnexus/test/fixtures/rename-integration/swift/memberwise-init-label/case.json new file mode 100644 index 000000000..eae0986c8 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/swift/memberwise-init-label/case.json @@ -0,0 +1,22 @@ +{ + "language": "swift", + "description": "Refuse a stored property whose argument label binds the synthesized memberwise initializer parameter.", + "categories": [ + "argument-label", + "synthesized-constructor", + "negative", + "unchanged-on-error" + ], + "target": { + "name": "x", + "kind": "Property", + "file": "Point.swift", + "startLine": 1 + }, + "newName": "px", + "expect": { + "status": "error", + "code": "ambiguous_reference", + "planningStatus": "unsupported" + } +} diff --git a/gitnexus/test/fixtures/rename-integration/swift/objc-argument-consumer/after/Greeter.swift b/gitnexus/test/fixtures/rename-integration/swift/objc-argument-consumer/after/Greeter.swift new file mode 100644 index 000000000..0bfed49ff --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/swift/objc-argument-consumer/after/Greeter.swift @@ -0,0 +1,3 @@ +@objc class Greeter { + @objc func greet(_ name: String, loudly: Bool) {} +} diff --git a/gitnexus/test/fixtures/rename-integration/swift/objc-argument-consumer/after/caller.m b/gitnexus/test/fixtures/rename-integration/swift/objc-argument-consumer/after/caller.m new file mode 100644 index 000000000..04991b3bc --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/swift/objc-argument-consumer/after/caller.m @@ -0,0 +1,3 @@ +#import "Greeter-Swift.h" + +void use(Greeter *greeter) { [greeter greet:@"x" loudly:YES]; } diff --git a/gitnexus/test/fixtures/rename-integration/swift/objc-argument-consumer/before/Greeter.swift b/gitnexus/test/fixtures/rename-integration/swift/objc-argument-consumer/before/Greeter.swift new file mode 100644 index 000000000..0bfed49ff --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/swift/objc-argument-consumer/before/Greeter.swift @@ -0,0 +1,3 @@ +@objc class Greeter { + @objc func greet(_ name: String, loudly: Bool) {} +} diff --git a/gitnexus/test/fixtures/rename-integration/swift/objc-argument-consumer/before/caller.m b/gitnexus/test/fixtures/rename-integration/swift/objc-argument-consumer/before/caller.m new file mode 100644 index 000000000..04991b3bc --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/swift/objc-argument-consumer/before/caller.m @@ -0,0 +1,3 @@ +#import "Greeter-Swift.h" + +void use(Greeter *greeter) { [greeter greet:@"x" loudly:YES]; } diff --git a/gitnexus/test/fixtures/rename-integration/swift/objc-argument-consumer/case.json b/gitnexus/test/fixtures/rename-integration/swift/objc-argument-consumer/case.json new file mode 100644 index 000000000..61f878f01 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/swift/objc-argument-consumer/case.json @@ -0,0 +1,21 @@ +{ + "language": "swift", + "description": "Refuse a Swift method with arguments that an indexed Objective-C caller names by its full selector, until the Objective-C binding is proven.", + "categories": [ + "cross-language", + "interop", + "negative", + "unchanged-on-error" + ], + "target": { + "name": "greet", + "kind": "Function", + "file": "Greeter.swift" + }, + "newName": "welcome", + "expect": { + "status": "error", + "code": "unsupported_boundary", + "planningStatus": "unsupported" + } +} diff --git a/gitnexus/test/fixtures/rename-integration/swift/objc-consumer/after/Greeter.swift b/gitnexus/test/fixtures/rename-integration/swift/objc-consumer/after/Greeter.swift new file mode 100644 index 000000000..3d6dbcdb8 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/swift/objc-consumer/after/Greeter.swift @@ -0,0 +1,3 @@ +@objc class Greeter { + @objc func greet() {} +} diff --git a/gitnexus/test/fixtures/rename-integration/swift/objc-consumer/after/caller.m b/gitnexus/test/fixtures/rename-integration/swift/objc-consumer/after/caller.m new file mode 100644 index 000000000..3a60a9983 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/swift/objc-consumer/after/caller.m @@ -0,0 +1,3 @@ +#import "Greeter-Swift.h" + +void use(Greeter *greeter) { [greeter greet]; } diff --git a/gitnexus/test/fixtures/rename-integration/swift/objc-consumer/before/Greeter.swift b/gitnexus/test/fixtures/rename-integration/swift/objc-consumer/before/Greeter.swift new file mode 100644 index 000000000..3d6dbcdb8 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/swift/objc-consumer/before/Greeter.swift @@ -0,0 +1,3 @@ +@objc class Greeter { + @objc func greet() {} +} diff --git a/gitnexus/test/fixtures/rename-integration/swift/objc-consumer/before/caller.m b/gitnexus/test/fixtures/rename-integration/swift/objc-consumer/before/caller.m new file mode 100644 index 000000000..3a60a9983 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/swift/objc-consumer/before/caller.m @@ -0,0 +1,3 @@ +#import "Greeter-Swift.h" + +void use(Greeter *greeter) { [greeter greet]; } diff --git a/gitnexus/test/fixtures/rename-integration/swift/objc-consumer/case.json b/gitnexus/test/fixtures/rename-integration/swift/objc-consumer/case.json new file mode 100644 index 000000000..16709ed63 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/swift/objc-consumer/case.json @@ -0,0 +1,21 @@ +{ + "language": "swift", + "description": "Refuse a Swift method that an indexed Objective-C caller names, until the Objective-C binding is proven.", + "categories": [ + "cross-language", + "interop", + "negative", + "unchanged-on-error" + ], + "target": { + "name": "greet", + "kind": "Function", + "file": "Greeter.swift" + }, + "newName": "welcome", + "expect": { + "status": "error", + "code": "unsupported_boundary", + "planningStatus": "unsupported" + } +} diff --git a/gitnexus/test/fixtures/rename-integration/swift/objc-init-constructor-refused/after/Greeter.swift b/gitnexus/test/fixtures/rename-integration/swift/objc-init-constructor-refused/after/Greeter.swift new file mode 100644 index 000000000..450a98a21 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/swift/objc-init-constructor-refused/after/Greeter.swift @@ -0,0 +1,3 @@ +@objc class Greeter { + @objc init(name: String) {} +} diff --git a/gitnexus/test/fixtures/rename-integration/swift/objc-init-constructor-refused/after/caller.m b/gitnexus/test/fixtures/rename-integration/swift/objc-init-constructor-refused/after/caller.m new file mode 100644 index 000000000..e869fbb1e --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/swift/objc-init-constructor-refused/after/caller.m @@ -0,0 +1,3 @@ +#import "Greeter-Swift.h" + +void use(void) { Greeter *greeter = [[Greeter alloc] initWithName:@"x"]; } diff --git a/gitnexus/test/fixtures/rename-integration/swift/objc-init-constructor-refused/before/Greeter.swift b/gitnexus/test/fixtures/rename-integration/swift/objc-init-constructor-refused/before/Greeter.swift new file mode 100644 index 000000000..450a98a21 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/swift/objc-init-constructor-refused/before/Greeter.swift @@ -0,0 +1,3 @@ +@objc class Greeter { + @objc init(name: String) {} +} diff --git a/gitnexus/test/fixtures/rename-integration/swift/objc-init-constructor-refused/before/caller.m b/gitnexus/test/fixtures/rename-integration/swift/objc-init-constructor-refused/before/caller.m new file mode 100644 index 000000000..e869fbb1e --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/swift/objc-init-constructor-refused/before/caller.m @@ -0,0 +1,3 @@ +#import "Greeter-Swift.h" + +void use(void) { Greeter *greeter = [[Greeter alloc] initWithName:@"x"]; } diff --git a/gitnexus/test/fixtures/rename-integration/swift/objc-init-constructor-refused/case.json b/gitnexus/test/fixtures/rename-integration/swift/objc-init-constructor-refused/case.json new file mode 100644 index 000000000..09c9e2334 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/swift/objc-init-constructor-refused/case.json @@ -0,0 +1,21 @@ +{ + "language": "swift", + "description": "Refuse a Swift initializer that an indexed Objective-C caller constructs as initWith..., because the selector base name init meets the selected name.", + "categories": [ + "cross-language", + "interop", + "negative", + "unchanged-on-error" + ], + "target": { + "name": "init", + "kind": "Constructor", + "file": "Greeter.swift" + }, + "newName": "make", + "expect": { + "status": "error", + "code": "unsupported_boundary", + "planningStatus": "unsupported" + } +} diff --git a/gitnexus/test/fixtures/rename-integration/swift/objc-swift-name-string-attribute/after/Foo.h b/gitnexus/test/fixtures/rename-integration/swift/objc-swift-name-string-attribute/after/Foo.h new file mode 100644 index 000000000..66f43ce3c --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/swift/objc-swift-name-string-attribute/after/Foo.h @@ -0,0 +1,3 @@ +@interface Foo +- (int)fetchData __attribute__((swift_name("retrieve()"))); +@end diff --git a/gitnexus/test/fixtures/rename-integration/swift/objc-swift-name-string-attribute/after/Impl.swift b/gitnexus/test/fixtures/rename-integration/swift/objc-swift-name-string-attribute/after/Impl.swift new file mode 100644 index 000000000..bb1dace2f --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/swift/objc-swift-name-string-attribute/after/Impl.swift @@ -0,0 +1,3 @@ +class Impl { + func retrieve() -> Int32 { return 1 } +} diff --git a/gitnexus/test/fixtures/rename-integration/swift/objc-swift-name-string-attribute/before/Foo.h b/gitnexus/test/fixtures/rename-integration/swift/objc-swift-name-string-attribute/before/Foo.h new file mode 100644 index 000000000..66f43ce3c --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/swift/objc-swift-name-string-attribute/before/Foo.h @@ -0,0 +1,3 @@ +@interface Foo +- (int)fetchData __attribute__((swift_name("retrieve()"))); +@end diff --git a/gitnexus/test/fixtures/rename-integration/swift/objc-swift-name-string-attribute/before/Impl.swift b/gitnexus/test/fixtures/rename-integration/swift/objc-swift-name-string-attribute/before/Impl.swift new file mode 100644 index 000000000..bb1dace2f --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/swift/objc-swift-name-string-attribute/before/Impl.swift @@ -0,0 +1,3 @@ +class Impl { + func retrieve() -> Int32 { return 1 } +} diff --git a/gitnexus/test/fixtures/rename-integration/swift/objc-swift-name-string-attribute/case.json b/gitnexus/test/fixtures/rename-integration/swift/objc-swift-name-string-attribute/case.json new file mode 100644 index 000000000..25e7e592d --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/swift/objc-swift-name-string-attribute/case.json @@ -0,0 +1,21 @@ +{ + "language": "swift", + "description": "Refuse a Swift method that an indexed Objective-C header exposes to Swift under the same name through a swift_name attribute string, until the Swift-facing binding is proven.", + "categories": [ + "cross-language", + "interop", + "negative", + "unchanged-on-error" + ], + "target": { + "name": "retrieve", + "kind": "Function", + "file": "Impl.swift" + }, + "newName": "obtain", + "expect": { + "status": "error", + "code": "unsupported_boundary", + "planningStatus": "unsupported" + } +} diff --git a/gitnexus/test/fixtures/rename-integration/swift/objc-unrelated-selector/after/Greeter.swift b/gitnexus/test/fixtures/rename-integration/swift/objc-unrelated-selector/after/Greeter.swift new file mode 100644 index 000000000..e21b30074 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/swift/objc-unrelated-selector/after/Greeter.swift @@ -0,0 +1,5 @@ +class Greeter { + func welcome(_ name: String, loudly: Bool) {} +} + +func run(greeter: Greeter) { greeter.welcome("x", loudly: true) } diff --git a/gitnexus/test/fixtures/rename-integration/swift/objc-unrelated-selector/after/Other.m b/gitnexus/test/fixtures/rename-integration/swift/objc-unrelated-selector/after/Other.m new file mode 100644 index 000000000..123957f40 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/swift/objc-unrelated-selector/after/Other.m @@ -0,0 +1,5 @@ +@interface Other +- (void)notify:(int)count loudly:(int)flag; +@end + +void use(Other *other) { [other notify:1 loudly:2]; } diff --git a/gitnexus/test/fixtures/rename-integration/swift/objc-unrelated-selector/before/Greeter.swift b/gitnexus/test/fixtures/rename-integration/swift/objc-unrelated-selector/before/Greeter.swift new file mode 100644 index 000000000..940516ad5 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/swift/objc-unrelated-selector/before/Greeter.swift @@ -0,0 +1,5 @@ +class Greeter { + func greet(_ name: String, loudly: Bool) {} +} + +func run(greeter: Greeter) { greeter.greet("x", loudly: true) } diff --git a/gitnexus/test/fixtures/rename-integration/swift/objc-unrelated-selector/before/Other.m b/gitnexus/test/fixtures/rename-integration/swift/objc-unrelated-selector/before/Other.m new file mode 100644 index 000000000..123957f40 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/swift/objc-unrelated-selector/before/Other.m @@ -0,0 +1,5 @@ +@interface Other +- (void)notify:(int)count loudly:(int)flag; +@end + +void use(Other *other) { [other notify:1 loudly:2]; } diff --git a/gitnexus/test/fixtures/rename-integration/swift/objc-unrelated-selector/case.json b/gitnexus/test/fixtures/rename-integration/swift/objc-unrelated-selector/case.json new file mode 100644 index 000000000..76c23db0f --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/swift/objc-unrelated-selector/case.json @@ -0,0 +1,19 @@ +{ + "language": "swift", + "description": "An indexed Objective-C source whose selectors share only a later piece with the Swift method does not block a proven Swift rename.", + "categories": [ + "cross-language", + "interop", + "comments-and-strings" + ], + "target": { + "name": "greet", + "kind": "Function", + "file": "Greeter.swift" + }, + "newName": "welcome", + "expect": { + "status": "success", + "edits": 2 + } +} diff --git a/gitnexus/test/fixtures/rename-integration/swift/objc-with-label-consumer/after/Greeter.swift b/gitnexus/test/fixtures/rename-integration/swift/objc-with-label-consumer/after/Greeter.swift new file mode 100644 index 000000000..900882604 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/swift/objc-with-label-consumer/after/Greeter.swift @@ -0,0 +1,3 @@ +@objc class Greeter { + @objc func greet(name: String) {} +} diff --git a/gitnexus/test/fixtures/rename-integration/swift/objc-with-label-consumer/after/caller.m b/gitnexus/test/fixtures/rename-integration/swift/objc-with-label-consumer/after/caller.m new file mode 100644 index 000000000..cab5118b4 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/swift/objc-with-label-consumer/after/caller.m @@ -0,0 +1,3 @@ +#import "Greeter-Swift.h" + +void use(Greeter *greeter) { [greeter greetWithName:@"x"]; } diff --git a/gitnexus/test/fixtures/rename-integration/swift/objc-with-label-consumer/before/Greeter.swift b/gitnexus/test/fixtures/rename-integration/swift/objc-with-label-consumer/before/Greeter.swift new file mode 100644 index 000000000..900882604 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/swift/objc-with-label-consumer/before/Greeter.swift @@ -0,0 +1,3 @@ +@objc class Greeter { + @objc func greet(name: String) {} +} diff --git a/gitnexus/test/fixtures/rename-integration/swift/objc-with-label-consumer/before/caller.m b/gitnexus/test/fixtures/rename-integration/swift/objc-with-label-consumer/before/caller.m new file mode 100644 index 000000000..cab5118b4 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/swift/objc-with-label-consumer/before/caller.m @@ -0,0 +1,3 @@ +#import "Greeter-Swift.h" + +void use(Greeter *greeter) { [greeter greetWithName:@"x"]; } diff --git a/gitnexus/test/fixtures/rename-integration/swift/objc-with-label-consumer/case.json b/gitnexus/test/fixtures/rename-integration/swift/objc-with-label-consumer/case.json new file mode 100644 index 000000000..56479e2f4 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/swift/objc-with-label-consumer/case.json @@ -0,0 +1,21 @@ +{ + "language": "swift", + "description": "Refuse a Swift method whose first argument label Objective-C spells as a With clause of the first selector piece, until the Objective-C binding is proven.", + "categories": [ + "cross-language", + "interop", + "negative", + "unchanged-on-error" + ], + "target": { + "name": "greet", + "kind": "Function", + "file": "Greeter.swift" + }, + "newName": "welcome", + "expect": { + "status": "error", + "code": "unsupported_boundary", + "planningStatus": "unsupported" + } +} diff --git a/gitnexus/test/fixtures/rename-integration/swift/polymorphic-delivery/after/delivery.swift b/gitnexus/test/fixtures/rename-integration/swift/polymorphic-delivery/after/delivery.swift new file mode 100644 index 000000000..59cce5009 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/swift/polymorphic-delivery/after/delivery.swift @@ -0,0 +1,19 @@ +protocol Courier { + func deliver() +} +class StandardCourier: Courier { + func deliver() {} +} +class ExpressCourier: StandardCourier { + override func deliver() { super.deliver() } +} +func send(contract: Courier, standard: StandardCourier, express: ExpressCourier) { + contract.deliver() + standard.deliver() + express.deliver() +} +class UnrelatedCourier { + func dispatch() {} + func use() { dispatch() } +} +let note = "dispatch" // dispatch remains documentation diff --git a/gitnexus/test/fixtures/rename-integration/swift/polymorphic-delivery/before/delivery.swift b/gitnexus/test/fixtures/rename-integration/swift/polymorphic-delivery/before/delivery.swift new file mode 100644 index 000000000..82a90f6e8 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/swift/polymorphic-delivery/before/delivery.swift @@ -0,0 +1,19 @@ +protocol Courier { + func dispatch() +} +class StandardCourier: Courier { + func dispatch() {} +} +class ExpressCourier: StandardCourier { + override func dispatch() { super.dispatch() } +} +func send(contract: Courier, standard: StandardCourier, express: ExpressCourier) { + contract.dispatch() + standard.dispatch() + express.dispatch() +} +class UnrelatedCourier { + func dispatch() {} + func use() { dispatch() } +} +let note = "dispatch" // dispatch remains documentation diff --git a/gitnexus/test/fixtures/rename-integration/swift/polymorphic-delivery/case.json b/gitnexus/test/fixtures/rename-integration/swift/polymorphic-delivery/case.json new file mode 100644 index 000000000..bf261d1db --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/swift/polymorphic-delivery/case.json @@ -0,0 +1,68 @@ +{ + "language": "swift", + "description": "Rename a Swift protocol requirement through its implementation, override, super call and typed receivers.", + "categories": [ + "polymorphism", + "call-graph", + "unrelated-owner", + "comments-and-strings" + ], + "target": { + "name": "dispatch", + "kind": "Method", + "file": "delivery.swift", + "startLine": 1 + }, + "newName": "deliver", + "expect": { + "status": "success", + "edits": 7 + }, + "edges": [ + { + "type": "METHOD_IMPLEMENTS", + "from": { + "name": "dispatch", + "kind": "Function", + "file": "delivery.swift", + "startLine": 4 + }, + "to": { + "name": "dispatch", + "kind": "Method", + "file": "delivery.swift", + "startLine": 1 + } + }, + { + "type": "METHOD_OVERRIDES", + "from": { + "name": "ExpressCourier", + "kind": "Class", + "file": "delivery.swift", + "startLine": 6 + }, + "to": { + "name": "dispatch", + "kind": "Function", + "file": "delivery.swift", + "startLine": 4 + } + }, + { + "type": "CALLS", + "from": { + "name": "send", + "kind": "Function", + "file": "delivery.swift", + "startLine": 9 + }, + "to": { + "name": "dispatch", + "kind": "Method", + "file": "delivery.swift", + "startLine": 1 + } + } + ] +} diff --git a/gitnexus/test/fixtures/rename-integration/swift/public-property-chain/after/profile.swift b/gitnexus/test/fixtures/rename-integration/swift/public-property-chain/after/profile.swift new file mode 100644 index 000000000..725490db0 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/swift/public-property-chain/after/profile.swift @@ -0,0 +1,17 @@ +class Profile { + public var displayBadge: String = "badge" +} +class Account { + var profile: Profile = Profile() +} +class Session { + var account: Account = Account() +} +func read(session: Session) -> String { return session.account.profile.displayBadge } +func update(session: Session, badge: String) { session.account.profile.displayBadge = badge } +func direct(profile: Profile) -> String { return profile.displayBadge } +class OtherProfile { + public var badge: String = "badge" + func read(other: OtherProfile) -> String { return other.badge } +} +// badge is a stored property, not this comment diff --git a/gitnexus/test/fixtures/rename-integration/swift/public-property-chain/before/profile.swift b/gitnexus/test/fixtures/rename-integration/swift/public-property-chain/before/profile.swift new file mode 100644 index 000000000..72add43cd --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/swift/public-property-chain/before/profile.swift @@ -0,0 +1,17 @@ +class Profile { + public var badge: String = "badge" +} +class Account { + var profile: Profile = Profile() +} +class Session { + var account: Account = Account() +} +func read(session: Session) -> String { return session.account.profile.badge } +func update(session: Session, badge: String) { session.account.profile.badge = badge } +func direct(profile: Profile) -> String { return profile.badge } +class OtherProfile { + public var badge: String = "badge" + func read(other: OtherProfile) -> String { return other.badge } +} +// badge is a stored property, not this comment diff --git a/gitnexus/test/fixtures/rename-integration/swift/public-property-chain/case.json b/gitnexus/test/fixtures/rename-integration/swift/public-property-chain/case.json new file mode 100644 index 000000000..256ab214e --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/swift/public-property-chain/case.json @@ -0,0 +1,53 @@ +{ + "language": "swift", + "description": "Rename a Swift public stored property through three-hop receiver chains without renaming a parameter label or unrelated property.", + "categories": [ + "public-field", + "deep-chain", + "shadowing", + "unrelated-owner" + ], + "target": { + "name": "badge", + "kind": "Property", + "file": "profile.swift", + "startLine": 1 + }, + "newName": "displayBadge", + "expect": { + "status": "success", + "edits": 4 + }, + "edges": [ + { + "type": "ACCESSES", + "from": { + "name": "read", + "kind": "Function", + "file": "profile.swift", + "startLine": 9 + }, + "to": { + "name": "badge", + "kind": "Property", + "file": "profile.swift", + "startLine": 1 + } + }, + { + "type": "ACCESSES", + "from": { + "name": "update", + "kind": "Function", + "file": "profile.swift", + "startLine": 10 + }, + "to": { + "name": "badge", + "kind": "Property", + "file": "profile.swift", + "startLine": 1 + } + } + ] +} diff --git a/gitnexus/test/fixtures/rename-integration/swift/unqualified-owner-visibility/after/owners.swift b/gitnexus/test/fixtures/rename-integration/swift/unqualified-owner-visibility/after/owners.swift new file mode 100644 index 000000000..577964f79 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/swift/unqualified-owner-visibility/after/owners.swift @@ -0,0 +1,12 @@ +class Base { + func touch() {} + func own() { touch() } +} +class Derived: Base { + func inherited() { touch() } +} +class Other { + func touch() {} +} +func measured() {} +func freeCaller() { touch(); measured() } diff --git a/gitnexus/test/fixtures/rename-integration/swift/unqualified-owner-visibility/before/owners.swift b/gitnexus/test/fixtures/rename-integration/swift/unqualified-owner-visibility/before/owners.swift new file mode 100644 index 000000000..35cb2c100 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/swift/unqualified-owner-visibility/before/owners.swift @@ -0,0 +1,12 @@ +class Base { + func touch() {} + func own() { touch() } +} +class Derived: Base { + func inherited() { touch() } +} +class Other { + func touch() {} +} +func sentinel() {} +func freeCaller() { touch(); sentinel() } diff --git a/gitnexus/test/fixtures/rename-integration/swift/unqualified-owner-visibility/case.json b/gitnexus/test/fixtures/rename-integration/swift/unqualified-owner-visibility/case.json new file mode 100644 index 000000000..22a535ace --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/swift/unqualified-owner-visibility/case.json @@ -0,0 +1,69 @@ +{ + "language": "swift", + "description": "Keep unqualified calls within their own or inherited owner and leave a free-context unresolved name unbound to instance methods.", + "categories": [ + "receiver", + "inheritance", + "unrelated-owner", + "call-graph" + ], + "target": { + "name": "sentinel", + "kind": "Function", + "file": "owners.swift", + "startLine": 10 + }, + "newName": "measured", + "expect": { + "status": "success", + "edits": 2 + }, + "edges": [ + { + "type": "CALLS", + "from": { + "name": "own", + "kind": "Function", + "file": "owners.swift", + "startLine": 2 + }, + "to": { + "name": "touch", + "kind": "Function", + "file": "owners.swift", + "startLine": 1 + } + }, + { + "type": "CALLS", + "from": { + "name": "inherited", + "kind": "Function", + "file": "owners.swift", + "startLine": 5 + }, + "to": { + "name": "touch", + "kind": "Function", + "file": "owners.swift", + "startLine": 1 + } + }, + { + "type": "CALLS", + "from": { + "name": "freeCaller", + "kind": "Function", + "file": "owners.swift", + "startLine": 11 + }, + "to": { + "name": "sentinel", + "kind": "Function", + "file": "owners.swift", + "startLine": 10 + } + } + ], + "exactOutgoing": true +} diff --git a/gitnexus/test/fixtures/rename-integration/typescript/aliases-shorthand-call-chain/after/entry.ts b/gitnexus/test/fixtures/rename-integration/typescript/aliases-shorthand-call-chain/after/entry.ts new file mode 100644 index 000000000..0611b35f3 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/aliases-shorthand-call-chain/after/entry.ts @@ -0,0 +1,6 @@ +import { compute } from "./source"; +import { middle } from "./middle"; +export function entry() { return middle(); } +export const handlers = { calculate: compute }; +function shadow(calculate: number) { return calculate; } +const label = "calculate"; diff --git a/gitnexus/test/fixtures/rename-integration/typescript/aliases-shorthand-call-chain/after/middle.ts b/gitnexus/test/fixtures/rename-integration/typescript/aliases-shorthand-call-chain/after/middle.ts new file mode 100644 index 000000000..805ccf032 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/aliases-shorthand-call-chain/after/middle.ts @@ -0,0 +1,2 @@ +import { compute as localCalculate } from "./source"; +export function middle() { return localCalculate(); } diff --git a/gitnexus/test/fixtures/rename-integration/typescript/aliases-shorthand-call-chain/after/source.ts b/gitnexus/test/fixtures/rename-integration/typescript/aliases-shorthand-call-chain/after/source.ts new file mode 100644 index 000000000..b96835f5e --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/aliases-shorthand-call-chain/after/source.ts @@ -0,0 +1,2 @@ +export function compute() { return 7; } +// calculate is documented here. diff --git a/gitnexus/test/fixtures/rename-integration/typescript/aliases-shorthand-call-chain/before/entry.ts b/gitnexus/test/fixtures/rename-integration/typescript/aliases-shorthand-call-chain/before/entry.ts new file mode 100644 index 000000000..7162f2dd5 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/aliases-shorthand-call-chain/before/entry.ts @@ -0,0 +1,6 @@ +import { calculate } from "./source"; +import { middle } from "./middle"; +export function entry() { return middle(); } +export const handlers = { calculate }; +function shadow(calculate: number) { return calculate; } +const label = "calculate"; diff --git a/gitnexus/test/fixtures/rename-integration/typescript/aliases-shorthand-call-chain/before/middle.ts b/gitnexus/test/fixtures/rename-integration/typescript/aliases-shorthand-call-chain/before/middle.ts new file mode 100644 index 000000000..945353a8a --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/aliases-shorthand-call-chain/before/middle.ts @@ -0,0 +1,2 @@ +import { calculate as localCalculate } from "./source"; +export function middle() { return localCalculate(); } diff --git a/gitnexus/test/fixtures/rename-integration/typescript/aliases-shorthand-call-chain/before/source.ts b/gitnexus/test/fixtures/rename-integration/typescript/aliases-shorthand-call-chain/before/source.ts new file mode 100644 index 000000000..29cee6113 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/aliases-shorthand-call-chain/before/source.ts @@ -0,0 +1,2 @@ +export function calculate() { return 7; } +// calculate is documented here. diff --git a/gitnexus/test/fixtures/rename-integration/typescript/aliases-shorthand-call-chain/case.json b/gitnexus/test/fixtures/rename-integration/typescript/aliases-shorthand-call-chain/case.json new file mode 100644 index 000000000..b62e09de1 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/aliases-shorthand-call-chain/case.json @@ -0,0 +1,49 @@ +{ + "language": "typescript", + "description": "Rename an exported function across an explicit import alias and a direct import, preserving object keys and parameter shadows.", + "categories": [ + "cross-file", + "alias", + "call-graph", + "shorthand", + "shadowing" + ], + "target": { + "name": "calculate", + "kind": "Function", + "file": "source.ts" + }, + "newName": "compute", + "expect": { + "status": "success", + "edits": 4 + }, + "edges": [ + { + "type": "CALLS", + "from": { + "name": "middle", + "kind": "Function", + "file": "middle.ts" + }, + "to": { + "name": "calculate", + "kind": "Function", + "file": "source.ts" + } + }, + { + "type": "CALLS", + "from": { + "name": "entry", + "kind": "Function", + "file": "entry.ts" + }, + "to": { + "name": "middle", + "kind": "Function", + "file": "middle.ts" + } + } + ] +} diff --git a/gitnexus/test/fixtures/rename-integration/typescript/deep-chain-intermediate-write/after/caller.ts b/gitnexus/test/fixtures/rename-integration/typescript/deep-chain-intermediate-write/after/caller.ts new file mode 100644 index 000000000..2cf904369 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/deep-chain-intermediate-write/after/caller.ts @@ -0,0 +1,10 @@ +import { Root, Other } from "./model"; +export function update() { + const root = new Root(); + const other = new Other(); + root.branch.leaf = { score: 12 }; + root.branch.leaf.score = root.branch.leaf.score + 1; + return root.branch.leaf.score + other.score; +} +// score stays in documentation. +const label = "score"; diff --git a/gitnexus/test/fixtures/rename-integration/typescript/deep-chain-intermediate-write/after/model.ts b/gitnexus/test/fixtures/rename-integration/typescript/deep-chain-intermediate-write/after/model.ts new file mode 100644 index 000000000..983bb5b14 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/deep-chain-intermediate-write/after/model.ts @@ -0,0 +1,12 @@ +export class Leaf { + public score: number = 0; +} +export class Branch { + public leaf: Leaf = new Leaf(); +} +export class Root { + public branch: Branch = new Branch(); +} +export class Other { + public score: number = 9; +} diff --git a/gitnexus/test/fixtures/rename-integration/typescript/deep-chain-intermediate-write/before/caller.ts b/gitnexus/test/fixtures/rename-integration/typescript/deep-chain-intermediate-write/before/caller.ts new file mode 100644 index 000000000..2cf904369 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/deep-chain-intermediate-write/before/caller.ts @@ -0,0 +1,10 @@ +import { Root, Other } from "./model"; +export function update() { + const root = new Root(); + const other = new Other(); + root.branch.leaf = { score: 12 }; + root.branch.leaf.score = root.branch.leaf.score + 1; + return root.branch.leaf.score + other.score; +} +// score stays in documentation. +const label = "score"; diff --git a/gitnexus/test/fixtures/rename-integration/typescript/deep-chain-intermediate-write/before/model.ts b/gitnexus/test/fixtures/rename-integration/typescript/deep-chain-intermediate-write/before/model.ts new file mode 100644 index 000000000..983bb5b14 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/deep-chain-intermediate-write/before/model.ts @@ -0,0 +1,12 @@ +export class Leaf { + public score: number = 0; +} +export class Branch { + public leaf: Leaf = new Leaf(); +} +export class Root { + public branch: Branch = new Branch(); +} +export class Other { + public score: number = 9; +} diff --git a/gitnexus/test/fixtures/rename-integration/typescript/deep-chain-intermediate-write/case.json b/gitnexus/test/fixtures/rename-integration/typescript/deep-chain-intermediate-write/case.json new file mode 100644 index 000000000..1da5f7e64 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/deep-chain-intermediate-write/case.json @@ -0,0 +1,40 @@ +{ + "language": "typescript", + "description": "Refuse a constructor-proven deep receiver after its intermediate field is replaced with an object literal; preserve every file.", + "categories": [ + "public-field", + "deep-chain", + "read-write", + "cross-file", + "constructor-proven-receiver", + "unsafe-intermediate-receiver" + ], + "target": { + "name": "score", + "kind": "Property", + "file": "model.ts", + "startLine": 1 + }, + "newName": "points", + "expect": { + "status": "error", + "code": "ambiguous_reference", + "planningStatus": "unsupported" + }, + "edges": [ + { + "type": "ACCESSES", + "from": { + "name": "update", + "kind": "Function", + "file": "caller.ts" + }, + "to": { + "name": "score", + "kind": "Property", + "file": "model.ts", + "startLine": 1 + } + } + ] +} diff --git a/gitnexus/test/fixtures/rename-integration/typescript/deep-chain-structural-alias-write/after/entry.ts b/gitnexus/test/fixtures/rename-integration/typescript/deep-chain-structural-alias-write/after/entry.ts new file mode 100644 index 000000000..3238c855e --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/deep-chain-structural-alias-write/after/entry.ts @@ -0,0 +1,5 @@ +import { Root, OtherBranch } from "./model"; +const root = new Root(); +const alias: OtherBranch = root.branch; +alias.leaf = { score: 12 }; +root.branch.leaf.score = root.branch.leaf.score + 1; diff --git a/gitnexus/test/fixtures/rename-integration/typescript/deep-chain-structural-alias-write/after/model.ts b/gitnexus/test/fixtures/rename-integration/typescript/deep-chain-structural-alias-write/after/model.ts new file mode 100644 index 000000000..4420efd33 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/deep-chain-structural-alias-write/after/model.ts @@ -0,0 +1,6 @@ +export class Leaf { score = 0; } +export class Branch { leaf: Leaf = new Leaf(); } +export class Root { branch: Branch = new Branch(); } +export class Other { score = 9; } +export const shared = new Root(); +export class OtherBranch { leaf: Leaf = new Leaf(); } diff --git a/gitnexus/test/fixtures/rename-integration/typescript/deep-chain-structural-alias-write/before/entry.ts b/gitnexus/test/fixtures/rename-integration/typescript/deep-chain-structural-alias-write/before/entry.ts new file mode 100644 index 000000000..3238c855e --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/deep-chain-structural-alias-write/before/entry.ts @@ -0,0 +1,5 @@ +import { Root, OtherBranch } from "./model"; +const root = new Root(); +const alias: OtherBranch = root.branch; +alias.leaf = { score: 12 }; +root.branch.leaf.score = root.branch.leaf.score + 1; diff --git a/gitnexus/test/fixtures/rename-integration/typescript/deep-chain-structural-alias-write/before/model.ts b/gitnexus/test/fixtures/rename-integration/typescript/deep-chain-structural-alias-write/before/model.ts new file mode 100644 index 000000000..4420efd33 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/deep-chain-structural-alias-write/before/model.ts @@ -0,0 +1,6 @@ +export class Leaf { score = 0; } +export class Branch { leaf: Leaf = new Leaf(); } +export class Root { branch: Branch = new Branch(); } +export class Other { score = 9; } +export const shared = new Root(); +export class OtherBranch { leaf: Leaf = new Leaf(); } diff --git a/gitnexus/test/fixtures/rename-integration/typescript/deep-chain-structural-alias-write/case.json b/gitnexus/test/fixtures/rename-integration/typescript/deep-chain-structural-alias-write/case.json new file mode 100644 index 000000000..13d4acf50 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/deep-chain-structural-alias-write/case.json @@ -0,0 +1,24 @@ +{ + "language": "typescript", + "description": "Refuse an intermediate-field mutation hidden by an unrelated structural annotation on an alias; preserve every file.", + "categories": [ + "public-field", + "deep-chain", + "cross-file", + "alias", + "structural-type", + "unsafe-intermediate-receiver" + ], + "target": { + "name": "score", + "kind": "Property", + "file": "model.ts", + "startLine": 0 + }, + "newName": "points", + "expect": { + "status": "error", + "code": "ambiguous_reference", + "planningStatus": "unsupported" + } +} diff --git a/gitnexus/test/fixtures/rename-integration/typescript/deep-chain-structural-initializer/after/caller.ts b/gitnexus/test/fixtures/rename-integration/typescript/deep-chain-structural-initializer/after/caller.ts new file mode 100644 index 000000000..29c07f8bc --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/deep-chain-structural-initializer/after/caller.ts @@ -0,0 +1,9 @@ +import { Root, Other } from "./model"; +export function update() { + const root = new Root(); + const other = new Other(); + root.branch.leaf.score = root.branch.leaf.score + 1; + return root.branch.leaf.score + other.score; +} +// score stays in documentation. +const label = "score"; diff --git a/gitnexus/test/fixtures/rename-integration/typescript/deep-chain-structural-initializer/after/model.ts b/gitnexus/test/fixtures/rename-integration/typescript/deep-chain-structural-initializer/after/model.ts new file mode 100644 index 000000000..a18449948 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/deep-chain-structural-initializer/after/model.ts @@ -0,0 +1,12 @@ +export class Leaf { + public score: number = 0; +} +export class Branch { + public leaf: Leaf = new Other(); +} +export class Root { + public branch: Branch = new Branch(); +} +export class Other { + public score: number = 9; +} diff --git a/gitnexus/test/fixtures/rename-integration/typescript/deep-chain-structural-initializer/before/caller.ts b/gitnexus/test/fixtures/rename-integration/typescript/deep-chain-structural-initializer/before/caller.ts new file mode 100644 index 000000000..29c07f8bc --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/deep-chain-structural-initializer/before/caller.ts @@ -0,0 +1,9 @@ +import { Root, Other } from "./model"; +export function update() { + const root = new Root(); + const other = new Other(); + root.branch.leaf.score = root.branch.leaf.score + 1; + return root.branch.leaf.score + other.score; +} +// score stays in documentation. +const label = "score"; diff --git a/gitnexus/test/fixtures/rename-integration/typescript/deep-chain-structural-initializer/before/model.ts b/gitnexus/test/fixtures/rename-integration/typescript/deep-chain-structural-initializer/before/model.ts new file mode 100644 index 000000000..a18449948 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/deep-chain-structural-initializer/before/model.ts @@ -0,0 +1,12 @@ +export class Leaf { + public score: number = 0; +} +export class Branch { + public leaf: Leaf = new Other(); +} +export class Root { + public branch: Branch = new Branch(); +} +export class Other { + public score: number = 9; +} diff --git a/gitnexus/test/fixtures/rename-integration/typescript/deep-chain-structural-initializer/case.json b/gitnexus/test/fixtures/rename-integration/typescript/deep-chain-structural-initializer/case.json new file mode 100644 index 000000000..4ec3e23b1 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/deep-chain-structural-initializer/case.json @@ -0,0 +1,40 @@ +{ + "language": "typescript", + "description": "Refuse an intermediate field whose structural annotation hides an unrelated constructor; preserve every file.", + "categories": [ + "public-field", + "deep-chain", + "read-write", + "cross-file", + "constructor-proven-receiver", + "unsafe-intermediate-receiver" + ], + "target": { + "name": "score", + "kind": "Property", + "file": "model.ts", + "startLine": 1 + }, + "newName": "points", + "expect": { + "status": "error", + "code": "ambiguous_reference", + "planningStatus": "unsupported" + }, + "edges": [ + { + "type": "ACCESSES", + "from": { + "name": "update", + "kind": "Function", + "file": "caller.ts" + }, + "to": { + "name": "score", + "kind": "Property", + "file": "model.ts", + "startLine": 1 + } + } + ] +} diff --git a/gitnexus/test/fixtures/rename-integration/typescript/escaped-callable-flow/after/callbacks.ts b/gitnexus/test/fixtures/rename-integration/typescript/escaped-callable-flow/after/callbacks.ts new file mode 100644 index 000000000..6943367c9 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/escaped-callable-flow/after/callbacks.ts @@ -0,0 +1,4 @@ +function compute() { return 7; } +function invoke(c\u0062) { return c\u0062(); } +export function run() { const c\u0062 = compute; const next = cb; return next(); } +export function throughParameter() { return invoke(compute); } diff --git a/gitnexus/test/fixtures/rename-integration/typescript/escaped-callable-flow/before/callbacks.ts b/gitnexus/test/fixtures/rename-integration/typescript/escaped-callable-flow/before/callbacks.ts new file mode 100644 index 000000000..8659ff937 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/escaped-callable-flow/before/callbacks.ts @@ -0,0 +1,4 @@ +function calc\u0075late() { return 7; } +function invoke(c\u0062) { return c\u0062(); } +export function run() { const c\u0062 = calc\u0075late; const next = cb; return next(); } +export function throughParameter() { return invoke(calc\u0075late); } diff --git a/gitnexus/test/fixtures/rename-integration/typescript/escaped-callable-flow/case.json b/gitnexus/test/fixtures/rename-integration/typescript/escaped-callable-flow/case.json new file mode 100644 index 000000000..7bb167a42 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/escaped-callable-flow/case.json @@ -0,0 +1,62 @@ +{ + "language": "typescript", + "description": "Keep escaped semantic keys aligned through persisted indexing, cached reload, and exact rename.", + "categories": [ + "escaped-identifier", + "call-graph", + "cross-file" + ], + "target": { + "name": "calc\\u0075late", + "kind": "Function", + "file": "callbacks.ts" + }, + "newName": "compute", + "expect": { + "status": "success", + "edits": 3 + }, + "edges": [ + { + "type": "CALLS", + "from": { + "name": "run", + "kind": "Function", + "file": "callbacks.ts" + }, + "to": { + "name": "calc\\u0075late", + "kind": "Function", + "file": "callbacks.ts" + } + }, + { + "type": "CALLS", + "from": { + "name": "throughParameter", + "kind": "Function", + "file": "callbacks.ts" + }, + "to": { + "name": "invoke", + "kind": "Function", + "file": "callbacks.ts" + } + }, + { + "type": "CALLS", + "from": { + "name": "invoke", + "kind": "Function", + "file": "callbacks.ts" + }, + "to": { + "name": "calc\\u0075late", + "kind": "Function", + "file": "callbacks.ts" + } + } + ], + "exactOutgoing": true, + "warmParseCache": true +} diff --git a/gitnexus/test/fixtures/rename-integration/typescript/escaped-import-destination/after/entry.ts b/gitnexus/test/fixtures/rename-integration/typescript/escaped-import-destination/after/entry.ts new file mode 100644 index 000000000..4e4194873 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/escaped-import-destination/after/entry.ts @@ -0,0 +1,2 @@ +import { comp\u0075te } from "./source"; +export function run() { return comp\u0075te(); } diff --git a/gitnexus/test/fixtures/rename-integration/typescript/escaped-import-destination/after/source.ts b/gitnexus/test/fixtures/rename-integration/typescript/escaped-import-destination/after/source.ts new file mode 100644 index 000000000..3fb83a5b8 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/escaped-import-destination/after/source.ts @@ -0,0 +1 @@ +export function comp\u0075te() { return 7; } diff --git a/gitnexus/test/fixtures/rename-integration/typescript/escaped-import-destination/before/entry.ts b/gitnexus/test/fixtures/rename-integration/typescript/escaped-import-destination/before/entry.ts new file mode 100644 index 000000000..ae714c33f --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/escaped-import-destination/before/entry.ts @@ -0,0 +1,2 @@ +import { calc\u0075late } from "./source"; +export function run() { return calc\u0075late(); } diff --git a/gitnexus/test/fixtures/rename-integration/typescript/escaped-import-destination/before/source.ts b/gitnexus/test/fixtures/rename-integration/typescript/escaped-import-destination/before/source.ts new file mode 100644 index 000000000..57c202da5 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/escaped-import-destination/before/source.ts @@ -0,0 +1 @@ +export function calculate() { return 7; } diff --git a/gitnexus/test/fixtures/rename-integration/typescript/escaped-import-destination/case.json b/gitnexus/test/fixtures/rename-integration/typescript/escaped-import-destination/case.json new file mode 100644 index 000000000..d7e525f36 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/escaped-import-destination/case.json @@ -0,0 +1,35 @@ +{ + "language": "typescript", + "description": "Rename a canonical export imported through an escaped identifier to an escaped destination and preserve its persisted call edge.", + "categories": [ + "cross-file", + "escaped-identifier", + "alias", + "call-graph" + ], + "target": { + "name": "calculate", + "kind": "Function", + "file": "source.ts" + }, + "newName": "comp\\u0075te", + "expect": { + "status": "success", + "edits": 3 + }, + "edges": [ + { + "type": "CALLS", + "from": { + "name": "run", + "kind": "Function", + "file": "entry.ts" + }, + "to": { + "name": "calculate", + "kind": "Function", + "file": "source.ts" + } + } + ] +} diff --git a/gitnexus/test/fixtures/rename-integration/typescript/escaped-import-local-spelling/after/entry.ts b/gitnexus/test/fixtures/rename-integration/typescript/escaped-import-local-spelling/after/entry.ts new file mode 100644 index 000000000..b08cae1d4 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/escaped-import-local-spelling/after/entry.ts @@ -0,0 +1,2 @@ +import { compute } from "./source"; +export function run() { return compute(); } diff --git a/gitnexus/test/fixtures/rename-integration/typescript/escaped-import-local-spelling/after/source.ts b/gitnexus/test/fixtures/rename-integration/typescript/escaped-import-local-spelling/after/source.ts new file mode 100644 index 000000000..394b03271 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/escaped-import-local-spelling/after/source.ts @@ -0,0 +1 @@ +export function compute() { return 7; } diff --git a/gitnexus/test/fixtures/rename-integration/typescript/escaped-import-local-spelling/before/entry.ts b/gitnexus/test/fixtures/rename-integration/typescript/escaped-import-local-spelling/before/entry.ts new file mode 100644 index 000000000..7e98d0616 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/escaped-import-local-spelling/before/entry.ts @@ -0,0 +1,2 @@ +import { calc\u0075late } from "./source"; +export function run() { return calculate(); } diff --git a/gitnexus/test/fixtures/rename-integration/typescript/escaped-import-local-spelling/before/source.ts b/gitnexus/test/fixtures/rename-integration/typescript/escaped-import-local-spelling/before/source.ts new file mode 100644 index 000000000..57c202da5 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/escaped-import-local-spelling/before/source.ts @@ -0,0 +1 @@ +export function calculate() { return 7; } diff --git a/gitnexus/test/fixtures/rename-integration/typescript/escaped-import-local-spelling/case.json b/gitnexus/test/fixtures/rename-integration/typescript/escaped-import-local-spelling/case.json new file mode 100644 index 000000000..93ad8b0f8 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/escaped-import-local-spelling/case.json @@ -0,0 +1,35 @@ +{ + "language": "typescript", + "description": "Bind an escaped import to an ordinarily spelled local call before and after rename.", + "categories": [ + "escaped-identifier", + "cross-file", + "call-graph" + ], + "target": { + "name": "calculate", + "kind": "Function", + "file": "source.ts" + }, + "newName": "compute", + "expect": { + "status": "success", + "edits": 3 + }, + "edges": [ + { + "type": "CALLS", + "from": { + "name": "run", + "kind": "Function", + "file": "entry.ts" + }, + "to": { + "name": "calculate", + "kind": "Function", + "file": "source.ts" + } + } + ], + "exactOutgoing": true +} diff --git a/gitnexus/test/fixtures/rename-integration/typescript/exported-constructor-binding/after/entry.ts b/gitnexus/test/fixtures/rename-integration/typescript/exported-constructor-binding/after/entry.ts new file mode 100644 index 000000000..3cc40bbe8 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/exported-constructor-binding/after/entry.ts @@ -0,0 +1,2 @@ +import { value as renderer } from "./model"; +export function entry() { return renderer.paint(); } diff --git a/gitnexus/test/fixtures/rename-integration/typescript/exported-constructor-binding/after/model.ts b/gitnexus/test/fixtures/rename-integration/typescript/exported-constructor-binding/after/model.ts new file mode 100644 index 000000000..e4d5b90fa --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/exported-constructor-binding/after/model.ts @@ -0,0 +1,16 @@ +export interface Renderer { + paint(): string; +} +export class Base implements Renderer { + paint(): string { return "render"; } +} +export class Child extends Base { + override paint(): string { return super.paint(); } +} +export class Other { + render(): string { return "other"; } +} +export const value: Renderer = new Child(); +export function invoke() { + return value.paint(); +} diff --git a/gitnexus/test/fixtures/rename-integration/typescript/exported-constructor-binding/before/entry.ts b/gitnexus/test/fixtures/rename-integration/typescript/exported-constructor-binding/before/entry.ts new file mode 100644 index 000000000..81fe7b113 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/exported-constructor-binding/before/entry.ts @@ -0,0 +1,2 @@ +import { value as renderer } from "./model"; +export function entry() { return renderer.render(); } diff --git a/gitnexus/test/fixtures/rename-integration/typescript/exported-constructor-binding/before/model.ts b/gitnexus/test/fixtures/rename-integration/typescript/exported-constructor-binding/before/model.ts new file mode 100644 index 000000000..43a112184 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/exported-constructor-binding/before/model.ts @@ -0,0 +1,16 @@ +export interface Renderer { + render(): string; +} +export class Base implements Renderer { + render(): string { return "render"; } +} +export class Child extends Base { + override render(): string { return super.render(); } +} +export class Other { + render(): string { return "other"; } +} +export const value: Renderer = new Child(); +export function invoke() { + return value.render(); +} diff --git a/gitnexus/test/fixtures/rename-integration/typescript/exported-constructor-binding/case.json b/gitnexus/test/fixtures/rename-integration/typescript/exported-constructor-binding/case.json new file mode 100644 index 000000000..e23f52f1b --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/exported-constructor-binding/case.json @@ -0,0 +1,38 @@ +{ + "language": "typescript", + "description": "Rename an explicit implementation family through a constructor-proven exported const and its imported alias.", + "categories": [ + "polymorphism", + "call-graph", + "constructor-proven-receiver", + "cross-file-alias", + "exported-value" + ], + "target": { + "name": "render", + "kind": "Method", + "file": "model.ts", + "startLine": 1 + }, + "newName": "paint", + "expect": { + "status": "success", + "edits": 6 + }, + "edges": [ + { + "type": "CALLS", + "from": { + "name": "entry", + "kind": "Function", + "file": "entry.ts" + }, + "to": { + "name": "render", + "kind": "Method", + "file": "model.ts", + "startLine": 1 + } + } + ] +} diff --git a/gitnexus/test/fixtures/rename-integration/typescript/external-base-family/after/model.ts b/gitnexus/test/fixtures/rename-integration/typescript/external-base-family/after/model.ts new file mode 100644 index 000000000..558d8c2af --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/external-base-family/after/model.ts @@ -0,0 +1,5 @@ +import { External } from "vendor"; + +export class Worker implements External { + target(): number { return 1; } +} diff --git a/gitnexus/test/fixtures/rename-integration/typescript/external-base-family/after/notes.txt b/gitnexus/test/fixtures/rename-integration/typescript/external-base-family/after/notes.txt new file mode 100644 index 000000000..81db7fc3a --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/external-base-family/after/notes.txt @@ -0,0 +1 @@ +External contract and target spelling remain unchanged. diff --git a/gitnexus/test/fixtures/rename-integration/typescript/external-base-family/before/model.ts b/gitnexus/test/fixtures/rename-integration/typescript/external-base-family/before/model.ts new file mode 100644 index 000000000..558d8c2af --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/external-base-family/before/model.ts @@ -0,0 +1,5 @@ +import { External } from "vendor"; + +export class Worker implements External { + target(): number { return 1; } +} diff --git a/gitnexus/test/fixtures/rename-integration/typescript/external-base-family/before/notes.txt b/gitnexus/test/fixtures/rename-integration/typescript/external-base-family/before/notes.txt new file mode 100644 index 000000000..81db7fc3a --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/external-base-family/before/notes.txt @@ -0,0 +1 @@ +External contract and target spelling remain unchanged. diff --git a/gitnexus/test/fixtures/rename-integration/typescript/external-base-family/case.json b/gitnexus/test/fixtures/rename-integration/typescript/external-base-family/case.json new file mode 100644 index 000000000..af2295e1a --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/external-base-family/case.json @@ -0,0 +1,22 @@ +{ + "language": "typescript", + "description": "Refuse a potentially polymorphic member whose external base is not indexed.", + "categories": [ + "polymorphism", + "external-base", + "negative", + "unchanged-on-error" + ], + "target": { + "name": "target", + "kind": "Method", + "file": "model.ts", + "startLine": 3 + }, + "newName": "renamed", + "expect": { + "status": "error", + "code": "unsupported_symbol_family", + "planningStatus": "unsupported" + } +} diff --git a/gitnexus/test/fixtures/rename-integration/typescript/external-base-private-member/after/model.ts b/gitnexus/test/fixtures/rename-integration/typescript/external-base-private-member/after/model.ts new file mode 100644 index 000000000..4de69ee18 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/external-base-private-member/after/model.ts @@ -0,0 +1,6 @@ +import { External } from "vendor"; + +export class Worker extends External { + private renamed(): number { return 1; } + invoke(): number { return this.renamed(); } +} diff --git a/gitnexus/test/fixtures/rename-integration/typescript/external-base-private-member/after/notes.txt b/gitnexus/test/fixtures/rename-integration/typescript/external-base-private-member/after/notes.txt new file mode 100644 index 000000000..e9d22ee2a --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/external-base-private-member/after/notes.txt @@ -0,0 +1 @@ +The external contract is outside this private member family. diff --git a/gitnexus/test/fixtures/rename-integration/typescript/external-base-private-member/before/model.ts b/gitnexus/test/fixtures/rename-integration/typescript/external-base-private-member/before/model.ts new file mode 100644 index 000000000..fce8f7d85 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/external-base-private-member/before/model.ts @@ -0,0 +1,6 @@ +import { External } from "vendor"; + +export class Worker extends External { + private target(): number { return 1; } + invoke(): number { return this.target(); } +} diff --git a/gitnexus/test/fixtures/rename-integration/typescript/external-base-private-member/before/notes.txt b/gitnexus/test/fixtures/rename-integration/typescript/external-base-private-member/before/notes.txt new file mode 100644 index 000000000..e9d22ee2a --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/external-base-private-member/before/notes.txt @@ -0,0 +1 @@ +The external contract is outside this private member family. diff --git a/gitnexus/test/fixtures/rename-integration/typescript/external-base-private-member/case.json b/gitnexus/test/fixtures/rename-integration/typescript/external-base-private-member/case.json new file mode 100644 index 000000000..a488bf247 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/external-base-private-member/case.json @@ -0,0 +1,36 @@ +{ + "language": "typescript", + "description": "Provider-private methods remain renameable despite an unindexed base.", + "categories": [ + "polymorphism", + "external-base", + "private-member", + "call-graph" + ], + "target": { + "name": "target", + "kind": "Method", + "file": "model.ts", + "startLine": 3 + }, + "newName": "renamed", + "expect": { + "status": "success", + "edits": 2 + }, + "edges": [ + { + "type": "CALLS", + "from": { + "name": "invoke", + "kind": "Method", + "file": "model.ts" + }, + "to": { + "name": "target", + "kind": "Method", + "file": "model.ts" + } + } + ] +} diff --git a/gitnexus/test/fixtures/rename-integration/typescript/imported-receiver-alias-write/after/entry.ts b/gitnexus/test/fixtures/rename-integration/typescript/imported-receiver-alias-write/after/entry.ts new file mode 100644 index 000000000..7333acd59 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/imported-receiver-alias-write/after/entry.ts @@ -0,0 +1,3 @@ +import { value as renderer, Other } from "./model"; +export function entry() { return renderer.render(); } +export function replace() { renderer = new Other(); } diff --git a/gitnexus/test/fixtures/rename-integration/typescript/imported-receiver-alias-write/after/model.ts b/gitnexus/test/fixtures/rename-integration/typescript/imported-receiver-alias-write/after/model.ts new file mode 100644 index 000000000..43a112184 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/imported-receiver-alias-write/after/model.ts @@ -0,0 +1,16 @@ +export interface Renderer { + render(): string; +} +export class Base implements Renderer { + render(): string { return "render"; } +} +export class Child extends Base { + override render(): string { return super.render(); } +} +export class Other { + render(): string { return "other"; } +} +export const value: Renderer = new Child(); +export function invoke() { + return value.render(); +} diff --git a/gitnexus/test/fixtures/rename-integration/typescript/imported-receiver-alias-write/before/entry.ts b/gitnexus/test/fixtures/rename-integration/typescript/imported-receiver-alias-write/before/entry.ts new file mode 100644 index 000000000..7333acd59 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/imported-receiver-alias-write/before/entry.ts @@ -0,0 +1,3 @@ +import { value as renderer, Other } from "./model"; +export function entry() { return renderer.render(); } +export function replace() { renderer = new Other(); } diff --git a/gitnexus/test/fixtures/rename-integration/typescript/imported-receiver-alias-write/before/model.ts b/gitnexus/test/fixtures/rename-integration/typescript/imported-receiver-alias-write/before/model.ts new file mode 100644 index 000000000..43a112184 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/imported-receiver-alias-write/before/model.ts @@ -0,0 +1,16 @@ +export interface Renderer { + render(): string; +} +export class Base implements Renderer { + render(): string { return "render"; } +} +export class Child extends Base { + override render(): string { return super.render(); } +} +export class Other { + render(): string { return "other"; } +} +export const value: Renderer = new Child(); +export function invoke() { + return value.render(); +} diff --git a/gitnexus/test/fixtures/rename-integration/typescript/imported-receiver-alias-write/case.json b/gitnexus/test/fixtures/rename-integration/typescript/imported-receiver-alias-write/case.json new file mode 100644 index 000000000..db7c8d751 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/imported-receiver-alias-write/case.json @@ -0,0 +1,23 @@ +{ + "language": "typescript", + "description": "Refuse a receiver whose imported alias is reassigned in the same file; semantic resolution blocks the plan before provider occurrence checks.", + "categories": [ + "cross-file-alias", + "constructor-proven-receiver", + "reassignment", + "negative", + "unchanged-on-error" + ], + "target": { + "name": "render", + "kind": "Method", + "file": "model.ts", + "startLine": 1 + }, + "newName": "paint", + "expect": { + "status": "error", + "code": "ambiguous_reference", + "planningStatus": "blocked" + } +} diff --git a/gitnexus/test/fixtures/rename-integration/typescript/imported-receiver-write-other-file/after/entry.ts b/gitnexus/test/fixtures/rename-integration/typescript/imported-receiver-write-other-file/after/entry.ts new file mode 100644 index 000000000..7da14f424 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/imported-receiver-write-other-file/after/entry.ts @@ -0,0 +1,2 @@ +import { value as renderer } from "./model"; +export function entry() { return renderer.render() + renderer.render(); } diff --git a/gitnexus/test/fixtures/rename-integration/typescript/imported-receiver-write-other-file/after/model.ts b/gitnexus/test/fixtures/rename-integration/typescript/imported-receiver-write-other-file/after/model.ts new file mode 100644 index 000000000..43a112184 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/imported-receiver-write-other-file/after/model.ts @@ -0,0 +1,16 @@ +export interface Renderer { + render(): string; +} +export class Base implements Renderer { + render(): string { return "render"; } +} +export class Child extends Base { + override render(): string { return super.render(); } +} +export class Other { + render(): string { return "other"; } +} +export const value: Renderer = new Child(); +export function invoke() { + return value.render(); +} diff --git a/gitnexus/test/fixtures/rename-integration/typescript/imported-receiver-write-other-file/after/mutator.ts b/gitnexus/test/fixtures/rename-integration/typescript/imported-receiver-write-other-file/after/mutator.ts new file mode 100644 index 000000000..f47f6d6ed --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/imported-receiver-write-other-file/after/mutator.ts @@ -0,0 +1,2 @@ +import { value as changedReceiver, Other } from "./model"; +export function replace() { changedReceiver = new Other(); } diff --git a/gitnexus/test/fixtures/rename-integration/typescript/imported-receiver-write-other-file/before/entry.ts b/gitnexus/test/fixtures/rename-integration/typescript/imported-receiver-write-other-file/before/entry.ts new file mode 100644 index 000000000..7da14f424 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/imported-receiver-write-other-file/before/entry.ts @@ -0,0 +1,2 @@ +import { value as renderer } from "./model"; +export function entry() { return renderer.render() + renderer.render(); } diff --git a/gitnexus/test/fixtures/rename-integration/typescript/imported-receiver-write-other-file/before/model.ts b/gitnexus/test/fixtures/rename-integration/typescript/imported-receiver-write-other-file/before/model.ts new file mode 100644 index 000000000..43a112184 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/imported-receiver-write-other-file/before/model.ts @@ -0,0 +1,16 @@ +export interface Renderer { + render(): string; +} +export class Base implements Renderer { + render(): string { return "render"; } +} +export class Child extends Base { + override render(): string { return super.render(); } +} +export class Other { + render(): string { return "other"; } +} +export const value: Renderer = new Child(); +export function invoke() { + return value.render(); +} diff --git a/gitnexus/test/fixtures/rename-integration/typescript/imported-receiver-write-other-file/before/mutator.ts b/gitnexus/test/fixtures/rename-integration/typescript/imported-receiver-write-other-file/before/mutator.ts new file mode 100644 index 000000000..f47f6d6ed --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/imported-receiver-write-other-file/before/mutator.ts @@ -0,0 +1,2 @@ +import { value as changedReceiver, Other } from "./model"; +export function replace() { changedReceiver = new Other(); } diff --git a/gitnexus/test/fixtures/rename-integration/typescript/imported-receiver-write-other-file/case.json b/gitnexus/test/fixtures/rename-integration/typescript/imported-receiver-write-other-file/case.json new file mode 100644 index 000000000..51d86e9c3 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/imported-receiver-write-other-file/case.json @@ -0,0 +1,23 @@ +{ + "language": "typescript", + "description": "Refuse writes through a different imported alias in another file while the calling alias remains resolved.", + "categories": [ + "cross-file-alias", + "constructor-proven-receiver", + "reassignment", + "negative", + "unchanged-on-error" + ], + "target": { + "name": "render", + "kind": "Method", + "file": "model.ts", + "startLine": 1 + }, + "newName": "paint", + "expect": { + "status": "error", + "code": "ambiguous_reference", + "planningStatus": "unsupported" + } +} diff --git a/gitnexus/test/fixtures/rename-integration/typescript/inherited-computed-member-refusal/after/model.ts b/gitnexus/test/fixtures/rename-integration/typescript/inherited-computed-member-refusal/after/model.ts new file mode 100644 index 000000000..4bc97cf85 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/inherited-computed-member-refusal/after/model.ts @@ -0,0 +1,7 @@ +export class Writer { close() { return 1; } } +class SpecializedWriter extends Writer {} +export function run() { + const writer = new SpecializedWriter(); + const key = 'close'; + return writer[key](); +} diff --git a/gitnexus/test/fixtures/rename-integration/typescript/inherited-computed-member-refusal/before/model.ts b/gitnexus/test/fixtures/rename-integration/typescript/inherited-computed-member-refusal/before/model.ts new file mode 100644 index 000000000..4bc97cf85 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/inherited-computed-member-refusal/before/model.ts @@ -0,0 +1,7 @@ +export class Writer { close() { return 1; } } +class SpecializedWriter extends Writer {} +export function run() { + const writer = new SpecializedWriter(); + const key = 'close'; + return writer[key](); +} diff --git a/gitnexus/test/fixtures/rename-integration/typescript/inherited-computed-member-refusal/case.json b/gitnexus/test/fixtures/rename-integration/typescript/inherited-computed-member-refusal/case.json new file mode 100644 index 000000000..9ceb4ea87 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/inherited-computed-member-refusal/case.json @@ -0,0 +1,8 @@ +{ + "language": "typescript", + "description": "Refuse a computed access through a subclass that can select an inherited renamed method.", + "categories": ["member-keys", "inheritance", "negative", "unchanged-on-error"], + "target": { "name": "close", "kind": "Method", "file": "model.ts", "startLine": 0 }, + "newName": "finish", + "expect": { "status": "error", "code": "unsupported_occurrence", "planningStatus": "unsupported" } +} diff --git a/gitnexus/test/fixtures/rename-integration/typescript/inherited-intermediate-field-shadow/after/model.ts b/gitnexus/test/fixtures/rename-integration/typescript/inherited-intermediate-field-shadow/after/model.ts new file mode 100644 index 000000000..62535f0d7 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/inherited-intermediate-field-shadow/after/model.ts @@ -0,0 +1,8 @@ +export class A { value = 1; } +export class B { value = 2; } +export class Base { + child = new A(); + read() { return this.child.value; } +} +export class Derived extends Base { child = new B(); } +export function run() { const instance = new Derived(); return instance.read(); } diff --git a/gitnexus/test/fixtures/rename-integration/typescript/inherited-intermediate-field-shadow/before/model.ts b/gitnexus/test/fixtures/rename-integration/typescript/inherited-intermediate-field-shadow/before/model.ts new file mode 100644 index 000000000..62535f0d7 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/inherited-intermediate-field-shadow/before/model.ts @@ -0,0 +1,8 @@ +export class A { value = 1; } +export class B { value = 2; } +export class Base { + child = new A(); + read() { return this.child.value; } +} +export class Derived extends Base { child = new B(); } +export function run() { const instance = new Derived(); return instance.read(); } diff --git a/gitnexus/test/fixtures/rename-integration/typescript/inherited-intermediate-field-shadow/case.json b/gitnexus/test/fixtures/rename-integration/typescript/inherited-intermediate-field-shadow/case.json new file mode 100644 index 000000000..544afc334 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/inherited-intermediate-field-shadow/case.json @@ -0,0 +1,64 @@ +{ + "language": "typescript", + "description": "Refuse a field rename through an inherited method when a descendant replaces the intermediate receiver field with a different constructor.", + "categories": [ + "public-field", + "deep-chain", + "inheritance", + "unsafe-intermediate-receiver" + ], + "target": { + "name": "value", + "kind": "Property", + "file": "model.ts", + "startLine": 0 + }, + "newName": "renamed", + "expect": { + "status": "error", + "code": "ambiguous_reference", + "planningStatus": "unsupported" + }, + "edges": [ + { + "type": "EXTENDS", + "from": { + "name": "Derived", + "kind": "Class", + "file": "model.ts" + }, + "to": { + "name": "Base", + "kind": "Class", + "file": "model.ts" + } + }, + { + "type": "CALLS", + "from": { + "name": "run", + "kind": "Function", + "file": "model.ts" + }, + "to": { + "name": "read", + "kind": "Method", + "file": "model.ts" + } + }, + { + "type": "ACCESSES", + "from": { + "name": "read", + "kind": "Method", + "file": "model.ts" + }, + "to": { + "name": "value", + "kind": "Property", + "file": "model.ts", + "startLine": 0 + } + } + ] +} diff --git a/gitnexus/test/fixtures/rename-integration/typescript/member-destructure-alias-refusal/after/model.ts b/gitnexus/test/fixtures/rename-integration/typescript/member-destructure-alias-refusal/after/model.ts new file mode 100644 index 000000000..21f9c1eb1 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/member-destructure-alias-refusal/after/model.ts @@ -0,0 +1,8 @@ +export class Writer { + close() { return 1; } +} +export function run() { + const writer = new Writer(); + const { close: closeWriter } = writer; + return writer.close(); +} diff --git a/gitnexus/test/fixtures/rename-integration/typescript/member-destructure-alias-refusal/before/model.ts b/gitnexus/test/fixtures/rename-integration/typescript/member-destructure-alias-refusal/before/model.ts new file mode 100644 index 000000000..21f9c1eb1 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/member-destructure-alias-refusal/before/model.ts @@ -0,0 +1,8 @@ +export class Writer { + close() { return 1; } +} +export function run() { + const writer = new Writer(); + const { close: closeWriter } = writer; + return writer.close(); +} diff --git a/gitnexus/test/fixtures/rename-integration/typescript/member-destructure-alias-refusal/case.json b/gitnexus/test/fixtures/rename-integration/typescript/member-destructure-alias-refusal/case.json new file mode 100644 index 000000000..297af106d --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/member-destructure-alias-refusal/case.json @@ -0,0 +1,38 @@ +{ + "language": "typescript", + "description": "Refuse an unproven object or destructuring member key, preserving all source bytes.", + "categories": [ + "member-keys", + "call-graph", + "negative", + "unchanged-on-error" + ], + "target": { + "name": "close", + "kind": "Method", + "file": "model.ts", + "startLine": 1 + }, + "newName": "finish", + "expect": { + "status": "error", + "code": "ambiguous_reference", + "planningStatus": "unsupported" + }, + "edges": [ + { + "type": "CALLS", + "from": { + "name": "run", + "kind": "Function", + "file": "model.ts" + }, + "to": { + "name": "close", + "kind": "Method", + "file": "model.ts", + "startLine": 1 + } + } + ] +} diff --git a/gitnexus/test/fixtures/rename-integration/typescript/member-destructure-destination-refusal/after/model.ts b/gitnexus/test/fixtures/rename-integration/typescript/member-destructure-destination-refusal/after/model.ts new file mode 100644 index 000000000..aadf6befc --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/member-destructure-destination-refusal/after/model.ts @@ -0,0 +1,8 @@ +export class Writer { + close() { return 1; } +} +export function run() { + const writer = new Writer(); + const { finish: finishWriter } = writer; + return writer.close(); +} diff --git a/gitnexus/test/fixtures/rename-integration/typescript/member-destructure-destination-refusal/before/model.ts b/gitnexus/test/fixtures/rename-integration/typescript/member-destructure-destination-refusal/before/model.ts new file mode 100644 index 000000000..aadf6befc --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/member-destructure-destination-refusal/before/model.ts @@ -0,0 +1,8 @@ +export class Writer { + close() { return 1; } +} +export function run() { + const writer = new Writer(); + const { finish: finishWriter } = writer; + return writer.close(); +} diff --git a/gitnexus/test/fixtures/rename-integration/typescript/member-destructure-destination-refusal/case.json b/gitnexus/test/fixtures/rename-integration/typescript/member-destructure-destination-refusal/case.json new file mode 100644 index 000000000..297af106d --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/member-destructure-destination-refusal/case.json @@ -0,0 +1,38 @@ +{ + "language": "typescript", + "description": "Refuse an unproven object or destructuring member key, preserving all source bytes.", + "categories": [ + "member-keys", + "call-graph", + "negative", + "unchanged-on-error" + ], + "target": { + "name": "close", + "kind": "Method", + "file": "model.ts", + "startLine": 1 + }, + "newName": "finish", + "expect": { + "status": "error", + "code": "ambiguous_reference", + "planningStatus": "unsupported" + }, + "edges": [ + { + "type": "CALLS", + "from": { + "name": "run", + "kind": "Function", + "file": "model.ts" + }, + "to": { + "name": "close", + "kind": "Method", + "file": "model.ts", + "startLine": 1 + } + } + ] +} diff --git a/gitnexus/test/fixtures/rename-integration/typescript/member-destructure-shorthand-refusal/after/model.ts b/gitnexus/test/fixtures/rename-integration/typescript/member-destructure-shorthand-refusal/after/model.ts new file mode 100644 index 000000000..b91d26038 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/member-destructure-shorthand-refusal/after/model.ts @@ -0,0 +1,8 @@ +export class Writer { + close() { return 1; } +} +export function run() { + const writer = new Writer(); + const { close } = writer; + return writer.close(); +} diff --git a/gitnexus/test/fixtures/rename-integration/typescript/member-destructure-shorthand-refusal/before/model.ts b/gitnexus/test/fixtures/rename-integration/typescript/member-destructure-shorthand-refusal/before/model.ts new file mode 100644 index 000000000..b91d26038 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/member-destructure-shorthand-refusal/before/model.ts @@ -0,0 +1,8 @@ +export class Writer { + close() { return 1; } +} +export function run() { + const writer = new Writer(); + const { close } = writer; + return writer.close(); +} diff --git a/gitnexus/test/fixtures/rename-integration/typescript/member-destructure-shorthand-refusal/case.json b/gitnexus/test/fixtures/rename-integration/typescript/member-destructure-shorthand-refusal/case.json new file mode 100644 index 000000000..297af106d --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/member-destructure-shorthand-refusal/case.json @@ -0,0 +1,38 @@ +{ + "language": "typescript", + "description": "Refuse an unproven object or destructuring member key, preserving all source bytes.", + "categories": [ + "member-keys", + "call-graph", + "negative", + "unchanged-on-error" + ], + "target": { + "name": "close", + "kind": "Method", + "file": "model.ts", + "startLine": 1 + }, + "newName": "finish", + "expect": { + "status": "error", + "code": "ambiguous_reference", + "planningStatus": "unsupported" + }, + "edges": [ + { + "type": "CALLS", + "from": { + "name": "run", + "kind": "Function", + "file": "model.ts" + }, + "to": { + "name": "close", + "kind": "Method", + "file": "model.ts", + "startLine": 1 + } + } + ] +} diff --git a/gitnexus/test/fixtures/rename-integration/typescript/member-keys-unrelated-owner/after/model.ts b/gitnexus/test/fixtures/rename-integration/typescript/member-keys-unrelated-owner/after/model.ts new file mode 100644 index 000000000..ef3095772 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/member-keys-unrelated-owner/after/model.ts @@ -0,0 +1,14 @@ +export class Writer { + finish() { return 1; } +} +export class Other { + close() { return 2; } +} +export function run() { + const writer = new Writer(); + const other = new Other(); + const { close: closeOther } = other; + const method: Other = { close() { return 3; } }; + const property: Other = { close: () => 4 }; + return writer.finish(); +} diff --git a/gitnexus/test/fixtures/rename-integration/typescript/member-keys-unrelated-owner/before/model.ts b/gitnexus/test/fixtures/rename-integration/typescript/member-keys-unrelated-owner/before/model.ts new file mode 100644 index 000000000..04906525a --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/member-keys-unrelated-owner/before/model.ts @@ -0,0 +1,14 @@ +export class Writer { + close() { return 1; } +} +export class Other { + close() { return 2; } +} +export function run() { + const writer = new Writer(); + const other = new Other(); + const { close: closeOther } = other; + const method: Other = { close() { return 3; } }; + const property: Other = { close: () => 4 }; + return writer.close(); +} diff --git a/gitnexus/test/fixtures/rename-integration/typescript/member-keys-unrelated-owner/case.json b/gitnexus/test/fixtures/rename-integration/typescript/member-keys-unrelated-owner/case.json new file mode 100644 index 000000000..0ddf2f328 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/member-keys-unrelated-owner/case.json @@ -0,0 +1,37 @@ +{ + "language": "typescript", + "description": "Preserve keys whose constructor receiver or contextual annotation proves an unrelated member owner.", + "categories": [ + "member-keys", + "call-graph", + "unrelated-owner", + "constructor-proven-receiver" + ], + "target": { + "name": "close", + "kind": "Method", + "file": "model.ts", + "startLine": 1 + }, + "newName": "finish", + "expect": { + "status": "success", + "edits": 2 + }, + "edges": [ + { + "type": "CALLS", + "from": { + "name": "run", + "kind": "Function", + "file": "model.ts" + }, + "to": { + "name": "close", + "kind": "Method", + "file": "model.ts", + "startLine": 1 + } + } + ] +} diff --git a/gitnexus/test/fixtures/rename-integration/typescript/member-object-method-refusal/after/model.ts b/gitnexus/test/fixtures/rename-integration/typescript/member-object-method-refusal/after/model.ts new file mode 100644 index 000000000..38c929540 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/member-object-method-refusal/after/model.ts @@ -0,0 +1,8 @@ +export class Writer { + close() { return 1; } +} +export function run() { + const writer = new Writer(); + const backup: Writer = { close() { return 2; } }; + return writer.close(); +} diff --git a/gitnexus/test/fixtures/rename-integration/typescript/member-object-method-refusal/before/model.ts b/gitnexus/test/fixtures/rename-integration/typescript/member-object-method-refusal/before/model.ts new file mode 100644 index 000000000..38c929540 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/member-object-method-refusal/before/model.ts @@ -0,0 +1,8 @@ +export class Writer { + close() { return 1; } +} +export function run() { + const writer = new Writer(); + const backup: Writer = { close() { return 2; } }; + return writer.close(); +} diff --git a/gitnexus/test/fixtures/rename-integration/typescript/member-object-method-refusal/case.json b/gitnexus/test/fixtures/rename-integration/typescript/member-object-method-refusal/case.json new file mode 100644 index 000000000..297af106d --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/member-object-method-refusal/case.json @@ -0,0 +1,38 @@ +{ + "language": "typescript", + "description": "Refuse an unproven object or destructuring member key, preserving all source bytes.", + "categories": [ + "member-keys", + "call-graph", + "negative", + "unchanged-on-error" + ], + "target": { + "name": "close", + "kind": "Method", + "file": "model.ts", + "startLine": 1 + }, + "newName": "finish", + "expect": { + "status": "error", + "code": "ambiguous_reference", + "planningStatus": "unsupported" + }, + "edges": [ + { + "type": "CALLS", + "from": { + "name": "run", + "kind": "Function", + "file": "model.ts" + }, + "to": { + "name": "close", + "kind": "Method", + "file": "model.ts", + "startLine": 1 + } + } + ] +} diff --git a/gitnexus/test/fixtures/rename-integration/typescript/member-object-pair-refusal/after/model.ts b/gitnexus/test/fixtures/rename-integration/typescript/member-object-pair-refusal/after/model.ts new file mode 100644 index 000000000..0e43f7599 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/member-object-pair-refusal/after/model.ts @@ -0,0 +1,8 @@ +export class Writer { + close() { return 1; } +} +export function run() { + const writer = new Writer(); + const backup: Writer = { close: () => 2 }; + return writer.close(); +} diff --git a/gitnexus/test/fixtures/rename-integration/typescript/member-object-pair-refusal/before/model.ts b/gitnexus/test/fixtures/rename-integration/typescript/member-object-pair-refusal/before/model.ts new file mode 100644 index 000000000..0e43f7599 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/member-object-pair-refusal/before/model.ts @@ -0,0 +1,8 @@ +export class Writer { + close() { return 1; } +} +export function run() { + const writer = new Writer(); + const backup: Writer = { close: () => 2 }; + return writer.close(); +} diff --git a/gitnexus/test/fixtures/rename-integration/typescript/member-object-pair-refusal/case.json b/gitnexus/test/fixtures/rename-integration/typescript/member-object-pair-refusal/case.json new file mode 100644 index 000000000..297af106d --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/member-object-pair-refusal/case.json @@ -0,0 +1,38 @@ +{ + "language": "typescript", + "description": "Refuse an unproven object or destructuring member key, preserving all source bytes.", + "categories": [ + "member-keys", + "call-graph", + "negative", + "unchanged-on-error" + ], + "target": { + "name": "close", + "kind": "Method", + "file": "model.ts", + "startLine": 1 + }, + "newName": "finish", + "expect": { + "status": "error", + "code": "ambiguous_reference", + "planningStatus": "unsupported" + }, + "edges": [ + { + "type": "CALLS", + "from": { + "name": "run", + "kind": "Function", + "file": "model.ts" + }, + "to": { + "name": "close", + "kind": "Method", + "file": "model.ts", + "startLine": 1 + } + } + ] +} diff --git a/gitnexus/test/fixtures/rename-integration/typescript/polymorphic-family/after/model.ts b/gitnexus/test/fixtures/rename-integration/typescript/polymorphic-family/after/model.ts new file mode 100644 index 000000000..b7da46caf --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/polymorphic-family/after/model.ts @@ -0,0 +1,20 @@ +interface Renderer { + paint(): string; +} +class Base implements Renderer { + paint(): string { return "render"; } +} +class Child extends Base { + override paint(): string { return super.paint(); } +} +class Other { + render(): string { return "other"; } +} +function invoke() { + const value: Renderer = new Child(); + const base = new Base(); + const child = new Child(); + const other = new Other(); + return value.paint() + base.paint() + child.paint() + other.render(); +} +// render is an API name. diff --git a/gitnexus/test/fixtures/rename-integration/typescript/polymorphic-family/before/model.ts b/gitnexus/test/fixtures/rename-integration/typescript/polymorphic-family/before/model.ts new file mode 100644 index 000000000..a68121ce4 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/polymorphic-family/before/model.ts @@ -0,0 +1,20 @@ +interface Renderer { + render(): string; +} +class Base implements Renderer { + render(): string { return "render"; } +} +class Child extends Base { + override render(): string { return super.render(); } +} +class Other { + render(): string { return "other"; } +} +function invoke() { + const value: Renderer = new Child(); + const base = new Base(); + const child = new Child(); + const other = new Other(); + return value.render() + base.render() + child.render() + other.render(); +} +// render is an API name. diff --git a/gitnexus/test/fixtures/rename-integration/typescript/polymorphic-family/case.json b/gitnexus/test/fixtures/rename-integration/typescript/polymorphic-family/case.json new file mode 100644 index 000000000..eef6f6517 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/polymorphic-family/case.json @@ -0,0 +1,52 @@ +{ + "language": "typescript", + "description": "Rename the interface, base implementation, override and calls through all three receiver types while preserving an unrelated method.", + "categories": [ + "polymorphism", + "call-graph", + "unrelated-owner", + "constructor-proven-receiver" + ], + "target": { + "name": "render", + "kind": "Method", + "file": "model.ts", + "startLine": 1 + }, + "newName": "paint", + "expect": { + "status": "success", + "edits": 7 + }, + "edges": [ + { + "type": "METHOD_IMPLEMENTS", + "from": { + "name": "render", + "kind": "Method", + "file": "model.ts", + "startLine": 4 + }, + "to": { + "name": "render", + "kind": "Method", + "file": "model.ts", + "startLine": 1 + } + }, + { + "type": "METHOD_OVERRIDES", + "from": { + "name": "Child", + "kind": "Class", + "file": "model.ts" + }, + "to": { + "name": "render", + "kind": "Method", + "file": "model.ts", + "startLine": 4 + } + } + ] +} diff --git a/gitnexus/test/fixtures/rename-integration/typescript/public-field-chain/after/caller.ts b/gitnexus/test/fixtures/rename-integration/typescript/public-field-chain/after/caller.ts new file mode 100644 index 000000000..6d11b1fd8 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/public-field-chain/after/caller.ts @@ -0,0 +1,9 @@ +import { Root, Other } from "./model"; +export function update() { + const root = new Root(); + const other = new Other(); + root.branch.leaf.points = root.branch.leaf.points + 1; + return root.branch.leaf.points + other.score; +} +// score stays in documentation. +const label = "score"; diff --git a/gitnexus/test/fixtures/rename-integration/typescript/public-field-chain/after/model.ts b/gitnexus/test/fixtures/rename-integration/typescript/public-field-chain/after/model.ts new file mode 100644 index 000000000..a313e1e05 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/public-field-chain/after/model.ts @@ -0,0 +1,12 @@ +export class Leaf { + public points: number = 0; +} +export class Branch { + public leaf: Leaf = new Leaf(); +} +export class Root { + public branch: Branch = new Branch(); +} +export class Other { + public score: number = 9; +} diff --git a/gitnexus/test/fixtures/rename-integration/typescript/public-field-chain/before/caller.ts b/gitnexus/test/fixtures/rename-integration/typescript/public-field-chain/before/caller.ts new file mode 100644 index 000000000..29c07f8bc --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/public-field-chain/before/caller.ts @@ -0,0 +1,9 @@ +import { Root, Other } from "./model"; +export function update() { + const root = new Root(); + const other = new Other(); + root.branch.leaf.score = root.branch.leaf.score + 1; + return root.branch.leaf.score + other.score; +} +// score stays in documentation. +const label = "score"; diff --git a/gitnexus/test/fixtures/rename-integration/typescript/public-field-chain/before/model.ts b/gitnexus/test/fixtures/rename-integration/typescript/public-field-chain/before/model.ts new file mode 100644 index 000000000..983bb5b14 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/public-field-chain/before/model.ts @@ -0,0 +1,12 @@ +export class Leaf { + public score: number = 0; +} +export class Branch { + public leaf: Leaf = new Leaf(); +} +export class Root { + public branch: Branch = new Branch(); +} +export class Other { + public score: number = 9; +} diff --git a/gitnexus/test/fixtures/rename-integration/typescript/public-field-chain/case.json b/gitnexus/test/fixtures/rename-integration/typescript/public-field-chain/case.json new file mode 100644 index 000000000..fe5dbf0fa --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/public-field-chain/case.json @@ -0,0 +1,38 @@ +{ + "language": "typescript", + "description": "Rename a public field through typed three-hop receivers, reads and writes, without editing a different owner.", + "categories": [ + "public-field", + "deep-chain", + "read-write", + "cross-file", + "constructor-proven-receiver" + ], + "target": { + "name": "score", + "kind": "Property", + "file": "model.ts", + "startLine": 1 + }, + "newName": "points", + "expect": { + "status": "success", + "edits": 4 + }, + "edges": [ + { + "type": "ACCESSES", + "from": { + "name": "update", + "kind": "Function", + "file": "caller.ts" + }, + "to": { + "name": "score", + "kind": "Property", + "file": "model.ts", + "startLine": 1 + } + } + ] +} diff --git a/gitnexus/test/fixtures/rename-integration/typescript/structural-constructor-mismatch/after/model.ts b/gitnexus/test/fixtures/rename-integration/typescript/structural-constructor-mismatch/after/model.ts new file mode 100644 index 000000000..6fc3d6259 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/structural-constructor-mismatch/after/model.ts @@ -0,0 +1,16 @@ +export interface Renderer { + render(): string; +} +export class Base implements Renderer { + render(): string { return "render"; } +} +export class Child extends Base { + override render(): string { return super.render(); } +} +export class Other { + render(): string { return "other"; } +} +const value: Renderer = new Other(); +export function invoke() { + return value.render(); +} diff --git a/gitnexus/test/fixtures/rename-integration/typescript/structural-constructor-mismatch/before/model.ts b/gitnexus/test/fixtures/rename-integration/typescript/structural-constructor-mismatch/before/model.ts new file mode 100644 index 000000000..6fc3d6259 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/structural-constructor-mismatch/before/model.ts @@ -0,0 +1,16 @@ +export interface Renderer { + render(): string; +} +export class Base implements Renderer { + render(): string { return "render"; } +} +export class Child extends Base { + override render(): string { return super.render(); } +} +export class Other { + render(): string { return "other"; } +} +const value: Renderer = new Other(); +export function invoke() { + return value.render(); +} diff --git a/gitnexus/test/fixtures/rename-integration/typescript/structural-constructor-mismatch/case.json b/gitnexus/test/fixtures/rename-integration/typescript/structural-constructor-mismatch/case.json new file mode 100644 index 000000000..994745d69 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/structural-constructor-mismatch/case.json @@ -0,0 +1,23 @@ +{ + "language": "typescript", + "description": "Refuse an unrelated constructor assigned to a structural interface even when explicit implementors exist.", + "categories": [ + "polymorphism", + "structural-typing", + "unrelated-owner", + "negative", + "unchanged-on-error" + ], + "target": { + "name": "render", + "kind": "Method", + "file": "model.ts", + "startLine": 1 + }, + "newName": "paint", + "expect": { + "status": "error", + "code": "ambiguous_reference", + "planningStatus": "unsupported" + } +} diff --git a/gitnexus/test/fixtures/rename-integration/typescript/structurally-open-field-parameter/after/caller.ts b/gitnexus/test/fixtures/rename-integration/typescript/structurally-open-field-parameter/after/caller.ts new file mode 100644 index 000000000..87f1bfeed --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/structurally-open-field-parameter/after/caller.ts @@ -0,0 +1,7 @@ +import { Root, Other } from "./model"; +export function update(root: Root, other: Other) { + root.branch.leaf.score = root.branch.leaf.score + 1; + return root.branch.leaf.score + other.score; +} +// score stays in documentation. +const label = "score"; diff --git a/gitnexus/test/fixtures/rename-integration/typescript/structurally-open-field-parameter/after/model.ts b/gitnexus/test/fixtures/rename-integration/typescript/structurally-open-field-parameter/after/model.ts new file mode 100644 index 000000000..983bb5b14 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/structurally-open-field-parameter/after/model.ts @@ -0,0 +1,12 @@ +export class Leaf { + public score: number = 0; +} +export class Branch { + public leaf: Leaf = new Leaf(); +} +export class Root { + public branch: Branch = new Branch(); +} +export class Other { + public score: number = 9; +} diff --git a/gitnexus/test/fixtures/rename-integration/typescript/structurally-open-field-parameter/before/caller.ts b/gitnexus/test/fixtures/rename-integration/typescript/structurally-open-field-parameter/before/caller.ts new file mode 100644 index 000000000..87f1bfeed --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/structurally-open-field-parameter/before/caller.ts @@ -0,0 +1,7 @@ +import { Root, Other } from "./model"; +export function update(root: Root, other: Other) { + root.branch.leaf.score = root.branch.leaf.score + 1; + return root.branch.leaf.score + other.score; +} +// score stays in documentation. +const label = "score"; diff --git a/gitnexus/test/fixtures/rename-integration/typescript/structurally-open-field-parameter/before/model.ts b/gitnexus/test/fixtures/rename-integration/typescript/structurally-open-field-parameter/before/model.ts new file mode 100644 index 000000000..983bb5b14 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/structurally-open-field-parameter/before/model.ts @@ -0,0 +1,12 @@ +export class Leaf { + public score: number = 0; +} +export class Branch { + public leaf: Leaf = new Leaf(); +} +export class Root { + public branch: Branch = new Branch(); +} +export class Other { + public score: number = 9; +} diff --git a/gitnexus/test/fixtures/rename-integration/typescript/structurally-open-field-parameter/case.json b/gitnexus/test/fixtures/rename-integration/typescript/structurally-open-field-parameter/case.json new file mode 100644 index 000000000..b3ddc903e --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/structurally-open-field-parameter/case.json @@ -0,0 +1,41 @@ +{ + "language": "typescript", + "description": "Refuse an open structural parameter whose runtime owner cannot be proven, preserving every file.", + "categories": [ + "public-field", + "deep-chain", + "read-write", + "cross-file", + "structural-typing", + "negative", + "unchanged-on-error" + ], + "target": { + "name": "score", + "kind": "Property", + "file": "model.ts", + "startLine": 1 + }, + "newName": "points", + "expect": { + "status": "error", + "code": "ambiguous_reference", + "planningStatus": "unsupported" + }, + "edges": [ + { + "type": "ACCESSES", + "from": { + "name": "update", + "kind": "Function", + "file": "caller.ts" + }, + "to": { + "name": "score", + "kind": "Property", + "file": "model.ts", + "startLine": 1 + } + } + ] +} diff --git a/gitnexus/test/fixtures/rename-integration/typescript/structurally-open-method-parameter/after/model.ts b/gitnexus/test/fixtures/rename-integration/typescript/structurally-open-method-parameter/after/model.ts new file mode 100644 index 000000000..3e65fe9d7 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/structurally-open-method-parameter/after/model.ts @@ -0,0 +1,16 @@ +interface Renderer { + render(): string; +} +class Base implements Renderer { + render(): string { return "render"; } +} +class Child extends Base { + override render(): string { return super.render(); } +} +class Other { + render(): string { return "other"; } +} +function invoke(value: Renderer, base: Base, child: Child, other: Other) { + return value.render() + base.render() + child.render() + other.render(); +} +// render is an API name. diff --git a/gitnexus/test/fixtures/rename-integration/typescript/structurally-open-method-parameter/before/model.ts b/gitnexus/test/fixtures/rename-integration/typescript/structurally-open-method-parameter/before/model.ts new file mode 100644 index 000000000..3e65fe9d7 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/structurally-open-method-parameter/before/model.ts @@ -0,0 +1,16 @@ +interface Renderer { + render(): string; +} +class Base implements Renderer { + render(): string { return "render"; } +} +class Child extends Base { + override render(): string { return super.render(); } +} +class Other { + render(): string { return "other"; } +} +function invoke(value: Renderer, base: Base, child: Child, other: Other) { + return value.render() + base.render() + child.render() + other.render(); +} +// render is an API name. diff --git a/gitnexus/test/fixtures/rename-integration/typescript/structurally-open-method-parameter/case.json b/gitnexus/test/fixtures/rename-integration/typescript/structurally-open-method-parameter/case.json new file mode 100644 index 000000000..f437835fd --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/structurally-open-method-parameter/case.json @@ -0,0 +1,55 @@ +{ + "language": "typescript", + "description": "Refuse an open structural parameter whose runtime owner cannot be proven, preserving every file.", + "categories": [ + "polymorphism", + "call-graph", + "unrelated-owner", + "structural-typing", + "negative", + "unchanged-on-error" + ], + "target": { + "name": "render", + "kind": "Method", + "file": "model.ts", + "startLine": 1 + }, + "newName": "paint", + "expect": { + "status": "error", + "code": "ambiguous_reference", + "planningStatus": "unsupported" + }, + "edges": [ + { + "type": "METHOD_IMPLEMENTS", + "from": { + "name": "render", + "kind": "Method", + "file": "model.ts", + "startLine": 4 + }, + "to": { + "name": "render", + "kind": "Method", + "file": "model.ts", + "startLine": 1 + } + }, + { + "type": "METHOD_OVERRIDES", + "from": { + "name": "Child", + "kind": "Class", + "file": "model.ts" + }, + "to": { + "name": "render", + "kind": "Method", + "file": "model.ts", + "startLine": 4 + } + } + ] +} diff --git a/gitnexus/test/fixtures/rename-integration/typescript/this-parameter-function-refusal/after/model.ts b/gitnexus/test/fixtures/rename-integration/typescript/this-parameter-function-refusal/after/model.ts new file mode 100644 index 000000000..2ae2dc120 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/this-parameter-function-refusal/after/model.ts @@ -0,0 +1,4 @@ +export class Writer { + closeWriter(): number { return 1; } +} +export function flush(this: Writer): number { return this.closeWriter(); } diff --git a/gitnexus/test/fixtures/rename-integration/typescript/this-parameter-function-refusal/before/model.ts b/gitnexus/test/fixtures/rename-integration/typescript/this-parameter-function-refusal/before/model.ts new file mode 100644 index 000000000..2ae2dc120 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/this-parameter-function-refusal/before/model.ts @@ -0,0 +1,4 @@ +export class Writer { + closeWriter(): number { return 1; } +} +export function flush(this: Writer): number { return this.closeWriter(); } diff --git a/gitnexus/test/fixtures/rename-integration/typescript/this-parameter-function-refusal/case.json b/gitnexus/test/fixtures/rename-integration/typescript/this-parameter-function-refusal/case.json new file mode 100644 index 000000000..529f8db20 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/this-parameter-function-refusal/case.json @@ -0,0 +1,17 @@ +{ + "language": "typescript", + "description": "Refuse a free function that declares a `this` parameter; its call-site-bound receiver is not provably unrelated, so every file must stay unchanged.", + "categories": ["receiver", "this-binding", "structural-type", "negative", "unchanged-on-error"], + "target": { + "name": "closeWriter", + "kind": "Method", + "file": "model.ts", + "startLine": 1 + }, + "newName": "shutdown", + "expect": { + "status": "error", + "code": "ambiguous_reference", + "planningStatus": "blocked" + } +} diff --git a/gitnexus/test/fixtures/rename-integration/typescript/transitive-external-base-family/after/model.ts b/gitnexus/test/fixtures/rename-integration/typescript/transitive-external-base-family/after/model.ts new file mode 100644 index 000000000..717a6aa7e --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/transitive-external-base-family/after/model.ts @@ -0,0 +1,6 @@ +import { External } from "vendor"; + +interface Contract extends External {} +export class Worker implements Contract { + target(): number { return 1; } +} diff --git a/gitnexus/test/fixtures/rename-integration/typescript/transitive-external-base-family/before/model.ts b/gitnexus/test/fixtures/rename-integration/typescript/transitive-external-base-family/before/model.ts new file mode 100644 index 000000000..717a6aa7e --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/transitive-external-base-family/before/model.ts @@ -0,0 +1,6 @@ +import { External } from "vendor"; + +interface Contract extends External {} +export class Worker implements Contract { + target(): number { return 1; } +} diff --git a/gitnexus/test/fixtures/rename-integration/typescript/transitive-external-base-family/case.json b/gitnexus/test/fixtures/rename-integration/typescript/transitive-external-base-family/case.json new file mode 100644 index 000000000..37fcb26eb --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/typescript/transitive-external-base-family/case.json @@ -0,0 +1,38 @@ +{ + "language": "typescript", + "description": "An indexed interface does not prove a complete family when its own base is external.", + "categories": [ + "polymorphism", + "external-base", + "transitive-interface", + "negative", + "unchanged-on-error" + ], + "target": { + "name": "target", + "kind": "Method", + "file": "model.ts", + "startLine": 4 + }, + "newName": "renamed", + "expect": { + "status": "error", + "code": "unsupported_symbol_family", + "planningStatus": "unsupported" + }, + "edges": [ + { + "type": "IMPLEMENTS", + "from": { + "name": "Worker", + "kind": "Class", + "file": "model.ts" + }, + "to": { + "name": "Contract", + "kind": "Interface", + "file": "model.ts" + } + } + ] +} diff --git a/gitnexus/test/fixtures/rename-integration/vue/component-tags-and-alias/after/App.vue b/gitnexus/test/fixtures/rename-integration/vue/component-tags-and-alias/after/App.vue new file mode 100644 index 000000000..00ac42c7a --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/vue/component-tags-and-alias/after/App.vue @@ -0,0 +1,5 @@ + + +Card diff --git a/gitnexus/test/fixtures/rename-integration/vue/component-tags-and-alias/after/Other.vue b/gitnexus/test/fixtures/rename-integration/vue/component-tags-and-alias/after/Other.vue new file mode 100644 index 000000000..211be66cd --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/vue/component-tags-and-alias/after/Other.vue @@ -0,0 +1,4 @@ + + diff --git a/gitnexus/test/fixtures/rename-integration/vue/component-tags-and-alias/after/components.ts b/gitnexus/test/fixtures/rename-integration/vue/component-tags-and-alias/after/components.ts new file mode 100644 index 000000000..6ee8e4bb1 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/vue/component-tags-and-alias/after/components.ts @@ -0,0 +1 @@ +export class Panel {} diff --git a/gitnexus/test/fixtures/rename-integration/vue/component-tags-and-alias/before/App.vue b/gitnexus/test/fixtures/rename-integration/vue/component-tags-and-alias/before/App.vue new file mode 100644 index 000000000..4d69ad20e --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/vue/component-tags-and-alias/before/App.vue @@ -0,0 +1,5 @@ + + +Card diff --git a/gitnexus/test/fixtures/rename-integration/vue/component-tags-and-alias/before/Other.vue b/gitnexus/test/fixtures/rename-integration/vue/component-tags-and-alias/before/Other.vue new file mode 100644 index 000000000..70def7419 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/vue/component-tags-and-alias/before/Other.vue @@ -0,0 +1,4 @@ + + diff --git a/gitnexus/test/fixtures/rename-integration/vue/component-tags-and-alias/before/components.ts b/gitnexus/test/fixtures/rename-integration/vue/component-tags-and-alias/before/components.ts new file mode 100644 index 000000000..4c092d2c8 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/vue/component-tags-and-alias/before/components.ts @@ -0,0 +1 @@ +export class Card {} diff --git a/gitnexus/test/fixtures/rename-integration/vue/component-tags-and-alias/case.json b/gitnexus/test/fixtures/rename-integration/vue/component-tags-and-alias/case.json new file mode 100644 index 000000000..7c7171ffa --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/vue/component-tags-and-alias/case.json @@ -0,0 +1,22 @@ +{ + "language": "vue", + "description": "Rename imported component bindings in opening and closing tags while preserving an explicit alias, attributes, text and custom blocks.", + "categories": [ + "cross-file", + "component", + "alias", + "template", + "opaque-content" + ], + "target": { + "name": "Card", + "kind": "Class", + "file": "components.ts" + }, + "newName": "Panel", + "expect": { + "status": "success", + "edits": 5 + }, + "edges": [] +} diff --git a/gitnexus/test/fixtures/rename-integration/vue/escaped-import-source/after/App.vue b/gitnexus/test/fixtures/rename-integration/vue/escaped-import-source/after/App.vue new file mode 100644 index 000000000..53e6f54b0 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/vue/escaped-import-source/after/App.vue @@ -0,0 +1,5 @@ + + diff --git a/gitnexus/test/fixtures/rename-integration/vue/escaped-import-source/after/source.ts b/gitnexus/test/fixtures/rename-integration/vue/escaped-import-source/after/source.ts new file mode 100644 index 000000000..394b03271 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/vue/escaped-import-source/after/source.ts @@ -0,0 +1 @@ +export function compute() { return 7; } diff --git a/gitnexus/test/fixtures/rename-integration/vue/escaped-import-source/before/App.vue b/gitnexus/test/fixtures/rename-integration/vue/escaped-import-source/before/App.vue new file mode 100644 index 000000000..cd9ac839e --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/vue/escaped-import-source/before/App.vue @@ -0,0 +1,5 @@ + + diff --git a/gitnexus/test/fixtures/rename-integration/vue/escaped-import-source/before/source.ts b/gitnexus/test/fixtures/rename-integration/vue/escaped-import-source/before/source.ts new file mode 100644 index 000000000..42f29babb --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/vue/escaped-import-source/before/source.ts @@ -0,0 +1 @@ +export function calc\u0075late() { return 7; } diff --git a/gitnexus/test/fixtures/rename-integration/vue/escaped-import-source/case.json b/gitnexus/test/fixtures/rename-integration/vue/escaped-import-source/case.json new file mode 100644 index 000000000..4363f4704 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/vue/escaped-import-source/case.json @@ -0,0 +1,35 @@ +{ + "language": "vue", + "description": "Resolve an escaped source export through a differently escaped Vue import while preserving the local script alias and template.", + "categories": [ + "cross-file", + "escaped-identifier", + "alias", + "call-graph" + ], + "target": { + "name": "calc\\u0075late", + "kind": "Function", + "file": "source.ts" + }, + "newName": "compute", + "expect": { + "status": "success", + "edits": 2 + }, + "edges": [ + { + "type": "CALLS", + "from": { + "name": "run", + "kind": "Function", + "file": "App.vue" + }, + "to": { + "name": "calc\\u0075late", + "kind": "Function", + "file": "source.ts" + } + } + ] +} diff --git a/gitnexus/test/fixtures/rename-integration/vue/external-base-family/after/App.vue b/gitnexus/test/fixtures/rename-integration/vue/external-base-family/after/App.vue new file mode 100644 index 000000000..bcc0c79a8 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/vue/external-base-family/after/App.vue @@ -0,0 +1,7 @@ + + diff --git a/gitnexus/test/fixtures/rename-integration/vue/external-base-family/after/notes.txt b/gitnexus/test/fixtures/rename-integration/vue/external-base-family/after/notes.txt new file mode 100644 index 000000000..81db7fc3a --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/vue/external-base-family/after/notes.txt @@ -0,0 +1 @@ +External contract and target spelling remain unchanged. diff --git a/gitnexus/test/fixtures/rename-integration/vue/external-base-family/before/App.vue b/gitnexus/test/fixtures/rename-integration/vue/external-base-family/before/App.vue new file mode 100644 index 000000000..bcc0c79a8 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/vue/external-base-family/before/App.vue @@ -0,0 +1,7 @@ + + diff --git a/gitnexus/test/fixtures/rename-integration/vue/external-base-family/before/notes.txt b/gitnexus/test/fixtures/rename-integration/vue/external-base-family/before/notes.txt new file mode 100644 index 000000000..81db7fc3a --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/vue/external-base-family/before/notes.txt @@ -0,0 +1 @@ +External contract and target spelling remain unchanged. diff --git a/gitnexus/test/fixtures/rename-integration/vue/external-base-family/case.json b/gitnexus/test/fixtures/rename-integration/vue/external-base-family/case.json new file mode 100644 index 000000000..bdbbeea9c --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/vue/external-base-family/case.json @@ -0,0 +1,22 @@ +{ + "language": "vue", + "description": "Refuse a potentially polymorphic member whose external base is not indexed.", + "categories": [ + "polymorphism", + "external-base", + "negative", + "unchanged-on-error" + ], + "target": { + "name": "target", + "kind": "Method", + "file": "App.vue", + "startLine": 3 + }, + "newName": "renamed", + "expect": { + "status": "error", + "code": "unsupported_symbol_family", + "planningStatus": "unsupported" + } +} diff --git a/gitnexus/test/fixtures/rename-integration/vue/import-alias-call-chain/after/App.vue b/gitnexus/test/fixtures/rename-integration/vue/import-alias-call-chain/after/App.vue new file mode 100644 index 000000000..8dab5d257 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/vue/import-alias-call-chain/after/App.vue @@ -0,0 +1,7 @@ + + + diff --git a/gitnexus/test/fixtures/rename-integration/vue/import-alias-call-chain/after/api.ts b/gitnexus/test/fixtures/rename-integration/vue/import-alias-call-chain/after/api.ts new file mode 100644 index 000000000..8cb7cbb74 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/vue/import-alias-call-chain/after/api.ts @@ -0,0 +1 @@ +export function shade() { return "color"; } diff --git a/gitnexus/test/fixtures/rename-integration/vue/import-alias-call-chain/after/middle.ts b/gitnexus/test/fixtures/rename-integration/vue/import-alias-call-chain/after/middle.ts new file mode 100644 index 000000000..63bfdf35b --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/vue/import-alias-call-chain/after/middle.ts @@ -0,0 +1,2 @@ +import { shade } from "./api"; +export function middle() { return shade(); } diff --git a/gitnexus/test/fixtures/rename-integration/vue/import-alias-call-chain/before/App.vue b/gitnexus/test/fixtures/rename-integration/vue/import-alias-call-chain/before/App.vue new file mode 100644 index 000000000..c5e2d144c --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/vue/import-alias-call-chain/before/App.vue @@ -0,0 +1,7 @@ + + + diff --git a/gitnexus/test/fixtures/rename-integration/vue/import-alias-call-chain/before/api.ts b/gitnexus/test/fixtures/rename-integration/vue/import-alias-call-chain/before/api.ts new file mode 100644 index 000000000..491868dce --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/vue/import-alias-call-chain/before/api.ts @@ -0,0 +1 @@ +export function color() { return "color"; } diff --git a/gitnexus/test/fixtures/rename-integration/vue/import-alias-call-chain/before/middle.ts b/gitnexus/test/fixtures/rename-integration/vue/import-alias-call-chain/before/middle.ts new file mode 100644 index 000000000..9928ad5e4 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/vue/import-alias-call-chain/before/middle.ts @@ -0,0 +1,2 @@ +import { color } from "./api"; +export function middle() { return color(); } diff --git a/gitnexus/test/fixtures/rename-integration/vue/import-alias-call-chain/case.json b/gitnexus/test/fixtures/rename-integration/vue/import-alias-call-chain/case.json new file mode 100644 index 000000000..f7c643ebf --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/vue/import-alias-call-chain/case.json @@ -0,0 +1,49 @@ +{ + "language": "vue", + "description": "Rename a shared function used through stable aliases in Vue script, template and CSS, and directly through a TypeScript call graph.", + "categories": [ + "cross-file", + "alias", + "call-graph", + "template", + "css-binding" + ], + "target": { + "name": "color", + "kind": "Function", + "file": "api.ts" + }, + "newName": "shade", + "expect": { + "status": "success", + "edits": 4 + }, + "edges": [ + { + "type": "CALLS", + "from": { + "name": "middle", + "kind": "Function", + "file": "middle.ts" + }, + "to": { + "name": "color", + "kind": "Function", + "file": "api.ts" + } + }, + { + "type": "CALLS", + "from": { + "name": "run", + "kind": "Function", + "file": "App.vue" + }, + "to": { + "name": "middle", + "kind": "Function", + "file": "middle.ts" + } + } + ] +} diff --git a/gitnexus/test/fixtures/rename-integration/vue/member-destructure-refusal/after/App.vue b/gitnexus/test/fixtures/rename-integration/vue/member-destructure-refusal/after/App.vue new file mode 100644 index 000000000..102bf1c88 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/vue/member-destructure-refusal/after/App.vue @@ -0,0 +1,9 @@ + + diff --git a/gitnexus/test/fixtures/rename-integration/vue/member-destructure-refusal/after/model.ts b/gitnexus/test/fixtures/rename-integration/vue/member-destructure-refusal/after/model.ts new file mode 100644 index 000000000..6e2b35106 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/vue/member-destructure-refusal/after/model.ts @@ -0,0 +1,3 @@ +export class Writer { + close() { return 1; } +} diff --git a/gitnexus/test/fixtures/rename-integration/vue/member-destructure-refusal/before/App.vue b/gitnexus/test/fixtures/rename-integration/vue/member-destructure-refusal/before/App.vue new file mode 100644 index 000000000..102bf1c88 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/vue/member-destructure-refusal/before/App.vue @@ -0,0 +1,9 @@ + + diff --git a/gitnexus/test/fixtures/rename-integration/vue/member-destructure-refusal/before/model.ts b/gitnexus/test/fixtures/rename-integration/vue/member-destructure-refusal/before/model.ts new file mode 100644 index 000000000..6e2b35106 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/vue/member-destructure-refusal/before/model.ts @@ -0,0 +1,3 @@ +export class Writer { + close() { return 1; } +} diff --git a/gitnexus/test/fixtures/rename-integration/vue/member-destructure-refusal/case.json b/gitnexus/test/fixtures/rename-integration/vue/member-destructure-refusal/case.json new file mode 100644 index 000000000..2c68cc0a9 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/vue/member-destructure-refusal/case.json @@ -0,0 +1,38 @@ +{ + "language": "vue", + "description": "Refuse an unproven object or destructuring member key, preserving all source bytes.", + "categories": [ + "member-keys", + "call-graph", + "negative", + "unchanged-on-error" + ], + "target": { + "name": "close", + "kind": "Method", + "file": "model.ts", + "startLine": 1 + }, + "newName": "finish", + "expect": { + "status": "error", + "code": "ambiguous_reference", + "planningStatus": "unsupported" + }, + "edges": [ + { + "type": "CALLS", + "from": { + "name": "run", + "kind": "Function", + "file": "App.vue" + }, + "to": { + "name": "close", + "kind": "Method", + "file": "model.ts", + "startLine": 1 + } + } + ] +} diff --git a/gitnexus/test/fixtures/rename-integration/vue/member-object-key-refusal/after/App.vue b/gitnexus/test/fixtures/rename-integration/vue/member-object-key-refusal/after/App.vue new file mode 100644 index 000000000..c54b84aee --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/vue/member-object-key-refusal/after/App.vue @@ -0,0 +1,9 @@ + + diff --git a/gitnexus/test/fixtures/rename-integration/vue/member-object-key-refusal/after/model.ts b/gitnexus/test/fixtures/rename-integration/vue/member-object-key-refusal/after/model.ts new file mode 100644 index 000000000..6e2b35106 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/vue/member-object-key-refusal/after/model.ts @@ -0,0 +1,3 @@ +export class Writer { + close() { return 1; } +} diff --git a/gitnexus/test/fixtures/rename-integration/vue/member-object-key-refusal/before/App.vue b/gitnexus/test/fixtures/rename-integration/vue/member-object-key-refusal/before/App.vue new file mode 100644 index 000000000..c54b84aee --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/vue/member-object-key-refusal/before/App.vue @@ -0,0 +1,9 @@ + + diff --git a/gitnexus/test/fixtures/rename-integration/vue/member-object-key-refusal/before/model.ts b/gitnexus/test/fixtures/rename-integration/vue/member-object-key-refusal/before/model.ts new file mode 100644 index 000000000..6e2b35106 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/vue/member-object-key-refusal/before/model.ts @@ -0,0 +1,3 @@ +export class Writer { + close() { return 1; } +} diff --git a/gitnexus/test/fixtures/rename-integration/vue/member-object-key-refusal/case.json b/gitnexus/test/fixtures/rename-integration/vue/member-object-key-refusal/case.json new file mode 100644 index 000000000..2c68cc0a9 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/vue/member-object-key-refusal/case.json @@ -0,0 +1,38 @@ +{ + "language": "vue", + "description": "Refuse an unproven object or destructuring member key, preserving all source bytes.", + "categories": [ + "member-keys", + "call-graph", + "negative", + "unchanged-on-error" + ], + "target": { + "name": "close", + "kind": "Method", + "file": "model.ts", + "startLine": 1 + }, + "newName": "finish", + "expect": { + "status": "error", + "code": "ambiguous_reference", + "planningStatus": "unsupported" + }, + "edges": [ + { + "type": "CALLS", + "from": { + "name": "run", + "kind": "Function", + "file": "App.vue" + }, + "to": { + "name": "close", + "kind": "Method", + "file": "model.ts", + "startLine": 1 + } + } + ] +} diff --git a/gitnexus/test/fixtures/rename-integration/vue/raw-block-close-boundaries/after/App.vue b/gitnexus/test/fixtures/rename-integration/vue/raw-block-close-boundaries/after/App.vue new file mode 100644 index 000000000..5b4f5fabc --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/vue/raw-block-close-boundaries/after/App.vue @@ -0,0 +1,8 @@ + + + +Literal text. + + diff --git a/gitnexus/test/fixtures/rename-integration/vue/raw-block-close-boundaries/before/App.vue b/gitnexus/test/fixtures/rename-integration/vue/raw-block-close-boundaries/before/App.vue new file mode 100644 index 000000000..00eccdf83 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/vue/raw-block-close-boundaries/before/App.vue @@ -0,0 +1,8 @@ + + + +Literal text. + + diff --git a/gitnexus/test/fixtures/rename-integration/vue/raw-block-close-boundaries/case.json b/gitnexus/test/fixtures/rename-integration/vue/raw-block-close-boundaries/case.json new file mode 100644 index 000000000..de7568898 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/vue/raw-block-close-boundaries/case.json @@ -0,0 +1,20 @@ +{ + "language": "vue", + "description": "Keep raw custom blocks opaque when their content contains a longer closing tag with the same prefix.", + "categories": [ + "raw-block", + "unrelated-name", + "template-binding" + ], + "target": { + "name": "target", + "kind": "Function", + "file": "App.vue", + "startLine": 1 + }, + "newName": "renamed", + "expect": { + "status": "success", + "edits": 2 + } +} diff --git a/gitnexus/test/fixtures/rename-integration/vue/setup-template-css-shadows/after/App.vue b/gitnexus/test/fixtures/rename-integration/vue/setup-template-css-shadows/after/App.vue new file mode 100644 index 000000000..a742c2953 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/vue/setup-template-css-shadows/after/App.vue @@ -0,0 +1,14 @@ + + + diff --git a/gitnexus/test/fixtures/rename-integration/vue/setup-template-css-shadows/before/App.vue b/gitnexus/test/fixtures/rename-integration/vue/setup-template-css-shadows/before/App.vue new file mode 100644 index 000000000..8320bb814 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/vue/setup-template-css-shadows/before/App.vue @@ -0,0 +1,14 @@ + + + diff --git a/gitnexus/test/fixtures/rename-integration/vue/setup-template-css-shadows/case.json b/gitnexus/test/fixtures/rename-integration/vue/setup-template-css-shadows/case.json new file mode 100644 index 000000000..cccba932b --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/vue/setup-template-css-shadows/case.json @@ -0,0 +1,23 @@ +{ + "language": "vue", + "description": "Rename one setup binding across event handlers, interpolation, script reads and CSS expressions while preserving template shadows, labels and CSS text.", + "categories": [ + "template", + "css-binding", + "shadowing", + "read", + "call-graph" + ], + "target": { + "name": "color", + "kind": "Function", + "file": "App.vue", + "startLine": 6 + }, + "newName": "shade", + "expect": { + "status": "success", + "edits": 6 + }, + "edges": [] +} diff --git a/gitnexus/test/fixtures/rename-integration/zig/c-consumer/after/caller.c b/gitnexus/test/fixtures/rename-integration/zig/c-consumer/after/caller.c new file mode 100644 index 000000000..cd39a79eb --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/zig/c-consumer/after/caller.c @@ -0,0 +1,3 @@ +int compute(int value); + +int use(void) { return compute(1); } diff --git a/gitnexus/test/fixtures/rename-integration/zig/c-consumer/after/lib.zig b/gitnexus/test/fixtures/rename-integration/zig/c-consumer/after/lib.zig new file mode 100644 index 000000000..36b6823a4 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/zig/c-consumer/after/lib.zig @@ -0,0 +1,3 @@ +export fn compute(value: c_int) c_int { + return value; +} diff --git a/gitnexus/test/fixtures/rename-integration/zig/c-consumer/before/caller.c b/gitnexus/test/fixtures/rename-integration/zig/c-consumer/before/caller.c new file mode 100644 index 000000000..cd39a79eb --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/zig/c-consumer/before/caller.c @@ -0,0 +1,3 @@ +int compute(int value); + +int use(void) { return compute(1); } diff --git a/gitnexus/test/fixtures/rename-integration/zig/c-consumer/before/lib.zig b/gitnexus/test/fixtures/rename-integration/zig/c-consumer/before/lib.zig new file mode 100644 index 000000000..36b6823a4 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/zig/c-consumer/before/lib.zig @@ -0,0 +1,3 @@ +export fn compute(value: c_int) c_int { + return value; +} diff --git a/gitnexus/test/fixtures/rename-integration/zig/c-consumer/case.json b/gitnexus/test/fixtures/rename-integration/zig/c-consumer/case.json new file mode 100644 index 000000000..ec12a9f82 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/zig/c-consumer/case.json @@ -0,0 +1,21 @@ +{ + "language": "zig", + "description": "Refuse a Zig function exported with the C ABI that an indexed C caller names, until the C binding is proven.", + "categories": [ + "cross-language", + "interop", + "negative", + "unchanged-on-error" + ], + "target": { + "name": "compute", + "kind": "Function", + "file": "lib.zig" + }, + "newName": "calculate", + "expect": { + "status": "error", + "code": "unsupported_boundary", + "planningStatus": "unsupported" + } +} diff --git a/gitnexus/test/fixtures/rename-integration/zig/import-alias-call-chain/after/main.zig b/gitnexus/test/fixtures/rename-integration/zig/import-alias-call-chain/after/main.zig new file mode 100644 index 000000000..0a860b21d --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/zig/import-alias-call-chain/after/main.zig @@ -0,0 +1,3 @@ +const invoke = @import("service.zig").calculate; +const service = @import("service.zig"); +pub fn main() void { _ = invoke(1); _ = service.entry(2); } diff --git a/gitnexus/test/fixtures/rename-integration/zig/import-alias-call-chain/after/service.zig b/gitnexus/test/fixtures/rename-integration/zig/import-alias-call-chain/after/service.zig new file mode 100644 index 000000000..7ac01450d --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/zig/import-alias-call-chain/after/service.zig @@ -0,0 +1,4 @@ +pub fn calculate(value: u8) u8 { return value; } +pub fn middle(value: u8) u8 { return calculate(value); } +pub fn entry(value: u8) u8 { return middle(value); } +// process is a public API; this comment is not a reference. diff --git a/gitnexus/test/fixtures/rename-integration/zig/import-alias-call-chain/before/main.zig b/gitnexus/test/fixtures/rename-integration/zig/import-alias-call-chain/before/main.zig new file mode 100644 index 000000000..0bb769144 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/zig/import-alias-call-chain/before/main.zig @@ -0,0 +1,3 @@ +const invoke = @import("service.zig").process; +const service = @import("service.zig"); +pub fn main() void { _ = invoke(1); _ = service.entry(2); } diff --git a/gitnexus/test/fixtures/rename-integration/zig/import-alias-call-chain/before/service.zig b/gitnexus/test/fixtures/rename-integration/zig/import-alias-call-chain/before/service.zig new file mode 100644 index 000000000..7e2940710 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/zig/import-alias-call-chain/before/service.zig @@ -0,0 +1,4 @@ +pub fn process(value: u8) u8 { return value; } +pub fn middle(value: u8) u8 { return process(value); } +pub fn entry(value: u8) u8 { return middle(value); } +// process is a public API; this comment is not a reference. diff --git a/gitnexus/test/fixtures/rename-integration/zig/import-alias-call-chain/case.json b/gitnexus/test/fixtures/rename-integration/zig/import-alias-call-chain/case.json new file mode 100644 index 000000000..8bc26a0e8 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/zig/import-alias-call-chain/case.json @@ -0,0 +1,77 @@ +{ + "language": "zig", + "description": "Rename a public function through a module alias while preserving the imported local name and three levels of callers. Zig has no nominal override family.", + "categories": ["call-graph", "import-alias", "call-chain"], + "target": { + "name": "process", + "kind": "Function", + "file": "service.zig" + }, + "newName": "calculate", + "expect": { + "status": "success", + "edits": 3 + }, + "edges": [ + { + "type": "CALLS", + "from": { + "name": "main", + "kind": "Function", + "file": "main.zig", + "startLine": 2 + }, + "to": { + "name": "entry", + "kind": "Function", + "file": "service.zig", + "startLine": 2 + } + }, + { + "type": "CALLS", + "from": { + "name": "main", + "kind": "Function", + "file": "main.zig", + "startLine": 2 + }, + "to": { + "name": "process", + "kind": "Function", + "file": "service.zig", + "startLine": 0 + } + }, + { + "type": "CALLS", + "from": { + "name": "middle", + "kind": "Function", + "file": "service.zig", + "startLine": 1 + }, + "to": { + "name": "process", + "kind": "Function", + "file": "service.zig", + "startLine": 0 + } + }, + { + "type": "CALLS", + "from": { + "name": "entry", + "kind": "Function", + "file": "service.zig", + "startLine": 2 + }, + "to": { + "name": "middle", + "kind": "Function", + "file": "service.zig", + "startLine": 1 + } + } + ] +} diff --git a/gitnexus/test/fixtures/rename-integration/zig/reflected-hasdecl-new-name/after/model.zig b/gitnexus/test/fixtures/rename-integration/zig/reflected-hasdecl-new-name/after/model.zig new file mode 100644 index 000000000..c27b1ba63 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/zig/reflected-hasdecl-new-name/after/model.zig @@ -0,0 +1,7 @@ +const Worker = struct { + pub fn target(self: Worker) void { _ = self; } +}; +fn caller(worker: Worker) void { worker.target(); } +comptime { + if (@hasDecl(Worker, "renamed")) @compileError("Worker.renamed must stay undeclared"); +} diff --git a/gitnexus/test/fixtures/rename-integration/zig/reflected-hasdecl-new-name/before/model.zig b/gitnexus/test/fixtures/rename-integration/zig/reflected-hasdecl-new-name/before/model.zig new file mode 100644 index 000000000..c27b1ba63 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/zig/reflected-hasdecl-new-name/before/model.zig @@ -0,0 +1,7 @@ +const Worker = struct { + pub fn target(self: Worker) void { _ = self; } +}; +fn caller(worker: Worker) void { worker.target(); } +comptime { + if (@hasDecl(Worker, "renamed")) @compileError("Worker.renamed must stay undeclared"); +} diff --git a/gitnexus/test/fixtures/rename-integration/zig/reflected-hasdecl-new-name/case.json b/gitnexus/test/fixtures/rename-integration/zig/reflected-hasdecl-new-name/case.json new file mode 100644 index 000000000..38d627774 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/zig/reflected-hasdecl-new-name/case.json @@ -0,0 +1,40 @@ +{ + "language": "zig", + "description": "Refuse a literal @hasDecl check of the destination name on the owner, which would flip from false to true.", + "categories": [ + "reflection", + "has-decl", + "destination-collision", + "negative", + "unchanged-on-error" + ], + "target": { + "name": "target", + "kind": "Method", + "file": "model.zig", + "startLine": 1 + }, + "newName": "renamed", + "expect": { + "status": "error", + "code": "unsupported_occurrence", + "planningStatus": "unsupported" + }, + "edges": [ + { + "type": "CALLS", + "from": { + "name": "caller", + "kind": "Function", + "file": "model.zig", + "startLine": 3 + }, + "to": { + "name": "target", + "kind": "Method", + "file": "model.zig", + "startLine": 1 + } + } + ] +} diff --git a/gitnexus/test/fixtures/rename-integration/zig/reflected-hasdecl-old-name/after/model.zig b/gitnexus/test/fixtures/rename-integration/zig/reflected-hasdecl-old-name/after/model.zig new file mode 100644 index 000000000..6f3e4a890 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/zig/reflected-hasdecl-old-name/after/model.zig @@ -0,0 +1,7 @@ +const Worker = struct { + pub fn target(self: Worker) void { _ = self; } +}; +fn caller(worker: Worker) void { worker.target(); } +comptime { + if (!@hasDecl(Worker, "target")) @compileError("Worker.target must stay declared"); +} diff --git a/gitnexus/test/fixtures/rename-integration/zig/reflected-hasdecl-old-name/before/model.zig b/gitnexus/test/fixtures/rename-integration/zig/reflected-hasdecl-old-name/before/model.zig new file mode 100644 index 000000000..6f3e4a890 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/zig/reflected-hasdecl-old-name/before/model.zig @@ -0,0 +1,7 @@ +const Worker = struct { + pub fn target(self: Worker) void { _ = self; } +}; +fn caller(worker: Worker) void { worker.target(); } +comptime { + if (!@hasDecl(Worker, "target")) @compileError("Worker.target must stay declared"); +} diff --git a/gitnexus/test/fixtures/rename-integration/zig/reflected-hasdecl-old-name/case.json b/gitnexus/test/fixtures/rename-integration/zig/reflected-hasdecl-old-name/case.json new file mode 100644 index 000000000..b0a1fa227 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/zig/reflected-hasdecl-old-name/case.json @@ -0,0 +1,34 @@ +{ + "language": "zig", + "description": "Refuse a literal @hasDecl check of the selected declaration on its owner, which would flip from true to false.", + "categories": ["reflection", "has-decl", "negative", "unchanged-on-error"], + "target": { + "name": "target", + "kind": "Method", + "file": "model.zig", + "startLine": 1 + }, + "newName": "renamed", + "expect": { + "status": "error", + "code": "unsupported_occurrence", + "planningStatus": "unsupported" + }, + "edges": [ + { + "type": "CALLS", + "from": { + "name": "caller", + "kind": "Function", + "file": "model.zig", + "startLine": 3 + }, + "to": { + "name": "target", + "kind": "Method", + "file": "model.zig", + "startLine": 1 + } + } + ] +} diff --git a/gitnexus/test/fixtures/rename-integration/zig/reflected-hasdecl-unrelated-owner/after/model.zig b/gitnexus/test/fixtures/rename-integration/zig/reflected-hasdecl-unrelated-owner/after/model.zig new file mode 100644 index 000000000..7358d9e8b --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/zig/reflected-hasdecl-unrelated-owner/after/model.zig @@ -0,0 +1,11 @@ +const Worker = struct { + pub fn renamed(self: Worker) void { _ = self; } +}; +const Other = struct { + pub fn target(self: Other) void { _ = self; } +}; +fn caller(worker: Worker, other: Other) void { worker.renamed(); other.target(); } +comptime { + if (!@hasDecl(Other, "target")) @compileError("Other.target must stay declared"); + if (@hasDecl(Other, "renamed")) @compileError("Other.renamed must stay undeclared"); +} diff --git a/gitnexus/test/fixtures/rename-integration/zig/reflected-hasdecl-unrelated-owner/before/model.zig b/gitnexus/test/fixtures/rename-integration/zig/reflected-hasdecl-unrelated-owner/before/model.zig new file mode 100644 index 000000000..698c5425b --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/zig/reflected-hasdecl-unrelated-owner/before/model.zig @@ -0,0 +1,11 @@ +const Worker = struct { + pub fn target(self: Worker) void { _ = self; } +}; +const Other = struct { + pub fn target(self: Other) void { _ = self; } +}; +fn caller(worker: Worker, other: Other) void { worker.target(); other.target(); } +comptime { + if (!@hasDecl(Other, "target")) @compileError("Other.target must stay declared"); + if (@hasDecl(Other, "renamed")) @compileError("Other.renamed must stay undeclared"); +} diff --git a/gitnexus/test/fixtures/rename-integration/zig/reflected-hasdecl-unrelated-owner/case.json b/gitnexus/test/fixtures/rename-integration/zig/reflected-hasdecl-unrelated-owner/case.json new file mode 100644 index 000000000..64cbb2e0d --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/zig/reflected-hasdecl-unrelated-owner/case.json @@ -0,0 +1,33 @@ +{ + "language": "zig", + "description": "Rename a method while preserving @hasDecl checks of the old and destination spellings on a proven different container.", + "categories": ["reflection", "has-decl", "unrelated-owner", "receiver-isolation"], + "target": { + "name": "target", + "kind": "Method", + "file": "model.zig", + "startLine": 1 + }, + "newName": "renamed", + "expect": { + "status": "success", + "edits": 2 + }, + "edges": [ + { + "type": "CALLS", + "from": { + "name": "caller", + "kind": "Function", + "file": "model.zig", + "startLine": 6 + }, + "to": { + "name": "target", + "kind": "Method", + "file": "model.zig", + "startLine": 1 + } + } + ] +} diff --git a/gitnexus/test/fixtures/rename-integration/zig/reflected-hasfield-new-name/after/model.zig b/gitnexus/test/fixtures/rename-integration/zig/reflected-hasfield-new-name/after/model.zig new file mode 100644 index 000000000..3b9f019e4 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/zig/reflected-hasfield-new-name/after/model.zig @@ -0,0 +1,5 @@ +const Worker = struct { target: u8 }; +fn caller(worker: Worker) u8 { return worker.target; } +comptime { + if (@hasField(Worker, "renamed")) @compileError("Worker.renamed must stay absent"); +} diff --git a/gitnexus/test/fixtures/rename-integration/zig/reflected-hasfield-new-name/before/model.zig b/gitnexus/test/fixtures/rename-integration/zig/reflected-hasfield-new-name/before/model.zig new file mode 100644 index 000000000..3b9f019e4 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/zig/reflected-hasfield-new-name/before/model.zig @@ -0,0 +1,5 @@ +const Worker = struct { target: u8 }; +fn caller(worker: Worker) u8 { return worker.target; } +comptime { + if (@hasField(Worker, "renamed")) @compileError("Worker.renamed must stay absent"); +} diff --git a/gitnexus/test/fixtures/rename-integration/zig/reflected-hasfield-new-name/case.json b/gitnexus/test/fixtures/rename-integration/zig/reflected-hasfield-new-name/case.json new file mode 100644 index 000000000..20b34f370 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/zig/reflected-hasfield-new-name/case.json @@ -0,0 +1,40 @@ +{ + "language": "zig", + "description": "Refuse a literal @hasField check of the destination name on the owner, which would flip from false to true.", + "categories": [ + "reflection", + "has-field", + "destination-collision", + "negative", + "unchanged-on-error" + ], + "target": { + "name": "target", + "kind": "Property", + "file": "model.zig", + "startLine": 0 + }, + "newName": "renamed", + "expect": { + "status": "error", + "code": "unsupported_occurrence", + "planningStatus": "unsupported" + }, + "edges": [ + { + "type": "ACCESSES", + "from": { + "name": "caller", + "kind": "Function", + "file": "model.zig", + "startLine": 1 + }, + "to": { + "name": "target", + "kind": "Property", + "file": "model.zig", + "startLine": 0 + } + } + ] +} diff --git a/gitnexus/test/fixtures/rename-integration/zig/reflected-hasfield-old-name/after/model.zig b/gitnexus/test/fixtures/rename-integration/zig/reflected-hasfield-old-name/after/model.zig new file mode 100644 index 000000000..8c18e466f --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/zig/reflected-hasfield-old-name/after/model.zig @@ -0,0 +1,5 @@ +const Worker = struct { target: u8 }; +fn caller(worker: Worker) u8 { return worker.target; } +comptime { + if (!@hasField(Worker, "target")) @compileError("Worker.target must stay a field"); +} diff --git a/gitnexus/test/fixtures/rename-integration/zig/reflected-hasfield-old-name/before/model.zig b/gitnexus/test/fixtures/rename-integration/zig/reflected-hasfield-old-name/before/model.zig new file mode 100644 index 000000000..8c18e466f --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/zig/reflected-hasfield-old-name/before/model.zig @@ -0,0 +1,5 @@ +const Worker = struct { target: u8 }; +fn caller(worker: Worker) u8 { return worker.target; } +comptime { + if (!@hasField(Worker, "target")) @compileError("Worker.target must stay a field"); +} diff --git a/gitnexus/test/fixtures/rename-integration/zig/reflected-hasfield-old-name/case.json b/gitnexus/test/fixtures/rename-integration/zig/reflected-hasfield-old-name/case.json new file mode 100644 index 000000000..088cdcab5 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/zig/reflected-hasfield-old-name/case.json @@ -0,0 +1,34 @@ +{ + "language": "zig", + "description": "Refuse a literal @hasField check of the selected field on its owner, which would flip from true to false.", + "categories": ["reflection", "has-field", "negative", "unchanged-on-error"], + "target": { + "name": "target", + "kind": "Property", + "file": "model.zig", + "startLine": 0 + }, + "newName": "renamed", + "expect": { + "status": "error", + "code": "unsupported_occurrence", + "planningStatus": "unsupported" + }, + "edges": [ + { + "type": "ACCESSES", + "from": { + "name": "caller", + "kind": "Function", + "file": "model.zig", + "startLine": 1 + }, + "to": { + "name": "target", + "kind": "Property", + "file": "model.zig", + "startLine": 0 + } + } + ] +} diff --git a/gitnexus/test/fixtures/rename-integration/zig/reflected-hasfield-unrelated-owner/after/model.zig b/gitnexus/test/fixtures/rename-integration/zig/reflected-hasfield-unrelated-owner/after/model.zig new file mode 100644 index 000000000..0060aa620 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/zig/reflected-hasfield-unrelated-owner/after/model.zig @@ -0,0 +1,7 @@ +const Worker = struct { renamed: u8 }; +const Other = struct { target: u8 }; +fn caller(worker: Worker, other: Other) u8 { return worker.renamed + other.target; } +comptime { + if (!@hasField(Other, "target")) @compileError("Other.target must stay a field"); + if (@hasField(Other, "renamed")) @compileError("Other.renamed must stay absent"); +} diff --git a/gitnexus/test/fixtures/rename-integration/zig/reflected-hasfield-unrelated-owner/before/model.zig b/gitnexus/test/fixtures/rename-integration/zig/reflected-hasfield-unrelated-owner/before/model.zig new file mode 100644 index 000000000..202d0107b --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/zig/reflected-hasfield-unrelated-owner/before/model.zig @@ -0,0 +1,7 @@ +const Worker = struct { target: u8 }; +const Other = struct { target: u8 }; +fn caller(worker: Worker, other: Other) u8 { return worker.target + other.target; } +comptime { + if (!@hasField(Other, "target")) @compileError("Other.target must stay a field"); + if (@hasField(Other, "renamed")) @compileError("Other.renamed must stay absent"); +} diff --git a/gitnexus/test/fixtures/rename-integration/zig/reflected-hasfield-unrelated-owner/case.json b/gitnexus/test/fixtures/rename-integration/zig/reflected-hasfield-unrelated-owner/case.json new file mode 100644 index 000000000..89282dbc5 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/zig/reflected-hasfield-unrelated-owner/case.json @@ -0,0 +1,33 @@ +{ + "language": "zig", + "description": "Rename a field while preserving @hasField checks of the old and destination spellings on a proven different container.", + "categories": ["reflection", "has-field", "unrelated-owner", "receiver-isolation"], + "target": { + "name": "target", + "kind": "Property", + "file": "model.zig", + "startLine": 0 + }, + "newName": "renamed", + "expect": { + "status": "success", + "edits": 2 + }, + "edges": [ + { + "type": "ACCESSES", + "from": { + "name": "caller", + "kind": "Function", + "file": "model.zig", + "startLine": 2 + }, + "to": { + "name": "target", + "kind": "Property", + "file": "model.zig", + "startLine": 0 + } + } + ] +} diff --git a/gitnexus/test/fixtures/rename-integration/zig/typed-container-deep-field/after/model.zig b/gitnexus/test/fixtures/rename-integration/zig/typed-container-deep-field/after/model.zig new file mode 100644 index 000000000..a8b3b9740 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/zig/typed-container-deep-field/after/model.zig @@ -0,0 +1,7 @@ +const Leaf = struct { count: u8 }; +const Branch = struct { leaf: Leaf }; +const Root = struct { branch: Branch }; +const Other = struct { value: u8 }; +fn read(root: Root, other: Other) u8 { return root.branch.leaf.count + other.value; } +fn write(leaf: *Leaf) void { leaf.count = 3; } +// value must remain in this comment. diff --git a/gitnexus/test/fixtures/rename-integration/zig/typed-container-deep-field/before/model.zig b/gitnexus/test/fixtures/rename-integration/zig/typed-container-deep-field/before/model.zig new file mode 100644 index 000000000..548a7074f --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/zig/typed-container-deep-field/before/model.zig @@ -0,0 +1,7 @@ +const Leaf = struct { value: u8 }; +const Branch = struct { leaf: Leaf }; +const Root = struct { branch: Branch }; +const Other = struct { value: u8 }; +fn read(root: Root, other: Other) u8 { return root.branch.leaf.value + other.value; } +fn write(leaf: *Leaf) void { leaf.value = 3; } +// value must remain in this comment. diff --git a/gitnexus/test/fixtures/rename-integration/zig/typed-container-deep-field/case.json b/gitnexus/test/fixtures/rename-integration/zig/typed-container-deep-field/case.json new file mode 100644 index 000000000..684f71966 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/zig/typed-container-deep-field/case.json @@ -0,0 +1,93 @@ +{ + "language": "zig", + "description": "Rename a container field through a four-part typed member chain and pointer receiver, preserving a same-named unrelated field.", + "categories": ["public-field", "deep-chain", "typed-container"], + "target": { + "name": "value", + "kind": "Property", + "file": "model.zig", + "startLine": 0 + }, + "newName": "count", + "expect": { + "status": "success", + "edits": 3 + }, + "edges": [ + { + "type": "ACCESSES", + "from": { + "name": "read", + "kind": "Function", + "file": "model.zig", + "startLine": 4 + }, + "to": { + "name": "branch", + "kind": "Property", + "file": "model.zig", + "startLine": 2 + } + }, + { + "type": "ACCESSES", + "from": { + "name": "read", + "kind": "Function", + "file": "model.zig", + "startLine": 4 + }, + "to": { + "name": "leaf", + "kind": "Property", + "file": "model.zig", + "startLine": 1 + } + }, + { + "type": "ACCESSES", + "from": { + "name": "read", + "kind": "Function", + "file": "model.zig", + "startLine": 4 + }, + "to": { + "name": "value", + "kind": "Property", + "file": "model.zig", + "startLine": 0 + } + }, + { + "type": "ACCESSES", + "from": { + "name": "read", + "kind": "Function", + "file": "model.zig", + "startLine": 4 + }, + "to": { + "name": "value", + "kind": "Property", + "file": "model.zig", + "startLine": 3 + } + }, + { + "type": "ACCESSES", + "from": { + "name": "write", + "kind": "Function", + "file": "model.zig", + "startLine": 5 + }, + "to": { + "name": "value", + "kind": "Property", + "file": "model.zig", + "startLine": 0 + } + } + ] +} diff --git a/gitnexus/test/fixtures/rename-integration/zig/typed-container-method/after/model.zig b/gitnexus/test/fixtures/rename-integration/zig/typed-container-method/after/model.zig new file mode 100644 index 000000000..dc9a37cfe --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/zig/typed-container-method/after/model.zig @@ -0,0 +1,6 @@ +const Worker = struct { + pub fn execute(self: Worker) void { _ = self; } + pub fn dispatch(self: Worker) void { self.execute(); } +}; +const Other = struct { pub fn process(self: Other) void { _ = self; } }; +fn caller(worker: Worker, other: Other) void { worker.execute(); worker.dispatch(); other.process(); } diff --git a/gitnexus/test/fixtures/rename-integration/zig/typed-container-method/before/model.zig b/gitnexus/test/fixtures/rename-integration/zig/typed-container-method/before/model.zig new file mode 100644 index 000000000..6d160128e --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/zig/typed-container-method/before/model.zig @@ -0,0 +1,6 @@ +const Worker = struct { + pub fn process(self: Worker) void { _ = self; } + pub fn dispatch(self: Worker) void { self.process(); } +}; +const Other = struct { pub fn process(self: Other) void { _ = self; } }; +fn caller(worker: Worker, other: Other) void { worker.process(); worker.dispatch(); other.process(); } diff --git a/gitnexus/test/fixtures/rename-integration/zig/typed-container-method/case.json b/gitnexus/test/fixtures/rename-integration/zig/typed-container-method/case.json new file mode 100644 index 000000000..cc7970fab --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/zig/typed-container-method/case.json @@ -0,0 +1,78 @@ +{ + "language": "zig", + "description": "Rename a public container method through self and typed receivers, leaving another container method unchanged.", + "categories": ["call-graph", "typed-container", "receiver-isolation"], + "target": { + "name": "process", + "kind": "Method", + "file": "model.zig", + "startLine": 1 + }, + "newName": "execute", + "expect": { + "status": "success", + "edits": 3 + }, + "edges": [ + { + "type": "CALLS", + "from": { + "name": "dispatch", + "kind": "Method", + "file": "model.zig", + "startLine": 2 + }, + "to": { + "name": "process", + "kind": "Method", + "file": "model.zig", + "startLine": 1 + } + }, + { + "type": "CALLS", + "from": { + "name": "caller", + "kind": "Function", + "file": "model.zig", + "startLine": 5 + }, + "to": { + "name": "process", + "kind": "Method", + "file": "model.zig", + "startLine": 1 + } + }, + { + "type": "CALLS", + "from": { + "name": "caller", + "kind": "Function", + "file": "model.zig", + "startLine": 5 + }, + "to": { + "name": "dispatch", + "kind": "Method", + "file": "model.zig", + "startLine": 2 + } + }, + { + "type": "CALLS", + "from": { + "name": "caller", + "kind": "Function", + "file": "model.zig", + "startLine": 5 + }, + "to": { + "name": "process", + "kind": "Method", + "file": "model.zig", + "startLine": 4 + } + } + ] +} diff --git a/gitnexus/test/fixtures/rename-planning-safety/model.ts b/gitnexus/test/fixtures/rename-planning-safety/model.ts new file mode 100644 index 000000000..44cc27750 --- /dev/null +++ b/gitnexus/test/fixtures/rename-planning-safety/model.ts @@ -0,0 +1,7 @@ +export function target() { + return 1; +} + +export function caller() { + return target(); +} diff --git a/gitnexus/test/fixtures/ruby-captures-golden/expected-captures.json b/gitnexus/test/fixtures/ruby-captures-golden/expected-captures.json index bf6b2bab3..d8baae704 100644 --- a/gitnexus/test/fixtures/ruby-captures-golden/expected-captures.json +++ b/gitnexus/test/fixtures/ruby-captures-golden/expected-captures.json @@ -1,4 +1,12 @@ { + "ruby-accessor-dispatch/app.rb": { + "captureGroups": 34, + "digest": "9cbd09825a1c376dbba6d30132224aadf8ba0f729e4e388a114e057b25d4cdf0" + }, + "ruby-accessor-dispatch/model.rb": { + "captureGroups": 35, + "digest": "8b086a53c7ce1c810db1a25b9c0013ca4d602bc0b44f0aa21b2280eaaa326420" + }, "ruby-ambiguous/lib/user_handler.rb": { "captureGroups": 10, "digest": "b8a871dabb894b4b43c2efd8b4b7616b116a86041abae8cfac6a3d43b717e6e4" @@ -73,11 +81,11 @@ }, "ruby-child-extends-parent/lib/app.rb": { "captureGroups": 12, - "digest": "266fe732ed53abcf9dfb1f7faa310f20258a63184f6b087be3afc66ad7f0d280" + "digest": "b923d92cee4bd5c8c04ff295961fed4f791ba845b591168e998a43a295e23b0a" }, "ruby-child-extends-parent/lib/child.rb": { "captureGroups": 6, - "digest": "7742919ebf9abc5c07d42b7889af78f4cb1c5b54a432b1cd42da68b69c02c052" + "digest": "6cb62cea8acbcc969bf7ac6fc95433a0c7d8bdd001b907e6c5ddf9fa30d1bd04" }, "ruby-child-extends-parent/lib/parent.rb": { "captureGroups": 6, @@ -85,7 +93,7 @@ }, "ruby-constant-constructor/app.rb": { "captureGroups": 7, - "digest": "cb37edd7c73b7759607850b774627ff2ad223458c611171d666cee83e4de99cd" + "digest": "7d41513772031732809f0be4e2f4feddba320b0a579bba154b7890d3d5f60446" }, "ruby-constant-constructor/models.rb": { "captureGroups": 9, @@ -97,7 +105,7 @@ }, "ruby-constant-factory-call/app.rb": { "captureGroups": 14, - "digest": "b06a8b9abf30a2e34587e524c2a5f3f058690e9d636790d2a9e4a0c3c15e84c8" + "digest": "e92f96a46c7c173cbad49575e1984b180cc8f18475f8b11a13210ad498b0a894" }, "ruby-constant-factory-call/user_service.rb": { "captureGroups": 9, @@ -105,11 +113,11 @@ }, "ruby-construction-selector/lib/app.rb": { "captureGroups": 18, - "digest": "429dbea1c9b012f92be4c99fdf2425b60e3223ea73567af8706115949bf018ac" + "digest": "4fc410b7e2aa6d92e6128b4e5921943afa79b7db9a78ee941e93625154301d99" }, "ruby-construction-selector/lib/factory.rb": { "captureGroups": 25, - "digest": "b1c415b5db1426cfd0f7636fe80ae5d79b11d0575152f1f98f67d35521398e58" + "digest": "c2e4d8d2479249c2575f6ca5897a41a6e874c0d41ffe599764304c9004ebdd57" }, "ruby-construction-selector/lib/models.rb": { "captureGroups": 11, @@ -137,11 +145,11 @@ }, "ruby-field-type-disambig/service.rb": { "captureGroups": 9, - "digest": "cf74d10705010c4c798db2232ae1fdb8372f17a06cad2e23d784d908cadb77d2" + "digest": "e2bfb059c77ead63aafd2b2a1e317d5beb90cc787157abafa361f23cec3743c8" }, "ruby-field-type-disambig/user.rb": { "captureGroups": 13, - "digest": "6ab1bbaf4193173ceba9381d319e51f342e115788b8326408e256f07dddbfcae" + "digest": "6c59c3c5ccdf077ba46d4f3a4722820b6b3033fccdceb2403015c0827fa2f140" }, "ruby-field-types/models.rb": { "captureGroups": 19, @@ -149,11 +157,11 @@ }, "ruby-field-types/service.rb": { "captureGroups": 9, - "digest": "56bc5ed9388fc0716ba92095e0a602f4051ef0af321f2c734bb400f7ccc4bf88" + "digest": "f45af61b30ae5cdf4db81618262791df3060c042fd98888b4aa5cd6961c9fe83" }, "ruby-for-in-loop/app.rb": { "captureGroups": 9, - "digest": "3981ec9af3c13030f144f3981a89bade0ac8c76450a3206706fb117cb417e7e3" + "digest": "644a9aa1629982ae1d0aefbd40e249325fe82926b97bbfbd3f550a9f77d4626b" }, "ruby-for-in-loop/repo.rb": { "captureGroups": 11, @@ -165,19 +173,19 @@ }, "ruby-grandparent-resolution/lib/app.rb": { "captureGroups": 10, - "digest": "60593906e9e50258423b4181d0152295dc9037a4940f004db84a901fa91ebaf2" + "digest": "a906a8e2c0e6eeb82603110c7ebbc3998c68c7e5e831e2b0abbdce6c9240c05f" }, "ruby-grandparent-resolution/lib/models/a.rb": { "captureGroups": 10, - "digest": "c7434e0859c5698858c3d14452a5d4641f6bac2ea4e672a24bf2c2bec6fdebe7" + "digest": "3fb7308a7c8c6401a38f59e2d1b9d3822e2ba1259c6f02f0f7f0f88f518c8be5" }, "ruby-grandparent-resolution/lib/models/b.rb": { "captureGroups": 6, - "digest": "e904d2c4238558168579e0c3afc2ca8bf3eed71e94861942858089d30d5dc7bf" + "digest": "023daca780caddfca5b7bcf94bd510d934e80b00f9974892a2906ba5f997ee51" }, "ruby-grandparent-resolution/lib/models/c.rb": { "captureGroups": 6, - "digest": "fd1bf5ad5b3a1eb4fbad3473e7eac7a156b01e6d77f6662d768bca1a2b97fb18" + "digest": "bb0f508e99c86e5b04ffa59b73b38e62c06d2d3fc04a0d3623b55ec9643ffba1" }, "ruby-grandparent-resolution/lib/models/greeting.rb": { "captureGroups": 6, @@ -185,7 +193,7 @@ }, "ruby-inline-constructor-receiver/lib/app.rb": { "captureGroups": 17, - "digest": "5ebc93cd9e546e88d583dcf75313c350c25189808d2b043735a7839c7ee9a2d9" + "digest": "2e9f86d8a6da4e4de6434a631dd9348b1379482869d7c8fb8440c462ebf9178f" }, "ruby-inline-constructor-receiver/lib/svc.rb": { "captureGroups": 6, @@ -221,7 +229,7 @@ }, "ruby-namespaced-constructor/app.rb": { "captureGroups": 8, - "digest": "8f6555980417465c870a16742eb2d49fcb8a31e26777c11b926180674aa93e97" + "digest": "fa47f24525d99708ecf0284aed69a5ecf7f24a8c494b37c5a1e2080c964834f7" }, "ruby-namespaced-constructor/models/user_service.rb": { "captureGroups": 13, @@ -269,11 +277,11 @@ }, "ruby-parent-resolution/lib/models/user.rb": { "captureGroups": 9, - "digest": "0e229692965ca5fd6dc5493aa69f056585912f2e4f4acf5af7915a4aee26cb09" + "digest": "28fcec8144f3e4c977f8d6a9cf74ca0e533665d0ae436a3e36aa1eae35beeb81" }, "ruby-qualified-base/lib/derived.rb": { "captureGroups": 18, - "digest": "b09f52e09a1cc7d76607c794b3bc99366abbfd9507ee41cf0a147e7f66d0a453" + "digest": "d2159e17bafe682b3fff552aa4dc682f7d16173e92f1fe6a5ae94fc333c7dec8" }, "ruby-qualified-base/lib/outer.rb": { "captureGroups": 18, @@ -293,7 +301,7 @@ }, "ruby-return-type/app.rb": { "captureGroups": 17, - "digest": "55a732b0be9143e51e5c992c31bf30f1417b41601c665394cc6b2ec1a877ed6b" + "digest": "cd94b43c4aa7f099749ee4b116ecddbb018009d4a1f8230374c45823bc3b170b" }, "ruby-return-type/models.rb": { "captureGroups": 18, @@ -313,7 +321,7 @@ }, "ruby-sequential-mixin/lib/account.rb": { "captureGroups": 27, - "digest": "316ae20946216f503802083e0c568bb0ced7a2a158555ca50254fbee11d78481" + "digest": "12db3ab74a16993f1c972486850248b4a73da0e3f9abe5d297fb4229ce7e0437" }, "ruby-sequential-mixin/lib/greetable.rb": { "captureGroups": 6, @@ -329,7 +337,7 @@ }, "ruby-sequential-mixin/lib/usage.rb": { "captureGroups": 14, - "digest": "6be0c6bcf08525e26c69bd6a2be43c75b357467a9744a15f12cfd7b619b23836" + "digest": "96582922af9a4aef47325cbd8d85b3e773222d1ada0ad309b8797734d5162547" }, "ruby-super-resolution/lib/models/base_model.rb": { "captureGroups": 6, @@ -341,7 +349,7 @@ }, "ruby-super-resolution/lib/models/user.rb": { "captureGroups": 9, - "digest": "1466db4d96a012e158a401cc67802267f3f36c24c374c3319e39586c64a35019" + "digest": "c86cc85e32d6dce06a19e21056e82d0d9b5244be67421dc511d71fbb94b614c3" }, "ruby-tail-collision/collision.rb": { "captureGroups": 15, @@ -353,7 +361,7 @@ }, "ruby-write-access/service.rb": { "captureGroups": 21, - "digest": "431c57194d8b29d22b88eead94e1621b34bfcd47d9980abdea225a971646d44a" + "digest": "3c085df417f96a2c5cd028470b92df4d0e0656ee63222cbe2946e8a02c53dc90" }, "ruby-yard-annotations/models.rb": { "captureGroups": 15, diff --git a/gitnexus/test/fixtures/rust-captures-golden/expected-captures.json b/gitnexus/test/fixtures/rust-captures-golden/expected-captures.json index 14502f512..c4a0506f0 100644 --- a/gitnexus/test/fixtures/rust-captures-golden/expected-captures.json +++ b/gitnexus/test/fixtures/rust-captures-golden/expected-captures.json @@ -197,7 +197,7 @@ }, "rust-chain-call/src/main.rs": { "captureGroups": 25, - "digest": "b9325f0a7f5398c7695eaac5a8381df3f714b19763487d193cfe136c1d5f1fda" + "digest": "7c2a5494b55349cf93ebd99ef14b73a84cd8b18e82f932f93f78c84e20d69502" }, "rust-chain-call/src/models/mod.rs": { "captureGroups": 5, @@ -269,7 +269,7 @@ }, "rust-deep-field-chain/service.rs": { "captureGroups": 16, - "digest": "84b1da3df9c40b1dd044d1cb33b6334ad4d17ab9609e9b1d63dfab4860908be9" + "digest": "4d24cc0c864be1cdacb589d6725526b4f3ebfb5415fce0f542ea785f56c7d401" }, "rust-default-constructor/src/main.rs": { "captureGroups": 36, @@ -381,7 +381,7 @@ }, "rust-field-types/service.rs": { "captureGroups": 11, - "digest": "24a82b599a0ba6a669608ef368f42410cef7b7f42ce7d7927705666895de4386" + "digest": "76dabd12eb2b14f6e03e27a659cabc27615d7ebd10a82e4ae2b431979a5aabe6" }, "rust-for-call-expr/src/main.rs": { "captureGroups": 26, @@ -617,7 +617,7 @@ }, "rust-nullable-receiver/src/main.rs": { "captureGroups": 41, - "digest": "10b8b250f5bc19363013357c3ce47e6681f89b5db5d89532ee1ebea6b55a6494" + "digest": "0a13ec81f654053e6f254cd767931fceddc258d7ffecc9f6c3634dcc8193ddfc" }, "rust-nullable-receiver/src/repo.rs": { "captureGroups": 10, @@ -729,7 +729,7 @@ }, "rust-self-struct-literal/models.rs": { "captureGroups": 32, - "digest": "6745d17a43e50c7dc1cdd985f3f892be5f5116a096bad0db3deb69dde0b4cf94" + "digest": "8c9b9d3c99839f6d77e7c8d2a3392550f1bee52cf68fff891944be50db81391d" }, "rust-self-this-resolution/src/repo.rs": { "captureGroups": 10, diff --git a/gitnexus/test/helpers/grammar-introspection.ts b/gitnexus/test/helpers/grammar-introspection.ts index d61af9b5d..59e5df050 100644 --- a/gitnexus/test/helpers/grammar-introspection.ts +++ b/gitnexus/test/helpers/grammar-introspection.ts @@ -55,6 +55,7 @@ const GRAMMAR_PACKAGES: Partial = { java: SupportedLanguages.Java, c: SupportedLanguages.C, cpp: SupportedLanguages.CPlusPlus, + 'objective-c': SupportedLanguages.ObjectiveC, csharp: SupportedLanguages.CSharp, go: SupportedLanguages.Go, ruby: SupportedLanguages.Ruby, @@ -94,6 +95,7 @@ const DIR_LANG: Record = { /** Basename (no .ts) → language set, for extractor files that name a language. */ const BASENAME_LANGS: Record = { 'c-cpp': [SupportedLanguages.C, SupportedLanguages.CPlusPlus], + 'objective-c': [SupportedLanguages.ObjectiveC], jvm: [SupportedLanguages.Java, SupportedLanguages.Kotlin], 'typescript-javascript': [SupportedLanguages.TypeScript, SupportedLanguages.JavaScript], csharp: [SupportedLanguages.CSharp], @@ -142,8 +144,9 @@ function fileLanguages(relPath: string): SupportedLanguages[] { } const base = relPath.replace(/\.ts$/, '').split('/').pop() ?? ''; if (BASENAME_LANGS[base]) return BASENAME_LANGS[base]; - // A `-harvest.ts` CFG def/use harvester is validated against the SAME - // grammar(s) as its `.ts` CFG visitor (go-harvest → Go, c-cpp-harvest → + // A `-harvest.ts` CFG def/use harvester or `-ast.ts` helper is + // validated against the SAME grammar(s) as its `.ts` CFG visitor + // (go-harvest → Go, c-cpp-harvest → // C+C++, typescript-harvest → TS, …) — strip the suffix and reuse the visitor // basename map. The two genuinely language-agnostic harvesters — // call-site-harvest.ts (pure taint-site mechanism) and scope-tree-harvest.ts @@ -151,7 +154,7 @@ function fileLanguages(relPath: string): SupportedLanguages[] { // (`call-site`, `scope-tree`) misses BASENAME_LANGS and they fall through to // the valid-if-any ALL_LANGS bucket below (correct: they pin no per-grammar // literal). - const harvestBase = base.replace(/-harvest$/, ''); + const harvestBase = base.replace(/-(?:harvest|ast)$/, ''); if (harvestBase !== base && BASENAME_LANGS[harvestBase]) return BASENAME_LANGS[harvestBase]; // generic / shared / cross-language helpers → any grammar (valid-if-any) return [...ALL_LANGS]; diff --git a/gitnexus/test/helpers/rename-integration-cases.ts b/gitnexus/test/helpers/rename-integration-cases.ts new file mode 100644 index 000000000..71a9aae71 --- /dev/null +++ b/gitnexus/test/helpers/rename-integration-cases.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest'; +import { execFile } from 'node:child_process'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; + +const fixtures = fileURLToPath(new URL('../fixtures/rename-integration/', import.meta.url)); +const runner = fileURLToPath(new URL('./rename-integration-runner.mjs', import.meta.url)); +const execute = promisify(execFile); +export const renameCases: Array<{ + id: string; + directory: string; + language: string; + status: string; +}> = []; +for (const language of (await fs.readdir(fixtures, { withFileTypes: true })).filter((entry) => + entry.isDirectory(), +)) { + for (const entry of ( + await fs.readdir(path.join(fixtures, language.name), { withFileTypes: true }) + ).filter((entry) => entry.isDirectory())) { + const directory = path.join(fixtures, language.name, entry.name); + const specification = JSON.parse(await fs.readFile(path.join(directory, 'case.json'), 'utf8')); + if (specification.language !== language.name) + throw new Error(`Fixture language mismatch: ${directory}`); + renameCases.push({ + id: `${language.name}/${entry.name}`, + directory, + language: specification.language, + status: specification.expect.status, + }); + } +} + +renameCases.sort((left, right) => (left.id < right.id ? -1 : left.id > right.id ? 1 : 0)); + +// Separate files let CI distribute these real indexing runs across its shards. +export function defineRenameFixtureTests(language: string): void { + describe('persisted semantic rename across providers (no mocks)', () => { + it.each(renameCases.filter((fixture) => fixture.language === language))( + '$id', + async ({ directory }) => { + const temporary = await fs.mkdtemp(path.join(os.tmpdir(), 'rename-integration-')); + const report = path.join(temporary, 'result.json'); + const env: NodeJS.ProcessEnv = { + ...process.env, + GITNEXUS_HOME: path.join(temporary, 'home'), + GITNEXUS_STORAGE_PATH: path.join(temporary, 'index'), + GITNEXUS_SHARED_STORE: 'off', + GITNEXUS_MEMORY: 'off', + GITNEXUS_INDEX_LOCK_BACKEND: 'file', + GITNEXUS_CONTENT_RETENTION: 'full', + GITNEXUS_LBUG_EXTENSION_INSTALL: 'off', + GITNEXUS_WORKER_READY_TIMEOUT_MS: process.env.GITNEXUS_WORKER_READY_TIMEOUT_MS ?? '15000', + }; + delete env.CODEX_HOME; + delete env.GITNEXUS_STORAGE_ROOT; + try { + try { + // No kill timeout: let native workers finish and release their own handles. + await execute(process.execPath, [runner, directory, temporary, report], { + cwd: temporary, + env, + maxBuffer: 8 * 1024 * 1024, + }); + } catch (error) { + const evidence = await fs.readFile(report, 'utf8').catch(() => String(error)); + throw new Error(evidence, { cause: error }); + } + const result = JSON.parse(await fs.readFile(report, 'utf8')); + expect(result.status).toBe('passed'); + expect(result.nodes).toBeGreaterThan(0); + } finally { + await fs.rm(temporary, { recursive: true, force: true }); + } + }, + 180_000, + ); + }); +} diff --git a/gitnexus/test/helpers/rename-integration-runner.mjs b/gitnexus/test/helpers/rename-integration-runner.mjs new file mode 100644 index 000000000..e791a18cb --- /dev/null +++ b/gitnexus/test/helpers/rename-integration-runner.mjs @@ -0,0 +1,238 @@ +// Run one fixture in its own process: real registry, persisted index, and public tools. +// Build first (`npm run build`); no compiler or test-runner API is loaded by this runner. +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { runFullAnalysis } from '../../dist/core/run-analyze.js'; +import { LocalBackend } from '../../dist/mcp/local/local-backend.js'; +import * as adapter from '../../dist/core/lbug/lbug-adapter.js'; +import { getStoragePaths, listRegisteredRepos, loadMeta } from '../../dist/storage/repo-manager.js'; + +const [fixtureDirectory, temporaryDirectory, reportPath] = process.argv.slice(2); +const specification = JSON.parse( + await fs.readFile(path.join(fixtureDirectory, 'case.json'), 'utf8'), +); +const root = path.join(temporaryDirectory, 'repo'); +const repo = 'rename-fixture'; +let backend; + +async function files(directory, prefix = '') { + const result = {}; + for (const entry of await fs.readdir(directory, { withFileTypes: true })) { + const relative = prefix ? `${prefix}/${entry.name}` : entry.name; + if (entry.isDirectory()) + Object.assign(result, await files(path.join(directory, entry.name), relative)); + else result[relative] = await fs.readFile(path.join(directory, entry.name), 'utf8'); + } + return result; +} + +async function assertSources(expected) { + for (const [file, source] of Object.entries(expected)) { + assert.equal( + await fs.readFile(path.join(root, file), 'utf8'), + source, + `Unexpected contents: ${file}`, + ); + } +} + +async function index(useParseCache = false) { + const result = await runFullAnalysis( + root, + { + force: true, + useParseCache, + skipGit: true, + skipFts: true, + skipSkills: true, + skipAgentsMd: true, + embeddings: false, + pdg: true, + workerPoolSize: 1, + registryName: repo, + }, + { onProgress() {} }, + ); + assert.equal(result.graphWriteCollapsed, undefined); + assert.ok(result.stats.nodes > 0, 'Index must contain real parsed nodes'); + const parseCache = { + hitFileCount: result.pipelineResult.parseCacheHitFileCount, + reparsedFileCount: result.pipelineResult.reparsedFileCount, + usedWorkerPool: result.pipelineResult.usedWorkerPool, + }; + if (useParseCache) { + assert.ok(parseCache.hitFileCount > 0, 'Warm indexing must load real cached captures'); + assert.equal(parseCache.reparsedFileCount, 0, 'Warm indexing must not reparse sources'); + assert.equal(parseCache.usedWorkerPool, false, 'Warm indexing must bypass parse workers'); + } + const storage = getStoragePaths(root); + const meta = await loadMeta(storage.storagePath); + assert.ok(meta?.pdg, 'Real indexing must produce PDG metadata'); + assert.equal(meta.graphWriteCollapsed, undefined); + assert.equal(meta.incrementalInProgress, undefined); + assert.equal(meta.scopeExtractionReceipt, 1, 'Every grammar must be available'); + assert.equal(meta.scopeExtractionFailures?.total ?? 0, 0); + const registrations = await listRegisteredRepos({ validate: true }); + assert.equal(registrations.length, 1); + assert.equal(registrations[0].name, repo); + assert.equal(await fs.realpath(registrations[0].path), await fs.realpath(root)); + assert.equal( + await fs.realpath(registrations[0].storagePath), + await fs.realpath(result.storagePath), + ); + await adapter.initLbug(storage.lbugPath, { skipFts: true }); + try { + const nodes = await adapter.executeQuery( + 'MATCH (n) WHERE n.filePath IS NOT NULL RETURN n.id AS id, n.name AS name, n.filePath AS file, n.startLine AS startLine', + ); + const edges = await adapter.executeQuery( + 'MATCH (a)-[r:CodeRelation]->(b) RETURN a.id AS source, b.id AS target, r.type AS type', + ); + return { + nodes: nodes.map((node) => ({ ...node, kind: node.id.split(':')[0] })), + edges, + parseCache, + }; + } finally { + await adapter.closeLbug(); + } +} + +function select(nodes, selector) { + const matches = nodes.filter((node) => + Object.entries(selector).every(([key, value]) => node[key] === value), + ); + assert.equal( + matches.length, + 1, + `Expected exactly one indexed symbol for ${JSON.stringify(selector)}; got ${JSON.stringify(matches)}; nearby: ${JSON.stringify(nodes.filter((node) => node.name === selector.name || node.file === selector.file))}`, + ); + return matches[0]; +} + +function assertEdges(graph, expected, exactOutgoing = false) { + for (const edge of expected) { + assert.ok( + graph.edges.some( + (actual) => + actual.type === edge.type && + actual.source === edge.source.id && + actual.target === edge.target.id, + ), + `Missing persisted ${edge.type}: ${JSON.stringify(edge.source)} -> ${JSON.stringify(edge.target)}`, + ); + if (exactOutgoing) { + const actualTargets = graph.edges + .filter((actual) => actual.type === edge.type && actual.source === edge.source.id) + .map((actual) => actual.target); + const expectedTargets = expected + .filter((other) => other.type === edge.type && other.source.id === edge.source.id) + .map((other) => other.target.id); + assert.deepEqual( + [...new Set(actualTargets)].sort(), + [...new Set(expectedTargets)].sort(), + `Unexpected persisted ${edge.type} targets from ${JSON.stringify(edge.source)}`, + ); + } + } +} + +try { + const before = await files(path.join(fixtureDirectory, 'before')); + const after = await files(path.join(fixtureDirectory, 'after')); + assert.deepEqual( + Object.keys(before).sort(), + Object.keys(after).sort(), + 'Every source needs an explicit expected snapshot', + ); + await fs.cp(path.join(fixtureDirectory, 'before'), root, { recursive: true }); + let graph = await index(); + const target = select(graph.nodes, specification.target); + const expectedEdges = (specification.edges ?? []).map((edge) => ({ + type: edge.type, + source: select(graph.nodes, edge.from), + target: select(graph.nodes, edge.to), + })); + assertEdges(graph, expectedEdges, specification.exactOutgoing); + if (specification.warmParseCache === true) { + const warm = await index(true); + assert.deepEqual( + select(warm.nodes, specification.target), + target, + 'Warm indexing must preserve the persisted target identity', + ); + assertEdges(warm, expectedEdges, specification.exactOutgoing); + graph = warm; + } + backend = new LocalBackend(); + assert.equal(await backend.init(), true); + const request = { repo, symbol_uid: target.id, new_name: specification.newName }; + const preview = await backend.callTool('rename', { ...request, dry_run: true }); + assert.equal(preview.status, specification.expect.status, JSON.stringify(preview)); + await assertSources(before); + const applied = await backend.callTool('rename', { ...request, dry_run: false }); + assert.equal(applied.status, specification.expect.status, JSON.stringify(applied)); + if (specification.expect.status === 'success') { + assert.equal(preview.applied, false); + assert.equal(preview.total_edits, specification.expect.edits, JSON.stringify(preview)); + assert.equal(applied.applied, true); + assert.equal(applied.total_edits, specification.expect.edits); + assert.deepEqual(applied.changes, preview.changes, 'Apply must execute the previewed edits'); + } else { + for (const result of [preview, applied]) { + assert.equal(result.code, specification.expect.code, JSON.stringify(result)); + assert.equal(result.planning_status, specification.expect.planningStatus); + assert.equal(result.total_edits, 0); + assert.equal(result.applied, false); + } + assert.deepEqual(after, before, 'Refused operations must retain all source bytes'); + } + await assertSources(after); + await backend.dispose(); + backend = undefined; + if (specification.expect.status === 'success') { + const reindexed = await index(); + select(reindexed.nodes, { + ...specification.target, + name: specification.expectedTargetName ?? specification.newName, + }); + // Names/IDs may change across the entire family; declaration positions do not. + const corresponding = (node) => + select(reindexed.nodes, { + file: node.file, + kind: node.kind, + startLine: node.startLine, + }); + assertEdges( + reindexed, + expectedEdges.map((edge) => ({ + type: edge.type, + source: corresponding(edge.source), + target: corresponding(edge.target), + })), + specification.exactOutgoing, + ); + } + await fs.writeFile( + reportPath, + JSON.stringify({ + status: 'passed', + nodes: graph.nodes.length, + graphAssertions: expectedEdges.length, + edits: applied.total_edits, + reindexed: specification.expect.status === 'success', + ...(specification.warmParseCache === true ? { warmParseCache: graph.parseCache } : {}), + }), + ); +} catch (error) { + await fs.writeFile( + reportPath, + JSON.stringify({ status: 'failed', error: error.stack ?? String(error) }), + ); + console.error(error); + process.exitCode = 1; +} finally { + await backend?.dispose(); + await adapter.closeLbug(); +} diff --git a/gitnexus/test/helpers/rename-planning-safety-runner.mjs b/gitnexus/test/helpers/rename-planning-safety-runner.mjs new file mode 100644 index 000000000..2a530ca94 --- /dev/null +++ b/gitnexus/test/helpers/rename-planning-safety-runner.mjs @@ -0,0 +1,110 @@ +// Real persisted-index safety checks, isolated from other registry/database users. +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { runFullAnalysis } from '../../dist/core/run-analyze.js'; +import { LocalBackend } from '../../dist/mcp/local/local-backend.js'; +import * as adapter from '../../dist/core/lbug/lbug-adapter.js'; +import { listRegisteredRepos } from '../../dist/storage/repo-manager.js'; + +const [scenario, temporary, reportPath] = process.argv.slice(2); +const root = path.join(temporary, 'repo'); +const sourceDirectory = path.join(root, 'src'); +const sourcePath = path.join(sourceDirectory, 'model.ts'); +const repo = 'rename-planning-safety'; +let backend; + +try { + await fs.mkdir(sourceDirectory, { recursive: true }); + await fs.copyFile( + new URL('../fixtures/rename-planning-safety/model.ts', import.meta.url), + sourcePath, + ); + const indexed = await runFullAnalysis( + root, + { + force: true, + useParseCache: false, + skipGit: true, + skipFts: true, + skipSkills: true, + skipAgentsMd: true, + embeddings: false, + pdg: true, + workerPoolSize: 1, + registryName: repo, + }, + { onProgress() {} }, + ); + assert.ok(indexed.stats.nodes > 0); + assert.equal(indexed.graphWriteCollapsed, undefined); + const registrations = await listRegisteredRepos({ validate: true }); + assert.equal(registrations.length, 1); + assert.equal(registrations[0].name, repo); + assert.equal(await fs.realpath(registrations[0].path), await fs.realpath(root)); + await adapter.initLbug(path.join(indexed.storagePath, 'lbug'), { skipFts: true }); + let target; + try { + const targets = await adapter.executeQuery( + "MATCH (n:Function) WHERE n.name = 'target' AND n.filePath = 'src/model.ts' RETURN n.id AS id", + ); + assert.equal(targets.length, 1); + target = targets[0].id; + const calls = await adapter.executeQuery( + "MATCH (a)-[r:CodeRelation]->(b) WHERE r.type = 'CALLS' AND a.name = 'caller' RETURN b.id AS id", + ); + assert.deepEqual(calls, [{ id: target }]); + } finally { + await adapter.closeLbug(); + } + + let expectedCode; + let expectedPlanning; + let outsidePath; + if (scenario === 'unsupported-encoding') { + await fs.appendFile(sourcePath, Buffer.from([0x2f, 0x2f, 0x20, 0xff, 0x0a])); + expectedCode = 'unsupported_encoding'; + expectedPlanning = 'unsupported'; + } else { + assert.equal(scenario, 'symlink-escape'); + const outside = path.join(temporary, 'outside'); + await fs.rename(sourceDirectory, outside); + await fs.symlink(outside, sourceDirectory, process.platform === 'win32' ? 'junction' : 'dir'); + outsidePath = path.join(outside, 'model.ts'); + expectedCode = 'outside_repository'; + expectedPlanning = 'blocked'; + } + const before = await fs.readFile(sourcePath); + backend = new LocalBackend(); + assert.equal(await backend.init(), true); + const request = { repo, symbol_uid: target, new_name: 'renamed' }; + // Both modes of the one public tool: dry_run:true previews, dry_run:false applies. + for (const dry_run of [true, false]) { + const result = await backend.callTool('rename', { ...request, dry_run }); + assert.equal(result.status, 'error', JSON.stringify(result)); + assert.equal(result.code, expectedCode, JSON.stringify(result)); + assert.equal(result.planning_status, expectedPlanning); + assert.equal(result.application_status, 'not_started'); + assert.equal(result.applied, false); + assert.equal(result.total_edits, 0); + assert.equal(result.files_affected, 0); + assert.deepEqual(result.changes, []); + assert.deepEqual(await fs.readFile(sourcePath), before); + if (outsidePath) assert.deepEqual(await fs.readFile(outsidePath), before); + assert.deepEqual(await fs.readdir(sourceDirectory), ['model.ts']); + } + await fs.writeFile( + reportPath, + JSON.stringify({ status: 'passed', nodes: indexed.stats.nodes, code: expectedCode }), + ); +} catch (error) { + await fs.writeFile( + reportPath, + JSON.stringify({ status: 'failed', error: error.stack ?? String(error) }), + ); + console.error(error); + process.exitCode = 1; +} finally { + await backend?.dispose(); + await adapter.closeLbug(); +} diff --git a/gitnexus/test/helpers/rename-provider-fixture.ts b/gitnexus/test/helpers/rename-provider-fixture.ts new file mode 100644 index 000000000..826a9ab59 --- /dev/null +++ b/gitnexus/test/helpers/rename-provider-fixture.ts @@ -0,0 +1,71 @@ +import { afterEach, expect } from 'vitest'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js'; +import { renameSymbol } from '../../src/mcp/local/rename/rename-plan.js'; + +export function createRenameFixtureHelpers() { + const roots: string[] = []; + afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true }))); + }); + + async function fixture( + files: Record, + name = 'target', + kind = 'Function', + targetFile?: string, + ) { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'rename-native-')); + roots.push(root); + await Promise.all( + Object.entries(files).map(([file, content]) => fs.writeFile(path.join(root, file), content)), + ); + const result = await runPipelineFromRepo(root, () => undefined, { + workerPoolSize: 1, + skipGraphPhases: true, + }); + const nodes = [...result.graph.iterNodes()]; + const target = nodes.find( + (node) => + node.properties.name === name && + node.label === kind && + (!targetFile || node.properties.filePath === targetFile), + ); + expect(target, JSON.stringify(nodes.filter((node) => node.label !== 'File'))).toBeDefined(); + const symbol = { + uid: target!.id, + name: target!.properties.name, + filePath: target!.properties.filePath, + startLine: target!.properties.startLine! + 1, + endLine: target!.properties.endLine! + 1, + kind: target!.label, + }; + return { root, nodes, symbol }; + } + + async function expectRename( + files: Record, + expected: Record, + name = 'target', + kind = 'Function', + newName = 'renamed', + targetFile?: string, + ) { + const { root, nodes, symbol } = await fixture(files, name, kind, targetFile); + const preview = await renameSymbol(root, symbol, { new_name: newName, dry_run: true }, nodes); + expect(preview, JSON.stringify(preview)).toMatchObject({ status: 'success', applied: false }); + for (const [file, content] of Object.entries(files)) + expect(await fs.readFile(path.join(root, file), 'utf8')).toBe(content); + const applied = await renameSymbol(root, symbol, { new_name: newName, dry_run: false }, nodes); + expect(applied, JSON.stringify(applied)).toMatchObject({ + status: 'success', + applied: true, + changes: preview.changes, + }); + for (const [file, content] of Object.entries(expected)) + expect(await fs.readFile(path.join(root, file), 'utf8')).toBe(content); + } + return { fixture, expectRename }; +} diff --git a/gitnexus/test/integration/analyze-index-lock-concurrency.test.ts b/gitnexus/test/integration/analyze-index-lock-concurrency.test.ts index 6e4416c02..ac267ce6b 100644 --- a/gitnexus/test/integration/analyze-index-lock-concurrency.test.ts +++ b/gitnexus/test/integration/analyze-index-lock-concurrency.test.ts @@ -59,6 +59,44 @@ afterEach(() => { }); describe('index lock across processes (#2658)', () => { + it('publishes the held marker only after its PID is written', async () => { + // Observe the create/write boundary deterministically inside the real child. + const observePidWrite = ` + import fs from 'node:fs'; + import { syncBuiltinESMExports } from 'node:module'; + const original = fs.writeFileSync; + fs.writeFileSync = (file, data, options) => { + if (data === String(process.pid)) { + fs.closeSync(fs.openSync(file, 'w')); + fs.writeSync(1, JSON.stringify({ markerVisible: fs.existsSync(process.env.MARKER) }) + '\\n'); + } + return original(file, data, options); + }; + syncBuiltinESMExports(); + `; + child = spawn( + process.execPath, + ['--import', `data:text/javascript,${encodeURIComponent(observePidWrite)}`, childScript], + { + env: { + ...process.env, + LOCK_MODULE: lockModule, + LOCK_DIR: dir, + MARKER: marker, + GITNEXUS_INDEX_LOCK_BACKEND: 'file', + }, + stdio: ['ignore', 'pipe', 'pipe'], + }, + ); + let stdout = ''; + child.stdout?.on('data', (chunk) => { + stdout += String(chunk); + }); + await waitFor(() => stdout.includes('\n') && existsSync(marker), 40_000); + expect(JSON.parse(stdout.trim())).toEqual({ markerVisible: false }); + expect(Number(readFileSync(marker, 'utf8'))).toBe(child.pid); + }, 60_000); + it.each(['EEXIST', 'ENOENT'])( 'reports sentinel-create %s distinctly from other failures', async (code) => { diff --git a/gitnexus/test/integration/basicblock-roundtrip.test.ts b/gitnexus/test/integration/basicblock-roundtrip.test.ts index ef6cfc86f..7cdd6900d 100644 --- a/gitnexus/test/integration/basicblock-roundtrip.test.ts +++ b/gitnexus/test/integration/basicblock-roundtrip.test.ts @@ -22,13 +22,14 @@ import os from 'os'; import { NODE_TABLES } from 'gitnexus-shared'; import { buildTestGraph } from '../helpers/test-graph.js'; import { getNodeQuery } from '../../src/server/api.js'; +import { runImpactPDG } from '../../src/mcp/local/pdg-impact.js'; let tmpBase: string; let storagePath: string; let dbPath: string; -const BB1 = 'BasicBlock:src/a.ts:0'; -const BB2 = 'BasicBlock:src/a.ts:1'; +const BB1 = 'BasicBlock:src/a.ts:1:0:0'; +const BB2 = 'BasicBlock:src/a.ts:1:0:1'; const NEW_EDGE_TYPES = [ 'CFG', 'REACHING_DEF', @@ -52,6 +53,14 @@ beforeAll(async () => { // REACHING_DEF edge stores its variable name ('x') in `reason`. const graph = buildTestGraph( [ + { + id: 'Module:src/a.ts:program', + label: 'Module', + name: 'program', + filePath: 'src/a.ts', + startLine: 0, + endLine: 6, + }, { id: BB1, label: 'BasicBlock', @@ -59,7 +68,7 @@ beforeAll(async () => { filePath: 'src/a.ts', startLine: 1, endLine: 3, - extra: { text: 'const x = req.body;' }, + extra: { text: 'const x = req.body;', ownerId: 'Module:src/a.ts:program' }, }, { id: BB2, @@ -102,6 +111,39 @@ afterAll(async () => { }); describe('BasicBlock + taint/PDG edge round-trip (#2080)', () => { + it('projects persisted explicit program ownership through the real PDG query path', async () => { + const adapter = await import('../../src/core/lbug/lbug-adapter.js'); + const result = await runImpactPDG({ + repo: { lbugPath: dbPath }, + sym: { + id: 'Function:src/a.ts:consumer', + name: 'consumer', + filePath: 'src/a.ts', + startLine: 3, + endLine: 6, + }, + symType: 'Function', + direction: 'upstream', + maxDepth: 1, + limit: 50, + line: 4, + executeParameterized: (_db, query, params) => adapter.executePrepared(query, params), + }); + expect(result).toMatchObject({ + mode: 'pdg', + byDepth: { 1: [expect.objectContaining({ id: 'Module:src/a.ts:program', type: 'Module' })] }, + }); + }); + + it('persists a provider-declared executable owner without inventing owners for ordinary blocks', async () => { + const adapter = await import('../../src/core/lbug/lbug-adapter.js'); + const rows = await adapter.executeQuery( + 'MATCH (n:BasicBlock) RETURN n.id AS id, n.ownerId AS ownerId ORDER BY n.id', + ); + expect(rows[0].ownerId).toBe('Module:src/a.ts:program'); + expect(rows[1].ownerId ?? '').toBe(''); + }); + it('BasicBlock nodes round-trip with their source span and text', async () => { const adapter = await import('../../src/core/lbug/lbug-adapter.js'); const rows = await adapter.executeQuery( @@ -171,4 +213,70 @@ describe('BasicBlock + taint/PDG edge round-trip (#2080)', () => { expect(rows[0].from).toBe(BB1); expect(rows[0].to).toBe(BB2); }); + + it('persists explicit and absent owners through CREATE', async () => { + const adapter = await import('../../src/core/lbug/lbug-adapter.js'); + for (const [index, ownerId] of ['Module:src/owner.ts:Before', undefined].entries()) { + const id = `BasicBlock:src/owner.ts:1:0:${index}`; + expect( + await adapter.insertNodeToLbug('BasicBlock', { + id, + filePath: 'src/owner.ts', + startLine: 1, + endLine: 1, + text: 'run();', + ...(ownerId ? { ownerId } : {}), + }), + ).toBe(true); + const rows = await adapter.executePrepared( + 'MATCH (b:BasicBlock {id: $id}) RETURN b.ownerId AS ownerId', + { id }, + ); + expect(rows[0]?.ownerId).toBe(ownerId ?? ''); + } + }); + + // Matches the native close/reopen guard in lbug-core-adapter and the metadata + // roundtrip suite. Query-generation coverage still runs on Windows. + (process.platform === 'win32' ? it.skip : it)( + 'persists and clears explicit owners through MERGE', + async () => { + const adapter = await import('../../src/core/lbug/lbug-adapter.js'); + const id = 'BasicBlock:src/merged-owner.ts:1:0:0'; + for (const ownerId of [ + 'Module:src/merged-owner.ts:Before', + 'Module:src/merged-owner.ts:After', + undefined, + ]) { + await adapter.closeLbug(); + try { + expect( + await adapter.batchInsertNodesToLbug( + [ + { + label: 'BasicBlock', + properties: { + id, + filePath: 'src/merged-owner.ts', + startLine: 1, + endLine: 1, + text: 'run();', + ...(ownerId ? { ownerId } : {}), + }, + }, + ], + dbPath, + ), + ).toEqual({ inserted: 1, failed: 0 }); + } finally { + await adapter.initLbug(dbPath); + } + const rows = await adapter.executePrepared( + 'MATCH (b:BasicBlock {id: $id}) RETURN b.ownerId AS ownerId', + { id }, + ); + expect(rows[0]?.ownerId).toBe(ownerId ?? ''); + } + }, + ); }); diff --git a/gitnexus/test/integration/cfg/__snapshots__/pipeline-pdg.test.ts.snap b/gitnexus/test/integration/cfg/__snapshots__/pipeline-pdg.test.ts.snap index e36595271..4eef8f672 100644 --- a/gitnexus/test/integration/cfg/__snapshots__/pipeline-pdg.test.ts.snap +++ b/gitnexus/test/integration/cfg/__snapshots__/pipeline-pdg.test.ts.snap @@ -6,6 +6,7 @@ exports[`U7 — C-family worker-mode --pdg pipeline > C#: --pdg off is byte-iden "CALLS": 6, "DEFINES": 3, "HAS_METHOD": 16, + "HAS_PROPERTY": 2, "MEMBER_OF": 9, "STEP_IN_PROCESS": 3, }, @@ -17,10 +18,11 @@ exports[`U7 — C-family worker-mode --pdg pipeline > C#: --pdg off is byte-iden "Method": 15, "Namespace": 1, "Process": 1, + "Property": 2, }, - "edgeDigest": "60d3d5449952563f9364148494c5bce96c306dba921d140dc08176e99d7f2b3d", - "relationships": 37, - "symbols": 24, + "edgeDigest": "54b42d08be57dc994cddc3a452981925bb4f0ebc77a9a0a1f3235e3eeef389cd", + "relationships": 39, + "symbols": 26, } `; diff --git a/gitnexus/test/integration/cfg/new-provider-pipeline.test.ts b/gitnexus/test/integration/cfg/new-provider-pipeline.test.ts new file mode 100644 index 000000000..0c7ab40b5 --- /dev/null +++ b/gitnexus/test/integration/cfg/new-provider-pipeline.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { SupportedLanguages } from 'gitnexus-shared'; +import { isLanguageAvailable } from '../../../src/core/tree-sitter/parser-loader.js'; +import { isOptionalGrammarRequired } from '../../helpers/optional-grammar.js'; +import { runPipelineFromRepo } from '../../../src/core/ingestion/pipeline.js'; +import { + loadParseCache, + saveParseCache, + PARSE_CACHE_VERSION, +} from '../../../src/storage/parse-cache.js'; +import { + getDurableParsedFileDir, + pruneAndSaveDurableParsedFileStore, +} from '../../../src/storage/parsedfile-store.js'; +import type { PipelineResult } from '../../../src/types/pipeline.js'; +import { createTempDirPool } from '../../helpers/temp-dir-pool.js'; +import { _captureLogger } from '../../../src/core/logger.js'; + +const directories = createTempDirPool('new-provider-pdg-'); +const fixtures = { + 'model.m': `@interface Worker\n- (int)run:(int)input;\n@end\n@implementation Worker\n- (int)run:(int)input {\n int value = input;\n if (input) { value = 1; } else { value = 2; }\n return value;\n}\n@end\n`, + 'model.zig': `fn run(input: i32) i32 {\n var value: i32 = input;\n if (input > 0) { value = 1; } else { value = 2; }\n return value;\n}\n`, + 'model.cbl': ` >>SOURCE FORMAT FREE\nidentification division.\nprogram-id. Demo.\ndata division.\nworking-storage section.\n01 X PIC 9.\n01 FLAG PIC 9.\nprocedure division.\nmove 0 to x\nif flag = 1\nmove 1 to x\nelse\nmove 2 to x\nend-if\ndisplay x\ngoback.\nend program Demo.\n`, +}; +const workerOptions = { + workerPoolSize: 1, + workerThresholdsForTest: { minFiles: 1, minBytes: 1 }, + skipGraphPhases: true, +} as const; +const edgeTypes = new Set(['CFG', 'REACHING_DEF', 'CDG', 'POST_DOMINATE', 'TAINTED', 'SANITIZES']); + +function layer(result: PipelineResult) { + return { + nodes: [...result.graph.iterNodes()] + .filter((n) => n.label === 'BasicBlock') + .sort((a, b) => a.id.localeCompare(b.id)), + edges: [...result.graph.iterRelationships()] + .filter((e) => edgeTypes.has(e.type)) + .sort((a, b) => a.id.localeCompare(b.id)), + }; +} + +describe('new flow providers through production ingestion', () => { + it.skipIf( + !isLanguageAvailable(SupportedLanguages.Zig) && + !isOptionalGrammarRequired(SupportedLanguages.Zig), + )( + 'reports unsupported Zig CFGs through worker warning aggregation', + async () => { + const repo = directories.dir(); + await fs.writeFile( + path.join(repo, 'assembly.zig'), + ` + extern fn external() void; + fn normal() void { work(); } + fn hasAssembly() void { asm volatile ("nop"); } + `, + ); + const warning = _captureLogger(); + try { + const result = await runPipelineFromRepo(repo, () => {}, { ...workerOptions, pdg: true }); + expect(result.usedWorkerPool).toBe(true); + const blocks = layer(result).nodes; + expect(blocks.some((node) => node.properties.text?.includes('work()'))).toBe(true); + expect(warning.text()).toContain( + 'CFG functions skipped (zig): 0 too-deeply-nested, 1 build-error(s), 0 over line cap', + ); + } finally { + warning.restore(); + } + }, + 120_000, + ); + + for (const [file, content] of Object.entries(fixtures)) { + const test = + file === 'model.zig' && + !isLanguageAvailable(SupportedLanguages.Zig) && + !isOptionalGrammarRequired(SupportedLanguages.Zig) + ? it.skip + : it; + test(`${file} preserves CFG/CDG/data flow through cache and streaming`, async () => { + const repo = directories.dir(); + const storage = directories.dir(); + await fs.writeFile(path.join(repo, file), content); + const cache = await loadParseCache(storage); + const cold = await runPipelineFromRepo(repo, () => {}, { + ...workerOptions, + pdg: true, + parseCache: cache, + }); + const expected = layer(cold); + expect(expected.nodes.length).toBeGreaterThan(0); + for (const type of ['CFG', 'CDG', 'REACHING_DEF']) { + expect( + expected.edges.some((e) => e.type === type), + `${file}: ${type}`, + ).toBe(true); + } + const ids = new Set(expected.nodes.map((n) => n.id)); + for (const edge of expected.edges) { + expect(ids.has(edge.sourceId)).toBe(true); + expect(ids.has(edge.targetId)).toBe(true); + } + if (file === 'model.cbl') { + expect(new Set(expected.nodes.map((n) => n.properties.ownerId))).toEqual( + new Set(['Module:model.cbl:Demo']), + ); + expect(cold.graph.getNode('Module:model.cbl:Demo')).toBeDefined(); + } else { + expect(cold.usedWorkerPool).toBe(true); + } + + const savedKeys = await saveParseCache(storage, cache); + await pruneAndSaveDurableParsedFileStore( + getDurableParsedFileDir(storage), + PARSE_CACHE_VERSION, + new Set(savedKeys), + ); + const warm = await runPipelineFromRepo(repo, () => {}, { + ...workerOptions, + pdg: true, + parseCache: await loadParseCache(storage), + }); + expect(layer(warm)).toEqual(expected); + if (file !== 'model.cbl') { + expect(warm.parseCacheHitFileCount).toBe(1); + expect(warm.usedWorkerPool).toBe(false); + } + + const streamed = await runPipelineFromRepo(repo, () => {}, { + ...workerOptions, + pdg: true, + streamPdgEmit: true, + parseCache: await loadParseCache(storage), + }); + expect(layer(streamed)).toEqual({ nodes: [], edges: [] }); + const manifest = streamed.pdgEmitManifest!; + expect(manifest.nodeFiles.get('BasicBlock')?.rows).toBe(expected.nodes.length); + expect([...manifest.relsByPair.values()].reduce((total, item) => total + item.rows, 0)).toBe( + expected.edges.length, + ); + if (file === 'model.cbl') { + const csv = await fs.readFile(manifest.nodeFiles.get('BasicBlock')!.csvPath, 'utf8'); + expect(csv).toContain('ownerId'); + expect(csv).toContain('Module:model.cbl:Demo'); + } + + const off = await runPipelineFromRepo(repo, () => {}, { + ...workerOptions, + pdg: false, + parseCache: await loadParseCache(storage), + }); + expect(layer(off)).toEqual({ nodes: [], edges: [] }); + }, 120_000); + } +}); diff --git a/gitnexus/test/integration/cfg/worker-roundtrip.test.ts b/gitnexus/test/integration/cfg/worker-roundtrip.test.ts index 097191f97..7899bf72d 100644 --- a/gitnexus/test/integration/cfg/worker-roundtrip.test.ts +++ b/gitnexus/test/integration/cfg/worker-roundtrip.test.ts @@ -28,17 +28,18 @@ const tsVisitor = (): CfgVisitor => { return v; }; -describe('CFG provider gate — a cfgVisitor enables the worker CFG path', () => { - it('TS and JS providers carry a cfgVisitor', () => { - expect(getProvider(SupportedLanguages.TypeScript).cfgVisitor).toBeDefined(); - expect(getProvider(SupportedLanguages.JavaScript).cfgVisitor).toBeDefined(); - }); +describe('CFG provider gate', () => { + it.each(Object.values(SupportedLanguages))( + '%s provides AST or source-backed flow extraction', + (language) => { + const provider = getProvider(language); + expect(provider.cfgVisitor ?? provider.collectSourceCfgs).toBeDefined(); + }, + ); - it('a non-CFG language (COBOL) has no cfgVisitor ⇒ worker emits no cfgSideChannel', () => { - // `provider.cfgVisitor &&` short-circuits in the worker → no CFG, no field. - // COBOL is the deliberate non-goal of the PDG-language rollout (#2195) — - // every other supported language now carries a cfgVisitor. + it('standalone source extraction does not require a synthetic tree visitor', () => { expect(getProvider(SupportedLanguages.Cobol).cfgVisitor).toBeUndefined(); + expect(getProvider(SupportedLanguages.Cobol).collectSourceCfgs).toBeTypeOf('function'); }); }); diff --git a/gitnexus/test/integration/grammar-literal-validation.test.ts b/gitnexus/test/integration/grammar-literal-validation.test.ts index ffba56b6c..aef797cc2 100644 --- a/gitnexus/test/integration/grammar-literal-validation.test.ts +++ b/gitnexus/test/integration/grammar-literal-validation.test.ts @@ -152,6 +152,32 @@ describe('grammar literal validation gate', () => { expect(collected.nodeTypes.some((n) => n.literal.startsWith('@'))).toBe(false); }); + it('validates Objective-C provider and CFG helper literals against their actual grammar', () => { + const model = models.get(SupportedLanguages.ObjectiveC); + expect(model).toBeDefined(); + expect(model).not.toBeNull(); + expect( + validateNodeType(SupportedLanguages.ObjectiveC, model ?? null, 'message_expression'), + ).toBe('valid'); + for (const file of [ + 'cfg/visitors/objective-c.ts', + 'cfg/visitors/objective-c-ast.ts', + 'cfg/visitors/objective-c-harvest.ts', + 'languages/objective-c/rename.ts', + ]) { + const literals = collected.nodeTypes.filter((literal) => literal.file === file); + expect(literals.length, file).toBeGreaterThan(0); + expect( + literals.every( + (literal) => + literal.languages.length === 1 && + literal.languages[0] === SupportedLanguages.ObjectiveC, + ), + file, + ).toBe(true); + } + }); + it('validates a real node-scoped field and rejects a bogus one (Python)', () => { const model = loadGrammarModel(SupportedLanguages.Python); expect(validateField(model, 'name', 'function_definition')).toBe('valid'); diff --git a/gitnexus/test/integration/local-backend-calltool.test.ts b/gitnexus/test/integration/local-backend-calltool.test.ts index 6477a5fce..3763ddea5 100644 --- a/gitnexus/test/integration/local-backend-calltool.test.ts +++ b/gitnexus/test/integration/local-backend-calltool.test.ts @@ -6,8 +6,10 @@ * end-to-end against seeded graph data with FTS indexes. */ import fs from 'fs/promises'; -import { describe, it, expect, beforeAll, vi } from 'vitest'; +import path from 'path'; +import { describe, it, expect, beforeAll, beforeEach, vi } from 'vitest'; import { LocalBackend } from '../../src/mcp/local/local-backend.js'; +import { renameSymbol } from '../../src/mcp/local/rename/rename-plan.js'; import { listRegisteredRepos, saveMeta } from '../../src/storage/repo-manager.js'; import { withTestLbugDB } from '../helpers/test-indexed-db.js'; import { @@ -27,6 +29,13 @@ vi.mock('../../src/storage/repo-manager.js', async (importActual) => ({ findSiblingClones: vi.fn().mockResolvedValue([]), })); +// Pass-through spy: rename still plans for real, the test just reads the inventory +// LocalBackend.rename hands to the planner. +vi.mock('../../src/mcp/local/rename/rename-plan.js', async (importActual) => { + const actual = await importActual(); + return { ...actual, renameSymbol: vi.fn(actual.renameSymbol) }; +}); + // ─── Block 2: callTool dispatch tests ──────────────────────────────── withTestLbugDB( @@ -1053,3 +1062,100 @@ withTestLbugDB( }, }, ); + +// ─── rename node inventory on a --pdg index (PR #3557 review) ───────────── +// A --pdg index stores one BasicBlock row per CFG block, and BasicBlock has a +// filePath column, so an unlabelled `MATCH (n) WHERE n.filePath ...` inventory +// would hand every block to the planner, which adds each one to every fresh +// scope-resolution graph. The real database must answer the label predicate: +// the BasicBlock rows are dropped while File and declaration rows still arrive. +const INVENTORY_FILE = 'target.ts'; +const INVENTORY_FILE_ID = `File:${INVENTORY_FILE}`; +const INVENTORY_FUNCTION_ID = `Function:${INVENTORY_FILE}:target`; +const INVENTORY_BLOCK_IDS = [ + `BasicBlock:${INVENTORY_FILE}:0:0`, + `BasicBlock:${INVENTORY_FILE}:0:1`, +]; +const INVENTORY_SOURCE = 'function target() {}\ntarget();\n'; +const INVENTORY_PROBE = + "MATCH (n) WHERE n.filePath IS NOT NULL AND n.filePath <> '' RETURN n.id AS id ORDER BY n.id"; + +withTestLbugDB( + 'rename-node-inventory-pdg', + (handle) => { + describe('rename node inventory with persisted BasicBlock rows', () => { + let backend: LocalBackend; + + beforeAll(async () => { + const ext = handle as typeof handle & { _backend?: LocalBackend }; + if (!ext._backend) throw new Error('LocalBackend not initialized in afterSetup'); + backend = ext._backend; + // The planner lazily imports every language provider on first use (~30s + // cold); pay that under the hook timeout, not the per-test one. + await import('../../src/mcp/local/rename/graph-rename.js'); + }); + + beforeEach(() => { + vi.mocked(renameSymbol).mockClear(); + }); + + it('seeds BasicBlock rows that the unlabelled filePath scan would return', async () => { + const result = await backend.callTool('cypher', { query: INVENTORY_PROBE }); + const ids = (result.markdown as string).split('\n').join(' '); + for (const id of [INVENTORY_FILE_ID, INVENTORY_FUNCTION_ID, ...INVENTORY_BLOCK_IDS]) { + expect(ids).toContain(id); + } + }); + + it('hands the planner File and declaration rows but no BasicBlock rows', async () => { + const result = await backend.callTool('rename', { + symbol_uid: INVENTORY_FUNCTION_ID, + new_name: 'renamed', + dry_run: true, + }); + expect(result.status).toBe('success'); + expect(result.applied).toBe(false); + // The declaration and its one call site: unchanged by the inventory filter. + expect(result.total_edits).toBe(2); + const calls = vi.mocked(renameSymbol).mock.calls; + expect(calls).toHaveLength(1); + const inventoryIds = calls[0][3].map((node) => node.id).sort(); + expect(inventoryIds).toEqual([INVENTORY_FILE_ID, INVENTORY_FUNCTION_ID].sort()); + }); + }); + }, + { + seed: [ + `CREATE (:File {id: '${INVENTORY_FILE_ID}', name: '${INVENTORY_FILE}', filePath: '${INVENTORY_FILE}'})`, + `CREATE (:Function {id: '${INVENTORY_FUNCTION_ID}', name: 'target', filePath: '${INVENTORY_FILE}', startLine: 0, endLine: 0})`, + ...INVENTORY_BLOCK_IDS.map( + (id, index) => + `CREATE (:BasicBlock {id: '${id}', filePath: '${INVENTORY_FILE}', startLine: ${index}, endLine: ${index}, text: 'target()', callees: '', calleeIds: '', ownerId: '${INVENTORY_FUNCTION_ID}'})`, + ), + ], + poolAdapter: true, + afterSetup: async (handle) => { + const checkout = path.join(handle.tmpHandle.dbPath, 'checkout'); + await fs.mkdir(checkout, { recursive: true }); + await fs.writeFile(path.join(checkout, INVENTORY_FILE), INVENTORY_SOURCE); + const indexedAt = new Date().toISOString(); + await fs.writeFile( + path.join(handle.tmpHandle.dbPath, 'gitnexus.json'), + JSON.stringify({ contentRetention: 'full', indexedAt }), + ); + vi.mocked(listRegisteredRepos).mockResolvedValue([ + { + name: 'rename-inventory-repo', + path: checkout, + storagePath: handle.tmpHandle.dbPath, + indexedAt, + lastCommit: 'abc123', + stats: { files: 1, nodes: 4, communities: 0, processes: 0 }, + }, + ]); + const backend = new LocalBackend(); + await backend.init(); + (handle as typeof handle & { _backend: LocalBackend })._backend = backend; + }, + }, +); diff --git a/gitnexus/test/integration/pdg-explicit-owner.test.ts b/gitnexus/test/integration/pdg-explicit-owner.test.ts new file mode 100644 index 000000000..f3c196069 --- /dev/null +++ b/gitnexus/test/integration/pdg-explicit-owner.test.ts @@ -0,0 +1,203 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { runFullAnalysis } from '../../src/core/run-analyze.js'; +import * as adapter from '../../src/core/lbug/lbug-adapter.js'; +import * as pool from '../../src/core/lbug/pool-adapter.js'; +import { LocalBackend } from '../../src/mcp/local/local-backend.js'; +import { getStoragePaths, listRegisteredRepos, loadMeta } from '../../src/storage/repo-manager.js'; +import { createTempDir } from '../helpers/test-db.js'; + +const fixture = fileURLToPath( + new URL('../fixtures/pdg-explicit-owner/program.cbl', import.meta.url), +); +const repoId = 'pdg-owner'; +const filePath = 'program.cbl'; +const outer = 'Module:program.cbl:Outer'; +const inner = 'Module:program.cbl:Inner'; +const savedEnvironment = new Map(); +let temporary: Awaited>; +let backend: LocalBackend; +let db: string; +let blocks: Array<{ id: string; ownerId: string; line: number; text: string | null }>; + +beforeAll(async () => { + temporary = await createTempDir(); + const root = path.join(temporary.dbPath, 'repo'); + await fs.mkdir(root); + for (const [name, value] of Object.entries({ + GITNEXUS_HOME: path.join(temporary.dbPath, 'home'), + GITNEXUS_SHARED_STORE: 'off', + GITNEXUS_INDEX_LOCK_BACKEND: 'file', + GITNEXUS_STORAGE_PATH: undefined, + GITNEXUS_STORAGE_ROOT: undefined, + })) { + savedEnvironment.set(name, process.env[name]); + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + } + await fs.copyFile(fixture, path.join(root, filePath)); + execFileSync('git', ['init', '-q'], { cwd: root }); + execFileSync('git', ['add', filePath], { cwd: root }); + execFileSync( + 'git', + [ + '-c', + 'user.name=test', + '-c', + 'user.email=test@test', + '-c', + 'commit.gpgsign=false', + 'commit', + '-qm', + 'PDG ownership fixture', + ], + { cwd: root }, + ); + await runFullAnalysis( + root, + { + pdg: true, + skipFts: true, + skipAgentsMd: true, + skipSkills: true, + workerPoolSize: 1, + registryName: repoId, + }, + { onProgress: () => {} }, + ); + const storage = getStoragePaths(root); + db = storage.lbugPath; + expect((await loadMeta(storage.storagePath))?.pdg).toBeDefined(); + expect((await listRegisteredRepos()).map((entry) => entry.name)).toEqual([repoId]); + await adapter.initLbug(db, { skipFts: true }); + blocks = await adapter.executeQuery( + 'MATCH (b:BasicBlock) RETURN b.id AS id, b.ownerId AS ownerId, b.startLine AS line, b.text AS text ORDER BY b.startLine, b.id', + ); + await pool.initLbugWithDb(repoId, adapter.getDatabase()!, db); + backend = new LocalBackend(); + await backend.init(); +}, 180_000); + +afterAll(async () => { + await backend?.dispose(); + await pool.closeLbug(repoId); + await adapter.closeLbug(); + for (const [name, value] of savedEnvironment) { + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + } + await temporary?.cleanup(); +}); + +const impact = (target: string, line?: number) => + backend.callTool('impact', { + repo: repoId, + target, + mode: 'pdg', + direction: 'downstream', + maxDepth: 1, + line, + }); + +describe('explicit executable ownership from real indexing', () => { + it('persists separate owners for nested COBOL programs', () => { + expect(new Set(blocks.map((block) => block.ownerId))).toEqual(new Set([outer, inner])); + expect( + blocks + .filter((block) => block.ownerId === outer) + .some((block) => block.text?.includes('INNER-TOTAL')), + ).toBe(false); + expect( + blocks + .filter((block) => block.ownerId === inner) + .some((block) => block.text?.includes('OUTER-TOTAL')), + ).toBe(false); + }); + + it('excludes a nested executable owner from its enclosing source span', async () => { + const result = await impact('Outer'); + expect(result.error).toBeUndefined(); + expect([...result.seedBlocks].sort()).toEqual( + blocks + .filter((block) => block.ownerId === outer) + .map((block) => block.id) + .sort(), + ); + }); + + it('does not seed a nested program statement for its enclosing program', async () => { + expect(await impact('Outer', 25)).toMatchObject({ + seedBlocks: [], + epistemic: 'pdg-no-block-at-line', + }); + }); + + it('retains paragraph selection and marks its program dependencies intra-procedural', async () => { + const result = await impact('main', 10); + expect(result.error).toBeUndefined(); + expect(result.seedBlocks.length).toBeGreaterThan(0); + expect(result.affectedStatements.length).toBeGreaterThan(0); + expect( + result.affectedStatements.every( + (statement: { scope: string; text: string }) => + statement.scope === 'intra' && !statement.text.includes('INNER-TOTAL'), + ), + ).toBe(true); + }); + + it.each(['controls', 'flows'])( + 'public %s queries exclude nested program dependencies', + async (mode) => { + const result = await backend.callTool('pdg_query', { repo: repoId, mode, target: 'Outer' }); + expect(result.error).toBeUndefined(); + expect(result.total).toBeGreaterThan(0); + expect(JSON.stringify(result.results)).toContain('OUTER'); + expect(JSON.stringify(result.results)).not.toContain('INNER'); + const nested = await backend.callTool('pdg_query', { repo: repoId, mode, target: 'Inner' }); + expect(nested.total).toBeGreaterThan(0); + expect(JSON.stringify(nested.results)).toContain('INNER'); + expect(JSON.stringify(nested.results)).not.toContain('OUTER'); + const fileResult = await backend.callTool('pdg_query', { + repo: repoId, + mode, + target: filePath, + }); + expect(fileResult.total).toBe(result.total + nested.total); + const paragraph = await backend.callTool('pdg_query', { repo: repoId, mode, target: 'main' }); + expect(paragraph.total).toBe(result.total); + }, + ); + + it('public explain reads the indexed program without fabricating taint findings', async () => { + const result = await backend.callTool('explain', { repo: repoId, target: 'Outer' }); + expect(result.error).toBeUndefined(); + expect(result.totalFindings).toBe(0); + }); + + it('rejects a ready legacy PDG index with explicit schema recovery guidance', async () => { + await adapter.executeQuery('ALTER TABLE BasicBlock DROP ownerId'); + try { + for (const line of [undefined, 10]) { + expect(await impact('main', line)).toMatchObject({ + impactedCount: null, + recoverySuggestion: expect.stringContaining('gitnexus analyze --pdg'), + }); + } + for (const tool of ['pdg_query', 'explain']) { + const result = await backend.callTool(tool, { + repo: repoId, + mode: 'flows', + target: 'Outer', + }); + expect(result.recoverySuggestion).toContain('gitnexus analyze --pdg'); + } + const columns = await adapter.executeQuery("CALL table_info('BasicBlock') RETURN name"); + expect(columns.some((row) => row.name === 'ownerId')).toBe(false); + } finally { + await adapter.executeQuery('ALTER TABLE BasicBlock ADD ownerId STRING'); + } + }); +}); diff --git a/gitnexus/test/integration/rename-planning-safety.test.ts b/gitnexus/test/integration/rename-planning-safety.test.ts new file mode 100644 index 000000000..2389aaf7b --- /dev/null +++ b/gitnexus/test/integration/rename-planning-safety.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest'; +import { execFile } from 'node:child_process'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; + +const runner = fileURLToPath( + new URL('../helpers/rename-planning-safety-runner.mjs', import.meta.url), +); +const execute = promisify(execFile); + +describe('persisted rename planning safety (no mocks)', () => { + it.each([ + ['unsupported-encoding', 'unsupported_encoding'], + ['symlink-escape', 'outside_repository'], + ])( + 'refuses %s through preview and apply without changing source bytes', + async (scenario, code) => { + const temporary = await fs.mkdtemp(path.join(os.tmpdir(), 'rename-planning-safety-')); + const report = path.join(temporary, 'report.json'); + const env: NodeJS.ProcessEnv = { + ...process.env, + GITNEXUS_HOME: path.join(temporary, 'home'), + GITNEXUS_STORAGE_PATH: path.join(temporary, 'index'), + GITNEXUS_SHARED_STORE: 'off', + GITNEXUS_MEMORY: 'off', + GITNEXUS_INDEX_LOCK_BACKEND: 'file', + GITNEXUS_CONTENT_RETENTION: 'full', + GITNEXUS_LBUG_EXTENSION_INSTALL: 'off', + GITNEXUS_WORKER_READY_TIMEOUT_MS: '15000', + }; + delete env.CODEX_HOME; + delete env.GITNEXUS_STORAGE_ROOT; + try { + try { + await execute(process.execPath, [runner, scenario, temporary, report], { + cwd: temporary, + env, + maxBuffer: 8 * 1024 * 1024, + }); + } catch (error) { + throw new Error(await fs.readFile(report, 'utf8').catch(() => String(error)), { + cause: error, + }); + } + const result = JSON.parse(await fs.readFile(report, 'utf8')); + expect(result).toMatchObject({ status: 'passed', code }); + expect(result.nodes).toBeGreaterThan(0); + } finally { + await fs.rm(temporary, { recursive: true, force: true }); + } + }, + 180_000, + ); +}); diff --git a/gitnexus/test/integration/rename-provider-c.test.ts b/gitnexus/test/integration/rename-provider-c.test.ts new file mode 100644 index 000000000..df48eae8b --- /dev/null +++ b/gitnexus/test/integration/rename-provider-c.test.ts @@ -0,0 +1,3 @@ +import { defineRenameFixtureTests } from '../helpers/rename-integration-cases.js'; + +defineRenameFixtureTests('c'); diff --git a/gitnexus/test/integration/rename-provider-cobol.test.ts b/gitnexus/test/integration/rename-provider-cobol.test.ts new file mode 100644 index 000000000..d766de5c8 --- /dev/null +++ b/gitnexus/test/integration/rename-provider-cobol.test.ts @@ -0,0 +1,3 @@ +import { defineRenameFixtureTests } from '../helpers/rename-integration-cases.js'; + +defineRenameFixtureTests('cobol'); diff --git a/gitnexus/test/integration/rename-provider-cpp.test.ts b/gitnexus/test/integration/rename-provider-cpp.test.ts new file mode 100644 index 000000000..ecd3cd1e9 --- /dev/null +++ b/gitnexus/test/integration/rename-provider-cpp.test.ts @@ -0,0 +1,3 @@ +import { defineRenameFixtureTests } from '../helpers/rename-integration-cases.js'; + +defineRenameFixtureTests('cpp'); diff --git a/gitnexus/test/integration/rename-provider-csharp.test.ts b/gitnexus/test/integration/rename-provider-csharp.test.ts new file mode 100644 index 000000000..2e7fdff9a --- /dev/null +++ b/gitnexus/test/integration/rename-provider-csharp.test.ts @@ -0,0 +1,3 @@ +import { defineRenameFixtureTests } from '../helpers/rename-integration-cases.js'; + +defineRenameFixtureTests('csharp'); diff --git a/gitnexus/test/integration/rename-provider-dart.test.ts b/gitnexus/test/integration/rename-provider-dart.test.ts new file mode 100644 index 000000000..1f352e466 --- /dev/null +++ b/gitnexus/test/integration/rename-provider-dart.test.ts @@ -0,0 +1,3 @@ +import { defineRenameFixtureTests } from '../helpers/rename-integration-cases.js'; + +defineRenameFixtureTests('dart'); diff --git a/gitnexus/test/integration/rename-provider-go.test.ts b/gitnexus/test/integration/rename-provider-go.test.ts new file mode 100644 index 000000000..1cf2bd4cb --- /dev/null +++ b/gitnexus/test/integration/rename-provider-go.test.ts @@ -0,0 +1,3 @@ +import { defineRenameFixtureTests } from '../helpers/rename-integration-cases.js'; + +defineRenameFixtureTests('go'); diff --git a/gitnexus/test/integration/rename-provider-java.test.ts b/gitnexus/test/integration/rename-provider-java.test.ts new file mode 100644 index 000000000..704896cc4 --- /dev/null +++ b/gitnexus/test/integration/rename-provider-java.test.ts @@ -0,0 +1,3 @@ +import { defineRenameFixtureTests } from '../helpers/rename-integration-cases.js'; + +defineRenameFixtureTests('java'); diff --git a/gitnexus/test/integration/rename-provider-javascript.test.ts b/gitnexus/test/integration/rename-provider-javascript.test.ts new file mode 100644 index 000000000..2d0230b12 --- /dev/null +++ b/gitnexus/test/integration/rename-provider-javascript.test.ts @@ -0,0 +1,3 @@ +import { defineRenameFixtureTests } from '../helpers/rename-integration-cases.js'; + +defineRenameFixtureTests('javascript'); diff --git a/gitnexus/test/integration/rename-provider-kotlin.test.ts b/gitnexus/test/integration/rename-provider-kotlin.test.ts new file mode 100644 index 000000000..cccbca1b3 --- /dev/null +++ b/gitnexus/test/integration/rename-provider-kotlin.test.ts @@ -0,0 +1,3 @@ +import { defineRenameFixtureTests } from '../helpers/rename-integration-cases.js'; + +defineRenameFixtureTests('kotlin'); diff --git a/gitnexus/test/integration/rename-provider-matrix.test.ts b/gitnexus/test/integration/rename-provider-matrix.test.ts new file mode 100644 index 000000000..e261c514f --- /dev/null +++ b/gitnexus/test/integration/rename-provider-matrix.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import { SupportedLanguages } from 'gitnexus-shared'; +import { renameCases } from '../helpers/rename-integration-cases.js'; + +const fixtureLanguages = [...new Set(renameCases.map((entry) => entry.language))].sort(); + +describe('persisted semantic rename coverage', () => { + it('has successful rename fixtures for every supported language', () => { + expect( + new Set( + renameCases.filter((entry) => entry.status === 'success').map((entry) => entry.language), + ), + ).toEqual(new Set(Object.values(SupportedLanguages))); + }); + + // Fixtures only run through a per-language file (it also shards CI), so a missing or + // mis-targeted file would leave its fixtures silently unexecuted. + it.each(fixtureLanguages)( + 'runs the persisted fixtures of %s from its own rename-provider file', + async (language) => { + const file = new URL(`./rename-provider-${language}.test.ts`, import.meta.url); + const source = (await fs.readFile(fileURLToPath(file), 'utf8')) + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/\/\/.*$/gm, ''); + expect(source).toMatch( + new RegExp(`\\bdefineRenameFixtureTests\\(\\s*(['"\`])${language}\\1\\s*\\)`), + ); + }, + ); +}); diff --git a/gitnexus/test/integration/rename-provider-objective-c.test.ts b/gitnexus/test/integration/rename-provider-objective-c.test.ts new file mode 100644 index 000000000..1c9835053 --- /dev/null +++ b/gitnexus/test/integration/rename-provider-objective-c.test.ts @@ -0,0 +1,3 @@ +import { defineRenameFixtureTests } from '../helpers/rename-integration-cases.js'; + +defineRenameFixtureTests('objective-c'); diff --git a/gitnexus/test/integration/rename-provider-php.test.ts b/gitnexus/test/integration/rename-provider-php.test.ts new file mode 100644 index 000000000..48b8420b5 --- /dev/null +++ b/gitnexus/test/integration/rename-provider-php.test.ts @@ -0,0 +1,3 @@ +import { defineRenameFixtureTests } from '../helpers/rename-integration-cases.js'; + +defineRenameFixtureTests('php'); diff --git a/gitnexus/test/integration/rename-provider-python.test.ts b/gitnexus/test/integration/rename-provider-python.test.ts new file mode 100644 index 000000000..dd71c0f0a --- /dev/null +++ b/gitnexus/test/integration/rename-provider-python.test.ts @@ -0,0 +1,3 @@ +import { defineRenameFixtureTests } from '../helpers/rename-integration-cases.js'; + +defineRenameFixtureTests('python'); diff --git a/gitnexus/test/integration/rename-provider-ruby.test.ts b/gitnexus/test/integration/rename-provider-ruby.test.ts new file mode 100644 index 000000000..14c669359 --- /dev/null +++ b/gitnexus/test/integration/rename-provider-ruby.test.ts @@ -0,0 +1,3 @@ +import { defineRenameFixtureTests } from '../helpers/rename-integration-cases.js'; + +defineRenameFixtureTests('ruby'); diff --git a/gitnexus/test/integration/rename-provider-rust.test.ts b/gitnexus/test/integration/rename-provider-rust.test.ts new file mode 100644 index 000000000..9d6db6e2f --- /dev/null +++ b/gitnexus/test/integration/rename-provider-rust.test.ts @@ -0,0 +1,3 @@ +import { defineRenameFixtureTests } from '../helpers/rename-integration-cases.js'; + +defineRenameFixtureTests('rust'); diff --git a/gitnexus/test/integration/rename-provider-swift.test.ts b/gitnexus/test/integration/rename-provider-swift.test.ts new file mode 100644 index 000000000..2f49ff9f4 --- /dev/null +++ b/gitnexus/test/integration/rename-provider-swift.test.ts @@ -0,0 +1,3 @@ +import { defineRenameFixtureTests } from '../helpers/rename-integration-cases.js'; + +defineRenameFixtureTests('swift'); diff --git a/gitnexus/test/integration/rename-provider-typescript.test.ts b/gitnexus/test/integration/rename-provider-typescript.test.ts new file mode 100644 index 000000000..f328e3992 --- /dev/null +++ b/gitnexus/test/integration/rename-provider-typescript.test.ts @@ -0,0 +1,3 @@ +import { defineRenameFixtureTests } from '../helpers/rename-integration-cases.js'; + +defineRenameFixtureTests('typescript'); diff --git a/gitnexus/test/integration/rename-provider-vue.test.ts b/gitnexus/test/integration/rename-provider-vue.test.ts new file mode 100644 index 000000000..47c10ddeb --- /dev/null +++ b/gitnexus/test/integration/rename-provider-vue.test.ts @@ -0,0 +1,3 @@ +import { defineRenameFixtureTests } from '../helpers/rename-integration-cases.js'; + +defineRenameFixtureTests('vue'); diff --git a/gitnexus/test/integration/rename-provider-zig.test.ts b/gitnexus/test/integration/rename-provider-zig.test.ts new file mode 100644 index 000000000..f9c07205a --- /dev/null +++ b/gitnexus/test/integration/rename-provider-zig.test.ts @@ -0,0 +1,3 @@ +import { defineRenameFixtureTests } from '../helpers/rename-integration-cases.js'; + +defineRenameFixtureTests('zig'); diff --git a/gitnexus/test/integration/resolvers/cpp.test.ts b/gitnexus/test/integration/resolvers/cpp.test.ts index 47899b348..4ede546de 100644 --- a/gitnexus/test/integration/resolvers/cpp.test.ts +++ b/gitnexus/test/integration/resolvers/cpp.test.ts @@ -2888,7 +2888,7 @@ describe('C++ ADL — base-class associated namespaces', () => { expect(logCalls.length).toBe(1); expect(logCalls[0].targetFilePath).toContain('base_lib.h'); const targetNode = result.graph.getNode(logCalls[0].rel.targetId); - expect(logCalls[0].rel.targetId).toBe('Function:base_lib.h:log'); + expect(logCalls[0].rel.targetId).toBe('Function:base_lib.h:log#1~Base'); expect(targetNode?.properties.parameterTypes).toEqual(['Base']); }); @@ -2898,7 +2898,7 @@ describe('C++ ADL — base-class associated namespaces', () => { expect(traceCalls.length).toBe(1); expect(traceCalls[0].targetFilePath).toContain('base_lib.h'); const targetNode = result.graph.getNode(traceCalls[0].rel.targetId); - expect(traceCalls[0].rel.targetId).toBe('Function:base_lib.h:trace'); + expect(traceCalls[0].rel.targetId).toBe('Function:base_lib.h:trace#1~Root'); expect(targetNode?.properties.parameterTypes).toEqual(['Root']); }); @@ -2908,7 +2908,7 @@ describe('C++ ADL — base-class associated namespaces', () => { expect(pingCalls.length).toBe(1); expect(pingCalls[0].targetFilePath).toContain('base_lib.h'); const targetNode = result.graph.getNode(pingCalls[0].rel.targetId); - expect(pingCalls[0].rel.targetId).toBe('Function:base_lib.h:ping'); + expect(pingCalls[0].rel.targetId).toBe('Function:base_lib.h:ping#1~DiamondBase'); expect(targetNode?.properties.parameterTypes).toEqual(['DiamondBase']); }); }); diff --git a/gitnexus/test/integration/resolvers/go-namespace-member-visibility.test.ts b/gitnexus/test/integration/resolvers/go-namespace-member-visibility.test.ts new file mode 100644 index 000000000..00637960e --- /dev/null +++ b/gitnexus/test/integration/resolvers/go-namespace-member-visibility.test.ts @@ -0,0 +1,167 @@ +/** + * Go — namespace-qualified references honour the export rule, through the REAL + * pipeline (tree-sitter extraction + import resolution + every resolution pass). + * + * Go exports an identifier only when it begins with an uppercase letter, so + * `api.target()` from another package is a compile error whatever import + * spelling introduced `api`. The shared namespace lookups used to treat every + * module-scope declaration of the target package as a public member and minted + * a confident `import-resolved` edge to it anyway; `ScopeResolver + * .isNamespaceMemberVisible` now refuses the unexported ones. These cases pin + * both directions: the invalid qualified references get NO edge, and every + * legitimate way of reaching a declaration keeps its edge. + * + * (`api.target()` does not compile in real Go. Such edges arise only in invalid + * code, which is exactly why a rename that unexports `api.Target` must not have + * its re-resolution certified by one — see `rename-go-reresolution.test.ts`.) + */ +import { describe, it, expect, beforeAll } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { getRelationships, writeFixtureRepo, type PipelineResult } from './helpers.js'; +import { runPipelineFromRepo } from '../../../src/core/ingestion/pipeline.js'; + +const API = 'example.com/nsvis/api'; + +describe('Go namespace-qualified member visibility (real pipeline)', () => { + let result: PipelineResult; + + const targetsOf = (type: 'CALLS' | 'ACCESSES', source: string): string[] => + getRelationships(result, type) + .filter((edge) => edge.source === source) + .map((edge) => edge.target) + .sort(); + + beforeAll(async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-go-nsvis-')); + writeFixtureRepo(dir, { + 'go.mod': 'module example.com/nsvis\n\ngo 1.21\n', + 'api/api.go': [ + 'package api', + '', + 'func target() int { return 1 }', + '', + 'func Target() int { return 2 }', + '', + 'type thing struct{}', + '', + 'type Thing struct{}', + '', + 'var hidden = 1', + '', + 'var Shown = 2', + '', + 'func helper() *Thing { return &Thing{} }', + '', + 'func Maker() *Thing { return &Thing{} }', + '', + 'func (t *Thing) Pub() {}', + '', + 'func SameFile() int { return target() }', + '', + ].join('\n'), + // Same package, other file: unexported names are visible UNQUALIFIED. + 'api/sibling.go': ['package api', '', 'func SiblingUse() int { return target() }', ''].join( + '\n', + ), + // External test package of the same directory: a different package. + 'api/api_test.go': [ + 'package api_test', + '', + `import "${API}"`, + '', + 'func TestExtUpper() int { return api.Target() }', + '', + 'func TestExtLower() int { return api.target() }', + '', + ].join('\n'), + 'main/main.go': [ + 'package main', + '', + 'import (', + `\t"${API}"`, + `\tshim "${API}"`, + ')', + '', + 'func CallLower() int { return api.target() }', + '', + 'func CallUpper() int { return api.Target() }', + '', + 'func AliasLower() int { return shim.target() }', + '', + 'func AliasUpper() int { return shim.Target() }', + '', + 'func MakeLower() { _ = api.thing{} }', + '', + 'func MakeUpper() { _ = api.Thing{} }', + '', + 'func ReadLower() int { return api.hidden }', + '', + 'func ReadUpper() int { return api.Shown }', + '', + 'func ChainLower() { api.helper().Pub() }', + '', + 'func ChainUpper() { api.Maker().Pub() }', + '', + ].join('\n'), + // A dot import brings only the EXPORTED names into scope. + 'dot/dot.go': [ + 'package dot', + '', + `import . "${API}"`, + '', + 'func DotUpper() int { return Target() }', + '', + 'func DotLower() int { return target() }', + '', + ].join('\n'), + }); + result = await runPipelineFromRepo(dir, () => {}); + }, 60000); + + it('resolves an exported function through the package qualifier at import-resolved confidence', () => { + const edges = getRelationships(result, 'CALLS').filter((edge) => edge.source === 'CallUpper'); + expect(edges.map((edge) => edge.target)).toEqual(['Target']); + expect(edges[0]!.rel.reason).toBe('import-resolved'); + }); + + it('gives an unexported function reached through the package qualifier no edge at all', () => { + expect(targetsOf('CALLS', 'CallLower')).toEqual([]); + }); + + it('keeps the exported/unexported split under an import alias', () => { + expect(targetsOf('CALLS', 'AliasUpper')).toEqual(['Target']); + expect(targetsOf('CALLS', 'AliasLower')).toEqual([]); + }); + + it('keeps the exported/unexported split in an external test package of the same directory', () => { + expect(targetsOf('CALLS', 'TestExtUpper')).toEqual(['Target']); + expect(targetsOf('CALLS', 'TestExtLower')).toEqual([]); + }); + + it('keeps the exported/unexported split for a qualified composite literal', () => { + expect(targetsOf('CALLS', 'MakeUpper')).toEqual(['Thing']); + expect(targetsOf('CALLS', 'MakeLower')).toEqual([]); + }); + + it('keeps the exported/unexported split for a qualified package-variable read', () => { + expect(targetsOf('ACCESSES', 'ReadUpper')).toEqual(['Shown']); + expect(targetsOf('ACCESSES', 'ReadLower')).toEqual([]); + }); + + it('does not type a method chain from an unexported function reached through the qualifier', () => { + expect(targetsOf('CALLS', 'ChainUpper')).toContain('Maker'); + expect(targetsOf('CALLS', 'ChainLower')).toEqual([]); + }); + + it('still resolves unexported names UNQUALIFIED inside their own package, across files', () => { + expect(targetsOf('CALLS', 'SameFile')).toEqual(['target']); + expect(targetsOf('CALLS', 'SiblingUse')).toEqual(['target']); + }); + + it('resolves only the exported name through a dot import', () => { + expect(targetsOf('CALLS', 'DotUpper')).toEqual(['Target']); + expect(targetsOf('CALLS', 'DotLower')).toEqual([]); + }); +}); diff --git a/gitnexus/test/integration/resolvers/java-lombok.test.ts b/gitnexus/test/integration/resolvers/java-lombok.test.ts index 8d3091242..af8c571a9 100644 --- a/gitnexus/test/integration/resolvers/java-lombok.test.ts +++ b/gitnexus/test/integration/resolvers/java-lombok.test.ts @@ -42,6 +42,43 @@ function projectCallsToGetName(result: Awaited { + it.each(['same-file', 'cross-file'] as const)( + 'resolves all getter and setter calls to their owner in a %s fixture', + async (layout) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-java-lombok-pair-')); + try { + const models = `import lombok.Data; +@Data class Pair { private int first, second; } +@Data class Decoy { private int first, second; } +`; + const caller = `class Consumer { + void update(Pair pair) { + pair.setFirst(pair.getSecond()); + pair.setSecond(pair.getFirst()); + } +} +`; + writeFixtureRepo( + root, + layout === 'same-file' + ? { 'Models.java': models + caller } + : { 'Models.java': models, 'Consumer.java': caller }, + ); + const linked = await runPipelineFromRepo(root, () => {}); + const calls = getRelationships(linked, 'CALLS').filter((edge) => edge.source === 'update'); + expect(calls.map((edge) => edge.rel.targetId).sort()).toEqual([ + 'Method:Models.java:Pair.getFirst#0', + 'Method:Models.java:Pair.getSecond#0', + 'Method:Models.java:Pair.setFirst#1', + 'Method:Models.java:Pair.setSecond#1', + ]); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }, + 60000, + ); + it('resolves Consumer.consume -> User.getName for @Data', async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-java-lombok-')); try { diff --git a/gitnexus/test/integration/resolvers/kotlin-jvm-accessors.test.ts b/gitnexus/test/integration/resolvers/kotlin-jvm-accessors.test.ts index 46920168b..0fb291f88 100644 --- a/gitnexus/test/integration/resolvers/kotlin-jvm-accessors.test.ts +++ b/gitnexus/test/integration/resolvers/kotlin-jvm-accessors.test.ts @@ -13,6 +13,40 @@ import { } from './helpers.js'; describe('Kotlin JVM accessor synthesis (pipeline)', () => { + it.each(['same-file', 'cross-file'] as const)( + 'resolves mutable-property getters and setters to their owner in a %s fixture', + async (layout) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-kt-jvm-mutable-')); + try { + const models = `class Pair(var first: Int, var second: Int) +class Decoy(var first: Int, var second: Int) +`; + const caller = `fun update(pair: Pair) { + pair.setFirst(pair.getSecond()) + pair.setSecond(pair.getFirst()) +} +`; + writeFixtureRepo( + root, + layout === 'same-file' + ? { 'Models.kt': models + caller } + : { 'Models.kt': models, 'Consumer.kt': caller }, + ); + const linked = await runPipelineFromRepo(root, () => {}); + const calls = getRelationships(linked, 'CALLS').filter((edge) => edge.source === 'update'); + expect(calls.map((edge) => edge.rel.targetId).sort()).toEqual([ + 'Method:Models.kt:Pair.getFirst#0', + 'Method:Models.kt:Pair.getSecond#0', + 'Method:Models.kt:Pair.setFirst#1', + 'Method:Models.kt:Pair.setSecond#1', + ]); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }, + 60000, + ); + it('emits generated and custom getters and resolves same-language calls', async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-kt-jvm-acc-')); try { diff --git a/gitnexus/test/integration/resolvers/ruby-accessor-dispatch.test.ts b/gitnexus/test/integration/resolvers/ruby-accessor-dispatch.test.ts new file mode 100644 index 000000000..1d22c069d --- /dev/null +++ b/gitnexus/test/integration/resolvers/ruby-accessor-dispatch.test.ts @@ -0,0 +1,53 @@ +import path from 'node:path'; +import { beforeAll, describe, expect, it } from 'vitest'; +import { FIXTURES, getRelationships, runPipelineFromRepo, type PipelineResult } from './helpers.js'; + +describe('Ruby attribute dispatch through the production pipeline', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-accessor-dispatch'), () => {}, { + workerPoolSize: 1, + }); + }, 60000); + + it('resolves local, inherited and mixed-in reader calls without crossing singleton boundaries', () => { + // Ruby attributes are synthesized methods: their reads use CALLS to the + // Property node, while setter assignments separately produce ACCESSES. + const reads = getRelationships(result, 'CALLS') + .filter((edge) => edge.targetLabel === 'Property' && edge.source !== 'write_attributes') + .map((edge) => `${edge.source}->${edge.target}`) + .sort(); + expect(reads).toEqual([ + 'class_access->class_name', + 'instance_access->inherited', + 'instance_access->name', + 'read_attributes->class_name', + 'read_attributes->inherited', + 'read_attributes->module_label', + 'read_attributes->name', + 'read_attributes->slot', + ]); + }); + + it('keeps setter writes and their existing access relationship contract', () => { + const writes = getRelationships(result, 'ACCESSES').map((edge) => ({ + source: edge.source, + target: edge.target, + reason: edge.rel.reason, + confidence: edge.rel.confidence, + })); + expect(writes.sort((left, right) => left.target.localeCompare(right.target))).toEqual([ + { source: 'write_attributes', target: 'inherited', reason: 'write', confidence: 1 }, + { source: 'write_attributes', target: 'name', reason: 'write', confidence: 1 }, + ]); + }); + + it('lets a child reader hide its parent method and refuses invalid reader arity', () => { + const calls = getRelationships(result, 'CALLS'); + expect(calls.filter((edge) => edge.target === 'slot').map((edge) => edge.targetLabel)).toEqual([ + 'Property', + ]); + expect(calls.filter((edge) => edge.source === 'invalid_reader_arity')).toEqual([]); + }); +}); diff --git a/gitnexus/test/integration/resolvers/typescript.test.ts b/gitnexus/test/integration/resolvers/typescript.test.ts index 78c93c467..4046165ba 100644 --- a/gitnexus/test/integration/resolvers/typescript.test.ts +++ b/gitnexus/test/integration/resolvers/typescript.test.ts @@ -3317,10 +3317,8 @@ describe('TypeScript inline constructor receiver resolution', () => { // method call. Anything measuring resolver gaps has to tell those apart, and // the site kind is the only authoritative signal for it. // -// Both shapes below are empirically confirmed drops: `!` produces a reference -// site that reaches Case 0. (`?.` and explicit type arguments do NOT record a -// drop at all — they are invisible to this recorder, which is a property of the -// capture layer, not of this field.) +// Both negative cases use untyped receivers, so their drops do not depend on +// an unsupported expression shape. A typed write is the positive control. // --------------------------------------------------------------------------- describe('TypeScript receiver-unresolved drops carry their site kind (#2744)', () => { @@ -3359,9 +3357,13 @@ export async function droppedCall(svc): Promise { svc.getUser().save(); } -export function droppedWrite(svc: Service | null): void { - // A write receiver mints no chain at all (the emitter gates on CALL_TAGS), - // so this keeps dropping and stays separable from the call above. +export function droppedWrite(svc): void { + // The unannotated receiver has no type proof, even though writes now + // carry the same structural receiver chains as calls. + svc!.getUser().name = 'x'; +} + +export function resolvedWrite(svc: Service | null): void { svc!.getUser().name = 'x'; } `, @@ -3387,6 +3389,17 @@ export function droppedWrite(svc: Service | null): void { expect(drops).toContainEqual(expect.objectContaining({ name: 'name', siteKind: 'write' })); }); + it('resolves the typed compound write while retaining the untyped write drop', () => { + const accesses = getRelationships(result, 'ACCESSES').filter((edge) => edge.target === 'name'); + expect(accesses).toHaveLength(1); + expect(accesses[0]).toMatchObject({ + source: 'resolvedWrite', + target: 'name', + targetFilePath: 'models.ts', + rel: { targetId: 'Property:models.ts:User.name' }, + }); + }); + // The origin travels with the drop too, and its value here is the whole // point: `svc` is an UNANNOTATED parameter, so the scope model records it // nowhere — no type binding, no value binding, no qualified name. A diff --git a/gitnexus/test/unit/ai-context.test.ts b/gitnexus/test/unit/ai-context.test.ts index c411d7ec5..b57c556ed 100644 --- a/gitnexus/test/unit/ai-context.test.ts +++ b/gitnexus/test/unit/ai-context.test.ts @@ -51,6 +51,22 @@ describe('generateAIContextFiles', () => { expect(content).toContain('TestProject'); }); + it('tells agents to preview with rename dry_run, not a separate rename_preview tool', async () => { + await generateAIContextFiles(tmpDir, storagePath, 'TestProject', { + nodes: 50, + edges: 100, + processes: 5, + }); + + for (const file of ['CLAUDE.md', 'AGENTS.md']) { + const content = await fs.readFile(path.join(tmpDir, file), 'utf-8'); + expect(content).toContain( + 'use `rename` with `dry_run: true` first, review the graph-backed semantic spans and coverage, then apply with `dry_run: false`', + ); + expect(content).not.toContain('rename_preview'); + } + }); + it('omits volatile counts when noStats option is set (#1477)', async () => { // Distinct subdir per case so we can assert on a clean slate. const subDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-no-stats-test-')); diff --git a/gitnexus/test/unit/analyzer-identity.test.ts b/gitnexus/test/unit/analyzer-identity.test.ts index 981da0459..1b2a694fa 100644 --- a/gitnexus/test/unit/analyzer-identity.test.ts +++ b/gitnexus/test/unit/analyzer-identity.test.ts @@ -30,6 +30,37 @@ import { setupMiniRepo } from '../helpers/mini-repo.js'; import { createTempDir } from '../helpers/test-db.js'; describe('analyzer runner identity', () => { + it('ignores an unrelated root report created while computing identity', async () => { + const fixture = await createTempDir(); + try { + const modulePath = path.join(fixture.dbPath, 'src', 'core', 'analyzer.ts'); + const manifest = path.join(fixture.dbPath, 'package.json'); + await mkdir(path.dirname(modulePath), { recursive: true }); + await writeFile(manifest, '{"name":"fixture-analyzer","version":"1.0.0"}\n'); + await writeFile(modulePath, 'export const analyzer = 1;\n'); + const options = { cacheDirectory: path.join(fixture.dbPath, 'identity-cache') }; + let created = false; + const identity = resolveAnalyzerRunnerIdentity(pathToFileURL(modulePath).href, { + ...options, + onCacheMissWork: (input) => { + if (input.kind === 'manifest-read' && input.path === manifest && !created) { + created = true; + writeFileSync(path.join(fixture.dbPath, 'test-report.json'), '{}\n'); + } + }, + }); + expect(created).toBe(true); + expect(identity.build.digest).toMatch(/^sha256:/); + // Root reports are irrelevant, but analyzer changes must still fail closed. + await writeFile(modulePath, 'export const analyzer = 2;\n'); + expect(() => + finalizeAnalyzerRunnerIdentity(pathToFileURL(modulePath).href, identity, options), + ).toThrow(/changed/); + } finally { + await fixture.cleanup(); + } + }); + it('is versioned, resolved, and changes when the analyzer build tree changes', async () => { const fixture = await createTempDir(); try { diff --git a/gitnexus/test/unit/basicblock-callee-ids-schema.test.ts b/gitnexus/test/unit/basicblock-callee-ids-schema.test.ts index d6dbb8154..f3296fa83 100644 --- a/gitnexus/test/unit/basicblock-callee-ids-schema.test.ts +++ b/gitnexus/test/unit/basicblock-callee-ids-schema.test.ts @@ -55,11 +55,13 @@ const copyColumns = (copyQuery: string): string[] => { // ── 1. CSV header / row builder (pure) ──────────────────────────────────────── describe('BasicBlock calleeIds — CSV header + row builder', () => { - it('header lists calleeIds LAST, after callees', () => { - expect(BASICBLOCK_CSV_HEADER).toBe('id,filePath,startLine,endLine,text,callees,calleeIds'); + it('header keeps call identities and explicit owner in distinct columns', () => { + expect(BASICBLOCK_CSV_HEADER).toBe( + 'id,filePath,startLine,endLine,text,callees,calleeIds,ownerId', + ); const cols = BASICBLOCK_CSV_HEADER.split(','); - expect(cols[cols.length - 1]).toBe('calleeIds'); - expect(cols[cols.length - 2]).toBe('callees'); + expect(cols[cols.length - 1]).toBe('ownerId'); + expect(cols[cols.length - 2]).toBe('calleeIds'); }); it('round-trip: calleeIds lands in the column the header names for it', () => { @@ -101,10 +103,10 @@ describe('BasicBlock calleeIds — header/COPY/row column parity', () => { basicBlock('BasicBlock:src/a.ts:0', { callees: 'foo', calleeIds: 'id1' }), ).split(','); - // Exact counts (the drift guard): 7 columns through and through. - expect(headerCols).toHaveLength(7); - expect(copyCols).toHaveLength(7); - expect(rowCells).toHaveLength(7); + // Exact counts protect every positional COPY writer. + expect(headerCols).toHaveLength(8); + expect(copyCols).toHaveLength(8); + expect(rowCells).toHaveLength(8); expect(copyCols).toHaveLength(headerCols.length); expect(rowCells).toHaveLength(headerCols.length); }); @@ -113,7 +115,7 @@ describe('BasicBlock calleeIds — header/COPY/row column parity', () => { const headerCols = BASICBLOCK_CSV_HEADER.split(','); const copyCols = copyColumns(getCopyQuery('BasicBlock', '/tmp/bb.csv')); expect(copyCols).toEqual(headerCols); - expect(copyCols[copyCols.length - 1]).toBe('calleeIds'); + expect(copyCols[copyCols.length - 1]).toBe('ownerId'); }); }); @@ -208,6 +210,7 @@ describe('BasicBlock calleeIds — CREATE / MERGE query strings', () => { text: 'foo();', callees: 'foo', calleeIds: 'id1 id2', + ownerId: 'Module:src/a.ts:program', }, '/tmp/gitnexus-bb-create/lbug', ); @@ -217,7 +220,8 @@ describe('BasicBlock calleeIds — CREATE / MERGE query strings', () => { expect(createQuery).toBeDefined(); expect(createQuery).toContain("calleeIds: 'id1 id2'"); expect(createQuery).toContain("callees: 'foo'"); - // calleeIds is the LAST assignment in the tuple (mirrors the column order). + expect(createQuery).toContain("ownerId: 'Module:src/a.ts:program'"); + // Call identities follow callees in the persisted column order. expect(createQuery?.indexOf('calleeIds:')).toBeGreaterThan( createQuery?.indexOf('callees:') ?? -1, ); @@ -244,6 +248,7 @@ describe('BasicBlock calleeIds — CREATE / MERGE query strings', () => { const createQuery = queries.find((q) => q.startsWith('CREATE (n:BasicBlock')); expect(createQuery).toContain("calleeIds: ''"); + expect(createQuery).toContain("ownerId: ''"); expect(createQuery).not.toContain('calleeIds: undefined'); }); @@ -265,6 +270,7 @@ describe('BasicBlock calleeIds — CREATE / MERGE query strings', () => { text: 'foo();', callees: 'foo', calleeIds: 'id1 id2', + ownerId: 'Module:src/a.ts:program', }, }, ], @@ -277,6 +283,7 @@ describe('BasicBlock calleeIds — CREATE / MERGE query strings', () => { expect(mergeQuery).toContain('n.calleeIds'); expect(mergeQuery).toContain("n.calleeIds = 'id1 id2'"); expect(mergeQuery).toContain("n.callees = 'foo'"); + expect(mergeQuery).toContain("n.ownerId = 'Module:src/a.ts:program'"); expect(mergeQuery?.indexOf('n.calleeIds')).toBeGreaterThan( mergeQuery?.indexOf('n.callees') ?? -1, ); @@ -307,6 +314,7 @@ describe('BasicBlock calleeIds — CREATE / MERGE query strings', () => { const mergeQuery = queries.find((q) => q.startsWith('MERGE (n:BasicBlock')); expect(mergeQuery).toContain("n.calleeIds = ''"); + expect(mergeQuery).toContain("n.ownerId = ''"); expect(mergeQuery).not.toContain('n.calleeIds = undefined'); }); }); diff --git a/gitnexus/test/unit/calltool-dispatch.test.ts b/gitnexus/test/unit/calltool-dispatch.test.ts index aa4bc9d82..82e9728fa 100644 --- a/gitnexus/test/unit/calltool-dispatch.test.ts +++ b/gitnexus/test/unit/calltool-dispatch.test.ts @@ -195,6 +195,14 @@ function mockSymbolRows(rows: Record[]) { const duplicateFixtureDirs: string[] = []; +/** LocalBackend.rename is private; the dispatch tests spy on it through this narrow view. */ +interface RenameEntryPoint { + rename(repo: { repoPath: string }, params: Record): Promise; +} +function spyOnRename(target: LocalBackend) { + return vi.spyOn(target as unknown as RenameEntryPoint, 'rename'); +} + function makeDuplicateNameFixture() { const mainDir = mkdtempSync(path.join(os.tmpdir(), 'gnx-shared-main-')); const wtDir = mkdtempSync(path.join(os.tmpdir(), 'gnx-shared-wt-')); @@ -2534,20 +2542,49 @@ describe('LocalBackend.callTool', () => { expect(result.error).toContain('Either symbol_name or symbol_uid'); }); - it('rename: a swallowed apply-edit write failure degrades to status:partial + failed_files (#2283)', async () => { - // Resolve the definition, no graph refs. readFile succeeds (so a def edit is - // recorded), but writeFile fails on apply — the failure is swallowed via - // logQueryError. The result must NOT report a clean success: it degrades to - // 'partial' and lists the unwritten file, instead of status:'success'. + it.each([ + ['omitted', {}], + ['true', { dry_run: true }], + ['false', { dry_run: false }], + ])('forwards rename arguments untouched when dry_run is %s', async (_label, extra) => { + const rename = spyOnRename(backend).mockResolvedValue({ applied: false }); + await backend.callTool('rename', { new_name: 'newName', ...extra }); + expect(rename).toHaveBeenCalledWith(expect.anything(), { new_name: 'newName', ...extra }); + }); + + it('no longer dispatches a separate rename_preview tool', async () => { + const rename = spyOnRename(backend); + await expect(backend.callTool('rename_preview', { new_name: 'newName' })).rejects.toThrow( + 'Unknown tool: rename_preview', + ); + expect(rename).not.toHaveBeenCalled(); + }); + + it.each([{}, { dry_run: true }, { dry_run: false }])( + 'rename rejects branch-pinned checkout edits (%j)', + async (extra) => { + const rename = spyOnRename(backend); + const result = await backend.callTool('rename', { + new_name: 'newName', + branch: 'main', + ...extra, + }); + expect(result.error).toMatch(/branch/); + expect(rename).not.toHaveBeenCalled(); + }, + ); + + it('rename: a first apply-edit write failure reports status:error + failed_files (#2283)', async () => { + // Real graph/semantic resolution produces an edit; staging its replacement fails. (executeParameterized as any) .mockResolvedValueOnce([ { - id: 'func:oldName', + id: 'Function:src/target.ts:oldName', name: 'oldName', type: 'Function', filePath: 'src/target.ts', startLine: 1, - endLine: 5, + endLine: 1, }, ]) .mockResolvedValue([]); @@ -2558,12 +2595,19 @@ describe('LocalBackend.callTool', () => { backend = new LocalBackend(); await backend.init(); - // The symbol is stored at 0-based startLine 1; context() presents it 1-based - // (line 2) and rename subtracts 1 to recover the 0-based file index (1), so - // `oldName` must sit on the file's 0-based line 1 for the definition edit to - // fire. (#2380: the mock previously put it on line 0, which stopped matching - // once context() went 1-based.) - const readSpy = vi.spyOn(fsPromises, 'readFile').mockResolvedValue('\nfunction oldName() {}\n'); + // 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'); + 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')); @@ -2573,13 +2617,12 @@ describe('LocalBackend.callTool', () => { new_name: 'newName', dry_run: false, }); - expect(result.status).toBe('partial'); + expect(result.status).toBe('error'); expect(result.failed_files).toContain('src/target.ts'); - // It DID attempt to apply (not a dry run) — `applied` stays true; the - // honest signal is the 'partial' status + failed_files, not `applied`. - expect(result.applied).toBe(true); + expect(result.applied).toBe(false); + expect(result.total_edits).toBe(0); + expect(result.application_status).toBe('failed'); } finally { - readSpy.mockRestore(); writeSpy.mockRestore(); rmSync(repoDir, { recursive: true, force: true }); } @@ -3957,6 +4000,82 @@ describe('LocalBackend.resolveRepo', () => { } }); + describe('rename repo omission with several indexed repos (cwd inside one)', () => { + let outerDir: string; + let otherDir: string; + let renameSpy: ReturnType; + let cwdSpy: ReturnType; + + beforeEach(async () => { + outerDir = mkdtempSync(path.join(os.tmpdir(), 'gnx-rename-omit-outer-')); + otherDir = mkdtempSync(path.join(os.tmpdir(), 'gnx-rename-omit-other-')); + const cwdDir = path.join(outerDir, 'src'); + mkdirSync(cwdDir, { recursive: true }); + duplicateFixtureDirs.push(outerDir, otherDir); + (listRegisteredRepos as any).mockResolvedValue([ + { + ...MOCK_REPO_ENTRY, + name: 'outer', + path: outerDir, + storagePath: path.join(outerDir, '.gitnexus'), + }, + { + ...MOCK_REPO_ENTRY, + name: 'other', + path: otherDir, + storagePath: path.join(otherDir, '.gitnexus'), + }, + ]); + (getGitRoot as any).mockReturnValue(outerDir); + cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(cwdDir); + renameSpy = spyOnRename(backend).mockResolvedValue({ applied: false }); + await backend.init(); + }); + + afterEach(() => { + cwdSpy.mockRestore(); + }); + + // A call that provably cannot write follows the read-tool omission rule. + it.each([ + ['omitted', {}], + ['true', { dry_run: true }], + ])('previews with dry_run %s against the cwd-derived repo', async (_label, extra) => { + await backend.callTool('rename', { symbol_name: 'oldName', new_name: 'newName', ...extra }); + expect(renameSpy).toHaveBeenCalledTimes(1); + expect(renameSpy.mock.calls[0][0].repoPath).toBe(outerDir); + }); + + // Anything that could write - and any malformed value - keeps the stricter rule. + it.each([ + ['false', false], + ['a string', 'false'], + ['a number', 0], + ['null', null], + ])('requires an explicit repo when dry_run is %s', async (_label, dryRun) => { + await expect( + backend.callTool('rename', { + symbol_name: 'oldName', + new_name: 'newName', + dry_run: dryRun, + }), + ).rejects.toThrow('Multiple repositories indexed'); + expect(renameSpy).not.toHaveBeenCalled(); + }); + + it('routes an explicit repo to that repository for both modes', async () => { + for (const dryRun of [true, false]) { + await backend.callTool('rename', { + repo: 'other', + symbol_name: 'oldName', + new_name: 'newName', + dry_run: dryRun, + }); + } + expect(renameSpy.mock.calls.map(([repo]) => repo.repoPath)).toEqual([otherDir, otherDir]); + }); + }); + it('keeps explicit duplicate aliases on exact git-root disambiguation (#3073)', async () => { const outerDir = mkdtempSync(path.join(os.tmpdir(), 'gnx-cwd-alias-outer-')); const nestedDir = path.join(outerDir, 'packages', 'nested'); @@ -4101,7 +4220,7 @@ describe('LocalBackend.resolveRepo', () => { } }); - it('keeps mutating rename explicit with multiple repos (#3073)', async () => { + it('keeps applying rename (dry_run:false) explicit with multiple repos (#3073)', async () => { const cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue('/tmp/test-project/src'); try { @@ -4111,7 +4230,7 @@ describe('LocalBackend.resolveRepo', () => { backend.callTool('rename', { symbol_name: 'oldName', new_name: 'newName', - dry_run: true, + dry_run: false, }), ).rejects.toThrow('Multiple repositories indexed'); } finally { @@ -4119,6 +4238,21 @@ describe('LocalBackend.resolveRepo', () => { } }); + it('lets a rename preview resolve the cwd repo among multiple repos (#3073)', async () => { + const cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue('/tmp/test-project/src'); + const rename = spyOnRename(backend).mockResolvedValue({ applied: false }); + + try { + setupMultipleRepos(); + await backend.init(); + await backend.callTool('rename', { symbol_name: 'oldName', new_name: 'newName' }); + expect(rename).toHaveBeenCalledTimes(1); + expect(rename.mock.calls[0][0].repoPath).toBe('/tmp/test-project'); + } finally { + cwdSpy.mockRestore(); + } + }); + it('resolves repo by name parameter', async () => { setupMultipleRepos(); await backend.init(); diff --git a/gitnexus/test/unit/canonicalize-workspace-bindings.test.ts b/gitnexus/test/unit/canonicalize-workspace-bindings.test.ts new file mode 100644 index 000000000..5f624394b --- /dev/null +++ b/gitnexus/test/unit/canonicalize-workspace-bindings.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import { SupportedLanguages } from 'gitnexus-shared'; +import { SCOPE_RESOLVERS } from '../../src/core/ingestion/scope-resolution/pipeline/registry.js'; +import { extractParsedFile } from '../../src/core/ingestion/scope-extractor-bridge.js'; + +const cases = [ + [SupportedLanguages.CSharp, 'csharp', 'Worker.cs', '@worker', 'Read'], + [SupportedLanguages.Kotlin, 'kotlin', 'worker.kt', '`worker`', 'read'], + [SupportedLanguages.Swift, 'swift', 'worker.swift', '`worker`', 'read'], +] as const; + +describe('provider workspace identifier normalization', () => { + it.each(cases)( + 'keeps %s source identities while aligning escaped receiver and type bindings', + (language, directory, file, escapedReceiver, member) => { + const resolver = SCOPE_RESOLVERS.get(language)!; + const content = fs.readFileSync( + new URL( + `../fixtures/rename-integration/${directory}/escaped-receiver-identity/before/${file}`, + import.meta.url, + ), + 'utf8', + ); + const parsed = extractParsedFile(resolver.languageProvider, content, file)!; + expect(parsed).toBeDefined(); + resolver.populateOwners(parsed); + const originalScopes = parsed.scopes.map((scope) => ({ + bindings: [...scope.bindings], + typeBindings: [...scope.typeBindings], + })); + const originalReferences = structuredClone(parsed.referenceSites); + const originalReceiver = parsed.referenceSites.find( + (site) => site.name === member && site.explicitReceiver?.name === escapedReceiver, + ); + expect(originalReceiver).toBeDefined(); + + const workspace = [parsed]; + resolver.populateWorkspaceReferences!(workspace, { + fileContents: new Map([[file, content]]), + }); + const normalized = workspace[0]; + expect(normalized.localDefs).toBe(parsed.localDefs); + expect(normalized.localDefs.map((def) => [def.nodeId, def.nameRange])).toEqual( + parsed.localDefs.map((def) => [def.nodeId, def.nameRange]), + ); + expect(normalized.referenceSites.find((site) => site.name === member)).toEqual({ + ...originalReceiver, + explicitReceiver: { name: 'worker' }, + }); + const typeBindings = normalized.scopes.flatMap((scope) => [...scope.typeBindings]); + expect(typeBindings).toContainEqual([ + 'worker', + expect.objectContaining({ rawName: 'Worker' }), + ]); + expect(typeBindings.some(([name]) => name === escapedReceiver)).toBe(false); + expect( + parsed.scopes.map((scope) => ({ + bindings: [...scope.bindings], + typeBindings: [...scope.typeBindings], + })), + ).toEqual(originalScopes); + expect(parsed.referenceSites).toEqual(originalReferences); + const firstSites = normalized.referenceSites; + resolver.populateWorkspaceReferences!(workspace, { fileContents: new Map() }); + workspace[0].referenceSites.forEach((site, index) => expect(site).toBe(firstSites[index])); + }, + ); +}); + +it.each([SupportedLanguages.TypeScript, SupportedLanguages.JavaScript])( + 'normalizes %s callable operands and preserves raw owner identities and spans', + (language) => { + const resolver = SCOPE_RESOLVERS.get(language)!; + const source = String.raw`function calc\u0075late() { return 7; } +function invoke(c\u0062) { return c\u0062(); } +export function run() { const c\u0062 = calc\u0075late; const next = cb; return next(); } +export function throughParameter() { return invoke(calc\u0075late); } +`; + const parsed = extractParsedFile( + resolver.languageProvider, + source, + language === SupportedLanguages.TypeScript ? 'flow.ts' : 'flow.js', + )!; + const original = structuredClone(parsed.callableFlowSites); + const workspace = [parsed]; + resolver.populateWorkspaceReferences!(workspace, { fileContents: new Map() }); + const normalized = workspace[0]; + expect(normalized.localDefs).toBe(parsed.localDefs); + expect(parsed.callableFlowSites).toEqual(original); + expect(normalized.callableFlowSites?.length).toBeGreaterThan(0); + const operands = normalized.callableFlowSites!.flatMap((site) => + ['source', 'destination', 'binding', 'callee', 'receiver', 'pointer'].flatMap((key) => + key in site ? [site[key as keyof typeof site]] : [], + ), + ); + expect(operands).toContainEqual(expect.objectContaining({ name: 'calculate' })); + expect(operands).toContainEqual(expect.objectContaining({ name: 'cb' })); + expect(JSON.stringify(operands)).not.toContain('\\\\u'); + for (let index = 0; index < original!.length; index++) { + const before = original![index]; + const after = normalized.callableFlowSites![index]; + if (before.kind === 'formal' && after.kind === 'formal') { + expect(after.ownerName).toBe(before.ownerName); + expect(after.ownerRange).toEqual(before.ownerRange); + } + for (const key of ['source', 'destination', 'binding', 'callee', 'receiver', 'pointer']) { + if (key in before && key in after) { + expect((after as unknown as Record)[key].atRange).toEqual( + (before as unknown as Record)[key].atRange, + ); + } + } + } + }, +); + +it.each(cases)( + 'preserves raw %s member keys and canonicalizes only lexical receiver bases', + (language, directory, file) => { + const resolver = SCOPE_RESOLVERS.get(language)!; + const content = fs.readFileSync( + new URL( + `../fixtures/rename-integration/${directory}/escaped-member-identity/before/${file}`, + import.meta.url, + ), + 'utf8', + ); + const parsed = extractParsedFile(resolver.languageProvider, content, file)!; + const member = parsed.referenceSites.find( + (site) => site.kind === 'call' && site.explicitReceiver?.name.includes('worker'), + )!; + expect(member).toBeDefined(); + const chain = `2|${member.explicitReceiver!.name}|c${member.name}|i`; + const withChain = { ...parsed, referenceSites: [{ ...member, receiverChain: chain }] }; + const workspace = [withChain]; + resolver.populateWorkspaceReferences!(workspace, { fileContents: new Map() }); + const normalized = workspace[0].referenceSites[0]; + expect(normalized.name).toBe(member.name); + expect(normalized.explicitReceiver?.name).toBe('worker'); + expect(normalized.receiverChain).toBe(`2|worker|c${member.name}|i`); + expect(normalized.atRange).toBe(member.atRange); + expect(withChain.referenceSites[0].receiverChain).toBe(chain); + resolver.populateWorkspaceReferences!(workspace, { fileContents: new Map() }); + expect(workspace[0].referenceSites[0]).toBe(normalized); + }, +); diff --git a/gitnexus/test/unit/cfg/cobol-visitor.test.ts b/gitnexus/test/unit/cfg/cobol-visitor.test.ts new file mode 100644 index 000000000..356d574f5 --- /dev/null +++ b/gitnexus/test/unit/cfg/cobol-visitor.test.ts @@ -0,0 +1,331 @@ +import { describe, expect, it } from 'vitest'; +import { cobolProvider } from '../../../src/core/ingestion/languages/cobol.js'; +import { computeReachingDefs } from '../../../src/core/ingestion/cfg/reaching-defs.js'; +import { computeControlDependence } from '../../../src/core/ingestion/cfg/control-dependence.js'; +import type { FunctionCfg } from '../../../src/core/ingestion/cfg/types.js'; +import { block, reaches, bindingIdx } from '../../helpers/cfg-harness.js'; + +const source = (body: string, data = '01 X PIC 9.\n01 Y PIC 9.\n01 FLAG PIC 9.') => + ` >>SOURCE FORMAT FREE\nidentification division.\nprogram-id. Demo.\ndata division.\nworking-storage section.\n${data}\nprocedure division.\n${body}\nend program Demo.`; + +function collect(text: string, cap = 0) { + expect(cobolProvider.collectSourceCfgs).toBeTypeOf('function'); + return cobolProvider.collectSourceCfgs!(text, 'demo.cbl', cap); +} + +function cfgOf(body: string, data?: string): FunctionCfg { + const result = collect(source(body, data)); + expect(result.skipped).toEqual({ tooManyLines: 0, tooDeeplyNested: 0, buildError: 0 }); + expect(result.cfgs).toHaveLength(1); + return result.cfgs[0]!; +} + +const hasEdge = (cfg: FunctionCfg, from: number, to: number, kind?: string) => + cfg.edges.some((e) => e.from === from && e.to === to && (!kind || e.kind === kind)); + +describe('COBOL source CFG provider', () => { + it('does not confuse a group with a different group whose name has the same suffix', () => { + const cfg = cfgOf( + 'move 1 to REC\ndisplay AMOUNT OF OTHER-REC\ngoback.', + '01 REC.\n05 AMOUNT PIC 9.\n01 OTHER-REC.\n05 AMOUNT PIC 9.', + ); + const written = cfg.blocks[block(cfg, 'move 1')]!.statements![0]!.defs; + expect(written).toContain(bindingIdx(cfg, 'AMOUNT OF REC')); + expect(written).not.toContain(bindingIdx(cfg, 'AMOUNT OF OTHER-REC')); + }); + + it('keeps decimal constants inside their statement', () => { + const cfg = cfgOf('move 1.5 to x\ndisplay x\ngoback.'); + expect(cfg.blocks[block(cfg, 'move 1.5')]!.statements![0]!.defs).toContain( + bindingIdx(cfg, 'X'), + ); + }); + + it('zero TIMES does not enter an inline or out-of-line body', () => { + const cfg = cfgOf( + 'perform 0 times\nmove 8 to x\nend-perform\nperform worker 0 times\ndisplay x\ngoback.\nworker.\nmove 9 to x.', + ); + expect(cfg.blocks.some((b) => /move [89]/.test(b.text))).toBe(false); + }); + + it('PERFORM nests a second range and EXIT SECTION returns to the active continuation', () => { + const cfg = cfgOf( + 'perform workers\ndisplay y\ngoback.\nworkers section.\nfirst-worker.\nperform helper\nexit section\nmove 8 to y.\nlast-worker.\nmove 9 to y.\nhelpers section.\nhelper.\nmove 1 to y.', + ); + expect(reaches(cfg, block(cfg, 'move 1'), block(cfg, 'display y'))).toBe(true); + expect(cfg.blocks.some((b) => /move [89]/.test(b.text))).toBe(false); + }); + + it('rejects split-word/literal continuation instead of fabricating token identities', () => { + const text = [ + '000100 IDENTIFICATION DIVISION.', + '000200 PROGRAM-ID. FIXED.', + '000300 PROCEDURE DIVISION.', + '000400 MOVE WS-', + '000500- VALUE TO RESULT', + '000600 STOP RUN.', + ].join('\n'); + expect(collect(text).skipped.buildError).toBe(1); + }); + + it('uses the standard depth budget for pathological nested conditions', () => { + const body = 'if flag = 1\n'.repeat(260) + 'display x\n' + 'end-if\n'.repeat(260) + 'goback.'; + expect(collect(source(body)).skipped.tooDeeplyNested).toBe(1); + }); + + it('rejects REDEFINES aliases instead of claiming disjoint storage', () => { + const result = collect( + source('move 1 to x\ndisplay y\ngoback.', '01 X PIC 9.\n01 Y REDEFINES X PIC 9.'), + ); + expect(result.skipped.buildError).toBe(1); + }); + + it('owns the procedure region with the existing program Module ID', () => { + const cfg = cfgOf('main.\nmove 1 to x\nnext-para.\ndisplay x\ngoback.'); + expect(cfg.ownerId).toBe('Module:demo.cbl:Demo'); + expect(cfg.functionStartLine).toBe(9); + expect(reaches(cfg, block(cfg, 'move 1 to x'), block(cfg, 'display x'))).toBe(true); + expect( + computeReachingDefs(cfg).facts.some( + (f) => + f.bindingIdx === bindingIdx(cfg, 'X') && + f.def.blockIndex === block(cfg, 'move 1 to x') && + f.use.blockIndex === block(cfg, 'display x'), + ), + ).toBe(true); + expect(JSON.parse(JSON.stringify(cfg))).toEqual(cfg); + }); + + it('branches IF and EVALUATE through the generic CDG and reaching-def solver', () => { + const cfg = cfgOf(`if flag = 1 + move 1 to x +else + move 2 to x +end-if +evaluate x + when 1 move x to y + when other move 0 to y +end-evaluate +display y +stop run.`); + const cond = block(cfg, 'if flag'); + expect(hasEdge(cfg, cond, block(cfg, 'move 1'), 'cond-true')).toBe(true); + expect(hasEdge(cfg, cond, block(cfg, 'move 2'), 'cond-false')).toBe(true); + expect(computeControlDependence(cfg).edges).toContainEqual({ + controllerBlock: cond, + dependentBlock: block(cfg, 'move 1'), + label: 'T', + }); + const rd = computeReachingDefs(cfg); + expect(rd.status).toBe('computed'); + expect( + rd.facts.filter( + (f) => + f.bindingIdx === bindingIdx(cfg, 'Y') && f.use.blockIndex === block(cfg, 'display y'), + ), + ).toHaveLength(2); + }); + + it('a sentence period terminates nested IF scopes', () => { + const cfg = cfgOf('if flag = 1\n if x = 1\n move 2 to y.\ndisplay y\ngoback.'); + expect(reaches(cfg, block(cfg, 'if flag'), block(cfg, 'display y'))).toBe(true); + expect( + computeControlDependence(cfg).edges.some((e) => e.dependentBlock === block(cfg, 'display y')), + ).toBe(false); + }); + + it('returns PERFORM THRU only after the last paragraph, with no caller bypass', () => { + const cfg = cfgOf(`main. +perform first-para thru last-para +display y +goback. +first-para. +move 1 to x. +middle-para. +move x to y. +last-para. +exit. +unrelated. +move 9 to y.`); + const call = block(cfg, 'perform first-para'); + const read = block(cfg, 'display y'); + expect(hasEdge(cfg, call, read)).toBe(false); + expect(reaches(cfg, call, block(cfg, 'move x to y'))).toBe(true); + expect(reaches(cfg, block(cfg, 'move x to y'), read)).toBe(true); + expect(cfg.blocks.some((b) => b.text.includes('move 9'))).toBe(false); + expect( + computeReachingDefs(cfg).facts.some( + (f) => + f.bindingIdx === bindingIdx(cfg, 'Y') && + f.def.blockIndex === block(cfg, 'move x to y') && + f.use.blockIndex === read, + ), + ).toBe(true); + }); + + it('keeps two PERFORM return continuations separate', () => { + const cfg = cfgOf( + 'perform worker\nmove x to y\nperform worker\ndisplay x\ngoback.\nworker.\nadd 1 to x.', + ); + const workers = cfg.blocks.filter((b) => b.text.includes('add 1 to x')); + expect(workers).toHaveLength(2); + const middle = block(cfg, 'move x to y'); + const last = block(cfg, 'display x'); + const returning = workers.map((w) => + cfg.edges.filter((e) => e.from === w.index).map((e) => e.to), + ); + expect(returning[0]).not.toEqual(returning[1]); + expect(reaches(cfg, middle, last)).toBe(true); + }); + + it('GO TO skips following statements and EXIT PARAGRAPH jumps to the paragraph boundary', () => { + const cfg = cfgOf( + 'go to target\nmove 9 to x.\ntarget.\nmove 1 to x\nexit paragraph\nmove 8 to x.\nnext-one.\ndisplay x\nstop run.', + ); + expect(cfg.blocks.some((b) => /move [89]/.test(b.text))).toBe(false); + expect(reaches(cfg, block(cfg, 'move 1'), block(cfg, 'display x'))).toBe(true); + }); + + it('PERFORM section returns after its final paragraph', () => { + const cfg = cfgOf( + 'perform workers\ndisplay y\ngoback.\nworkers section.\na.\nmove 1 to x.\nb.\nmove x to y.\nother section.\nz.\nmove 9 to y.', + ); + expect(reaches(cfg, block(cfg, 'move x'), block(cfg, 'display y'))).toBe(true); + expect(cfg.blocks.some((b) => b.text.includes('move 9'))).toBe(false); + }); + + it('models inline UNTIL/VARYING, loop updates, EXIT PERFORM and CYCLE', () => { + const cfg = cfgOf(`perform varying x from 1 by 1 until x > 5 + if flag = 1 exit perform end-if + if flag = 2 exit perform cycle end-if + add x to y +end-perform +display y +goback.`); + expect(cfg.edges.some((e) => e.kind === 'loop-back')).toBe(true); + const exit = block(cfg, 'exit perform'); + expect(reaches(cfg, exit, block(cfg, 'display y'))).toBe(true); + const x = bindingIdx(cfg, 'X'); + expect( + cfg.blocks.some((b) => b.statements?.some((s) => s.defs.includes(x) && s.uses.includes(x))), + ).toBe(true); + }); + + it('TEST AFTER executes its body before the UNTIL test', () => { + const cfg = cfgOf( + 'perform with test after until flag = 1\nmove 1 to x\nend-perform\ndisplay x\ngoback.', + ); + const first = cfg.edges.find((e) => e.from === cfg.entryIndex)!; + expect(cfg.blocks[first.to]!.text).toContain('move 1'); + }); + + it('resolves qualified data names case-insensitively and preserves independent fields', () => { + const cfg = cfgOf( + 'move 1 to value of left-rec\nmove 2 to VALUE IN right-rec\ndisplay VaLuE of LEFT-REC\ngoback.', + '01 LEFT-REC.\n05 VALUE PIC 9.\n01 RIGHT-REC.\n05 VALUE PIC 9.', + ); + const rd = computeReachingDefs(cfg); + const read = block(cfg, 'display'); + expect( + rd.facts.filter((f) => f.use.blockIndex === read).map((f) => rd.bindings[f.bindingIdx]!.name), + ).toEqual(['VALUE OF LEFT-REC']); + }); + + it('array element writes are may-defs and read their subscripts', () => { + const cfg = cfgOf('move 1 to x(flag)\ndisplay x(y)\ngoback.'); + const facts = cfg.blocks[block(cfg, 'move 1')]!.statements![0]!; + expect(facts.mayDefs).toContain(bindingIdx(cfg, 'X')); + expect(facts.defs).not.toContain(bindingIdx(cfg, 'X')); + expect(facts.uses).toContain(bindingIdx(cfg, 'FLAG')); + }); + + it('CALL records positional uses, reference may-defs and RETURNING defs', () => { + const cfg = cfgOf( + 'call "OTHER" using by reference x by content y returning flag\ndisplay flag\ngoback.', + ); + const f = cfg.blocks[block(cfg, 'call')]!.statements![0]!; + expect(f.sites?.[0]).toMatchObject({ + kind: 'call', + callee: 'OTHER', + args: [[bindingIdx(cfg, 'X')], [bindingIdx(cfg, 'Y')]], + resultDefs: [bindingIdx(cfg, 'FLAG')], + }); + expect(f.mayDefs).toEqual([bindingIdx(cfg, 'X')]); + expect(f.defs).toEqual([bindingIdx(cfg, 'FLAG')]); + }); + + it('preserves fixed-format source lines and columns while excluding comments', () => { + const text = [ + '000100 IDENTIFICATION DIVISION.', + '000200 PROGRAM-ID. FIXED.', + '000300 DATA DIVISION.', + '000400 WORKING-STORAGE SECTION.', + '000500 01 X PIC 9.', + '000600 PROCEDURE DIVISION.', + '000700* ALTER A TO PROCEED TO B.', + '000800 MOVE 1 TO X', + '000900 DISPLAY X', + '001000 STOP RUN.', + ].join('\n'); + const cfg = collect(text).cfgs[0]!; + expect(cfg).toBeDefined(); + expect(cfg.blocks[block(cfg, 'MOVE 1')]!.startLine).toBe(8); + expect(cfg.bindings?.find((b) => b.name === 'X')).toMatchObject({ + declLine: 5, + declColumn: 10, + }); + }); + + it.each([ + 'alter a to proceed to b.', + 'exec sql select X end-exec.', + 'go to a b depending on x.', + 'perform missing.', + 'search x when y = 1 continue end-search.', + ])('reports unsupported control as a visible build gap: %s', (body) => { + const result = collect(source(body)); + expect(result.cfgs).toEqual([]); + expect(result.skipped.buildError).toBe(1); + }); + + it.each([ + 'ADD CORRESPONDING SRC TO DST', + 'ADD CORR SRC TO DST', + 'SUBTRACT CORRESPONDING SRC FROM DST', + 'SUBTRACT CORR SRC FROM DST', + ])( + 'reports unsupported corresponding arithmetic without overwriting unmatched children: %s', + (statement) => { + const result = collect( + source( + `MOVE 1 TO BB OF DST\n${statement}\nDISPLAY BB OF DST\nGOBACK.`, + '01 SRC.\n05 AA PIC 9.\n01 DST.\n05 AA PIC 9.\n05 BB PIC 9.', + ), + ); + expect(result.cfgs).toEqual([]); + expect(result.skipped).toEqual({ tooManyLines: 0, tooDeeplyNested: 0, buildError: 1 }); + }, + ); + + it('bounds procedure-region lines and recursive PERFORM expansion', () => { + expect(collect(source('move 1 to x\ngoback.'), 1).skipped.tooManyLines).toBe(1); + expect( + collect(source('perform again\ngoback.\nagain.\nperform again.')).skipped.buildError, + ).toBe(1); + }); + + it('separates nested programs and continues after an unsupported sibling', () => { + const text = + source('goback.').replace( + 'end program Demo.', + 'identification division.\nprogram-id. Child.\nprocedure division.\nstop run.\nend program Child.\nend program Demo.', + ) + + '\nidentification division.\nprogram-id. Bad.\nprocedure division.\nalter x to proceed to y.\nend program Bad.'; + const result = collect(text); + expect(result.cfgs.map((cfg) => cfg.ownerId)).toEqual([ + 'Module:demo.cbl:Demo', + 'Module:demo.cbl:Child', + ]); + expect(result.skipped.buildError).toBe(1); + }); +}); diff --git a/gitnexus/test/unit/cfg/explicit-owner.test.ts b/gitnexus/test/unit/cfg/explicit-owner.test.ts new file mode 100644 index 000000000..88d861c0b --- /dev/null +++ b/gitnexus/test/unit/cfg/explicit-owner.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; +import { createKnowledgeGraph } from '../../../src/core/graph/graph.js'; +import { emitFileCfgs, isEmitSafeCfg } from '../../../src/core/ingestion/cfg/emit.js'; +import { CfgBuilder } from '../../../src/core/ingestion/cfg/cfg-builder.js'; +import { BASICBLOCK_CSV_HEADER, buildBasicBlockRow } from '../../../src/core/lbug/csv-generator.js'; +import { getCopyQuery } from '../../../src/core/lbug/lbug-adapter.js'; +import { BASICBLOCK_SCHEMA } from '../../../src/core/lbug/schema.js'; + +describe('provider-declared executable region owner', () => { + it('carries the exact owner through graph emission and the persisted row', () => { + const builder = new CfgBuilder('program.cbl', 8, 15, 0); + builder.connect([builder.entryIndex], builder.exitIndex, 'seq'); + const cfg = { ...builder.finish(), ownerId: 'Module:program.cbl:PAYROLL' }; + const graph = createKnowledgeGraph(); + emitFileCfgs(graph, [cfg]); + const blocks = [...graph.iterNodes()]; + expect(blocks).toHaveLength(2); + expect(blocks.every((block) => block.properties.ownerId === cfg.ownerId)).toBe(true); + const columns = BASICBLOCK_CSV_HEADER.split(','); + expect(columns).toContain('ownerId'); + expect(buildBasicBlockRow(blocks[0]!).split(',')[columns.indexOf('ownerId')]).toBe( + '"Module:program.cbl:PAYROLL"', + ); + expect(getCopyQuery('BasicBlock', '/tmp/blocks.csv')).toContain('ownerId'); + expect(BASICBLOCK_SCHEMA).toContain('ownerId STRING'); + }); + + it('rejects malformed explicit owners while retaining the implicit function format', () => { + const builder = new CfgBuilder('a.ts', 1, 3, 0); + builder.connect([builder.entryIndex], builder.exitIndex, 'seq'); + const cfg = builder.finish(); + expect(isEmitSafeCfg(cfg)).toBe(true); + expect(isEmitSafeCfg({ ...cfg, ownerId: '' })).toBe(false); + expect(isEmitSafeCfg({ ...cfg, ownerId: 12 } as never)).toBe(false); + }); +}); diff --git a/gitnexus/test/unit/cfg/objective-c-visitor.test.ts b/gitnexus/test/unit/cfg/objective-c-visitor.test.ts new file mode 100644 index 000000000..c3af42c56 --- /dev/null +++ b/gitnexus/test/unit/cfg/objective-c-visitor.test.ts @@ -0,0 +1,401 @@ +import { describe, expect, it, vi } from 'vitest'; +import { objectiveCProvider } from '../../../src/core/ingestion/languages/objective-c.js'; +import { requireVendoredGrammar } from '../../../src/core/tree-sitter/vendored-grammars.js'; +import { collectFunctionCfgs } from '../../../src/core/ingestion/cfg/collect.js'; +import { computeReachingDefs } from '../../../src/core/ingestion/cfg/reaching-defs.js'; +import { computeControlDependence } from '../../../src/core/ingestion/cfg/control-dependence.js'; +import { isExitReachableFromAllBlocks } from '../../../src/core/ingestion/cfg/post-dominators.js'; +import { + emitFileCfgs, + emitFileCdg, + emitFileReachingDefs, +} from '../../../src/core/ingestion/cfg/emit.js'; +import { createKnowledgeGraph } from '../../../src/core/graph/graph.js'; +import { allSites, bindingIdx, block, makeCfgHarness, reaches } from '../../helpers/cfg-harness.js'; + +function harness() { + expect(objectiveCProvider.cfgVisitor).toBeDefined(); + return makeCfgHarness( + requireVendoredGrammar('tree-sitter-objc'), + objectiveCProvider.cfgVisitor!, + 'fixture.m', + ); +} + +const method = (body: string, params = '(id)input') => + `@implementation Worker\n- (id)run:${params} {\n${body}\n}\n@end`; + +describe('Objective-C CFG and PDG with the vendored grammar', () => { + it('registers C functions, methods and independent nested blocks, excluding declarations', () => { + const h = harness(); + const source = `@interface Worker\n- (id)run:(id)input;\n@end\n${method(` + void (^done)(id) = ^(id inner) { [self consume:inner]; }; + return input; + `)}\nint twice(int value) { return value * 2; }`; + const root = h.parse(source); + expect(root.hasError).toBe(false); + const { cfgs, skipped } = collectFunctionCfgs( + root, + objectiveCProvider.cfgVisitor!, + 'fixture.m', + ); + expect(cfgs).toHaveLength(3); + expect(skipped.buildError).toBe(0); + expect(cfgs[0].bindings?.some((b) => b.name === 'inner')).toBe(false); + expect(cfgs[1].bindings).toContainEqual( + expect.objectContaining({ name: 'inner', kind: 'param' }), + ); + expect(allSites(cfgs[0]).some((s) => s.callee?.includes('consume'))).toBe(false); + expect(allSites(cfgs[1]).some((s) => s.callee === 'self.consume:')).toBe(true); + }); + + it('harvests method parameters, pointer locals and message arguments without selector/type bindings', () => { + const cfg = harness().cfgOf( + method( + ` + Worker *result = [self transform:input fallback:backup]; + return result; + `, + '(id)input backup:(id)backup', + ), + ); + const result = bindingIdx(cfg, 'result'); + expect(cfg.bindings).toContainEqual( + expect.objectContaining({ name: 'input', kind: 'param', formalIndex: 0 }), + ); + expect(cfg.bindings).toContainEqual( + expect.objectContaining({ name: 'backup', kind: 'param', formalIndex: 1 }), + ); + expect(cfg.bindings?.map((b) => b.name)).not.toEqual( + expect.arrayContaining(['transform', 'fallback', 'Worker']), + ); + expect(allSites(cfg)).toContainEqual( + expect.objectContaining({ + callee: 'self.transform:fallback:', + resultDefs: [result], + args: [[bindingIdx(cfg, 'input')], [bindingIdx(cfg, 'backup')]], + }), + ); + expect(computeReachingDefs(cfg).facts).toContainEqual( + expect.objectContaining({ + bindingIdx: result, + def: expect.objectContaining({ blockIndex: block(cfg, 'Worker *result') }), + use: expect.objectContaining({ blockIndex: block(cfg, 'return result') }), + }), + ); + }); + + it.each(['{ [self consume:item]; }', '[self consume:item];'])( + 'models fast enumeration with body %s', + (body) => { + const cfg = harness().cfgOf(method(`for (id item in input) ${body}\nreturn input;`)); + const head = block(cfg, 'for (id item in input)'); + const consume = block(cfg, '[self consume:item]'); + expect(cfg.edges).toContainEqual({ from: head, to: consume, kind: 'cond-true' }); + expect(cfg.edges).toContainEqual({ from: consume, to: head, kind: 'loop-back' }); + expect( + computeReachingDefs(cfg).facts.some( + (f) => f.bindingIdx === bindingIdx(cfg, 'item') && f.use.blockIndex === consume, + ), + ).toBe(true); + expect(isExitReachableFromAllBlocks(cfg)).toBe(true); + }, + ); + + it('keeps enumeration variables scoped without shadowing the later parameter use', () => { + const cfg = harness().cfgOf( + method(`for (id input in input) { [self consume:input]; }\nreturn input;`), + ); + const inputs = cfg.bindings!.flatMap((b, i) => (b.name === 'input' ? [i] : [])); + expect(inputs).toHaveLength(2); + const use = cfg.blocks[block(cfg, 'return input')].statements![0].uses; + expect(use).toContain(inputs[0]); + expect(use).not.toContain(inputs[1]); + }); + + it('models ordinary C branches/loops and emits generic CFG, CDG and reaching-definition edges', () => { + const cfg = harness().cfgOf( + method(` + id result = input; + for (int i = 0; i < 2; i++) { if (input) { result = [self transform:input]; } } + return result; + `), + ); + expect(computeControlDependence(cfg).edges.length).toBeGreaterThan(0); + const graph = createKnowledgeGraph(); + expect(emitFileCfgs(graph, [cfg]).edges).toBeGreaterThan(0); + expect(emitFileCdg(graph, [cfg]).edges).toBeGreaterThan(0); + expect(emitFileReachingDefs(graph, [cfg]).edges).toBeGreaterThan(0); + expect(new Set(graph.relationships.map((r) => r.type))).toEqual( + new Set(['CFG', 'CDG', 'REACHING_DEF']), + ); + }); + + it('routes returns through finally and declares each catch parameter in its own scope', () => { + const cfg = harness().cfgOf( + method(` + @try { if (input) { return input; } @throw input; } + @catch (NSException *error) { [self recover:error]; } + @catch (id other) { [self recover:other]; } + @finally { [self cleanup]; } + return nil; + `), + ); + const returning = block(cfg, 'return input'); + const cleanup = cfg.edges.find((edge) => edge.from === returning && edge.kind === 'return')!.to; + expect(cfg.blocks[cleanup].text).toContain('[self cleanup]'); + expect(cfg.edges).not.toContainEqual({ from: returning, to: cfg.exitIndex, kind: 'return' }); + expect(cfg.bindings).toContainEqual(expect.objectContaining({ name: 'error', kind: 'catch' })); + expect(cfg.bindings).toContainEqual(expect.objectContaining({ name: 'other', kind: 'catch' })); + expect(reaches(cfg, block(cfg, '@throw input'), block(cfg, '[self recover:other]'))).toBe(true); + expect(isExitReachableFromAllBlocks(cfg)).toBe(true); + }); + + it('does not resume normal flow after a return through finally', () => { + const cfg = harness().cfgOf( + method(` + @try { return input; } @finally { [self cleanup]; } + [self unreachable]; + return nil; + `), + ); + const returning = block(cfg, 'return input'); + expect(reaches(cfg, returning, cfg.exitIndex)).toBe(true); + expect(reaches(cfg, returning, block(cfg, '[self unreachable]'))).toBe(false); + expect(reaches(cfg, cfg.entryIndex, block(cfg, '[self unreachable]'))).toBe(false); + }); + + it('keeps normal, return and break continuations separate after finally', () => { + const cfg = harness().cfgOf( + method(` + while (input) { + @try { + if ([self returning]) { return input; } + if ([self stopping]) { break; } + [self normal]; + } @finally { [self cleanup]; } + [self continued]; + } + [self afterLoop]; + return nil; + `), + ); + const continued = block(cfg, '[self continued]'); + expect(reaches(cfg, block(cfg, '[self normal]'), continued)).toBe(true); + expect(reaches(cfg, block(cfg, 'return input'), continued)).toBe(false); + expect(reaches(cfg, block(cfg, 'return input'), block(cfg, '[self afterLoop]'))).toBe(false); + expect(reaches(cfg, block(cfg, 'break;'), continued)).toBe(false); + expect(reaches(cfg, block(cfg, 'break;'), block(cfg, '[self afterLoop]'))).toBe(true); + }); + + it('lets a finally jump replace a return using the cleanup lexical loop context', () => { + const cfg = harness().cfgOf( + method(` + while (input) { + @try { while (input) { return input; } } + @finally { break; } + [self unreachable]; + } + [self afterLoop]; + return nil; + `), + ); + const returning = block(cfg, 'return input'); + expect(reaches(cfg, returning, block(cfg, '[self afterLoop]'))).toBe(true); + expect(reaches(cfg, returning, block(cfg, '[self unreachable]'))).toBe(false); + }); + + it.each(['return nil;', '@throw input;'])( + 'lets cleanup completion %s replace normal and return continuations', + (completion) => { + const cfg = harness().cfgOf( + method(` + @try { if (input) { return input; } [self normal]; } + @finally { ${completion} } + [self unreachable]; + `), + ); + for (const from of [block(cfg, 'return input'), block(cfg, '[self normal]')]) { + expect(reaches(cfg, from, cfg.exitIndex)).toBe(true); + expect(reaches(cfg, from, block(cfg, '[self unreachable]'))).toBe(false); + } + }, + ); + + it('keeps cleanup-local goto labels within their completion replay', () => { + const cfg = harness().cfgOf( + method(` + @try { if (input) { return input; } [self normal]; } + @finally { goto done; done: [self cleanup]; } + [self continued]; + return nil; + `), + ); + expect(reaches(cfg, block(cfg, '[self normal]'), block(cfg, '[self continued]'))).toBe(true); + expect(reaches(cfg, block(cfg, 'return input'), block(cfg, '[self continued]'))).toBe(false); + }); + + it('threads crossing break/continue through finally while preserving breaks inside the try', () => { + const cfg = harness().cfgOf( + method(` + while (input) { + @try { while (input) { break; } if (input) { continue; } break; } + @finally { [self cleanup]; } + } + return input; + `), + ); + const breaks = cfg.blocks.filter((b) => b.text === 'break;'); + expect( + cfg.edges.some( + (e) => + e.from === breaks[0].index && + cfg.blocks[e.to].text.includes('[self cleanup]') && + e.kind === 'break', + ), + ).toBe(false); + const breakCleanup = cfg.edges.find( + (edge) => edge.from === breaks[1].index && edge.kind === 'break', + )!.to; + const continueCleanup = cfg.edges.find( + (edge) => edge.from === block(cfg, 'continue;') && edge.kind === 'continue', + )!.to; + expect(cfg.blocks[breakCleanup].text).toContain('[self cleanup]'); + expect(cfg.blocks[continueCleanup].text).toContain('[self cleanup]'); + expect(breakCleanup).not.toBe(continueCleanup); + }); + + it('walks synchronized and autoreleasepool bodies, preserving return termination', () => { + const cfg = harness().cfgOf( + method(` + @synchronized (input) { if (input) { return input; } } + @autoreleasepool { return nil; } + [self unreachable]; + `), + ); + expect(reaches(cfg, block(cfg, 'return input'), block(cfg, '[self unreachable]'))).toBe(false); + expect(reaches(cfg, cfg.entryIndex, block(cfg, '[self unreachable]'))).toBe(false); + }); + + it('threads forward gotos out of try through cleanup without detouring in-region gotos', () => { + const cfg = harness().cfgOf( + method(` + @try { goto inside; inside: [self step]; goto outside; } + @finally { [self cleanup]; } + outside: return input; + `), + ); + const cleanup = cfg.edges.find( + (edge) => edge.from === block(cfg, 'goto outside;') && edge.kind === 'seq', + )!.to; + expect(cfg.blocks[cleanup].text).toContain('[self cleanup]'); + expect(cfg.edges).toContainEqual({ + from: block(cfg, 'goto inside;'), + to: block(cfg, '[self step]'), + kind: 'seq', + }); + expect(cfg.edges).toContainEqual({ + from: block(cfg, 'goto outside;'), + to: cleanup, + kind: 'seq', + }); + expect(cfg.edges).not.toContainEqual({ + from: block(cfg, 'goto outside;'), + to: block(cfg, 'return input;'), + kind: 'seq', + }); + expect(reaches(cfg, cleanup, block(cfg, 'return input;'))).toBe(true); + }); + + it('an empty try does not enter its catch on normal completion', () => { + const cfg = harness().cfgOf( + method(`@try {} @catch (id error) { return error; }\nreturn input;`), + ); + expect(reaches(cfg, cfg.entryIndex, block(cfg, 'return input;'))).toBe(true); + expect(reaches(cfg, cfg.entryIndex, block(cfg, 'return error;'))).toBe(false); + }); + + it('a rethrow from catch executes finally before reaching the outer catch', () => { + const cfg = harness().cfgOf( + method(` + @try { + @try { @throw input; } + @catch (id error) { @throw; } + @finally { [self cleanup]; } + } @catch (id outer) { [self recover:outer]; } + return input; + `), + ); + const rethrow = cfg.blocks.find((b) => b.text === '@throw;')!.index; + const cleanup = block(cfg, '[self cleanup]'); + expect(cfg.edges).toContainEqual({ from: rethrow, to: cleanup, kind: 'throw' }); + expect(reaches(cfg, cleanup, block(cfg, '[self recover:outer]'))).toBe(true); + const withoutCleanup = { + ...cfg, + edges: cfg.edges.filter((edge) => edge.from !== cleanup && edge.to !== cleanup), + }; + expect(reaches(withoutCleanup, rethrow, block(cfg, '[self recover:outer]'))).toBe(false); + expect( + reaches(withoutCleanup, block(cfg, '@throw input;'), block(cfg, '[self recover:outer]')), + ).toBe(false); + }); + + it('preserves source line and column anchors after provider preprocessing', () => { + const source = `NS_ASSUME_NONNULL_BEGIN\n${method('return [self transform:input];')}\nNS_ASSUME_NONNULL_END`; + const prepared = objectiveCProvider.preprocessSource!(source, 'fixture.m'); + const root = harness().parse(prepared); + const cfg = collectFunctionCfgs(root, objectiveCProvider.cfgVisitor!, 'fixture.m').cfgs[0]; + expect(cfg.functionStartLine).toBe(3); + expect(cfg.functionStartColumn).toBe(0); + expect(allSites(cfg)[0].at).toEqual([4, 7]); + }); + + // The visitor never throws for a construct it cannot model, so a build error is + // unreachable here. It has two reporting channels for a region it cannot resolve + // precisely: the unresolved-goto diagnostic and the shared nesting-depth skip. + it('routes a goto to an unresolved label to EXIT and reports the label', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + try { + const cfg = harness().cfgOf(method('[self work];\ngoto missing;')); + const jump = block(cfg, 'goto missing;'); + expect(cfg.edges).toContainEqual({ from: jump, to: cfg.exitIndex, kind: 'seq' }); + expect(isExitReachableFromAllBlocks(cfg)).toBe(true); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenCalledWith( + '[cfg] unresolved goto label "missing" routed to EXIT (1 site(s))', + ); + } finally { + warn.mockRestore(); + } + }); + + it('stays silent for a goto whose label is declared later in the method', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + try { + const cfg = harness().cfgOf(method('goto done;\n[self skipped];\ndone: [self finish];')); + expect(reaches(cfg, block(cfg, 'goto done;'), block(cfg, '[self finish]'))).toBe(true); + expect(reaches(cfg, block(cfg, 'goto done;'), block(cfg, '[self skipped]'))).toBe(false); + expect(warn).not.toHaveBeenCalled(); + } finally { + warn.mockRestore(); + } + }); + + it.each([ + [100, 0, 2, true], + [300, 1, 1, false], + ])( + 'applies the nesting budget to a method with %i nested @synchronized levels', + (depth, tooDeeplyNested, cfgCount, methodKept) => { + const body = `${'@synchronized (input) {\n'.repeat(depth)}[self work];\n${'}\n'.repeat(depth)}`; + const root = harness().parse(`${method(body)}\nint twice(int value) { return value * 2; }`); + expect(root.hasError).toBe(false); + const result = collectFunctionCfgs(root, objectiveCProvider.cfgVisitor!, 'fixture.m'); + expect(result.skipped).toEqual({ tooManyLines: 0, tooDeeplyNested, buildError: 0 }); + expect(result.cfgs).toHaveLength(cfgCount); + const texts = result.cfgs.flatMap((cfg) => cfg.blocks.map((b) => b.text)); + expect(texts.some((text) => text.includes('return value * 2'))).toBe(true); + expect(texts.some((text) => text.includes('[self work]'))).toBe(methodKept); + }, + ); +}); diff --git a/gitnexus/test/unit/cfg/source-provider.test.ts b/gitnexus/test/unit/cfg/source-provider.test.ts new file mode 100644 index 000000000..a21a9aae0 --- /dev/null +++ b/gitnexus/test/unit/cfg/source-provider.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it, vi } from 'vitest'; +import Parser from 'tree-sitter'; +import TypeScript from 'tree-sitter-typescript'; +import { createKnowledgeGraph } from '../../../src/core/graph/graph.js'; +import { createSemanticModel } from '../../../src/core/ingestion/model/semantic-model.js'; +import { typescriptScopeResolver } from '../../../src/core/ingestion/languages/typescript/scope-resolver.js'; +import { runScopeResolution } from '../../../src/core/ingestion/scope-resolution/pipeline/run.js'; +import { collectFunctionCfgs } from '../../../src/core/ingestion/cfg/collect.js'; +import { createTypeScriptCfgVisitor } from '../../../src/core/ingestion/cfg/visitors/typescript.js'; + +const source = 'function sample(value: number) {\n if (value) return value;\n return 0;\n}'; + +function sourceCollector(content: string, filePath: string, maxLines: number) { + const parser = new Parser(); + parser.setLanguage(TypeScript.typescript); + return collectFunctionCfgs( + parser.parse(content).rootNode, + createTypeScriptCfgVisitor(), + filePath, + maxLines, + ); +} + +function analyze(pdg: boolean, collector = vi.fn(sourceCollector), maxLines?: number) { + const graph = createKnowledgeGraph(); + const onWarn = vi.fn(); + const stats = runScopeResolution( + { + graph, + model: createSemanticModel(), + files: [{ path: 'source.ts', content: source }], + pdg, + pdgMaxFunctionLines: maxLines, + onWarn, + }, + { + ...typescriptScopeResolver, + languageProvider: { + ...typescriptScopeResolver.languageProvider, + collectSourceCfgs: collector, + }, + }, + ); + return { graph, stats, collector, onWarn }; +} + +describe('source-backed provider flow collection', () => { + it('feeds provider source CFGs through the existing CFG and CDG emission path', () => { + const { graph, collector } = analyze(true); + expect(collector).toHaveBeenCalledWith(source, 'source.ts', 2000); + const kinds = new Set([...graph.iterRelationships()].map((edge) => edge.type)); + expect(kinds.has('CFG')).toBe(true); + expect(kinds.has('CDG')).toBe(true); + }); + + it('does not collect or emit flow when PDG is disabled', () => { + const { graph, collector } = analyze(false); + expect(collector).not.toHaveBeenCalled(); + expect([...graph.iterNodes()].some((node) => node.label === 'BasicBlock')).toBe(false); + }); + + it('passes the configured source-region limit and reports skipped regions', () => { + const { graph, collector, onWarn } = analyze(true, vi.fn(sourceCollector), 1); + expect(collector).toHaveBeenCalledWith(source, 'source.ts', 1); + expect([...graph.iterNodes()].some((node) => node.label === 'BasicBlock')).toBe(false); + expect(onWarn).toHaveBeenCalledWith(expect.stringContaining('tooManyLines=1')); + }); + + it('keeps semantic extraction available and reports a source CFG provider failure', () => { + const collector = vi.fn(sourceCollector).mockImplementation(() => { + throw new Error('bad region'); + }); + const { stats, onWarn } = analyze(true, collector); + expect(stats.filesProcessed).toBe(1); + expect(onWarn).toHaveBeenCalledWith(expect.stringContaining('source CFG extraction failed')); + }); +}); diff --git a/gitnexus/test/unit/cfg/zig-visitor.test.ts b/gitnexus/test/unit/cfg/zig-visitor.test.ts new file mode 100644 index 000000000..a686a6245 --- /dev/null +++ b/gitnexus/test/unit/cfg/zig-visitor.test.ts @@ -0,0 +1,809 @@ +import { describe, expect, it } from 'vitest'; +import { SupportedLanguages } from 'gitnexus-shared'; +import { zigProvider } from '../../../src/core/ingestion/languages/zig.js'; +import { requireVendoredGrammar } from '../../../src/core/tree-sitter/vendored-grammars.js'; +import { isOptionalGrammarRequired } from '../../helpers/optional-grammar.js'; +import { makeCfgHarness, block, reaches, bindingIdx, allSites } from '../../helpers/cfg-harness.js'; +import { computeReachingDefs } from '../../../src/core/ingestion/cfg/reaching-defs.js'; +import { computeControlDependence } from '../../../src/core/ingestion/cfg/control-dependence.js'; +import { isExitReachableFromAllBlocks } from '../../../src/core/ingestion/cfg/post-dominators.js'; +import { collectFunctionCfgs } from '../../../src/core/ingestion/cfg/collect.js'; +import { + basicBlockId, + emitFileCfgs, + emitFileReachingDefs, + emitFileCdg, +} from '../../../src/core/ingestion/cfg/emit.js'; +import { decodeReachingDefReason } from '../../../src/core/ingestion/cfg/reaching-def-reason-codec.js'; +import type { FunctionCfg } from '../../../src/core/ingestion/cfg/types.js'; +import { createKnowledgeGraph } from '../../../src/core/graph/graph.js'; + +/** + * Backward dependence slice over the EMITTED graph (production REACHING_DEF + CDG + * emitters): from the block whose text contains `from`, follow each incoming + * REACHING_DEF edge to its defining block and each incoming CDG edge to its + * controller. Reports the parameters whose entry definitions the slice reaches and + * the controller blocks it visited. + */ +function backwardSlice( + cfg: FunctionCfg, + from: string, + followed: readonly string[] = ['REACHING_DEF', 'CDG'], +) { + const graph = createKnowledgeGraph(); + emitFileReachingDefs(graph, [cfg]); + emitFileCdg(graph, [cfg]); + const id = (index: number) => + basicBlockId(cfg.filePath, cfg.functionStartLine, cfg.functionStartColumn, index); + const indexOf = new Map(cfg.blocks.map((b) => [id(b.index), b.index])); + const incoming = new Map(); + for (const r of graph.iterRelationships()) { + if (!followed.includes(r.type)) continue; + const target = indexOf.get(r.targetId)!; + const list = incoming.get(target) ?? []; + list.push({ type: r.type, source: indexOf.get(r.sourceId)!, reason: r.reason }); + incoming.set(target, list); + } + const visited = new Set([block(cfg, from)]); + const queue = [...visited]; + const entryDefs = new Set(); + const controllers = new Set(); + for (const current of queue) { + for (const edge of incoming.get(current) ?? []) { + if (edge.type === 'CDG') controllers.add(cfg.blocks[edge.source].text); + else if (edge.source === cfg.entryIndex) + entryDefs.add(decodeReachingDefReason(edge.reason).name); + if (!visited.has(edge.source)) { + visited.add(edge.source); + queue.push(edge.source); + } + } + } + return { entryDefs: [...entryDefs].sort(), controllers: [...controllers].sort() }; +} + +let grammar: ReturnType | undefined; +try { + grammar = requireVendoredGrammar('tree-sitter-zig'); +} catch { + /* optional grammar */ +} + +describe.skipIf(!isOptionalGrammarRequired(SupportedLanguages.Zig))( + 'Zig CFG grammar presence', + () => { + it('loads the required vendored grammar', () => expect(grammar).toBeDefined()); + }, +); + +describe.skipIf(!grammar)('Zig provider CFG / shared PDG pipeline', () => { + const harness = () => { + expect(zigProvider.cfgVisitor, 'Zig must opt into the existing CFG pipeline').toBeDefined(); + return makeCfgHarness(grammar!, zigProvider.cfgVisitor!, 'fixture.zig'); + }; + + it('registers its visitor and preserves owning function spans', () => { + const h = harness(); + const cfgs = h.cfgsOf('fn a() void {} fn b() void { work(); }'); + expect(cfgs).toHaveLength(2); + expect(cfgs[1].functionStartColumn).toBeGreaterThan(cfgs[0].functionStartColumn); + expect(reaches(cfgs[0], cfgs[0].entryIndex, cfgs[0].exitIndex)).toBe(true); + }); + + it('feeds real branch control and reaching definitions to the shared solvers', () => { + const cfg = harness().cfgOf(`fn f(flag: bool, input: i32) i32 { + var value: i32 = input; + if (flag) { value = 1; } else { value = 2; } + return value; + }`); + const rd = computeReachingDefs(cfg); + const value = bindingIdx(cfg, 'value'); + const returning = block(cfg, 'return value'); + expect(rd.status).toBe('computed'); + expect( + new Set( + rd.facts + .filter((f) => f.bindingIdx === value && f.use.blockIndex === returning) + .map((f) => cfg.blocks[f.def.blockIndex].text), + ), + ).toEqual(new Set(['value = 1;', 'value = 2;'])); + const cdg = computeControlDependence(cfg); + expect(cdg.edges).toContainEqual({ + controllerBlock: block(cfg, 'flag'), + dependentBlock: block(cfg, 'value = 1;'), + label: 'T', + }); + expect(cdg.edges).toContainEqual({ + controllerBlock: block(cfg, 'flag'), + dependentBlock: block(cfg, 'value = 2;'), + label: 'F', + }); + }); + + it('routes labeled continue through while updates, and break bypasses loop else', () => { + const cfg = harness().cfgOf(`fn f(n: usize) void { + var i: usize = 0; + outer: while (i < n) : (i += 1) { + if (skip(i)) continue :outer; + if (stop(i)) break :outer; + step(i); + } else { exhausted(); } + done(); + }`); + expect(cfg.edges).toContainEqual({ + from: block(cfg, 'continue :outer'), + to: block(cfg, 'i += 1'), + kind: 'continue', + }); + expect(reaches(cfg, block(cfg, 'break :outer'), block(cfg, 'exhausted()'))).toBe(false); + expect(reaches(cfg, block(cfg, 'break :outer'), block(cfg, 'done()'))).toBe(true); + expect(isExitReachableFromAllBlocks(cfg)).toBe(true); + }); + + it('keeps breaks in a while continuation inside that loop and runs its cleanup', () => { + const cfg = harness().cfgOf(`fn f(flag: bool, stop: bool) void { + while (flag) : ({ + defer cleanup(); + if (stop) break; + update(); + }) { work(); continue; } else { exhausted(); } + done(); + }`); + const brk = block(cfg, 'break'); + expect(reaches(cfg, brk, block(cfg, 'done()'))).toBe(true); + expect(reaches(cfg, brk, block(cfg, 'exhausted()'))).toBe(false); + expect(reaches(cfg, brk, block(cfg, 'update()'))).toBe(false); + const cleanup = cfg.edges.find((edge) => edge.from === brk && edge.kind === 'break'); + expect(cfg.blocks[cleanup!.to].text).toContain('cleanup()'); + expect(reaches(cfg, block(cfg, 'continue'), block(cfg, 'update()'))).toBe(true); + expect(computeControlDependence(cfg).edges).toContainEqual({ + controllerBlock: block(cfg, 'stop'), + dependentBlock: brk, + label: 'T', + }); + expect(isExitReachableFromAllBlocks(cfg)).toBe(true); + }); + + it('binds for payloads to their body and has a structural infinite-loop escape', () => { + const cfg = harness().cfgOf(`fn f(xs: []i32) void { + for (xs, 0..) |*item, index| { use(item, index); } else { empty(); } + while (true) { tick(); } + }`); + for (const name of ['item', 'index']) { + const idx = bindingIdx(cfg, name); + expect(cfg.blocks.some((b) => b.statements?.some((s) => s.defs.includes(idx)))).toBe(true); + expect(computeReachingDefs(cfg).facts.some((f) => f.bindingIdx === idx)).toBe(true); + } + expect(isExitReachableFromAllBlocks(cfg)).toBe(true); + expect( + computeControlDependence(cfg).edges.some((e) => e.dependentBlock === block(cfg, 'tick()')), + ).toBe(true); + }); + + it('keeps distinct block shadows and sibling payloads out of one another', () => { + const cfg = harness().cfgOf(`fn f(value: i32, first: ?i32, second: ?i32) void { + { const value = 2; inner(value); } + if (first) |item| { one(item); } + if (second) |item| { two(item); } + outer(value); + }`); + const values = cfg.bindings!.flatMap((b, i) => (b.name === 'value' ? [i] : [])); + const items = cfg.bindings!.flatMap((b, i) => (b.name === 'item' ? [i] : [])); + expect(values).toHaveLength(2); + expect(items).toHaveLength(2); + const rd = computeReachingDefs(cfg); + expect( + rd.facts + .filter((f) => f.use.blockIndex === block(cfg, 'outer(value)')) + .map((f) => f.bindingIdx), + ).toContain(values[0]); + expect( + rd.facts + .filter((f) => f.use.blockIndex === block(cfg, 'outer(value)')) + .map((f) => f.bindingIdx), + ).not.toContain(values[1]); + expect(cfg.bindings!.filter((b) => b.kind === 'param').map((b) => b.formalIndex)).toEqual([ + 0, 1, 2, + ]); + }); + + it('keeps if error captures separate from success captures', () => { + const cfg = harness().cfgOf( + 'fn f(result: anyerror!i32) void { if (result) |v| { use(v); } else |err| { fail(err); } }', + ); + const rd = computeReachingDefs(cfg); + expect( + rd.facts.some( + (f) => f.bindingIdx === bindingIdx(cfg, 'v') && f.use.blockIndex === block(cfg, 'use(v)'), + ), + ).toBe(true); + expect( + rd.facts.some( + (f) => + f.bindingIdx === bindingIdx(cfg, 'err') && f.use.blockIndex === block(cfg, 'fail(err)'), + ), + ).toBe(true); + }); + + it('models switch and value-position if arms without fallthrough', () => { + const cfg = harness().cfgOf(`fn f(n: i32, flag: bool) i32 { + const selected = if (flag) left(n) else right(n); + return switch (selected) { 0 => zero(), 1...3 => small(), else => other(), }; + }`); + expect(reaches(cfg, block(cfg, 'left(n)'), block(cfg, 'right(n)'))).toBe(false); + expect(reaches(cfg, block(cfg, 'zero()'), block(cfg, 'small()'))).toBe(false); + expect(reaches(cfg, block(cfg, 'zero()'), cfg.exitIndex)).toBe(true); + expect( + allSites(cfg) + .filter((s) => s.kind === 'call') + .map((s) => s.callee) + .sort(), + ).toEqual(['left', 'other', 'right', 'small', 'zero']); + }); + + it.each([ + [ + 'if expression', + 'return x', + ['flag', 'input'], + `fn f(flag: bool, input: i32) i32 { + const x = if (flag) input else 0; + return x; + }`, + ], + [ + 'if payload', + 'return x', + ['opt', 'other'], + `fn f(opt: ?i32, other: i32) i32 { + const x = if (opt) |v| v else other; + return x; + }`, + ], + [ + 'switch expression', + 'return x', + ['a', 'b', 'kind'], + `fn f(kind: i32, a: i32, b: i32) i32 { + const x = switch (kind) { 0 => a, else => b, }; + return x; + }`, + ], + [ + 'switch payload', + 'return x', + ['u'], + `fn f(u: Union) i32 { + const x = switch (u) { .one => |v| v, else => 0, }; + return x; + }`, + ], + [ + 'orelse', + 'return x', + ['fallback', 'maybe'], + `fn f(maybe: ?i32, fallback: i32) i32 { + const x = maybe orelse fallback; + return x; + }`, + ], + [ + 'catch with payload', + 'return x', + ['input', 'other'], + `fn f(input: i32, other: i32) i32 { + const x = read(input) catch |err| handle(err, other); + return x; + }`, + ], + [ + 'try', + 'return x', + ['input'], + `fn f(input: i32) !i32 { + const x = try read(input); + return x; + }`, + ], + [ + 'labeled block', + 'return x', + ['flag', 'input'], + `fn f(flag: bool, input: i32) i32 { + const x = blk: { if (flag) break :blk input; break :blk 0; }; + return x; + }`, + ], + [ + 'compound assignment', + 'return x', + ['flag', 'input'], + `fn f(flag: bool, input: i32) i32 { + var x: i32 = 0; + x += if (flag) input else 1; + return x; + }`, + ], + [ + 'returned value', + 'return if', + ['flag', 'input'], + `fn f(flag: bool, input: i32) i32 { + return if (flag) input else 0; + }`, + ], + [ + 'call argument', + 'return x', + ['flag', 'input'], + `fn f(flag: bool, input: i32) i32 { + const x = pick(if (flag) input else 0); + return x; + }`, + ], + [ + 'branch condition', + 'return x', + ['a', 'b', 'input'], + `fn f(a: bool, b: bool, input: i32) i32 { + var x: i32 = 0; + if (a and b) { x = input; } + return x; + }`, + ], + [ + 'else-if chain', + 'return x', + ['a', 'b', 'p', 'q', 'r'], + `fn f(a: bool, b: bool, p: i32, q: i32, r: i32) i32 { + const x = if (a) p else if (b) q else r; + return x; + }`, + ], + [ + 'loop break value', + 'return x', + ['limit', 'seed'], + `fn f(limit: i32, seed: i32) i32 { + const x = while (cond(limit)) { break seed; } else 0; + return x; + }`, + ], + [ + 'loop else value', + 'return x', + ['fallback'], + `fn f(limit: i32, fallback: i32) i32 { + const x = while (cond(limit)) { work(); } else fallback; + return x; + }`, + ], + [ + 'labeled switch continue', + 'return x', + ['first', 'next'], + `fn f(first: i32, next: i32) i32 { + const x = dispatch: switch (first) { 0 => continue :dispatch next, else => |v| break :dispatch v, }; + return x; + }`, + ], + [ + 'store target', + 'obj.field =', + ['flag', 'input', 'obj'], + `fn f(flag: bool, input: i32, obj: Object) Object { + obj.field = if (flag) input else 0; + return obj; + }`, + ], + ])( + 'carries %s results into the destination definition (backward slice)', + (_name, from, expected, source) => { + const cfg = harness().cfgOf(source); + const slice = backwardSlice(cfg, from); + expect(computeReachingDefs(cfg).status).toBe('computed'); + expect(slice.entryDefs).toEqual(expect.arrayContaining(expected)); + }, + ); + + it.each([ + ['if', 'use(v)', ['opt'], 'fn f(opt: ?i32) void { if (opt) |v| { use(v); } }'], + [ + 'else error', + 'fail(err)', + ['result'], + 'fn f(result: anyerror!i32) void { if (result) |v| { use(v); } else |err| { fail(err); } }', + ], + ['for', 'use(item)', ['xs'], 'fn f(xs: []i32) void { for (xs) |item| { use(item); } }'], + [ + 'while', + 'use(v)', + ['cursor'], + 'fn f(cursor: Cursor) void { while (cursor.next()) |v| { use(v); } }', + ], + [ + 'switch', + 'use(v)', + ['u'], + 'fn f(u: Union) void { switch (u) { .one => |v| use(v), else => {}, } }', + ], + [ + 'catch', + 'handle(err)', + ['input'], + 'fn f(input: i32) void { _ = read(input) catch |err| handle(err); }', + ], + ])('binds a %s capture to the value it unwraps', (_name, from, expected, source) => { + // Data dependence only: the capture's arm is also control-dependent on the operand. + expect(backwardSlice(harness().cfgOf(source), from, ['REACHING_DEF']).entryDefs).toEqual( + expect.arrayContaining(expected), + ); + }); + + it('keeps a prior definition live on the arm that leaves the destination untouched', () => { + const cfg = harness().cfgOf(`fn f(flag: bool, input: i32) i32 { + var x: i32 = 0; + x = if (flag) input else x; + return x; + }`); + const rd = computeReachingDefs(cfg); + const defs = rd.facts + .filter( + (f) => f.bindingIdx === bindingIdx(cfg, 'x') && f.use.blockIndex === block(cfg, 'return x'), + ) + .map((f) => cfg.blocks[f.def.blockIndex].text); + expect(new Set(defs)).toEqual(new Set(['input', 'x'])); + const alternative = cfg.blocks.find((b) => b.text === 'x')!; + expect( + rd.facts.some( + (f) => + f.bindingIdx === bindingIdx(cfg, 'x') && + f.use.blockIndex === alternative.index && + cfg.blocks[f.def.blockIndex].text === 'var x: i32 = 0;', + ), + ).toBe(true); + }); + + it('carries a try value by data dependence, not only through its error branch', () => { + const cfg = harness().cfgOf(`fn f(input: i32) !i32 { + const x = try read(input); + return x; + }`); + expect(backwardSlice(cfg, 'return x', ['REACHING_DEF']).entryDefs).toEqual(['input']); + }); + + it('puts the controlling condition into the backward slice of a value-position if', () => { + const cfg = harness().cfgOf(`fn f(flag: bool, input: i32) i32 { + const x = if (flag) input else 0; + return x; + }`); + expect(backwardSlice(cfg, 'return x').controllers).toContain('flag'); + }); + + it('records each arm call once and routes its result to the destination', () => { + const cfg = harness().cfgOf(`fn f(flag: bool, n: i32) i32 { + const picked = if (flag) left(n) else try right(n); + return picked; + }`); + const calls = allSites(cfg).filter((s) => s.kind === 'call'); + expect(calls.map((s) => s.callee).sort()).toEqual(['left', 'right']); + for (const call of calls) expect(call.resultDefs).toEqual([bindingIdx(cfg, 'picked')]); + }); + + it('keeps value flow in nested control arms', () => { + const cfg = harness().cfgOf(`fn f(a: bool, b: bool, p: i32, q: i32, r: i32) i32 { + const x = if (a) (if (b) p else q) else r; + return x; + }`); + expect(backwardSlice(cfg, 'return x').entryDefs).toEqual(['a', 'b', 'p', 'q', 'r']); + }); + + it('resolves labeled block values and labeled switch continues', () => { + const cfg = harness().cfgOf(`fn f(n: i32) i32 { + const x = value: { if (n == 0) break :value 1; break :value n; }; + dispatch: switch (x) { 0 => continue :dispatch 1, else => break :dispatch, } + return x; + }`); + expect(reaches(cfg, block(cfg, 'break :value 1'), block(cfg, 'return x'))).toBe(true); + const continued = cfg.edges.find( + (e) => e.from === block(cfg, 'continue :dispatch') && e.kind === 'continue', + ); + expect(continued).toBeDefined(); + expect(cfg.blocks[continued!.to].text).toBe('x'); + expect(isExitReachableFromAllBlocks(cfg)).toBe(true); + }); + + it('runs block defers on normal exit in reverse order, before following statements', () => { + const cfg = harness().cfgOf(`fn f() void { + { defer first(); defer second(); work(); } + after(); + }`); + expect(cfg.edges).toContainEqual({ + from: block(cfg, 'work()'), + to: block(cfg, 'second()'), + kind: 'seq', + }); + expect(cfg.edges).toContainEqual({ + from: block(cfg, 'second()'), + to: block(cfg, 'first()'), + kind: 'seq', + }); + expect(cfg.edges).toContainEqual({ + from: block(cfg, 'first()'), + to: block(cfg, 'after()'), + kind: 'seq', + }); + expect(allSites(cfg).map((s) => s.callee)).toEqual(['work', 'second', 'first', 'after']); + }); + + it('runs only crossed defers on break and preserves branch-local registration', () => { + const cfg = harness().cfgOf(`fn f(flag: bool) void { + defer outer(); + while (flag) { defer inner(); break; } + if (flag) { defer branch(); work(); } + after(); + }`); + const brk = block(cfg, 'break'); + expect(cfg.edges).toContainEqual({ from: brk, to: block(cfg, 'inner()'), kind: 'break' }); + expect(reaches(cfg, block(cfg, 'outer()'), block(cfg, 'after()'))).toBe(false); + const branchCleanup = block(cfg, 'branch()'); + expect( + computeControlDependence(cfg).edges.some((e) => e.dependentBlock === branchCleanup), + ).toBe(true); + }); + + it('runs loop-body cleanup before continue reaches the while update', () => { + const cfg = harness().cfgOf( + 'fn f(flag: bool) void { defer outer(); while (flag) : (update()) { defer inner(); continue; } after(); }', + ); + expect(cfg.edges).toContainEqual({ + from: block(cfg, 'continue'), + to: block(cfg, 'inner()'), + kind: 'continue', + }); + expect(cfg.edges).toContainEqual({ + from: block(cfg, 'inner()'), + to: block(cfg, 'update()'), + kind: 'finally-continue', + }); + expect(reaches(cfg, block(cfg, 'outer()'), block(cfg, 'update()'))).toBe(false); + }); + + it('runs errdefer only on error-return paths and binds its error capture', () => { + const cfg = harness().cfgOf(`fn f(flag: bool) !i32 { + defer always(); + errdefer |err| report(err); + if (flag) return error.Failed; + return 1; + }`); + expect(reaches(cfg, block(cfg, 'return error.Failed'), block(cfg, 'report(err)'))).toBe(true); + expect(reaches(cfg, block(cfg, 'return 1'), block(cfg, 'report(err)'))).toBe(false); + expect( + computeReachingDefs(cfg).facts.some( + (f) => + f.bindingIdx === bindingIdx(cfg, 'err') && f.use.blockIndex === block(cfg, 'report(err)'), + ), + ).toBe(true); + expect(allSites(cfg).filter((s) => s.callee === 'always')).toHaveLength(2); + }); + + it('models try propagation and catch recovery without pretending ordinary calls throw', () => { + const cfg = harness().cfgOf(`fn f(input: i32) !i32 { + errdefer fail(); + const a = try read(input); + const b = recover(a) catch |err| fallback(err); + observe(b); + return b; + }`); + expect( + cfg.edges.some((e) => e.from === block(cfg, 'try read(input)') && e.kind === 'throw'), + ).toBe(true); + expect(reaches(cfg, block(cfg, 'fallback(err)'), block(cfg, 'observe(b)'))).toBe(true); + expect(cfg.edges.some((e) => e.from === block(cfg, 'observe(b)') && e.kind === 'throw')).toBe( + false, + ); + expect(allSites(cfg).find((s) => s.callee === 'read')?.resultDefs).toEqual([ + bindingIdx(cfg, 'a'), + ]); + }); + + it.each(['error{Oops}!i32', 'Result'])( + 'runs errdefer for explicit errors with return type %s', + (returnType) => { + const cfg = harness().cfgOf(`const Result = error{Oops}!i32; + fn f(flag: bool) ${returnType} { + errdefer cleanup(); + if (flag) return error.Oops; + return 1; + }`); + const cleanup = block(cfg, 'cleanup()'); + expect(reaches(cfg, block(cfg, 'return error.Oops'), cleanup)).toBe(true); + expect(reaches(cfg, block(cfg, 'return 1'), cleanup)).toBe(false); + }, + ); + + it('preserves success and error cleanup paths for an unknown aliased result', () => { + const cfg = harness().cfgOf(`const Result = error{Oops}!void; + fn f(value: Result) Result { errdefer cleanup(); return value; }`); + const returning = block(cfg, 'return value'); + expect(reaches(cfg, returning, block(cfg, 'cleanup()'))).toBe(true); + expect(cfg.edges).toContainEqual({ from: returning, to: cfg.exitIndex, kind: 'return' }); + }); + + it('counts unsupported CFGs while preserving supported functions and bodyless declarations', () => { + const root = harness().parse(`extern fn external() void; + fn normal() void { work(); } + fn hasAssembly() void { asm volatile ("nop"); }`); + expect(root.hasError).toBe(false); + const result = collectFunctionCfgs(root, zigProvider.cfgVisitor!, 'fixture.zig'); + expect(result.cfgs).toHaveLength(1); + expect(allSites(result.cfgs[0]).map((site) => site.callee)).toEqual(['work']); + expect(result.skipped).toEqual({ tooManyLines: 0, tooDeeplyNested: 0, buildError: 1 }); + }); + + it('harvests scalar assignments, receiver uses, nested call provenance and source anchors', () => { + const cfg = harness().cfgOf(`fn f(input: i32, obj: Object) void { + var value = input; + value += input; + obj.field = value; + const out = obj.send(clean(value)); + _ = out; + }`); + expect( + cfg.bindings!.some((b) => b.name === '_' || b.name === 'field' || b.name === 'send'), + ).toBe(false); + const assignment = cfg.blocks[block(cfg, 'value += input')].statements![0]; + expect(assignment.defs).toContain(bindingIdx(cfg, 'value')); + expect(assignment.uses).toContain(bindingIdx(cfg, 'value')); + expect(cfg.blocks[block(cfg, 'obj.field = value')].statements![0].defs).not.toContain( + bindingIdx(cfg, 'obj'), + ); + const sites = allSites(cfg); + expect(sites.find((s) => s.callee === 'obj.send')).toMatchObject({ + receiver: bindingIdx(cfg, 'obj'), + resultDefs: [bindingIdx(cfg, 'out')], + at: [5, 18], + }); + expect(sites.find((s) => s.callee === 'clean')?.parent).toEqual([0, 0]); + }); + + it('keeps short-circuit side effects conditional', () => { + const cfg = harness().cfgOf( + 'fn f(flag: bool) void { var x: i32 = 0; _ = flag and blk: { x = 1; break :blk true; }; use(x); }', + ); + const rd = computeReachingDefs(cfg); + const defs = rd.facts.filter( + (f) => f.bindingIdx === bindingIdx(cfg, 'x') && f.use.blockIndex === block(cfg, 'use(x)'), + ); + expect(new Set(defs.map((f) => cfg.blocks[f.def.blockIndex].text))).toEqual( + new Set(['var x: i32 = 0;', 'x = 1;']), + ); + }); + + it('collects nested methods separately and never harvests their bodies in the outer CFG', () => { + const cfgs = harness().cfgsOf( + 'fn outer() void { const S = struct { fn inner() void { hidden(); } }; visible(); }', + ); + expect(cfgs).toHaveLength(2); + expect(allSites(cfgs[0]).map((s) => s.callee)).toEqual(['visible']); + expect(allSites(cfgs[1]).map((s) => s.callee)).toEqual(['hidden']); + }); + + it('retains usable control dependence when all branch arms return', () => { + const cfg = harness().cfgOf( + 'fn f(flag: bool) i32 { if (flag) { return 1; } else { return 2; } }', + ); + expect(isExitReachableFromAllBlocks(cfg)).toBe(true); + expect( + computeControlDependence(cfg).edges.some((e) => e.dependentBlock === block(cfg, 'return 1')), + ).toBe(true); + }); + + it('skips malformed and unsupported function shapes without inventing precise flow', () => { + const h = harness(); + expect(h.cfgsOf('extern fn external() void;')).toEqual([]); + for (const body of ['break :unknown;', 'asm volatile ("nop");']) { + const result = collectFunctionCfgs( + h.parse(`fn f() void { ${body} }`), + zigProvider.cfgVisitor!, + 'fixture.zig', + ); + expect(result.cfgs).toEqual([]); + expect(result.skipped.buildError).toBe(1); + } + expect(h.cfgsOf('fn f() void { if (')).toEqual([]); + }); + + it('treats anonymous unreachable expression arms as terminating traps without cleanup', () => { + const cfg = harness().cfgOf( + 'fn f() i32 { defer cleanup(); const n = read() catch unreachable; return n; }', + ); + const trap = cfg.blocks.find((b) => b.text === 'unreachable')?.index; + expect(trap).toBeDefined(); + expect(reaches(cfg, trap!, block(cfg, 'cleanup()'))).toBe(false); + expect(reaches(cfg, trap!, block(cfg, 'return n'))).toBe(false); + }); + + it('keeps null/undefined argument slots instead of shifting later binding positions', () => { + const cfg = harness().cfgOf('fn f(input: i32) void { send(null, undefined, input); }'); + expect(allSites(cfg).find((s) => s.callee === 'send')?.args).toEqual([ + [], + [], + [bindingIdx(cfg, 'input')], + ]); + }); + + it('harvests builtin callees and argument positions alongside ordinary calls', () => { + const cfg = harness().cfgOf(`fn f(input: u32, obj: Object) void { + const converted = @as(u64, input); + obj.send(converted, @as(u64, input)); + }`); + const sites = allSites(cfg); + const builtins = sites.filter((site) => site.callee === '@as'); + expect(builtins).toHaveLength(2); + expect(builtins[0]).toMatchObject({ + resultDefs: [bindingIdx(cfg, 'converted')], + args: [[], [bindingIdx(cfg, 'input')]], + }); + expect(builtins[1].args).toEqual([[], [bindingIdx(cfg, 'input')]]); + expect(sites.find((site) => site.callee === 'obj.send')).toMatchObject({ + receiver: bindingIdx(cfg, 'obj'), + args: [[bindingIdx(cfg, 'converted')], [[bindingIdx(cfg, 'input'), 1]]], + }); + expect(builtins[1].parent).toEqual([0, 1]); + }); + + it('evaluates for iterable expressions once, outside the loop back edge', () => { + const cfg = harness().cfgOf('fn f() void { for (items()) |item| { use(item); } }'); + expect(reaches(cfg, block(cfg, 'use(item)'), block(cfg, 'items()'))).toBe(false); + }); + + it('does not duplicate nested calls when a call argument contains control flow', () => { + const cfg = harness().cfgOf( + 'fn f(flag: bool) !void { send(if (flag) left() else try right()); }', + ); + expect( + allSites(cfg) + .map((s) => s.callee) + .sort(), + ).toEqual(['left', 'right', 'send']); + expect(reaches(cfg, block(cfg, 'left()'), block(cfg, 'try right()'))).toBe(false); + }); + + it('does not let a switch capture unlabeled loop breaks or continues', () => { + const cfg = harness().cfgOf(`fn f(flag: bool, value: i32) void { + while (flag) : (update()) { + dispatch: switch (value) { 0 => continue, else => break, } + unreachable_tail(); + } + after(); + }`); + expect(cfg.edges).toContainEqual({ + from: block(cfg, 'continue'), + to: block(cfg, 'update()'), + kind: 'continue', + }); + expect(reaches(cfg, block(cfg, 'break'), block(cfg, 'after()'))).toBe(true); + expect(reaches(cfg, block(cfg, 'break'), block(cfg, 'unreachable_tail()'))).toBe(false); + }); + + it('round-trips worker facts through generic CFG, reaching-def and CDG graph emission', () => { + const h = harness(); + const root = h.parse( + 'fn f(flag: bool, input: i32) i32 { var value = input; if (flag) { value = 2; } return value; }', + ); + const collected = collectFunctionCfgs(root, zigProvider.cfgVisitor!, 'roundtrip.zig'); + expect(collected.skipped).toEqual({ tooManyLines: 0, tooDeeplyNested: 0, buildError: 0 }); + const cfgs = JSON.parse(JSON.stringify(collected.cfgs)); + const graph = createKnowledgeGraph(); + expect(emitFileCfgs(graph, cfgs).edges).toBeGreaterThan(0); + expect(emitFileReachingDefs(graph, cfgs).edges).toBeGreaterThan(0); + expect(emitFileCdg(graph, cfgs).edges).toBeGreaterThan(0); + const relations = [...graph.iterRelationships()]; + expect(new Set(relations.map((r) => r.type))).toEqual(new Set(['CFG', 'REACHING_DEF', 'CDG'])); + for (const relationship of relations) { + expect(graph.getNode(relationship.sourceId)?.label).toBe('BasicBlock'); + expect(graph.getNode(relationship.targetId)?.label).toBe('BasicBlock'); + } + }); +}); diff --git a/gitnexus/test/unit/dart-import-resolver.test.ts b/gitnexus/test/unit/dart-import-resolver.test.ts index ff5dfdc3e..938c88913 100644 --- a/gitnexus/test/unit/dart-import-resolver.test.ts +++ b/gitnexus/test/unit/dart-import-resolver.test.ts @@ -12,6 +12,8 @@ */ import { describe, it, expect } from 'vitest'; +import { emitDartScopeCaptures } from '../../src/core/ingestion/languages/dart/captures.js'; +import { interpretDartImport } from '../../src/core/ingestion/languages/dart/interpret.js'; import { dartPackageStrategy, dartRelativeStrategy, @@ -147,3 +149,24 @@ describe('Dart import resolver (full config)', () => { expect(result).toEqual({ kind: 'files', files: ['lib/models/user.dart'] }); }); }); + +describe('Dart import prefix semantic captures', () => { + it('keeps prefixed imports as namespace handles and ordinary imports as wildcards', () => { + const captures = emitDartScopeCaptures( + "import 'model.dart' as lib;\nimport 'other.dart';\n", + 'caller.dart', + ); + expect( + captures.filter((capture) => capture['@import.statement']).map(interpretDartImport), + ).toEqual([ + { + kind: 'namespace', + localName: 'lib', + importedName: 'model.dart', + targetRaw: 'model.dart', + explicitAlias: true, + }, + { kind: 'wildcard', targetRaw: 'other.dart' }, + ]); + }); +}); diff --git a/gitnexus/test/unit/graph-rename.test.ts b/gitnexus/test/unit/graph-rename.test.ts new file mode 100644 index 000000000..101751172 --- /dev/null +++ b/gitnexus/test/unit/graph-rename.test.ts @@ -0,0 +1,1362 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import type { GraphNode } from 'gitnexus-shared'; +import { planGraphRename } from '../../src/mcp/local/rename/graph-rename.js'; +import { renameSymbol, type RenameSymbol } from '../../src/mcp/local/rename/rename-plan.js'; + +describe('graph and SemanticModel rename', () => { + let root: string; + beforeEach(async () => { + root = await fs.mkdtemp(path.join(os.tmpdir(), 'graph-rename-')); + }); + afterEach(async () => { + await fs.rm(root, { recursive: true, force: true }); + }); + + async function fixture(file: string, source: string, extra: GraphNode[] = []) { + await fs.writeFile(path.join(root, file), source); + return [ + { id: `File:${file}`, label: 'File', properties: { name: file, filePath: file } }, + { + id: `Function:${file}:target`, + label: 'Function', + properties: { name: 'target', filePath: file, startLine: 1, endLine: 1 }, + }, + ...extra, + ] as GraphNode[]; + } + const cases = [ + ['model.ts', 'function target() {}\ntarget();\n// target\nconst text = "target";\n'], + ['model.js', 'function target() {}\ntarget();\n// target\nconst text = "target";\n'], + ['model.py', 'def target(): pass\ntarget()\n# target\ntext = "target"\n'], + ['model.rs', 'fn target() {}\nfn main() { target(); }\n// target\n'], + ['model.go', 'package model\nfunc target() {}\nfunc caller() { target() }\n// target\n'], + ]; + + async function expectBlockedPreviewAndApply( + file: string, + source: string, + symbol: RenameSymbol, + nodes: GraphNode[], + code: string, + newName = 'Renamed', + ) { + for (const dry_run of [true, false]) { + const result = await renameSymbol(root, symbol, { new_name: newName, dry_run }, nodes); + expect(result, JSON.stringify(result)).toMatchObject({ + status: 'error', + application_status: 'not_started', + applied: false, + total_edits: 0, + files_affected: 0, + changes: [], + code, + }); + expect(result.planning_status).not.toBe('ready'); + expect(await fs.readFile(path.join(root, file))).toEqual(Buffer.from(source)); + } + } + + async function expectSupportedPreviewAndApply( + file: string, + source: string, + symbol: RenameSymbol, + nodes: GraphNode[], + expected: string, + newName = 'Renamed', + ) { + const preview = await renameSymbol(root, symbol, { new_name: newName, dry_run: true }, nodes); + expect(preview, JSON.stringify(preview)).toMatchObject({ status: 'success', applied: false }); + expect(preview.total_edits).toBeGreaterThan(0); + expect(await fs.readFile(path.join(root, file), 'utf8')).toBe(source); + const applied = await renameSymbol(root, symbol, { new_name: newName, dry_run: false }, nodes); + expect(applied, JSON.stringify(applied)).toMatchObject({ + status: 'success', + applied: true, + changes: preview.changes, + }); + expect(await fs.readFile(path.join(root, file), 'utf8')).toBe(expected); + } + + it.each([ + [ + 'escaped JS reference', + 'model.js', + 'function target() {}\nt\\u0061rget();\n', + 'target', + 'Function', + 'function Renamed() {}\nRenamed();\n', + ], + [ + 'escaped TS reference', + 'model.ts', + 'function target() {}\ntar\\u{67}et();\n', + 'target', + 'Function', + 'function Renamed() {}\nRenamed();\n', + ], + [ + 'escaped shadow binder', + 'model.ts', + 'function target() {}\nfunction other(t\\u0061rget) { target(); }\n', + 'target', + 'Function', + 'function Renamed() {}\nfunction other(t\\u0061rget) { target(); }\n', + ], + [ + 'Python normalized reference', + 'model.py', + 'def K(): pass\nK()\n', + 'K', + 'Function', + 'def Renamed(): pass\nRenamed()\n', + ], + [ + 'Python normalized shadow binder', + 'model.py', + 'def K(): pass\ndef other(K):\n K()\n', + 'K', + 'Function', + 'def Renamed(): pass\ndef other(K):\n K()\n', + ], + ] as const)( + 'renames the provider-canonical binding and preserves shadows for %s', + async (_name, file, source, name, kind, expected) => { + const nodes = await fixture(file, source); + nodes[1] = { + id: `${kind}:${file}:${name}`, + label: kind, + properties: { name, filePath: file, startLine: 0, endLine: 0 }, + }; + await expectSupportedPreviewAndApply( + file, + source, + { + uid: nodes[1].id, + name, + kind, + filePath: file, + startLine: 1, + }, + nodes, + expected, + ); + }, + ); + + it('refuses a collision with a provider-canonical escaped declaration', async () => { + const file = 'model.ts'; + const source = 'function target() {}\nconst Re\\u006eamed = 1;\ntarget();\n'; + const nodes = await fixture(file, source); + nodes[1]!.properties.startLine = 0; + await expectBlockedPreviewAndApply( + file, + source, + { uid: nodes[1]!.id, name: 'target', filePath: file, startLine: 1 }, + nodes, + 'name_collision', + ); + }); + + it('renames a provider-proven verbatim type reference', async () => { + const file = 'model.cs'; + const source = 'class Target {}\nclass Use { @Target value; }\n'; + const nodes = await fixture(file, source); + nodes[1] = { + id: 'Class:model.cs:Target', + label: 'Class', + properties: { name: 'Target', filePath: file, startLine: 0, endLine: 0 }, + }; + await expectSupportedPreviewAndApply( + file, + source, + { uid: nodes[1]!.id, name: 'Target', kind: 'Class', filePath: file, startLine: 1 }, + nodes, + 'class Renamed {}\nclass Use { @Renamed value; }\n', + ); + }); + + it.each([''])( + 'blocks JSX component-to-intrinsic renames in preview and apply: %s', + async (element) => { + const file = 'model.tsx'; + const source = `function Target() { return null; }\nconst element = ${element};\n`; + const nodes = await fixture(file, source); + nodes[1] = { + id: `Function:${file}:Target`, + label: 'Function', + properties: { name: 'Target', filePath: file, startLine: 0, endLine: 0 }, + }; + await expectBlockedPreviewAndApply( + file, + source, + { + uid: nodes[1].id, + name: 'Target', + kind: 'Function', + filePath: file, + startLine: 1, + }, + nodes, + 'invalid_name', + 'widget', + ); + }, + ); + + it('preserves JSX component roles in a successful preview and apply', async () => { + const file = 'model.tsx'; + const source = 'function Target() { return null; }\nconst element = ;\n'; + const nodes = await fixture(file, source); + nodes[1] = { + id: `Function:${file}:Target`, + label: 'Function', + properties: { name: 'Target', filePath: file, startLine: 0, endLine: 0 }, + }; + const symbol = { + uid: nodes[1].id, + name: 'Target', + kind: 'Function', + filePath: file, + startLine: 1, + }; + const preview = await renameSymbol(root, symbol, { new_name: 'Widget', dry_run: true }, nodes); + expect(preview, JSON.stringify(preview)).toMatchObject({ status: 'success', total_edits: 2 }); + expect(await fs.readFile(path.join(root, file), 'utf8')).toBe(source); + const applied = await renameSymbol(root, symbol, { new_name: 'Widget', dry_run: false }, nodes); + expect(applied).toMatchObject({ status: 'success', applied: true, changes: preview.changes }); + expect(await fs.readFile(path.join(root, file), 'utf8')).toBe( + 'function Widget() { return null; }\nconst element = ;\n', + ); + }); + + it.each([ + ['private field', ' __secret = 42\n', 'w = Writer()\nprint(w._Writer__secret)\n'], + [ + 'private method', + ' def __secret(self): return 42\n', + 'w = Writer()\nprint(w._Writer__secret())\n', + ], + [ + 'private local in a method', + ' def read(self):\n __secret = 42\n return __secret\n', + 'w = Writer()\n', + ], + [ + 'private tuple slot', + ' __slots__ = ("__secret",)\n', + 'w = Writer()\nw._Writer__secret = 42\nprint(w._Writer__secret)\n', + ], + ['private string slot', ' __slots__ = "__secret"\n', 'w = Writer()\n'], + ['unknown slot expression', ' __slots__ = make_slots()\n', 'w = Writer()\n'], + [ + 'loop-bound private slots', + ' for __slots__ in [("__secret",)]: pass\n', + 'w = Writer()\nw._Writer__secret = 42\nprint(w._Writer__secret)\n', + ], + [ + 'named-expression private slots', + ' (__slots__ := ("__secret",))\n', + 'w = Writer()\nw._Writer__secret = 42\nprint(w._Writer__secret)\n', + ], + ])('blocks Python class renames with a mangled %s', async (_name, body, use) => { + const file = 'model.py'; + const source = `class Writer:\n${body}${use}`; + const uid = `Class:${file}:Writer`; + const nodes = await fixture(file, source, [ + { + id: uid, + label: 'Class', + properties: { name: 'Writer', filePath: file, startLine: 0 }, + }, + ]); + await expectBlockedPreviewAndApply( + file, + source, + { + uid, + name: 'Writer', + kind: 'Class', + filePath: file, + startLine: 1, + }, + nodes, + 'unsupported_symbol_family', + ); + }); + + it.each([ + ['shadow property', 'const object = { target: () => "object" };\nwith (object) target();\n'], + ['unknown object', 'with (object) target();\n'], + ['deferred closure', 'with (object) { function invoke() { return target(); } }\n'], + ])('blocks dynamic with bindings in preview and apply: %s', async (_name, use) => { + const file = 'model.js'; + const source = 'function target() { return "global"; }\n' + use; + const nodes = await fixture(file, source); + nodes[1]!.properties.startLine = 0; + await expectBlockedPreviewAndApply( + file, + source, + { + uid: nodes[1]!.id, + name: 'target', + kind: 'Function', + filePath: file, + startLine: 1, + }, + nodes, + 'unsupported_occurrence', + ); + }); + + it.each([ + [ + 'calls before and after a write', + 'let writer = new Writer();\nwriter.close();\nwriter = new Other();\nwriter.close();\n', + ], + [ + 'write before the call', + 'let writer = new Writer();\nwriter = new Other();\nwriter.close();\n', + ], + [ + 'write after the call', + 'let writer = new Writer();\nwriter.close();\nwriter = new Other();\n', + ], + [ + 'conditional write', + 'let writer = new Writer();\nif (false) { writer = new Other(); }\nwriter.close();\n', + ], + ['unknown write', 'let writer = new Writer();\nwriter = obtain();\nwriter.close();\n'], + [ + 'closure write', + 'let writer = new Writer();\nfunction change() { writer = new Other(); }\nwriter.close();\n', + ], + [ + 'destructuring write', + 'let writer = new Writer();\n[writer] = [new Other()];\nwriter.close();\n', + ], + ['structural initializer', 'const writer: Writer = new Other();\nwriter.close();\n'], + [ + 'structural parameter', + 'function use(writer: Writer) { writer.close(); }\nuse(new Other());\n', + ], + [ + 'conditional var redeclaration to another class', + 'var writer = new Writer();\nif (false) { var writer = new Other(); }\nwriter.close();\n', + 'model.js', + ], + [ + 'conditional var redeclaration to the target class', + 'var writer = new Other();\nif (false) { var writer = new Writer(); }\nwriter.close();\n', + 'model.js', + ], + [ + 'conditional var before the final declaration', + 'if (false) { var writer = new Other(); }\nvar writer = new Writer();\nwriter.close();\n', + 'model.js', + ], + [ + 'conditional target var before the final declaration', + 'if (false) { var writer = new Writer(); }\nvar writer = new Other();\nwriter.close();\n', + 'model.js', + ], + [ + 'function-hoisted conditional var redeclaration', + 'function use() {\nvar writer = new Writer();\nif (false) { var writer = new Other(); }\nwriter.close();\n}\nuse();\n', + 'model.js', + ], + ])( + 'blocks unproven receiver ownership in preview and apply: %s', + async (_name, use, file = 'model.ts') => { + const source = 'class Writer {\n close() {}\n}\nclass Other {\n close() {}\n}\n' + use; + const uid = `Method:${file}:Writer.close#0`; + const nodes = await fixture(file, source, [ + { + id: `Class:${file}:Writer`, + label: 'Class', + properties: { name: 'Writer', filePath: file, startLine: 0, endLine: 2 }, + }, + { + id: `Class:${file}:Other`, + label: 'Class', + properties: { name: 'Other', filePath: file, startLine: 3, endLine: 5 }, + }, + { + id: uid, + label: 'Method', + properties: { name: 'close', filePath: file, startLine: 1, endLine: 1 }, + }, + { + id: `Method:${file}:Other.close#0`, + label: 'Method', + properties: { name: 'close', filePath: file, startLine: 4, endLine: 4 }, + }, + ]); + await expectBlockedPreviewAndApply( + file, + source, + { + uid, + name: 'close', + kind: 'Method', + filePath: file, + startLine: 2, + }, + nodes, + 'ambiguous_reference', + ); + }, + ); + + it.each([ + ['constant receiver', 'const writer = new Writer();\nwriter.close();\n'], + ['unwritten mutable receiver', 'let writer = new Writer();\nwriter.close();\n'], + ['stable var receiver', 'var writer = new Writer();\nwriter.close();\n'], + [ + 'block let shadow', + 'const writer = new Writer();\nif (false) { let writer = obtain(); }\nwriter.close();\n', + ], + [ + 'block const shadow', + 'const writer = new Writer();\nif (false) { const writer = obtain(); }\nwriter.close();\n', + ], + [ + 'separate function var', + 'var writer = new Writer();\nfunction other() { var writer = obtain(); }\nwriter.close();\n', + ], + ['matching annotated initializer', 'const writer: Writer = new Writer();\nwriter.close();\n'], + [ + 'unrelated shadow write', + 'const writer = new Writer();\nfunction change(writer) { writer = obtain(); }\nwriter.close();\n', + ], + ])('preserves constructor-proven member preview and apply: %s', async (_name, use) => { + const file = 'model.ts'; + const source = 'class Writer {\n close() {}\n}\n' + use; + const uid = `Method:${file}:Writer.close#0`; + const nodes = await fixture(file, source, [ + { + id: `Class:${file}:Writer`, + label: 'Class', + properties: { name: 'Writer', filePath: file, startLine: 0, endLine: 2 }, + }, + { + id: uid, + label: 'Method', + properties: { name: 'close', filePath: file, startLine: 1, endLine: 1 }, + }, + ]); + const symbol = { uid, name: 'close', kind: 'Method', filePath: file, startLine: 2 }; + const preview = await renameSymbol(root, symbol, { new_name: 'finish', dry_run: true }, nodes); + expect(preview, JSON.stringify(preview)).toMatchObject({ status: 'success', total_edits: 2 }); + expect(preview.changes[0]!.edits.map((edit) => edit.start)).toEqual([ + source.indexOf('close'), + source.lastIndexOf('close'), + ]); + expect(await fs.readFile(path.join(root, file), 'utf8')).toBe(source); + const applied = await renameSymbol(root, symbol, { new_name: 'finish', dry_run: false }, nodes); + expect(applied).toMatchObject({ status: 'success', applied: true, changes: preview.changes }); + expect(await fs.readFile(path.join(root, file), 'utf8')).toBe( + source.replaceAll('close', 'finish'), + ); + }); + + it.each([ + [ + 'prototype function expression', + 'model.js', + 'class Writer {\n closeWriter() {}\n}\nWriter.prototype.flush = function () { return this.closeWriter(); };\n', + ], + [ + 'function with a this parameter', + 'model.ts', + 'class Writer {\n closeWriter() {}\n}\nexport function flush(this: Writer) { return this.closeWriter(); }\n', + ], + ])('refuses a member call on a call-site-bound this: %s', async (_name, file, source) => { + const uid = `Method:${file}:Writer.closeWriter#0`; + const nodes = await fixture(file, source, [ + { + id: `Class:${file}:Writer`, + label: 'Class', + properties: { name: 'Writer', filePath: file, startLine: 0, endLine: 2 }, + }, + { + id: uid, + label: 'Method', + properties: { name: 'closeWriter', filePath: file, startLine: 1, endLine: 1 }, + }, + ]); + await expectBlockedPreviewAndApply( + file, + source, + { uid, name: 'closeWriter', kind: 'Method', filePath: file, startLine: 2 }, + nodes, + 'ambiguous_reference', + 'shutdown', + ); + }); + + it('preserves provider-proven this receiver ownership', async () => { + const file = 'model.ts'; + const source = 'class Writer {\n close() {}\n use() { this.close(); }\n}\n'; + const uid = `Method:${file}:Writer.close#0`; + const nodes = await fixture(file, source, [ + { + id: `Class:${file}:Writer`, + label: 'Class', + properties: { name: 'Writer', filePath: file, startLine: 0, endLine: 3 }, + }, + { + id: uid, + label: 'Method', + properties: { name: 'close', filePath: file, startLine: 1, endLine: 1 }, + }, + ]); + const plan = await planGraphRename( + root, + { + uid, + name: 'close', + kind: 'Method', + filePath: file, + startLine: 2, + }, + { new_name: 'finish' }, + nodes, + ); + expect(plan.edits.map((edit) => edit.start)).toEqual([ + source.indexOf('close'), + source.lastIndexOf('close'), + ]); + }); + + it('preserves Python lexical with scopes', async () => { + const file = 'model.py'; + const source = 'def target(): pass\nwith open("file") as stream:\n target()\n'; + const nodes = await fixture(file, source); + nodes[1]!.properties.startLine = 0; + const symbol = { + uid: nodes[1]!.id, + name: 'target', + kind: 'Function', + filePath: file, + startLine: 1, + }; + const preview = await renameSymbol(root, symbol, { new_name: 'renamed', dry_run: true }, nodes); + expect(preview, JSON.stringify(preview)).toMatchObject({ status: 'success', total_edits: 2 }); + expect(await fs.readFile(path.join(root, file), 'utf8')).toBe(source); + const applied = await renameSymbol( + root, + symbol, + { new_name: 'renamed', dry_run: false }, + nodes, + ); + expect(applied).toMatchObject({ status: 'success', applied: true, changes: preview.changes }); + expect(await fs.readFile(path.join(root, file), 'utf8')).toBe( + 'def renamed(): pass\nwith open("file") as stream:\n renamed()\n', + ); + }); + + it('preserves Python member renames for a single constructor assignment', async () => { + const file = 'model.py'; + const source = 'class Writer:\n def close(self): pass\nwriter = Writer()\nwriter.close()\n'; + const uid = `Method:${file}:Writer.close#0`; + const nodes = await fixture(file, source, [ + { + id: `Class:${file}:Writer`, + label: 'Class', + properties: { name: 'Writer', filePath: file, startLine: 0, endLine: 1 }, + }, + { + id: uid, + label: 'Method', + properties: { name: 'close', filePath: file, startLine: 1, endLine: 1 }, + }, + ]); + const plan = await planGraphRename( + root, + { + uid, + name: 'close', + kind: 'Method', + filePath: file, + startLine: 2, + }, + { new_name: 'finish' }, + nodes, + ); + expect(plan.edits.map((edit) => edit.start)).toEqual([ + source.indexOf('close'), + source.lastIndexOf('close'), + ]); + }); + + it('preserves Python class renames with ordinary dunder names', async () => { + const file = 'model.py'; + const source = 'class Writer:\n def __init__(self): pass\nwriter = Writer()\n'; + const uid = `Class:${file}:Writer`; + const nodes = await fixture(file, source, [ + { + id: uid, + label: 'Class', + properties: { name: 'Writer', filePath: file, startLine: 0, endLine: 1 }, + }, + ]); + const plan = await planGraphRename( + root, + { + uid, + name: 'Writer', + kind: 'Class', + filePath: file, + startLine: 1, + }, + { new_name: 'Renamed' }, + nodes, + ); + expect(plan.edits.map((edit) => edit.start)).toEqual([ + source.indexOf('Writer'), + source.lastIndexOf('Writer'), + ]); + }); + + it.each([ + ['nested class slots', ' class Nested:\n __slots__ = ("__secret",)\n'], + ['ordinary string', ' label = "__slots__ __secret"\n'], + ['method-local slots name', ' def use(self):\n __slots__ = ("__secret",)\n'], + ['method parameter slots name', ' def use(self, __slots__): pass\n'], + ['method default parameter slots name', ' def use(self, __slots__ = ("__secret",)): pass\n'], + ])('preserves Python class preview and apply with an unrelated %s', async (_name, body) => { + const file = 'model.py'; + const source = `class Writer:\n${body}writer = Writer()\n`; + const uid = `Class:${file}:Writer`; + const nodes = await fixture(file, source, [ + { + id: uid, + label: 'Class', + properties: { name: 'Writer', filePath: file, startLine: 0 }, + }, + ]); + const symbol = { uid, name: 'Writer', kind: 'Class', filePath: file, startLine: 1 }; + const preview = await renameSymbol(root, symbol, { new_name: 'Renamed', dry_run: true }, nodes); + expect(preview, JSON.stringify(preview)).toMatchObject({ status: 'success', total_edits: 2 }); + expect(await fs.readFile(path.join(root, file), 'utf8')).toBe(source); + const applied = await renameSymbol( + root, + symbol, + { new_name: 'Renamed', dry_run: false }, + nodes, + ); + expect(applied).toMatchObject({ status: 'success', applied: true, changes: preview.changes }); + expect(await fs.readFile(path.join(root, file), 'utf8')).toBe( + `class Renamed:\n${body}writer = Renamed()\n`, + ); + }); + + it('allows escaped identifier text in strings and comments', async () => { + const file = 'model.ts'; + const source = + 'function target() {}\nconst text = "t\\u0061rget";\n// t\\u0061rget\ntarget();\n'; + const nodes = await fixture(file, source); + nodes[1]!.properties.startLine = 0; + const plan = await planGraphRename( + root, + { + uid: nodes[1]!.id, + name: 'target', + kind: 'Function', + filePath: file, + startLine: 1, + }, + { new_name: 'renamed' }, + nodes, + ); + expect(plan.edits.map((edit) => edit.start)).toEqual([ + source.indexOf('target'), + source.lastIndexOf('target'), + ]); + }); + + it('renames PHP case-insensitive callable names through its provider', async () => { + const file = 'model.php'; + const source = ' { + const uid = `Class:${file}:Writer`; + const nodes = await fixture(file!, source!, [ + { + id: uid, + label: 'Class', + properties: { name: 'Writer', filePath: file!, startLine: 0 }, + }, + ]); + await expectSupportedPreviewAndApply( + file!, + source!, + { + uid, + name: 'Writer', + kind: 'Class', + filePath: file!, + startLine: 1, + }, + nodes, + source!.replaceAll('Writer', 'Renamed'), + ); + }); + + it.each([ + [ + 'Python keyword argument', + 'model.py', + 'def target(): pass\ndef fn(**kwargs): pass\nfn(target=1)\ntarget()\n', + ], + [ + 'JSX intrinsic element', + 'model.tsx', + 'function target() {}\nconst element = ;\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('renames the provider-captured value binding inside a JSX expression', async () => { + const file = 'model.tsx'; + const source = 'function target() {}\nconst element = ;\n'; + const nodes = await fixture(file, source); + nodes[1]!.properties.startLine = 0; + await expectSupportedPreviewAndApply( + file, + source, + { + uid: nodes[1]!.id, + name: 'target', + kind: 'Function', + filePath: file, + startLine: 1, + }, + nodes, + 'function Renamed() {}\nconst element = ;\n', + ); + }); + + it.each([ + ['variable key', 'const key = "close";\nwriter[key]();\n'], + ['escaped literal key', 'writer["clo\\u0073e"]();\n'], + ['concatenated key', 'writer["cl" + "ose"]();\n'], + ['unknown receiver', 'unknown[key]();\n'], + ])('blocks preview and apply for an unresolved computed %s', async (_name, call) => { + const file = 'model.ts'; + const source = 'class Writer {\n close() {}\n}\nconst writer = new Writer();\n' + call; + const uid = `Method:${file}:Writer.close#0`; + const nodes = await fixture(file, source, [ + { + id: `Class:${file}:Writer`, + label: 'Class', + properties: { name: 'Writer', filePath: file, startLine: 0, endLine: 2 }, + }, + { + id: uid, + label: 'Method', + properties: { name: 'close', filePath: file, startLine: 1, endLine: 1 }, + }, + ]); + await expectBlockedPreviewAndApply( + file, + source, + { + uid, + name: 'close', + kind: 'Method', + filePath: file, + startLine: 2, + }, + nodes, + 'unsupported_occurrence', + ); + }); + it.each(cases)( + 'renames bound declaration and call with the registered provider: %s', + async (file, source) => { + const nodes = await fixture(file!, source!); + const line = file!.endsWith('.go') ? 2 : 1; + nodes[1]!.properties.startLine = line - 1; + nodes[1]!.properties.endLine = line - 1; + const plan = await planGraphRename( + root, + { + uid: nodes[1]!.id, + name: 'target', + kind: 'Function', + filePath: file!, + startLine: line, + endLine: line, + }, + { new_name: 'renamed' }, + nodes, + ); + expect(plan.edits).toHaveLength(2); + expect(plan.edits.map((e) => source!.slice(e.start, e.start + e.length))).toEqual([ + 'target', + 'target', + ]); + expect(plan.coverage).toMatchObject({ + name: 'gitnexus-semantic', + scope: 'indexed-repository', + source_file_count: 1, + }); + // Files added after the last analyze are skipped; the result must say so. + expect(plan.coverage.limitations).toContainEqual( + expect.stringContaining('added after the last analyze'), + ); + expect(await fs.readFile(path.join(root, file!), 'utf8')).toBe(source); + }, + ); + + it('refuses a stale graph declaration', async () => { + const nodes = await fixture('model.ts', 'function other() {}\n'); + await expect( + planGraphRename( + root, + { uid: nodes[1]!.id, name: 'target', kind: 'Function', filePath: 'model.ts', startLine: 1 }, + { new_name: 'renamed' }, + nodes, + ), + ).rejects.toMatchObject({ code: 'stale_graph' }); + }); + + it('permits an existing replacement name in an unrelated lexical scope', async () => { + const file = 'model.ts'; + const source = + 'function target() {}\n' + + 'function other() { const renamed = 1; return renamed; }\n' + + 'target();\n'; + const nodes = await fixture(file, source); + nodes[1]!.properties.startLine = 0; + nodes[1]!.properties.endLine = 0; + const symbol = { uid: nodes[1]!.id, name: 'target', filePath: file, startLine: 1 }; + const result = await renameSymbol(root, symbol, { new_name: 'renamed', dry_run: false }, nodes); + expect(result, JSON.stringify(result)).toMatchObject({ + status: 'success', + applied: true, + total_edits: 2, + }); + expect(await fs.readFile(path.join(root, file), 'utf8')).toBe( + 'function renamed() {}\n' + + 'function other() { const renamed = 1; return renamed; }\n' + + 'renamed();\n', + ); + }); + + it('renames only the member owned by the selected graph class', async () => { + const source = + 'class Writer {\n close() {}\n}\nclass Other {\n close() {}\n}\nconst writer = new Writer();\nconst other = new Other();\nwriter.close();\nother.close();\nconst unrelated: Other = { close() {} };\n'; + const file = 'model.ts'; + const nodes = await fixture(file, source, [ + { + id: `Class:${file}:Writer`, + label: 'Class', + properties: { name: 'Writer', filePath: file, startLine: 0, endLine: 2 }, + }, + { + id: `Class:${file}:Other`, + label: 'Class', + properties: { name: 'Other', filePath: file, startLine: 3, endLine: 5 }, + }, + { + id: `Method:${file}:Writer.close#0`, + label: 'Method', + properties: { name: 'close', filePath: file, startLine: 1, endLine: 1 }, + }, + { + id: `Method:${file}:Other.close#0`, + label: 'Method', + properties: { name: 'close', filePath: file, startLine: 4, endLine: 4 }, + }, + ]); + const plan = await planGraphRename( + root, + { + uid: `Method:${file}:Writer.close#0`, + name: 'close', + kind: 'Method', + filePath: file, + startLine: 2, + }, + { new_name: 'finish' }, + nodes, + ); + expect(plan.edits.map((edit) => edit.start)).toEqual([ + source.indexOf('close'), + source.indexOf('writer.close') + 7, + ]); + }); + + it('keeps parameter shadows, object keys, comments and strings unchanged while covering interpolation and bare values', async () => { + const source = + 'function target() {}\nfunction wrapper(target) { return target; }\nconst literal = { target: 1 };\nconst value = target;\nconst text = `target ${target()}`;\n// target\n'; + const nodes = await fixture('model.ts', source); + nodes[1]!.properties.startLine = 0; + const plan = await planGraphRename( + root, + { uid: nodes[1]!.id, name: 'target', kind: 'Function', filePath: 'model.ts', startLine: 1 }, + { new_name: 'renamed' }, + nodes, + ); + expect(plan.edits.map((edit) => edit.start)).toEqual([ + source.indexOf('target'), + source.indexOf('= target') + 2, + source.indexOf('${target') + 2, + ]); + }); + + it('updates a named import while preserving an explicit alias and its usages', async () => { + const source = 'export function target() {}\n'; + const nodes = await fixture('model.ts', source); + nodes[1]!.properties.startLine = 0; + const consumer = 'import { target as local } from "./model";\nlocal();\n'; + await fs.writeFile(path.join(root, 'consumer.ts'), consumer); + nodes.push({ + id: 'File:consumer.ts', + label: 'File', + properties: { name: 'consumer.ts', filePath: 'consumer.ts' }, + }); + const plan = await planGraphRename( + root, + { uid: nodes[1]!.id, name: 'target', kind: 'Function', filePath: 'model.ts', startLine: 1 }, + { new_name: 'renamed' }, + nodes, + ); + expect(plan.edits.map((edit) => [edit.file_path, edit.start])).toEqual([ + ['consumer.ts', consumer.indexOf('target')], + ['model.ts', source.indexOf('target')], + ]); + }); + + it('preserves a shorthand property key while renaming its value binding', async () => { + const source = 'function target() {}\nconst object = { target };\n'; + const nodes = await fixture('model.ts', source); + nodes[1]!.properties.startLine = 0; + const symbol = { uid: nodes[1]!.id, name: 'target', filePath: 'model.ts', startLine: 1 }; + const result = await renameSymbol(root, symbol, { new_name: 'renamed', dry_run: false }, nodes); + expect(result, JSON.stringify(result)).toMatchObject({ + status: 'success', + applied: true, + total_edits: 2, + }); + expect(await fs.readFile(path.join(root, 'model.ts'), 'utf8')).toBe( + 'function renamed() {}\nconst object = { target: renamed };\n', + ); + }); + + it('preserves an explicit import alias even when it has the exported spelling', async () => { + const source = 'export function target() {}\n'; + const nodes = await fixture('model.ts', source); + nodes[1]!.properties.startLine = 0; + const consumer = 'import { target as target } from "./model";\ntarget();\n'; + await fs.writeFile(path.join(root, 'consumer.ts'), consumer); + nodes.push({ + id: 'File:consumer.ts', + label: 'File', + properties: { name: 'consumer.ts', filePath: 'consumer.ts' }, + }); + const symbol = { uid: nodes[1]!.id, name: 'target', filePath: 'model.ts', startLine: 1 }; + const result = await renameSymbol(root, symbol, { new_name: 'renamed', dry_run: false }, nodes); + expect(result, JSON.stringify(result)).toMatchObject({ + status: 'success', + applied: true, + total_edits: 2, + }); + expect(await fs.readFile(path.join(root, 'consumer.ts'), 'utf8')).toBe( + 'import { renamed as target } from "./model";\ntarget();\n', + ); + }); + + it('renames through a provider-approved mixed source workspace', async () => { + const source = 'export function target() {}\n'; + const nodes = await fixture('model.ts', source); + nodes[1]!.properties.startLine = 0; + const consumer = 'import { target } from "./model.js";\ntarget();\n'; + await fs.writeFile(path.join(root, 'consumer.js'), consumer); + nodes.push({ + id: 'File:consumer.js', + label: 'File', + properties: { name: 'consumer.js', filePath: 'consumer.js' }, + }); + const symbol = { uid: nodes[1]!.id, name: 'target', filePath: 'model.ts', startLine: 1 }; + const result = await renameSymbol(root, symbol, { new_name: 'renamed', dry_run: false }, nodes); + expect(result, JSON.stringify(result)).toMatchObject({ + status: 'success', + applied: true, + total_edits: 3, + }); + expect(await fs.readFile(path.join(root, 'consumer.js'), 'utf8')).toBe( + 'import { renamed } from "./model.js";\nrenamed();\n', + ); + }); + + it('refuses capture of an existing reference that receives no source edit', async () => { + const source = 'function target() {}\nfunction caller() { return renamed; }\ntarget();\n'; + const nodes = await fixture('model.ts', source); + nodes[1]!.properties.startLine = 0; + await expectBlockedPreviewAndApply( + 'model.ts', + source, + { uid: nodes[1]!.id, name: 'target', filePath: 'model.ts', startLine: 1 }, + nodes, + 'name_collision', + 'renamed', + ); + }); + + it.each([ + [ + 'export alias', + 'function target() {}\nexport { target as publicTarget };\n', + 'function renamed() {}\nexport { renamed as publicTarget };\n', + ], + [ + 'unrelated destructuring key', + 'function target() {}\nconst { target: other } = object;\n', + 'function renamed() {}\nconst { target: other } = object;\n', + ], + ])('preserves the independent source name in an %s', async (_name, source, expected) => { + const nodes = await fixture('model.ts', source!); + nodes[1]!.properties.startLine = 0; + await expectSupportedPreviewAndApply( + 'model.ts', + source!, + { uid: nodes[1]!.id, name: 'target', filePath: 'model.ts', startLine: 1 }, + nodes, + expected!, + 'renamed', + ); + }); + + it('resolves a constructed member through finalized cross-file import bindings', async () => { + const file = 'model.ts'; + const nodes = await fixture(file, 'export class Writer {\n close() {}\n}\n', [ + { + id: `Class:${file}:Writer`, + label: 'Class', + properties: { name: 'Writer', filePath: file, startLine: 0, endLine: 2 }, + }, + { + id: `Method:${file}:Writer.close#0`, + label: 'Method', + properties: { name: 'close', filePath: file, startLine: 1, endLine: 1 }, + }, + ]); + const consumer = + 'import { Writer } from "./model.js";\nconst writer = new Writer();\nwriter.close();\n'; + await fs.writeFile(path.join(root, 'consumer.ts'), consumer); + nodes.push({ + id: 'File:consumer.ts', + label: 'File', + properties: { name: 'consumer.ts', filePath: 'consumer.ts' }, + }); + const plan = await planGraphRename( + root, + { + uid: `Method:${file}:Writer.close#0`, + name: 'close', + kind: 'Method', + filePath: file, + startLine: 2, + }, + { new_name: 'finish' }, + nodes, + ); + expect(plan.edits).toHaveLength(2); + expect(plan.edits[0]).toMatchObject({ + file_path: 'consumer.ts', + start: consumer.indexOf('close'), + }); + }); + + it.each([ + ['unknown receiver', 'function target() {}\nunknown.target();\n', 'ambiguous_reference'], + [ + 'parameter capture', + 'function target() {}\nfunction other(renamed) { target(); }\n', + 'name_collision', + ], + ['keyword replacement', 'function target() {}\ntarget();\n', 'invalid_name'], + ['keyword declaration only', 'function target() {}\n', 'invalid_name'], + ['computed member', 'function target() {}\nobject["target"]();\n', 'unsupported_occurrence'], + ])('refuses %s', async (_name, source, code) => { + const nodes = await fixture('model.ts', source); + nodes[1]!.properties.startLine = 0; + await expect( + planGraphRename( + root, + { uid: nodes[1]!.id, name: 'target', kind: 'Function', filePath: 'model.ts', startLine: 1 }, + { new_name: code === 'invalid_name' ? 'return' : 'renamed' }, + nodes, + ), + ).rejects.toMatchObject({ code }); + }); + + it('refuses known graph references across an unsupported language boundary', async () => { + const nodes = await fixture('model.ts', 'function target() {}\n'); + nodes[1]!.properties.startLine = 0; + await expect( + planGraphRename( + root, + { uid: nodes[1]!.id, name: 'target', filePath: 'model.ts', startLine: 1 }, + { new_name: 'renamed' }, + nodes, + ['consumer.js'], + ), + ).rejects.toMatchObject({ code: 'unsupported_boundary' }); + }); + + it('renames the complete provider-proven override family', async () => { + const file = 'model.ts'; + const source = 'class Base {\n close() {}\n}\nclass Derived extends Base {\n close() {}\n}\n'; + const nodes = await fixture(file, source, [ + { + id: `Class:${file}:Base`, + label: 'Class', + properties: { name: 'Base', filePath: file, startLine: 0, endLine: 2 }, + }, + { + id: `Class:${file}:Derived`, + label: 'Class', + properties: { name: 'Derived', filePath: file, startLine: 3, endLine: 5 }, + }, + { + id: `Method:${file}:Base.close#0`, + label: 'Method', + properties: { name: 'close', filePath: file, startLine: 1, endLine: 1 }, + }, + { + id: `Method:${file}:Derived.close#0`, + label: 'Method', + properties: { name: 'close', filePath: file, startLine: 4, endLine: 4 }, + }, + ]); + await expectSupportedPreviewAndApply( + file, + source, + { + uid: `Method:${file}:Base.close#0`, + name: 'close', + kind: 'Method', + filePath: file, + startLine: 2, + }, + nodes, + 'class Base {\n finish() {}\n}\nclass Derived extends Base {\n finish() {}\n}\n', + 'finish', + ); + }); + + it('refuses a new inherited family collision even without call sites', async () => { + const file = 'model.ts'; + const source = 'class Base {\n finish() {}\n}\nclass Derived extends Base {\n close() {}\n}\n'; + const nodes = await fixture(file, source, [ + { + id: `Class:${file}:Base`, + label: 'Class', + properties: { name: 'Base', filePath: file, startLine: 0, endLine: 2 }, + }, + { + id: `Class:${file}:Derived`, + label: 'Class', + properties: { name: 'Derived', filePath: file, startLine: 3, endLine: 5 }, + }, + { + id: `Method:${file}:Base.finish#0`, + label: 'Method', + properties: { name: 'finish', filePath: file, startLine: 1, endLine: 1 }, + }, + { + id: `Method:${file}:Derived.close#0`, + label: 'Method', + properties: { name: 'close', filePath: file, startLine: 4, endLine: 4 }, + }, + ]); + await expectBlockedPreviewAndApply( + file, + source, + { + uid: `Method:${file}:Derived.close#0`, + name: 'close', + kind: 'Method', + filePath: file, + startLine: 5, + }, + nodes, + 'name_collision', + 'finish', + ); + }); + + it('preserves a same-spelled import alias bound to a different symbol', async () => { + const file = 'model.ts'; + const nodes = await fixture(file, 'export function target() {}\nexport function other() {}\n', [ + { + id: `Function:${file}:other`, + label: 'Function', + properties: { name: 'other', filePath: file, startLine: 1, endLine: 1 }, + }, + ]); + nodes[1]!.properties.startLine = 0; + const consumer = 'import { other as target } from "./model";\ntarget();\n'; + await fs.writeFile(path.join(root, 'consumer.ts'), consumer); + nodes.push({ + id: 'File:consumer.ts', + label: 'File', + properties: { name: 'consumer.ts', filePath: 'consumer.ts' }, + }); + const plan = await planGraphRename( + root, + { uid: nodes[1]!.id, name: 'target', filePath: file, startLine: 1 }, + { new_name: 'renamed' }, + nodes, + ); + expect(plan.edits).toHaveLength(1); + expect(plan.edits[0]!.file_path).toBe(file); + }); + + it('keeps exact UTF-16 offsets after Unicode source', async () => { + const file = 'model.ts'; + const source = 'const message = "😀"; function target() {}\ntarget();\n'; + const nodes = await fixture(file, source); + nodes[1]!.properties.startLine = 0; + const plan = await planGraphRename( + root, + { uid: nodes[1]!.id, name: 'target', filePath: file, startLine: 1 }, + { new_name: 'renamed' }, + nodes, + ); + expect(plan.edits.map((edit) => edit.start)).toEqual([ + source.indexOf('target'), + source.lastIndexOf('target'), + ]); + }); + + it.each([{ kind: 'Method' }, { uid: 'Function:model.ts:unrelated' }, { endLine: 2 }])( + 'refuses mismatched graph evidence %j', + async (override) => { + const nodes = await fixture('model.ts', 'function target() {}\ntarget();\n'); + nodes[1]!.properties.startLine = 0; + await expect( + planGraphRename( + root, + { + uid: nodes[1]!.id, + name: 'target', + kind: 'Function', + filePath: 'model.ts', + startLine: 1, + ...override, + }, + { new_name: 'renamed' }, + nodes, + ), + ).rejects.toMatchObject({ code: 'stale_graph' }); + }, + ); + + it('refuses stale ownership even when graph name and source position still match', async () => { + const file = 'model.ts'; + const nodes = await fixture(file, 'class Other {\n close() {}\n}\n', [ + { + id: `Class:${file}:Writer`, + label: 'Class', + properties: { name: 'Writer', filePath: file, startLine: 0, endLine: 2 }, + }, + { + id: `Method:${file}:Writer.close#0`, + label: 'Method', + properties: { name: 'close', filePath: file, startLine: 1, endLine: 1 }, + }, + ]); + await expect( + planGraphRename( + root, + { + uid: `Method:${file}:Writer.close#0`, + name: 'close', + kind: 'Method', + filePath: file, + startLine: 2, + }, + { new_name: 'finish' }, + nodes, + ), + ).rejects.toMatchObject({ code: 'stale_graph' }); + }); +}); diff --git a/gitnexus/test/unit/impact-context-integrity.test.ts b/gitnexus/test/unit/impact-context-integrity.test.ts index 5ad29172b..5b1843c0e 100644 --- a/gitnexus/test/unit/impact-context-integrity.test.ts +++ b/gitnexus/test/unit/impact-context-integrity.test.ts @@ -137,7 +137,8 @@ function querySeam(query: string, params: Record | undefined): Seam if (query.includes('RETURN s.id AS id, s.filePath AS filePath')) return 'pdgSpans'; if (query.includes('RETURN c.id AS id, r.reason AS reason')) return 'pdgSummary'; if (query.includes('RETURN a.id AS id, r.reason AS reason')) return 'pdgSelf'; - if (query.includes('RETURN a.id AS id ORDER BY a.startLine')) return 'pdgSeed'; + if (query.includes('RETURN a.id AS id, a.ownerId AS ownerId ORDER BY a.startLine')) + return 'pdgSeed'; if (query.includes('RETURN b.id AS id')) { if (query.includes('b.calleeIds AS calleeIds')) return 'pdgCalleeBlocks'; if (query.includes('b.text AS text')) return 'pdgStatement'; diff --git a/gitnexus/test/unit/incremental-parse-cache.test.ts b/gitnexus/test/unit/incremental-parse-cache.test.ts index 9680bfde4..dc63c06d3 100644 --- a/gitnexus/test/unit/incremental-parse-cache.test.ts +++ b/gitnexus/test/unit/incremental-parse-cache.test.ts @@ -319,8 +319,11 @@ describe('PARSE_CACHE_VERSION', () => { // Moved 134 -> 135 for guarded-import and mutable binding review fixes (#3532). // Moved 135 -> 136 for parameter annotations and Vue embedded script fixes (#3532). // Moved 136 -> 137 for Python module-accessor and call-result assignment captures (#3497). - it('pins SCHEMA_BUMP to 137 so concurrent bumps cannot silently collide (#2766, #3015, #3088, #2885, #3128, #2865, #3130, #1432, #3161, #3179, #3219, #3190, #3253, #3273, #3339, #3354, #3371, #2965, #3390, #3398, #3396, #3394, #3399, #3414, #3408, #3402, #3446, #3450, #3499, #3502, #3487, #3505, #3504, #3497)', () => { - expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(137); + // Moved 137 -> 138 for provider-owned rename occurrences and explicit flow owners (#3486). + // Moved 138 -> 139 for provider graph/capture fixes exercised by real rename fixtures (#3486). + // Moved 139 -> 140 for Ruby accessor ownership captures used by rename (#3557). + it('pins SCHEMA_BUMP to 140 so concurrent bumps cannot silently collide (#2766, #3015, #3088, #2885, #3128, #2865, #3130, #1432, #3161, #3179, #3219, #3190, #3253, #3273, #3339, #3354, #3371, #2965, #3390, #3398, #3396, #3394, #3399, #3414, #3408, #3402, #3446, #3450, #3499, #3502, #3487, #3505, #3504, #3497, #3486)', () => { + expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(140); expect(PARSE_CACHE_BUCKET_COUNT).toBe(128); // The PREVIOUS version must fail the reuse gate, not merely differ from the // current one — a hardcoded number outside the conflict hunk rebases cleanly @@ -330,7 +333,7 @@ describe('PARSE_CACHE_VERSION', () => { 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, - 123, 124, 125, 126, 127, 128, 129, 130, 131, 132, 133, 134, 135, 136, + 123, 124, 125, 126, 127, 128, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138, 139, ]) { expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).not.toBe(taken); } diff --git a/gitnexus/test/unit/kotlin-lombok-synthesizer.test.ts b/gitnexus/test/unit/kotlin-lombok-synthesizer.test.ts index 9c8ed7326..6c7127ac2 100644 --- a/gitnexus/test/unit/kotlin-lombok-synthesizer.test.ts +++ b/gitnexus/test/unit/kotlin-lombok-synthesizer.test.ts @@ -368,19 +368,14 @@ class Outer { expect(result.symbols.map((s) => s.name).sort()).toEqual(['getName']); }); - it('uses unique scope ranges for getter and setter of the same property', () => { + it('emits bodyless declarations for getter and setter of the same property', () => { const tree = parse(` class Pair(var first: Int, var second: Int) `); const result = synthesizeLombokAccessors(tree, FILE_PATH, ownerMap(tree, FILE_PATH)); expect(result.symbols).toHaveLength(4); const captures = synthesizeLombokAccessorCaptures(tree.rootNode); - const scopes = captures - .map((m) => m['@scope.function']) - .filter((c): c is NonNullable => c !== undefined) - .map((c) => `${c.range.startLine}:${c.range.startCol}-${c.range.endLine}:${c.range.endCol}`); - expect(scopes).toHaveLength(4); - expect(new Set(scopes).size).toBe(4); + expect(captures.some((match) => match['@scope.function'] !== undefined)).toBe(false); expect( captures .map((m) => m['@declaration.qualified_name']?.text) diff --git a/gitnexus/test/unit/kotlin-scope-captures.test.ts b/gitnexus/test/unit/kotlin-scope-captures.test.ts index 4639ec4a2..2710bfeb7 100644 --- a/gitnexus/test/unit/kotlin-scope-captures.test.ts +++ b/gitnexus/test/unit/kotlin-scope-captures.test.ts @@ -8,6 +8,48 @@ function captureTexts(source: string): Array> { } describe('Kotlin scope captures', () => { + it('uses an explicit function type to constrain a free callable reference', () => { + const captures = captureTexts('fun use() { val callback: () -> String = ::encode }'); + const reference = captures.find((match) => match['@reference.call.free'] === '::encode'); + expect(reference?.['@reference.arity']).toBe('0'); + }); + + it('does not treat the receiver parameter of an unbound reference as method arity', () => { + const captures = captureTexts('fun use() { val callback: (Worker) -> Unit = Worker::run }'); + const reference = captures.find((match) => match['@reference.call.member'] === 'Worker::run'); + expect(reference).toBeDefined(); + expect(reference?.['@reference.arity']).toBeUndefined(); + }); + + it('does not infer a free callable arity from an extension function type', () => { + const captures = captureTexts('fun use() { val callback: Worker.() -> Unit = ::run }'); + const reference = captures.find((match) => match['@reference.call.free'] === '::run'); + expect(reference).toBeDefined(); + expect(reference?.['@reference.arity']).toBeUndefined(); + }); + + it('reads assignment receiver prefixes and writes only the terminal property', () => { + const captures = captureTexts( + 'fun update(session: Session, badge: String) { session.account.profile.badge = badge }', + ); + const members = captures.filter( + (match) => + match['@reference.read.member'] !== undefined || + match['@reference.write.member'] !== undefined, + ); + expect( + members.map((match) => ({ + kind: match['@reference.write.member'] === undefined ? 'read' : 'write', + name: match['@reference.name'], + receiver: match['@reference.receiver'], + })), + ).toEqual([ + { kind: 'read', name: 'account', receiver: 'session' }, + { kind: 'read', name: 'profile', receiver: 'session.account' }, + { kind: 'write', name: 'badge', receiver: 'session.account.profile' }, + ]); + }); + it('emits required arity equal to total arity when no parameters have defaults', () => { const captures = captureTexts(` fun greet(name: String, greeting: String) {} diff --git a/gitnexus/test/unit/lombok-synthesizer.test.ts b/gitnexus/test/unit/lombok-synthesizer.test.ts index 54b839abe..4b1b337d4 100644 --- a/gitnexus/test/unit/lombok-synthesizer.test.ts +++ b/gitnexus/test/unit/lombok-synthesizer.test.ts @@ -109,7 +109,7 @@ public class Order { expect(setter.parameterTypes).toEqual(['String']); }); - it('uses unique scope ranges for multi-declarator getters and setters', () => { + it('emits bodyless declarations for multi-declarator getters and setters', () => { const tree = parse(` import lombok.Data; @Data @@ -128,14 +128,14 @@ public class Pair { 'setFirst', 'setSecond', ]); - const scopes = synthesizeLombokAccessorCaptures(tree.rootNode) - .map((m) => m['@scope.function']) - .filter((c): c is NonNullable => c !== undefined) - .map( - (c) => `${c.range.startLine}:${c.range.startCol}-${c.range.endLine}:${c.range.endCol}`, - ); - expect(scopes).toHaveLength(4); - expect(new Set(scopes).size).toBe(4); + const captures = synthesizeLombokAccessorCaptures(tree.rootNode); + expect(captures.some((match) => match['@scope.function'] !== undefined)).toBe(false); + expect(captures.map((match) => match['@declaration.qualified_name']?.text).sort()).toEqual([ + 'Pair.getFirst', + 'Pair.getSecond', + 'Pair.setFirst', + 'Pair.setSecond', + ]); }); it('creates HAS_METHOD relationships linking to the class', () => { diff --git a/gitnexus/test/unit/mcp-read-only.test.ts b/gitnexus/test/unit/mcp-read-only.test.ts index 07d01a567..7668efefb 100644 --- a/gitnexus/test/unit/mcp-read-only.test.ts +++ b/gitnexus/test/unit/mcp-read-only.test.ts @@ -16,6 +16,7 @@ const READ_ONLY_TOOLS = [ 'pdg_query', 'query', 'read_file', + 'rename', 'route_map', 'shape_check', 'tool_map', @@ -65,6 +66,140 @@ afterEach(() => { }); describe('MCP read-only mode', () => { + it('lists rename as a preview-only tool and no longer lists rename_preview', async () => { + enableReadOnly(); + const session = await connect(); + try { + const { tools } = await session.client.listTools(); + const names = tools.map((tool) => tool.name); + expect(names).toContain('rename'); + expect(names).not.toContain('rename_preview'); + const rename = tools.find((tool) => tool.name === 'rename'); + expect(rename?.annotations).toEqual({ + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }); + // dry_run survives only as a constant `true`; branch stays unadvertised. + expect(rename?.inputSchema.properties).toMatchObject({ + dry_run: { type: 'boolean', const: true, default: true }, + }); + expect(rename?.inputSchema.properties).not.toHaveProperty('branch'); + expect(rename?.inputSchema.required).toEqual(['new_name']); + expect(rename?.description).toMatch(/preview-only/i); + expect(rename?.description).not.toMatch(/dry_run:false applies/); + } finally { + await session.close(); + } + }); + + it.each([ + ['omitted', {}], + ['true', { dry_run: true }], + ])('forwards a read-only rename when dry_run is %s', async (_label, extra) => { + enableReadOnly(); + const session = await connect(); + try { + const arguments_ = { + symbol_uid: 'Method:src/writer.ts:Writer.close#0', + new_name: 'closeWriter', + ...extra, + }; + const response = await session.client.callTool({ name: 'rename', arguments: arguments_ }); + expect(response.isError).not.toBe(true); + expect(session.backend.callTool).toHaveBeenCalledTimes(1); + expect(session.backend.callTool).toHaveBeenCalledWith('rename', arguments_); + const text = (response.content[0] as { text: string }).text; + expect(text).toContain('MCP read-only mode cannot apply it'); + expect(text).not.toContain('dry_run: false'); + expect(text).not.toContain('detect_changes'); + } finally { + await session.close(); + } + }); + + it.each([ + ['false', false], + ['the string "false"', 'false'], + ['the string "true"', 'true'], + ['zero', 0], + ['one', 1], + ['null', null], + ['an object', {}], + ])('rejects a read-only rename when dry_run is %s before dispatch', async (_label, dryRun) => { + enableReadOnly(); + const session = await connect(); + try { + const response = await session.client.callTool({ + name: 'rename', + arguments: { symbol_uid: 'Function:src/a.ts:a', new_name: 'b', dry_run: dryRun }, + }); + expect(response.isError).toBe(true); + expect((response.content[0] as { text: string }).text).toMatch( + /dry_run.*omitted or true.*read-only mode/i, + ); + expect(session.backend.callTool).not.toHaveBeenCalled(); + } finally { + await session.close(); + } + }); + + it('keeps branch pins and group routing out of a read-only rename', async () => { + enableReadOnly(); + const session = await connect(); + try { + for (const extra of [{ branch: 'main' }, { repo: '@portfolio/service-a' }]) { + const response = await session.client.callTool({ + name: 'rename', + arguments: { symbol_uid: 'Function:src/a.ts:a', new_name: 'b', ...extra }, + }); + expect(response.isError).toBe(true); + } + expect(session.backend.callTool).not.toHaveBeenCalled(); + } finally { + await session.close(); + } + }); + + it('rejects the removed rename_preview name before backend dispatch', async () => { + enableReadOnly(); + const session = await connect(); + try { + const response = await session.client.callTool({ + name: 'rename_preview', + arguments: { new_name: 'b' }, + }); + expect(response.isError).toBe(true); + expect((response.content[0] as { text: string }).text).toMatch(/read-only mode/i); + expect(session.backend.callTool).not.toHaveBeenCalled(); + } finally { + await session.close(); + } + }); + + it('advertises repo as optional when the read-only default can come from cwd', async () => { + enableReadOnly(); + const backend = { + ...createMockBackend(), + countRepos: vi.fn().mockResolvedValue(2), + cachedRepoCount: vi.fn().mockReturnValue(2), + selectToolRepository: vi + .fn() + .mockResolvedValue({ name: 'alpha', repoPath: '/tmp/alpha', lastCommit: 'abc' }), + }; + const session = await connect(backend); + try { + const { tools } = await session.client.listTools(); + // Read-only rename can only preview, so it follows the read-tool omission rule. + expect(tools.find((tool) => tool.name === 'rename')?.inputSchema.required).not.toContain( + 'repo', + ); + } finally { + await session.close(); + } + }); + it('discovers only proven single-repository read tools', async () => { enableReadOnly(); const session = await connect(); @@ -87,7 +222,7 @@ describe('MCP read-only mode', () => { } }); - it.each(['rename', 'group_sync', 'group_list', 'unknown_dynamic_tool'])( + it.each(['group_sync', 'group_list', 'unknown_dynamic_tool'])( 'rejects hidden tool %s before backend dispatch', async (name) => { enableReadOnly(); @@ -202,6 +337,8 @@ describe('MCP read-only mode', () => { expect(tools.tools.map((tool) => tool.name)).toEqual( expect.arrayContaining(['cypher', 'rename', 'group_list', 'group_sync']), ); + expect(tools.tools.map((tool) => tool.name)).not.toContain('rename_preview'); + expect(tools.tools).toHaveLength(19); const response = await session.client.callTool({ name: 'cypher', @@ -216,6 +353,71 @@ describe('MCP read-only mode', () => { } }); + it('keeps ordinary-mode rename destructive with a boolean dry_run', async () => { + const session = await connect(); + try { + const { tools } = await session.client.listTools(); + const rename = tools.find((tool) => tool.name === 'rename'); + expect(rename?.annotations).toMatchObject({ readOnlyHint: false, destructiveHint: true }); + const dryRun = (rename?.inputSchema.properties as Record).dry_run; + expect(dryRun).toMatchObject({ type: 'boolean', default: true }); + expect(dryRun).not.toHaveProperty('const'); + expect(rename?.description).toMatch(/dry_run:false applies/); + } finally { + await session.close(); + } + }); + + it.each([ + ['omitted', {}, 'dry_run: false, repo: "test"', 'detect_changes'], + ['true', { dry_run: true }, 'dry_run: false, repo: "test"', 'detect_changes'], + ['false', { dry_run: false }, 'detect_changes({repo: "test"})', 'dry_run: false'], + ])( + 'forwards ordinary-mode rename as sent and hints the right next step when dry_run is %s', + async (_label, extra, expectedHint, forbiddenHint) => { + const session = await connect(); + try { + const arguments_ = { + symbol_uid: 'Function:src/a.ts:a', + new_name: 'b', + repo: 'test', + ...extra, + }; + const response = await session.client.callTool({ name: 'rename', arguments: arguments_ }); + expect(response.isError).not.toBe(true); + expect(session.backend.callTool).toHaveBeenCalledWith('rename', arguments_); + const text = (response.content[0] as { text: string }).text; + expect(text).toContain(expectedHint); + expect(text).not.toContain(forbiddenHint); + } finally { + await session.close(); + } + }, + ); + + it.each([ + ['omitted', {}], + ['true', { dry_run: true }], + ['false', { dry_run: false }], + ])('routes ordinary-mode rename by explicit repo when dry_run is %s', async (_label, extra) => { + const session = await connect(); + try { + const arguments_ = { + symbol_uid: 'Function:src/a.ts:a', + new_name: 'b', + repo: 'beta', + ...extra, + }; + await session.client.callTool({ name: 'rename', arguments: arguments_ }); + expect(session.backend.callTool).toHaveBeenCalledWith( + 'rename', + expect.objectContaining({ repo: 'beta' }), + ); + } finally { + await session.close(); + } + }); + it('scrubs hidden tools and group routes from generated resource discovery', async () => { enableReadOnly(); const backend = createMockBackend(); @@ -237,7 +439,13 @@ describe('MCP read-only mode', () => { for (const uri of ['gitnexus://setup', 'gitnexus://repo/test/context']) { const resource = await session.client.readResource({ uri }); const text = (resource.contents[0] as { text: string }).text; - expect(text).not.toMatch(/(?:^\s*-\s+|^\|\s*`)(?:rename|cypher)/mu); + expect(text).not.toMatch(/(?:^\s*-\s+|^\|\s*`)cypher(?::|`)/mu); + expect(text).not.toContain('rename_preview'); + // rename stays listed, restated as the preview-only contract. + expect(text).toMatch( + /(?:^\s*-\s+rename: |^\| `rename` \| )Read-only semantic rename preview/mu, + ); + expect(text).not.toMatch(/rename.*dry_run: false applies/u); expect(text).not.toContain('gitnexus://group/'); } } finally { diff --git a/gitnexus/test/unit/objective-c-provider.test.ts b/gitnexus/test/unit/objective-c-provider.test.ts index 5d65dab27..c68efc9a8 100644 --- a/gitnexus/test/unit/objective-c-provider.test.ts +++ b/gitnexus/test/unit/objective-c-provider.test.ts @@ -646,7 +646,70 @@ static int helper(void) { return 1; } objcFunctionQualifiedName('SYModuleCompute', 'internal', 'SYModuleCaller.m'), ]), ); - expect(parsed!.localDefs.some((def) => def.type === 'Method')).toBe(false); + expect( + parsed!.localDefs.filter((def) => def.type === 'Method').map((def) => def.qualifiedName), + ).toContain('objc:method:objc:class:SYModuleCaller:-:runTask:completion:'); + }); + + it('captures C calls with function declarations outside their body and parameters inside it', () => { + const source = [ + 'int helper(void) { return 1; }', + 'int caller(void) { return helper(); }', + 'int indirect(int (*helper)(void)) { return helper(); }', + 'int prototype(int helper);', + 'int external(void) { return helper(); }', + '@interface Worker { int helper; } @end', + '@implementation Worker', + '+ (int)callHelper { return helper(); }', + '- (int)read { return helper; }', + '@end', + '', + ].join('\n'); + const parsed = extractParsedFile(objectiveCProvider, source, 'Calls.m')!; + const module = parsed.scopes.find((scope) => scope.id === parsed.moduleScope)!; + expect(module.bindings.get('helper')?.map((binding) => binding.def.type)).toEqual(['Function']); + expect(module.bindings.get('caller')?.map((binding) => binding.def.type)).toEqual(['Function']); + const indirect = parsed.scopes.find( + (scope) => scope.kind === 'Function' && scope.range.startLine === 3, + )!; + expect(indirect.bindings.get('helper')?.map((binding) => binding.def.type)).toEqual([ + 'Variable', + ]); + expect( + parsed.referenceSites.map((site) => [site.name, site.kind, site.atRange.startLine]), + ).toEqual([ + ['helper', 'call', 2], + ['helper', 'call', 3], + ['helper', 'call', 5], + ['helper', 'call', 8], + ]); + expect(parsed.localDefs).toContainEqual( + expect.objectContaining({ + type: 'Method', + qualifiedName: 'objc:method:objc:class:Worker:+:callHelper', + }), + ); + const facts = collectObjectiveCFacts(parseSource(source), 'Calls.m'); + expect(facts.memberAccesses!.filter((access) => access.selector === 'helper')).toEqual([ + expect.objectContaining({ sourceMethodKind: '-', startLine: 8 }), + ]); + }); + + it('preserves distinct method owners when implementations share a physical line', () => { + const parsed = extractParsedFile( + objectiveCProvider, + 'int run(void) { return 1; }\n' + + '@interface First @end @interface Second @end\n' + + '@implementation First + (int)run { return run(); } @end ' + + '@implementation Second + (int)run { return run(); } @end\n', + 'SameLine.m', + )!; + expect( + parsed.localDefs.filter((def) => def.type === 'Method').map((def) => def.qualifiedName), + ).toEqual(['objc:method:objc:class:First:+:run', 'objc:method:objc:class:Second:+:run']); + expect(parsed.referenceSites.filter((site) => site.name === 'run')).toHaveLength(2); + const module = parsed.scopes.find((scope) => scope.id === parsed.moduleScope)!; + expect(module.bindings.get('run')?.map((binding) => binding.def.type)).toEqual(['Function']); }); it('shares provider-extracted header/implementation defs, not file-static functions', () => { diff --git a/gitnexus/test/unit/pdg-impact-engine.test.ts b/gitnexus/test/unit/pdg-impact-engine.test.ts index 4d6f3f558..990abc062 100644 --- a/gitnexus/test/unit/pdg-impact-engine.test.ts +++ b/gitnexus/test/unit/pdg-impact-engine.test.ts @@ -3,6 +3,7 @@ import { IMPACT_MAX_DEPTH } from '../../src/mcp/tools.js'; import { CALLEES_TRUNCATED_SENTINEL } from '../../src/core/ingestion/cfg/callee-cell-format.js'; import { pdgLayerStatus, + resolvePdgBlockAnchor, runImpactPDG, splitCalleeIds, type RunPdgImpactDeps, @@ -46,6 +47,104 @@ describe('splitCalleeIds', () => { }); describe('runImpactPDG', () => { + it('classifies paragraph dependencies by seed program ownership across synthetic owner boundaries', async () => { + // Deliberately synthetic: the COBOL provider does not emit cross-program + // REACHING_DEF edges. Keep this defensive engine case in the unit layer; + // the integration fixture indexes real source and never manufactures edges. + const seed = 'BasicBlock:program.cbl:5:0:0'; + const local = 'BasicBlock:program.cbl:5:0:1'; + const foreign = 'BasicBlock:program.cbl:12:0:1'; + const outer = 'Module:program.cbl:Outer'; + const inner = 'Module:program.cbl:Inner'; + const exec: RunPdgImpactDeps['executeParameterized'] = async (_repo, query) => { + if (query.includes('MATCH (a:BasicBlock) WHERE')) return [{ id: seed, ownerId: outer }]; + if (query.includes('MATCH (a:BasicBlock)-[r:CodeRelation]->(b:BasicBlock)')) { + return [{ id: local }, { id: foreign }]; + } + if (query.includes('RETURN b.id AS id, b.ownerId AS ownerId')) { + return [ + { id: local, ownerId: outer }, + { id: foreign, ownerId: inner }, + ]; + } + if (query.includes('s.id IN $ownerIds')) { + return [ + { id: outer, name: 'Outer', label: 'Module', filePath: 'program.cbl', startLine: 0 }, + { id: inner, name: 'Inner', label: 'Module', filePath: 'program.cbl', startLine: 9 }, + ]; + } + if (query.includes('MATCH (b:BasicBlock) WHERE b.id IN $ids')) { + return [ + { id: local, line: 6, text: 'DISPLAY OUTER-TOTAL', ownerId: outer }, + { id: foreign, line: 13, text: 'DISPLAY INNER-TOTAL', ownerId: inner }, + ]; + } + return []; + }; + const result = await runImpactPDG({ + repo: { lbugPath: 'repo' }, + sym: { + id: 'Function:program.cbl:Outer:main', + name: 'main', + filePath: 'program.cbl', + startLine: 4, + endLine: 6, + }, + symType: 'Function', + direction: 'downstream', + maxDepth: 1, + limit: 50, + line: 5, + executeParameterized: exec, + }); + expect(result).toMatchObject({ + seedBlocks: [seed], + affectedStatements: [ + { line: 6, scope: 'intra' }, + { line: 13, scope: 'inter' }, + ], + }); + }); + + it('projects an explicitly owned executable region to its graph owner', async () => { + const seed = 'BasicBlock:program.cbl:8:0:0'; + const affected = 'BasicBlock:program.cbl:8:0:2'; + const owner = 'Module:program.cbl:PAYROLL'; + let walks = 0; + const exec: RunPdgImpactDeps['executeParameterized'] = async (_repo, query) => { + if (query.includes('MATCH (a:BasicBlock) WHERE')) return [{ id: seed, ownerId: owner }]; + if (query.includes('MATCH (a:BasicBlock)-[r:CodeRelation]->(b:BasicBlock)')) { + return walks++ === 0 ? [{ id: affected }] : []; + } + if (query.includes('RETURN b.id AS id, b.ownerId AS ownerId')) + return [{ id: affected, ownerId: owner }]; + if (query.includes('s.id IN $ownerIds')) { + return [ + { id: owner, name: 'PAYROLL', label: 'Module', filePath: 'program.cbl', startLine: 1 }, + ]; + } + if (query.includes('MATCH (b:BasicBlock) WHERE b.id IN $ids')) { + return [{ id: affected, line: 10, text: 'DISPLAY TOTAL', ownerId: owner }]; + } + return []; + }; + const result = await runImpactPDG({ + repo: { lbugPath: 'repo' }, + sym: { id: owner, name: 'PAYROLL', filePath: 'program.cbl', startLine: 1, endLine: 15 }, + symType: 'Module', + direction: 'downstream', + maxDepth: 2, + limit: 50, + line: 9, + executeParameterized: exec, + }); + expect(result).toMatchObject({ + mode: 'pdg', + byDepth: { 1: [expect.objectContaining({ id: owner, type: 'Module' })] }, + affectedStatements: [expect.objectContaining({ scope: 'intra' })], + }); + }); + it.each([ ['leading', '\tFunction:src/hot.ts:callee'], ['interior', 'Function:src/hot.ts:a\t\tFunction:src/hot.ts:b'], @@ -437,3 +536,19 @@ describe('pdgLayerStatus', () => { expect(result.recoverySuggestion).toContain('LadybugDB'); }); }); + +describe('PDG ownership schema capability', () => { + const sym = { id: 'Function:src/a.ts:hot', filePath: 'src/a.ts', startLine: 0, endLine: 5 }; + it.each([ + 'Database is locked', + 'Binder exception: Cannot find property other for owner.', + 'Binder exception: Cannot find property ownerId for unrelated.', + ])('preserves unrelated database errors: %s', async (message) => { + const error = new Error(message); + await expect( + resolvePdgBlockAnchor('repo', sym, async () => { + throw error; + }), + ).rejects.toBe(error); + }); +}); diff --git a/gitnexus/test/unit/release-docker-gate.test.ts b/gitnexus/test/unit/release-docker-gate.test.ts index 23b6e0248..0563267d1 100644 --- a/gitnexus/test/unit/release-docker-gate.test.ts +++ b/gitnexus/test/unit/release-docker-gate.test.ts @@ -155,12 +155,13 @@ describe('Docker publication prerequisites', () => { const accuracy = gate.steps.find((step) => step.run?.includes('bench/tool-accuracy/run.ts')); expect(accuracy?.run).toContain('--check'); expect(accuracy?.if).toBe("steps.version.outputs.publish == 'true'"); - const installIndex = gate.steps.findIndex( - (step) => step.run?.includes('install') && step.run.includes('ripgrep'), + const setupIndex = gate.steps.findIndex( + (step) => step.uses === './.github/actions/setup-gitnexus', ); - expect(installIndex).toBeGreaterThanOrEqual(0); - expect(gate.steps[installIndex].if).toBe(accuracy?.if); - expect(installIndex).toBeLessThan(gate.steps.findIndex((step) => step === accuracy)); + expect(setupIndex).toBeGreaterThanOrEqual(0); + expect(gate.steps[setupIndex].if).toBe(accuracy?.if); + expect(gate.steps[setupIndex].with?.build).toBe('true'); + expect(setupIndex).toBeLessThan(gate.steps.findIndex((step) => step === accuracy)); const paid = gate.steps.find( (step) => step.uses === './.github/actions/require-agent-release-evidence', ); diff --git a/gitnexus/test/unit/rename-cobol-providers.test.ts b/gitnexus/test/unit/rename-cobol-providers.test.ts new file mode 100644 index 000000000..849f14a41 --- /dev/null +++ b/gitnexus/test/unit/rename-cobol-providers.test.ts @@ -0,0 +1,512 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js'; +import { planGraphRename } from '../../src/mcp/local/rename/graph-rename.js'; +import { renameSymbol } from '../../src/mcp/local/rename/rename-plan.js'; + +const roots: string[] = []; +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true }))); +}); + +async function fixture( + files: Record, + name: string, + kind: string, + filePath = Object.keys(files)[0]!, + line?: number, +) { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'rename-cobol-')); + roots.push(root); + await Promise.all( + Object.entries(files).map(([file, content]) => fs.writeFile(path.join(root, file), content)), + ); + const result = await runPipelineFromRepo(root, () => undefined, { + workerPoolSize: 1, + skipGraphPhases: true, + }); + const nodes = [...result.graph.iterNodes()]; + const target = nodes.find( + (node) => + node.label === kind && + node.properties.name === name && + node.properties.filePath === filePath && + (line === undefined || node.properties.startLine === line - 1), + ); + expect(target).toBeDefined(); + const symbol = { + uid: target!.id, + name: target!.properties.name, + filePath, + startLine: target!.properties.startLine! + 1, + endLine: target!.properties.endLine! + 1, + kind: target!.label, + }; + return { root, nodes, symbol }; +} + +describe('COBOL semantic rename from the production parsing pipeline', () => { + it('previews and applies a free-format paragraph rename through PERFORM and GO TO', async () => { + const content = + '>>SOURCE FORMAT FREE\nIDENTIFICATION DIVISION.\nPROGRAM-ID. DEMO.\nPROCEDURE DIVISION.\nMAIN-PARA.\n PERFORM work-para\n GO TO WORK-PARA.\nWORK-PARA.\n DISPLAY "WORK-PARA".\n STOP RUN.\n'; + const { root, nodes, symbol } = await fixture({ 'demo.cbl': content }, 'WORK-PARA', 'Function'); + const preview = await planGraphRename(root, symbol, { new_name: 'FINISH-PARA' }, nodes); + expect(preview.edits).toHaveLength(3); + const result = await renameSymbol( + root, + symbol, + { new_name: 'FINISH-PARA', dry_run: false }, + nodes, + ); + expect(result, JSON.stringify(result)).toMatchObject({ applied: true, total_edits: 3 }); + expect(await fs.readFile(path.join(root, 'demo.cbl'), 'utf8')).toBe( + content + .replace('work-para', 'FINISH-PARA') + .replace('GO TO WORK-PARA', 'GO TO FINISH-PARA') + .replace('\nWORK-PARA.', '\nFINISH-PARA.'), + ); + }, 60000); + + it('previews and applies FILE STATUS together with its declaration and procedure use', async () => { + const content = [ + '>>SOURCE FORMAT FREE', + 'IDENTIFICATION DIVISION.', + 'PROGRAM-ID. DEMO.', + 'ENVIRONMENT DIVISION.', + 'INPUT-OUTPUT SECTION.', + 'FILE-CONTROL.', + 'SELECT DATAFILE ASSIGN TO "data.txt" FILE STATUS IS WS-STATUS.', + 'DATA DIVISION.', + 'WORKING-STORAGE SECTION.', + '01 WS-STATUS PIC XX.', + 'PROCEDURE DIVISION.', + ' DISPLAY WS-STATUS.', + '', + ].join('\n'); + const { root, nodes, symbol } = await fixture({ 'demo.cbl': content }, 'WS-STATUS', 'Property'); + const expected = content.replaceAll('WS-STATUS', 'IO-STATUS'); + const preview = await planGraphRename(root, symbol, { new_name: 'IO-STATUS' }, nodes); + expect(preview.edits).toHaveLength(3); + expect( + preview.edits.map((edit) => content.slice(edit.start, edit.start + edit.length)), + ).toEqual(['WS-STATUS', 'WS-STATUS', 'WS-STATUS']); + expect(await fs.readFile(path.join(root, 'demo.cbl'), 'utf8')).toBe(content); + const result = await renameSymbol( + root, + symbol, + { new_name: 'IO-STATUS', dry_run: false }, + nodes, + ); + expect(result, JSON.stringify(result)).toMatchObject({ applied: true, total_edits: 3 }); + expect(await fs.readFile(path.join(root, 'demo.cbl'), 'utf8')).toBe(expected); + }, 60000); + + it.each(['FILE STATUS', 'SORT STATUS IS', 'RELATIVE KEY IS', 'RECORD KEY IS'])( + 'resolves qualified %s operands without editing another group', + async (clause) => { + const content = [ + '>>SOURCE FORMAT FREE', + 'IDENTIFICATION DIVISION.', + 'PROGRAM-ID. DEMO.', + 'ENVIRONMENT DIVISION.', + 'INPUT-OUTPUT SECTION.', + 'FILE-CONTROL.', + `SELECT DATAFILE ASSIGN TO "data.txt" ${clause} ITEM-CODE OF FIRST-GROUP.`, + 'DATA DIVISION.', + 'WORKING-STORAGE SECTION.', + '01 FIRST-GROUP.', + ' 05 ITEM-CODE PIC 99.', + '01 SECOND-GROUP.', + ' 05 ITEM-CODE PIC 99.', + 'PROCEDURE DIVISION.', + ' DISPLAY ITEM-CODE OF FIRST-GROUP ITEM-CODE OF SECOND-GROUP.', + '', + ].join('\n'); + const { root, nodes, symbol } = await fixture( + { 'demo.cbl': content }, + 'ITEM-CODE', + 'Property', + 'demo.cbl', + 11, + ); + const result = await renameSymbol( + root, + symbol, + { new_name: 'PRODUCT-CODE', dry_run: false }, + nodes, + ); + expect(result, JSON.stringify(result)).toMatchObject({ applied: true, total_edits: 3 }); + expect(await fs.readFile(path.join(root, 'demo.cbl'), 'utf8')).toBe( + content + .replaceAll('ITEM-CODE OF FIRST-GROUP', 'PRODUCT-CODE OF FIRST-GROUP') + .replace('05 ITEM-CODE', '05 PRODUCT-CODE'), + ); + }, + 60000, + ); + + it('retains qualified OCCURS DEPENDING ON data-clause references', async () => { + const content = [ + '>>SOURCE FORMAT FREE', + 'IDENTIFICATION DIVISION.', + 'PROGRAM-ID. DEMO.', + 'DATA DIVISION.', + 'WORKING-STORAGE SECTION.', + '01 COUNTS.', + ' 05 ITEM-COUNT PIC 9.', + '01 ITEMS.', + ' 05 ITEM-VALUE PIC X OCCURS 1 TO 9 TIMES DEPENDING ON ITEM-COUNT OF COUNTS.', + 'PROCEDURE DIVISION.', + ' DISPLAY ITEM-COUNT OF COUNTS.', + '', + ].join('\n'); + const { root, nodes, symbol } = await fixture( + { 'demo.cbl': content }, + 'ITEM-COUNT', + 'Property', + ); + const result = await renameSymbol( + root, + symbol, + { new_name: 'RECORD-COUNT', dry_run: false }, + nodes, + ); + expect(result, JSON.stringify(result)).toMatchObject({ applied: true, total_edits: 3 }); + expect(await fs.readFile(path.join(root, 'demo.cbl'), 'utf8')).toBe( + content.replaceAll('ITEM-COUNT', 'RECORD-COUNT'), + ); + }, 60000); + + it('keeps PICTURE strings unrelated when their spelling matches a data name', async () => { + const content = [ + '>>SOURCE FORMAT FREE', + 'IDENTIFICATION DIVISION.', + 'PROGRAM-ID. DEMO.', + 'DATA DIVISION.', + 'WORKING-STORAGE SECTION.', + '01 XX PIC IS XX(2).', + 'PROCEDURE DIVISION.', + ' DISPLAY XX.', + '', + ].join('\n'); + const { root, nodes, symbol } = await fixture({ 'demo.cbl': content }, 'XX', 'Property'); + const result = await renameSymbol( + root, + symbol, + { new_name: 'VALUE-CODE', dry_run: false }, + nodes, + ); + expect(result, JSON.stringify(result)).toMatchObject({ applied: true, total_edits: 2 }); + expect(await fs.readFile(path.join(root, 'demo.cbl'), 'utf8')).toBe( + content.replace('01 XX', '01 VALUE-CODE').replace('DISPLAY XX', 'DISPLAY VALUE-CODE'), + ); + }, 60000); + + it('refuses an unclassified data clause operand instead of silently omitting it', async () => { + const content = [ + '>>SOURCE FORMAT FREE', + 'IDENTIFICATION DIVISION.', + 'PROGRAM-ID. DEMO.', + 'DATA DIVISION.', + 'FILE SECTION.', + 'FD DATAFILE LINAGE IS LINE-COUNT.', + '01 OUTPUT-RECORD PIC X(80).', + 'WORKING-STORAGE SECTION.', + '01 LINE-COUNT PIC 99.', + 'PROCEDURE DIVISION.', + ' DISPLAY LINE-COUNT.', + '', + ].join('\n'); + const { root, nodes, symbol } = await fixture( + { 'demo.cbl': content }, + 'LINE-COUNT', + 'Property', + ); + await expect( + planGraphRename(root, symbol, { new_name: 'ROW-COUNT' }, nodes), + ).rejects.toMatchObject({ + code: 'unsupported_occurrence', + }); + const result = await renameSymbol( + root, + symbol, + { new_name: 'ROW-COUNT', dry_run: false }, + nodes, + ); + expect(result, JSON.stringify(result)).toMatchObject({ + applied: false, + code: 'unsupported_occurrence', + }); + expect(await fs.readFile(path.join(root, 'demo.cbl'), 'utf8')).toBe(content); + }, 60000); + + it('preserves fixed-format sequence columns, CRLF, comments and string literals', async () => { + const content = [ + '000100 IDENTIFICATION DIVISION.', + '000200 PROGRAM-ID. DEMO.', + '000300 PROCEDURE DIVISION.', + '000400 MAIN-PARA.', + '000500 PERFORM WORK-PARA.', + '000600* WORK-PARA stays in this comment 😀', + '000700 WORK-PARA.', + '000800 DISPLAY "WORK-PARA".', + '000900 STOP RUN.', + '', + ].join('\r\n'); + const { root, nodes, symbol } = await fixture({ 'demo.cbl': content }, 'WORK-PARA', 'Function'); + const result = await renameSymbol( + root, + symbol, + { new_name: 'FINISH-PARA', dry_run: false }, + nodes, + ); + expect(result, JSON.stringify(result)).toMatchObject({ applied: true, total_edits: 2 }); + expect(await fs.readFile(path.join(root, 'demo.cbl'), 'utf8')).toBe( + content + .replace('PERFORM WORK-PARA', 'PERFORM FINISH-PARA') + .replace('000700 WORK-PARA', '000700 FINISH-PARA'), + ); + }, 60000); + + it('renames a section and its qualified paragraph uses and PERFORM target', async () => { + const content = + '>>SOURCE FORMAT FREE\nIDENTIFICATION DIVISION.\nPROGRAM-ID. DEMO.\nPROCEDURE DIVISION.\nMAIN-PARA.\n PERFORM WORK-SECTION\n PERFORM WORK-PARA OF WORK-SECTION THRU LAST-PARA.\nWORK-SECTION SECTION.\nWORK-PARA.\n CONTINUE.\nLAST-PARA.\n STOP RUN.\n'; + const { root, nodes, symbol } = await fixture( + { 'demo.cbl': content }, + 'WORK-SECTION', + 'Namespace', + ); + const result = await renameSymbol( + root, + symbol, + { new_name: 'FINISH-SECTION', dry_run: false }, + nodes, + ); + expect(result, JSON.stringify(result)).toMatchObject({ applied: true, total_edits: 3 }); + expect(await fs.readFile(path.join(root, 'demo.cbl'), 'utf8')).toBe( + content.replaceAll('WORK-SECTION', 'FINISH-SECTION'), + ); + }, 60000); + + it('keeps qualified same-name data fields and unrelated programs distinct', async () => { + const content = + '>>SOURCE FORMAT FREE\nIDENTIFICATION DIVISION.\nPROGRAM-ID. DEMO.\nDATA DIVISION.\nWORKING-STORAGE SECTION.\n01 FIRST-GROUP.\n 05 ITEM-CODE PIC 9.\n01 SECOND-GROUP.\n 05 ITEM-CODE PIC 9.\nPROCEDURE DIVISION.\nMAIN-PARA.\n MOVE ITEM-CODE OF FIRST-GROUP TO ITEM-CODE IN SECOND-GROUP\n DISPLAY item-code of first-group "ITEM-CODE".\n STOP RUN.\n'; + const other = + '>>SOURCE FORMAT FREE\nIDENTIFICATION DIVISION.\nPROGRAM-ID. OTHER.\nDATA DIVISION.\nWORKING-STORAGE SECTION.\n01 ITEM-CODE PIC 9.\nPROCEDURE DIVISION.\nMAIN-PARA.\n DISPLAY ITEM-CODE.\n'; + const { root, nodes, symbol } = await fixture( + { 'demo.cbl': content, 'other.cbl': other }, + 'ITEM-CODE', + 'Property', + 'demo.cbl', + 7, + ); + expect( + nodes.filter( + (node) => + node.label === 'Property' && + node.properties.filePath === 'demo.cbl' && + node.properties.name === 'ITEM-CODE', + ), + ).toHaveLength(2); + const result = await renameSymbol( + root, + symbol, + { new_name: 'PRODUCT-CODE', dry_run: false }, + nodes, + ); + expect(result, JSON.stringify(result)).toMatchObject({ applied: true, total_edits: 3 }); + expect(await fs.readFile(path.join(root, 'demo.cbl'), 'utf8')).toBe( + content + .replace('05 ITEM-CODE', '05 PRODUCT-CODE') + .replace('MOVE ITEM-CODE', 'MOVE PRODUCT-CODE') + .replace('DISPLAY item-code', 'DISPLAY PRODUCT-CODE'), + ); + expect(await fs.readFile(path.join(root, 'other.cbl'), 'utf8')).toBe(other); + }, 60000); + + it('renames a data group through qualifiers without renaming its subordinate storage', async () => { + const content = + '>>SOURCE FORMAT FREE\nIDENTIFICATION DIVISION.\nPROGRAM-ID. DEMO.\nDATA DIVISION.\nWORKING-STORAGE SECTION.\n01 FIRST-GROUP.\n 05 ITEM-CODE PIC 9.\n01 SECOND-GROUP.\n 05 ITEM-CODE PIC 9.\nPROCEDURE DIVISION.\nMAIN-PARA.\n INITIALIZE FIRST-GROUP\n MOVE ITEM-CODE OF FIRST-GROUP TO ITEM-CODE OF SECOND-GROUP.\n'; + const { root, nodes, symbol } = await fixture( + { 'demo.cbl': content }, + 'FIRST-GROUP', + 'Property', + ); + const result = await renameSymbol( + root, + symbol, + { new_name: 'INPUT-GROUP', dry_run: false }, + nodes, + ); + expect(result, JSON.stringify(result)).toMatchObject({ applied: true, total_edits: 3 }); + expect(await fs.readFile(path.join(root, 'demo.cbl'), 'utf8')).toBe( + content.replaceAll('FIRST-GROUP', 'INPUT-GROUP'), + ); + }, 60000); + + it('renames PROGRAM-ID, END PROGRAM and static CALL literals across files', async () => { + const program = + '>>SOURCE FORMAT FREE\nIDENTIFICATION DIVISION.\nPROGRAM-ID. WORKER.\nPROCEDURE DIVISION.\nMAIN-PARA.\n GOBACK.\nEND PROGRAM worker.\n'; + const caller = + '>>SOURCE FORMAT FREE\nIDENTIFICATION DIVISION.\nPROGRAM-ID. DRIVER.\nPROCEDURE DIVISION.\nMAIN-PARA.\n CALL "worker"\n DISPLAY "WORKER".\n'; + const { root, nodes, symbol } = await fixture( + { 'worker.cbl': program, 'driver.cbl': caller }, + 'WORKER', + 'Module', + 'worker.cbl', + ); + const result = await renameSymbol(root, symbol, { new_name: 'SERVICE', dry_run: false }, nodes); + expect(result, JSON.stringify(result)).toMatchObject({ applied: true, total_edits: 3 }); + expect(await fs.readFile(path.join(root, 'worker.cbl'), 'utf8')).toBe( + program + .replace('PROGRAM-ID. WORKER', 'PROGRAM-ID. SERVICE') + .replace('END PROGRAM worker', 'END PROGRAM SERVICE'), + ); + expect(await fs.readFile(path.join(root, 'driver.cbl'), 'utf8')).toBe( + caller.replace('CALL "worker"', 'CALL "SERVICE"'), + ); + }, 60000); + + it('refuses ambiguous unqualified data without changing source', async () => { + const content = + '>>SOURCE FORMAT FREE\nIDENTIFICATION DIVISION.\nPROGRAM-ID. DEMO.\nDATA DIVISION.\nWORKING-STORAGE SECTION.\n01 FIRST-GROUP.\n 05 ITEM-CODE PIC 9.\n01 SECOND-GROUP.\n 05 ITEM-CODE PIC 9.\nPROCEDURE DIVISION.\nMAIN-PARA.\n DISPLAY ITEM-CODE.\n'; + const { root, nodes, symbol } = await fixture( + { 'demo.cbl': content }, + 'ITEM-CODE', + 'Property', + 'demo.cbl', + 7, + ); + const result = await renameSymbol( + root, + symbol, + { new_name: 'PRODUCT-CODE', dry_run: false }, + nodes, + ); + expect(result, JSON.stringify(result)).toMatchObject({ + applied: false, + code: 'ambiguous_reference', + }); + expect(await fs.readFile(path.join(root, 'demo.cbl'), 'utf8')).toBe(content); + }, 60000); + + it('rejects a case-insensitive declaration collision', async () => { + const content = + '>>SOURCE FORMAT FREE\nIDENTIFICATION DIVISION.\nPROGRAM-ID. DEMO.\nDATA DIVISION.\nWORKING-STORAGE SECTION.\n01 ITEM-CODE PIC 9.\n01 PRODUCT-CODE PIC 9.\nPROCEDURE DIVISION.\nMAIN-PARA.\n DISPLAY ITEM-CODE.\n'; + const { root, nodes, symbol } = await fixture({ 'demo.cbl': content }, 'ITEM-CODE', 'Property'); + const result = await renameSymbol( + root, + symbol, + { new_name: 'product-code', dry_run: false }, + nodes, + ); + expect(result, JSON.stringify(result)).toMatchObject({ + applied: false, + code: 'name_collision', + }); + expect(await fs.readFile(path.join(root, 'demo.cbl'), 'utf8')).toBe(content); + }, 60000); + + it('rejects program renaming across an unresolved dynamic CALL', async () => { + const content = + '>>SOURCE FORMAT FREE\nIDENTIFICATION DIVISION.\nPROGRAM-ID. DEMO.\nDATA DIVISION.\nWORKING-STORAGE SECTION.\n01 PROGRAM-NAME PIC X(8).\nPROCEDURE DIVISION.\nMAIN-PARA.\n CALL PROGRAM-NAME.\n'; + const { root, nodes, symbol } = await fixture({ 'demo.cbl': content }, 'DEMO', 'Module'); + const result = await renameSymbol(root, symbol, { new_name: 'SERVICE', dry_run: false }, nodes); + expect(result, JSON.stringify(result)).toMatchObject({ + applied: false, + code: 'unsupported_occurrence', + }); + expect(await fs.readFile(path.join(root, 'demo.cbl'), 'utf8')).toBe(content); + }, 60000); + + it('allows qualified data declarations in different groups to share the new name', async () => { + const content = + '>>SOURCE FORMAT FREE\nIDENTIFICATION DIVISION.\nPROGRAM-ID. DEMO.\nDATA DIVISION.\nWORKING-STORAGE SECTION.\n01 FIRST-GROUP.\n 05 ITEM-CODE PIC 9.\n01 SECOND-GROUP.\n 05 PRODUCT-CODE PIC 9.\nPROCEDURE DIVISION.\nMAIN-PARA.\n MOVE ITEM-CODE OF FIRST-GROUP TO PRODUCT-CODE OF SECOND-GROUP.\n'; + const { root, nodes, symbol } = await fixture({ 'demo.cbl': content }, 'ITEM-CODE', 'Property'); + const result = await renameSymbol( + root, + symbol, + { new_name: 'PRODUCT-CODE', dry_run: false }, + nodes, + ); + expect(result, JSON.stringify(result)).toMatchObject({ applied: true, total_edits: 2 }); + expect(await fs.readFile(path.join(root, 'demo.cbl'), 'utf8')).toBe( + content.replaceAll('ITEM-CODE', 'PRODUCT-CODE'), + ); + }, 60000); + + it('rejects a program-name collision in another source file', async () => { + const worker = + '>>SOURCE FORMAT FREE\nIDENTIFICATION DIVISION.\nPROGRAM-ID. WORKER.\nPROCEDURE DIVISION.\nMAIN-PARA.\n GOBACK.\n'; + const driver = worker.replace('WORKER', 'DRIVER'); + const { root, nodes, symbol } = await fixture( + { 'worker.cbl': worker, 'driver.cbl': driver }, + 'WORKER', + 'Module', + 'worker.cbl', + ); + const result = await renameSymbol(root, symbol, { new_name: 'driver', dry_run: false }, nodes); + expect(result, JSON.stringify(result)).toMatchObject({ + applied: false, + code: 'name_collision', + }); + expect(await fs.readFile(path.join(root, 'worker.cbl'), 'utf8')).toBe(worker); + }, 60000); + + it('preserves a sibling program with the same paragraph name', async () => { + const content = + '>>SOURCE FORMAT FREE\nIDENTIFICATION DIVISION.\nPROGRAM-ID. FIRST-PROG.\nPROCEDURE DIVISION.\nMAIN-PARA.\n PERFORM WORK-PARA.\nWORK-PARA.\n GOBACK.\nEND PROGRAM FIRST-PROG.\nIDENTIFICATION DIVISION.\nPROGRAM-ID. SECOND-PROG.\nPROCEDURE DIVISION.\nMAIN-PARA.\n PERFORM WORK-PARA.\nWORK-PARA.\n GOBACK.\nEND PROGRAM SECOND-PROG.\n'; + const { root, nodes, symbol } = await fixture( + { 'demo.cbl': content }, + 'WORK-PARA', + 'Function', + 'demo.cbl', + 7, + ); + const result = await renameSymbol( + root, + symbol, + { new_name: 'FINISH-PARA', dry_run: false }, + nodes, + ); + expect(result, JSON.stringify(result)).toMatchObject({ applied: true, total_edits: 2 }); + expect(await fs.readFile(path.join(root, 'demo.cbl'), 'utf8')).toBe( + content + .replace('PERFORM WORK-PARA', 'PERFORM FINISH-PARA') + .replace('\nWORK-PARA.', '\nFINISH-PARA.'), + ); + }, 60000); + + it('renames a dynamic CALL data operand independently of program identities', async () => { + const content = + '>>SOURCE FORMAT FREE\nIDENTIFICATION DIVISION.\nPROGRAM-ID. DEMO.\nDATA DIVISION.\nWORKING-STORAGE SECTION.\n01 PROGRAM-NAME PIC X(8).\nPROCEDURE DIVISION.\nMAIN-PARA.\n CALL PROGRAM-NAME.\n'; + const { root, nodes, symbol } = await fixture( + { 'demo.cbl': content }, + 'PROGRAM-NAME', + 'Property', + ); + const result = await renameSymbol( + root, + symbol, + { new_name: 'TARGET-PROGRAM', dry_run: false }, + nodes, + ); + expect(result, JSON.stringify(result)).toMatchObject({ applied: true, total_edits: 2 }); + expect(await fs.readFile(path.join(root, 'demo.cbl'), 'utf8')).toBe( + content.replaceAll('PROGRAM-NAME', 'TARGET-PROGRAM'), + ); + }, 60000); + + it('does not reinterpret PICTURE symbols or intrinsic function names as data references', async () => { + const content = + '>>SOURCE FORMAT FREE\nIDENTIFICATION DIVISION.\nPROGRAM-ID. DEMO.\nDATA DIVISION.\nWORKING-STORAGE SECTION.\n01 X PIC X(8).\n01 TRIM PIC X(8).\nPROCEDURE DIVISION.\nMAIN-PARA.\n DISPLAY X FUNCTION TRIM(TRIM).\n'; + const { root, nodes, symbol } = await fixture({ 'demo.cbl': content }, 'TRIM', 'Property'); + const result = await renameSymbol(root, symbol, { new_name: 'TRIMMED', dry_run: false }, nodes); + expect(result, JSON.stringify(result)).toMatchObject({ applied: true, total_edits: 2 }); + expect(await fs.readFile(path.join(root, 'demo.cbl'), 'utf8')).toBe( + content.replace('01 TRIM ', '01 TRIMMED ').replace('TRIM(TRIM)', 'TRIM(TRIMMED)'), + ); + }, 60000); +}); diff --git a/gitnexus/test/unit/rename-dynamic-providers.test.ts b/gitnexus/test/unit/rename-dynamic-providers.test.ts new file mode 100644 index 000000000..a77cc46c6 --- /dev/null +++ b/gitnexus/test/unit/rename-dynamic-providers.test.ts @@ -0,0 +1,555 @@ +import { describe, expect, it } from 'vitest'; +import { SupportedLanguages, type GraphNode } from 'gitnexus-shared'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { SCOPE_RESOLVERS } from '../../src/core/ingestion/scope-resolution/pipeline/registry.js'; +import { planGraphRename } from '../../src/mcp/local/rename/graph-rename.js'; +import { renameSymbol } from '../../src/mcp/local/rename/rename-plan.js'; +import { createRenameFixtureHelpers } from '../helpers/rename-provider-fixture.js'; + +const { expectRename, fixture } = createRenameFixtureHelpers(); + +async function source(language: SupportedLanguages, path: string, content: string) { + const resolver = SCOPE_RESOLVERS.get(language)!; + expect(resolver.languageProvider.rename).toBeDefined(); + return resolver.languageProvider.rename!.parseSource({ + filePath: path, + content, + languageProvider: resolver.languageProvider, + scopeResolver: resolver, + }); +} + +describe('dynamic-language rename providers', () => { + const chainModel = `export class Leaf { score = 0; } +export class Branch { leaf: Leaf = new Leaf(); } +export class Root { branch: Branch = new Branch(); } +export class Other { score = 9; } +export const shared = new Root(); +`; + const chainUse = 'root.branch.leaf.score = root.branch.leaf.score + 1;\n'; + + it.each([ + ['mismatched field initializer', 'const root = new Root();\n', '', 'new Other()'], + ['base write', 'let root = new Root();\nroot = new Root();\n'], + ['first field write', 'const root = new Root();\nroot.branch = { leaf: { score: 12 } };\n'], + ['second field write', 'const root = new Root();\nroot.branch.leaf = { score: 12 };\n'], + [ + 'closure alias write', + 'const root = new Root();\nconst alias = root.branch;\nfunction change() { alias.leaf = { score: 12 }; }\n', + ], + ['destructuring write', 'const root = new Root();\n[root.branch.leaf] = [{ score: 12 }];\n'], + ['delete', 'const root = new Root();\ndelete root.branch.leaf;\n'], + [ + 'computed write', + 'const root = new Root();\nroot.branch["leaf"] = { score: 12 };\n', + 'ambiguous_reference', + ], + [ + 'unknown computed write', + 'const root = new Root();\nconst key = readKey();\nroot.branch[key] = { score: 12 };\n', + 'unsupported_occurrence', + ], + ])( + 'refuses unsafe deep receiver ownership in preview and apply: %s', + async (_name, setup, code = 'ambiguous_reference', initializer = 'new Leaf()') => { + const files = { + 'model.ts': chainModel.replace('leaf: Leaf = new Leaf()', `leaf: Leaf = ${initializer}`), + 'entry.ts': 'import { Root, Other } from "./model";\n' + setup + chainUse, + }; + const { root, nodes, symbol } = await fixture(files, 'score', 'Property', 'model.ts'); + for (const dry_run of [true, false]) { + expect( + await renameSymbol(root, symbol, { new_name: 'points', dry_run }, nodes), + ).toMatchObject({ + status: 'error', + code: code || 'ambiguous_reference', + planning_status: 'unsupported', + applied: false, + }); + for (const [file, content] of Object.entries(files)) + expect(await fs.readFile(path.join(root, file), 'utf8')).toBe(content); + } + }, + 60_000, + ); + + it("refuses an intermediate field write through another file's imported alias", async () => { + const files = { + 'model.ts': chainModel, + 'entry.ts': 'import { shared as root } from "./model";\n' + chainUse, + 'mutator.ts': + 'import { shared as alias } from "./model";\nexport function change() { alias.branch.leaf = { score: 12 }; }\n', + }; + const { root, nodes, symbol } = await fixture(files, 'score', 'Property', 'model.ts'); + for (const dry_run of [true, false]) { + expect( + await renameSymbol(root, symbol, { new_name: 'points', dry_run }, nodes), + ).toMatchObject({ + status: 'error', + code: 'ambiguous_reference', + planning_status: 'unsupported', + applied: false, + }); + for (const [file, content] of Object.entries(files)) + expect(await fs.readFile(path.join(root, file), 'utf8')).toBe(content); + } + }, 60_000); + + it.each([ + [ + 'member', + 'const alias: OtherBranch = root.branch;\nalias.leaf = { score: 12 };\n', + 'ambiguous_reference', + ], + [ + 'computed', + 'const alias: OtherBranch = root.branch;\nalias["leaf"] = { score: 12 };\n', + 'ambiguous_reference', + ], + [ + 'parameter', + 'function change(alias: OtherBranch) { alias.leaf = { score: 12 }; }\n', + 'ambiguous_reference', + ], + ])( + 'refuses a structural annotation hiding an alias write: %s', + async (_name, mutation, code) => { + const files = { + 'model.ts': chainModel + 'export class OtherBranch { leaf: Leaf = new Leaf(); }\n', + 'entry.ts': + 'import { Root, OtherBranch } from "./model";\nconst root = new Root();\n' + + mutation + + chainUse, + }; + const { root, nodes, symbol } = await fixture(files, 'score', 'Property', 'model.ts'); + for (const dry_run of [true, false]) { + expect( + await renameSymbol(root, symbol, { new_name: 'points', dry_run }, nodes), + ).toMatchObject({ + status: 'error', + code, + planning_status: 'unsupported', + applied: false, + }); + for (const [file, content] of Object.entries(files)) + expect(await fs.readFile(path.join(root, file), 'utf8')).toBe(content); + } + }, + 60_000, + ); + + it('preserves inherited constructor-proven field chains beside unrelated writes', async () => { + const files = { + 'model.ts': + chainModel + .replace('export class Root', 'export class Child extends Branch {}\nexport class Root') + .replace('branch: Branch = new Branch()', 'branch: Branch = new Child()') + + 'export class Unrelated { leaf = 1; }\n', + 'entry.ts': + 'import { shared as root, Unrelated } from "./model";\nconst other = new Unrelated();\nother.leaf = 3;\nother["leaf"] = 4;\nfunction shadow() { const root = new Unrelated(); root.leaf = 5; }\n' + + chainUse, + }; + await expectRename( + files, + { + 'model.ts': files['model.ts'].replace('score = 0', 'points = 0'), + 'entry.ts': files['entry.ts'].replaceAll('.score', '.points'), + }, + 'score', + 'Property', + 'points', + 'model.ts', + ); + }, 60_000); + + it('preserves a deep field receiver rooted in lexical this', async () => { + const content = chainModel.replace( + 'branch: Branch = new Branch();', + 'branch: Branch = new Branch();\nupdate() { this.branch.leaf.score += 1; }', + ); + await expectRename( + { 'model.ts': content }, + { 'model.ts': content.replace('score = 0', 'points = 0').replace('.score', '.points') }, + 'score', + 'Property', + 'points', + ); + }, 60_000); + + it.each([ + 'export class Derived extends Base { child = new B(); }', + 'export class Middle extends Base {}\nexport class Derived extends Middle { child = new B(); }', + ])( + 'refuses a descendant replacing a polymorphic intermediate field: %s', + async (derived) => { + const content = `export class A { value = 1; } +export class B { value = 2; } +export class Base { + child = new A(); + read() { return this.child.value; } +} +${derived} +export function run() { const instance = new Derived(); return instance.read(); } +`; + const { root, nodes, symbol } = await fixture({ 'model.ts': content }, 'value', 'Property'); + for (const dry_run of [true, false]) { + expect( + await renameSymbol(root, symbol, { new_name: 'renamed', dry_run }, nodes), + ).toMatchObject({ + status: 'error', + code: 'ambiguous_reference', + planning_status: 'unsupported', + applied: false, + }); + expect(await fs.readFile(path.join(root, 'model.ts'), 'utf8')).toBe(content); + } + }, + 60_000, + ); + + it('preserves inherited this receivers when descendants retain the intermediate field', async () => { + const content = `export class A { value = 1; } +export class Base { + child = new A(); + read() { return this.child.value; } +} +export class Derived extends Base {} +export function run() { const instance = new Derived(); return instance.read(); } +`; + await expectRename( + { 'model.ts': content }, + { 'model.ts': content.replace('value = 1', 'renamed = 1').replace('.value', '.renamed') }, + 'value', + 'Property', + ); + }, 60_000); + + it('preserves a concrete receiver beside a descendant shadowing its intermediate field', async () => { + const content = `export class A { value = 1; } +export class B { value = 2; } +export class Base { child = new A(); } +export class Derived extends Base { child = new B(); } +export function run() { const instance = new Base(); return instance.child.value; } +`; + await expectRename( + { 'model.ts': content }, + { 'model.ts': content.replace('value = 1', 'renamed = 1').replace('.value', '.renamed') }, + 'value', + 'Property', + ); + }, 60_000); + + it('preserves an explicit implementation family behind a constructor-proven field', async () => { + const content = `export interface Renderer { render(): string; } +export class Base implements Renderer { render() { return "base"; } } +export class Child extends Base { override render() { return "child"; } } +export class Root { renderer: Renderer = new Child(); } +const root = new Root(); +root.renderer.render(); +`; + await expectRename( + { 'model.ts': content }, + { 'model.ts': content.replaceAll('render()', 'paint()') }, + 'render', + 'Method', + 'paint', + ); + }, 60_000); + + it('refuses writes to inferred JavaScript receiver fields', async () => { + const content = + chainModel.replaceAll(': Leaf', '').replaceAll(': Branch', '') + + 'const root = new Root();\nroot.branch.leaf = { score: 12 };\n' + + chainUse; + const { root, nodes, symbol } = await fixture({ 'model.js': content }, 'score', 'Property'); + for (const dry_run of [true, false]) { + expect( + await renameSymbol(root, symbol, { new_name: 'points', dry_run }, nodes), + ).toMatchObject({ + status: 'error', + code: 'ambiguous_reference', + planning_status: 'unsupported', + applied: false, + }); + expect(await fs.readFile(path.join(root, 'model.js'), 'utf8')).toBe(content); + } + }, 60_000); + + it('rechecks imported receiver writes in each fresh workspace while preserving lexical shadows', async () => { + const model = + 'export class Worker { target() {} }\nexport class Other { target() {} }\nexport const value = new Worker();\n'; + const entry = + 'import { value as receiver, Other } from "./model";\nreceiver.target();\nreceiver.target();\nfunction shadow(receiver: Other) { receiver = new Other(); }\n'; + const mutator = + 'import { value as changedReceiver, Other } from "./model";\nexport function replace() { return 0; }\n'; + const { root, nodes, symbol } = await fixture( + { 'model.ts': model, 'entry.ts': entry, 'mutator.ts': mutator }, + 'target', + 'Method', + 'model.ts', + ); + const options = { new_name: 'renamed', dry_run: true }; + const preview = await renameSymbol(root, symbol, options, nodes); + expect(preview, JSON.stringify(preview)).toMatchObject({ status: 'success', total_edits: 3 }); + const reassigned = + 'import { value as changedReceiver, Other } from "./model";\nexport function replace() { changedReceiver = new Other(); }\n'; + await fs.writeFile(path.join(root, 'mutator.ts'), reassigned); + expect(await renameSymbol(root, symbol, options, nodes)).toMatchObject({ + status: 'error', + code: 'ambiguous_reference', + planning_status: 'unsupported', + error: 'The imported receiver binding is reassigned.', + applied: false, + }); + expect(await fs.readFile(path.join(root, 'mutator.ts'), 'utf8')).toBe(reassigned); + await fs.writeFile(path.join(root, 'mutator.ts'), mutator); + expect(await renameSymbol(root, symbol, { ...options, dry_run: false }, nodes)).toMatchObject({ + status: 'success', + total_edits: 3, + applied: true, + }); + expect(await fs.readFile(path.join(root, 'model.ts'), 'utf8')).toBe( + 'export class Worker { renamed() {} }\nexport class Other { target() {} }\nexport const value = new Worker();\n', + ); + expect(await fs.readFile(path.join(root, 'entry.ts'), 'utf8')).toBe( + 'import { value as receiver, Other } from "./model";\nreceiver.renamed();\nreceiver.renamed();\nfunction shadow(receiver: Other) { receiver = new Other(); }\n', + ); + }, 60_000); + + it('renames Python members beside unrelated decoded literal attributes', async () => { + const uses = String.raw`worker = Worker() +worker.target() +getattr(worker, "other_member") +setattr(worker, "other_\x6dember", 1) +hasattr(worker, u"other_\u006dember") +hasattr(worker, "other_\U0000006dember") +hasattr(worker, "other_\155ember") +hasattr(worker, r"other_member") +hasattr(worker, """other_member""") +delattr(worker, "other_" "member") +def inspect_unknown(receiver): + return getattr(receiver, "other_member") +`; + await expectRename( + { 'model.py': 'class Worker:\n def target(self): pass\n' + uses }, + { + 'model.py': + 'class Worker:\n def renamed(self): pass\n' + + uses.replace('worker.target()', 'worker.renamed()'), + }, + 'target', + 'Method', + ); + }, 60_000); + + it.each([ + ['getattr(worker, "renamed")', 'renamed'], + ['setattr(worker, "renamed", 42)', 'renamed'], + ['delattr(worker, "renamed")', 'renamed'], + ['hasattr(worker, "renamed")', 'renamed'], + [String.raw`setattr(worker, "rena\u006ded", 42)`, 'renamed'], + ['setattr(worker, "Kelvin", 42)', 'Kelvin'], + ])( + 'refuses Python dynamic destination collisions: %s renamed to %s', + async (access, newName) => { + const content = + 'class Worker:\n def target(self): return 7\nworker = Worker()\n' + + access + + '\nworker.target()\n'; + const { root, nodes, symbol } = await fixture({ 'model.py': content }, 'target', 'Method'); + for (const dry_run of [true, false]) { + expect( + await renameSymbol(root, symbol, { new_name: newName, dry_run }, nodes), + ).toMatchObject({ + status: 'error', + planning_status: 'unsupported', + code: 'unsupported_occurrence', + applied: false, + }); + expect(await fs.readFile(path.join(root, 'model.py'), 'utf8')).toBe(content); + } + }, + 60_000, + ); + + it('keeps Python runtime attribute strings distinct from normalized destination identifiers', async () => { + const content = + 'class Worker:\n def target(self): return 7\nworker = Worker()\n' + + 'setattr(worker, "Kelvin", 42)\nworker.target()\n'; + await expectRename( + { 'model.py': content }, + { 'model.py': content.replaceAll('target', 'Kelvin') }, + 'target', + 'Method', + 'Kelvin', + ); + }, 60_000); + + it('preserves Python dynamic destination attributes on proven unrelated receivers', async () => { + const content = + 'class Worker:\n def target(self): return 7\n' + + 'class Other:\n def unrelated(self): return 1\n' + + 'worker = Worker()\nother = Other()\n' + + 'setattr(other, "renamed", 42)\nworker.target()\n'; + await expectRename( + { 'model.py': content }, + { 'model.py': content.replaceAll('target', 'renamed') }, + 'target', + 'Method', + ); + }, 60_000); + + it.each([ + '"target"', + '"tar\\x67et"', + '"tar\\u0067et"', + '"tar\\U00000067et"', + '"tar\\147et"', + '"tar" "get"', + 'r"target"', + '"""target"""', + 'f"{name}"', + 'name', + '"tar\\N{LATIN SMALL LETTER G}et"', + ])( + 'refuses matching or unproven Python attribute names: %s', + async (attribute) => { + const content = + 'class Worker:\n def target(self): pass\nworker = Worker()\nworker.target()\n' + + `getattr(worker, ${attribute})\n`; + const { root, nodes, symbol } = await fixture({ 'model.py': content }, 'target', 'Method'); + for (const dry_run of [true, false]) { + const result = await renameSymbol(root, symbol, { new_name: 'renamed', dry_run }, nodes); + expect(result).toMatchObject({ + status: 'error', + planning_status: 'unsupported', + code: 'unsupported_occurrence', + applied: false, + }); + expect(await fs.readFile(path.join(root, 'model.py'), 'utf8')).toBe(content); + } + }, + 60_000, + ); + + it('renames escaped JavaScript declarations and references using ingested graph identities', async () => { + await expectRename( + { 'model.js': 'function t\\u0061rget() {}\nconst value = target;\nt\\u0061rget();\n' }, + { 'model.js': 'function renamed() {}\nconst value = renamed;\nrenamed();\n' }, + 't\\u0061rget', + ); + }, 60_000); + + it('renames normalized Python graph identities while preserving a shadowing binder', async () => { + await expectRename( + { + 'model.py': + '# 😀\ndef Kelvin(): pass\ndef other(Kelvin):\n return Kelvin\nKelvin()\nKelvin()\n', + }, + { + 'model.py': + '# 😀\ndef renamed(): pass\ndef other(Kelvin):\n return Kelvin\nrenamed()\nrenamed()\n', + }, + 'Kelvin', + ); + }, 60_000); + + it('inventories escaped names as one original-source span and covers bare reads', async () => { + const content = 'function target() {}\nconst value = t\\u0061rget;\nif (target) target();\n'; + const parsed = await source(SupportedLanguages.TypeScript, 'model.ts', content); + const escaped = parsed.occurrences.find( + (occurrence) => occurrence.node?.text === 't\\u0061rget', + ); + expect(escaped?.name).toBe('target'); + expect(escaped?.spans).toEqual([{ start: content.indexOf('t\\u0061rget'), length: 11 }]); + expect( + parsed.parsed.referenceSites.filter((site) => site.name === 'target').length, + ).toBeGreaterThanOrEqual(3); + }); + + it('normalizes Python identifiers and retains UTF-16 source positions', async () => { + const content = '# 😀\ndef Kelvin(): pass\nKelvin\n'; + const parsed = await source(SupportedLanguages.Python, 'model.py', content); + const occurrence = parsed.occurrences.find((item) => item.node?.text === 'Kelvin'); + expect(occurrence?.name).toBe('Kelvin'); + expect(occurrence?.spans).toEqual([{ start: content.indexOf('Kelvin'), length: 6 }]); + expect(parsed.parsed.referenceSites.some((site) => site.name === 'Kelvin')).toBe(true); + }); + + it('keeps labels and keyword names separate from lexical names', async () => { + const js = await source( + SupportedLanguages.TypeScript, + 'model.ts', + 'const value = { target: other, target };', + ); + expect( + js.occurrences.filter((item) => item.name === 'target').map((item) => item.role), + ).toEqual(['unrelated', 'name']); + const py = await source(SupportedLanguages.Python, 'model.py', 'call(target=target)'); + expect( + py.occurrences.filter((item) => item.name === 'target').map((item) => item.role), + ).toEqual(['unrelated', 'name']); + }); + + it.each([ + [ + 'model.ts', + 'function target() {}\nconst value = { target };\n', + 'function renamed() {}\nconst value = { target: renamed };\n', + ], + ['model.ts', 'function target() {}\nt\\u0061rget();\n', 'function renamed() {}\nrenamed();\n'], + [ + 'model.ts', + 'function target() {}\nif (target) { target(); }\nconst values = [1];\nconst other = values[0];\n', + 'function renamed() {}\nif (renamed) { renamed(); }\nconst values = [1];\nconst other = values[0];\n', + ], + [ + 'model.py', + 'def target(): pass\nif target:\n target()\nvalues[0]\n', + 'def renamed(): pass\nif renamed:\n renamed()\nvalues[0]\n', + ], + [ + 'model.py', + 'def target(): pass\ndef other(target):\n return target\ntarget()\n', + 'def renamed(): pass\ndef other(target):\n return target\nrenamed()\n', + ], + [ + 'model.ts', + 'function target() {}\nconst other = (target) => target;\ntarget();\n', + 'function renamed() {}\nconst other = (target) => target;\nrenamed();\n', + ], + [ + 'model.ts', + 'function target() {}\nconst other = target => target;\ntarget();\n', + 'function renamed() {}\nconst other = target => target;\nrenamed();\n', + ], + ])('renames source-supported occurrences in %s (case %#)', async (file, content, expected) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'rename-provider-')); + try { + await fs.writeFile(path.join(root, file), content); + const nodes: GraphNode[] = [ + { id: `File:${file}`, label: 'File', properties: { name: file, filePath: file } }, + { + id: `Function:${file}:target`, + label: 'Function', + properties: { name: 'target', filePath: file, startLine: 0, endLine: 0 }, + }, + ]; + const plan = await planGraphRename( + root, + { uid: nodes[1]!.id, name: 'target', filePath: file, startLine: 1, kind: 'Function' }, + { new_name: 'renamed' }, + nodes, + ); + let result = content; + for (const edit of [...plan.edits].reverse()) + result = + result.slice(0, edit.start) + edit.new_text + result.slice(edit.start + edit.length); + expect(result).toBe(expected); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } + }); +}); diff --git a/gitnexus/test/unit/rename-edit-report.test.ts b/gitnexus/test/unit/rename-edit-report.test.ts index b7088b9e7..e730ed729 100644 --- a/gitnexus/test/unit/rename-edit-report.test.ts +++ b/gitnexus/test/unit/rename-edit-report.test.ts @@ -1,25 +1,10 @@ -/** - * Regression test for issue #2605: `rename` must report every edit it applies. - * - * The apply step does a whole-file `\boldName\b` global replace on each touched - * file, but the reported `changes`/`total_edits` were built from a partial - * enumeration that (a) recorded only the definition line, (b) recorded one edit - * per graph-ref file then broke, and (c) skipped text-search on any file already - * covered by the graph. When a private symbol's definition and all its call - * sites live in one file, only the definition line was reported (total_edits: 1) - * while apply rewrote every occurrence. These tests drive the single-file repro, - * a mixed graph/text_search multi-file rename, and a partial write failure, and - * assert the report matches what apply actually writes in each case. - */ +/** Backend regressions for #3486; retain preview/apply fidelity from #2605. */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { promises as fs } from 'node:fs'; import fsPromises from 'fs/promises'; import os from 'node:os'; import path from 'node:path'; -// Prevent onnxruntime / native search adapters from loading at import time -// (mirrors test/unit/calltool-dispatch.test.ts). We drive the private rename() -// directly, so the graph/DB/embedding layers are never exercised. vi.mock('../../src/core/search/bm25-index.js', () => ({ searchFTSFromLbug: vi.fn().mockResolvedValue({ results: [], ftsAvailable: true }), })); @@ -28,111 +13,142 @@ vi.mock('../../src/mcp/core/embedder.js', () => ({ getEmbeddingDims: vi.fn().mockReturnValue(384), })); -// rename() shells out to `rg -l` to discover text-search files. Stub it so the -// ripgrep-discovery branch is deterministic and driveable (rg is not reliably -// on PATH inside the vitest worker). Default: no hits. -const { execFileSyncMock } = vi.hoisted(() => ({ execFileSyncMock: vi.fn(() => '') })); -vi.mock('child_process', async (importActual) => { - const actual = await importActual(); - return { ...actual, execFileSync: execFileSyncMock }; -}); +// No text discovery is permitted, even when rg is installed on the host. +const { execFileSyncMock } = vi.hoisted(() => ({ + execFileSyncMock: vi.fn(() => { + throw new Error('rg unavailable'); + }), +})); +vi.mock('child_process', async (importActual) => ({ + ...(await importActual()), + execFileSync: execFileSyncMock, +})); import { LocalBackend } from '../../src/mcp/local/local-backend.js'; -type Incoming = { - calls: { filePath: string }[]; - imports: { filePath: string }[]; - extends: { filePath: string }[]; - implements: { filePath: string }[]; -}; -const EMPTY_INCOMING: Incoming = { calls: [], imports: [], extends: [], implements: [] }; +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' }]), +})); -type RenameResult = { - status: string; - applied: boolean; - files_affected: number; - total_edits: number; - graph_edits: number; - text_search_edits: number; - changes: { file_path: string; edits: { line: number; confidence: string }[] }[]; - failed_files?: string[]; -}; - -// The #2605 repro: a private free fn with exactly 4 textual occurrences of -// `rename_target` — the definition, one production call, two test calls — all -// in the same file. -const RUST_SRC = `fn rename_target(x: u32) -> u32 { - x + 1 +const SOURCE = `export class Writer { + close() {} } - -pub fn prod_call() -> u32 { - rename_target(1) +export class Other { + close() {} } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn unit_one() { - assert_eq!(rename_target(1), 2); - } - - #[test] - fn unit_two() { - assert_eq!(rename_target(2), 3); - } +export function run() { + const writer = new Writer(); + const other = new Other(); + const hooks: Other = { close() { writer.close(); } }; writer.close(); other.close(); + // close should remain in this comment. + const title = 'close'; + return { hooks, title }; +} +`; +const CALLER = `import { Writer } from './writer.js'; +export function caller() { + const writer = new Writer(); + writer.close(); } `; -/** 1-based occurrence lines of `rename_target` in `src` — the ground truth the - * report must match. Computed (not hardcoded) so editing a fixture cannot - * silently desync the expectation. */ -function occurrenceLines(src: string): number[] { - return src - .split('\n') - .map((line, i) => (/\brename_target\b/.test(line) ? i + 1 : 0)) - .filter((n) => n > 0); -} -const OCCURRENCE_LINES = occurrenceLines(RUST_SRC); - -/** Build a backend whose graph lookup returns the symbol (definition at - * src/lib.rs) with the given incoming refs. */ -function stubbedBackend(incoming: Incoming = EMPTY_INCOMING): LocalBackend { +function stubbedBackend(symbol: Record = {}) { const backend = new LocalBackend(); - vi.spyOn( - backend as unknown as { ensureInitialized: () => Promise }, - 'ensureInitialized', - ).mockResolvedValue(undefined); - vi.spyOn(backend as unknown as { context: () => Promise }, 'context').mockResolvedValue({ - status: 'success', - symbol: { name: 'rename_target', filePath: 'src/lib.rs', startLine: OCCURRENCE_LINES[0] }, - incoming, + vi.spyOn(backend as any, 'ensureInitialized').mockResolvedValue(undefined); + vi.spyOn(backend as any, 'context').mockResolvedValue({ + status: 'found', + symbol: { + uid: 'Method:src/writer.ts:Writer.close#0', + name: 'close', + kind: 'Method', + filePath: 'src/writer.ts', + startLine: 2, + endLine: 2, + ...symbol, + }, + incoming: { calls: [], imports: [], extends: [], implements: [] }, }); return backend; } -function callRename( - backend: LocalBackend, - repoPath: string, - params: Record, -): Promise { - return ( - backend as unknown as { rename: (r: unknown, p: unknown) => Promise } - ).rename({ repoPath }, { symbol_name: 'rename_target', new_name: 'renamed_fn', ...params }); -} +const callRename = (backend: LocalBackend, repoPath: string, params = {}) => + (backend as any).rename( + { repoPath, lbugPath: path.join(repoPath, '.gitnexus', 'lbug') }, + { + symbol_name: 'close', + new_name: 'closeWriter', + ...params, + }, + ); -const editsFor = (r: RenameResult, file: string) => - r.changes.find((c) => c.file_path === file)?.edits ?? []; - -describe('rename edit report is faithful to apply (#2605)', () => { +describe('semantic rename reports exact applied occurrences (#3486, #2605)', () => { let tmpDir: string; beforeEach(async () => { - execFileSyncMock.mockReturnValue(''); // default: no ripgrep hits - tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-2605-')); + 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: 13, + }, + { + id: 'Function:src/caller.ts:caller', + name: 'caller', + filePath: 'src/caller.ts', + startLine: 1, + endLine: 4, + }, + ); + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-3486-backend-')); + await fs.mkdir(path.join(tmpDir, '.gitnexus')); + await fs.writeFile( + path.join(tmpDir, '.gitnexus', 'gitnexus.json'), + JSON.stringify({ contentRetention: 'full', indexedAt: '2024-06-01T12:00:00Z' }), + ); await fs.mkdir(path.join(tmpDir, 'src')); - await fs.writeFile(path.join(tmpDir, 'src', 'lib.rs'), RUST_SRC, 'utf-8'); + await fs.writeFile(path.join(tmpDir, 'src/writer.ts'), SOURCE); + await fs.writeFile(path.join(tmpDir, 'src/caller.ts'), CALLER); }); afterEach(async () => { @@ -140,100 +156,91 @@ describe('rename edit report is faithful to apply (#2605)', () => { await fs.rm(tmpDir, { recursive: true, force: true }); }); - it('previews every occurrence that apply will rewrite (dry_run)', async () => { - const result = await callRename(stubbedBackend(), tmpDir, { dry_run: true }); - - expect(result.applied).toBe(false); - expect(result.files_affected).toBe(1); - expect(result.total_edits).toBe(OCCURRENCE_LINES.length); // 4, not 1 - // Concrete split, not just the sum: all occurrences are in the definition - // file, so they are graph-confidence and text_search is zero. - expect(result.graph_edits).toBe(OCCURRENCE_LINES.length); - expect(result.text_search_edits).toBe(0); - - const edits = editsFor(result, 'src/lib.rs'); - expect(edits.map((e) => e.line).sort((a, b) => a - b)).toEqual(OCCURRENCE_LINES); - expect(edits.every((e) => e.confidence === 'graph')).toBe(true); - - // A dry run leaves the file untouched. - const onDisk = await fs.readFile(path.join(tmpDir, 'src', 'lib.rs'), 'utf-8'); - expect(onDisk).toContain('rename_target'); - }); - - it('reports exactly what it wrote (apply)', async () => { - const result = await callRename(stubbedBackend(), tmpDir, { dry_run: false }); - - expect(result.applied).toBe(true); - expect(result.total_edits).toBe(OCCURRENCE_LINES.length); - - const onDisk = await fs.readFile(path.join(tmpDir, 'src', 'lib.rs'), 'utf-8'); - const renamedCount = (onDisk.match(/\brenamed_fn\b/g) || []).length; - const stragglers = (onDisk.match(/\brename_target\b/g) || []).length; - expect(renamedCount).toBe(OCCURRENCE_LINES.length); // all 4 rewritten - expect(stragglers).toBe(0); - - // The reported edit count equals the number of replacements that landed. - const reportedEdits = result.changes.reduce((n, c) => n + c.edits.length, 0); - expect(reportedEdits).toBe(renamedCount); - }); - - it('enumerates all occurrences across graph-ref and text-search files, keeping confidence per file', async () => { - // A graph-referencing file (not the definition) with MULTIPLE occurrences — - // the exact "one edit per file then break" bug's other original trigger. - const CALLER = 'use crate::rename_target;\nfn a() { rename_target(1); rename_target(2); }\n'; - // A file discovered only by ripgrep — the text_search branch. - const NOTES = '// see rename_target for details\n'; - await fs.writeFile(path.join(tmpDir, 'src', 'caller.rs'), CALLER, 'utf-8'); - await fs.writeFile(path.join(tmpDir, 'src', 'notes.rs'), NOTES, 'utf-8'); - // rg reports the definition file (already graph — exercises never-downgrade) - // and the text-only file. - execFileSyncMock.mockReturnValue('src/lib.rs\nsrc/notes.rs\n'); - - const backend = stubbedBackend({ - ...EMPTY_INCOMING, - calls: [{ filePath: 'src/caller.rs' }], + it('previews bound occurrences, including callers absent from graph incoming files', async () => { + const result = await callRename(stubbedBackend(), tmpDir); + expect(result, JSON.stringify(result)).toMatchObject({ + status: 'success', + applied: false, + result_version: 2, + total_edits: 4, + semantic_edits: 4, + graph_edits: 0, + text_search_edits: 0, + text_search: 'not_used', + files_affected: 2, + coverage: { name: 'gitnexus-semantic', scope: 'indexed-repository' }, }); - const result = await callRename(backend, tmpDir, { dry_run: true }); - - const callerOcc = occurrenceLines(CALLER).length; // 3 - const notesOcc = occurrenceLines(NOTES).length; // 1 - - expect(result.files_affected).toBe(3); - expect(result.total_edits).toBe(OCCURRENCE_LINES.length + callerOcc + notesOcc); - // Split is concrete: definition + graph-ref file are graph; the rg-only file - // is text_search. A file reached by both graph and rg keeps graph (never - // downgraded). - expect(result.graph_edits).toBe(OCCURRENCE_LINES.length + callerOcc); - expect(result.text_search_edits).toBe(notesOcc); - - expect(editsFor(result, 'src/lib.rs').every((e) => e.confidence === 'graph')).toBe(true); - expect(editsFor(result, 'src/caller.rs').map((e) => e.confidence)).toEqual(['graph', 'graph']); - expect(editsFor(result, 'src/notes.rs').map((e) => e.confidence)).toEqual(['text_search']); + const edits = result.changes.flatMap((change: any) => change.edits); + expect(edits.every((edit: any) => edit.confidence === 'semantic')).toBe(true); + expect(edits.every((edit: any) => edit.old_text === 'close')).toBe(true); + expect( + result.changes.find((change: any) => change.file_path === 'src/writer.ts').edits, + ).toHaveLength(3); + expect(execFileSyncMock).not.toHaveBeenCalled(); + expect(await fs.readFile(path.join(tmpDir, 'src/writer.ts'), 'utf8')).toBe(SOURCE); }); - it('reports only files that landed when a write fails mid-apply (#2605 partial)', async () => { - const CALLER = 'fn a() { rename_target(1); rename_target(2); }\n'; - await fs.writeFile(path.join(tmpDir, 'src', 'caller.rs'), CALLER, 'utf-8'); - const backend = stubbedBackend({ ...EMPTY_INCOMING, calls: [{ filePath: 'src/caller.rs' }] }); - - // caller.rs write throws; lib.rs succeeds. - vi.spyOn(fsPromises, 'writeFile').mockImplementation( - async (p: Parameters[0]) => { - if (String(p).endsWith(`${path.sep}caller.rs`)) { - throw Object.assign(new Error('EACCES'), { code: 'EACCES' }); - } - }, - ); - + it('applies only displayed spans, preserving unrelated same-line keys and symbols', async () => { + const backend = stubbedBackend(); + const preview = await callRename(backend, tmpDir); const result = await callRename(backend, tmpDir, { dry_run: false }); + 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( + 'writer.close()', + 'writer.closeWriter()', + ), + ); + expect(await fs.readFile(path.join(tmpDir, 'src/caller.ts'), 'utf8')).toBe( + CALLER.replace('writer.close()', 'writer.closeWriter()'), + ); + }); - expect(result.status).toBe('partial'); - expect(result.failed_files).toEqual(['src/caller.rs']); - // The failed file's edits are NOT reported as applied: totals describe only - // what reached disk (lib.rs), never the attempted caller.rs occurrences. - expect(result.files_affected).toBe(1); - expect(result.total_edits).toBe(OCCURRENCE_LINES.length); - expect(result.graph_edits).toBe(OCCURRENCE_LINES.length); - expect(result.changes.map((c) => c.file_path)).toEqual(['src/lib.rs']); + 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({ + uid: 'Function:src/lib.rs:close', + kind: 'Function', + filePath: 'src/lib.rs', + startLine: 1, + endLine: 1, + }); + const result = await callRename(backend, tmpDir, { dry_run: false }); + expect(result, JSON.stringify(result)).toMatchObject({ applied: false, status: 'error' }); + expect(await fs.readFile(path.join(tmpDir, 'src/lib.rs'), 'utf8')).toBe(rust); + }); + + it('rejects malformed dry_run instead of treating it as an apply request', async () => { + const result = await callRename(stubbedBackend(), tmpDir, { dry_run: 0 }); + expect(result.error).toMatch(/dry_run/); + expect(result.applied).toBe(false); + expect(await fs.readFile(path.join(tmpDir, 'src/writer.ts'), 'utf8')).toBe(SOURCE); + }); + + it('reports only the files and occurrences whose writes landed', async () => { + const originalRename = fsPromises.rename.bind(fsPromises); + vi.spyOn(fsPromises, 'rename').mockImplementation(async (source, destination) => { + if (String(destination).endsWith(`${path.sep}writer.ts`)) throw new Error('EACCES'); + return originalRename(source, destination); + }); + const result = await callRename(stubbedBackend(), tmpDir, { dry_run: false }); + expect(result, JSON.stringify(result)).toMatchObject({ + status: 'partial', + applied: true, + total_edits: 1, + semantic_edits: 1, + }); + expect(result.failed_files).toEqual(['src/writer.ts']); + expect(result.changes.map((change: any) => change.file_path)).toEqual(['src/caller.ts']); + expect(await fs.readFile(path.join(tmpDir, 'src/writer.ts'), 'utf8')).toBe(SOURCE); + expect(await fs.readFile(path.join(tmpDir, 'src/caller.ts'), 'utf8')).toContain( + 'writer.closeWriter()', + ); }); }); diff --git a/gitnexus/test/unit/rename-family.test.ts b/gitnexus/test/unit/rename-family.test.ts new file mode 100644 index 000000000..fc0623e86 --- /dev/null +++ b/gitnexus/test/unit/rename-family.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'vitest'; +import type { SymbolDefinition } from 'gitnexus-shared'; +import { createKnowledgeGraph } from '../../src/core/graph/graph.js'; +import { graphRenameFamily } from '../../src/core/ingestion/languages/rename-family.js'; +import type { RenameSemanticContext } from '../../src/core/ingestion/rename-provider.js'; + +function fixture(edge: boolean) { + const graph = createKnowledgeGraph(); + const definitions: SymbolDefinition[] = [ + { + nodeId: 'def:model.ts#1:0:Class:Base', + filePath: 'model.ts', + type: 'Class', + qualifiedName: 'Base', + }, + { + nodeId: 'def:model.ts#4:0:Class:Derived', + filePath: 'model.ts', + type: 'Class', + qualifiedName: 'Derived', + }, + ]; + for (const [index, owner] of [...definitions].entries()) { + graph.addNode({ + id: `Class:model.ts:${owner.qualifiedName}`, + label: 'Class', + properties: { name: owner.qualifiedName!, filePath: 'model.ts', startLine: index * 3 }, + }); + const method = { + nodeId: `def:model.ts#${index * 3 + 2}:1:Method:close`, + filePath: 'model.ts', + type: 'Method' as const, + qualifiedName: `${owner.qualifiedName}.close`, + parameterCount: 0, + ownerId: owner.nodeId, + }; + definitions.push(method); + graph.addNode({ + id: `Method:model.ts:${method.qualifiedName}#0`, + label: 'Method', + properties: { + name: 'close', + filePath: 'model.ts', + startLine: index * 3 + 1, + parameterCount: 0, + }, + }); + } + if (edge) + graph.addRelationship({ + id: 'override', + sourceId: 'Class:model.ts:Derived', + targetId: 'Method:model.ts:Base.close#0', + type: 'METHOD_OVERRIDES', + confidence: 0.9, + reason: 'single-ancestor override', + }); + return { + definitions, + graph, + target: definitions[2]!, + referenceBindings: new Map(), + indexes: { + referenceSites: [], + methodDispatch: { + mroFor: (id: string) => (id === definitions[1]!.nodeId ? [definitions[0]!.nodeId] : []), + implementorsOf: () => [], + }, + }, + } as unknown as RenameSemanticContext; +} + +describe('provider-selected graph rename families', () => { + const matches = (left: SymbolDefinition, right: SymbolDefinition) => + left.type === 'Method' && right.type === 'Method'; + + it('joins the precise class-to-method override edge to its written implementation', () => { + const context = fixture(true); + expect(graphRenameFamily(context, { matches })).toEqual([ + context.target, + context.definitions[3], + ]); + }); + + it('refuses a potential family when the graph has only owner inheritance', () => { + expect(graphRenameFamily(fixture(false), { matches })).toMatchObject({ + code: 'unsupported_symbol_family', + }); + }); + + it('lets the provider reject incompatible syntax despite an override edge', () => { + expect(graphRenameFamily(fixture(true), { matches, canCouple: () => false })).toMatchObject({ + code: 'unsupported_symbol_family', + }); + }); +}); diff --git a/gitnexus/test/unit/rename-go-reresolution.test.ts b/gitnexus/test/unit/rename-go-reresolution.test.ts new file mode 100644 index 000000000..9737f8944 --- /dev/null +++ b/gitnexus/test/unit/rename-go-reresolution.test.ts @@ -0,0 +1,201 @@ +/** + * The Go rename provider refuses to unexport a symbol another package uses + * (`goRenameProvider.checkOccurrence`, see `rename-go-visibility.test.ts`), so + * the planner normally stops before it edits anything. These cases switch that + * provider gate OFF and ask the OTHER line of defence on its own: the + * re-resolution pass that re-runs scope resolution over the rewritten sources + * and certifies that every written binding still denotes what it denoted. + * + * That pass used to certify the unsafe rewrite. A namespace-qualified lookup + * treated every module-scope declaration of the target package as a public + * member, so `api.target` re-resolved to the very declaration `api.Target` + * named, the binding looked unchanged, and a program that no longer compiles + * was accepted. `ScopeResolver.isNamespaceMemberVisible` (Go: an identifier is + * exported only with an uppercase first letter) closes that. + */ + +import { afterEach, describe, expect, it, vi } from 'vitest'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js'; +import { goRenameProvider } from '../../src/core/ingestion/languages/go/rename.js'; +import { renameSymbol } from '../../src/mcp/local/rename/rename-plan.js'; + +const MODULE = 'module example.com/renamefixture\n\ngo 1.22\n'; +const IMPORT = 'import "example.com/renamefixture/api"'; + +const roots: string[] = []; +afterEach(async () => { + vi.restoreAllMocks(); + await Promise.all(roots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true }))); +}); + +/** Go packages are directories, so unlike the flat shared helper this writes subdirectories. */ +async function indexTree( + files: Record, + name: string, + kind: string, + targetFile: string, +) { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'rename-go-reresolution-')); + roots.push(root); + const tree = { 'go.mod': MODULE, ...files }; + for (const [file, content] of Object.entries(tree)) { + await fs.mkdir(path.dirname(path.join(root, file)), { recursive: true }); + await fs.writeFile(path.join(root, file), content); + } + const result = await runPipelineFromRepo(root, () => undefined, { + workerPoolSize: 1, + skipGraphPhases: true, + }); + const nodes = [...result.graph.iterNodes()]; + const target = nodes.find( + (node) => + node.properties.name === name && + node.label === kind && + node.properties.filePath === targetFile, + ); + expect(target, JSON.stringify(nodes.filter((node) => node.label !== 'File'))).toBeDefined(); + const symbol = { + uid: target!.id, + name: target!.properties.name, + filePath: target!.properties.filePath, + startLine: target!.properties.startLine! + 1, + endLine: target!.properties.endLine! + 1, + kind: target!.label, + }; + return { root, nodes, symbol, tree }; +} + +/** The provider gate answers "no issue" for every occurrence: only re-resolution remains. */ +function disableProviderVisibilityGate(): void { + vi.spyOn(goRenameProvider, 'checkOccurrence').mockReturnValue(undefined); +} + +describe('Go rename re-resolution refuses to certify an unexport on its own', () => { + it.each([ + [ + 'a function called through a package qualifier', + 'Target', + 'Function', + 'target', + { + 'api/api.go': 'package api\n\nfunc Target(value int) int { return value + 1 }\n', + 'main.go': `package main\n\n${IMPORT}\n\nfunc run(value int) int { return api.Target(value) }\n\nfunc main() { _ = run(1) }\n`, + }, + ], + [ + 'a package variable read through a package qualifier', + 'Counter', + 'Variable', + 'counter', + { + 'api/api.go': 'package api\n\nvar Counter = 3\n', + 'main.go': `package main\n\n${IMPORT}\n\nvar seen = api.Counter\n\nfunc main() { _ = seen }\n`, + }, + ], + [ + 'a function called by bare name through a dot import', + 'Target', + 'Function', + 'target', + { + 'api/api.go': 'package api\n\nfunc Target(value int) int { return value + 1 }\n', + 'main.go': `package main\n\nimport . "example.com/renamefixture/api"\n\nfunc run(value int) int { return Target(value) }\n\nfunc main() { _ = run(1) }\n`, + }, + ], + [ + 'a function called through an aliased import', + 'Target', + 'Function', + 'target', + { + 'api/api.go': 'package api\n\nfunc Target(value int) int { return value + 1 }\n', + 'main.go': `package main\n\nimport shim "example.com/renamefixture/api"\n\nfunc run(value int) int { return shim.Target(value) }\n\nfunc main() { _ = run(1) }\n`, + }, + ], + [ + 'a function called by an external test package of the same directory', + 'Target', + 'Function', + 'target', + { + 'api/api.go': 'package api\n\nfunc Target(value int) int { return value + 1 }\n', + 'api/api_test.go': `package api_test\n\nimport "testing"\n\n${IMPORT}\n\nfunc TestTarget(t *testing.T) { _ = api.Target(1) }\n`, + }, + ], + ])( + 'with the provider gate off, %s is still refused by re-resolution', + async (_label, name, kind, newName, files) => { + disableProviderVisibilityGate(); + const { root, nodes, symbol, tree } = await indexTree(files, name, kind, 'api/api.go'); + for (const dry_run of [true, false]) { + const result = await renameSymbol(root, symbol, { new_name: newName, dry_run }, nodes); + expect(result, JSON.stringify(result)).toMatchObject({ + status: 'error', + code: 'name_collision', + applied: false, + total_edits: 0, + }); + expect(result.error).toContain('changes a source binding'); + } + for (const [file, content] of Object.entries(tree)) + expect(await fs.readFile(path.join(root, file), 'utf8')).toBe(content); + }, + 60000, + ); +}); + +describe('Go rename re-resolution still certifies safe renames with the provider gate off', () => { + it.each([ + [ + 'an exported function renamed to another exported name across packages', + 'Renamed', + { + 'api/api.go': 'package api\n\nfunc Target(value int) int { return value + 1 }\n', + 'main.go': `package main\n\n${IMPORT}\n\nfunc run(value int) int { return api.Target(value) }\n\nfunc main() { _ = run(1) }\n`, + }, + { + 'api/api.go': 'package api\n\nfunc Renamed(value int) int { return value + 1 }\n', + 'main.go': `package main\n\n${IMPORT}\n\nfunc run(value int) int { return api.Renamed(value) }\n\nfunc main() { _ = run(1) }\n`, + }, + ], + [ + 'an exported function unexported when every user shares its package', + 'target', + { + 'api/api.go': 'package api\n\nfunc Target(value int) int { return value + 1 }\n', + 'api/other.go': + 'package api\n\nfunc Twice(value int) int { return Target(Target(value)) }\n', + }, + { + 'api/api.go': 'package api\n\nfunc target(value int) int { return value + 1 }\n', + 'api/other.go': + 'package api\n\nfunc Twice(value int) int { return target(target(value)) }\n', + }, + ], + ])( + 'with the provider gate off, %s is applied', + async (_label, newName, files, expected) => { + disableProviderVisibilityGate(); + const { root, nodes, symbol } = await indexTree(files, 'Target', 'Function', 'api/api.go'); + const preview = await renameSymbol(root, symbol, { new_name: newName, dry_run: true }, nodes); + expect(preview, JSON.stringify(preview)).toMatchObject({ status: 'success', applied: false }); + const applied = await renameSymbol( + root, + symbol, + { new_name: newName, dry_run: false }, + nodes, + ); + expect(applied, JSON.stringify(applied)).toMatchObject({ + status: 'success', + applied: true, + changes: preview.changes, + }); + for (const [file, content] of Object.entries(expected)) + expect(await fs.readFile(path.join(root, file), 'utf8')).toBe(content); + }, + 60000, + ); +}); diff --git a/gitnexus/test/unit/rename-go-visibility.test.ts b/gitnexus/test/unit/rename-go-visibility.test.ts new file mode 100644 index 000000000..460b8193e --- /dev/null +++ b/gitnexus/test/unit/rename-go-visibility.test.ts @@ -0,0 +1,248 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js'; +import { hidesExportFromPackage } from '../../src/core/ingestion/languages/go/rename.js'; +import { renameSymbol } from '../../src/mcp/local/rename/rename-plan.js'; + +const API = 'package api\n\nfunc Target(value int) int { return value + 1 }\n'; +const MODULE = 'module example.com/renamefixture\n\ngo 1.22\n'; +const IMPORT = 'import "example.com/renamefixture/api"'; + +const roots: string[] = []; +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true }))); +}); + +/** Go packages are directories, so unlike the flat shared helper this writes subdirectories. */ +async function indexTree( + files: Record, + name: string, + kind: string, + targetFile: string, +) { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'rename-go-visibility-')); + roots.push(root); + const tree = { 'go.mod': MODULE, ...files }; + for (const [file, content] of Object.entries(tree)) { + await fs.mkdir(path.dirname(path.join(root, file)), { recursive: true }); + await fs.writeFile(path.join(root, file), content); + } + const result = await runPipelineFromRepo(root, () => undefined, { + workerPoolSize: 1, + skipGraphPhases: true, + }); + const nodes = [...result.graph.iterNodes()]; + const target = nodes.find( + (node) => + node.properties.name === name && + node.label === kind && + node.properties.filePath === targetFile, + ); + expect(target, JSON.stringify(nodes.filter((node) => node.label !== 'File'))).toBeDefined(); + const symbol = { + uid: target!.id, + name: target!.properties.name, + filePath: target!.properties.filePath, + startLine: target!.properties.startLine! + 1, + endLine: target!.properties.endLine! + 1, + kind: target!.label, + }; + return { root, nodes, symbol, tree }; +} + +describe('Go export rule (spec: first code point has Unicode category Lu)', () => { + const declaring = { path: 'api/api.go', content: 'package api\n' }; + const importer = { path: 'main.go', content: 'package main\n' }; + + it.each([ + ['Target', 'target'], + ['Ünicode', 'ünicode'], + ['Σigma', 'σigma'], + ['Target', '_target'], + ['Ünicode', '日本'], + ['Target', 'Džtitle'], + ['Target', 'Ⅷnumeral'], + ['\u{1D400}name', 'name'], + ])('hides %s from another package when renamed to %s', (oldName, newName) => { + expect(hidesExportFromPackage({ oldName, newName, declaring, user: importer })).toBe(true); + }); + + it.each([ + ['Target', 'Renamed'], + ['Target', 'Ünicode'], + ['target', 'Renamed'], + ['target', 'renamed'], + ['日本', '中文'], + ['日本', 'Ünicode'], + ['Džtitle', 'renamed'], + ['Ⅷnumeral', 'renamed'], + ['_target', 'Renamed'], + ['Target', '\u{1D400}name'], + ])('keeps %s visible to another package when renamed to %s', (oldName, newName) => { + expect(hidesExportFromPackage({ oldName, newName, declaring, user: importer })).toBe(false); + }); + + it.each([ + [ + 'another file of the same package', + declaring, + { path: 'api/other.go', content: 'package api\n' }, + ], + ['a same-package test file', declaring, { path: 'api/api_test.go', content: 'package api\n' }], + ['the declaring file itself', declaring, declaring], + [ + 'the declaring file even with no readable clause', + { path: 'api/api.go', content: 'not a package clause\n' }, + { path: 'api/api.go', content: 'not a package clause\n' }, + ], + ])('allows unexporting for a use in %s', (_label, home, user) => { + expect( + hidesExportFromPackage({ oldName: 'Target', newName: 'target', declaring: home, user }), + ).toBe(false); + }); + + it.each([ + ['an external test package in the same directory', 'api/api_test.go', 'package api_test\n'], + ['another directory', 'other/api.go', 'package api\n'], + ['a file with no readable package clause', 'api/broken.go', 'func broken() {}\n'], + ['a clause that follows a leading comment', 'api/c.go', '/* package api */\npackage main\n'], + ])('refuses unexporting for a use in %s', (_label, userPath, content) => { + expect( + hidesExportFromPackage({ + oldName: 'Target', + newName: 'target', + declaring, + user: { path: userPath, content }, + }), + ).toBe(true); + }); + + it('cannot prove the package when the declaring source is unavailable', () => { + expect( + hidesExportFromPackage({ + oldName: 'Target', + newName: 'target', + declaring: undefined, + user: importer, + }), + ).toBe(true); + }); +}); + +describe('Go rename refuses to unexport symbols other packages use', () => { + it.each([ + [ + 'a constant', + 'Limit', + 'Const', + 'limit', + { + 'api/api.go': 'package api\n\nconst Limit = 3\n', + 'main.go': `package main\n\n${IMPORT}\n\nvar bound = api.Limit\n\nfunc main() { _ = bound }\n`, + }, + ], + [ + 'a package variable', + 'Counter', + 'Variable', + 'counter', + { + 'api/api.go': 'package api\n\nvar Counter = 3\n', + 'main.go': `package main\n\n${IMPORT}\n\nvar seen = api.Counter\n\nfunc main() { _ = seen }\n`, + }, + ], + [ + 'a function used through a dot import', + 'Target', + 'Function', + 'target', + { + 'api/api.go': API, + 'main.go': `package main\n\nimport . "example.com/renamefixture/api"\n\nfunc run(value int) int { return Target(value) }\n\nfunc main() { _ = run(1) }\n`, + }, + ], + [ + 'a function used by an external test package in the same directory', + 'Target', + 'Function', + 'target', + { + 'api/api.go': API, + 'api/api_test.go': `package api_test\n\nimport "testing"\n\n${IMPORT}\n\nfunc TestTarget(t *testing.T) { _ = api.Target(1) }\n`, + }, + ], + ])( + '%s', + async (_label, name, kind, newName, files) => { + const { root, nodes, symbol, tree } = await indexTree(files, name, kind, 'api/api.go'); + for (const dry_run of [true, false]) { + const result = await renameSymbol(root, symbol, { new_name: newName, dry_run }, nodes); + expect(result, JSON.stringify(result)).toMatchObject({ + status: 'error', + code: 'invalid_name', + planning_status: 'blocked', + applied: false, + total_edits: 0, + }); + expect(result.error).toContain('outside its declaring package'); + } + for (const [file, content] of Object.entries(tree)) + expect(await fs.readFile(path.join(root, file), 'utf8')).toBe(content); + }, + 60000, + ); +}); + +describe('Go rename keeps visibility-preserving and package-local renames', () => { + it.each([ + [ + 'exported to exported across packages', + 'Renamed', + { + 'api/api.go': API, + 'main.go': `package main\n\n${IMPORT}\n\nfunc run(value int) int { return api.Target(value) }\n\nfunc main() { _ = run(1) }\n`, + }, + { + 'api/api.go': 'package api\n\nfunc Renamed(value int) int { return value + 1 }\n', + 'main.go': `package main\n\n${IMPORT}\n\nfunc run(value int) int { return api.Renamed(value) }\n\nfunc main() { _ = run(1) }\n`, + }, + ], + [ + 'exported to unexported when a same-package test file is the only other user', + 'target', + { + 'api/api.go': API, + 'api/api_test.go': + 'package api\n\nimport "testing"\n\nfunc TestTarget(t *testing.T) { _ = Target(1) }\n', + }, + { + 'api/api.go': 'package api\n\nfunc target(value int) int { return value + 1 }\n', + 'api/api_test.go': + 'package api\n\nimport "testing"\n\nfunc TestTarget(t *testing.T) { _ = target(1) }\n', + }, + ], + ])( + '%s', + async (_label, newName, files, expected) => { + const { root, nodes, symbol } = await indexTree(files, 'Target', 'Function', 'api/api.go'); + const preview = await renameSymbol(root, symbol, { new_name: newName, dry_run: true }, nodes); + expect(preview, JSON.stringify(preview)).toMatchObject({ status: 'success', applied: false }); + const applied = await renameSymbol( + root, + symbol, + { new_name: newName, dry_run: false }, + nodes, + ); + expect(applied, JSON.stringify(applied)).toMatchObject({ + status: 'success', + applied: true, + changes: preview.changes, + }); + for (const [file, content] of Object.entries(expected)) + expect(await fs.readFile(path.join(root, file), 'utf8')).toBe(content); + }, + 60000, + ); +}); diff --git a/gitnexus/test/unit/rename-managed-identifiers.test.ts b/gitnexus/test/unit/rename-managed-identifiers.test.ts new file mode 100644 index 000000000..20458287e --- /dev/null +++ b/gitnexus/test/unit/rename-managed-identifiers.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; +import { javaRenameProvider } from '../../src/core/ingestion/languages/java/rename.js'; +import { dartRenameProvider } from '../../src/core/ingestion/languages/dart/rename.js'; + +const validateJavaName = javaRenameProvider.validateName; +const validateDartName = dartRenameProvider.validateName; +if (!validateJavaName || !validateDartName) throw new Error('Missing identifier validator'); + +describe('managed provider replacement identifiers', () => { + it.each(['_', String.raw`\u005f`, String.raw`\uu005F`])( + 'rejects the reserved Java underscore spelling %s', + (name) => { + expect(validateJavaName(name)).toMatchObject({ code: 'invalid_name' }); + }, + ); + + it.each(['_measure', 'measure_', '__', String.raw`\u005fmeasure`])( + 'allows an underscore within the longer Java identifier %s', + (name) => { + expect(validateJavaName(name)).toBeUndefined(); + }, + ); + + // Dart's built-in identifiers are restricted by declaration kind, not lexically reserved. + it.each(['abstract', 'import', 'dynamic', 'Function', 'type', 'get', 'set'])( + 'keeps the Dart built-in identifier %s available for member names', + (name) => { + expect(validateDartName(name)).toBeUndefined(); + }, + ); + + it.each(['class', 'return', 'void', 'new'])('rejects the Dart reserved word %s', (name) => { + expect(validateDartName(name)).toMatchObject({ code: 'invalid_name' }); + }); +}); diff --git a/gitnexus/test/unit/rename-managed-providers.test.ts b/gitnexus/test/unit/rename-managed-providers.test.ts new file mode 100644 index 000000000..047d38047 --- /dev/null +++ b/gitnexus/test/unit/rename-managed-providers.test.ts @@ -0,0 +1,688 @@ +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { SupportedLanguages } from 'gitnexus-shared'; +import { createRenameFixtureHelpers } from '../helpers/rename-provider-fixture.js'; +import { SCOPE_RESOLVERS } from '../../src/core/ingestion/scope-resolution/pipeline/registry.js'; +import { renameSymbol } from '../../src/mcp/local/rename/rename-plan.js'; + +const { fixture, expectRename } = createRenameFixtureHelpers(); + +describe('managed provider rename through the production parsing pipeline', () => { + it('preserves C# named argument labels that share a method name', async () => { + const content = + 'class Model { static void target() {} static void other(int target) {} static void caller() { target(); other(target: 1); } }\n'; + await expectRename( + { 'model.cs': content }, + { 'model.cs': content.replaceAll('target()', 'renamed()') }, + 'target', + 'Method', + ); + }, 60_000); + + it('keeps independent Dart static methods out of inherited rename families', async () => { + const content = + 'class Base { static void target() {} }\nclass Child extends Base { static void target() {} }\nvoid use() { Base.target(); Child.target(); }\n'; + await expectRename( + { 'model.dart': content }, + { 'model.dart': content.replace('target', 'renamed').replace('Base.target', 'Base.renamed') }, + 'target', + 'Method', + ); + }, 60_000); + + it.each([ + ['Class', 'public class Writer { public Writer() {} }\n'], + ['Constructor', 'public class Writer { public Writer() {} }\n'], + ['Interface', 'public interface Writer {}\n'], + ['Enum', 'public enum Writer { VALUE }\n'], + ['Record', 'public record Writer() {}\n'], + ['Annotation', 'public @interface Writer {}\n'], + ])( + 'refuses a public top-level Java %s rename requiring a file move', + async (kind, content) => { + const { root, nodes, symbol } = await fixture({ 'Writer.java': content }, 'Writer', kind); + for (const dry_run of [true, false]) { + const result = await renameSymbol(root, symbol, { new_name: 'Reader', dry_run }, nodes); + expect(result, JSON.stringify(result)).toMatchObject({ + status: 'error', + code: 'unsupported_occurrence', + planning_status: 'unsupported', + application_status: 'not_started', + applied: false, + total_edits: 0, + }); + expect(result.error).toContain('file move'); + expect(await fs.readFile(path.join(root, 'Writer.java'), 'utf8')).toBe(content); + expect(await fs.readdir(root)).not.toContain('Reader.java'); + } + }, + 60_000, + ); + + it.each([ + ['Class', 'class Writer { Writer() {} }\n'], + ['Constructor', 'class Writer { Writer() {} }\n'], + ['Class', 'public class Outer { public static class Writer { public Writer() {} } }\n'], + ['Constructor', 'public class Outer { public static class Writer { public Writer() {} } }\n'], + ['Annotation', '@interface Writer {}\n'], + ['Annotation', 'public class Outer { public @interface Writer {} }\n'], + ])( + 'renames a package-private or nested Java %s without requiring a file move (case %#)', + async (kind, content) => { + await expectRename( + { 'Outer.java': content }, + { 'Outer.java': content.replaceAll('Writer', 'Reader') }, + 'Writer', + kind, + 'Reader', + ); + }, + 60_000, + ); + + it('allows a Java public type rename that matches the existing file name', async () => { + await expectRename( + { 'Reader.java': 'public class Writer { public Writer() {} }\n' }, + { 'Reader.java': 'public class Reader { public Reader() {} }\n' }, + 'Writer', + 'Class', + 'Reader', + ); + }, 60_000); + + it.each([ + [ + 'Constructor', + 'class Outer { static class Writer { Writer() {} } }\nclass Other { static class Writer { Writer() {} } }\n', + ], + ['Annotation', 'class Outer { @interface Writer {} }\nclass Other { @interface Writer {} }\n'], + ])( + 'refuses collapsed same-name nested Java %s graph identities', + async (kind, content) => { + const { root, nodes, symbol } = await fixture({ 'Outer.java': content }, 'Writer', kind); + for (const dry_run of [true, false]) { + const result = await renameSymbol(root, symbol, { new_name: 'Reader', dry_run }, nodes); + expect(result, JSON.stringify(result)).toMatchObject({ + status: 'error', + code: 'stale_graph', + planning_status: 'blocked', + application_status: 'not_started', + applied: false, + total_edits: 0, + }); + expect(await fs.readFile(path.join(root, 'Outer.java'), 'utf8')).toBe(content); + } + }, + 60_000, + ); + + it('renames a C# class and its destructor without changing another class', async () => { + const content = + 'class Writer { public Writer() {} ~Writer() {} }\nclass Other { ~Other() {} Writer Create() { return new Writer(); } }\n'; + await expectRename( + { 'model.cs': content }, + { 'model.cs': content.replaceAll('Writer', 'Renamed') }, + 'Writer', + 'Class', + 'Renamed', + ); + }, 60_000); + it.each([ + [ + 'model.java', + 'class Model { static int target() { return 1; } static int caller() { return target(); } }\n// target\n', + 'Method', + ], + [ + 'model.kt', + 'fun target() {}\nfun caller() { val value = ::target; target() }\n// target\n', + 'Function', + ], + [ + 'model.cs', + 'class Model { static void target() {} static void caller() { System.Action value = target; target(); } }\n// target\n', + 'Method', + ], + [ + 'model.swift', + 'func target() {}\nfunc caller() { let value = target; target() }\n// target\n', + 'Function', + ], + [ + 'model.dart', + 'void target() {}\nvoid caller() { var value = target; target(); }\n// target\n', + 'Function', + ], + ])( + 'previews and applies calls and bare values in %s', + async (file, content, kind) => { + await expectRename( + { [file]: content }, + { [file]: content.replaceAll('target', 'renamed').replace('// renamed', '// target') }, + 'target', + kind, + ); + }, + 60000, + ); + + it.each([ + [ + 'model.java', + 'class Model { static int target() { return 1; } int caller(int target) { return target; } int use() { return target(); } }\n', + 'Method', + ], + [ + 'model.kt', + 'fun target() {}\nfun caller(target: Int): Int { return target }\nfun use() { target() }\n', + 'Function', + ], + [ + 'model.cs', + 'class Model { static int target() { return 1; } int caller(int target) { return target; } int use() { return target(); } }\n', + 'Method', + ], + [ + 'model.swift', + 'func target() {}\nfunc caller(target: Int) -> Int { return target }\nfunc use() { target() }\n', + 'Function', + ], + [ + 'model.dart', + 'int target() { return 1; }\nint caller(int target) { return target; }\nint use() { return target(); }\n', + 'Function', + ], + ])( + 'preserves shadow parameters in %s', + async (file, content, kind) => { + await expectRename( + { [file]: content }, + { [file]: content.replaceAll('target()', 'renamed()') }, + 'target', + kind, + ); + }, + 60000, + ); + + it.each([ + [ + 'model.java', + 'class Target { Target() {} }\nclass User { Target value; Target make() { return new Target(); } }\n', + ], + ['model.kt', 'class Target\nfun use(value: Target): Target { return Target() }\n'], + [ + 'model.cs', + 'class Target { Target() {} }\nclass User { Target value; Target make() { return new Target(); } }\n', + ], + ['model.swift', 'class Target {}\nfunc use(value: Target) -> Target { return Target() }\n'], + ['model.dart', 'class Target { Target(); }\nTarget use(Target value) { return Target(); }\n'], + ])( + 'renames type uses and named constructors in %s', + async (file, content) => { + await expectRename( + { [file]: content }, + { [file]: content.replaceAll('Target', 'Renamed') }, + 'Target', + 'Class', + 'Renamed', + ); + }, + 60000, + ); + + it.each([ + [ + 'model.java', + 'class Model { void target() {} void caller(Model value) { value.target(); } }\nclass Other { void target() {} void caller(Other value) { value.target(); } }\n', + 'Method', + ], + [ + 'model.kt', + 'class Model {\nfun target() {}\nfun caller(value: Model) { value.target() }\n}\nclass Other {\nfun target() {}\n}\n', + 'Method', + ], + [ + 'model.cs', + 'class Model { void target() {} void caller(Model value) { value.target(); } }\nclass Other { void target() {} void caller(Other value) { value.target(); } }\n', + 'Method', + ], + [ + 'model.swift', + 'class Model { func target() {}\nfunc caller(value: Model) { value.target() } }\nclass Other { func target() {} }\n', + 'Function', + ], + [ + 'model.dart', + 'class Model { void target() {} void caller(Model value) { value.target(); } }\nclass Other { void target() {} }\n', + 'Method', + ], + ])( + 'preserves unrelated owners in %s', + async (file, content, kind) => { + const boundary = content.indexOf('class Other'); + await expectRename( + { [file]: content }, + { + [file]: + content.slice(0, boundary).replaceAll('target', 'renamed') + content.slice(boundary), + }, + 'target', + kind, + ); + }, + 60000, + ); + + it.each([ + [ + SupportedLanguages.Kotlin, + 'model.kt', + 'fun `target`() {}\nfun caller() { target() }', + '`target`', + ], + [ + SupportedLanguages.CSharp, + 'model.cs', + 'class Model { void @target() {} void caller() { target(); } }', + '@target', + ], + [ + SupportedLanguages.Swift, + 'model.swift', + 'func `target`() {}\nfunc caller() { target() }', + '`target`', + ], + ])( + 'inventories canonical escaped identifiers in %s', + async (language, filePath, content, spelling) => { + const resolver = SCOPE_RESOLVERS.get(language)!; + const source = await resolver.languageProvider.rename!.parseSource({ + filePath, + content, + languageProvider: resolver.languageProvider, + scopeResolver: resolver, + }); + const occurrence = source.occurrences.find((entry) => entry.node?.text === spelling)!; + expect(occurrence.name).toBe('target'); + expect(occurrence.spans).toEqual([ + { start: content.indexOf(spelling), length: spelling.length }, + ]); + }, + ); + + it.each([ + [ + 'model.kt', + 'package demo\nfun target() {}\n', + 'caller.kt', + 'package app\nimport demo.target as local\nfun use() { local() }\n', + 'Function', + ], + [ + 'model.cs', + 'namespace Demo { public class Target {} }\n', + 'caller.cs', + 'using Local = Demo.Target;\nclass User { Local value; }\n', + 'Class', + ], + [ + 'model.dart', + 'void target() {}\n', + 'caller.dart', + "import 'model.dart' as lib;\nvoid use() { lib.target(); }\n", + 'Function', + ], + ])( + 'renames imported identity while preserving aliases in %s', + async (file, content, callerFile, caller, kind) => { + const name = kind === 'Class' ? 'Target' : 'target'; + await expectRename( + { [file]: content, [callerFile]: caller }, + { + [file]: content.replaceAll(name, 'renamed'), + [callerFile]: caller.replaceAll(name, 'renamed'), + }, + name, + kind, + 'renamed', + file, + ); + }, + 60000, + ); + + it.each([ + [ + 'model.kt', + 'fun `target`() {}\nfun caller() { target() }\n', + '`target`', + 'Function', + 'fun `renamed`() {}\nfun caller() { renamed() }\n', + ], + [ + 'model.cs', + 'class Model { void @target() {} void caller() { target(); } }\n', + '@target', + 'Method', + 'class Model { void @renamed() {} void caller() { renamed(); } }\n', + ], + [ + 'model.swift', + 'func `target`() {}\nfunc caller() { target() }\n', + '`target`', + 'Function', + 'func `renamed`() {}\nfunc caller() { renamed() }\n', + ], + ])( + 'renames escaped declarations and ordinary references in %s', + async (file, content, name, kind, expected) => { + await expectRename({ [file]: content }, { [file]: expected }, name, kind); + }, + 60000, + ); + + it.each([ + [ + 'model.java', + 'interface Base { void target(); }\nclass Child implements Base { public void target() {} }\n', + 'Method', + ], + [ + 'model.kt', + 'interface Base {\nfun target()\n}\nclass Child: Base {\noverride fun target() {}\n}\n', + 'Method', + ], + [ + 'model.cs', + 'interface Base { void target(); }\nclass Child : Base { public void target() {} }\n', + 'Method', + ], + [ + 'model.swift', + 'protocol Base { func target() }\nclass Child: Base { func target() {} }\n', + 'Function', + ], + [ + 'model.dart', + 'abstract class Base { void target(); }\nclass Child implements Base { void target() {} }\n', + 'Method', + ], + ])( + 'renames proven interface declaration families in %s', + async (file, content, kind) => { + await expectRename( + { [file]: content }, + { [file]: content.replaceAll('target', 'renamed') }, + 'target', + kind, + ); + }, + 60000, + ); + + it.each([ + [ + 'model.java', + 'class Model { void target() {} void renamed() {} void use() { target(); } }\n', + 'Method', + ], + ['model.kt', 'fun target() {}\nfun renamed() {}\nfun use() { target() }\n', 'Function'], + [ + 'model.cs', + 'class Model { void target() {} void renamed() {} void use() { target(); } }\n', + 'Method', + ], + ['model.swift', 'func target() {}\nfunc renamed() {}\nfunc use() { target() }\n', 'Function'], + ['model.dart', 'void target() {}\nvoid renamed() {}\nvoid use() { target(); }\n', 'Function'], + ])( + 'refuses scope collisions without writing %s', + async (file, content, kind) => { + const { root, nodes, symbol } = await fixture({ [file]: content }, 'target', kind); + const result = await renameSymbol( + root, + symbol, + { new_name: 'renamed', dry_run: false }, + nodes, + ); + expect(result).toMatchObject({ status: 'error', code: 'name_collision', applied: false }); + expect(await fs.readFile(path.join(root, file), 'utf8')).toBe(content); + }, + 60000, + ); + + it('retains Swift conditional directives and original source offsets', async () => { + const content = + 'class Model {\n#if DEBUG // 日本語 😀\nfunc target() {}\n#endif\nfunc use() { target() }\n}\n'; + await expectRename( + { 'model.swift': content }, + { 'model.swift': content.replaceAll('target', 'renamed') }, + 'target', + 'Function', + ); + }, 60000); + + it('fails closed when the Java grammar cannot inventory Unicode escape syntax', async () => { + const resolver = SCOPE_RESOLVERS.get(SupportedLanguages.Java)!; + await expect( + resolver.languageProvider.rename!.parseSource({ + filePath: 'model.java', + content: 'class Model { void t\\u0061rget() {} }', + languageProvider: resolver.languageProvider, + scopeResolver: resolver, + }), + ).rejects.toMatchObject({ code: 'parse_error' }); + }); + + it.each([ + [ + 'model.java', + 'class Model { void target() {} void target(int value) {} void use() { target(); target(1); } }\n', + 'Method', + ], + [ + 'model.kt', + 'fun target() {}\nfun target(value: Int) {}\nfun use() { target(); target(1) }\n', + 'Function', + ], + [ + 'model.cs', + 'class Model { void target() {} void target(int value) {} void use() { target(); target(1); } }\n', + 'Method', + ], + [ + 'model.swift', + 'func target() {}\nfunc target(value: Int) {}\nfunc use() { target(); target(value: 1) }\n', + 'Function', + ], + ])( + 'renames overload declaration groups and calls in %s', + async (file, content, kind) => { + await expectRename( + { [file]: content }, + { [file]: content.replaceAll('target', 'renamed') }, + 'target', + kind, + ); + }, + 60000, + ); + + it.each([ + [SupportedLanguages.Java, 'class'], + [SupportedLanguages.Kotlin, 'fun'], + [SupportedLanguages.CSharp, 'class'], + [SupportedLanguages.Swift, 'func'], + [SupportedLanguages.Dart, 'class'], + ])('rejects reserved replacement names in %s', (language, name) => { + const provider = SCOPE_RESOLVERS.get(language)!.languageProvider.rename; + expect(provider).toBeDefined(); + expect(provider!.validateName!(name)).toMatchObject({ code: 'invalid_name' }); + }); +}); + +async function expectLabelRefusal( + file: string, + content: string, + name: string, + newName: string, + code = 'ambiguous_reference', +) { + const { root, nodes, symbol } = await fixture({ [file]: content }, name, 'Property'); + for (const dry_run of [true, false]) { + const result = await renameSymbol(root, symbol, { new_name: newName, dry_run }, nodes); + expect(result, `${name} -> ${newName} dry_run=${dry_run}`).toMatchObject({ + status: 'error', + code, + total_edits: 0, + applied: false, + }); + expect(await fs.readFile(path.join(root, file), 'utf8')).toBe(content); + } +} + +describe('named argument labels spelled like a renamed property', () => { + const dataClass = 'data class Pair2(val first: Int, val second: Int)\n'; + it.each([ + ['a primary-constructor property', dataClass + 'fun make() = Pair2(first = 1, second = 2)\n'], + [ + 'a data class copy() parameter', + dataClass + 'fun move(pair: Pair2): Pair2 = pair.copy(first = 3)\n', + ], + [ + 'a supertype initializer of the owner', + 'open class Base(val first: Int)\nclass Child : Base(first = 1)\n', + ], + ['an enum entry argument', 'enum class Level(val first: Int) { LOW(first = 1) }\n'], + [ + 'a typealias of the owner', + dataClass + 'typealias Alias = Pair2\nfun make() = Alias(first = 1)\n', + ], + ['an unresolved callee', dataClass + 'fun make(factory: Any) = factory.build(first = 1)\n'], + ])( + 'refuses a Kotlin label that may bind %s', + async (_description, content) => { + await expectLabelRefusal('model.kt', content, 'first', 'head'); + }, + 60_000, + ); + + it('refuses a Kotlin destination label of a constructor that would gain the property', async () => { + await expectLabelRefusal( + 'model.kt', + 'class Pair2(val first: Int) {\n constructor(head: Int, extra: Int) : this(head)\n}\nfun make() = Pair2(head = 1, extra = 2)\n', + 'first', + 'head', + ); + }, 60_000); + + it('renames a Kotlin property beside labels of other functions and types', async () => { + const calls = + 'class Unrelated(val first: Int)\nfun configure(first: Int) {}\nopen class Base(val first: Int)\n' + + 'class Child(first2: Int) : Base(first2)\n' + + 'fun make() {\n Unrelated(first = 1)\n configure(first = 2)\n Child(first2 = 3)\n}\n'; + await expectRename( + { 'model.kt': dataClass + calls }, + { 'model.kt': dataClass.replace('first', 'head') + calls }, + 'first', + 'Property', + 'head', + ); + }, 60_000); + + const struct = 'struct Point {\n var x: Int\n var y: Int\n}\n'; + it.each([ + ['the memberwise initializer', struct + 'let origin = Point(x: 0, y: 0)\n'], + [ + 'an explicit .init call of the memberwise initializer', + struct + 'let origin = Point.init(x: 0, y: 0)\n', + ], + [ + 'a memberwise initializer beside an extension initializer', + struct + 'extension Point {\n init(other: Int) { self.init(x: other, y: other) }\n}\n', + ], + [ + 'a typealias of the owner', + struct + 'typealias Spot = Point\nlet origin = Spot(x: 0, y: 0)\n', + ], + ])( + 'refuses a Swift label that may bind %s', + async (_description, content) => { + await expectLabelRefusal('model.swift', content, 'x', 'px'); + }, + 60_000, + ); + + it('renames a Swift property whose type declares its own initializer', async () => { + const custom = (name: string) => + `struct Custom {\n var ${name}: Int\n init(x: Int) { self.${name} = x }\n}\n`; + const calls = 'func configure(x: Int) {}\nlet custom = Custom(x: 1)\nconfigure(x: 2)\n'; + await expectRename( + { 'model.swift': custom('x') + struct + calls }, + { 'model.swift': custom('px') + struct + calls }, + 'x', + 'Property', + 'px', + ); + }, 60_000); + + it('renames a Swift class property beside its labeled initializer', async () => { + const box = (name: string) => + `class Box {\n var ${name}: Int\n init(x: Int) { self.${name} = x }\n}\nlet box = Box(x: 1)\n`; + await expectRename( + { 'model.swift': box('x') }, + { 'model.swift': box('px') }, + 'x', + 'Property', + 'px', + ); + }, 60_000); + + it('binds a C# named argument to the constructor parameter, not the property', async () => { + const counter = (name: string) => + `public class Counter {\n public int ${name} { get; set; }\n public Counter(int Count) { this.${name} = Count; }\n}\n` + + 'public class Maker { public Counter Make() { return new Counter(Count: 1); } }\n'; + await expectRename( + { 'model.cs': counter('Count') }, + { 'model.cs': counter('Total') }, + 'Count', + 'Property', + 'Total', + ); + }, 60_000); + + it('refuses a C# positional record whose explicit property shares the parameter name', async () => { + await expectLabelRefusal( + 'model.cs', + 'public record Pair(int First) { public int First { get; init; } = First; }\n' + + 'public class Maker { public Pair Make() { return new Pair(First: 1); } }\n', + 'First', + 'Head', + 'incomplete_semantics', + ); + }, 60_000); + + it('refuses a Dart field whose initializing formal parameter a named argument binds', async () => { + await expectLabelRefusal( + 'model.dart', + 'class Point {\n int x;\n Point({required this.x});\n}\nvoid make() { Point(x: 1); }\n', + 'x', + 'px', + 'name_collision', + ); + }, 60_000); + + it('renames a Dart field beside a named argument of another function', async () => { + const point = (name: string) => `class Point {\n int ${name} = 0;\n}\n`; + const use = (name: string) => + `void configure({int x = 0}) {}\nvoid make(Point p) { configure(x: 1); p.${name} = 2; }\n`; + await expectRename( + { 'model.dart': point('x') + use('x') }, + { 'model.dart': point('px') + use('px') }, + 'x', + 'Property', + 'px', + ); + }, 60_000); +}); diff --git a/gitnexus/test/unit/rename-native-providers.test.ts b/gitnexus/test/unit/rename-native-providers.test.ts new file mode 100644 index 000000000..1e898c3cd --- /dev/null +++ b/gitnexus/test/unit/rename-native-providers.test.ts @@ -0,0 +1,1113 @@ +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { SupportedLanguages } from 'gitnexus-shared'; +import { createRenameFixtureHelpers } from '../helpers/rename-provider-fixture.js'; +import { SCOPE_RESOLVERS } from '../../src/core/ingestion/scope-resolution/pipeline/registry.js'; +import { renameSymbol } from '../../src/mcp/local/rename/rename-plan.js'; + +const { fixture, expectRename } = createRenameFixtureHelpers(); + +describe('native provider rename through the production parsing pipeline', () => { + it('refuses a reserved C++ identifier in the global namespace before preview or apply', async () => { + const content = 'int target() { return 1; }\nint caller() { return target(); }\n'; + const { root, nodes, symbol } = await fixture({ 'model.cpp': content }); + for (const dry_run of [true, false]) { + const result = await renameSymbol(root, symbol, { new_name: '_value', dry_run }, nodes); + expect(result).toMatchObject({ + status: 'error', + code: 'invalid_name', + applied: false, + total_edits: 0, + }); + expect(await fs.readFile(path.join(root, 'model.cpp'), 'utf8')).toBe(content); + } + }, 60000); + + it('allows a leading underscore for a C++ class member', async () => { + await expectRename( + { + 'model.cpp': + 'struct Model {\nint target() { return 1; }\n};\nint caller(Model &model) { return model.target(); }\n', + }, + { + 'model.cpp': + 'struct Model {\nint _value() { return 1; }\n};\nint caller(Model &model) { return model._value(); }\n', + }, + 'target', + 'Method', + '_value', + ); + }, 60000); + + it('refuses a leading underscore for a C++ type declared in the global namespace', async () => { + const content = 'struct Target {};\nvoid caller(Target value) {}\n'; + const { root, nodes, symbol } = await fixture({ 'model.cpp': content }, 'Target', 'Struct'); + for (const dry_run of [true, false]) { + const result = await renameSymbol(root, symbol, { new_name: '_value', dry_run }, nodes); + expect(result).toMatchObject({ + status: 'error', + code: 'invalid_name', + applied: false, + total_edits: 0, + }); + expect(await fs.readFile(path.join(root, 'model.cpp'), 'utf8')).toBe(content); + } + }, 60000); + + it('refuses a reserved C++ type name when its constructor is selected', async () => { + const content = 'struct Target {\n Target() {}\n};\nTarget caller() { return Target(); }\n'; + const { root, nodes, symbol } = await fixture({ 'model.cpp': content }, 'Target', 'Method'); + for (const dry_run of [true, false]) { + const result = await renameSymbol(root, symbol, { new_name: '_value', dry_run }, nodes); + expect(result).toMatchObject({ + status: 'error', + code: 'invalid_name', + applied: false, + total_edits: 0, + }); + expect(await fs.readFile(path.join(root, 'model.cpp'), 'utf8')).toBe(content); + } + }, 60000); + + it.each(['await', 'yield'])( + 'refuses Dart contextual identifier %s inside an async body', + async (new_name) => { + const content = 'int target() => 1;\nFuture caller() async { return target(); }\n'; + const { root, nodes, symbol } = await fixture({ 'model.dart': content }); + for (const dry_run of [true, false]) { + const result = await renameSymbol(root, symbol, { new_name, dry_run }, nodes); + expect(result).toMatchObject({ + status: 'error', + code: 'invalid_name', + applied: false, + total_edits: 0, + }); + expect(await fs.readFile(path.join(root, 'model.dart'), 'utf8')).toBe(content); + } + }, + 60000, + ); + + it.each(['yield'])( + 'allows Dart contextual identifier %s in a synchronous body', + async (newName) => { + await expectRename( + { 'model.dart': 'int target() => 1;\nint caller() { return target(); }\n' }, + { 'model.dart': `int ${newName}() => 1;\nint caller() { return ${newName}(); }\n` }, + 'target', + 'Function', + newName, + ); + }, + 60000, + ); + + it.each(['local', 'target'])( + 'records Rust alias identity even with equal spelling: %s', + async (alias) => { + const resolver = SCOPE_RESOLVERS.get(SupportedLanguages.Rust)!; + const source = await resolver.languageProvider.rename!.parseSource({ + filePath: 'model.rs', + content: `use source::target as ${alias};`, + languageProvider: resolver.languageProvider, + scopeResolver: resolver, + }); + expect(source.parsed.parsedImports).toMatchObject([ + { kind: 'alias', alias, localName: alias, importedName: 'target' }, + ]); + }, + ); + + it.each([ + [ + 'model.c', + 'int target(void) { return 1; }\nint caller(void) { int (*value)(void) = target; return target(); }\n// target\n', + ], + [ + 'model.cpp', + 'int target() { return 1; }\nint caller() { auto value = target; return target(); }\n// target\n', + ], + ['model.rs', 'fn target() {}\nfn caller() { let value = target; target(); }\n// target\n'], + [ + 'model.go', + 'package model\nfunc target() {}\nfunc caller() { value := target; value(); target() }\n// target\n', + ], + [ + 'model.zig', + 'fn target() void {}\nfn caller() void { const value = target; value(); target(); }\n// target\n', + ], + ])( + 'previews and applies function calls and bare values in %s', + async (file, content) => { + await expectRename( + { [file]: content }, + { [file]: content.replaceAll('target', 'renamed').replace('// renamed', '// target') }, + ); + }, + 60000, + ); + + it.each([ + [SupportedLanguages.C, 'int'], + [SupportedLanguages.C, 'restrict'], + [SupportedLanguages.CPlusPlus, 'class'], + [SupportedLanguages.CPlusPlus, 'co_await'], + [SupportedLanguages.CPlusPlus, '_Atomic'], + [SupportedLanguages.CPlusPlus, 'internal__name'], + [SupportedLanguages.Rust, 'fn'], + [SupportedLanguages.Go, 'func'], + [SupportedLanguages.Zig, 'comptime'], + ])('rejects reserved replacement names in %s: %s', (language, name) => { + const provider = SCOPE_RESOLVERS.get(language)!.languageProvider.rename; + expect(provider).toBeDefined(); + expect(provider!.validateName!(name)).toMatchObject({ code: 'invalid_name' }); + }); + + it('accepts restrict as a C++ type identifier', async () => { + await expectRename( + { 'model.cpp': 'struct Target {};\nvoid caller(struct Target value) {}\n' }, + { 'model.cpp': 'struct restrict {};\nvoid caller(struct restrict value) {}\n' }, + 'Target', + 'Struct', + 'restrict', + ); + }, 60000); + + it('preserves Go type-parameter bindings when renaming an outer type', async () => { + await expectRename( + { + 'model.go': + 'package model\ntype Target struct {}\ntype Container[Target any] struct { Item Target }\nfunc identity[Target any](value Target) Target { return value }\nfunc concrete(value Target) Target { return value }\n', + }, + { + 'model.go': + 'package model\ntype Renamed struct {}\ntype Container[Target any] struct { Item Target }\nfunc identity[Target any](value Target) Target { return value }\nfunc concrete(value Renamed) Renamed { return value }\n', + }, + 'Target', + 'Struct', + 'Renamed', + ); + }, 60000); + + it('blocks a Go type rename captured by a generic type parameter', async () => { + const content = + 'package model\ntype Target struct {}\nfunc identity[Element any](value Target, item Element) Target { return value }\n'; + const { root, nodes, symbol } = await fixture({ 'model.go': content }, 'Target', 'Struct'); + for (const dry_run of [true, false]) { + expect( + await renameSymbol(root, symbol, { new_name: 'Element', dry_run }, nodes), + ).toMatchObject({ + status: 'error', + code: 'name_collision', + planning_status: 'blocked', + applied: false, + total_edits: 0, + }); + expect(await fs.readFile(path.join(root, 'model.go'), 'utf8')).toBe(content); + } + }, 60000); + + it.each([ + [ + 'model.c', + 'struct Target { int value; };\nint call(struct Target* p) { return p->value; }\n', + 'Struct', + ], + ['model.cpp', 'class Target {};\nvoid call(Target& p) {}\n', 'Class'], + ['model.rs', 'struct Target {}\nfn call(p: Target) {}\n', 'Struct'], + ['model.go', 'package p\ntype Target struct {}\nfunc call(p Target) {}\n', 'Struct'], + ['model.zig', 'const Target = struct { value: u8 };\nfn call(p: Target) void {}\n', 'Struct'], + ])( + 'renames type annotations in %s', + async (file, content, kind) => { + await expectRename( + { [file]: content }, + { [file]: content.replaceAll('Target', 'Renamed') }, + 'Target', + kind, + 'Renamed', + ); + }, + 60000, + ); + + it.each([ + [ + 'model.c', + 'struct Target { int target; };\nint call(struct Target* p) { return p->target; }\n', + 'Property', + ], + [ + 'model.cpp', + 'class Target { public: int target() { return 1; } };\nint call(Target& p) { return p.target(); }\n', + 'Method', + ], + [ + 'model.rs', + 'struct Target {}\nimpl Target { fn target(&self) {} }\nfn call(p: Target) { p.target(); }\n', + 'Function', + ], + [ + 'model.go', + 'package p\ntype Target struct {}\nfunc (p Target) target() {}\nfunc call(p Target) { p.target() }\n', + 'Method', + ], + [ + 'model.zig', + 'const Target = struct { pub fn target(self: Target) void {} };\nfn call(p: Target) void { p.target(); }\n', + 'Method', + ], + ])( + 'renames members on proven receivers in %s', + async (file, content, kind) => { + await expectRename( + { [file]: content }, + { [file]: content.replaceAll('target', 'renamed') }, + 'target', + kind, + ); + }, + 60000, + ); + + it.each(['local', 'target'])( + 'keeps Rust imported aliases stable: %s', + async (alias) => { + const files = { + 'lib.rs': `mod source;\nuse source::target as ${alias};\nfn call() { ${alias}(); }\n`, + 'source.rs': 'pub fn target() {}\n', + }; + await expectRename( + files, + { + ...files, + 'source.rs': 'pub fn renamed() {}\n', + 'lib.rs': files['lib.rs'].replace('source::target', 'source::renamed'), + }, + 'target', + 'Function', + 'renamed', + 'source.rs', + ); + }, + 60000, + ); + + it('keeps Zig imported aliases stable', async () => { + const files = { + 'model.zig': 'const local = @import("source.zig").target;\nfn call() void { local(); }\n', + 'source.zig': 'pub fn target() void {}\n', + }; + await expectRename( + files, + { + ...files, + 'source.zig': 'pub fn renamed() void {}\n', + 'model.zig': files['model.zig'].replace('.target', '.renamed'), + }, + 'target', + 'Function', + 'renamed', + 'source.zig', + ); + }, 60000); + + it.each([ + ['model.rs', 'fn r#target() {}\nfn call() { r#target(); target(); }\n'], + ['model.zig', 'fn @"target"() void {}\nfn call() void { @"target"(); target(); }\n'], + ])( + 'treats escaped and plain spellings as one identity in %s', + async (file, content) => { + await expectRename( + { [file]: content }, + { + [file]: content + .replaceAll('r#target', 'renamed') + .replaceAll('@"target"', 'renamed') + .replaceAll('target', 'renamed'), + }, + file.endsWith('.rs') ? 'r#target' : '@"target"', + ); + }, + 60000, + ); + + it.each([ + [ + 'model.go', + 'package p\ntype Work interface { target() }\ntype Worker struct {}\nfunc (p Worker) target() {}\nfunc call(p Work, q Worker) { p.target(); q.target() }\n', + 'Method', + ], + [ + 'model.rs', + 'trait Work { fn target(&self); }\nstruct Worker {}\nimpl Work for Worker { fn target(&self) {} }\nfn call(p: &dyn Work, q: Worker) { p.target(); q.target(); }\n', + 'Function', + ], + ])( + 'renames proven interface method families in %s', + async (file, content, kind) => { + await expectRename( + { [file]: content }, + { [file]: content.replaceAll('target', 'renamed') }, + 'target', + kind, + ); + }, + 60000, + ); + + it('renames C prototypes and their definition across a header', async () => { + const files = { + 'model.h': 'int target(void);\n', + 'model.c': + '#include "model.h"\nint target(void) { return 1; }\nint call(void) { return target(); }\n', + }; + await expectRename( + files, + Object.fromEntries( + Object.entries(files).map(([file, content]) => [ + file, + content.replaceAll('target', 'renamed'), + ]), + ), + 'target', + 'Function', + 'renamed', + 'model.c', + ); + }, 60000); + + it('renames C++ classes together with constructors and destructors', async () => { + const content = + 'class Target {\npublic:\n Target() {}\n ~Target() {}\n};\nvoid call() { Target value; }\n'; + await expectRename( + { 'model.cpp': content }, + { 'model.cpp': content.replaceAll('Target', 'Renamed') }, + 'Target', + 'Class', + 'Renamed', + ); + }, 60000); + + it('renames C++ class families with out-of-line constructors and destructors', async () => { + const files = { + 'model.h': 'class Target { public: Target(); ~Target(); };\n', + 'model.cpp': '#include "model.h"\nTarget::Target() {}\nTarget::~Target() {}\n', + }; + await expectRename( + files, + Object.fromEntries( + Object.entries(files).map(([file, content]) => [ + file, + content.replaceAll('Target', 'Renamed'), + ]), + ), + 'Target', + 'Class', + 'Renamed', + 'model.h', + ); + }, 60000); + + it('renames a selected C++ overload without changing other signatures', async () => { + const content = + 'int target(int value) { return value; }\ndouble target(double value) { return value; }\nint call() { return target(1); }\ndouble other() { return target(1.5); }\n'; + await expectRename( + { 'model.cpp': content }, + { + 'model.cpp': content + .replace('int target(', 'int renamed(') + .replace('target(1)', 'renamed(1)'), + }, + ); + }, 60000); + + it('keeps same-named methods on unrelated C++ owners unchanged', async () => { + const content = + 'struct A { int target() { return 1; } };\nstruct B { int target() { return 2; } };\nint call(A& a, B& b) { return a.target() + b.target(); }\n'; + await expectRename( + { 'model.cpp': content }, + { + 'model.cpp': content + .replace('int target()', 'int renamed()') + .replace('a.target()', 'a.renamed()'), + }, + 'target', + 'Method', + ); + }, 60000); + + it('renames C++ header declarations and their out-of-line definitions', async () => { + const files = { + 'model.h': 'struct Target { int target(); };\n', + 'model.cpp': + '#include "model.h"\nint Target::target() { return 1; }\nint call(Target& p) { return p.target(); }\n', + }; + await expectRename( + files, + Object.fromEntries( + Object.entries(files).map(([file, content]) => [ + file, + content.replaceAll('target', 'renamed'), + ]), + ), + 'target', + 'Method', + 'renamed', + 'model.h', + ); + }, 60000); + + it.each([ + [ + 'model.c', + 'int target(void) { return 1; }\nint other(int target) { return target; }\nint call(void) { return target(); }\n', + ], + [ + 'model.cpp', + 'int target() { return 1; }\nint other(int target) { return target; }\nint call() { return target(); }\n', + ], + [ + 'model.rs', + 'fn target() {}\nfn other(target: i32) { let value = target; }\nfn call() { target(); }\n', + ], + [ + 'model.go', + 'package p\nfunc target() {}\nfunc other(target int) int { return target }\nfunc call() { target() }\n', + ], + [ + 'model.zig', + 'fn target() void {}\nfn other(target: u8) u8 { return target; }\nfn call() void { target(); }\n', + ], + ])( + 'preserves shadowing parameters in %s', + async (file, content) => { + await expectRename( + { [file]: content }, + { + [file]: content + .replace('target(', 'renamed(') + .replace('return target();', 'return renamed();') + .replace('{ target(); }', '{ renamed(); }') + .replace('{ target() }', '{ renamed() }'), + }, + ); + }, + 60000, + ); + + it.each([ + [ + 'model.c', + 'int target(void) { return 1; }\nint call(void) { int renamed = 0; return target(); }\n', + ], + ['model.cpp', 'int target() { return 1; }\nint call() { int renamed = 0; return target(); }\n'], + ['model.rs', 'fn target() {}\nfn call() { let renamed = 1; target(); }\n'], + ['model.go', 'package p\nfunc target() {}\nfunc call() { renamed := 1; target() }\n'], + ['model.zig', 'fn target() void {}\nfn call() void { const renamed = 1; target(); }\n'], + ])( + 'blocks a capture at an edited use in %s', + async (file, content) => { + const { root, nodes, symbol } = await fixture({ [file]: content }); + for (const dry_run of [true, false]) { + const result = await renameSymbol(root, symbol, { new_name: 'renamed', dry_run }, nodes); + expect(result, JSON.stringify(result)).toMatchObject({ + status: 'error', + code: 'name_collision', + planning_status: 'blocked', + application_status: 'not_started', + applied: false, + total_edits: 0, + }); + expect(await fs.readFile(path.join(root, file), 'utf8')).toBe(content); + } + }, + 60000, + ); + + it('allows a C call before a later local declaration of the proposed name', async () => { + await expectRename( + { + 'model.c': + 'int target(void) { return 1; }\nint caller(void) { int result = target(); int renamed = 0; return result + renamed; }\n', + }, + { + 'model.c': + 'int renamed(void) { return 1; }\nint caller(void) { int result = renamed(); int renamed = 0; return result + renamed; }\n', + }, + ); + }, 60000); + + it('rejects a C capture inside the local declaration initializer', async () => { + const content = + 'int target(void) { return 1; }\nint caller(void) { int renamed = target(); return renamed; }\n'; + const { root, nodes, symbol } = await fixture({ 'model.c': content }); + for (const dry_run of [true, false]) { + expect( + await renameSymbol(root, symbol, { new_name: 'renamed', dry_run }, nodes), + ).toMatchObject({ + status: 'error', + code: 'name_collision', + applied: false, + total_edits: 0, + }); + expect(await fs.readFile(path.join(root, 'model.c'), 'utf8')).toBe(content); + } + }, 60000); + + it.each([ + [ + 'model.c', + '#define alias target\nint target(void) { return 1; }\nint call(void) { return alias(); }\n', + 'Function', + 'unsupported_occurrence', + 'unsupported', + ], + [ + 'model.cpp', + '#define alias target\nint target() { return 1; }\nint call() { return alias(); }\n', + 'Function', + 'unsupported_occurrence', + 'unsupported', + ], + [ + 'model.rs', + 'fn target() {}\nfn call() { arbitrary_macro!(target); }\n', + 'Function', + 'incomplete_semantics', + 'blocked', + ], + [ + 'model.zig', + 'const Target = struct { pub fn target(self: Target) void {} };\nfn call(p: Target) void { @field(p, "target")(); }\n', + 'Method', + 'unsupported_occurrence', + 'unsupported', + ], + ])( + 'blocks names hidden in unresolved expansion or reflection in %s', + async (file, content, kind, code, planning_status) => { + const { root, nodes, symbol } = await fixture({ [file]: content }, 'target', kind); + for (const dry_run of [true, false]) { + const result = await renameSymbol(root, symbol, { new_name: 'renamed', dry_run }, nodes); + expect(result, JSON.stringify(result)).toMatchObject({ + status: 'error', + code, + planning_status, + application_status: 'not_started', + applied: false, + total_edits: 0, + }); + expect(await fs.readFile(path.join(root, file), 'utf8')).toBe(content); + } + }, + 60000, + ); +}); + +async function expectBoundaryRefusal( + files: Record, + name: string, + kind: string, + newName: string, + targetFile: string, +) { + const { root, nodes, symbol } = await fixture(files, name, kind, targetFile); + for (const dry_run of [true, false]) { + const result = await renameSymbol(root, symbol, { new_name: newName, dry_run }, nodes); + expect(result, `${name} -> ${newName} dry_run=${dry_run}`).toMatchObject({ + status: 'error', + code: 'unsupported_boundary', + planning_status: 'unsupported', + applied: false, + total_edits: 0, + }); + for (const [file, content] of Object.entries(files)) + expect(await fs.readFile(path.join(root, file), 'utf8')).toBe(content); + } +} + +describe('names shared with indexed sources of an interoperating language', () => { + const header = { 'api.h': 'int target(int value);\n' }; + const definition = '#include "api.h"\nint target(int value) { return value; }\n'; + it.each([ + [ + 'Rust declares it in an extern block', + 'ffi.rs', + 'extern "C" {\n fn target(value: i32) -> i32;\n}\npub fn call() -> i32 { unsafe { target(1) } }\n', + ], + [ + 'Go calls it through cgo', + 'main.go', + 'package main\n\n/*\n#include "api.h"\n*/\nimport "C"\n\nfunc call() int { return int(C.target(1)) }\n', + ], + [ + 'Zig calls it through @cImport', + 'main.zig', + 'const c = @cImport({\n @cInclude("api.h");\n});\npub fn call() c_int { return c.target(1); }\n', + ], + [ + 'Swift calls it through a bridging header', + 'App.swift', + 'func call() -> Int32 { return target(1) }\n', + ], + ])( + 'refuses a C function that %s', + async (_description, consumer, content) => { + await expectBoundaryRefusal( + { ...header, 'api.c': definition, [consumer]: content }, + 'target', + 'Function', + 'renamed', + 'api.c', + ); + }, + 60_000, + ); + + it.each([ + ['a struct tag as C.struct_tag', 'struct', 'Struct', 'point', 'C.struct_point{}.x'], + ['an enum tag as C.enum_tag', 'enum', 'Enum', 'color', 'C.enum_color(0)'], + ['a union tag as C.union_tag', 'union', 'Union', 'value', 'C.union_value{}'], + [ + 'the size of a struct as C.sizeof_struct_tag', + 'struct', + 'Struct', + 'point', + 'C.sizeof_struct_point', + ], + ])( + 'refuses a C tag that Go cgo names, spelling %s', + async (_description, keyword, kind, tag, expression) => { + await expectBoundaryRefusal( + { + 'api.c': `${keyword} ${tag} { int x; };\n`, + 'main.go': `package main\n\nimport "C"\n\nfunc use() int { return int(${expression}) }\n`, + }, + tag, + kind, + 'renamed', + 'api.c', + ); + }, + 60_000, + ); + + it('renames a C struct tag beside Go cgo names that only share a prefix or sit in comments and strings', async () => { + const consumer = { + 'main.go': + 'package main\n\nimport "C"\n\n// point renamed\nfunc use() int { _ = "point renamed"; return int(C.struct_other{}.x) }\n', + }; + const model = 'struct point { int x; };\nint norm(struct point *value) { return value->x; }\n'; + await expectRename( + { 'api.c': model, ...consumer }, + { 'api.c': model.replaceAll('point', 'renamed'), ...consumer }, + 'point', + 'Struct', + 'renamed', + 'api.c', + ); + }, 60_000); + + it('refuses a C++ extern "C" function that an indexed Go cgo caller names', async () => { + await expectBoundaryRefusal( + { + 'api.cpp': 'extern "C" int target(int value) { return value; }\n', + 'main.go': 'package main\n\nimport "C"\n\nfunc call() int { return int(C.target(1)) }\n', + }, + 'target', + 'Function', + 'renamed', + 'api.cpp', + ); + }, 60_000); + + it('renames a C++ function beside a Go source that only mentions it in comments and strings', async () => { + const consumer = { + 'main.go': + 'package main\n\n// target renamed\nfunc note() string { return "target renamed" }\n', + }; + const model = 'int target() { return 1; }\nint consume(void) { return target(); }\n'; + await expectRename( + { 'api.cpp': model, ...consumer }, + { 'api.cpp': model.replaceAll('target', 'renamed'), ...consumer }, + 'target', + 'Function', + 'renamed', + 'api.cpp', + ); + }, 60_000); + + it('renames a C function beside consumer sources that only mention it in comments and strings', async () => { + const consumers = { + 'other.rs': '// target renamed\npub fn note() -> &\'static str { "target renamed" }\n', + 'other.go': + 'package main\n\n// target renamed\nfunc note() string { return "target renamed" }\n', + 'other.zig': '// target renamed\npub fn note() []const u8 { return "target renamed"; }\n', + 'Other.swift': '// target renamed\nfunc note() -> String { return "target renamed" }\n', + }; + await expectRename( + { ...header, 'api.c': definition, ...consumers }, + { + 'api.h': 'int renamed(int value);\n', + 'api.c': definition.replaceAll('target', 'renamed'), + ...consumers, + }, + 'target', + 'Function', + 'renamed', + 'api.c', + ); + }, 60_000); + + it('renames a C function beside an Objective-C selector that only shares its first piece', async () => { + // A selector base name bridges to Swift only; C and C++ never spell a function that way. + const selectors = + '@interface Other\n- (void)target:(int)value with:(int)other;\n@end\n' + + 'void use(Other *other) { [other target:1 with:2]; }\n'; + await expectRename( + { ...header, 'api.c': definition, 'Other.m': selectors }, + { + 'api.h': 'int renamed(int value);\n', + 'api.c': definition.replaceAll('target', 'renamed'), + 'Other.m': selectors, + }, + 'target', + 'Function', + 'renamed', + 'api.c', + ); + }, 60_000); + + it('refuses a C function when a Swift source spells the name as an unrelated local', async () => { + // The inventory compares names, as it does for C++ and Objective-C sources. + await expectBoundaryRefusal( + { + ...header, + 'api.c': definition, + 'App.swift': 'func call() -> Int { let target = 1; return target }\n', + }, + 'target', + 'Function', + 'renamed', + 'api.c', + ); + }, 60_000); + + const lib = 'int use(void) { return target(1); }\n'; + it.each([ + ['Rust', 'lib.rs', '#[no_mangle]\npub extern "C" fn target(value: i32) -> i32 { value }\n'], + ['Zig', 'lib.zig', 'export fn target(value: c_int) c_int { return value; }\n'], + [ + 'Go', + 'lib.go', + 'package main\n\nimport "C"\n\n//export target\nfunc target(value C.int) C.int { return value }\n\nfunc main() {}\n', + ], + ])( + 'refuses a %s function exported to an indexed C caller', + async (_language, file, content) => { + await expectBoundaryRefusal( + { [file]: content, 'caller.c': `int target(int value);\n${lib}` }, + 'target', + 'Function', + 'renamed', + file, + ); + }, + 60_000, + ); + + it('refuses an Objective-C selector that an indexed Swift caller names', async () => { + await expectBoundaryRefusal( + { + 'Foo.h': '@interface Foo\n- (int)target;\n@end\n', + 'Foo.m': '#import "Foo.h"\n@implementation Foo\n- (int)target { return 1; }\n@end\n', + 'App.swift': 'func run(foo: Foo) -> Int32 { return foo.target() }\n', + }, + 'target', + 'Method', + 'renamed', + 'Foo.h', + ); + }, 60_000); + + it.each([ + [ + 'its first piece as the base name', + 'fetch:with:', + 'load:using:', + '- (int)fetch:(int)key with:(int)value;', + 'foo.fetch(1, with: 2)', + ], + [ + 'the base name before a With clause', + 'fetchWithKey:', + 'loadUsingKey:', + '- (int)fetchWithKey:(int)key;', + 'foo.fetch(key: 1)', + ], + ])( + 'refuses a multipart Objective-C selector that an indexed Swift caller names by %s', + async (_description, selector, newName, declaration, call) => { + await expectBoundaryRefusal( + { + 'Foo.h': `@interface Foo\n${declaration}\n@end\n`, + 'App.swift': `func run(foo: Foo) -> Int32 { return ${call} }\n`, + }, + selector, + 'Method', + newName, + 'Foo.h', + ); + }, + 60_000, + ); + + it('renames an Objective-C selector beside Swift sources that only mention it in comments and strings', async () => { + const swift = '// target renamed\nlet note = "target renamed"\n'; + const declaration = (name: string) => `@interface Foo\n- (int)${name};\n@end\n`; + const implementation = (name: string) => + `#import "Foo.h"\n@implementation Foo\n- (int)${name} { return 1; }\n@end\n`; + await expectRename( + { 'Foo.h': declaration('target'), 'Foo.m': implementation('target'), 'Other.swift': swift }, + { + 'Foo.h': declaration('renamed'), + 'Foo.m': implementation('renamed'), + 'Other.swift': swift, + }, + 'target', + 'Method', + 'renamed', + 'Foo.h', + ); + }, 60_000); + + it('refuses a Swift method that an indexed Objective-C caller names', async () => { + await expectBoundaryRefusal( + { + 'Greeter.swift': '@objc class Greeter {\n @objc func target() {}\n}\n', + 'caller.m': '#import "Greeter-Swift.h"\nvoid use(Greeter *greeter) { [greeter target]; }\n', + }, + 'target', + 'Function', + 'renamed', + 'Greeter.swift', + ); + }, 60_000); + + it.each([ + [ + 'its two-piece selector', + '@objc func target(_ name: String, loudly: Bool) {}', + '[greeter target:@"x" loudly:YES]', + ], + ['its one-piece selector', '@objc func target(_ name: String) {}', '[greeter target:@"x"]'], + [ + 'a With clause of the first piece', + '@objc func target(name: String) {}', + '[greeter targetWithName:@"x"]', + ], + [ + 'a With clause before a later piece', + '@objc func target(name: String, loudly: Bool) {}', + '[greeter targetWithName:@"x" loudly:YES]', + ], + ])( + 'refuses a Swift method taking arguments that an indexed Objective-C caller names by %s', + async (_description, declaration, call) => { + await expectBoundaryRefusal( + { + 'Greeter.swift': `@objc class Greeter {\n ${declaration}\n}\n`, + 'caller.m': `#import "Greeter-Swift.h"\nvoid use(Greeter *greeter) { ${call}; }\n`, + }, + 'target', + 'Function', + 'renamed', + 'Greeter.swift', + ); + }, + 60_000, + ); + + it('refuses a Swift property that an indexed Objective-C caller writes through its setter selector', async () => { + await expectBoundaryRefusal( + { + 'Greeter.swift': '@objc class Greeter {\n @objc var target: Int = 0\n}\n', + 'caller.m': + '#import "Greeter-Swift.h"\nvoid use(Greeter *greeter) { [greeter setTarget:1]; }\n', + }, + 'target', + 'Property', + 'renamed', + 'Greeter.swift', + ); + }, 60_000); + + it('renames a Swift method beside Objective-C selectors that share only a later piece or a prefix', async () => { + const caller = + '@interface Other\n- (void)notify:(int)count target:(int)flag;\n- (void)targetedWith:(int)flag;\n@end\n' + + 'void use(Other *other) { [other notify:1 target:2]; [other targetedWith:3]; }\n'; + const swift = (name: string) => + `class Greeter {\n func ${name}(_ value: Int, loudly: Bool) {}\n}\n` + + `func run(greeter: Greeter) { greeter.${name}(1, loudly: true) }\n`; + await expectRename( + { 'Greeter.swift': swift('target'), 'Other.m': caller }, + { 'Greeter.swift': swift('renamed'), 'Other.m': caller }, + 'target', + 'Function', + 'renamed', + 'Greeter.swift', + ); + }, 60_000); + + it.each([ + ['an NS_SWIFT_NAME argument', '- (int)fetchData NS_SWIFT_NAME(target());'], + ['a swift_name attribute string', '- (int)fetchData __attribute__((swift_name("target()")));'], + ])( + 'refuses a Swift method that an indexed Objective-C header names to Swift through %s', + async (_description, declaration) => { + await expectBoundaryRefusal( + { + 'Foo.h': `@interface Foo\n${declaration}\n@end\n`, + 'Impl.swift': 'class Impl {\n func target() -> Int32 { return 1 }\n}\n', + }, + 'target', + 'Function', + 'renamed', + 'Impl.swift', + ); + }, + 60_000, + ); + + it('refuses a Swift initializer that an indexed Objective-C caller constructs with initWith', async () => { + await expectBoundaryRefusal( + { + 'Greeter.swift': '@objc class Greeter {\n @objc init(name: String) {}\n}\n', + 'caller.m': + '#import "Greeter-Swift.h"\nvoid use(void) { Greeter *greeter = [[Greeter alloc] initWithName:@"x"]; }\n', + }, + 'init', + 'Constructor', + 'make', + 'Greeter.swift', + ); + }, 60_000); + + it.each([ + [ + 'one label', + '- (instancetype)initWithName:(int)name;', + 'initWithName:', + 'initWithTitle:', + 'Foo(name: 1)', + ], + [ + 'several labels', + '- (instancetype)initWithName:(int)name age:(int)age;', + 'initWithName:age:', + 'initWithTitle:age:', + 'Foo(name: 1, age: 2)', + ], + [ + 'an acronym label', + '- (instancetype)initWithURL:(int)url;', + 'initWithURL:', + 'initWithLink:', + 'Foo(url: 1)', + ], + ])( + 'refuses an Objective-C initWith selector that an indexed Swift caller constructs with %s', + async (_description, declaration, selector, newName, call) => { + await expectBoundaryRefusal( + { + 'Foo.h': `@interface Foo\n${declaration}\n@end\n`, + 'App.swift': `func make() -> Foo { return ${call} }\n`, + }, + selector, + 'Method', + newName, + 'Foo.h', + ); + }, + 60_000, + ); + + it.each([ + [ + 'a nullary selector', + '- (int)target NS_REFINED_FOR_SWIFT;', + 'target', + 'renamed', + 'f.__target()', + ], + [ + 'a multipart selector', + '- (int)target:(int)key with:(int)value NS_REFINED_FOR_SWIFT;', + 'target:with:', + 'renamed:using:', + 'f.__target(1, with: 2)', + ], + ])( + 'refuses an Objective-C %s refined for Swift that an indexed Swift caller spells with a double underscore', + async (_description, declaration, selector, newName, call) => { + await expectBoundaryRefusal( + { + 'Foo.h': `@interface Foo\n${declaration}\n@end\n`, + 'App.swift': `func run(f: Foo) -> Int32 { return ${call} }\n`, + }, + selector, + 'Method', + newName, + 'Foo.h', + ); + }, + 60_000, + ); + + it('refuses an Objective-C selector that another declaration spells in an NS_SWIFT_NAME argument', async () => { + await expectBoundaryRefusal( + { + 'Foo.h': '@interface Foo\n- (int)target;\n- (int)other NS_SWIFT_NAME(target());\n@end\n', + 'Foo.m': + '#import "Foo.h"\n@implementation Foo\n- (int)target { return 1; }\n- (int)other { return 2; }\n@end\n', + }, + 'target', + 'Method', + 'renamed', + 'Foo.h', + ); + }, 60_000); + + it('renames an Objective-C selector whose Swift spelling an explicit NS_SWIFT_NAME keeps independent', async () => { + const header = (name: string) => + `@interface Foo\n- (int)${name} NS_SWIFT_NAME(retrieve());\n@end\n`; + const implementation = (name: string) => + `#import "Foo.h"\n@implementation Foo\n- (int)${name} { return 1; }\n@end\n`; + const swift = 'func run(f: Foo) -> Int32 { return f.retrieve() }\n'; + await expectRename( + { 'Foo.h': header('target'), 'Foo.m': implementation('target'), 'App.swift': swift }, + { 'Foo.h': header('renamed'), 'Foo.m': implementation('renamed'), 'App.swift': swift }, + 'target', + 'Method', + 'renamed', + 'Foo.h', + ); + }, 60_000); + + it('renames an Objective-C selector refined for Swift when no indexed Swift source spells it', async () => { + const header = (name: string) => `@interface Foo\n- (int)${name} NS_REFINED_FOR_SWIFT;\n@end\n`; + const implementation = (name: string) => + `#import "Foo.h"\n@implementation Foo\n- (int)${name} { return 1; }\n@end\n`; + const swift = 'func run(f: Foo) -> Int32 { return f.other() }\n'; + await expectRename( + { 'Foo.h': header('target'), 'Foo.m': implementation('target'), 'App.swift': swift }, + { 'Foo.h': header('renamed'), 'Foo.m': implementation('renamed'), 'App.swift': swift }, + 'target', + 'Method', + 'renamed', + 'Foo.h', + ); + }, 60_000); +}); diff --git a/gitnexus/test/unit/rename-objective-c.test.ts b/gitnexus/test/unit/rename-objective-c.test.ts new file mode 100644 index 000000000..3d35bc73a --- /dev/null +++ b/gitnexus/test/unit/rename-objective-c.test.ts @@ -0,0 +1,434 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js'; +import { planGraphRename } from '../../src/mcp/local/rename/graph-rename.js'; +import { renameSymbol } from '../../src/mcp/local/rename/rename-plan.js'; + +const roots: string[] = []; +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true }))); +}); + +async function fixture(files: Record, qualifiedName: string) { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'rename-objc-')); + roots.push(root); + await Promise.all( + Object.entries(files).map(([file, content]) => fs.writeFile(path.join(root, file), content)), + ); + const result = await runPipelineFromRepo(root, () => undefined, { + workerPoolSize: 1, + skipGraphPhases: true, + }); + const nodes = [...result.graph.iterNodes()]; + const target = nodes.find((node) => node.properties.qualifiedName === qualifiedName)!; + expect(target).toBeDefined(); + const symbol = { + uid: target.id, + name: target.properties.name, + filePath: target.properties.filePath, + startLine: target.properties.startLine! + 1, + endLine: target.properties.endLine! + 1, + kind: target.label, + }; + return { root, nodes, symbol }; +} + +describe('Objective-C semantic rename from the production parsing pipeline', () => { + it('renames a class through forward declarations, categories, annotations and class receivers', async () => { + const files = { + 'Worker.h': + '@class Worker;\n@interface Worker\n+ (id)build;\n@end\n@interface Worker (Extra)\n- (void)run;\n@end\n', + 'Worker.m': + '#import "Worker.h"\n// 😀 Worker\n@implementation Worker\n+ (id)build { return nil; }\n- (void)accept:(Worker *)worker { Worker *local = worker; [Worker build]; }\n@end\n@implementation Worker (Extra)\n- (void)run {}\n@end\n', + }; + const { root, nodes, symbol } = await fixture(files, 'objc:class:Worker'); + const result = await renameSymbol(root, symbol, { new_name: 'Agent', dry_run: false }, nodes); + expect(result, JSON.stringify(result)).toMatchObject({ applied: true }); + expect(await fs.readFile(path.join(root, 'Worker.h'), 'utf8')).toBe( + files['Worker.h'].replaceAll('Worker', 'Agent'), + ); + expect(await fs.readFile(path.join(root, 'Worker.m'), 'utf8')).toBe( + files['Worker.m'] + .replaceAll('Worker', 'Agent') + .replace('"Agent.h"', '"Worker.h"') + .replace('// 😀 Agent', '// 😀 Worker'), + ); + }, 60000); + + it('renames a C helper and its uses while preserving a shadowing parameter', async () => { + const content = + 'static int helper(int value) { return value; }\nint caller(void) { return helper(1); }\nint shadow(int helper) { return helper; }\nint indirect(int (*helper)(int)) { return helper(1); }\nint multiple(void) { int first = 1, helper = 2; return helper; }\nint ordered(void) { helper(1); int helper = 2; return helper; }\nint external(int helper);\n'; + const { root, nodes, symbol } = await fixture( + { 'Helpers.m': content }, + 'objc:function:static:Helpers.m:helper', + ); + const result = await renameSymbol( + root, + symbol, + { new_name: 'calculate', dry_run: false }, + nodes, + ); + expect(result, JSON.stringify(result)).toMatchObject({ applied: true, total_edits: 3 }); + expect(await fs.readFile(path.join(root, 'Helpers.m'), 'utf8')).toBe( + content + .replace('int helper(', 'int calculate(') + .replace('return helper(1)', 'return calculate(1)') + .replace('{ helper(1); int helper', '{ calculate(1); int helper'), + ); + }, 60000); + + it('renames a protocol selector together with conforming implementations and inherited overrides', async () => { + const content = + '@protocol Runnable\n- (void)run;\n@end\n@interface Worker \n- (void)run;\n@end\n@interface Child : Worker\n- (void)run;\n@end\n@implementation Worker\n- (void)run {}\n- (void)use:(id)worker { [worker run]; }\n@end\n@implementation Child\n- (void)run { [super run]; [self run]; }\n@end\n@interface Other\n- (void)run;\n@end\n'; + const { root, nodes, symbol } = await fixture( + { 'Worker.m': content }, + 'objc:method:objc:protocol:Runnable:-:run', + ); + const result = await renameSymbol(root, symbol, { new_name: 'execute', dry_run: false }, nodes); + expect(result, JSON.stringify(result)).toMatchObject({ applied: true }); + expect(await fs.readFile(path.join(root, 'Worker.m'), 'utf8')).toBe( + content + .replaceAll('run', 'execute') + .replace('@interface Other\n- (void)execute;', '@interface Other\n- (void)run;'), + ); + }, 60000); + + it('blocks an unresolved dynamic send of the selected selector', async () => { + const content = + '@interface Worker\n- (void)run;\n@end\n@implementation Worker\n- (void)run {}\n- (void)use:(id)worker { [worker run]; }\n@end\n'; + const { root, nodes, symbol } = await fixture( + { 'Worker.m': content }, + 'objc:method:objc:class:Worker:-:run', + ); + const result = await renameSymbol(root, symbol, { new_name: 'execute', dry_run: false }, nodes); + expect(result, JSON.stringify(result)).toMatchObject({ + applied: false, + code: 'ambiguous_reference', + }); + expect(await fs.readFile(path.join(root, 'Worker.m'), 'utf8')).toBe(content); + }, 60000); + + it('renames a property, ordinary accessors and its synthesized backing ivar on the owning class', async () => { + const content = + '@interface Worker\n@property int value;\n@end\n@interface Other\n@property int value;\n@end\n@implementation Worker\n@synthesize value = _value;\n- (int)value { return _value; }\n- (void)setValue:(int)value { _value = value; }\n- (void)run:(Worker *)worker other:(Other *)other { self.value = 1; worker.value = 2; other.value = 3; [self setValue:4]; [worker value]; }\n@end\n'; + const { root, nodes, symbol } = await fixture( + { 'Worker.m': content }, + 'objc:property:objc:class:Worker:value', + ); + const result = await renameSymbol(root, symbol, { new_name: 'count', dry_run: false }, nodes); + expect(result, JSON.stringify(result)).toMatchObject({ applied: true }); + expect(await fs.readFile(path.join(root, 'Worker.m'), 'utf8')).toBe( + '@interface Worker\n@property int count;\n@end\n@interface Other\n@property int value;\n@end\n@implementation Worker\n@synthesize count = _count;\n- (int)count { return _count; }\n- (void)setCount:(int)value { _count = value; }\n- (void)run:(Worker *)worker other:(Other *)other { self.count = 1; worker.count = 2; other.value = 3; [self setCount:4]; [worker count]; }\n@end\n', + ); + }, 60000); + + it('renames an ivar and its accesses while preserving a local shadow', async () => { + const content = + '@interface Worker { int _value; }\n@end\n@implementation Worker\n- (int)read { return _value; }\n- (int)shadow:(int)_value { return _value; }\n- (int)multiple { int first = 1, _value = 2; return _value; }\n- (void)block { void (^block)(int) = ^(int _value) { (void)_value; }; }\n@end\n'; + const { root, nodes, symbol } = await fixture( + { 'Worker.m': content }, + 'objc:ivar:objc:class:Worker:_value', + ); + const result = await renameSymbol(root, symbol, { new_name: '_count', dry_run: false }, nodes); + expect(result, JSON.stringify(result)).toMatchObject({ applied: true, total_edits: 2 }); + expect(await fs.readFile(path.join(root, 'Worker.m'), 'utf8')).toBe( + content.replace('int _value;', 'int _count;').replace('return _value;', 'return _count;'), + ); + }, 60000); + + it('preserves a block parameter whose type owns an unrelated same-name selector', async () => { + const content = + '@interface Worker\n- (void)run;\n@end\n@interface Other\n- (void)run;\n@end\n@implementation Worker\n- (void)run {}\n- (void)use:(Worker *)worker { [worker run]; void (^block)(Other *) = ^(Other *worker) { [worker run]; }; }\n@end\n'; + const { root, nodes, symbol } = await fixture( + { 'Worker.m': content }, + 'objc:method:objc:class:Worker:-:run', + ); + const result = await renameSymbol(root, symbol, { new_name: 'execute', dry_run: false }, nodes); + expect(result, JSON.stringify(result)).toMatchObject({ applied: true, total_edits: 3 }); + expect(await fs.readFile(path.join(root, 'Worker.m'), 'utf8')).toBe( + content + .replace('- (void)run;', '- (void)execute;') + .replace('- (void)run {}', '- (void)execute {}') + .replace('[worker run]', '[worker execute]'), + ); + }, 60000); + + it('renames category selector declarations and implementations through typed calls', async () => { + const content = + '@interface Worker\n@end\n@interface Worker (Extra)\n- (void)trace;\n@end\n@implementation Worker (Extra)\n- (void)trace {}\n- (void)run { [self trace]; }\n@end\n@interface Other\n- (void)trace;\n@end\n'; + const { root, nodes, symbol } = await fixture( + { 'Worker.m': content }, + 'objc:method:objc:category:Worker:Extra:-:trace', + ); + const result = await renameSymbol(root, symbol, { new_name: 'record', dry_run: false }, nodes); + expect(result, JSON.stringify(result)).toMatchObject({ applied: true, total_edits: 3 }); + expect(await fs.readFile(path.join(root, 'Worker.m'), 'utf8')).toBe( + content + .replaceAll('trace', 'record') + .replace('@interface Other\n- (void)record;', '@interface Other\n- (void)trace;'), + ); + }, 60000); + + it('renames a category name on its host while preserving another host category', async () => { + const content = + '@interface Worker\n@end\n@interface Worker (Extra)\n@end\n@implementation Worker (Extra)\n@end\n@interface Other\n@end\n@interface Other (Extra)\n@end\n'; + const { root, nodes, symbol } = await fixture( + { 'Worker.m': content }, + 'objc:category:Worker:Extra', + ); + const result = await renameSymbol(root, symbol, { new_name: 'Tracing', dry_run: false }, nodes); + expect(result, JSON.stringify(result)).toMatchObject({ applied: true, total_edits: 2 }); + expect(await fs.readFile(path.join(root, 'Worker.m'), 'utf8')).toBe( + content.replaceAll('Worker (Extra)', 'Worker (Tracing)'), + ); + }, 60000); + + it('renames a protocol name in conformance and qualified object annotations', async () => { + const content = + '@protocol Runnable\n- (void)run;\n@end\n@interface Worker \n- (void)use:(id)other;\n@end\n'; + const { root, nodes, symbol } = await fixture( + { 'Worker.m': content }, + 'objc:protocol:Runnable', + ); + const result = await renameSymbol( + root, + symbol, + { new_name: 'Executable', dry_run: false }, + nodes, + ); + expect(result, JSON.stringify(result)).toMatchObject({ applied: true, total_edits: 3 }); + expect(await fs.readFile(path.join(root, 'Worker.m'), 'utf8')).toBe( + content.replaceAll('Runnable', 'Executable'), + ); + }, 60000); + + it('renames the entire static selector family when a category replaces a host method', async () => { + const content = + '@interface Worker\n- (void)run;\n@end\n@interface Worker (Extra)\n- (void)run;\n@end\n@implementation Worker\n- (void)run {}\n- (void)call { [self run]; }\n@end\n@implementation Worker (Extra)\n- (void)run {}\n@end\n'; + const { root, nodes, symbol } = await fixture( + { 'Worker.m': content }, + 'objc:method:objc:class:Worker:-:run', + ); + const result = await renameSymbol(root, symbol, { new_name: 'execute', dry_run: false }, nodes); + expect(result, JSON.stringify(result)).toMatchObject({ applied: true, total_edits: 5 }); + expect(await fs.readFile(path.join(root, 'Worker.m'), 'utf8')).toBe( + content.replaceAll('run', 'execute'), + ); + }, 60000); + + it('keeps protocol and inherited property contracts in the same rename family', async () => { + const content = + '@protocol HasValue\n@property int value;\n@end\n@interface Worker \n@property int value;\n@end\n@interface Child : Worker\n@property int value;\n@end\n@implementation Child\n- (void)run:(id)other { self.value = other.value; }\n@end\n'; + const { root, nodes, symbol } = await fixture( + { 'Worker.m': content }, + 'objc:property:objc:protocol:HasValue:value', + ); + const result = await renameSymbol(root, symbol, { new_name: 'count', dry_run: false }, nodes); + expect(result, JSON.stringify(result)).toMatchObject({ applied: true, total_edits: 5 }); + expect(await fs.readFile(path.join(root, 'Worker.m'), 'utf8')).toBe( + content.replaceAll('int value;', 'int count;').replaceAll('.value', '.count'), + ); + }, 60000); + + it('blocks a selector literal that has no statically bound owner', async () => { + const content = + '@interface Worker\n- (void)send:(int)value completion:(int)done;\n@end\n@implementation Worker\n- (void)send:(int)value completion:(int)done {}\n- (void)run { [self performSelector:@selector(send:completion:)]; }\n@end\n'; + const { root, nodes, symbol } = await fixture( + { 'Worker.m': content }, + 'objc:method:objc:class:Worker:-:send:completion:', + ); + const result = await renameSymbol( + root, + symbol, + { new_name: 'deliver:finished:', dry_run: false }, + nodes, + ); + expect(result, JSON.stringify(result)).toMatchObject({ + applied: false, + code: 'unsupported_symbol_family', + }); + expect(await fs.readFile(path.join(root, 'Worker.m'), 'utf8')).toBe(content); + }, 60000); + + it('blocks custom accessor property contracts with an explicit reason', async () => { + const content = + '@interface Worker\n@property(getter=enabled, setter=assignFlag:) int flag;\n@end\n'; + const { root, nodes, symbol } = await fixture( + { 'Worker.m': content }, + 'objc:property:objc:class:Worker:flag', + ); + const result = await renameSymbol(root, symbol, { new_name: 'active', dry_run: false }, nodes); + expect(result, JSON.stringify(result)).toMatchObject({ + applied: false, + code: 'unsupported_symbol_family', + }); + expect(await fs.readFile(path.join(root, 'Worker.m'), 'utf8')).toBe(content); + }, 60000); + + it('renames automatically synthesized accessors and backing storage', async () => { + const content = + '@interface Worker\n@property int value;\n@end\n@implementation Worker\n- (void)run { _value = 1; [self setValue:2]; self.value; }\n@end\n'; + const { root, nodes, symbol } = await fixture( + { 'Worker.m': content }, + 'objc:property:objc:class:Worker:value', + ); + const result = await renameSymbol(root, symbol, { new_name: 'count', dry_run: false }, nodes); + expect(result, JSON.stringify(result)).toMatchObject({ applied: true, total_edits: 4 }); + expect(await fs.readFile(path.join(root, 'Worker.m'), 'utf8')).toBe( + content + .replace('int value;', 'int count;') + .replace('_value', '_count') + .replace('setValue:', 'setCount:') + .replace('self.value', 'self.count'), + ); + }, 60000); + + it('renames the coupled property when the selected symbol is its ordinary setter', async () => { + const content = + '@interface Worker\n@property int value;\n- (void)setValue:(int)value;\n@end\n@implementation Worker\n- (void)run { _value = 1; [self setValue:2]; }\n@end\n'; + const { root, nodes, symbol } = await fixture( + { 'Worker.m': content }, + 'objc:method:objc:class:Worker:-:setValue:', + ); + const result = await renameSymbol( + root, + symbol, + { new_name: 'setCount:', dry_run: false }, + nodes, + ); + expect(result, JSON.stringify(result)).toMatchObject({ applied: true, total_edits: 4 }); + expect(await fs.readFile(path.join(root, 'Worker.m'), 'utf8')).toBe( + content + .replace('int value;', 'int count;') + .replaceAll('setValue:', 'setCount:') + .replace('_value', '_count'), + ); + }, 60000); + + it('preserves an explicitly named backing ivar when renaming its property', async () => { + const content = + '@interface Worker { int storage; }\n@property int value;\n@end\n@implementation Worker\n@synthesize value = storage;\n- (void)run { storage = 1; self.value = 2; }\n@end\n'; + const { root, nodes, symbol } = await fixture( + { 'Worker.m': content }, + 'objc:property:objc:class:Worker:value', + ); + const result = await renameSymbol(root, symbol, { new_name: 'count', dry_run: false }, nodes); + expect(result, JSON.stringify(result)).toMatchObject({ applied: true, total_edits: 3 }); + expect(await fs.readFile(path.join(root, 'Worker.m'), 'utf8')).toBe( + content.replaceAll('value', 'count'), + ); + }, 60000); + + it('renames a readonly public property together with its readwrite extension', async () => { + const content = + '@interface Worker\n@property(readonly) int value;\n@end\n@interface Worker ()\n@property(readwrite) int value;\n@end\n@implementation Worker\n- (void)run { [self setValue:1]; self.value; }\n@end\n'; + const { root, nodes, symbol } = await fixture( + { 'Worker.m': content }, + 'objc:property:objc:class:Worker:value', + ); + const result = await renameSymbol(root, symbol, { new_name: 'count', dry_run: false }, nodes); + expect(result, JSON.stringify(result)).toMatchObject({ applied: true, total_edits: 4 }); + expect(await fs.readFile(path.join(root, 'Worker.m'), 'utf8')).toBe( + content + .replaceAll('int value;', 'int count;') + .replace('setValue:', 'setCount:') + .replace('self.value', 'self.count'), + ); + }, 60000); + + it('blocks a property whose accessors are supplied at runtime', async () => { + const content = + '@interface Worker\n@property int value;\n@end\n@implementation Worker\n@dynamic value;\n@end\n'; + const { root, nodes, symbol } = await fixture( + { 'Worker.m': content }, + 'objc:property:objc:class:Worker:value', + ); + const result = await renameSymbol(root, symbol, { new_name: 'count', dry_run: false }, nodes); + expect(result, JSON.stringify(result)).toMatchObject({ + applied: false, + code: 'unsupported_symbol_family', + }); + expect(await fs.readFile(path.join(root, 'Worker.m'), 'utf8')).toBe(content); + }, 60000); + + it('rejects a property rename that would capture an existing setter', async () => { + const content = '@interface Worker\n@property int value;\n- (void)setCount:(int)count;\n@end\n'; + const { root, nodes, symbol } = await fixture( + { 'Worker.m': content }, + 'objc:property:objc:class:Worker:value', + ); + const result = await renameSymbol(root, symbol, { new_name: 'count', dry_run: false }, nodes); + expect(result, JSON.stringify(result)).toMatchObject({ + applied: false, + code: 'name_collision', + }); + expect(await fs.readFile(path.join(root, 'Worker.m'), 'utf8')).toBe(content); + }, 60000); + + it('applies multipart selector changes across header, implementation and typed sends, preserving other owners and method kinds', async () => { + const files = { + 'Worker.h': + '@interface Worker\n- (void)send:(int)value completion:(int)done;\n+ (void)send:(int)value completion:(int)done;\n@end\n@interface Other\n- (void)send:(int)value completion:(int)done;\n@end\n', + 'Worker.m': + '#import "Worker.h"\n@implementation Worker\n- (void)send:(int)value completion:(int)done {}\n+ (void)send:(int)value completion:(int)done {}\n- (void)run:(Worker *)worker other:(Other *)other {\n [self send:1 completion:2];\n [worker send:1 completion:2];\n [other send:1 completion:2];\n [Worker send:1 completion:2];\n}\n@end\n', + }; + const { root, nodes, symbol } = await fixture( + files, + 'objc:method:objc:class:Worker:-:send:completion:', + ); + const result = await renameSymbol( + root, + symbol, + { new_name: 'deliver:finished:', dry_run: false }, + nodes, + ); + expect(result, JSON.stringify(result)).toMatchObject({ applied: true, total_edits: 8 }); + expect(await fs.readFile(path.join(root, 'Worker.h'), 'utf8')).toBe( + files['Worker.h'].replace( + '- (void)send:(int)value completion:', + '- (void)deliver:(int)value finished:', + ), + ); + expect(await fs.readFile(path.join(root, 'Worker.m'), 'utf8')).toBe( + files['Worker.m'] + .replace('- (void)send:(int)value completion:', '- (void)deliver:(int)value finished:') + .replace('[self send:1 completion:2]', '[self deliver:1 finished:2]') + .replace('[worker send:1 completion:2]', '[worker deliver:1 finished:2]'), + ); + }, 60000); + + it('rejects a selector arity change without modifying either file', async () => { + const files = { + 'Worker.m': '@interface Worker\n- (void)send:(int)value completion:(int)done;\n@end\n', + }; + const { root, nodes, symbol } = await fixture( + files, + 'objc:method:objc:class:Worker:-:send:completion:', + ); + await expect( + planGraphRename(root, symbol, { new_name: 'deliver:' }, nodes), + ).rejects.toMatchObject({ code: 'invalid_name' }); + expect(await fs.readFile(path.join(root, 'Worker.m'), 'utf8')).toBe(files['Worker.m']); + }, 60000); + + it('blocks a selector with an unnamed piece instead of treating it as a shorter selector', async () => { + const content = '@interface Worker\n- (void)send:(int)a :(int)b;\n@end\n'; + const { root, nodes, symbol } = await fixture( + { 'Worker.m': content }, + 'objc:method:objc:class:Worker:-:send:', + ); + const result = await renameSymbol( + root, + symbol, + { new_name: 'deliver:', dry_run: false }, + nodes, + ); + expect(result, JSON.stringify(result)).toMatchObject({ + applied: false, + code: 'unsupported_symbol_family', + }); + expect(await fs.readFile(path.join(root, 'Worker.m'), 'utf8')).toBe(content); + }, 60000); +}); diff --git a/gitnexus/test/unit/rename-php-ruby-providers.test.ts b/gitnexus/test/unit/rename-php-ruby-providers.test.ts new file mode 100644 index 000000000..71062574b --- /dev/null +++ b/gitnexus/test/unit/rename-php-ruby-providers.test.ts @@ -0,0 +1,646 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js'; +import { renameSymbol } from '../../src/mcp/local/rename/rename-plan.js'; +import { SCOPE_RESOLVERS } from '../../src/core/ingestion/scope-resolution/pipeline/registry.js'; +import { SupportedLanguages } from 'gitnexus-shared'; +import { phpRenameProvider } from '../../src/core/ingestion/languages/php/rename.js'; + +const roots: string[] = []; +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true }))); +}); + +async function fixture(files: Record, name: string, kind: string) { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'rename-php-ruby-')); + roots.push(root); + await Promise.all( + Object.entries(files).map(async ([file, content]) => { + await fs.mkdir(path.dirname(path.join(root, file)), { recursive: true }); + await fs.writeFile(path.join(root, file), content); + }), + ); + const result = await runPipelineFromRepo(root, () => undefined, { + workerPoolSize: 1, + skipGraphPhases: true, + }); + const nodes = [...result.graph.iterNodes()]; + const target = nodes.find((node) => node.properties.name === name && node.label === kind); + expect(target, JSON.stringify(nodes.filter((node) => node.label !== 'File'))).toBeDefined(); + return { + root, + nodes, + symbol: { + uid: target!.id, + name: target!.properties.name, + filePath: target!.properties.filePath, + startLine: target!.properties.startLine! + 1, + endLine: target!.properties.endLine! + 1, + kind: target!.label, + }, + }; +} + +async function expectRename( + files: Record, + expected: Record, + name: string, + kind: string, + newName: string, +) { + const { root, nodes, symbol } = await fixture(files, name, kind); + const preview = await renameSymbol(root, symbol, { new_name: newName, dry_run: true }, nodes); + expect(preview, JSON.stringify(preview)).toMatchObject({ status: 'success', applied: false }); + for (const [file, content] of Object.entries(files)) + expect(await fs.readFile(path.join(root, file), 'utf8')).toBe(content); + const applied = await renameSymbol(root, symbol, { new_name: newName, dry_run: false }, nodes); + expect(applied, JSON.stringify(applied)).toMatchObject({ + status: 'success', + applied: true, + changes: preview.changes, + }); + for (const [file, content] of Object.entries(expected)) + expect(await fs.readFile(path.join(root, file), 'utf8')).toBe(content); +} + +describe('PHP and Ruby rename through the production parsing pipeline', () => { + it.each([ + [ + 'model.php', + ' { + await expectRename( + { [file]: content }, + { + [file]: content + .replace('function target()', 'function renamed()') + .replace('def target', 'def renamed') + .replace('\ntarget()', '\nrenamed()'), + }, + 'target', + kind, + 'renamed', + ); + }, + 60000, + ); + + it('refuses PHP class imports that require an autoload filename change', async () => { + const files = { + 'composer.json': '{"autoload":{"psr-4":{"Source\\\\":"Source/"}}}', + 'Source/Target.php': ' { + const files = { + 'composer.json': '{"autoload":{"psr-4":{"Source\\\\":"Source/"}}}', + 'Source/first.php': ` { + const content = ' { + await expectRename( + { [file]: content }, + { + [file]: content + .replace('target()', 'renamed()') + .replace('target();', 'renamed();') + .replace('def target', 'def renamed') + .replace('\ntarget()', '\nrenamed()'), + }, + 'target', + kind, + 'renamed', + ); + }, + 60000, + ); + + it.each([ + [ + 'model.php', + ' { + await expectRename( + { [file]: content }, + { [file]: content.replaceAll('Target', 'Renamed') }, + name, + kind, + 'Renamed', + ); + }, + 60000, + ); + + it.each([ + [ + 'model.php', + 'target(); } }\n$value = new Worker();\n$value->target();\n', + ], + [ + 'model.rb', + 'class Worker\n def target\n end\n def call\n self.target()\n end\nend\nvalue = Worker.new\nvalue.target()\n', + ], + ])( + 'renames members on this/self and constructed receivers in %s', + async (file, content) => { + await expectRename( + { [file]: content }, + { [file]: content.replaceAll('target', 'renamed') }, + 'target', + 'Method', + 'renamed', + ); + }, + 60000, + ); + + it('keeps PHP explicit function aliases stable', async () => { + const files = { + 'composer.json': '{"autoload":{"psr-4":{"Source\\\\":"Source/"}}}', + 'Source/functions.php': ' { + const files = { + 'source.rb': 'class Target\nend\n', + 'caller.rb': 'require_relative "source"\nvalue = Target.new\n', + }; + await expectRename( + files, + Object.fromEntries( + Object.entries(files).map(([file, content]) => [ + file, + content.replaceAll('Target', 'Renamed'), + ]), + ), + 'Target', + 'Class', + 'Renamed', + ); + }, 60000); + + it('uses PHP case-insensitive callable names without folding variables', async () => { + const content = + ' { + const content = + 'TARGET(); $this->target; $this->Target; } }\n'; + await expectRename( + { 'model.php': content }, + { + 'model.php': content + .replace('function Target()', 'function renamed()') + .replace('->TARGET()', '->renamed()'), + }, + 'Target', + 'Method', + 'renamed', + ); + }, 60000); + + it('keeps PHP properties case-sensitive', async () => { + const content = + 'target = 1; return $this->Target + $this->target; } }\n'; + await expectRename( + { 'model.php': content }, + { + 'model.php': content.replaceAll('$target', '$renamed').replaceAll('->target', '->renamed'), + }, + 'target', + 'Property', + 'renamed', + ); + }, 60000); + + it('keeps grouped PHP aliases stable', async () => { + const files = { + 'composer.json': '{"autoload":{"psr-4":{"Source\\\\":"Source/"}}}', + 'Source/functions.php': + ' [ + file, + content.replace('target', 'renamed'), + ]), + ), + 'target', + 'Function', + 'renamed', + ); + }, 60000); + + it.each([ + ['target?', 'renamed!'], + ['target!', 'renamed?'], + ])( + 'renames Ruby punctuation method %s', + async (name, newName) => { + const content = `class Worker\n def ${name}\n end\nend\nvalue = Worker.new\nvalue.${name}()\n`; + await expectRename( + { 'model.rb': content }, + { 'model.rb': content.replaceAll(name, newName) }, + name, + 'Method', + newName, + ); + }, + 60000, + ); + + it('preserves Ruby block parameter shadows and unrelated indexed reads', async () => { + const content = + 'def target\nend\n[1].each { |target| puts target }\ntarget()\nvalues = [1]\nvalues[0]\n'; + await expectRename( + { 'model.rb': content }, + { 'model.rb': content.replace('def target', 'def renamed').replace('target()', 'renamed()') }, + 'target', + 'Method', + 'renamed', + ); + }, 60000); + + it.each([ + [ + 'model.php', + 'target(); }\n', + ], + [ + 'model.rb', + 'module Contract\n def target\n end\nend\nclass Worker\n include Contract\n def target\n end\nend\nvalue = Worker.new\nvalue.target()\n', + ], + ])( + 'renames graph-proven method families in %s', + async (file, content) => { + await expectRename( + { [file]: content }, + { [file]: content.replaceAll('target', 'renamed') }, + 'target', + 'Method', + 'renamed', + ); + }, + 60000, + ); + + it.each([ + ['model.php', ' { + const { root, nodes, symbol } = await fixture({ [file]: content }, 'target', kind); + const result = await renameSymbol( + root, + symbol, + { new_name: 'renamed', dry_run: false }, + nodes, + ); + expect(result).toMatchObject({ status: 'error', applied: false, code: 'name_collision' }); + expect(await fs.readFile(path.join(root, file), 'utf8')).toBe(content); + }, + 60000, + ); + + it('preserves Ruby relative and load-path import distinctions', async () => { + const resolver = SCOPE_RESOLVERS.get(SupportedLanguages.Ruby)!; + const source = await resolver.languageProvider.rename!.parseSource({ + filePath: 'caller.rb', + content: 'require_relative "source"\nrequire "source"\n', + languageProvider: resolver.languageProvider, + scopeResolver: resolver, + }); + expect(source.parsed.parsedImports.map((entry) => entry.targetRaw)).toEqual([ + './source', + 'source', + ]); + }); + + it('renames PHP class spellings case-insensitively', async () => { + const content = + ' { + const content = + 'module Outer\n class Target\n end\nend\nmodule Other\n class Target\n end\nend\nfirst = Outer::Target.new\nsecond = Other::Target.new\n'; + await expectRename( + { 'model.rb': content }, + { + 'model.rb': content + .replace('class Target', 'class Renamed') + .replace('Outer::Target', 'Outer::Renamed'), + }, + 'Target', + 'Class', + 'Renamed', + ); + }, 60000); + + it('renames Ruby setters through assignment syntax', async () => { + const content = + 'class Worker\n def target=(value)\n end\nend\nworker = Worker.new\nworker.target = 1\n'; + await expectRename( + { 'model.rb': content }, + { 'model.rb': content.replaceAll('target', 'renamed') }, + 'target=', + 'Method', + 'renamed=', + ); + }, 60000); + + it('distinguishes Ruby singleton and instance methods', async () => { + const content = + 'class Worker\n def target\n end\n def self.target\n end\nend\nworker = Worker.new\nworker.target()\nWorker.target()\n'; + await expectRename( + { 'model.rb': content }, + { + 'model.rb': content + .replace('def target', 'def renamed') + .replace('worker.target()', 'worker.renamed()'), + }, + 'target', + 'Method', + 'renamed', + ); + }, 60000); + + it('renames Ruby extended module methods through singleton receivers', async () => { + const content = + 'module Contract\n def target\n end\nend\nclass Worker\n extend Contract\nend\nWorker.target()\n'; + await expectRename( + { 'model.rb': content }, + { 'model.rb': content.replaceAll('target', 'renamed') }, + 'target', + 'Method', + 'renamed', + ); + }, 60000); + + it.each([ + [ + 'model.php', + '$name();\n', + ], + ['model.rb', 'class Worker\n def target\n end\nend\nvalue = Worker.new\nvalue.send(:target)\n'], + ])( + 'refuses dynamic member references to the selected owner in %s', + async (file, content) => { + const { root, nodes, symbol } = await fixture({ [file]: content }, 'target', 'Method'); + const result = await renameSymbol( + root, + symbol, + { new_name: 'renamed', dry_run: false }, + nodes, + ); + expect(result).toMatchObject({ + status: 'error', + applied: false, + code: 'unsupported_occurrence', + }); + expect(await fs.readFile(path.join(root, file), 'utf8')).toBe(content); + }, + 60000, + ); + + it.each([ + ['model.php', ' { + const { root, nodes, symbol } = await fixture({ [file]: content }, 'target', kind); + const result = await renameSymbol( + root, + symbol, + { new_name: 'renamed', dry_run: false }, + nodes, + ); + expect(result).toMatchObject({ + status: 'error', + applied: false, + code: 'unsupported_occurrence', + }); + expect(await fs.readFile(path.join(root, file), 'utf8')).toBe(content); + }, + 60000, + ); + + it.each([ + [ + 'model.php', + 'target();\n$other = new Other();\n$name = "target";\n$other->$name();\n', + ], + [ + 'model.rb', + 'class Worker\n def target\n end\nend\nclass Other\n def target\n end\nend\nvalue = Worker.new\nvalue.target()\nother = Other.new\nother.send(:target)\n', + ], + ])( + 'allows dynamic references proven to belong to unrelated owners in %s', + async (file, content) => { + await expectRename( + { [file]: content }, + { + [file]: content + .replace('function target()', 'function renamed()') + .replace('def target', 'def renamed') + .replace('value->target()', 'value->renamed()') + .replace('value.target()', 'value.renamed()'), + }, + 'target', + 'Method', + 'renamed', + ); + }, + 60000, + ); +}); + +describe('PHP replacement identifier validation', () => { + const validateName = phpRenameProvider.validateName; + if (!validateName) throw new Error('Missing PHP identifier validator'); + + // PHP labels accept every non-ASCII code point, so the guard targets only names that + // render invisibly or reorder neighbouring source. Names are written with escapes so + // this file carries no hidden characters itself. + it.each([ + ['a right-to-left override', 'user\u202Eadmin'], + ['a left-to-right embedding', 'user\u202Aadmin'], + ['a bidirectional isolate', 'user\u2066admin'], + ['a left-to-right mark', 'user\u200Eadmin'], + ['a zero-width space', 'a\u200Bb'], + ['a zero-width non-joiner', 'a\u200Cb'], + ['a zero-width joiner', 'a\u200Db'], + ['a word joiner', 'a\u2060b'], + ['a leading byte order mark', '\uFEFFname'], + ['a trailing byte order mark', 'name\uFEFF'], + ['a C1 control character', 'a\u0085b'], + ['a no-break space', 'a\u00A0b'], + ['a line separator', 'a\u2028b'], + ['a paragraph separator', 'a\u2029b'], + ['a lone surrogate', 'a\uD800'], + ['a private-use code point', 'a\uE000b'], + ['a noncharacter', 'a\uFFFEb'], + ])('rejects %s in a replacement name', (_label, name) => { + expect(validateName(name)).toMatchObject({ code: 'invalid_name' }); + }); + + it.each([ + ['a Latin letter with a diacritic', '\u00E9'], + ['CJK letters', '\u65E5\u672C\u8A9E'], + ['a leading underscore with a digit', '_x1'], + ['a decomposed accent', 'e\u0301'], + ['an astral letter', '\u{1D4B3}name'], + ['mixed ASCII and non-ASCII letters', 'caf\u00E9_\u540D\u524D2'], + ])('accepts %s in a replacement name', (_label, name) => { + expect(validateName(name)).toBeUndefined(); + }); + + it.each([ + ['a reserved word', 'class'], + ['a reserved word in another case', 'CLASS'], + ['a leading digit', '1name'], + ['an empty name', ''], + ['a hyphen', 'a-b'], + ['a leading dollar sign', '$name'], + ])('keeps rejecting %s in a replacement name', (_label, name) => { + expect(validateName(name)).toMatchObject({ code: 'invalid_name' }); + }); + + it('refuses a bidirectional override before preview or apply', async () => { + const content = ' { + let root: string; + beforeEach(async () => { + root = await fs.mkdtemp(path.join(os.tmpdir(), 'rename-plan-')); + }); + afterEach(async () => { + vi.restoreAllMocks(); + await fs.rm(root, { recursive: true, force: true }); + }); + + async function plan(files: Record): Promise { + for (const [name, text] of Object.entries(files)) + await fs.writeFile(path.join(root, name), text); + return { + symbol: { name: 'close', filePath: 'a.ts', startLine: 1 }, + new_name: 'finish', + coverage: { + name: 'gitnexus-semantic', + scope: 'indexed-repository', + languages: ['typescript'], + limitations: [], + source_file_count: Object.keys(files).length, + }, + snapshots: new Map( + Object.entries(files).map(([file, text]) => [path.join(root, file), text]), + ), + edits: Object.entries(files).flatMap(([file_path, text]) => { + const start = text.indexOf('close'); + return [{ file_path, start, length: 5, old_text: 'close', new_text: 'finish' }]; + }), + }; + } + + it('counts occurrences on one line and preserves unrelated keys, CRLF and UTF-16 offsets', async () => { + const text = '// 🐈\r\nconst hooks = { close: () => writer.close() }; writer.close();\r\n'; + const p = await plan({ 'a.ts': text }); + const starts = [text.indexOf('writer.close') + 7, text.lastIndexOf('writer.close') + 7]; + p.edits = starts.map((start) => ({ + file_path: 'a.ts', + start, + length: 5, + old_text: 'close', + new_text: 'complete', + })); + const preview = await executeRenamePlan(root, p, true); + expect(preview).toMatchObject({ + status: 'success', + applied: false, + application_status: 'not_requested', + total_edits: 2, + semantic_edits: 2, + }); + expect(preview.changes[0].edits.map((e) => e.line)).toEqual([2, 2]); + expect(await fs.readFile(path.join(root, 'a.ts'), 'utf8')).toBe(text); + const applied = await executeRenamePlan(root, p, false); + expect(applied).toMatchObject({ + status: 'success', + applied: true, + application_status: 'applied', + total_edits: 2, + }); + expect(await fs.readFile(path.join(root, 'a.ts'), 'utf8')).toBe( + '// 🐈\r\nconst hooks = { close: () => writer.complete() }; writer.complete();\r\n', + ); + }); + + it('deduplicates identical ranges while retaining full affix replacements', async () => { + const p = await plan({ 'a.ts': 'export { close };\nconst obj = { close };' }); + p.edits[0].new_text = 'finish as close'; + p.edits.push({ ...p.edits[0] }); + const start = p.snapshots.get(path.join(root, 'a.ts'))!.lastIndexOf('close'); + p.edits.push({ + file_path: 'a.ts', + start, + length: 5, + old_text: 'close', + new_text: 'close: finish', + }); + expect(await executeRenamePlan(root, p, false)).toMatchObject({ + total_edits: 2, + semantic_edits: 2, + }); + expect(await fs.readFile(path.join(root, 'a.ts'), 'utf8')).toBe( + 'export { finish as close };\nconst obj = { close: finish };', + ); + }); + + it.each(['overlap', 'unexpected text', 'stale snapshot'] as const)( + 'blocks all writes on %s', + async (failure) => { + const p = await plan({ 'a.ts': 'close()', 'b.ts': 'close()' }); + if (failure === 'overlap') + p.edits.push({ ...p.edits[0], start: 2, length: 3, old_text: 'ose' }); + if (failure === 'unexpected text') p.edits[1].old_text = 'wrong'; + if (failure === 'stale snapshot') await fs.writeFile(path.join(root, 'b.ts'), 'close(1)'); + const result = await executeRenamePlan(root, p, false); + expect(result).toMatchObject({ + status: 'error', + planning_status: 'blocked', + applied: false, + application_status: 'not_started', + total_edits: 0, + }); + expect(await fs.readFile(path.join(root, 'a.ts'), 'utf8')).toBe('close()'); + }, + ); + + it('checks snapshots of unedited project files too', async () => { + const p = await plan({ 'a.ts': 'close()', 'b.ts': 'close()' }); + p.edits.pop(); + await fs.writeFile(path.join(root, 'b.ts'), 'changed binding'); + expect(await executeRenamePlan(root, p, false)).toMatchObject({ + planning_status: 'blocked', + total_edits: 0, + }); + expect(await fs.readFile(path.join(root, 'a.ts'), 'utf8')).toBe('close()'); + }); + + it.each([{ start: -1 }, { start: 0.5 }, { length: 0 }, { length: 9 }, { old_text: 'wrong' }])( + 'refuses invalid edit spans before any write: %j', + async (invalid) => { + const p = await plan({ 'a.ts': 'close()', 'b.ts': 'close()' }); + Object.assign(p.edits[1], invalid); + for (const dryRun of [true, false]) { + expect(await executeRenamePlan(root, p, dryRun)).toMatchObject({ + status: 'error', + code: 'invalid_edit', + planning_status: 'blocked', + application_status: 'not_started', + applied: false, + total_edits: 0, + files_affected: 0, + changes: [], + }); + expect(await fs.readFile(path.join(root, 'a.ts'), 'utf8')).toBe('close()'); + expect(await fs.readFile(path.join(root, 'b.ts'), 'utf8')).toBe('close()'); + } + }, + ); + + it('refuses two paths to the same physical source before any write', async () => { + const p = await plan({ 'a.ts': 'close()' }); + await fs.symlink( + root, + path.join(root, 'alias'), + process.platform === 'win32' ? 'junction' : 'dir', + ); + p.edits.push({ ...p.edits[0], file_path: 'alias/a.ts' }); + p.snapshots.set(path.join(root, 'alias/a.ts'), 'close()'); + for (const dryRun of [true, false]) { + expect(await executeRenamePlan(root, p, dryRun)).toMatchObject({ + status: 'error', + code: 'duplicate_file', + planning_status: 'blocked', + application_status: 'not_started', + applied: false, + total_edits: 0, + files_affected: 0, + changes: [], + }); + expect(await fs.readFile(path.join(root, 'a.ts'), 'utf8')).toBe('close()'); + } + }); + + it('accepts landed outputs while applying an ordinary multi-file plan', async () => { + const p = await plan({ 'a.ts': 'close()', 'b.ts': 'close()' }); + expect(await executeRenamePlan(root, p, false)).toMatchObject({ + status: 'success', + applied: true, + application_status: 'applied', + total_edits: 2, + files_affected: 2, + }); + expect(await fs.readFile(path.join(root, 'a.ts'), 'utf8')).toBe('finish()'); + expect(await fs.readFile(path.join(root, 'b.ts'), 'utf8')).toBe('finish()'); + expect((await fs.readdir(root)).sort()).toEqual(['a.ts', 'b.ts']); + }); + + it.each([0, 1])( + 'blocks installation when an unedited snapshot changes while staging file %i', + async (changeAt) => { + const p = await plan({ 'a.ts': 'close()', 'b.ts': 'close()', 'c.ts': '// no references' }); + p.edits.pop(); + const original = fs.writeFile.bind(fs); + let calls = 0; + vi.spyOn(fs, 'writeFile').mockImplementation(async (...args) => { + await original(...args); + if (calls++ === changeAt) await original(path.join(root, 'c.ts'), 'close();'); + }); + expect(await executeRenamePlan(root, p, false)).toMatchObject({ + status: changeAt ? 'partial' : 'error', + applied: changeAt > 0, + application_status: changeAt ? 'partial' : 'failed', + code: 'source_changed', + total_edits: changeAt, + files_affected: changeAt, + failed_files: changeAt ? ['b.ts'] : ['a.ts'], + unattempted_files: changeAt ? [] : ['b.ts'], + }); + expect(await fs.readFile(path.join(root, 'a.ts'), 'utf8')).toBe( + changeAt ? 'finish()' : 'close()', + ); + expect(await fs.readFile(path.join(root, 'b.ts'), 'utf8')).toBe('close()'); + expect(await fs.readFile(path.join(root, 'c.ts'), 'utf8')).toBe('close();'); + expect((await fs.readdir(root)).sort()).toEqual(['a.ts', 'b.ts', 'c.ts']); + }, + ); + + it('rejects lexical traversal and symlink escapes', async () => { + const p = await plan({ 'a.ts': 'close()' }); + p.edits[0].file_path = '../outside.ts'; + expect(await executeRenamePlan(root, p, false)).toMatchObject({ planning_status: 'blocked' }); + const outside = await fs.mkdtemp(path.join(os.tmpdir(), 'rename-outside-')); + try { + await fs.writeFile(path.join(outside, 'b.ts'), 'close()'); + // Directory junctions exercise the same escape without Windows symlink privileges. + await fs.symlink( + outside, + path.join(root, 'link'), + process.platform === 'win32' ? 'junction' : 'dir', + ); + p.edits[0].file_path = 'link/b.ts'; + p.snapshots.set(path.join(root, 'link/b.ts'), 'close()'); + expect(await executeRenamePlan(root, p, false)).toMatchObject({ planning_status: 'blocked' }); + expect(await fs.readFile(path.join(outside, 'b.ts'), 'utf8')).toBe('close()'); + } finally { + await fs.rm(outside, { recursive: true, force: true }); + } + }); + + const writeFailures: { + failAt: number; + landed: string[]; + failed: string[]; + unattempted: string[]; + }[] = [ + { failAt: 0, landed: [], failed: ['a.ts'], unattempted: ['b.ts', 'c.ts'] }, + { failAt: 1, landed: ['a.ts'], failed: ['b.ts'], unattempted: ['c.ts'] }, + { failAt: 2, landed: ['a.ts', 'b.ts'], failed: ['c.ts'], unattempted: [] }, + ]; + + it.each(writeFailures)( + 'separates landed, failed and never-attempted files when writing file $failAt fails', + async ({ failAt, landed, failed, unattempted }) => { + const p = await plan({ 'a.ts': 'close()', 'b.ts': 'close()', 'c.ts': 'close()' }); + const original = fs.writeFile.bind(fs); + let calls = 0; + vi.spyOn(fs, 'writeFile').mockImplementation(async (...args) => { + if (calls++ === failAt) { + await original(args[0], String(args[1]).slice(0, 3), args[2]); + throw new Error('disk full'); + } + return original(...args); + }); + const result = await executeRenamePlan(root, p, false); + expect(result).toMatchObject({ + status: failAt ? 'partial' : 'error', + applied: failAt > 0, + application_status: failAt ? 'partial' : 'failed', + code: 'write_failed', + total_edits: failAt, + files_affected: failAt, + failed_files: failed, + unattempted_files: unattempted, + }); + expect(result.changes.map((change) => change.file_path)).toEqual(landed); + expect(result.error).toContain('failed_files and unattempted_files'); + expect(result.error).toContain('not transactional'); + for (const file of ['a.ts', 'b.ts', 'c.ts']) + expect(await fs.readFile(path.join(root, file), 'utf8')).toBe( + landed.includes(file) ? 'finish()' : 'close()', + ); + expect((await fs.readdir(root)).sort()).toEqual(['a.ts', 'b.ts', 'c.ts']); + }, + ); + + it.each(['a.ts', 'b.ts'])( + 'reports a race in %s after an earlier write as partial', + async (changedFile) => { + const p = await plan({ 'a.ts': 'close()', 'b.ts': 'close()' }); + const original = fs.rename.bind(fs); + vi.spyOn(fs, 'rename').mockImplementation(async (...args) => { + await original(...args); + if (String(args[1]).endsWith('a.ts')) + await fs.writeFile(path.join(root, changedFile), 'user edit'); + }); + expect(await executeRenamePlan(root, p, false)).toMatchObject({ + status: 'partial', + applied: true, + application_status: 'partial', + code: 'source_changed', + total_edits: 1, + failed_files: ['b.ts'], + unattempted_files: [], + }); + expect(await fs.readFile(path.join(root, changedFile), 'utf8')).toBe('user edit'); + if (changedFile === 'a.ts') + expect(await fs.readFile(path.join(root, 'b.ts'), 'utf8')).toBe('close()'); + expect((await fs.readdir(root)).sort()).toEqual(['a.ts', 'b.ts']); + }, + ); + + it('blocks a destination changed while staging its replacement', async () => { + const p = await plan({ 'a.ts': 'close()' }); + const original = fs.writeFile.bind(fs); + vi.spyOn(fs, 'writeFile').mockImplementation(async (...args) => { + await original(...args); + await original(path.join(root, 'a.ts'), 'user edit'); + }); + expect(await executeRenamePlan(root, p, false)).toMatchObject({ + status: 'error', + applied: false, + code: 'source_changed', + total_edits: 0, + }); + expect(await fs.readFile(path.join(root, 'a.ts'), 'utf8')).toBe('user edit'); + expect(await fs.readdir(root)).toEqual(['a.ts']); + }); + + it('preserves a destination edited during the final scan of unedited sources', async () => { + const p = await plan({ 'a.ts': 'close()', 'z.ts': '// no references' }); + p.edits.pop(); + const destination = path.join(root, 'a.ts'); + const original = fs.readFile.bind(fs); + let evidenceReads = 0; + vi.spyOn(fs, 'readFile').mockImplementation(async (...args) => { + const contents = await original(...args); + if (args[0] === path.join(root, 'z.ts') && ++evidenceReads === 2) + await fs.writeFile(destination, 'user edit'); + return contents; + }); + + expect(await executeRenamePlan(root, p, false)).toMatchObject({ + status: 'error', + applied: false, + application_status: 'failed', + code: 'source_changed', + total_edits: 0, + failed_files: ['a.ts'], + unattempted_files: [], + }); + expect(await fs.readFile(destination, 'utf8')).toBe('user edit'); + expect((await fs.readdir(root)).sort()).toEqual(['a.ts', 'z.ts']); + }); + + it('preserves the destination mode after replacement', async () => { + const p = await plan({ 'a.ts': 'close()' }); + await fs.chmod(path.join(root, 'a.ts'), 0o750); + const mode = (await fs.stat(path.join(root, 'a.ts'))).mode; + expect(await executeRenamePlan(root, p, false)).toMatchObject({ applied: true }); + expect((await fs.stat(path.join(root, 'a.ts'))).mode).toBe(mode); + }); +}); diff --git a/gitnexus/test/unit/rename-planning-errors.test.ts b/gitnexus/test/unit/rename-planning-errors.test.ts new file mode 100644 index 000000000..82c09a630 --- /dev/null +++ b/gitnexus/test/unit/rename-planning-errors.test.ts @@ -0,0 +1,80 @@ +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 type { GraphNode } from 'gitnexus-shared'; +import { _captureLogger, type LoggerCapture } from '../../src/core/logger.js'; +import { typescriptRenameProvider } from '../../src/core/ingestion/languages/typescript/rename.js'; +import { RenameFailure, renameSymbol } from '../../src/mcp/local/rename/rename-plan.js'; + +describe('rename planning diagnostics', () => { + let root: string; + let capture: LoggerCapture; + const source = 'function target() {}\ntarget();\n'; + const symbol = { + uid: 'Function:model.ts:target', + name: 'target', + filePath: 'model.ts', + startLine: 1, + }; + const nodes: GraphNode[] = [ + { id: 'File:model.ts', label: 'File', properties: { name: 'model.ts', filePath: 'model.ts' } }, + { + id: symbol.uid, + label: 'Function', + properties: { name: symbol.name, filePath: symbol.filePath, startLine: 0, endLine: 0 }, + }, + ]; + + beforeEach(async () => { + root = await fs.mkdtemp(path.join(os.tmpdir(), 'rename-planning-errors-')); + await fs.writeFile(path.join(root, symbol.filePath), source); + capture = _captureLogger(); + }); + afterEach(async () => { + vi.restoreAllMocks(); + capture.restore(); + await fs.rm(root, { recursive: true, force: true }); + }); + + it('logs unexpected provider failures with structured error details and preserves sources', async () => { + const failure = new Error('provider exploded'); + vi.spyOn(typescriptRenameProvider, 'validateName').mockImplementation(() => { + throw failure; + }); + expect( + await renameSymbol(root, symbol, { new_name: 'renamed', dry_run: false }, nodes), + ).toMatchObject({ + status: 'error', + code: 'rename_failed', + application_status: 'not_started', + total_edits: 0, + }); + expect(capture.records()).toEqual([ + expect.objectContaining({ + level: 50, + msg: 'Semantic rename planning failed', + symbolUid: symbol.uid, + filePath: symbol.filePath, + err: expect.objectContaining({ message: failure.message, stack: failure.stack }), + }), + ]); + expect(await fs.readFile(path.join(root, symbol.filePath), 'utf8')).toBe(source); + }); + + it('keeps expected provider refusals quiet', async () => { + vi.spyOn(typescriptRenameProvider, 'validateName').mockImplementation(() => { + throw new RenameFailure('unsupported_reference', 'Provider cannot prove this reference.'); + }); + expect( + await renameSymbol(root, symbol, { new_name: 'renamed', dry_run: false }, nodes), + ).toMatchObject({ + status: 'error', + code: 'unsupported_reference', + application_status: 'not_started', + total_edits: 0, + }); + expect(capture.records()).toEqual([]); + expect(await fs.readFile(path.join(root, symbol.filePath), 'utf8')).toBe(source); + }); +}); diff --git a/gitnexus/test/unit/rename-python-exports.test.ts b/gitnexus/test/unit/rename-python-exports.test.ts new file mode 100644 index 000000000..a74f30c90 --- /dev/null +++ b/gitnexus/test/unit/rename-python-exports.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { renameSymbol } from '../../src/mcp/local/rename/rename-plan.js'; +import { createRenameFixtureHelpers } from '../helpers/rename-provider-fixture.js'; + +const { expectRename, fixture } = createRenameFixtureHelpers(); +const functions = 'def target(): return 7\ndef run(): return target()\n'; + +describe('Python module export-list rename safety', () => { + it.each([ + ['old name in a list', '__all__ = ["target"]\n'], + ['destination in a tuple', '__all__ = ("rena\\u006ded",)\n'], + ['annotated export list', '__all__: list[str] = ["target"]\n'], + ['unknown export list', '__all__ = discover_exports()\n'], + ['unknown literal element', '__all__ = [export_name]\n'], + ['augmented assignment', '__all__ = ["run"]\n__all__ += ["target"]\n'], + ['mutation', '__all__ = ["run"]\n__all__.append("target")\n'], + ['escaping list alias', '__all__ = ["run"]\nexports = __all__\nexports.append("target")\n'], + [ + 'mutation through a global binding', + '__all__ = ["run"]\ndef configure():\n global __all__\n __all__.append("target")\n', + ], + ])( + 'refuses %s without changing any source', + async (_description, exports) => { + const content = exports + functions; + const { root, nodes, symbol } = await fixture({ 'model.py': content }); + for (const dry_run of [true, false]) { + expect( + await renameSymbol(root, symbol, { new_name: 'renamed', dry_run }, nodes), + ).toMatchObject({ + status: 'error', + planning_status: 'unsupported', + code: 'unsupported_occurrence', + total_edits: 0, + applied: false, + }); + expect(await fs.readFile(path.join(root, 'model.py'), 'utf8')).toBe(content); + } + }, + 60_000, + ); + + it('checks an unaliased re-export module whose local binding changes', async () => { + const files = { + 'model.py': functions, + 'public.py': 'from model import target\n__all__ = ["target"]\n', + }; + const { root, nodes, symbol } = await fixture(files, 'target', 'Function', 'model.py'); + for (const dry_run of [true, false]) { + expect( + await renameSymbol(root, symbol, { new_name: 'renamed', dry_run }, nodes), + ).toMatchObject({ + status: 'error', + planning_status: 'unsupported', + code: 'unsupported_occurrence', + total_edits: 0, + applied: false, + }); + for (const [file, content] of Object.entries(files)) + expect(await fs.readFile(path.join(root, file), 'utf8')).toBe(content); + } + }, 60_000); + + it('preserves unrelated module exports, local export-list shadows, and ordinary strings', async () => { + const decoys = + '__all__ = ("run",)\nnote = "target renamed"\n' + + 'def inspect():\n __all__ = ["target", "renamed"]\n return __all__\n' + + 'class Metadata:\n __all__ = ["target", "renamed"]\n'; + const other = '__all__ = ["target", "renamed"]\ndef target(): return 99\n'; + await expectRename( + { 'model.py': decoys + functions, 'other.py': other }, + { + 'model.py': decoys + 'def renamed(): return 7\ndef run(): return renamed()\n', + 'other.py': other, + }, + 'target', + 'Function', + 'renamed', + 'model.py', + ); + }, 60_000); +}); diff --git a/gitnexus/test/unit/rename-python-members.test.ts b/gitnexus/test/unit/rename-python-members.test.ts new file mode 100644 index 000000000..6b76c4607 --- /dev/null +++ b/gitnexus/test/unit/rename-python-members.test.ts @@ -0,0 +1,379 @@ +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { renameSymbol } from '../../src/mcp/local/rename/rename-plan.js'; +import { createRenameFixtureHelpers } from '../helpers/rename-provider-fixture.js'; + +const { expectRename, fixture } = createRenameFixtureHelpers(); + +async function expectRefusal( + files: Record, + name: string, + kind: string, + newName: string, + code: string, + targetFile?: string, +) { + const { root, nodes, symbol } = await fixture(files, name, kind, targetFile); + for (const dry_run of [true, false]) { + expect( + await renameSymbol(root, symbol, { new_name: newName, dry_run }, nodes), + `${name} -> ${newName} dry_run=${dry_run}`, + ).toMatchObject({ + status: 'error', + planning_status: 'unsupported', + code, + total_edits: 0, + applied: false, + }); + for (const [file, content] of Object.entries(files)) + expect(await fs.readFile(path.join(root, file), 'utf8')).toBe(content); + } +} + +// The shared helper selects the first declaration; this one picks by zero-based start line. +async function expectRefusalAtLine( + files: Record, + name: string, + startLine: number, + newName: string, + code: string, +) { + const { root, nodes } = await fixture(files, name, 'Method'); + const target = nodes.find( + (node) => + node.label === 'Method' && + node.properties.name === name && + node.properties.startLine === startLine, + ); + expect(target, JSON.stringify(nodes.filter((node) => node.label === 'Method'))).toBeDefined(); + const symbol = { + uid: target!.id, + name, + filePath: target!.properties.filePath, + startLine: target!.properties.startLine! + 1, + endLine: target!.properties.endLine! + 1, + kind: 'Method', + }; + for (const dry_run of [true, false]) { + expect(await renameSymbol(root, symbol, { new_name: newName, dry_run }, nodes)).toMatchObject({ + status: 'error', + planning_status: 'unsupported', + code, + total_edits: 0, + applied: false, + }); + for (const [file, content] of Object.entries(files)) + expect(await fs.readFile(path.join(root, file), 'utf8')).toBe(content); + } +} + +describe('Python special-method protocol names', () => { + it.each([ + [ + '__len__ used through len()', + 'class Bag:\n def __len__(self):\n return 7\nprint(len(Bag()))\n', + '__len__', + 'Method', + 'renamed', + ], + [ + '__iter__ used by iteration', + 'class Bag:\n def __iter__(self):\n return iter([1])\nfor item in Bag():\n pass\n', + '__iter__', + 'Method', + 'walk', + ], + [ + '__init__ used by construction', + 'class Bag:\n def __init__(self):\n self.size = 1\nBag()\n', + '__init__', + 'Method', + 'build', + ], + [ + 'an ordinary method moved into __len__', + 'class Bag:\n def target(self):\n return 7\nprint(len(Bag()))\nBag().target()\n', + 'target', + 'Method', + '__len__', + ], + [ + 'an ordinary method moved into __iter__', + 'class Bag:\n def target(self):\n return iter([1])\nfor item in Bag():\n pass\nBag().target()\n', + 'target', + 'Method', + '__iter__', + ], + [ + 'an ordinary method moved into __init__', + 'class Bag:\n def target(self):\n return 7\nBag().target()\n', + 'target', + 'Method', + '__init__', + ], + [ + 'an ordinary method moved into a decomposed special name', + 'class Bag:\n def target(self):\n return 7\nBag().target()\n', + 'target', + 'Method', + '__len__', + ], + [ + 'a class property moved into __class__', + 'class Bag:\n size: int = 1\nBag().size\n', + 'size', + 'Property', + '__class__', + ], + ])( + 'refuses %s without changing any source', + async (_description, content, name, kind, newName) => { + await expectRefusal( + { 'model.py': content }, + name, + kind, + newName, + 'unsupported_symbol_family', + ); + }, + 60_000, + ); + + it('still renames an ordinary method beside an untouched special method', async () => { + await expectRename( + { + 'model.py': + 'class Bag:\n def target(self):\n return 7\n def __len__(self):\n return self.target()\nprint(len(Bag()))\nBag().target()\n', + }, + { + 'model.py': + 'class Bag:\n def renamed(self):\n return 7\n def __len__(self):\n return self.renamed()\nprint(len(Bag()))\nBag().renamed()\n', + }, + 'target', + 'Method', + 'renamed', + ); + }, 60_000); +}); + +describe('Python private-name mangling destinations', () => { + it.each([ + ['a double-underscore name', '__secret'], + ['a name with one trailing underscore', '__secret_'], + ['a compatibility-equivalent spelling', '__secret'], + ])( + 'refuses a public member renamed to %s', + async (_description, newName) => { + await expectRefusal( + { + 'model.py': + 'class Worker:\n def target(self):\n return 7\nworker = Worker()\nworker.target()\n', + }, + 'target', + 'Method', + newName, + 'unsupported_symbol_family', + ); + }, + 60_000, + ); + + it('refuses a public field renamed to a private name', async () => { + await expectRefusal( + { 'model.py': 'class Worker:\n size: int = 1\nWorker().size\n' }, + 'size', + 'Property', + '__size', + 'unsupported_symbol_family', + ); + }, 60_000); + + it('still renames a public member to a single-underscore name', async () => { + await expectRename( + { + 'model.py': + 'class Worker:\n def target(self):\n return 7\nworker = Worker()\nworker.target()\n', + }, + { + 'model.py': + 'class Worker:\n def _hidden(self):\n return 7\nworker = Worker()\nworker._hidden()\n', + }, + 'target', + 'Method', + '_hidden', + ); + }, 60_000); +}); + +describe('Python reflective access through inherited members', () => { + const classes = + 'class Base:\n def target(self):\n return 7\nclass Child(Base):\n pass\nworker = Child()\n'; + it.each([ + ['getattr', 'getattr(worker, "target")()\n'], + ['hasattr', 'hasattr(worker, "target")\n'], + ['setattr', 'setattr(worker, "target", lambda: 8)\n'], + ['delattr', 'delattr(worker, "target")\n'], + ])( + 'refuses %s of the selected name on a receiver inheriting the member', + async (_function, access) => { + await expectRefusal( + { 'model.py': classes + access }, + 'target', + 'Method', + 'renamed', + 'unsupported_occurrence', + ); + }, + 60_000, + ); + + it.each([ + ['getattr', 'getattr(worker, "renamed", None)\n'], + ['hasattr', 'hasattr(worker, "renamed")\n'], + ['setattr', 'setattr(worker, "renamed", 8)\n'], + ['delattr', 'delattr(worker, "renamed")\n'], + ])( + 'refuses %s of the destination name on a receiver inheriting the member', + async (_function, access) => { + await expectRefusal( + { 'model.py': classes + access }, + 'target', + 'Method', + 'renamed', + 'unsupported_occurrence', + ); + }, + 60_000, + ); + + it('refuses an annotated subclass receiver that inherits the selected member', async () => { + await expectRefusal( + { + 'model.py': + 'class Base:\n def target(self):\n return 7\nclass Child(Base):\n pass\n' + + 'def run(child: Child):\n return getattr(child, "target")()\n', + }, + 'target', + 'Method', + 'renamed', + 'unsupported_occurrence', + ); + }, 60_000); + + it('refuses a base-typed receiver that may hold an instance of the declaring subclass', async () => { + await expectRefusal( + { + 'model.py': + 'class Base:\n pass\nclass Child(Base):\n def target(self):\n return 7\n' + + 'def run(base: Base):\n return getattr(base, "target")()\n', + }, + 'target', + 'Method', + 'renamed', + 'unsupported_occurrence', + ); + }, 60_000); + + it('refuses a sibling receiver that inherits the base member of the selected override family', async () => { + await expectRefusalAtLine( + { + 'model.py': + 'class Base:\n def target(self):\n return 7\n' + + 'class Child(Base):\n def target(self):\n return 8\n' + + 'class Sibling(Base):\n pass\n' + + 'sibling = Sibling()\ngetattr(sibling, "target")()\n', + }, + 'target', + 4, + 'renamed', + 'unsupported_occurrence', + ); + }, 60_000); + + it.each([ + ['getattr', 'getattr(other, "target")()\n', 'getattr(other, "target")()\n'], + ['hasattr', 'hasattr(other, "target")\n', 'hasattr(other, "target")\n'], + ['setattr', 'setattr(other, "target", 8)\n', 'setattr(other, "target", 8)\n'], + ['delattr', 'delattr(other, "target")\n', 'delattr(other, "target")\n'], + [ + 'destination getattr', + 'getattr(other, "renamed", None)\n', + 'getattr(other, "renamed", None)\n', + ], + ['destination setattr', 'setattr(other, "renamed", 8)\n', 'setattr(other, "renamed", 8)\n'], + ])( + 'renames a base member beside %s on a proven unrelated receiver', + async (_function, access, preserved) => { + const unrelated = 'class Other:\n pass\nother = Other()\n'; + await expectRename( + { 'model.py': classes + unrelated + access }, + { + 'model.py': classes.replace('def target', 'def renamed') + unrelated + preserved, + }, + 'target', + 'Method', + 'renamed', + ); + }, + 60_000, + ); +}); + +describe('Python keyword argument labels naming a field', () => { + const dataclass = 'from dataclasses import dataclass\n@dataclass\nclass Point:\n x: int\n'; + it.each([ + ['a synthesized dataclass constructor', dataclass + 'point = Point(x=1)\n', 'x', 'renamed'], + [ + 'a subclass inheriting the synthesized constructor', + dataclass + 'class Child(Point):\n pass\nchild = Child(x=1)\n', + 'x', + 'renamed', + ], + [ + 'a dataclasses.replace() copy', + 'from dataclasses import dataclass, replace\n@dataclass\nclass Point:\n x: int\n' + + 'def move(point: Point):\n return replace(point, x=2)\n', + 'x', + 'renamed', + ], + [ + 'a destination label of a synthesized constructor', + dataclass.replace('x: int', 'size: int') + 'point = Point(x=1)\n', + 'size', + 'x', + ], + [ + 'an unresolved callee', + dataclass + 'import factory\npoint = factory.build(x=1)\n', + 'x', + 'renamed', + ], + ])( + 'refuses a label that may bind %s', + async (_description, content, name, newName) => { + await expectRefusal( + { 'model.py': content }, + name, + 'Property', + newName, + 'ambiguous_reference', + ); + }, + 60_000, + ); + + it('renames a field beside labels of calls proven to bind other parameters', async () => { + const calls = + 'def configure(x):\n return x\nconfigure(x=1)\n' + + 'class Runner:\n def run(self, x):\n return x\nRunner().run(x=3)\n' + + 'class Other:\n def __init__(self, x):\n self.value = x\nOther(x=2)\n'; + await expectRename( + { 'model.py': dataclass + calls }, + { 'model.py': dataclass.replace('x: int', 'renamed: int') + calls }, + 'x', + 'Property', + 'renamed', + ); + }, 60_000); +}); diff --git a/gitnexus/test/unit/rename-retention.test.ts b/gitnexus/test/unit/rename-retention.test.ts new file mode 100644 index 000000000..ec43f76bd --- /dev/null +++ b/gitnexus/test/unit/rename-retention.test.ts @@ -0,0 +1,172 @@ +/** Exercise rename dispatch and source policy with real metadata and semantic plans. */ +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'; + +const { lbugMocks } = vi.hoisted(() => ({ + lbugMocks: { + initLbug: vi.fn().mockResolvedValue(undefined), + executeQuery: vi.fn().mockResolvedValue([]), + executeParameterized: vi.fn().mockResolvedValue([]), + ensureVectorExtension: vi.fn().mockResolvedValue(true), + closeLbug: vi.fn().mockResolvedValue(undefined), + isLbugReady: vi.fn().mockReturnValue(true), + }, +})); + +vi.mock('../../src/core/lbug/pool-adapter.js', async (importOriginal) => ({ + ...(await importOriginal()), + ...lbugMocks, +})); +vi.mock('../../src/mcp/core/lbug-adapter.js', async (importOriginal) => ({ + ...(await importOriginal()), + ...lbugMocks, +})); +vi.mock('../../src/storage/repo-manager.js', async (importOriginal) => ({ + ...(await importOriginal()), + listRegisteredRepos: vi.fn().mockResolvedValue([]), +})); +vi.mock('../../src/core/git-staleness.js', () => ({ + checkStaleness: vi.fn().mockReturnValue({ isStale: false, commitsBehind: 0 }), + checkStalenessAsync: vi.fn().mockResolvedValue({ isStale: false, commitsBehind: 0 }), + checkCwdMatch: vi.fn().mockResolvedValue({ match: 'none' }), +})); + +import { LocalBackend } from '../../src/mcp/local/local-backend.js'; +import { listRegisteredRepos } from '../../src/storage/repo-manager.js'; + +const NAME = 'rename-source-policy'; +const INDEXED_AT = '2024-06-01T12:00:00Z'; +const FILE = 'target.ts'; +const UID = `Function:${FILE}:target`; +const SOURCE = 'function target() { return "retention-private-body"; }\ntarget();\n'; +const symbol = { + id: UID, + name: 'target', + type: 'Function', + filePath: FILE, + startLine: 0, + endLine: 0, +}; +// One public tool, three call shapes: dry_run omitted (default preview), true, false. +const routes = [ + { route: 'default preview', args: {}, applies: false }, + { route: 'dry run', args: { dry_run: true }, applies: false }, + { route: 'apply', args: { dry_run: false }, applies: true }, +] as const; + +describe('rename checkout source policy', () => { + let root: string; + let checkout: string; + let storage: string; + let backend: LocalBackend; + + const writeMeta = async (contentRetention?: string) => { + await fs.writeFile( + path.join(storage, 'gitnexus.json'), + JSON.stringify({ contentRetention, indexedAt: INDEXED_AT }), + ); + }; + + beforeEach(async () => { + vi.clearAllMocks(); + root = await fs.mkdtemp(path.join(os.tmpdir(), 'gnx-rename-retention-')); + checkout = path.join(root, 'checkout'); + storage = path.join(root, 'index'); + await fs.mkdir(checkout); + await fs.mkdir(path.join(storage, 'lbug'), { recursive: true }); + await writeMeta('full'); + await fs.writeFile(path.join(checkout, FILE), SOURCE); + vi.mocked(listRegisteredRepos).mockResolvedValue([ + { + name: NAME, + path: checkout, + storagePath: storage, + indexedAt: INDEXED_AT, + lastCommit: 'abc123', + } as Awaited>[number], + ]); + lbugMocks.executeQuery.mockResolvedValue([ + { id: `File:${FILE}`, name: FILE, filePath: FILE }, + symbol, + ]); + // Context, policy, parser, plan and apply run normally against the graph rows. + lbugMocks.executeParameterized.mockImplementation(async (_db, query) => + query.includes('MATCH (n {id: $uid})') || query.includes('WHERE n.name = $symName') + ? [symbol] + : [], + ); + backend = new LocalBackend(); + }); + + afterEach(async () => { + await backend.disconnect(); + await fs.rm(root, { recursive: true, force: true }); + }); + + const rename = ({ args }: (typeof routes)[number]) => + backend.callTool('rename', { repo: NAME, symbol_uid: UID, new_name: 'renamed', ...args }); + + const expectUnavailable = (result: any, reason: string) => { + expect(result).toMatchObject({ code: 'source-unavailable', reason }); + expect(Object.keys(result).sort()).toEqual(['code', 'error', 'reason']); + expect(result.changes).toBeUndefined(); + expect(JSON.stringify(result)).not.toContain('retention-private-body'); + expect(lbugMocks.executeQuery).not.toHaveBeenCalled(); + }; + + for (const retention of ['none', 'symbol', 'corrupt']) { + it.each(routes)( + `refuses $route source disclosure and writes with ${retention} retention`, + async (route) => { + await writeMeta(retention); + const result = await rename(route); + expectUnavailable(result, 'content-retention'); + expect(await fs.readFile(path.join(checkout, FILE), 'utf8')).toBe(SOURCE); + }, + ); + } + + it.each(routes)('permits $route under full retention with a live checkout', async (route) => { + const result = await rename(route); + expect(result.status).toBe('success'); + expect(result.total_edits).toBe(2); + expect(result.changes[0].edits[0].before).toContain('retention-private-body'); + expect(result.applied).toBe(route.applies); + expect(await fs.readFile(path.join(checkout, FILE), 'utf8')).toBe( + route.applies + ? 'function renamed() { return "retention-private-body"; }\nrenamed();\n' + : SOURCE, + ); + }); + + it.each(routes)('refuses $route when the full-retention checkout is missing', async (route) => { + await fs.rm(checkout, { recursive: true }); + expectUnavailable(await rename(route), 'checkout-missing'); + }); + + it('preserves missing-symbol argument validation under non-full retention', async () => { + await writeMeta('none'); + const result = await backend.callTool('rename', { repo: NAME, new_name: 'renamed' }); + expect(result.error).toContain('Either symbol_name or symbol_uid'); + expect(result.code).toBeUndefined(); + }); + + it('preserves symbol ambiguity without exposing source under non-full retention', async () => { + await writeMeta('none'); + lbugMocks.executeParameterized.mockResolvedValue([ + symbol, + { ...symbol, id: 'Function:other.ts:target', filePath: 'other.ts' }, + ]); + const result = await backend.callTool('rename', { + repo: NAME, + symbol_name: 'target', + new_name: 'renamed', + }); + expect(result.status).toBe('ambiguous'); + expect(result.candidates).toHaveLength(2); + expect(result.changes).toBeUndefined(); + expect(JSON.stringify(result)).not.toContain('retention-private-body'); + }); +}); diff --git a/gitnexus/test/unit/rename-typescript-member-keys.test.ts b/gitnexus/test/unit/rename-typescript-member-keys.test.ts new file mode 100644 index 000000000..f8afbfb39 --- /dev/null +++ b/gitnexus/test/unit/rename-typescript-member-keys.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { renameSymbol } from '../../src/mcp/local/rename/rename-plan.js'; +import { createRenameFixtureHelpers } from '../helpers/rename-provider-fixture.js'; + +const { fixture, expectRename } = createRenameFixtureHelpers(); +const fixtureRoot = fileURLToPath(new URL('../fixtures/rename-integration/', import.meta.url)); +const cases = [ + 'typescript/member-destructure-alias-refusal', + 'typescript/member-destructure-destination-refusal', + 'typescript/member-destructure-shorthand-refusal', + 'typescript/member-object-method-refusal', + 'typescript/member-object-pair-refusal', + 'typescript/member-keys-unrelated-owner', + 'typescript/inherited-computed-member-refusal', + 'javascript/member-destructure-refusal', + 'javascript/member-object-key-refusal', + 'vue/member-destructure-refusal', + 'vue/member-object-key-refusal', +]; + +async function sources(directory: string): Promise> { + return Object.fromEntries( + await Promise.all( + (await fs.readdir(directory)).map(async (file) => [ + file, + await fs.readFile(path.join(directory, file), 'utf8'), + ]), + ), + ); +} + +async function expectRefusal( + files: Record, + targetFile = 'model.ts', + code = 'ambiguous_reference', +) { + const { root, nodes, symbol } = await fixture(files, 'close', 'Method', targetFile); + for (const dry_run of [true, false]) { + expect(await renameSymbol(root, symbol, { new_name: 'finish', dry_run }, nodes)).toMatchObject({ + status: 'error', + code, + planning_status: 'unsupported', + applied: false, + }); + for (const [file, content] of Object.entries(files)) + expect(await fs.readFile(path.join(root, file), 'utf8')).toBe(content); + } +} + +describe('TypeScript-family member-key safety', () => { + it.each(cases)( + 'checks the real-source fixture %s', + async (name) => { + const directory = path.join(fixtureRoot, name); + const specification = JSON.parse( + await fs.readFile(path.join(directory, 'case.json'), 'utf8'), + ); + const before = await sources(path.join(directory, 'before')); + if (specification.expect.status === 'success') { + await expectRename( + before, + await sources(path.join(directory, 'after')), + 'close', + 'Method', + 'finish', + specification.target.file, + ); + } else await expectRefusal(before, specification.target.file, specification.expect.code); + }, + 60_000, + ); + + it.each([ + 'const { close = fallback } = writer;', + 'let alias; ({ close: alias } = writer);', + 'const { nested: { close: alias } } = unknown;', + 'const { "close": alias } = writer;', + 'const { [key]: alias } = writer;', + 'const backup = { "close": () => 1 };', + String.raw`const backup = { "cl\u006fse": () => 1 };`, + 'const backup = { [key]: () => 1 };', + 'const finish = () => 1; const backup = { finish };', + ])( + 'refuses unproven key syntax: %s', + async (statement) => { + const content = + 'export class Writer { close() { return 1; } }\n' + + 'const writer = new Writer();\n' + + statement + + '\nwriter.close();\n'; + await expectRefusal({ 'model.ts': content }); + }, + 60_000, + ); +}); diff --git a/gitnexus/test/unit/rename-vue-providers.test.ts b/gitnexus/test/unit/rename-vue-providers.test.ts new file mode 100644 index 000000000..89db25670 --- /dev/null +++ b/gitnexus/test/unit/rename-vue-providers.test.ts @@ -0,0 +1,477 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js'; +import { renameSymbol } from '../../src/mcp/local/rename/rename-plan.js'; + +const roots: string[] = []; +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true }))); +}); + +async function fixture( + files: Record, + name = 'target', + kind = 'Function', + file = 'App.vue', +) { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'rename-vue-')); + roots.push(root); + await Promise.all( + Object.entries(files).map(([name, content]) => fs.writeFile(path.join(root, name), content)), + ); + const result = await runPipelineFromRepo(root, () => undefined, { + workerPoolSize: 1, + skipGraphPhases: true, + }); + const nodes = [...result.graph.iterNodes()]; + const target = nodes.find( + (node) => + node.properties.name === name && node.label === kind && node.properties.filePath === file, + ); + expect(target, JSON.stringify(nodes)).toBeDefined(); + const symbol = { + uid: target!.id, + name: target!.properties.name, + filePath: file, + kind, + startLine: target!.properties.startLine! + 1, + endLine: target!.properties.endLine! + 1, + }; + return { root, nodes, symbol }; +} + +async function expectRename( + files: Record, + expected: Record, + name = 'target', + kind = 'Function', + file = 'App.vue', + newName = 'renamed', +) { + const { root, nodes, symbol } = await fixture(files, name, kind, file); + const preview = await renameSymbol(root, symbol, { new_name: newName, dry_run: true }, nodes); + expect(preview, JSON.stringify(preview)).toMatchObject({ status: 'success', applied: false }); + for (const [name, content] of Object.entries(files)) + expect(await fs.readFile(path.join(root, name), 'utf8')).toBe(content); + const applied = await renameSymbol(root, symbol, { new_name: newName, dry_run: false }, nodes); + expect(applied, JSON.stringify(applied)).toMatchObject({ + status: 'success', + applied: true, + changes: preview.changes, + }); + for (const [name, content] of Object.entries(expected)) + expect(await fs.readFile(path.join(root, name), 'utf8')).toBe(content); +} + +describe('Vue semantic rename through the production graph pipeline', () => { + it('keeps custom raw blocks opaque until their exact escaped closing tag', async () => { + const raw = + '\nLiteral text.\n\n\n'; + await expectRename( + { + 'App.vue': + '\n\n' + + raw, + }, + { + 'App.vue': + '\n\n' + + raw, + }, + ); + }, 60_000); + + it.each(['target', "'target()'", '"target()"', 'target()'])( + 'renames executable CSS v-bind(%s) at original offsets', + async (binding) => { + const content = `\n\n`; + await expectRename( + { 'App.vue': content }, + { 'App.vue': content.replaceAll('target', 'renamed') }, + ); + }, + 60000, + ); + + it('resolves CSS bindings before setup while preserving CSS comments, strings, and template locals', async () => { + const content = `\r\n\r\n\r\n`; + await expectRename( + { 'App.vue': content }, + { + 'App.vue': content + .replace('v-bind( target )', 'v-bind( renamed )') + .replace('const target', 'const renamed'), + }, + 'target', + 'Const', + ); + }, 60000); + + it('preserves JavaScript string literals and nested parentheses inside quoted CSS expressions', async () => { + const content = `\n\n`; + await expectRename( + { 'App.vue': content }, + { + 'App.vue': content + .replace('function target', 'function renamed') + .replace('target("target )")', 'renamed("target )")'), + }, + ); + }, 60000); + + it('renames imported CSS calls across the semantic source cohort', async () => { + const files = { + 'api.ts': 'export function target() { return "red"; }\n', + 'App.vue': `\n\n`, + }; + await expectRename( + files, + { + 'api.ts': files['api.ts'].replace('target', 'renamed'), + 'App.vue': files['App.vue'].replaceAll('target', 'renamed'), + }, + 'target', + 'Function', + 'api.ts', + ); + }, 60000); + + it.each([ + ['unmapped dialect', ''], + ['external source', '`], + ['unquoted template literal', ''], + ['unclosed binding', ''], + ['invalid expression', ""], + ['callback capture', ""], + ])( + 'refuses CSS %s before applying any edits', + async (_label, style) => { + const content = `\n${style}\n`; + const { root, nodes, symbol } = await fixture({ 'App.vue': content }); + for (const dry_run of [true, false]) { + const result = await renameSymbol(root, symbol, { new_name: 'renamed', dry_run }, nodes); + expect(result, JSON.stringify(result)).toMatchObject({ + status: 'error', + applied: false, + total_edits: 0, + }); + expect(await fs.readFile(path.join(root, 'App.vue'), 'utf8')).toBe(content); + } + }, + 60000, + ); + + it('preserves opaque custom block content containing markup examples', async () => { + const content = + '\n\n\n'; + await expectRename( + { 'App.vue': content }, + { + 'App.vue': content + .replace('function target()', 'function renamed()') + .replace('\n