mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-14 04:08:05 +00:00
fix(rename): extend safe refactoring and flow coverage across languages (#3557)
This commit is contained in:
parent
61c9a894a4
commit
8fa8e64388
1222 changed files with 40339 additions and 1525 deletions
|
|
@ -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` |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
11
.github/workflows/ci-tests.yml
vendored
11
.github/workflows/ci-tests.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
5
.github/workflows/docker.yml
vendored
5
.github/workflows/docker.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: "@<group>"`) 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`.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
60
README.md
60
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
|
|||
<details>
|
||||
<summary><strong>MCP read-only mode</strong></summary>
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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` |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
57
gitnexus-shared/src/rename/edits.ts
Normal file
57
gitnexus-shared/src/rename/edits.ts
Normal file
|
|
@ -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 };
|
||||
}
|
||||
125
gitnexus-shared/src/rename/family.ts
Normal file
125
gitnexus-shared/src/rename/family.ts
Normal file
|
|
@ -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<string, readonly SymbolDefinition[]>;
|
||||
readonly relationships: Iterable<{ type: string; sourceId: string; targetId: string }>;
|
||||
readonly incompleteOwners: ReadonlySet<string>;
|
||||
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<readonly [SymbolDefinition, SymbolDefinition]> = [];
|
||||
const uncertain = new Set<string>();
|
||||
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.',
|
||||
};
|
||||
}
|
||||
48
gitnexus-shared/src/rename/positions.ts
Normal file
48
gitnexus-shared/src/rename/positions.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
41
gitnexus-shared/src/rename/results.ts
Normal file
41
gitnexus-shared/src/rename/results.ts
Normal file
|
|
@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
111
gitnexus-shared/src/rename/types.ts
Normal file
111
gitnexus-shared/src/rename/types.ts
Normal file
|
|
@ -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<string, string>;
|
||||
edits: OccurrenceEdit[];
|
||||
}
|
||||
|
||||
export interface ReportedEdit extends Omit<OccurrenceEdit, 'file_path'> {
|
||||
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;
|
||||
}
|
||||
|
|
@ -1497,7 +1497,7 @@ const EXPORTS_BY_NAME = new WeakMap<
|
|||
ReadonlyMap<string, SymbolDefinition>
|
||||
>();
|
||||
|
||||
function indexExportsByName(
|
||||
export function indexExportsByName(
|
||||
defs: readonly SymbolDefinition[],
|
||||
): ReadonlyMap<string, SymbolDefinition> {
|
||||
const cached = EXPORTS_BY_NAME.get(defs);
|
||||
|
|
@ -1536,7 +1536,7 @@ const TOP_LEVEL_EXPORTS_BY_NAME = new WeakMap<
|
|||
ReadonlyMap<string, SymbolDefinition>
|
||||
>();
|
||||
|
||||
function indexTopLevelExportsByName(
|
||||
export function indexTopLevelExportsByName(
|
||||
defs: readonly SymbolDefinition[],
|
||||
): ReadonlyMap<string, SymbolDefinition> {
|
||||
const cached = TOP_LEVEL_EXPORTS_BY_NAME.get(defs);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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_<reason>` 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."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "<null>"
|
||||
}
|
||||
},
|
||||
"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": "<null>"
|
||||
}
|
||||
},
|
||||
"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."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
"<<unclassified>>": 11,
|
||||
"chain-mixed": 2,
|
||||
"chain-unwrap": 1
|
||||
},
|
||||
"callDropsByOrigin": {
|
||||
"in-program": 56,
|
||||
"in-program": 54,
|
||||
"external": 44,
|
||||
"unknown": 29
|
||||
}
|
||||
|
|
|
|||
47
gitnexus/bench/rename/README.md
Normal file
47
gitnexus/bench/rename/README.md
Normal file
|
|
@ -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.
|
||||
179
gitnexus/bench/rename/measure.mjs
Normal file
179
gitnexus/bench/rename/measure.mjs
Normal file
|
|
@ -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 });
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"_comment": "Per-language baselines for bench/scope-capture/measure.mjs --check. fingerprint = order-independent sha256 over the lang-resolution/<lang>-* 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 <file>), 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 <file>), 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<User>` (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<Entity_n> repo;` (bare template_type) and `std::vector<Entity_n> items;` (qualified_identifier wrapping a template_type) \u2014 plus the header declaring `template <typename T> 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<String> 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<String>`) \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<T>()`) 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<GuestPayload>(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<AdminPayload>(token, ...)` (admin.ts/auth.ts) KEEPS `direct-callee-name|verifyToken`: tree-sitter-typescript parses `await f<T>(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.",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { Writer } from './writer';
|
||||
export function useImported(writer: Writer): void {
|
||||
export function useImported(): void {
|
||||
const writer = new Writer();
|
||||
writer.close();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
export class DeferredWriter {
|
||||
flush(): void {}
|
||||
}
|
||||
export function flushDeferred(writer: DeferredWriter): void {
|
||||
writer.flush();
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<string>();
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -44,6 +44,27 @@
|
|||
* partition depend on the very machine load it is trying to protect against.
|
||||
*/
|
||||
export const WINDOWS_WEIGHTS_SEC: Readonly<Record<string, number>> = {
|
||||
// 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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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` |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<string, DependencyDirectoryGuard>,
|
||||
pathGuards: Map<string, DependencyPathGuardResult>,
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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++;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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<string>,
|
||||
) {
|
||||
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;
|
||||
|
|
|
|||
35
gitnexus/src/core/ingestion/cfg/visitors/objective-c-ast.ts
Normal file
35
gitnexus/src/core/ingestion/cfg/visitors/objective-c-ast.ts
Normal file
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
161
gitnexus/src/core/ingestion/cfg/visitors/objective-c-harvest.ts
Normal file
161
gitnexus/src/core/ingestion/cfg/visitors/objective-c-harvest.ts
Normal file
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
319
gitnexus/src/core/ingestion/cfg/visitors/objective-c.ts
Normal file
319
gitnexus/src/core/ingestion/cfg/visitors/objective-c.ts
Normal file
|
|
@ -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<string, GotoContext>();
|
||||
private readonly gotoSources = new Map<string, GotoContext[]>();
|
||||
private readonly exceptionRouted = new Set<number>();
|
||||
private cleanupLabels: ReadonlyMap<string, string> = 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<SyntaxNode> {
|
||||
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());
|
||||
},
|
||||
};
|
||||
}
|
||||
93
gitnexus/src/core/ingestion/cfg/visitors/zig-ast.ts
Normal file
93
gitnexus/src/core/ingestion/cfg/visitors/zig-ast.ts
Normal file
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
347
gitnexus/src/core/ingestion/cfg/visitors/zig-harvest.ts
Normal file
347
gitnexus/src/core/ingestion/cfg/visitors/zig-harvest.ts
Normal file
|
|
@ -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<number, number>;
|
||||
|
||||
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),
|
||||
});
|
||||
}
|
||||
}
|
||||
566
gitnexus/src/core/ingestion/cfg/visitors/zig.ts
Normal file
566
gitnexus/src/core/ingestion/cfg/visitors/zig.ts
Normal file
|
|
@ -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<number, number>();
|
||||
private readonly controlCache = new Map<number, boolean>();
|
||||
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<number, ZigResultSink>();
|
||||
|
||||
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<number, number>();
|
||||
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<SyntaxNode> {
|
||||
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());
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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<SyntaxNode>;
|
||||
|
||||
/** 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
|
||||
|
|
|
|||
|
|
@ -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<LanguageProvider['labelOverride']> = (
|
|||
};
|
||||
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
220
gitnexus/src/core/ingestion/languages/c/rename.ts
Normal file
220
gitnexus/src/core/ingestion/languages/c/rename.ts
Normal file
|
|
@ -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<string, RenameSource['parsed']['scopes'][number]['nameClaims']>();
|
||||
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))),
|
||||
};
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<string, Capture>): 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<string, Capture> = {
|
||||
'@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<string, Capture> = {
|
||||
'@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<string, Capture> = {
|
||||
'@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<string, Capture> = {
|
||||
'@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<string, Capture> = {
|
||||
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<string, Capture> = {
|
||||
'@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<string, Capture> = {
|
||||
'@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<string, Capture> = {
|
||||
'@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;
|
||||
}
|
||||
|
|
|
|||
289
gitnexus/src/core/ingestion/languages/cobol/flow-harvest.ts
Normal file
289
gitnexus/src/core/ingestion/languages/cobol/flow-harvest.ts
Normal file
|
|
@ -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<string, number>();
|
||||
|
||||
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],
|
||||
};
|
||||
}
|
||||
}
|
||||
248
gitnexus/src/core/ingestion/languages/cobol/flow-source.ts
Normal file
248
gitnexus/src/core/ingestion/languages/cobol/flow-source.ts
Normal file
|
|
@ -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<string>, 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 };
|
||||
}
|
||||
}
|
||||
499
gitnexus/src/core/ingestion/languages/cobol/flow.ts
Normal file
499
gitnexus/src/core/ingestion/languages/cobol/flow.ts
Normal file
|
|
@ -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<CobolStatement, { kind: 'perform' }>,
|
||||
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<string, State>();
|
||||
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<string>();
|
||||
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<number, CobolToken[]>();
|
||||
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 } };
|
||||
}
|
||||
211
gitnexus/src/core/ingestion/languages/cobol/rename.ts
Normal file
211
gitnexus/src/core/ingestion/languages/cobol/rename.ts
Normal file
|
|
@ -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<RenameSource, CobolSemanticSource>();
|
||||
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);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
471
gitnexus/src/core/ingestion/languages/cobol/semantic.ts
Normal file
471
gitnexus/src/core/ingestion/languages/cobol/semantic.ts
Normal file
|
|
@ -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<string>();
|
||||
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<string>(declared);
|
||||
const addReference = (
|
||||
chain: readonly CobolToken[],
|
||||
kind: CobolReference['kind'],
|
||||
program = programAt(chain[0]!.line),
|
||||
extra?: Pick<CobolReference, 'isCall' | 'localProgram'>,
|
||||
) => {
|
||||
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<Program | undefined, number>();
|
||||
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)),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
|
@ -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<ScopeId, Map<string, BindingRef[]>>;
|
||||
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:
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
256
gitnexus/src/core/ingestion/languages/cpp/rename.ts
Normal file
256
gitnexus/src/core/ingestion/languages/cpp/rename.ts
Normal file
|
|
@ -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<string>();
|
||||
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<string, string>();
|
||||
const writtenOwners = new Map<string, string>();
|
||||
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))),
|
||||
};
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<string> = 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),
|
||||
|
|
|
|||
11
gitnexus/src/core/ingestion/languages/csharp/identifiers.ts
Normal file
11
gitnexus/src/core/ingestion/languages/csharp/identifiers.ts
Normal file
|
|
@ -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;
|
||||
},
|
||||
);
|
||||
|
|
@ -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
|
||||
|
|
|
|||
156
gitnexus/src/core/ingestion/languages/csharp/rename.ts
Normal file
156
gitnexus/src/core/ingestion/languages/csharp/rename.ts
Normal file
|
|
@ -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)),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
@ -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<string, readonly string[]>
|
||||
>();
|
||||
|
||||
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<string, Set<string>>();
|
||||
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<string>();
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -291,7 +291,10 @@ export function emitDartScopeCaptures(
|
|||
// ── Pass B: tree-walked references, type inference, heritage ────────────
|
||||
const seenReadSpans = new Set<string>();
|
||||
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<string>,
|
||||
): 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),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
166
gitnexus/src/core/ingestion/languages/dart/rename.ts
Normal file
166
gitnexus/src/core/ingestion/languages/dart/rename.ts
Normal file
|
|
@ -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)),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { goRenameProvider } from './go/rename.js';
|
||||
/**
|
||||
* Go Language Provider
|
||||
*
|
||||
|
|
@ -91,6 +92,7 @@ const GO_BUILT_INS: ReadonlySet<string> = new Set([
|
|||
]);
|
||||
|
||||
export const goProvider = defineLanguage({
|
||||
rename: goRenameProvider,
|
||||
id: SupportedLanguages.Go,
|
||||
extensions: ['.go'],
|
||||
entryPointPatterns: [/Handler$/, /^Serve/, /^New[A-Z]/, /^Make[A-Z]/],
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
239
gitnexus/src/core/ingestion/languages/go/rename.ts
Normal file
239
gitnexus/src/core/ingestion/languages/go/rename.ts
Normal file
|
|
@ -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<string, NameClaim[]>();
|
||||
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<RenameSource, 'path' | 'content'>;
|
||||
|
||||
const packageKeys = new WeakMap<GoSourceFile, string | undefined>();
|
||||
|
||||
/**
|
||||
* 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)),
|
||||
};
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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$/],
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
212
gitnexus/src/core/ingestion/languages/java/rename.ts
Normal file
212
gitnexus/src/core/ingestion/languages/java/rename.ts
Normal file
|
|
@ -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)),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue