Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb5270c260 | ||
|
|
604b575e4b | ||
|
|
02dfab578c | ||
|
|
1326490a5b | ||
|
|
b48cfe9894 | ||
|
|
c1703fc0a9 | ||
|
|
480fae933b | ||
|
|
3879490817 | ||
|
|
50dbd03779 | ||
|
|
1003d8b6a5 | ||
|
|
74b9701509 | ||
|
|
f0132c1077 | ||
|
|
f6b92d4f13 | ||
|
|
64b7ff0061 |
@@ -85,14 +85,17 @@ jobs:
|
||||
# for fork PRs. Create a temporary branch ref via the API so the action
|
||||
# can find it. Using the API (not git push) avoids the GITHUB_TOKEN
|
||||
# restriction that blocks pushing commits containing workflow file changes.
|
||||
# Use a prefixed temporary branch name to avoid overwriting real branches
|
||||
# (e.g. a fork branch named "main" would overwrite origin/main).
|
||||
- name: Create fork branch ref on origin
|
||||
id: push-fork
|
||||
if: steps.pr.outputs.is_fork == 'true'
|
||||
env:
|
||||
FORK_BRANCH: ${{ steps.pr.outputs.branch }}
|
||||
FORK_BRANCH: claude-tmp/fork-pr-${{ steps.pr.outputs.number }}
|
||||
FORK_SHA: ${{ steps.pr.outputs.sha }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
echo "FORK_BRANCH=$FORK_BRANCH" >> "$GITHUB_ENV"
|
||||
gh api "repos/${{ github.repository }}/git/refs" \
|
||||
--method POST \
|
||||
-f ref="refs/heads/$FORK_BRANCH" \
|
||||
@@ -116,6 +119,5 @@ jobs:
|
||||
- name: Delete fork branch ref from origin
|
||||
if: always() && steps.push-fork.outcome == 'success'
|
||||
env:
|
||||
FORK_BRANCH: ${{ steps.pr.outputs.branch }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: gh api "repos/${{ github.repository }}/git/refs/heads/$FORK_BRANCH" --method DELETE || true
|
||||
|
||||
@@ -64,6 +64,7 @@ jobs:
|
||||
const isFork = pr.head.repo.full_name !== pr.base.repo.full_name;
|
||||
|
||||
core.setOutput('is_pr', 'true');
|
||||
core.setOutput('number', String(prNumber));
|
||||
core.setOutput('is_fork', String(isFork));
|
||||
core.setOutput('branch', pr.head.ref);
|
||||
core.setOutput('sha', pr.head.sha);
|
||||
@@ -78,14 +79,17 @@ jobs:
|
||||
# for fork PRs. Create a temporary branch ref via the API so the action
|
||||
# can find it. Using the API (not git push) avoids the GITHUB_TOKEN
|
||||
# restriction that blocks pushing commits containing workflow file changes.
|
||||
# Use a prefixed temporary branch name to avoid overwriting real branches
|
||||
# (e.g. a fork branch named "main" would overwrite origin/main).
|
||||
- name: Create fork branch ref on origin
|
||||
id: push-fork
|
||||
if: steps.pr.outputs.is_fork == 'true'
|
||||
env:
|
||||
FORK_BRANCH: ${{ steps.pr.outputs.branch }}
|
||||
FORK_BRANCH: claude-tmp/fork-pr-${{ steps.pr.outputs.number }}
|
||||
FORK_SHA: ${{ steps.pr.outputs.sha }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
echo "FORK_BRANCH=$FORK_BRANCH" >> "$GITHUB_ENV"
|
||||
gh api "repos/${{ github.repository }}/git/refs" \
|
||||
--method POST \
|
||||
-f ref="refs/heads/$FORK_BRANCH" \
|
||||
@@ -110,6 +114,5 @@ jobs:
|
||||
- name: Delete fork branch ref from origin
|
||||
if: always() && steps.push-fork.outcome == 'success'
|
||||
env:
|
||||
FORK_BRANCH: ${{ steps.pr.outputs.branch }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: gh api "repos/${{ github.repository }}/git/refs/heads/$FORK_BRANCH" --method DELETE || true
|
||||
|
||||
+3
-1
@@ -67,4 +67,6 @@ gitnexus/test/fixtures/mini-repo/.gitignore
|
||||
# Ignore csharp generated obj and bin folders
|
||||
gitnexus/test/fixtures/lang-resolution/**/obj
|
||||
gitnexus/test/fixtures/lang-resolution/**/bin
|
||||
GitNexus.sln
|
||||
GitNexus.sln
|
||||
# Git worktrees
|
||||
.worktrees/
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globalSetup: ['test/global-setup.ts'],
|
||||
include: ['test/**/*.test.ts'],
|
||||
testTimeout: 30000,
|
||||
hookTimeout: 120000,
|
||||
pool: 'forks',
|
||||
globals: true,
|
||||
setupFiles: ['test/setup.ts'],
|
||||
teardownTimeout: 3000,
|
||||
dangerouslyIgnoreUnhandledErrors: true, // LadybugDB N-API destructor segfaults on fork exit — not a test failure
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
include: ['src/**/*.ts'],
|
||||
exclude: [
|
||||
'src/cli/index.ts', // CLI entry point (commander wiring)
|
||||
'src/server/**', // HTTP server (requires network)
|
||||
'src/core/wiki/**', // Wiki generation (requires LLM)
|
||||
],
|
||||
// Auto-ratchet: vitest bumps thresholds when coverage exceeds them.
|
||||
// CI will fail if a PR drops below these floors.
|
||||
thresholds: {
|
||||
statements: 26,
|
||||
branches: 23,
|
||||
functions: 28,
|
||||
lines: 27,
|
||||
autoUpdate: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
<!-- gitnexus:start -->
|
||||
# GitNexus — Code Intelligence
|
||||
|
||||
This project is indexed by GitNexus as **GitNexus** (1999 symbols, 4681 relationships, 149 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
This project is indexed by GitNexus as **feat-phase7-type-resolution** (2075 symbols, 4935 relationships, 157 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
|
||||
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
||||
|
||||
@@ -17,7 +17,7 @@ This project is indexed by GitNexus as **GitNexus** (1999 symbols, 4681 relation
|
||||
|
||||
1. `gitnexus_query({query: "<error or symptom>"})` — find execution flows related to the issue
|
||||
2. `gitnexus_context({name: "<suspect function>"})` — see all callers, callees, and process participation
|
||||
3. `READ gitnexus://repo/GitNexus/process/{processName}` — trace the full execution flow step by step
|
||||
3. `READ gitnexus://repo/feat-phase7-type-resolution/process/{processName}` — trace the full execution flow step by step
|
||||
4. For regressions: `gitnexus_detect_changes({scope: "compare", base_ref: "main"})` — see what your branch changed
|
||||
|
||||
## When Refactoring
|
||||
@@ -56,10 +56,10 @@ This project is indexed by GitNexus as **GitNexus** (1999 symbols, 4681 relation
|
||||
|
||||
| Resource | Use for |
|
||||
|----------|---------|
|
||||
| `gitnexus://repo/GitNexus/context` | Codebase overview, check index freshness |
|
||||
| `gitnexus://repo/GitNexus/clusters` | All functional areas |
|
||||
| `gitnexus://repo/GitNexus/processes` | All execution flows |
|
||||
| `gitnexus://repo/GitNexus/process/{name}` | Step-by-step execution trace |
|
||||
| `gitnexus://repo/feat-phase7-type-resolution/context` | Codebase overview, check index freshness |
|
||||
| `gitnexus://repo/feat-phase7-type-resolution/clusters` | All functional areas |
|
||||
| `gitnexus://repo/feat-phase7-type-resolution/processes` | All execution flows |
|
||||
| `gitnexus://repo/feat-phase7-type-resolution/process/{name}` | Step-by-step execution trace |
|
||||
|
||||
## Self-Check Before Finishing
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<!-- gitnexus:start -->
|
||||
# GitNexus — Code Intelligence
|
||||
|
||||
This project is indexed by GitNexus as **GitNexus** (1999 symbols, 4681 relationships, 149 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
This project is indexed by GitNexus as **feat-phase7-type-resolution** (2075 symbols, 4935 relationships, 157 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
|
||||
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
||||
|
||||
@@ -17,7 +17,7 @@ This project is indexed by GitNexus as **GitNexus** (1999 symbols, 4681 relation
|
||||
|
||||
1. `gitnexus_query({query: "<error or symptom>"})` — find execution flows related to the issue
|
||||
2. `gitnexus_context({name: "<suspect function>"})` — see all callers, callees, and process participation
|
||||
3. `READ gitnexus://repo/GitNexus/process/{processName}` — trace the full execution flow step by step
|
||||
3. `READ gitnexus://repo/feat-phase7-type-resolution/process/{processName}` — trace the full execution flow step by step
|
||||
4. For regressions: `gitnexus_detect_changes({scope: "compare", base_ref: "main"})` — see what your branch changed
|
||||
|
||||
## When Refactoring
|
||||
@@ -56,10 +56,10 @@ This project is indexed by GitNexus as **GitNexus** (1999 symbols, 4681 relation
|
||||
|
||||
| Resource | Use for |
|
||||
|----------|---------|
|
||||
| `gitnexus://repo/GitNexus/context` | Codebase overview, check index freshness |
|
||||
| `gitnexus://repo/GitNexus/clusters` | All functional areas |
|
||||
| `gitnexus://repo/GitNexus/processes` | All execution flows |
|
||||
| `gitnexus://repo/GitNexus/process/{name}` | Step-by-step execution trace |
|
||||
| `gitnexus://repo/feat-phase7-type-resolution/context` | Codebase overview, check index freshness |
|
||||
| `gitnexus://repo/feat-phase7-type-resolution/clusters` | All functional areas |
|
||||
| `gitnexus://repo/feat-phase7-type-resolution/processes` | All execution flows |
|
||||
| `gitnexus://repo/feat-phase7-type-resolution/process/{name}` | Step-by-step execution trace |
|
||||
|
||||
## Self-Check Before Finishing
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ https://github.com/user-attachments/assets/172685ba-8e54-4ea7-9ad1-e31a3398da72
|
||||
| | **CLI + MCP** | **Web UI** |
|
||||
| ----------------- | -------------------------------------------------------------- | ------------------------------------------------------------ |
|
||||
| **What** | Index repos locally, connect AI agents via MCP | Visual graph explorer + AI chat in browser |
|
||||
| **For** | Daily development with Cursor, Claude Code, Windsurf, OpenCode | Quick exploration, demos, one-off analysis |
|
||||
| **For** | Daily development with Cursor, Claude Code, Windsurf, OpenCode, Codex | Quick exploration, demos, one-off analysis |
|
||||
| **Scale** | Full repos, any size | Limited by browser memory (~5k files), or unlimited via backend mode |
|
||||
| **Install** | `npm install -g gitnexus` | No install —[gitnexus.vercel.app](https://gitnexus.vercel.app) |
|
||||
| **Storage** | LadybugDB native (fast, persistent) | LadybugDB WASM (in-memory, per session) |
|
||||
@@ -86,6 +86,7 @@ To configure MCP for your editor, run `npx gitnexus setup` once — or set it up
|
||||
| **Cursor** | Yes | Yes | — | MCP + Skills |
|
||||
| **Windsurf** | Yes | — | — | MCP |
|
||||
| **OpenCode** | Yes | Yes | — | MCP + Skills |
|
||||
| **Codex** | Yes | — | — | MCP |
|
||||
|
||||
> **Claude Code** gets the deepest integration: MCP tools + agent skills + PreToolUse hooks that enrich searches with graph context + PostToolUse hooks that auto-reindex after commits.
|
||||
|
||||
@@ -129,6 +130,14 @@ claude mcp add gitnexus -- npx -y gitnexus@latest mcp
|
||||
}
|
||||
```
|
||||
|
||||
**Codex** (`~/.codex/config.toml` for system scope, or `.codex/config.toml` for project scope):
|
||||
|
||||
```toml
|
||||
[mcp_servers.gitnexus]
|
||||
command = "npx"
|
||||
args = ["-y", "gitnexus@latest", "mcp"]
|
||||
```
|
||||
|
||||
### CLI Commands
|
||||
|
||||
```bash
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
---
|
||||
review_agents: [kieran-typescript-reviewer, pattern-recognition-specialist, architecture-strategist, data-integrity-guardian, security-sentinel, performance-oracle, code-simplicity-reviewer]
|
||||
plan_review_agents: [kieran-typescript-reviewer, architecture-strategist, code-simplicity-reviewer]
|
||||
voltagent_agents: [voltagent-lang:typescript-pro, voltagent-qa-sec:security-auditor, voltagent-data-ai:database-optimizer]
|
||||
---
|
||||
|
||||
# Review Context
|
||||
|
||||
## Project Overview
|
||||
GitNexus is a code intelligence tool that builds a knowledge graph from source code using tree-sitter AST parsing across 12 languages and KuzuDB for graph storage. Two packages: `gitnexus/` (CLI/MCP, TypeScript) and `gitnexus-web/` (browser).
|
||||
|
||||
## Cross-Language Pattern Consistency (pattern-recognition-specialist)
|
||||
- 12 language-specific type extractors in `gitnexus/src/core/ingestion/type-extractors/` must follow identical patterns for: async unwrapping, constructor binding, namespace handling, nullable type stripping, for-loop element typing.
|
||||
- Past bugs: C#/Rust missing `await_expression` unwrapping that TypeScript handled correctly; PHP backslash namespace splitting inconsistent with other languages' `::` / `.` splitting.
|
||||
- When reviewing type extractor changes, verify the same pattern exists in ALL applicable language files — asymmetry is the #1 source of bugs.
|
||||
|
||||
## Data Integrity (data-integrity-guardian)
|
||||
- KuzuDB graph operations: schema in `gitnexus/src/core/kuzu/schema.ts`, adapter in `kuzu-adapter.ts`.
|
||||
- The ingestion pipeline writes symbols and relationships to the graph — changes to node/relation schemas or the ingestion pipeline can corrupt the index.
|
||||
- Known issue: KuzuDB `close()` hangs on Linux due to C++ destructor — use `detachKuzu()` pattern.
|
||||
- `lbug-adapter.ts` fallback path needs quote/newline escaping for Cypher injection prevention.
|
||||
|
||||
## Security (security-sentinel)
|
||||
- Cypher query construction in `lbug-adapter.ts` and `kuzu-adapter.ts` — watch for injection via unescaped user-provided symbol names.
|
||||
- CLI accepts `--repo` parameter and file paths — validate against path traversal.
|
||||
- MCP server exposes tools to external AI agents — all tool inputs are untrusted.
|
||||
|
||||
## Performance (performance-oracle)
|
||||
- Tree-sitter buffer size is adaptive (512KB–32MB) via `getTreeSitterBufferSize()` in `constants.ts`.
|
||||
- The ingestion pipeline processes entire repositories — O(n) per file with potential O(n²) in cross-file resolution.
|
||||
- KuzuDB batch inserts vs individual inserts matter for large repos.
|
||||
|
||||
## Architecture (architecture-strategist)
|
||||
- Ingestion pipeline phases: structure → parsing → imports → calls → heritage → processes → type resolution.
|
||||
- Shared modules: `export-detection.ts`, `constants.ts`, `utils.ts` — changes here have wide blast radius.
|
||||
- `gitnexus-web` package drifts behind CLI — flag if a change should be mirrored.
|
||||
|
||||
## Voltagent Supplementary Agents
|
||||
|
||||
Invoke these via the Agent tool alongside `/ce:review` for deeper specialist analysis. These cover gaps that compound-engineering agents don't:
|
||||
|
||||
### voltagent-lang:typescript-pro
|
||||
**When:** Changes touch type-resolution logic, generics, conditional types, or complex type-level programming in `type-env.ts`, `type-extractors/*.ts`, or `types.ts`.
|
||||
**Why:** The type resolution system uses advanced TypeScript patterns (discriminated unions, mapped types, recursive generics) that benefit from deep TS type-system review beyond what kieran-typescript-reviewer covers.
|
||||
|
||||
### voltagent-qa-sec:security-auditor
|
||||
**When:** Changes touch MCP tool handlers, Cypher query construction, CLI argument parsing, or any code that processes external input.
|
||||
**Why:** GitNexus is an MCP server — all tool inputs come from untrusted AI agents. Systematic OWASP-level audit catches injection vectors that spot-checking misses. Past finding: `lbug-adapter.ts` fallback path had unescaped newlines in Cypher queries.
|
||||
|
||||
### voltagent-data-ai:database-optimizer
|
||||
**When:** Changes touch `kuzu-adapter.ts`, `schema.ts`, `lbug-adapter.ts`, or any Cypher query construction/execution.
|
||||
**Why:** No CE agent specializes in graph database optimization. KuzuDB batch insert patterns, index usage, and query planning directly affect analysis speed on large repos.
|
||||
|
||||
## Review Tooling
|
||||
- Use `gitnexus_impact()` before approving changes to any symbol — check d=1 (WILL BREAK) callers.
|
||||
- Use `gitnexus_detect_changes({scope: "compare", base_ref: "main"})` to map PR diffs to affected execution flows.
|
||||
- Use claude-mem to surface past architectural decisions relevant to the code under review.
|
||||
@@ -0,0 +1,72 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to GitNexus will be documented in this file.
|
||||
|
||||
## [1.4.6] - 2026-03-18
|
||||
|
||||
### Added
|
||||
- **Phase 7 type resolution** — return-aware loop inference for call-expression iterables (#341)
|
||||
- `ReturnTypeLookup` interface with `lookupReturnType` / `lookupRawReturnType` split
|
||||
- `ForLoopExtractorContext` context object replacing positional `(node, env)` signature
|
||||
- Call-expression iterable resolution across 8 languages (TS/JS, Java, Kotlin, C#, Go, Rust, Python, PHP)
|
||||
- PHP `$this->property` foreach via `@var` class property scan (Strategy C)
|
||||
- PHP `function_call_expression` and `member_call_expression` foreach paths
|
||||
- `extractElementTypeFromString` as canonical raw-string container unwrapper in `shared.ts`
|
||||
- `extractReturnTypeName` deduplicated from `call-processor.ts` into `shared.ts` (137 lines removed)
|
||||
- `SKIP_SUBTREE_TYPES` performance optimization with documented `template_string` exclusion
|
||||
- `pendingCallResults` infrastructure (dormant — Phase 9 work)
|
||||
|
||||
### Fixed
|
||||
- **impact**: return structured error + partial results instead of crashing (#345)
|
||||
- **impact**: add `HAS_METHOD` and `OVERRIDES` to `VALID_RELATION_TYPES` (#350)
|
||||
- **cli**: write tool output to stdout via fd 1 instead of stderr (#346)
|
||||
- **postinstall**: add permission fix for CLI and hook scripts (#348)
|
||||
- **workflow**: use prefixed temporary branch name for fork PRs to prevent overwriting real branches
|
||||
- **test**: add `--repo` to CLI e2e tool tests for multi-repo environment
|
||||
- **php**: add `declaration_list` type guard on `findClassPropertyElementType` fallback
|
||||
- **docs**: correct `pendingCallResults` description in roadmap and system docs
|
||||
|
||||
### Chore
|
||||
- Add `.worktrees/` to `.gitignore`
|
||||
|
||||
## [1.4.5] - 2026-03-17
|
||||
|
||||
### Added
|
||||
- **Ruby language support** for CLI and web (#111)
|
||||
- **TypeEnvironment API** with constructor inference, self/this/super resolution (#274)
|
||||
- **Return type inference** with doc-comment parsing (JSDoc, PHPDoc, YARD) and per-language type extractors (#284)
|
||||
- **Phase 4 type resolution** — nullable unwrapping, for-loop typing, assignment chain propagation (#310)
|
||||
- **Phase 5 type resolution** — chained calls, pattern matching, class-as-receiver (#315)
|
||||
- **Phase 6 type resolution** — for-loop Tier 1c, pattern matching, container descriptors, 10-language coverage (#318)
|
||||
- Container descriptor table for generic type argument resolution (Map keys vs values)
|
||||
- Method-aware for-loop extractors with integration tests for all languages
|
||||
- Recursive pattern binding (C# `is` patterns, Kotlin `when/is` smart casts)
|
||||
- Class field declaration unwrapping for C#/Java
|
||||
- PHP `$this->property` foreach member access
|
||||
- C++ pointer dereference range-for
|
||||
- Java `this.data.values()` field access patterns
|
||||
- Position-indexed when/is bindings for branch-local narrowing
|
||||
- **Type resolution system documentation** with architecture guide and roadmap
|
||||
- `.gitignore` and `.gitnexusignore` support during file discovery (#231)
|
||||
- Codex MCP configuration documentation in README (#236)
|
||||
- `skipGraphPhases` pipeline option to skip MRO/community/process phases for faster test runs
|
||||
- `hookTimeout: 120000` in vitest config for CI beforeAll hooks
|
||||
|
||||
### Changed
|
||||
- **Migrated from KuzuDB to LadybugDB v0.15** (#275)
|
||||
- Dynamically discover and install agent skills in CLI (#270)
|
||||
|
||||
### Performance
|
||||
- Worker pool threshold — skip worker creation for small repos (<15 files or <512KB total)
|
||||
- AST walk pruning via `SKIP_SUBTREE_TYPES` for leaf-only nodes (string, comment, number literals)
|
||||
- Pre-computed `interestingNodeTypes` set — single Set.has() replaces 3 checks per AST node
|
||||
- `fastStripNullable` — skip full nullable parsing for simple identifiers (90%+ case)
|
||||
- Replace `.children?.find()` with manual for loops in `extractFunctionName` to eliminate array allocations
|
||||
|
||||
### Fixed
|
||||
- Same-directory Python import resolution (#328)
|
||||
- Ruby method-level call resolution, HAS_METHOD edges, and dispatch table (#278)
|
||||
- C++ fixture file casing for case-sensitive CI
|
||||
- Template string incorrectly included in AST pruning set (contains interpolated expressions)
|
||||
|
||||
## [1.4.0] - Previous release
|
||||
Regular → Executable
Regular → Executable
Regular → Executable
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gitnexus",
|
||||
"version": "1.4.0",
|
||||
"version": "1.4.6",
|
||||
"description": "Graph-powered code intelligence for AI agents. Index any codebase, query via MCP or CLI.",
|
||||
"author": "Abhigyan Patwari",
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
@@ -45,7 +45,8 @@
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"prepare": "npm run build",
|
||||
"postinstall": "node scripts/patch-tree-sitter-swift.cjs"
|
||||
"postinstall": "node scripts/patch-tree-sitter-swift.cjs",
|
||||
"prepack": "npm run build && chmod +x dist/cli/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@huggingface/transformers": "^3.0.0",
|
||||
|
||||
Regular → Executable
@@ -25,6 +25,7 @@
|
||||
*/
|
||||
|
||||
import http from 'http';
|
||||
import { writeSync } from 'node:fs';
|
||||
import { LocalBackend } from '../mcp/local/local-backend.js';
|
||||
|
||||
export interface EvalServerOptions {
|
||||
@@ -142,7 +143,10 @@ export function formatContextResult(result: any): string {
|
||||
}
|
||||
|
||||
export function formatImpactResult(result: any): string {
|
||||
if (result.error) return `Error: ${result.error}`;
|
||||
if (result.error) {
|
||||
const suggestion = result.suggestion ? `\nSuggestion: ${result.suggestion}` : '';
|
||||
return `Error: ${result.error}${suggestion}`;
|
||||
}
|
||||
|
||||
const target = result.target;
|
||||
const direction = result.direction;
|
||||
@@ -155,7 +159,11 @@ export function formatImpactResult(result: any): string {
|
||||
|
||||
const lines: string[] = [];
|
||||
const dirLabel = direction === 'upstream' ? 'depends on this (will break if changed)' : 'this depends on';
|
||||
lines.push(`Blast radius for ${target?.kind || ''} ${target?.name} (${direction}): ${total} symbol(s) ${dirLabel}\n`);
|
||||
lines.push(`Blast radius for ${target?.kind || ''} ${target?.name} (${direction}): ${total} symbol(s) ${dirLabel}`);
|
||||
if (result.partial) {
|
||||
lines.push('⚠️ Partial results — graph traversal was interrupted. Deeper impacts may exist.');
|
||||
}
|
||||
lines.push('');
|
||||
|
||||
const depthLabels: Record<number, string> = {
|
||||
1: 'WILL BREAK (direct)',
|
||||
@@ -401,9 +409,10 @@ export async function evalServerCommand(options?: EvalServerOptions): Promise<vo
|
||||
console.error(` Auto-shutdown after ${idleTimeoutSec}s idle`);
|
||||
}
|
||||
try {
|
||||
process.stdout.write(`GITNEXUS_EVAL_SERVER_READY:${port}\n`);
|
||||
// Use fd 1 directly — LadybugDB captures process.stdout (#324)
|
||||
writeSync(1, `GITNEXUS_EVAL_SERVER_READY:${port}\n`);
|
||||
} catch {
|
||||
// stdout may not be available
|
||||
// stdout may not be available (e.g., broken pipe)
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
+46
-13
@@ -10,10 +10,12 @@
|
||||
* gitnexus impact --target "AuthService" --direction upstream
|
||||
* gitnexus cypher "MATCH (n:Function) RETURN n.name LIMIT 10"
|
||||
*
|
||||
* Note: Output goes to stderr because LadybugDB's native module captures stdout
|
||||
* at the OS level during init. This is consistent with augment.ts.
|
||||
* Note: Output goes to stdout via fs.writeSync(fd 1), bypassing LadybugDB's
|
||||
* native module which captures the Node.js process.stdout stream during init.
|
||||
* See the output() function for details (#324).
|
||||
*/
|
||||
|
||||
import { writeSync } from 'node:fs';
|
||||
import { LocalBackend } from '../mcp/local/local-backend.js';
|
||||
|
||||
let _backend: LocalBackend | null = null;
|
||||
@@ -29,10 +31,29 @@ async function getBackend(): Promise<LocalBackend> {
|
||||
return _backend;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write tool output to stdout using low-level fd write.
|
||||
*
|
||||
* LadybugDB's native module captures Node.js process.stdout during init,
|
||||
* but the underlying OS file descriptor 1 (stdout) remains intact.
|
||||
* By using fs.writeSync(1, ...) we bypass the Node.js stream layer
|
||||
* and write directly to the real stdout fd (#324).
|
||||
*
|
||||
* Falls back to stderr if the fd write fails (e.g., broken pipe).
|
||||
*/
|
||||
function output(data: any): void {
|
||||
const text = typeof data === 'string' ? data : JSON.stringify(data, null, 2);
|
||||
// stderr because LadybugDB captures stdout at OS level
|
||||
process.stderr.write(text + '\n');
|
||||
try {
|
||||
writeSync(1, text + '\n');
|
||||
} catch (err: any) {
|
||||
if (err?.code === 'EPIPE') {
|
||||
// Consumer closed the pipe (e.g., `gitnexus cypher ... | head -1`)
|
||||
// Exit cleanly per Unix convention
|
||||
process.exit(0);
|
||||
}
|
||||
// Fallback: stderr (previous behavior, works on all platforms)
|
||||
process.stderr.write(text + '\n');
|
||||
}
|
||||
}
|
||||
|
||||
export async function queryCommand(queryText: string, options?: {
|
||||
@@ -92,15 +113,27 @@ export async function impactCommand(target: string, options?: {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const backend = await getBackend();
|
||||
const result = await backend.callTool('impact', {
|
||||
target,
|
||||
direction: options?.direction || 'upstream',
|
||||
maxDepth: options?.depth ? parseInt(options.depth) : undefined,
|
||||
includeTests: options?.includeTests ?? false,
|
||||
repo: options?.repo,
|
||||
});
|
||||
output(result);
|
||||
try {
|
||||
const backend = await getBackend();
|
||||
const result = await backend.callTool('impact', {
|
||||
target,
|
||||
direction: options?.direction || 'upstream',
|
||||
maxDepth: options?.depth ? parseInt(options.depth, 10) : undefined,
|
||||
includeTests: options?.includeTests ?? false,
|
||||
repo: options?.repo,
|
||||
});
|
||||
output(result);
|
||||
} catch (err: unknown) {
|
||||
// Belt-and-suspenders: catch infrastructure failures (getBackend, callTool transport)
|
||||
// The backend's impact() already returns structured errors for graph query failures
|
||||
output({
|
||||
error: (err instanceof Error ? err.message : String(err)) || 'Impact analysis failed unexpectedly',
|
||||
target: { name: target },
|
||||
direction: options?.direction || 'upstream',
|
||||
suggestion: 'Try reducing --depth or using gitnexus context <symbol> as a fallback',
|
||||
});
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
export async function cypherCommand(query: string, options?: {
|
||||
|
||||
@@ -28,6 +28,7 @@ import type { ConstructorBinding } from './type-env.js';
|
||||
import { getTreeSitterBufferSize } from './constants.js';
|
||||
import type { ExtractedCall, ExtractedHeritage, ExtractedRoute, FileConstructorBindings } from './workers/parse-worker.js';
|
||||
import { callRouters } from './call-routing.js';
|
||||
import { extractReturnTypeName } from './type-extractors/shared.js';
|
||||
|
||||
/**
|
||||
* Walk up the AST from a node to find the enclosing function/method.
|
||||
@@ -216,7 +217,7 @@ export const processCalls = async (
|
||||
const nodeId = generateId('Property', `${file.path}:${item.propName}`);
|
||||
graph.addNode({
|
||||
id: nodeId,
|
||||
label: 'Property' as any, // TODO: add 'Property' to graph node label union
|
||||
label: 'Property',
|
||||
properties: {
|
||||
name: item.propName, filePath: file.path,
|
||||
startLine: item.startLine, endLine: item.endLine,
|
||||
@@ -498,136 +499,6 @@ const resolveCallTarget = (
|
||||
return toResolveResult(filteredCandidates[0], tiered.tier);
|
||||
};
|
||||
|
||||
// ── Return type text helpers ─────────────────────────────────────────────
|
||||
// extractSimpleTypeName works on AST nodes; this operates on raw return-type
|
||||
// text already stored in SymbolDefinition (e.g. "User", "Promise<User>",
|
||||
// "User | null", "*User"). Extracts the base user-defined type name.
|
||||
|
||||
/** Primitive / built-in types that should NOT produce a receiver binding. */
|
||||
const PRIMITIVE_TYPES = new Set([
|
||||
'string', 'number', 'boolean', 'void', 'int', 'float', 'double', 'long',
|
||||
'short', 'byte', 'char', 'bool', 'str', 'i8', 'i16', 'i32', 'i64',
|
||||
'u8', 'u16', 'u32', 'u64', 'f32', 'f64', 'usize', 'isize',
|
||||
'undefined', 'null', 'None', 'nil',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Extract a simple type name from raw return-type text.
|
||||
* Handles common patterns:
|
||||
* "User" → "User"
|
||||
* "Promise<User>" → "User" (unwrap wrapper generics)
|
||||
* "Option<User>" → "User"
|
||||
* "Result<User, Error>" → "User" (first type arg)
|
||||
* "User | null" → "User" (strip nullable union)
|
||||
* "User?" → "User" (strip nullable suffix)
|
||||
* "*User" → "User" (Go pointer)
|
||||
* "&User" → "User" (Rust reference)
|
||||
* Returns undefined for complex types or primitives.
|
||||
*/
|
||||
const WRAPPER_GENERICS = new Set([
|
||||
'Promise', 'Observable', 'Future', 'CompletableFuture', 'Task', 'ValueTask', // async wrappers
|
||||
'Option', 'Some', 'Optional', 'Maybe', // nullable wrappers
|
||||
'Result', 'Either', // result wrappers
|
||||
// Rust smart pointers (Deref to inner type)
|
||||
'Rc', 'Arc', 'Weak', // pointer types
|
||||
'MutexGuard', 'RwLockReadGuard', 'RwLockWriteGuard', // guard types
|
||||
'Ref', 'RefMut', // RefCell guards
|
||||
'Cow', // copy-on-write
|
||||
// Containers (List, Array, Vec, Set, etc.) are intentionally excluded —
|
||||
// methods are called on the container, not the element type.
|
||||
// Non-wrapper generics return the base type (e.g., List) via the else branch.
|
||||
]);
|
||||
|
||||
/**
|
||||
* Extracts the first type argument from a comma-separated generic argument string,
|
||||
* respecting nested angle brackets. For example:
|
||||
* "Result<User, Error>" → "Result<User, Error>" (no top-level comma)
|
||||
* "User, Error" → "User"
|
||||
* "Map<K, V>, string" → "Map<K, V>"
|
||||
*/
|
||||
function extractFirstGenericArg(args: string): string {
|
||||
let depth = 0;
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '<') depth++;
|
||||
else if (args[i] === '>') depth--;
|
||||
else if (args[i] === ',' && depth === 0) return args.slice(0, i).trim();
|
||||
}
|
||||
return args.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the first non-lifetime type argument from a generic argument string.
|
||||
* Skips Rust lifetime parameters (e.g., `'a`, `'_`) to find the actual type.
|
||||
* "'_, User" → "User"
|
||||
* "'a, User" → "User"
|
||||
* "User, Error" → "User" (no lifetime — delegates to extractFirstGenericArg)
|
||||
*/
|
||||
function extractFirstTypeArg(args: string): string {
|
||||
let remaining = args;
|
||||
while (remaining) {
|
||||
const first = extractFirstGenericArg(remaining);
|
||||
if (!first.startsWith("'")) return first;
|
||||
// Skip past this lifetime arg + the comma separator
|
||||
const commaIdx = remaining.indexOf(',', first.length);
|
||||
if (commaIdx < 0) return first; // only lifetimes — fall through
|
||||
remaining = remaining.slice(commaIdx + 1).trim();
|
||||
}
|
||||
return args.trim();
|
||||
}
|
||||
|
||||
export const extractReturnTypeName = (raw: string, depth = 0): string | undefined => {
|
||||
if (depth > 10) return undefined;
|
||||
let text = raw.trim();
|
||||
if (!text) return undefined;
|
||||
|
||||
// Strip pointer/reference prefixes: *User, &User, &mut User
|
||||
text = text.replace(/^[&*]+\s*(mut\s+)?/, '');
|
||||
|
||||
// Strip nullable suffix: User?
|
||||
text = text.replace(/\?$/, '');
|
||||
|
||||
// Handle union types: "User | null" → "User"
|
||||
if (text.includes('|')) {
|
||||
const parts = text.split('|').map(p => p.trim()).filter(p =>
|
||||
p !== 'null' && p !== 'undefined' && p !== 'void' && p !== 'None' && p !== 'nil'
|
||||
);
|
||||
if (parts.length === 1) text = parts[0];
|
||||
else return undefined; // genuine union — too complex
|
||||
}
|
||||
|
||||
// Handle generics: Promise<User> → unwrap if wrapper, else take base
|
||||
const genericMatch = text.match(/^(\w+)\s*<(.+)>$/);
|
||||
if (genericMatch) {
|
||||
const [, base, args] = genericMatch;
|
||||
if (WRAPPER_GENERICS.has(base)) {
|
||||
// Take the first non-lifetime type argument, using bracket-balanced splitting
|
||||
// so that nested generics like Result<User, Error> are not split at the inner
|
||||
// comma. Lifetime parameters (Rust 'a, '_) are skipped.
|
||||
const firstArg = extractFirstTypeArg(args);
|
||||
return extractReturnTypeName(firstArg, depth + 1);
|
||||
}
|
||||
// Non-wrapper generic: return the base type (e.g., Map<K,V> → Map)
|
||||
return PRIMITIVE_TYPES.has(base.toLowerCase()) ? undefined : base;
|
||||
}
|
||||
|
||||
// Bare wrapper type without generic argument (e.g. Task, Promise, Option)
|
||||
// should not produce a binding — these are meaningless without a type parameter
|
||||
if (WRAPPER_GENERICS.has(text)) return undefined;
|
||||
|
||||
// Handle qualified names: models.User → User, Models::User → User, \App\Models\User → User
|
||||
if (text.includes('::') || text.includes('.') || text.includes('\\')) {
|
||||
text = text.split(/::|[.\\]/).pop()!;
|
||||
}
|
||||
|
||||
// Final check: skip primitives
|
||||
if (PRIMITIVE_TYPES.has(text) || PRIMITIVE_TYPES.has(text.toLowerCase())) return undefined;
|
||||
|
||||
// Must start with uppercase (class/type convention) or be a valid identifier
|
||||
if (!/^[A-Z_]\w*$/.test(text)) return undefined;
|
||||
|
||||
return text;
|
||||
};
|
||||
|
||||
// ── Scope key helpers ────────────────────────────────────────────────────
|
||||
// Scope keys use the format "funcName@startIndex" (produced by type-env.ts).
|
||||
// Source IDs use "Label:filepath:funcName" (produced by parse-worker.ts).
|
||||
@@ -732,7 +603,6 @@ export const processCallsFromExtracted = async (
|
||||
if (!list) { list = []; byFile.set(call.filePath, list); }
|
||||
list.push(call);
|
||||
}
|
||||
|
||||
const totalFiles = byFile.size;
|
||||
let filesProcessed = 0;
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
resolvePhpImport,
|
||||
resolveRustImport,
|
||||
resolveRubyImport,
|
||||
resolvePythonImport,
|
||||
} from './resolvers/index.js';
|
||||
import { callRouters } from './call-routing.js';
|
||||
import type { ResolutionContext } from './resolution-context.js';
|
||||
@@ -219,6 +220,14 @@ function resolveLanguageImport(
|
||||
return null; // External framework (Foundation, UIKit, etc.)
|
||||
}
|
||||
|
||||
// Python: relative imports (PEP 328) + proximity-based bare imports
|
||||
// Falls through to standard suffix resolution when proximity finds no match.
|
||||
if (language === SupportedLanguages.Python) {
|
||||
const resolved = resolvePythonImport(filePath, rawImportPath, allFilePaths);
|
||||
if (resolved) return { kind: 'files', files: [resolved] };
|
||||
if (rawImportPath.startsWith('.')) return null; // relative but unresolved — don't suffix-match
|
||||
}
|
||||
|
||||
// Ruby: require / require_relative
|
||||
if (language === SupportedLanguages.Ruby) {
|
||||
const resolved = resolveRubyImport(rawImportPath, normalizedFileList, allFileList, index);
|
||||
|
||||
@@ -238,10 +238,12 @@ const processParsingSequential = async (
|
||||
: undefined;
|
||||
|
||||
// Language-specific return type fallback (e.g. Ruby YARD @return [Type])
|
||||
if (methodSig && !methodSig.returnType && definitionNode) {
|
||||
// Also upgrades uninformative AST types like PHP `array` with PHPDoc `@return User[]`
|
||||
if (methodSig && (!methodSig.returnType || methodSig.returnType === 'array' || methodSig.returnType === 'iterable') && definitionNode) {
|
||||
const tc = typeConfigs[language as keyof typeof typeConfigs];
|
||||
if (tc?.extractReturnType) {
|
||||
methodSig.returnType = tc.extractReturnType(definitionNode);
|
||||
const docReturn = tc.extractReturnType(definitionNode);
|
||||
if (docReturn) methodSig.returnType = docReturn;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,9 +33,15 @@ const CHUNK_BYTE_BUDGET = 20 * 1024 * 1024; // 20MB
|
||||
/** Max AST trees to keep in LRU cache */
|
||||
const AST_CACHE_CAP = 50;
|
||||
|
||||
export interface PipelineOptions {
|
||||
/** Skip MRO, community detection, and process extraction for faster test runs. */
|
||||
skipGraphPhases?: boolean;
|
||||
}
|
||||
|
||||
export const runPipelineFromRepo = async (
|
||||
repoPath: string,
|
||||
onProgress: (progress: PipelineProgress) => void
|
||||
onProgress: (progress: PipelineProgress) => void,
|
||||
options?: PipelineOptions,
|
||||
): Promise<PipelineResult> => {
|
||||
const graph = createKnowledgeGraph();
|
||||
const ctx = createResolutionContext();
|
||||
@@ -154,22 +160,29 @@ export const runPipelineFromRepo = async (
|
||||
stats: { filesProcessed: 0, totalFiles: totalParseable, nodesCreated: graph.nodeCount },
|
||||
});
|
||||
|
||||
// Don't spawn workers for tiny repos — overhead exceeds benefit
|
||||
const MIN_FILES_FOR_WORKERS = 15;
|
||||
const MIN_BYTES_FOR_WORKERS = 512 * 1024;
|
||||
const totalBytes = parseableScanned.reduce((s, f) => s + f.size, 0);
|
||||
|
||||
// Create worker pool once, reuse across chunks
|
||||
let workerPool: WorkerPool | undefined;
|
||||
try {
|
||||
let workerUrl = new URL('./workers/parse-worker.js', import.meta.url);
|
||||
// When running under vitest, import.meta.url points to src/ where no .js exists.
|
||||
// Fall back to the compiled dist/ worker so the pool can spawn real worker threads.
|
||||
const thisDir = fileURLToPath(new URL('.', import.meta.url));
|
||||
if (!fs.existsSync(fileURLToPath(workerUrl))) {
|
||||
const distWorker = path.resolve(thisDir, '..', '..', '..', 'dist', 'core', 'ingestion', 'workers', 'parse-worker.js');
|
||||
if (fs.existsSync(distWorker)) {
|
||||
workerUrl = pathToFileURL(distWorker) as URL;
|
||||
if (totalParseable >= MIN_FILES_FOR_WORKERS || totalBytes >= MIN_BYTES_FOR_WORKERS) {
|
||||
try {
|
||||
let workerUrl = new URL('./workers/parse-worker.js', import.meta.url);
|
||||
// When running under vitest, import.meta.url points to src/ where no .js exists.
|
||||
// Fall back to the compiled dist/ worker so the pool can spawn real worker threads.
|
||||
const thisDir = fileURLToPath(new URL('.', import.meta.url));
|
||||
if (!fs.existsSync(fileURLToPath(workerUrl))) {
|
||||
const distWorker = path.resolve(thisDir, '..', '..', '..', 'dist', 'core', 'ingestion', 'workers', 'parse-worker.js');
|
||||
if (fs.existsSync(distWorker)) {
|
||||
workerUrl = pathToFileURL(distWorker) as URL;
|
||||
}
|
||||
}
|
||||
workerPool = createWorkerPool(workerUrl);
|
||||
} catch (err) {
|
||||
if (isDev) console.warn('Worker pool creation failed, using sequential fallback:', (err as Error).message);
|
||||
}
|
||||
workerPool = createWorkerPool(workerUrl);
|
||||
} catch (err) {
|
||||
if (isDev) console.warn('Worker pool creation failed, using sequential fallback:', (err as Error).message);
|
||||
}
|
||||
|
||||
let filesParsedSoFar = 0;
|
||||
@@ -322,130 +335,137 @@ export const runPipelineFromRepo = async (
|
||||
(importCtx as any).suffixIndex = null;
|
||||
(importCtx as any).normalizedFileList = null;
|
||||
|
||||
// ── Phase 4.5: Method Resolution Order ──────────────────────────────
|
||||
onProgress({
|
||||
phase: 'parsing',
|
||||
percent: 81,
|
||||
message: 'Computing method resolution order...',
|
||||
stats: { filesProcessed: totalFiles, totalFiles, nodesCreated: graph.nodeCount },
|
||||
});
|
||||
let communityResult: Awaited<ReturnType<typeof processCommunities>> | undefined;
|
||||
let processResult: Awaited<ReturnType<typeof processProcesses>> | undefined;
|
||||
|
||||
const mroResult = computeMRO(graph);
|
||||
if (isDev && mroResult.entries.length > 0) {
|
||||
console.log(`🔀 MRO: ${mroResult.entries.length} classes analyzed, ${mroResult.ambiguityCount} ambiguities found, ${mroResult.overrideEdges} OVERRIDES edges`);
|
||||
}
|
||||
|
||||
// ── Phase 5: Communities ───────────────────────────────────────────
|
||||
onProgress({
|
||||
phase: 'communities',
|
||||
percent: 82,
|
||||
message: 'Detecting code communities...',
|
||||
stats: { filesProcessed: totalFiles, totalFiles, nodesCreated: graph.nodeCount },
|
||||
});
|
||||
|
||||
const communityResult = await processCommunities(graph, (message, progress) => {
|
||||
const communityProgress = 82 + (progress * 0.10);
|
||||
if (!options?.skipGraphPhases) {
|
||||
// ── Phase 4.5: Method Resolution Order ──────────────────────────────
|
||||
onProgress({
|
||||
phase: 'communities',
|
||||
percent: Math.round(communityProgress),
|
||||
message,
|
||||
phase: 'parsing',
|
||||
percent: 81,
|
||||
message: 'Computing method resolution order...',
|
||||
stats: { filesProcessed: totalFiles, totalFiles, nodesCreated: graph.nodeCount },
|
||||
});
|
||||
});
|
||||
|
||||
if (isDev) {
|
||||
console.log(`🏘️ Community detection: ${communityResult.stats.totalCommunities} communities found (modularity: ${communityResult.stats.modularity.toFixed(3)})`);
|
||||
}
|
||||
const mroResult = computeMRO(graph);
|
||||
if (isDev && mroResult.entries.length > 0) {
|
||||
console.log(`🔀 MRO: ${mroResult.entries.length} classes analyzed, ${mroResult.ambiguityCount} ambiguities found, ${mroResult.overrideEdges} OVERRIDES edges`);
|
||||
}
|
||||
|
||||
communityResult.communities.forEach(comm => {
|
||||
graph.addNode({
|
||||
id: comm.id,
|
||||
label: 'Community' as const,
|
||||
properties: {
|
||||
name: comm.label,
|
||||
filePath: '',
|
||||
heuristicLabel: comm.heuristicLabel,
|
||||
cohesion: comm.cohesion,
|
||||
symbolCount: comm.symbolCount,
|
||||
}
|
||||
// ── Phase 5: Communities ───────────────────────────────────────────
|
||||
onProgress({
|
||||
phase: 'communities',
|
||||
percent: 82,
|
||||
message: 'Detecting code communities...',
|
||||
stats: { filesProcessed: totalFiles, totalFiles, nodesCreated: graph.nodeCount },
|
||||
});
|
||||
});
|
||||
|
||||
communityResult.memberships.forEach(membership => {
|
||||
graph.addRelationship({
|
||||
id: `${membership.nodeId}_member_of_${membership.communityId}`,
|
||||
type: 'MEMBER_OF',
|
||||
sourceId: membership.nodeId,
|
||||
targetId: membership.communityId,
|
||||
confidence: 1.0,
|
||||
reason: 'leiden-algorithm',
|
||||
});
|
||||
});
|
||||
|
||||
// ── Phase 6: Processes ─────────────────────────────────────────────
|
||||
onProgress({
|
||||
phase: 'processes',
|
||||
percent: 94,
|
||||
message: 'Detecting execution flows...',
|
||||
stats: { filesProcessed: totalFiles, totalFiles, nodesCreated: graph.nodeCount },
|
||||
});
|
||||
|
||||
let symbolCount = 0;
|
||||
graph.forEachNode(n => { if (n.label !== 'File') symbolCount++; });
|
||||
const dynamicMaxProcesses = Math.max(20, Math.min(300, Math.round(symbolCount / 10)));
|
||||
|
||||
const processResult = await processProcesses(
|
||||
graph,
|
||||
communityResult.memberships,
|
||||
(message, progress) => {
|
||||
const processProgress = 94 + (progress * 0.05);
|
||||
communityResult = await processCommunities(graph, (message, progress) => {
|
||||
const communityProgress = 82 + (progress * 0.10);
|
||||
onProgress({
|
||||
phase: 'processes',
|
||||
percent: Math.round(processProgress),
|
||||
phase: 'communities',
|
||||
percent: Math.round(communityProgress),
|
||||
message,
|
||||
stats: { filesProcessed: totalFiles, totalFiles, nodesCreated: graph.nodeCount },
|
||||
});
|
||||
},
|
||||
{ maxProcesses: dynamicMaxProcesses, minSteps: 3 }
|
||||
);
|
||||
});
|
||||
|
||||
if (isDev) {
|
||||
console.log(`🔄 Process detection: ${processResult.stats.totalProcesses} processes found (${processResult.stats.crossCommunityCount} cross-community)`);
|
||||
if (isDev) {
|
||||
console.log(`🏘️ Community detection: ${communityResult.stats.totalCommunities} communities found (modularity: ${communityResult.stats.modularity.toFixed(3)})`);
|
||||
}
|
||||
|
||||
communityResult.communities.forEach(comm => {
|
||||
graph.addNode({
|
||||
id: comm.id,
|
||||
label: 'Community' as const,
|
||||
properties: {
|
||||
name: comm.label,
|
||||
filePath: '',
|
||||
heuristicLabel: comm.heuristicLabel,
|
||||
cohesion: comm.cohesion,
|
||||
symbolCount: comm.symbolCount,
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
communityResult.memberships.forEach(membership => {
|
||||
graph.addRelationship({
|
||||
id: `${membership.nodeId}_member_of_${membership.communityId}`,
|
||||
type: 'MEMBER_OF',
|
||||
sourceId: membership.nodeId,
|
||||
targetId: membership.communityId,
|
||||
confidence: 1.0,
|
||||
reason: 'leiden-algorithm',
|
||||
});
|
||||
});
|
||||
|
||||
// ── Phase 6: Processes ─────────────────────────────────────────────
|
||||
onProgress({
|
||||
phase: 'processes',
|
||||
percent: 94,
|
||||
message: 'Detecting execution flows...',
|
||||
stats: { filesProcessed: totalFiles, totalFiles, nodesCreated: graph.nodeCount },
|
||||
});
|
||||
|
||||
let symbolCount = 0;
|
||||
graph.forEachNode(n => { if (n.label !== 'File') symbolCount++; });
|
||||
const dynamicMaxProcesses = Math.max(20, Math.min(300, Math.round(symbolCount / 10)));
|
||||
|
||||
processResult = await processProcesses(
|
||||
graph,
|
||||
communityResult.memberships,
|
||||
(message, progress) => {
|
||||
const processProgress = 94 + (progress * 0.05);
|
||||
onProgress({
|
||||
phase: 'processes',
|
||||
percent: Math.round(processProgress),
|
||||
message,
|
||||
stats: { filesProcessed: totalFiles, totalFiles, nodesCreated: graph.nodeCount },
|
||||
});
|
||||
},
|
||||
{ maxProcesses: dynamicMaxProcesses, minSteps: 3 }
|
||||
);
|
||||
|
||||
if (isDev) {
|
||||
console.log(`🔄 Process detection: ${processResult.stats.totalProcesses} processes found (${processResult.stats.crossCommunityCount} cross-community)`);
|
||||
}
|
||||
|
||||
processResult.processes.forEach(proc => {
|
||||
graph.addNode({
|
||||
id: proc.id,
|
||||
label: 'Process' as const,
|
||||
properties: {
|
||||
name: proc.label,
|
||||
filePath: '',
|
||||
heuristicLabel: proc.heuristicLabel,
|
||||
processType: proc.processType,
|
||||
stepCount: proc.stepCount,
|
||||
communities: proc.communities,
|
||||
entryPointId: proc.entryPointId,
|
||||
terminalId: proc.terminalId,
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
processResult.steps.forEach(step => {
|
||||
graph.addRelationship({
|
||||
id: `${step.nodeId}_step_${step.step}_${step.processId}`,
|
||||
type: 'STEP_IN_PROCESS',
|
||||
sourceId: step.nodeId,
|
||||
targetId: step.processId,
|
||||
confidence: 1.0,
|
||||
reason: 'trace-detection',
|
||||
step: step.step,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
processResult.processes.forEach(proc => {
|
||||
graph.addNode({
|
||||
id: proc.id,
|
||||
label: 'Process' as const,
|
||||
properties: {
|
||||
name: proc.label,
|
||||
filePath: '',
|
||||
heuristicLabel: proc.heuristicLabel,
|
||||
processType: proc.processType,
|
||||
stepCount: proc.stepCount,
|
||||
communities: proc.communities,
|
||||
entryPointId: proc.entryPointId,
|
||||
terminalId: proc.terminalId,
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
processResult.steps.forEach(step => {
|
||||
graph.addRelationship({
|
||||
id: `${step.nodeId}_step_${step.step}_${step.processId}`,
|
||||
type: 'STEP_IN_PROCESS',
|
||||
sourceId: step.nodeId,
|
||||
targetId: step.processId,
|
||||
confidence: 1.0,
|
||||
reason: 'trace-detection',
|
||||
step: step.step,
|
||||
});
|
||||
});
|
||||
|
||||
onProgress({
|
||||
phase: 'complete',
|
||||
percent: 100,
|
||||
message: `Graph complete! ${communityResult.stats.totalCommunities} communities, ${processResult.stats.totalProcesses} processes detected.`,
|
||||
message: communityResult && processResult
|
||||
? `Graph complete! ${communityResult.stats.totalCommunities} communities, ${processResult.stats.totalProcesses} processes detected.`
|
||||
: 'Graph complete! (graph phases skipped)',
|
||||
stats: {
|
||||
filesProcessed: totalFiles,
|
||||
totalFiles,
|
||||
|
||||
@@ -21,5 +21,7 @@ export { resolveRustImport, tryRustModulePath } from './rust.js';
|
||||
|
||||
export { resolveRubyImport } from './ruby.js';
|
||||
|
||||
export { resolvePythonImport } from './python.js';
|
||||
|
||||
export { resolveImportPath, RESOLVE_CACHE_CAP } from './standard.js';
|
||||
export type { TsconfigPaths } from './standard.js';
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Python import resolution — PEP 328 relative imports and proximity-based bare imports.
|
||||
* Import system spec: PEP 302 (original), PEP 451 (current).
|
||||
*/
|
||||
|
||||
import { tryResolveWithExtensions } from './utils.js';
|
||||
|
||||
/**
|
||||
* Resolve a Python import to a file path.
|
||||
*
|
||||
* 1. Relative (PEP 328): `.module`, `..module` — 1 dot = current package, each extra dot goes up one level.
|
||||
* 2. Proximity bare import: static heuristic — checks the importer's own directory first.
|
||||
* Approximates the common case where co-located files find each other without an installed package.
|
||||
* Single-segment only — multi-segment (e.g. `os.path`) falls through to suffixResolve.
|
||||
* Checks package (__init__.py) before module (.py), matching CPython's finder order (PEP 451 §4).
|
||||
* Coexistence of both is physically impossible (same name = file vs directory), so the order
|
||||
* only matters for spec compliance.
|
||||
* Note: namespace packages (PEP 420, directory without __init__.py) are not handled.
|
||||
*
|
||||
* Returns null to let the caller fall through to suffixResolve.
|
||||
*/
|
||||
export function resolvePythonImport(
|
||||
currentFile: string,
|
||||
importPath: string,
|
||||
allFiles: Set<string>,
|
||||
): string | null {
|
||||
// Relative import — PEP 328 (https://peps.python.org/pep-0328/)
|
||||
if (importPath.startsWith('.')) {
|
||||
const dotMatch = importPath.match(/^(\.+)(.*)/);
|
||||
if (!dotMatch) return null;
|
||||
|
||||
const dotCount = dotMatch[1].length;
|
||||
const modulePart = dotMatch[2];
|
||||
const dirParts = currentFile.split('/').slice(0, -1);
|
||||
|
||||
// PEP 328: more dots than directory levels → beyond top-level package → invalid
|
||||
if (dotCount - 1 > dirParts.length) return null;
|
||||
for (let i = 1; i < dotCount; i++) dirParts.pop();
|
||||
|
||||
if (modulePart) {
|
||||
dirParts.push(...modulePart.replace(/\./g, '/').split('/'));
|
||||
}
|
||||
|
||||
return tryResolveWithExtensions(dirParts.join('/'), allFiles);
|
||||
}
|
||||
|
||||
// Proximity bare import — single-segment only; package before module (PEP 451 §4)
|
||||
const pathLike = importPath.replace(/\./g, '/');
|
||||
if (pathLike.includes('/')) return null;
|
||||
|
||||
// Normalize for Windows backslashes
|
||||
const importerDir = currentFile.replace(/\\/g, '/').split('/').slice(0, -1).join('/');
|
||||
if (!importerDir) return null;
|
||||
|
||||
if (allFiles.has(`${importerDir}/${pathLike}/__init__.py`)) return `${importerDir}/${pathLike}/__init__.py`;
|
||||
if (allFiles.has(`${importerDir}/${pathLike}.py`)) return `${importerDir}/${pathLike}.py`;
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -113,32 +113,6 @@ export const resolveImportPath = (
|
||||
// Fall through to generic resolution if Rust-specific didn't match
|
||||
}
|
||||
|
||||
// ---- Python relative imports (PEP 328): .module, ..module, ... ----
|
||||
if (language === SupportedLanguages.Python && importPath.startsWith('.')) {
|
||||
const dotMatch = importPath.match(/^(\.+)(.*)/);
|
||||
if (dotMatch) {
|
||||
const dotCount = dotMatch[1].length;
|
||||
const modulePart = dotMatch[2]; // e.g., "models" from ".models"
|
||||
const dirParts = currentFile.split('/').slice(0, -1); // remove filename
|
||||
|
||||
// Navigate up: 1 dot = same package, 2 dots = parent package, etc.
|
||||
// First dot means "current package", each additional dot goes up one level
|
||||
for (let i = 1; i < dotCount; i++) {
|
||||
dirParts.pop();
|
||||
}
|
||||
|
||||
if (modulePart) {
|
||||
// from .models import User → resolve "models" relative to current package
|
||||
const modulePath = modulePart.replace(/\./g, '/');
|
||||
dirParts.push(...modulePath.split('/'));
|
||||
}
|
||||
|
||||
const basePath = dirParts.join('/');
|
||||
const resolved = tryResolveWithExtensions(basePath, allFiles);
|
||||
return cache(resolved);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Generic relative import resolution (./ and ../) ----
|
||||
const currentDir = currentFile.split('/').slice(0, -1);
|
||||
const parts = importPath.split('/');
|
||||
|
||||
@@ -38,6 +38,13 @@ export interface SymbolTable {
|
||||
* Used when imports are missing or for framework magic
|
||||
*/
|
||||
lookupFuzzy: (name: string) => SymbolDefinition[];
|
||||
|
||||
/**
|
||||
* Low Confidence: Look for callable symbols (Function/Method/Constructor) by name.
|
||||
* Faster than `lookupFuzzy` + filter — backed by a lazy callable-only index.
|
||||
* Used by ReturnTypeLookup to resolve callee → return type.
|
||||
*/
|
||||
lookupFuzzyCallable: (name: string) => SymbolDefinition[];
|
||||
|
||||
/**
|
||||
* Debugging: See how many symbols are tracked
|
||||
@@ -59,6 +66,13 @@ export const createSymbolTable = (): SymbolTable => {
|
||||
// Structure: SymbolName -> [List of Definitions]
|
||||
const globalIndex = new Map<string, SymbolDefinition[]>();
|
||||
|
||||
// 3. Lazy Callable Index — populated on first lookupFuzzyCallable call.
|
||||
// Structure: SymbolName -> [Callable Definitions]
|
||||
// Only Function, Method, Constructor symbols are indexed.
|
||||
let callableIndex: Map<string, SymbolDefinition[]> | null = null;
|
||||
|
||||
const CALLABLE_TYPES = new Set(['Function', 'Method', 'Constructor']);
|
||||
|
||||
const add = (
|
||||
filePath: string,
|
||||
name: string,
|
||||
@@ -86,6 +100,9 @@ export const createSymbolTable = (): SymbolTable => {
|
||||
globalIndex.set(name, []);
|
||||
}
|
||||
globalIndex.get(name)!.push(def);
|
||||
|
||||
// Invalidate the lazy callable index — it will be rebuilt on next use
|
||||
callableIndex = null;
|
||||
};
|
||||
|
||||
const lookupExact = (filePath: string, name: string): string | undefined => {
|
||||
@@ -100,6 +117,18 @@ export const createSymbolTable = (): SymbolTable => {
|
||||
return globalIndex.get(name) || [];
|
||||
};
|
||||
|
||||
const lookupFuzzyCallable = (name: string): SymbolDefinition[] => {
|
||||
if (!callableIndex) {
|
||||
// Build the callable index lazily on first use
|
||||
callableIndex = new Map();
|
||||
for (const [symName, defs] of globalIndex) {
|
||||
const callables = defs.filter(d => CALLABLE_TYPES.has(d.type));
|
||||
if (callables.length > 0) callableIndex.set(symName, callables);
|
||||
}
|
||||
}
|
||||
return callableIndex.get(name) ?? [];
|
||||
};
|
||||
|
||||
const getStats = () => ({
|
||||
fileCount: fileIndex.size,
|
||||
globalSymbolCount: globalIndex.size
|
||||
@@ -108,7 +137,8 @@ export const createSymbolTable = (): SymbolTable => {
|
||||
const clear = () => {
|
||||
fileIndex.clear();
|
||||
globalIndex.clear();
|
||||
callableIndex = null;
|
||||
};
|
||||
|
||||
return { add, lookupExact, lookupExactFull, lookupFuzzy, getStats, clear };
|
||||
return { add, lookupExact, lookupExactFull, lookupFuzzy, lookupFuzzyCallable, getStats, clear };
|
||||
};
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { SyntaxNode } from './utils.js';
|
||||
import { FUNCTION_NODE_TYPES, extractFunctionName, CLASS_CONTAINER_TYPES } from './utils.js';
|
||||
import { FUNCTION_NODE_TYPES, extractFunctionName, CLASS_CONTAINER_TYPES, isBuiltInOrNoise } from './utils.js';
|
||||
import { SupportedLanguages } from '../../config/supported-languages.js';
|
||||
import { typeConfigs, TYPED_PARAMETER_TYPES } from './type-extractors/index.js';
|
||||
import type { ClassNameLookup } from './type-extractors/types.js';
|
||||
import { extractSimpleTypeName, stripNullable } from './type-extractors/shared.js';
|
||||
import type { ClassNameLookup, ReturnTypeLookup, ForLoopExtractorContext } from './type-extractors/types.js';
|
||||
import { extractSimpleTypeName, extractVarName, stripNullable, extractReturnTypeName } from './type-extractors/shared.js';
|
||||
import type { SymbolTable } from './symbol-table.js';
|
||||
|
||||
/**
|
||||
@@ -48,11 +48,57 @@ export interface TypeEnvironment {
|
||||
readonly env: TypeEnv;
|
||||
}
|
||||
|
||||
/**
|
||||
* Position-indexed pattern binding: active only within a specific AST range.
|
||||
* Used for smart-cast narrowing in mutually exclusive branches (e.g., Kotlin when arms).
|
||||
*/
|
||||
interface PatternOverride {
|
||||
rangeStart: number;
|
||||
rangeEnd: number;
|
||||
typeName: string;
|
||||
}
|
||||
|
||||
/** scope → varName → overrides (checked in order, first range match wins) */
|
||||
type PatternOverrides = Map<string, Map<string, PatternOverride[]>>;
|
||||
|
||||
/** AST node types that represent mutually exclusive branch containers for pattern bindings. */
|
||||
const PATTERN_BRANCH_TYPES = new Set([
|
||||
'when_entry', // Kotlin when
|
||||
'switch_block_label', // Java switch (enhanced)
|
||||
]);
|
||||
|
||||
/** Walk up the AST from a pattern node to find the enclosing branch container. */
|
||||
const findPatternBranchScope = (node: SyntaxNode): SyntaxNode | undefined => {
|
||||
let current = node.parent;
|
||||
while (current) {
|
||||
if (PATTERN_BRANCH_TYPES.has(current.type)) return current;
|
||||
if (FUNCTION_NODE_TYPES.has(current.type)) return undefined;
|
||||
current = current.parent;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/** Bare nullable keywords that fastStripNullable must reject. */
|
||||
const FAST_NULLABLE_KEYWORDS = new Set(['null', 'undefined', 'void', 'None', 'nil']);
|
||||
|
||||
/**
|
||||
* Fast-path nullable check: 90%+ of type names are simple identifiers (e.g. "User")
|
||||
* that don't need the full stripNullable parse. Only call stripNullable when the
|
||||
* string contains nullable markers ('|' for union types, '?' for nullable suffix).
|
||||
*/
|
||||
const fastStripNullable = (typeName: string): string | undefined => {
|
||||
if (FAST_NULLABLE_KEYWORDS.has(typeName)) return undefined;
|
||||
return (typeName.indexOf('|') === -1 && typeName.indexOf('?') === -1)
|
||||
? typeName
|
||||
: stripNullable(typeName);
|
||||
};
|
||||
|
||||
/** Implementation of the lookup logic — shared between TypeEnvironment and the legacy export. */
|
||||
const lookupInEnv = (
|
||||
env: TypeEnv,
|
||||
varName: string,
|
||||
callNode: SyntaxNode,
|
||||
patternOverrides?: PatternOverrides,
|
||||
): string | undefined => {
|
||||
// Self/this receiver: resolve to enclosing class name via AST walk
|
||||
if (varName === 'self' || varName === 'this' || varName === '$this') {
|
||||
@@ -68,19 +114,33 @@ const lookupInEnv = (
|
||||
// Determine the enclosing function scope for the call
|
||||
const scopeKey = findEnclosingScopeKey(callNode);
|
||||
|
||||
// Check position-indexed pattern overrides first (e.g., Kotlin when/is smart casts).
|
||||
// These take priority over flat scopeEnv because they represent per-branch narrowing.
|
||||
if (scopeKey && patternOverrides) {
|
||||
const varOverrides = patternOverrides.get(scopeKey)?.get(varName);
|
||||
if (varOverrides) {
|
||||
const pos = callNode.startIndex;
|
||||
for (const override of varOverrides) {
|
||||
if (pos >= override.rangeStart && pos <= override.rangeEnd) {
|
||||
return fastStripNullable(override.typeName);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try function-local scope first
|
||||
if (scopeKey) {
|
||||
const scopeEnv = env.get(scopeKey);
|
||||
if (scopeEnv) {
|
||||
const result = scopeEnv.get(varName);
|
||||
if (result) return stripNullable(result);
|
||||
if (result) return fastStripNullable(result);
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to file-level scope
|
||||
const fileEnv = env.get(FILE_SCOPE);
|
||||
const raw = fileEnv?.get(varName);
|
||||
return raw ? stripNullable(raw) : undefined;
|
||||
return raw ? fastStripNullable(raw) : undefined;
|
||||
};
|
||||
|
||||
|
||||
@@ -283,20 +343,78 @@ const createClassNameLookup = (
|
||||
* the project are available for constructor inference in languages like Kotlin
|
||||
* where constructors are syntactically identical to function calls.
|
||||
*/
|
||||
/**
|
||||
* Node types whose subtrees can NEVER contain type-relevant descendants
|
||||
* (declarations, parameters, for-loops, class definitions, pattern bindings).
|
||||
* Conservative leaf-only set — verified safe across all 12 supported language grammars.
|
||||
* IMPORTANT: Do NOT add expression containers (arguments, binary_expression, etc.) —
|
||||
* they can contain arrow functions with typed parameters.
|
||||
*/
|
||||
const SKIP_SUBTREE_TYPES = new Set([
|
||||
// Plain string literals (NOT template_string — it contains interpolated expressions
|
||||
// that can hold arrow functions with typed parameters, e.g. `${(x: T) => x}`)
|
||||
'string', 'string_literal',
|
||||
'string_content', 'string_fragment', 'heredoc_body',
|
||||
// Comments
|
||||
'comment', 'line_comment', 'block_comment',
|
||||
// Numeric/boolean/null literals
|
||||
'number', 'integer_literal', 'float_literal',
|
||||
'true', 'false', 'null',
|
||||
// Regex
|
||||
'regex', 'regex_pattern',
|
||||
]);
|
||||
|
||||
export const buildTypeEnv = (
|
||||
tree: { rootNode: SyntaxNode },
|
||||
language: SupportedLanguages,
|
||||
symbolTable?: SymbolTable,
|
||||
): TypeEnvironment => {
|
||||
const env: TypeEnv = new Map();
|
||||
const patternOverrides: PatternOverrides = new Map();
|
||||
const localClassNames = new Set<string>();
|
||||
const classNames = createClassNameLookup(localClassNames, symbolTable);
|
||||
const config = typeConfigs[language];
|
||||
const bindings: ConstructorBinding[] = [];
|
||||
const pendingAssignments: Array<{ scope: string; lhs: string; rhs: string }> = [];
|
||||
|
||||
// Build ReturnTypeLookup from optional SymbolTable.
|
||||
// Conservative: returns undefined when callee is ambiguous (0 or 2+ matches).
|
||||
const returnTypeLookup: ReturnTypeLookup = {
|
||||
lookupReturnType(callee: string): string | undefined {
|
||||
if (!symbolTable) return undefined;
|
||||
if (isBuiltInOrNoise(callee)) return undefined;
|
||||
const callables = symbolTable.lookupFuzzyCallable(callee);
|
||||
if (callables.length !== 1) return undefined;
|
||||
const rawReturn = callables[0].returnType;
|
||||
if (!rawReturn) return undefined;
|
||||
return extractReturnTypeName(rawReturn);
|
||||
},
|
||||
lookupRawReturnType(callee: string): string | undefined {
|
||||
if (!symbolTable) return undefined;
|
||||
if (isBuiltInOrNoise(callee)) return undefined;
|
||||
const callables = symbolTable.lookupFuzzyCallable(callee);
|
||||
if (callables.length !== 1) return undefined;
|
||||
return callables[0].returnType;
|
||||
}
|
||||
};
|
||||
|
||||
// Pre-compute combined set of node types that need extractTypeBinding.
|
||||
// Single Set.has() replaces 3 separate checks per node in walk().
|
||||
const interestingNodeTypes = new Set<string>();
|
||||
TYPED_PARAMETER_TYPES.forEach(t => interestingNodeTypes.add(t));
|
||||
config.declarationNodeTypes.forEach(t => interestingNodeTypes.add(t));
|
||||
config.forLoopNodeTypes?.forEach(t => interestingNodeTypes.add(t));
|
||||
// Tier 2: copy-propagation (`const b = a`) and call-result propagation (`const b = foo()`)
|
||||
const pendingCopies: Array<{ scope: string; lhs: string; rhs: string }> = [];
|
||||
// NOTE: Infrastructure-ready — no language extractor currently returns { kind: 'callResult' }
|
||||
// from extractPendingAssignment. When one does, this array will bind variables to their
|
||||
// function return types at TypeEnv build time. See PendingAssignment in types.ts.
|
||||
const pendingCallResults: Array<{ scope: string; lhs: string; callee: string }> = [];
|
||||
// Maps `scope\0varName` → the type annotation AST node from the original declaration.
|
||||
// Allows pattern extractors to navigate back to the declaration's generic type arguments
|
||||
// (e.g., to extract T from Result<T, E> for `if let Ok(x) = res`).
|
||||
// NOTE: This is a SUPERSET of scopeEnv — entries exist even when extractSimpleTypeName
|
||||
// returns undefined for container types (User[], []User, List[User]). This is intentional:
|
||||
// for-loop Strategy 1 needs the raw AST type node for exactly those container types.
|
||||
const declarationTypeNodes = new Map<string, SyntaxNode>();
|
||||
|
||||
/**
|
||||
@@ -314,35 +432,94 @@ export const buildTypeEnv = (
|
||||
const extractTypeBinding = (node: SyntaxNode, scopeEnv: Map<string, string>, scope: string): void => {
|
||||
// This guard eliminates 90%+ of calls before any language dispatch.
|
||||
if (TYPED_PARAMETER_TYPES.has(node.type)) {
|
||||
const keysBefore = new Set(scopeEnv.keys());
|
||||
config.extractParameter(node, scopeEnv);
|
||||
// Capture the type node for newly introduced parameter bindings
|
||||
const typeNode = node.childForFieldName('type');
|
||||
// Capture the raw type annotation BEFORE extractParameter.
|
||||
// Most languages use 'name' field; Rust uses 'pattern'; TS uses 'pattern' for some param types.
|
||||
// Kotlin `parameter` nodes use positional children instead of named fields,
|
||||
// so we fall back to scanning children by type when childForFieldName returns null.
|
||||
let typeNode = node.childForFieldName('type');
|
||||
if (typeNode) {
|
||||
for (const varName of scopeEnv.keys()) {
|
||||
if (!keysBefore.has(varName)) {
|
||||
const nameNode = node.childForFieldName('name')
|
||||
?? node.childForFieldName('pattern');
|
||||
if (nameNode) {
|
||||
const varName = extractVarName(nameNode);
|
||||
if (varName && !declarationTypeNodes.has(`${scope}\0${varName}`)) {
|
||||
declarationTypeNodes.set(`${scope}\0${varName}`, typeNode);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback: positional children (Kotlin `parameter` → simple_identifier + user_type)
|
||||
let fallbackName: SyntaxNode | null = null;
|
||||
let fallbackType: SyntaxNode | null = null;
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
const child = node.namedChild(i);
|
||||
if (!child) continue;
|
||||
if (!fallbackName && (child.type === 'simple_identifier' || child.type === 'identifier')) {
|
||||
fallbackName = child;
|
||||
}
|
||||
if (!fallbackType && (child.type === 'user_type' || child.type === 'type_identifier'
|
||||
|| child.type === 'generic_type' || child.type === 'parameterized_type')) {
|
||||
fallbackType = child;
|
||||
}
|
||||
}
|
||||
if (fallbackName && fallbackType) {
|
||||
const varName = extractVarName(fallbackName);
|
||||
if (varName && !declarationTypeNodes.has(`${scope}\0${varName}`)) {
|
||||
declarationTypeNodes.set(`${scope}\0${varName}`, fallbackType);
|
||||
}
|
||||
}
|
||||
}
|
||||
config.extractParameter(node, scopeEnv);
|
||||
return;
|
||||
}
|
||||
// For-each loop variable bindings (Java/C#/Kotlin): explicit element types in the AST.
|
||||
// Checked before declarationNodeTypes — loop variables are not declarations.
|
||||
if (config.forLoopNodeTypes?.has(node.type)) {
|
||||
config.extractForLoopBinding?.(node, scopeEnv);
|
||||
if (config.extractForLoopBinding) {
|
||||
const forLoopCtx: ForLoopExtractorContext = { scopeEnv, declarationTypeNodes, scope, returnTypeLookup };
|
||||
config.extractForLoopBinding(node, forLoopCtx);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (config.declarationNodeTypes.has(node.type)) {
|
||||
const keysBefore = new Set(scopeEnv.keys());
|
||||
config.extractDeclaration(node, scopeEnv);
|
||||
// Capture the type annotation AST node for newly introduced bindings.
|
||||
// Only declarations with an explicit 'type' field are recorded — constructor
|
||||
// inferences (Tier 1) don't have a type annotation node to preserve.
|
||||
const typeNode = node.childForFieldName('type');
|
||||
// Capture the raw type annotation AST node BEFORE extractDeclaration.
|
||||
// This decouples type node capture from scopeEnv success — container types
|
||||
// (User[], []User, List[User]) that fail extractSimpleTypeName still get
|
||||
// their AST type node recorded for Strategy 1 for-loop resolution.
|
||||
// Try direct extraction first (works for Go var_spec, Python assignment, Rust let_declaration).
|
||||
// Try direct type field first, then unwrap wrapper nodes (C# field_declaration,
|
||||
// local_declaration_statement wrap their type inside a variable_declaration child).
|
||||
let typeNode = node.childForFieldName('type');
|
||||
if (!typeNode) {
|
||||
// C# field_declaration / local_declaration_statement wrap type inside variable_declaration.
|
||||
// Use manual loop instead of namedChildren.find() to avoid array allocation on hot path.
|
||||
let wrapped = node.childForFieldName('declaration');
|
||||
if (!wrapped) {
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
const c = node.namedChild(i);
|
||||
if (c?.type === 'variable_declaration') { wrapped = c; break; }
|
||||
}
|
||||
}
|
||||
if (wrapped) typeNode = wrapped.childForFieldName('type');
|
||||
}
|
||||
if (typeNode) {
|
||||
const nameNode = node.childForFieldName('name')
|
||||
?? node.childForFieldName('left')
|
||||
?? node.childForFieldName('pattern');
|
||||
if (nameNode) {
|
||||
const varName = extractVarName(nameNode);
|
||||
if (varName && !declarationTypeNodes.has(`${scope}\0${varName}`)) {
|
||||
declarationTypeNodes.set(`${scope}\0${varName}`, typeNode);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Run the language-specific declaration extractor (may or may not add to scopeEnv).
|
||||
const keysBefore = typeNode ? new Set(scopeEnv.keys()) : undefined;
|
||||
config.extractDeclaration(node, scopeEnv);
|
||||
// Fallback: for multi-declarator languages (TS, C#, Java) where the type field
|
||||
// is on variable_declarator children, capture via keysBefore/keysAfter diff.
|
||||
if (typeNode && keysBefore) {
|
||||
for (const varName of scopeEnv.keys()) {
|
||||
if (!keysBefore.has(varName)) {
|
||||
if (!keysBefore.has(varName) && !declarationTypeNodes.has(`${scope}\0${varName}`)) {
|
||||
declarationTypeNodes.set(`${scope}\0${varName}`, typeNode);
|
||||
}
|
||||
}
|
||||
@@ -358,6 +535,9 @@ export const buildTypeEnv = (
|
||||
};
|
||||
|
||||
const walk = (node: SyntaxNode, currentScope: string): void => {
|
||||
// Fast skip: subtrees that can never contain type-relevant nodes (leaf-like literals).
|
||||
if (SKIP_SUBTREE_TYPES.has(node.type)) return;
|
||||
|
||||
// Collect class/struct names as we encounter them (used by extractInitializer
|
||||
// to distinguish constructor calls from function calls, e.g. C++ `User()` vs `getUser()`)
|
||||
// Currently only C++ uses this locally; other languages rely on the SymbolTable path.
|
||||
@@ -375,20 +555,46 @@ export const buildTypeEnv = (
|
||||
if (funcName) scope = `${funcName}@${node.startIndex}`;
|
||||
}
|
||||
|
||||
// Get or create the sub-map for this scope
|
||||
if (!env.has(scope)) env.set(scope, new Map());
|
||||
const scopeEnv = env.get(scope)!;
|
||||
|
||||
extractTypeBinding(node, scopeEnv, scope);
|
||||
// Only create scope map and call extractTypeBinding for interesting node types.
|
||||
// Single Set.has() replaces 3 separate checks inside extractTypeBinding.
|
||||
if (interestingNodeTypes.has(node.type)) {
|
||||
if (!env.has(scope)) env.set(scope, new Map());
|
||||
const scopeEnv = env.get(scope)!;
|
||||
extractTypeBinding(node, scopeEnv, scope);
|
||||
}
|
||||
|
||||
// Pattern binding extraction: handles constructs that introduce NEW typed variables
|
||||
// via pattern matching (e.g. `if let Some(x) = opt`, `x instanceof T t`).
|
||||
// Runs after Tier 0/1 so scopeEnv already contains the source variable's type.
|
||||
// Conservative: extractor returns undefined when source type is unknown.
|
||||
if (config.extractPatternBinding) {
|
||||
if (config.extractPatternBinding && (!config.patternBindingNodeTypes || config.patternBindingNodeTypes.has(node.type))) {
|
||||
// Ensure scopeEnv exists for pattern binding reads/writes
|
||||
if (!env.has(scope)) env.set(scope, new Map());
|
||||
const scopeEnv = env.get(scope)!;
|
||||
const patternBinding = config.extractPatternBinding(node, scopeEnv, declarationTypeNodes, scope);
|
||||
if (patternBinding && !scopeEnv.has(patternBinding.varName)) {
|
||||
scopeEnv.set(patternBinding.varName, patternBinding.typeName);
|
||||
if (patternBinding) {
|
||||
if (config.allowPatternBindingOverwrite) {
|
||||
// Position-indexed: store per-branch binding for smart-cast narrowing.
|
||||
// Each when arm / switch case gets its own type for the variable,
|
||||
// preventing cross-arm contamination (e.g., Kotlin when/is).
|
||||
const branchNode = findPatternBranchScope(node);
|
||||
if (branchNode) {
|
||||
if (!patternOverrides.has(scope)) patternOverrides.set(scope, new Map());
|
||||
const varMap = patternOverrides.get(scope)!;
|
||||
if (!varMap.has(patternBinding.varName)) varMap.set(patternBinding.varName, []);
|
||||
varMap.get(patternBinding.varName)!.push({
|
||||
rangeStart: branchNode.startIndex,
|
||||
rangeEnd: branchNode.endIndex,
|
||||
typeName: patternBinding.typeName,
|
||||
});
|
||||
}
|
||||
// Also store in flat scopeEnv as fallback (last arm wins — same as before
|
||||
// for code that doesn't use position-indexed lookup).
|
||||
scopeEnv.set(patternBinding.varName, patternBinding.typeName);
|
||||
} else if (!scopeEnv.has(patternBinding.varName)) {
|
||||
// First-writer-wins for languages without smart-cast overwrite (Java instanceof, etc.)
|
||||
scopeEnv.set(patternBinding.varName, patternBinding.typeName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -397,9 +603,18 @@ export const buildTypeEnv = (
|
||||
// (JS uses variable_declarator/name/value, Rust uses let_declaration/pattern/value,
|
||||
// Python uses assignment/left/right, Go uses short_var_declaration/expression_list).
|
||||
if (config.extractPendingAssignment && config.declarationNodeTypes.has(node.type)) {
|
||||
const pending = config.extractPendingAssignment(node, scopeEnv);
|
||||
if (pending) {
|
||||
pendingAssignments.push({ scope, ...pending });
|
||||
// scopeEnv is guaranteed to exist here because declarationNodeTypes is a subset
|
||||
// of interestingNodeTypes, so extractTypeBinding already created the scope map above.
|
||||
const scopeEnv = env.get(scope);
|
||||
if (scopeEnv) {
|
||||
const pending = config.extractPendingAssignment(node, scopeEnv);
|
||||
if (pending) {
|
||||
if (pending.kind === 'copy') {
|
||||
pendingCopies.push({ scope, lhs: pending.lhs, rhs: pending.rhs });
|
||||
} else {
|
||||
pendingCallResults.push({ scope, lhs: pending.lhs, callee: pending.callee });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -407,8 +622,11 @@ export const buildTypeEnv = (
|
||||
// Only collect if TypeEnv didn't already resolve this binding.
|
||||
if (config.scanConstructorBinding) {
|
||||
const result = config.scanConstructorBinding(node);
|
||||
if (result && !scopeEnv.has(result.varName)) {
|
||||
bindings.push({ scope, ...result });
|
||||
if (result) {
|
||||
const scopeEnv = env.get(scope);
|
||||
if (!scopeEnv?.has(result.varName)) {
|
||||
bindings.push({ scope, ...result });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -421,22 +639,32 @@ export const buildTypeEnv = (
|
||||
|
||||
walk(tree.rootNode, FILE_SCOPE);
|
||||
|
||||
// Tier 2: single-pass assignment chain propagation in source order.
|
||||
// Resolves `const b = a` where `a` has a known type from Tier 0/1.
|
||||
// Tier 2a: copy-propagation — `const b = a` where `a` has a known type from Tier 0/1.
|
||||
// Multi-hop chains resolve when forward-declared (a→b→c in source order);
|
||||
// reverse-order assignments are depth-1 only. No fixpoint iteration —
|
||||
// this covers 95%+ of real-world patterns.
|
||||
for (const { scope, lhs, rhs } of pendingAssignments) {
|
||||
for (const { scope, lhs, rhs } of pendingCopies) {
|
||||
const scopeEnv = env.get(scope);
|
||||
if (!scopeEnv || scopeEnv.has(lhs)) continue;
|
||||
const rhsType = scopeEnv.get(rhs) ?? env.get(FILE_SCOPE)?.get(rhs);
|
||||
if (rhsType) {
|
||||
scopeEnv.set(lhs, rhsType);
|
||||
}
|
||||
if (rhsType) scopeEnv.set(lhs, rhsType);
|
||||
}
|
||||
|
||||
// Tier 2b: call-result propagation — `const b = foo()` where `foo` has a declared return type.
|
||||
// Uses ReturnTypeLookup which is backed by SymbolTable.lookupFuzzyCallable.
|
||||
// Conservative: only binds when exactly one callable matches (avoids overload ambiguity).
|
||||
// NOTE: Currently dormant — no extractPendingAssignment implementation emits 'callResult' yet.
|
||||
// The loop is structurally complete and will activate when any language extractor starts
|
||||
// returning { kind: 'callResult', lhs, callee } from extractPendingAssignment.
|
||||
for (const { scope, lhs, callee } of pendingCallResults) {
|
||||
const scopeEnv = env.get(scope);
|
||||
if (!scopeEnv || scopeEnv.has(lhs)) continue;
|
||||
const typeName = returnTypeLookup.lookupReturnType(callee);
|
||||
if (typeName) scopeEnv.set(lhs, typeName);
|
||||
}
|
||||
|
||||
return {
|
||||
lookup: (varName, callNode) => lookupInEnv(env, varName, callNode),
|
||||
lookup: (varName, callNode) => lookupInEnv(env, varName, callNode, patternOverrides),
|
||||
constructorBindings: bindings,
|
||||
env,
|
||||
};
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import type { SyntaxNode } from '../utils.js';
|
||||
import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, InitializerExtractor, ClassNameLookup, ConstructorBindingScanner, PendingAssignmentExtractor } from './types.js';
|
||||
import { extractSimpleTypeName, extractVarName } from './shared.js';
|
||||
import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, InitializerExtractor, ClassNameLookup, ConstructorBindingScanner, PendingAssignmentExtractor, ForLoopExtractor } from './types.js';
|
||||
import { extractSimpleTypeName, extractVarName, resolveIterableElementType, methodToTypeArgPosition, type TypeArgPosition } from './shared.js';
|
||||
|
||||
const DECLARATION_NODE_TYPES: ReadonlySet<string> = new Set([
|
||||
'declaration',
|
||||
'for_range_loop',
|
||||
]);
|
||||
|
||||
/** C++: Type x = ...; Type* x; Type& x; */
|
||||
@@ -180,14 +179,181 @@ const extractPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) =>
|
||||
if (!finalName) return undefined;
|
||||
const lhs = extractVarName(finalName);
|
||||
if (!lhs || scopeEnv.has(lhs)) return undefined;
|
||||
return { lhs, rhs: value.text };
|
||||
return { kind: 'copy', lhs, rhs: value.text };
|
||||
};
|
||||
|
||||
// --- For-loop Tier 1c ---
|
||||
|
||||
const FOR_LOOP_NODE_TYPES: ReadonlySet<string> = new Set(['for_range_loop']);
|
||||
|
||||
/** Extract template type arguments from a C++ template_type node.
|
||||
* C++ template_type uses template_argument_list (not type_arguments), and each
|
||||
* argument is a type_descriptor with a 'type' field containing the type_specifier. */
|
||||
const extractCppTemplateTypeArgs = (templateTypeNode: SyntaxNode): string[] => {
|
||||
const argsNode = templateTypeNode.childForFieldName('arguments');
|
||||
if (!argsNode || argsNode.type !== 'template_argument_list') return [];
|
||||
const result: string[] = [];
|
||||
for (let i = 0; i < argsNode.namedChildCount; i++) {
|
||||
let argNode = argsNode.namedChild(i);
|
||||
if (!argNode) continue;
|
||||
// type_descriptor wraps the actual type specifier in a 'type' field
|
||||
if (argNode.type === 'type_descriptor') {
|
||||
const inner = argNode.childForFieldName('type');
|
||||
if (inner) argNode = inner;
|
||||
}
|
||||
const name = extractSimpleTypeName(argNode);
|
||||
if (name) result.push(name);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
/** Extract element type from a C++ type annotation AST node.
|
||||
* Handles: template_type (vector<User>, map<string, User>),
|
||||
* pointer/reference types (User*, User&). */
|
||||
const extractCppElementTypeFromTypeNode = (typeNode: SyntaxNode, pos: TypeArgPosition = 'last', depth = 0): string | undefined => {
|
||||
if (depth > 50) return undefined;
|
||||
// template_type: vector<User>, map<string, User> — extract type arg based on position
|
||||
if (typeNode.type === 'template_type') {
|
||||
const args = extractCppTemplateTypeArgs(typeNode);
|
||||
if (args.length >= 1) return pos === 'first' ? args[0] : args[args.length - 1];
|
||||
}
|
||||
// reference/pointer types: unwrap and recurse (vector<User>& → vector<User>)
|
||||
if (typeNode.type === 'reference_type' || typeNode.type === 'pointer_type'
|
||||
|| typeNode.type === 'type_descriptor') {
|
||||
const inner = typeNode.lastNamedChild;
|
||||
if (inner) return extractCppElementTypeFromTypeNode(inner, pos, depth + 1);
|
||||
}
|
||||
// qualified/scoped types: std::vector<User> → unwrap to template_type child
|
||||
if (typeNode.type === 'qualified_identifier' || typeNode.type === 'scoped_type_identifier') {
|
||||
const inner = typeNode.lastNamedChild;
|
||||
if (inner) return extractCppElementTypeFromTypeNode(inner, pos, depth + 1);
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/** Walk up from a for-range-loop to the enclosing function_definition and search parameters
|
||||
* for one named `iterableName`. Returns the element type from its annotation. */
|
||||
const findCppParamElementType = (iterableName: string, startNode: SyntaxNode, pos: TypeArgPosition = 'last'): string | undefined => {
|
||||
let current: SyntaxNode | null = startNode.parent;
|
||||
while (current) {
|
||||
if (current.type === 'function_definition') {
|
||||
const declarator = current.childForFieldName('declarator');
|
||||
// function_definition > declarator (function_declarator) > parameters (parameter_list)
|
||||
const paramsNode = declarator?.childForFieldName('parameters');
|
||||
if (paramsNode) {
|
||||
for (let i = 0; i < paramsNode.namedChildCount; i++) {
|
||||
const param = paramsNode.namedChild(i);
|
||||
if (!param || param.type !== 'parameter_declaration') continue;
|
||||
const paramDeclarator = param.childForFieldName('declarator');
|
||||
if (!paramDeclarator) continue;
|
||||
// Unwrap reference/pointer declarators: vector<User>& users → &users
|
||||
let identNode = paramDeclarator;
|
||||
if (identNode.type === 'reference_declarator' || identNode.type === 'pointer_declarator') {
|
||||
identNode = identNode.firstNamedChild ?? identNode;
|
||||
}
|
||||
if (identNode.text !== iterableName) continue;
|
||||
const typeNode = param.childForFieldName('type');
|
||||
if (typeNode) return extractCppElementTypeFromTypeNode(typeNode, pos);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
current = current.parent;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/** C++: for (auto& user : users) — extract loop variable binding.
|
||||
* Handles explicit types (for (User& user : users)) and auto (for (auto& user : users)).
|
||||
* For auto, resolves element type from the iterable's container type. */
|
||||
const extractForLoopBinding: ForLoopExtractor = (node, { scopeEnv, declarationTypeNodes, scope } ): void => {
|
||||
if (node.type !== 'for_range_loop') return;
|
||||
|
||||
const typeNode = node.childForFieldName('type');
|
||||
const declaratorNode = node.childForFieldName('declarator');
|
||||
const rightNode = node.childForFieldName('right');
|
||||
if (!typeNode || !declaratorNode || !rightNode) return;
|
||||
|
||||
// Unwrap reference/pointer declarator to get the loop variable name
|
||||
let nameNode = declaratorNode;
|
||||
if (nameNode.type === 'reference_declarator' || nameNode.type === 'pointer_declarator') {
|
||||
nameNode = nameNode.firstNamedChild ?? nameNode;
|
||||
}
|
||||
|
||||
// Handle structured bindings: auto& [key, value] or auto [key, value]
|
||||
// Bind the last identifier (value heuristic for [key, value] patterns)
|
||||
let loopVarName: string | undefined;
|
||||
if (nameNode.type === 'structured_binding_declarator') {
|
||||
const lastChild = nameNode.lastNamedChild;
|
||||
if (lastChild?.type === 'identifier') {
|
||||
loopVarName = lastChild.text;
|
||||
}
|
||||
} else if (declaratorNode.type === 'structured_binding_declarator') {
|
||||
const lastChild = declaratorNode.lastNamedChild;
|
||||
if (lastChild?.type === 'identifier') {
|
||||
loopVarName = lastChild.text;
|
||||
}
|
||||
}
|
||||
|
||||
const varName = loopVarName ?? extractVarName(nameNode);
|
||||
if (!varName) return;
|
||||
|
||||
// Check if the type is auto/placeholder — if not, use the explicit type directly
|
||||
const isAuto = typeNode.type === 'placeholder_type_specifier'
|
||||
|| typeNode.text === 'auto'
|
||||
|| typeNode.text === 'const auto'
|
||||
|| typeNode.text === 'decltype(auto)';
|
||||
|
||||
if (!isAuto) {
|
||||
// Explicit type: for (User& user : users) — extract directly
|
||||
const typeName = extractSimpleTypeName(typeNode);
|
||||
if (typeName) scopeEnv.set(varName, typeName);
|
||||
return;
|
||||
}
|
||||
|
||||
// auto/const auto/auto& — resolve from the iterable's container type
|
||||
// Extract iterable name + optional method
|
||||
let iterableName: string | undefined;
|
||||
let methodName: string | undefined;
|
||||
if (rightNode.type === 'identifier') {
|
||||
iterableName = rightNode.text;
|
||||
} else if (rightNode.type === 'field_expression') {
|
||||
const prop = rightNode.lastNamedChild;
|
||||
if (prop) iterableName = prop.text;
|
||||
} else if (rightNode.type === 'call_expression') {
|
||||
// users.begin() is NOT used in range-for, but container.items() etc. might be
|
||||
const fieldExpr = rightNode.childForFieldName('function');
|
||||
if (fieldExpr?.type === 'field_expression') {
|
||||
const obj = fieldExpr.firstNamedChild;
|
||||
if (obj?.type === 'identifier') iterableName = obj.text;
|
||||
const field = fieldExpr.lastNamedChild;
|
||||
if (field?.type === 'field_identifier') methodName = field.text;
|
||||
}
|
||||
} else if (rightNode.type === 'pointer_expression') {
|
||||
// Dereference: for (auto& user : *ptr) → pointer_expression > identifier
|
||||
// Only handles simple *identifier; *this->field and **ptr are not resolved.
|
||||
const operand = rightNode.lastNamedChild;
|
||||
if (operand?.type === 'identifier') iterableName = operand.text;
|
||||
}
|
||||
if (!iterableName) return;
|
||||
|
||||
const containerTypeName = scopeEnv.get(iterableName);
|
||||
const typeArgPos = methodToTypeArgPosition(methodName, containerTypeName);
|
||||
const elementType = resolveIterableElementType(
|
||||
iterableName, node, scopeEnv, declarationTypeNodes, scope,
|
||||
extractCppElementTypeFromTypeNode, findCppParamElementType,
|
||||
typeArgPos,
|
||||
);
|
||||
if (elementType) scopeEnv.set(varName, elementType);
|
||||
};
|
||||
|
||||
export const typeConfig: LanguageTypeConfig = {
|
||||
declarationNodeTypes: DECLARATION_NODE_TYPES,
|
||||
forLoopNodeTypes: FOR_LOOP_NODE_TYPES,
|
||||
extractDeclaration,
|
||||
extractParameter,
|
||||
extractInitializer,
|
||||
scanConstructorBinding,
|
||||
extractForLoopBinding,
|
||||
extractPendingAssignment,
|
||||
};
|
||||
|
||||
@@ -1,31 +1,18 @@
|
||||
import type { SyntaxNode } from '../utils.js';
|
||||
import type { ConstructorBindingScanner, ForLoopExtractor, LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, PendingAssignmentExtractor } from './types.js';
|
||||
import { extractSimpleTypeName, extractVarName, findChildByType, unwrapAwait } from './shared.js';
|
||||
import type { ConstructorBindingScanner, ForLoopExtractor, LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, PendingAssignmentExtractor, PatternBindingExtractor } from './types.js';
|
||||
import { extractSimpleTypeName, extractVarName, findChildByType, unwrapAwait, extractGenericTypeArgs, resolveIterableElementType, methodToTypeArgPosition, extractElementTypeFromString, type TypeArgPosition } from './shared.js';
|
||||
|
||||
/** Known container property accessors that operate on the container itself (e.g., dict.Keys, dict.Values) */
|
||||
const KNOWN_CONTAINER_PROPS: ReadonlySet<string> = new Set(['Keys', 'Values']);
|
||||
|
||||
const DECLARATION_NODE_TYPES: ReadonlySet<string> = new Set([
|
||||
'local_declaration_statement',
|
||||
'variable_declaration',
|
||||
'field_declaration',
|
||||
'is_pattern_expression',
|
||||
]);
|
||||
|
||||
/** C#: Type x = ...; var x = new Type(); obj is Type x */
|
||||
/** C#: Type x = ...; var x = new Type(); */
|
||||
const extractDeclaration: TypeBindingExtractor = (node: SyntaxNode, env: Map<string, string>): void => {
|
||||
// C# pattern matching: `obj is User user` → is_pattern_expression > declaration_pattern
|
||||
if (node.type === 'is_pattern_expression') {
|
||||
const pattern = node.childForFieldName('pattern');
|
||||
if (pattern?.type === 'declaration_pattern') {
|
||||
const typeNode = pattern.childForFieldName('type');
|
||||
const nameNode = pattern.childForFieldName('name');
|
||||
if (typeNode && nameNode) {
|
||||
const typeName = extractSimpleTypeName(typeNode);
|
||||
const varName = extractVarName(nameNode);
|
||||
if (typeName && varName) env.set(varName, typeName);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// C# tree-sitter: local_declaration_statement > variable_declaration > ...
|
||||
// Recursively descend through wrapper nodes
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
@@ -147,17 +134,176 @@ const FOR_LOOP_NODE_TYPES: ReadonlySet<string> = new Set([
|
||||
'foreach_statement',
|
||||
]);
|
||||
|
||||
/** C#: foreach (User user in users) — extract loop variable binding */
|
||||
const extractForLoopBinding: ForLoopExtractor = (node: SyntaxNode, scopeEnv: Map<string, string>): void => {
|
||||
/** Extract element type from a C# type annotation AST node.
|
||||
* Handles generic_name (List<User>), array_type (User[]), nullable_type (?).
|
||||
* `pos` selects which type arg: 'first' for keys, 'last' for values (default). */
|
||||
const extractCSharpElementTypeFromTypeNode = (typeNode: SyntaxNode, pos: TypeArgPosition = 'last', depth = 0): string | undefined => {
|
||||
if (depth > 50) return undefined;
|
||||
// generic_name: List<User>, IEnumerable<User>, Dictionary<string, User>
|
||||
// C# uses generic_name (not generic_type)
|
||||
if (typeNode.type === 'generic_name') {
|
||||
const argList = findChildByType(typeNode, 'type_argument_list');
|
||||
if (argList && argList.namedChildCount >= 1) {
|
||||
if (pos === 'first') {
|
||||
const firstArg = argList.namedChild(0);
|
||||
if (firstArg) return extractSimpleTypeName(firstArg);
|
||||
} else {
|
||||
const lastArg = argList.namedChild(argList.namedChildCount - 1);
|
||||
if (lastArg) return extractSimpleTypeName(lastArg);
|
||||
}
|
||||
}
|
||||
}
|
||||
// array_type: User[]
|
||||
if (typeNode.type === 'array_type') {
|
||||
const elemNode = typeNode.firstNamedChild;
|
||||
if (elemNode) return extractSimpleTypeName(elemNode);
|
||||
}
|
||||
// nullable_type: unwrap and recurse (List<User>? → List<User> → User)
|
||||
if (typeNode.type === 'nullable_type') {
|
||||
const inner = typeNode.firstNamedChild;
|
||||
if (inner) return extractCSharpElementTypeFromTypeNode(inner, pos, depth + 1);
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/** Walk up from a foreach to the enclosing method and search parameters. */
|
||||
const findCSharpParamElementType = (iterableName: string, startNode: SyntaxNode, pos: TypeArgPosition = 'last'): string | undefined => {
|
||||
let current: SyntaxNode | null = startNode.parent;
|
||||
while (current) {
|
||||
if (current.type === 'method_declaration' || current.type === 'local_function_statement') {
|
||||
const paramsNode = current.childForFieldName('parameters');
|
||||
if (paramsNode) {
|
||||
for (let i = 0; i < paramsNode.namedChildCount; i++) {
|
||||
const param = paramsNode.namedChild(i);
|
||||
if (!param || param.type !== 'parameter') continue;
|
||||
const nameNode = param.childForFieldName('name');
|
||||
if (nameNode?.text !== iterableName) continue;
|
||||
const typeNode = param.childForFieldName('type');
|
||||
if (typeNode) return extractCSharpElementTypeFromTypeNode(typeNode, pos);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
current = current.parent;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/** C#: foreach (User user in users) — extract loop variable binding.
|
||||
* Tier 1c: for `foreach (var user in users)`, resolves element type from iterable. */
|
||||
const extractForLoopBinding: ForLoopExtractor = (node, { scopeEnv, declarationTypeNodes, scope, returnTypeLookup }): void => {
|
||||
const typeNode = node.childForFieldName('type');
|
||||
// The loop variable name is in the 'left' field in tree-sitter-c-sharp
|
||||
const nameNode = node.childForFieldName('left');
|
||||
if (!typeNode || !nameNode) return;
|
||||
// Skip 'var' — type would need to be inferred from the collection element type
|
||||
if (typeNode.type === 'implicit_type' && typeNode.text === 'var') return;
|
||||
const typeName = extractSimpleTypeName(typeNode);
|
||||
const varName = extractVarName(nameNode);
|
||||
if (typeName && varName) scopeEnv.set(varName, typeName);
|
||||
if (!varName) return;
|
||||
|
||||
// Explicit type (existing behavior): foreach (User user in users)
|
||||
if (!(typeNode.type === 'implicit_type' && typeNode.text === 'var')) {
|
||||
const typeName = extractSimpleTypeName(typeNode);
|
||||
if (typeName) scopeEnv.set(varName, typeName);
|
||||
return;
|
||||
}
|
||||
|
||||
// Tier 1c: implicit type (var) — resolve from iterable's container type
|
||||
const rightNode = node.childForFieldName('right');
|
||||
let iterableName: string | undefined;
|
||||
let methodName: string | undefined;
|
||||
let callExprElementType: string | undefined;
|
||||
|
||||
if (rightNode?.type === 'identifier') {
|
||||
iterableName = rightNode.text;
|
||||
} else if (rightNode?.type === 'member_access_expression') {
|
||||
// C# property access: data.Keys, data.Values → member_access_expression
|
||||
// Also handles bare member access: this.users, repo.users → use property as iterableName
|
||||
const obj = rightNode.childForFieldName('expression');
|
||||
const prop = rightNode.childForFieldName('name');
|
||||
const propText = prop?.type === 'identifier' ? prop.text : undefined;
|
||||
if (propText && KNOWN_CONTAINER_PROPS.has(propText)) {
|
||||
if (obj?.type === 'identifier') {
|
||||
iterableName = obj.text;
|
||||
} else if (obj?.type === 'member_access_expression') {
|
||||
// Nested member access: this.data.Values → obj is "this.data", extract "data"
|
||||
const innerProp = obj.childForFieldName('name');
|
||||
if (innerProp) iterableName = innerProp.text;
|
||||
}
|
||||
methodName = propText;
|
||||
} else if (propText) {
|
||||
// Bare member access: this.users → use property name for scopeEnv lookup
|
||||
iterableName = propText;
|
||||
}
|
||||
} else if (rightNode?.type === 'invocation_expression') {
|
||||
// C# method call: data.Select(...) → invocation_expression > member_access_expression
|
||||
// Direct function call: GetUsers() → invocation_expression > identifier
|
||||
const fn = rightNode.firstNamedChild;
|
||||
if (fn?.type === 'member_access_expression') {
|
||||
const obj = fn.childForFieldName('expression');
|
||||
const prop = fn.childForFieldName('name');
|
||||
if (obj?.type === 'identifier') iterableName = obj.text;
|
||||
if (prop?.type === 'identifier') methodName = prop.text;
|
||||
} else if (fn?.type === 'identifier') {
|
||||
// Direct function call: foreach (var u in GetUsers())
|
||||
const rawReturn = returnTypeLookup.lookupRawReturnType(fn.text);
|
||||
if (rawReturn) callExprElementType = extractElementTypeFromString(rawReturn);
|
||||
}
|
||||
}
|
||||
if (!iterableName && !callExprElementType) return;
|
||||
|
||||
let elementType: string | undefined;
|
||||
if (callExprElementType) {
|
||||
elementType = callExprElementType;
|
||||
} else {
|
||||
const containerTypeName = scopeEnv.get(iterableName!);
|
||||
const typeArgPos = methodToTypeArgPosition(methodName, containerTypeName);
|
||||
elementType = resolveIterableElementType(
|
||||
iterableName!, node, scopeEnv, declarationTypeNodes, scope,
|
||||
extractCSharpElementTypeFromTypeNode, findCSharpParamElementType,
|
||||
typeArgPos,
|
||||
);
|
||||
}
|
||||
if (elementType) scopeEnv.set(varName, elementType);
|
||||
};
|
||||
|
||||
/**
|
||||
* C# pattern binding extractor for `obj is Type variable` (type pattern).
|
||||
*
|
||||
* AST structure:
|
||||
* is_pattern_expression
|
||||
* expression: (the variable being tested)
|
||||
* pattern: declaration_pattern
|
||||
* type: (the declared type)
|
||||
* name: single_variable_designation > identifier (the new variable name)
|
||||
*
|
||||
* Conservative: returns undefined when the pattern field is absent, is not a
|
||||
* declaration_pattern, or when the type/name cannot be extracted.
|
||||
* No scopeEnv lookup is needed — the pattern explicitly declares the new variable's type.
|
||||
*/
|
||||
const extractPatternBinding: PatternBindingExtractor = (node) => {
|
||||
// is_pattern_expression: `obj is User user` — has a declaration_pattern child
|
||||
if (node.type === 'is_pattern_expression') {
|
||||
const pattern = node.childForFieldName('pattern');
|
||||
if (pattern?.type !== 'declaration_pattern' && pattern?.type !== 'recursive_pattern') return undefined;
|
||||
const typeNode = pattern.childForFieldName('type');
|
||||
const nameNode = pattern.childForFieldName('name');
|
||||
if (!typeNode || !nameNode) return undefined;
|
||||
const typeName = extractSimpleTypeName(typeNode);
|
||||
const varName = extractVarName(nameNode);
|
||||
if (!typeName || !varName) return undefined;
|
||||
return { varName, typeName };
|
||||
}
|
||||
// declaration_pattern / recursive_pattern: standalone in switch statements and switch expressions
|
||||
// `case User u:` or `User u =>` or `User { Name: "Alice" } u =>`
|
||||
// Both use the same 'type' and 'name' fields.
|
||||
if (node.type === 'declaration_pattern' || node.type === 'recursive_pattern') {
|
||||
const typeNode = node.childForFieldName('type');
|
||||
const nameNode = node.childForFieldName('name');
|
||||
if (!typeNode || !nameNode) return undefined;
|
||||
const typeName = extractSimpleTypeName(typeNode);
|
||||
const varName = extractVarName(nameNode);
|
||||
if (!typeName || !varName) return undefined;
|
||||
return { varName, typeName };
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/** C#: var alias = u → variable_declarator with name + equals_value_clause.
|
||||
@@ -179,7 +325,7 @@ const extractPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) =>
|
||||
}
|
||||
const valueNode = evc?.firstNamedChild ?? child.namedChild(child.namedChildCount - 1);
|
||||
if (valueNode && valueNode !== nameNode && (valueNode.type === 'identifier' || valueNode.type === 'simple_identifier')) {
|
||||
return { lhs, rhs: valueNode.text };
|
||||
return { kind: 'copy', lhs, rhs: valueNode.text };
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
@@ -188,9 +334,11 @@ const extractPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) =>
|
||||
export const typeConfig: LanguageTypeConfig = {
|
||||
declarationNodeTypes: DECLARATION_NODE_TYPES,
|
||||
forLoopNodeTypes: FOR_LOOP_NODE_TYPES,
|
||||
patternBindingNodeTypes: new Set(['is_pattern_expression', 'declaration_pattern', 'recursive_pattern']),
|
||||
extractDeclaration,
|
||||
extractParameter,
|
||||
scanConstructorBinding,
|
||||
extractForLoopBinding,
|
||||
extractPendingAssignment,
|
||||
extractPatternBinding,
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { SyntaxNode } from '../utils.js';
|
||||
import type { ConstructorBindingScanner, LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, PendingAssignmentExtractor } from './types.js';
|
||||
import { extractSimpleTypeName, extractVarName } from './shared.js';
|
||||
import type { ConstructorBindingScanner, ForLoopExtractor, LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, PendingAssignmentExtractor } from './types.js';
|
||||
import { extractSimpleTypeName, extractVarName, extractElementTypeFromString, extractGenericTypeArgs, findChildByType, resolveIterableElementType, methodToTypeArgPosition, type TypeArgPosition } from './shared.js';
|
||||
|
||||
const DECLARATION_NODE_TYPES: ReadonlySet<string> = new Set([
|
||||
'var_declaration',
|
||||
@@ -181,6 +181,209 @@ const scanConstructorBinding: ConstructorBindingScanner = (node) => {
|
||||
return { varName: leftIds[0].text, calleeName };
|
||||
};
|
||||
|
||||
const FOR_LOOP_NODE_TYPES: ReadonlySet<string> = new Set([
|
||||
'for_statement',
|
||||
]);
|
||||
|
||||
/** Go function/method node types that carry a parameter list. */
|
||||
const GO_FUNCTION_NODE_TYPES = new Set([
|
||||
'function_declaration', 'method_declaration', 'func_literal',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Extract element type from a Go type annotation AST node.
|
||||
* Handles:
|
||||
* slice_type "[]User" → element field → type_identifier "User"
|
||||
* array_type "[10]User" → element field → type_identifier "User"
|
||||
* Falls back to text-based extraction via extractElementTypeFromString.
|
||||
*/
|
||||
const extractGoElementTypeFromTypeNode = (typeNode: SyntaxNode, pos: TypeArgPosition = 'last'): string | undefined => {
|
||||
// slice_type: []User — element field is the element type
|
||||
if (typeNode.type === 'slice_type' || typeNode.type === 'array_type') {
|
||||
const elemNode = typeNode.childForFieldName('element');
|
||||
if (elemNode) return extractSimpleTypeName(elemNode);
|
||||
}
|
||||
// map_type: map[string]User — value field is the element type (for range, second var gets value)
|
||||
if (typeNode.type === 'map_type') {
|
||||
const valueNode = typeNode.childForFieldName('value');
|
||||
if (valueNode) return extractSimpleTypeName(valueNode);
|
||||
}
|
||||
// channel_type: chan User — the type argument is the element type
|
||||
if (typeNode.type === 'channel_type') {
|
||||
const valueNode = typeNode.childForFieldName('value') ?? typeNode.lastNamedChild;
|
||||
if (valueNode) return extractSimpleTypeName(valueNode);
|
||||
}
|
||||
// generic_type: Go 1.18+ generics (e.g., MySlice[User], Cache[string, User])
|
||||
// Use position-aware arg selection: 'first' for keys, 'last' for values.
|
||||
if (typeNode.type === 'generic_type') {
|
||||
const args = extractGenericTypeArgs(typeNode);
|
||||
if (args.length >= 1) return pos === 'first' ? args[0] : args[args.length - 1];
|
||||
}
|
||||
// Fallback: text-based extraction ([]User → User, User[] → User)
|
||||
return extractElementTypeFromString(typeNode.text, pos);
|
||||
};
|
||||
|
||||
/** Check if a Go type node represents a channel type. Used to determine
|
||||
* whether single-var range yields the element (channels) vs index (slices/maps). */
|
||||
const isChannelType = (
|
||||
iterableName: string,
|
||||
scopeEnv: ReadonlyMap<string, string>,
|
||||
declarationTypeNodes?: ReadonlyMap<string, SyntaxNode>,
|
||||
scope?: string,
|
||||
): boolean => {
|
||||
if (declarationTypeNodes && scope) {
|
||||
const typeNode = declarationTypeNodes.get(`${scope}\0${iterableName}`);
|
||||
if (typeNode) return typeNode.type === 'channel_type';
|
||||
}
|
||||
const t = scopeEnv.get(iterableName);
|
||||
return !!t && t.startsWith('chan ');
|
||||
};
|
||||
|
||||
/**
|
||||
* Walk up the AST from a for-statement to find the enclosing function declaration,
|
||||
* then search its parameters for one named `iterableName`.
|
||||
* Returns the element type extracted from its type annotation, or undefined.
|
||||
*
|
||||
* Go parameter_declaration has:
|
||||
* name field: identifier (the parameter name)
|
||||
* type field: the type node (slice_type for []User)
|
||||
*/
|
||||
const findGoParamElementType = (iterableName: string, startNode: SyntaxNode, pos: TypeArgPosition = 'last'): string | undefined => {
|
||||
let current: SyntaxNode | null = startNode.parent;
|
||||
while (current) {
|
||||
if (GO_FUNCTION_NODE_TYPES.has(current.type)) {
|
||||
const paramsNode = current.childForFieldName('parameters');
|
||||
if (paramsNode) {
|
||||
for (let i = 0; i < paramsNode.namedChildCount; i++) {
|
||||
const paramDecl = paramsNode.namedChild(i);
|
||||
if (!paramDecl || paramDecl.type !== 'parameter_declaration') continue;
|
||||
// parameter_declaration: name type — name field is the identifier
|
||||
const nameNode = paramDecl.childForFieldName('name');
|
||||
if (nameNode?.text === iterableName) {
|
||||
const typeNode = paramDecl.childForFieldName('type');
|
||||
if (typeNode) return extractGoElementTypeFromTypeNode(typeNode, pos);
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
current = current.parent;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Go: for _, user := range users where users has a known slice type.
|
||||
*
|
||||
* Go uses a single `for_statement` node for all for-loop forms. We detect
|
||||
* range-based loops by looking for a `range_clause` child node. C-style for
|
||||
* loops (with `for_clause`) and infinite loops (no clause) are ignored.
|
||||
*
|
||||
* Tier 1c: resolves the element type via three strategies in priority order:
|
||||
* 1. declarationTypeNodes — raw type annotation AST node
|
||||
* 2. scopeEnv string — extractElementTypeFromString on the stored type
|
||||
* 3. AST walk — walks up to the enclosing function's parameters to read []User directly
|
||||
* For `_, user := range users`, the loop variable is the second identifier in
|
||||
* the `left` expression_list (index is discarded, value is the element).
|
||||
*/
|
||||
const extractForLoopBinding: ForLoopExtractor = (node, { scopeEnv, declarationTypeNodes, scope, returnTypeLookup }): void => {
|
||||
if (node.type !== 'for_statement') return;
|
||||
|
||||
// Find the range_clause child — this distinguishes range loops from other for forms.
|
||||
let rangeClause: SyntaxNode | null = null;
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
const child = node.namedChild(i);
|
||||
if (child?.type === 'range_clause') {
|
||||
rangeClause = child;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!rangeClause) return;
|
||||
|
||||
// The iterable is the `right` field of the range_clause.
|
||||
const rightNode = rangeClause.childForFieldName('right');
|
||||
let iterableName: string | undefined;
|
||||
let callExprElementType: string | undefined;
|
||||
if (rightNode?.type === 'identifier') {
|
||||
iterableName = rightNode.text;
|
||||
} else if (rightNode?.type === 'selector_expression') {
|
||||
const field = rightNode.childForFieldName('field');
|
||||
if (field) iterableName = field.text;
|
||||
} else if (rightNode?.type === 'call_expression') {
|
||||
// Range over a call result: `for _, v := range getItems()` or `for _, v := range repo.All()`
|
||||
const funcNode = rightNode.childForFieldName('function');
|
||||
let callee: string | undefined;
|
||||
if (funcNode?.type === 'identifier') {
|
||||
callee = funcNode.text;
|
||||
} else if (funcNode?.type === 'selector_expression') {
|
||||
const field = funcNode.childForFieldName('field');
|
||||
if (field) callee = field.text;
|
||||
}
|
||||
if (callee) {
|
||||
const rawReturn = returnTypeLookup.lookupRawReturnType(callee);
|
||||
if (rawReturn) callExprElementType = extractElementTypeFromString(rawReturn);
|
||||
}
|
||||
}
|
||||
if (!iterableName && !callExprElementType) return;
|
||||
|
||||
let elementType: string | undefined;
|
||||
if (callExprElementType) {
|
||||
elementType = callExprElementType;
|
||||
} else {
|
||||
const containerTypeName = scopeEnv.get(iterableName!);
|
||||
const typeArgPos = methodToTypeArgPosition(undefined, containerTypeName);
|
||||
elementType = resolveIterableElementType(
|
||||
iterableName!, node, scopeEnv, declarationTypeNodes, scope,
|
||||
extractGoElementTypeFromTypeNode, findGoParamElementType,
|
||||
typeArgPos,
|
||||
);
|
||||
}
|
||||
if (!elementType) return;
|
||||
|
||||
// The loop variable(s) are in the `left` field.
|
||||
// Go range semantics:
|
||||
// Slice/Array/String: single-var → INDEX (int); two-var → (index, element)
|
||||
// Map: single-var → KEY; two-var → (key, value)
|
||||
// Channel: single-var → ELEMENT (channels have no index)
|
||||
const leftNode = rangeClause.childForFieldName('left');
|
||||
if (!leftNode) return;
|
||||
|
||||
let loopVarNode: SyntaxNode | null = null;
|
||||
if (leftNode.type === 'expression_list') {
|
||||
if (leftNode.namedChildCount >= 2) {
|
||||
// Two-var form: `_, user` or `i, user` — second variable gets element/value type
|
||||
loopVarNode = leftNode.namedChild(1);
|
||||
} else {
|
||||
// Single-var in expression_list — yields INDEX for slices/maps, ELEMENT for channels.
|
||||
// For call-expression iterables (iterableName undefined), conservative: treat as non-channel.
|
||||
// Channels are rarely returned from function calls, and even if they were, skipping here
|
||||
// just means we miss a binding rather than create an incorrect one.
|
||||
if (iterableName && isChannelType(iterableName, scopeEnv, declarationTypeNodes, scope)) {
|
||||
loopVarNode = leftNode.namedChild(0);
|
||||
} else {
|
||||
return; // index-only range on slice/map — skip
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Plain identifier (single-var form without expression_list)
|
||||
// For call-expression iterables (iterableName undefined), conservative: treat as non-channel.
|
||||
// Channels are rarely returned from function calls, and even if they were, skipping here
|
||||
// just means we miss a binding rather than create an incorrect one.
|
||||
if (iterableName && isChannelType(iterableName, scopeEnv, declarationTypeNodes, scope)) {
|
||||
loopVarNode = leftNode;
|
||||
} else {
|
||||
return; // index-only range on slice/map — skip
|
||||
}
|
||||
}
|
||||
if (!loopVarNode) return;
|
||||
|
||||
// Skip the blank identifier `_`
|
||||
if (loopVarNode.text === '_') return;
|
||||
|
||||
const loopVarName = extractVarName(loopVarNode);
|
||||
if (loopVarName) scopeEnv.set(loopVarName, elementType);
|
||||
};
|
||||
|
||||
/** Go: alias := u (short_var_declaration) or var b = u (var_spec) */
|
||||
const extractPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) => {
|
||||
if (node.type === 'short_var_declaration') {
|
||||
@@ -193,7 +396,7 @@ const extractPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) =>
|
||||
if (lhsNode.type !== 'identifier') return undefined;
|
||||
const lhs = lhsNode.text;
|
||||
if (scopeEnv.has(lhs)) return undefined;
|
||||
if (rhsNode.type === 'identifier') return { lhs, rhs: rhsNode.text };
|
||||
if (rhsNode.type === 'identifier') return { kind: 'copy', lhs, rhs: rhsNode.text };
|
||||
return undefined;
|
||||
}
|
||||
if (node.type === 'var_spec' || node.type === 'var_declaration') {
|
||||
@@ -218,7 +421,7 @@ const extractPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) =>
|
||||
if (spec.child(i)?.type === 'expression_list') { exprList = spec.child(i); break; }
|
||||
}
|
||||
const rhsNode = exprList?.firstNamedChild;
|
||||
if (rhsNode?.type === 'identifier') return { lhs, rhs: rhsNode.text };
|
||||
if (rhsNode?.type === 'identifier') return { kind: 'copy', lhs, rhs: rhsNode.text };
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
@@ -226,8 +429,10 @@ const extractPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) =>
|
||||
|
||||
export const typeConfig: LanguageTypeConfig = {
|
||||
declarationNodeTypes: DECLARATION_NODE_TYPES,
|
||||
forLoopNodeTypes: FOR_LOOP_NODE_TYPES,
|
||||
extractDeclaration,
|
||||
extractParameter,
|
||||
scanConstructorBinding,
|
||||
extractForLoopBinding,
|
||||
extractPendingAssignment,
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { SyntaxNode } from '../utils.js';
|
||||
import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, InitializerExtractor, ClassNameLookup, ConstructorBindingScanner, ForLoopExtractor, PendingAssignmentExtractor, PatternBindingExtractor } from './types.js';
|
||||
import { extractSimpleTypeName, extractVarName, findChildByType } from './shared.js';
|
||||
import { extractSimpleTypeName, extractVarName, findChildByType, extractGenericTypeArgs, resolveIterableElementType, methodToTypeArgPosition, extractElementTypeFromString, type TypeArgPosition } from './shared.js';
|
||||
|
||||
// ── Java ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -89,14 +89,103 @@ const JAVA_FOR_LOOP_NODE_TYPES: ReadonlySet<string> = new Set([
|
||||
'enhanced_for_statement',
|
||||
]);
|
||||
|
||||
/** Java: for (User user : users) — extract loop variable binding */
|
||||
const extractJavaForLoopBinding: ForLoopExtractor = (node: SyntaxNode, scopeEnv: Map<string, string>): void => {
|
||||
/** Extract element type from a Java type annotation AST node.
|
||||
* Handles generic_type (List<User>), array_type (User[]). */
|
||||
const extractJavaElementTypeFromTypeNode = (typeNode: SyntaxNode, pos: TypeArgPosition = 'last'): string | undefined => {
|
||||
if (typeNode.type === 'generic_type') {
|
||||
const args = extractGenericTypeArgs(typeNode);
|
||||
if (args.length >= 1) return pos === 'first' ? args[0] : args[args.length - 1];
|
||||
}
|
||||
if (typeNode.type === 'array_type') {
|
||||
const elemNode = typeNode.firstNamedChild;
|
||||
if (elemNode) return extractSimpleTypeName(elemNode);
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/** Walk up from a for-each to the enclosing method_declaration and search parameters. */
|
||||
const findJavaParamElementType = (iterableName: string, startNode: SyntaxNode, pos: TypeArgPosition = 'last'): string | undefined => {
|
||||
let current: SyntaxNode | null = startNode.parent;
|
||||
while (current) {
|
||||
if (current.type === 'method_declaration' || current.type === 'constructor_declaration') {
|
||||
const paramsNode = current.childForFieldName('parameters');
|
||||
if (paramsNode) {
|
||||
for (let i = 0; i < paramsNode.namedChildCount; i++) {
|
||||
const param = paramsNode.namedChild(i);
|
||||
if (!param || param.type !== 'formal_parameter') continue;
|
||||
const nameNode = param.childForFieldName('name');
|
||||
if (nameNode?.text !== iterableName) continue;
|
||||
const typeNode = param.childForFieldName('type');
|
||||
if (typeNode) return extractJavaElementTypeFromTypeNode(typeNode, pos);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
current = current.parent;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/** Java: for (User user : users) — extract loop variable binding.
|
||||
* Tier 1c: for `for (var user : users)`, resolves element type from iterable. */
|
||||
const extractJavaForLoopBinding: ForLoopExtractor = (node, { scopeEnv, declarationTypeNodes, scope, returnTypeLookup }): void => {
|
||||
const typeNode = node.childForFieldName('type');
|
||||
const nameNode = node.childForFieldName('name');
|
||||
if (!typeNode || !nameNode) return;
|
||||
const typeName = extractSimpleTypeName(typeNode);
|
||||
const varName = extractVarName(nameNode);
|
||||
if (typeName && varName) scopeEnv.set(varName, typeName);
|
||||
if (!varName) return;
|
||||
|
||||
// Explicit type (existing behavior): for (User user : users)
|
||||
const typeName = extractSimpleTypeName(typeNode);
|
||||
if (typeName && typeName !== 'var') {
|
||||
scopeEnv.set(varName, typeName);
|
||||
return;
|
||||
}
|
||||
|
||||
// Tier 1c: var — resolve from iterable's container type
|
||||
const iterableNode = node.childForFieldName('value');
|
||||
if (!iterableNode) return;
|
||||
|
||||
let iterableName: string | undefined;
|
||||
let methodName: string | undefined;
|
||||
let callExprElementType: string | undefined;
|
||||
if (iterableNode.type === 'identifier') {
|
||||
iterableName = iterableNode.text;
|
||||
} else if (iterableNode.type === 'field_access') {
|
||||
const field = iterableNode.childForFieldName('field');
|
||||
if (field) iterableName = field.text;
|
||||
} else if (iterableNode.type === 'method_invocation') {
|
||||
// data.keySet() → method_invocation > object: identifier + name: identifier
|
||||
// Also handles this.data.values() → object is field_access, extract inner field name
|
||||
const obj = iterableNode.childForFieldName('object');
|
||||
const name = iterableNode.childForFieldName('name');
|
||||
if (obj?.type === 'identifier') {
|
||||
iterableName = obj.text;
|
||||
} else if (obj?.type === 'field_access') {
|
||||
const innerField = obj.childForFieldName('field');
|
||||
if (innerField) iterableName = innerField.text;
|
||||
} else if (!obj && name) {
|
||||
// Direct function call: for (var u : getUsers()) — no receiver object
|
||||
const rawReturn = returnTypeLookup.lookupRawReturnType(name.text);
|
||||
if (rawReturn) callExprElementType = extractElementTypeFromString(rawReturn);
|
||||
}
|
||||
if (name) methodName = name.text;
|
||||
}
|
||||
if (!iterableName && !callExprElementType) return;
|
||||
|
||||
let elementType: string | undefined;
|
||||
if (callExprElementType) {
|
||||
elementType = callExprElementType;
|
||||
} else {
|
||||
const containerTypeName = scopeEnv.get(iterableName!);
|
||||
const typeArgPos = methodToTypeArgPosition(methodName, containerTypeName);
|
||||
elementType = resolveIterableElementType(
|
||||
iterableName!, node, scopeEnv, declarationTypeNodes, scope,
|
||||
extractJavaElementTypeFromTypeNode, findJavaParamElementType,
|
||||
typeArgPos,
|
||||
);
|
||||
}
|
||||
if (elementType) scopeEnv.set(varName, elementType);
|
||||
};
|
||||
|
||||
/** Java: var alias = u → local_variable_declaration > variable_declarator with name/value */
|
||||
@@ -109,7 +198,7 @@ const extractJavaPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv
|
||||
if (!nameNode || !valueNode) continue;
|
||||
const lhs = nameNode.text;
|
||||
if (scopeEnv.has(lhs)) continue;
|
||||
if (valueNode.type === 'identifier' || valueNode.type === 'simple_identifier') return { lhs, rhs: valueNode.text };
|
||||
if (valueNode.type === 'identifier' || valueNode.type === 'simple_identifier') return { kind: 'copy', lhs, rhs: valueNode.text };
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
@@ -130,6 +219,19 @@ const extractJavaPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv
|
||||
* declares the new type, so no scopeEnv lookup is needed.
|
||||
*/
|
||||
const extractJavaPatternBinding: PatternBindingExtractor = (node) => {
|
||||
if (node.type === 'type_pattern') {
|
||||
// Java 17+ switch pattern: case User u -> ...
|
||||
// type_pattern has positional children (NO named fields):
|
||||
// namedChild(0) = type (type_identifier, e.g., User)
|
||||
// namedChild(1) = identifier (e.g., u)
|
||||
const typeNode = node.namedChild(0);
|
||||
const nameNode = node.namedChild(1);
|
||||
if (!typeNode || !nameNode) return undefined;
|
||||
const typeName = extractSimpleTypeName(typeNode);
|
||||
const varName = extractVarName(nameNode);
|
||||
if (!typeName || !varName) return undefined;
|
||||
return { varName, typeName };
|
||||
}
|
||||
if (node.type !== 'instanceof_expression') return undefined;
|
||||
const nameNode = node.childForFieldName('name');
|
||||
if (!nameNode) return undefined;
|
||||
@@ -143,11 +245,12 @@ const extractJavaPatternBinding: PatternBindingExtractor = (node) => {
|
||||
|
||||
export const javaTypeConfig: LanguageTypeConfig = {
|
||||
declarationNodeTypes: JAVA_DECLARATION_NODE_TYPES,
|
||||
forLoopNodeTypes: JAVA_FOR_LOOP_NODE_TYPES,
|
||||
patternBindingNodeTypes: new Set(['instanceof_expression', 'type_pattern']),
|
||||
extractDeclaration: extractJavaDeclaration,
|
||||
extractParameter: extractJavaParameter,
|
||||
extractInitializer: extractJavaInitializer,
|
||||
scanConstructorBinding: scanJavaConstructorBinding,
|
||||
forLoopNodeTypes: JAVA_FOR_LOOP_NODE_TYPES,
|
||||
extractForLoopBinding: extractJavaForLoopBinding,
|
||||
extractPendingAssignment: extractJavaPendingAssignment,
|
||||
extractPatternBinding: extractJavaPatternBinding,
|
||||
@@ -195,7 +298,10 @@ const extractKotlinDeclaration: TypeBindingExtractor = (node: SyntaxNode, env: M
|
||||
}
|
||||
};
|
||||
|
||||
/** Kotlin: formal_parameter → type name */
|
||||
/** Kotlin: parameter / formal_parameter → type name.
|
||||
* Kotlin's tree-sitter grammar uses positional children (simple_identifier, user_type)
|
||||
* rather than named fields (name, type) on `parameter` nodes, so we fall back to
|
||||
* findChildByType when childForFieldName returns null. */
|
||||
const extractKotlinParameter: ParameterExtractor = (node: SyntaxNode, env: Map<string, string>): void => {
|
||||
let nameNode: SyntaxNode | null = null;
|
||||
let typeNode: SyntaxNode | null = null;
|
||||
@@ -208,6 +314,10 @@ const extractKotlinParameter: ParameterExtractor = (node: SyntaxNode, env: Map<s
|
||||
typeNode = node.childForFieldName('type');
|
||||
}
|
||||
|
||||
// Fallback: Kotlin `parameter` nodes use positional children, not named fields
|
||||
if (!nameNode) nameNode = findChildByType(node, 'simple_identifier');
|
||||
if (!typeNode) typeNode = findChildByType(node, 'user_type');
|
||||
|
||||
if (!nameNode || !typeNode) return;
|
||||
const varName = extractVarName(nameNode);
|
||||
const typeName = extractSimpleTypeName(typeNode);
|
||||
@@ -279,19 +389,143 @@ const KOTLIN_FOR_LOOP_NODE_TYPES: ReadonlySet<string> = new Set([
|
||||
'for_statement',
|
||||
]);
|
||||
|
||||
/** Kotlin: for (user: User in users) — extract loop variable binding when explicit type annotation exists */
|
||||
const extractKotlinForLoopBinding: ForLoopExtractor = (node: SyntaxNode, scopeEnv: Map<string, string>): void => {
|
||||
// Kotlin loop variable: variable_declaration child with optional user_type annotation
|
||||
/** Extract element type from a Kotlin type annotation AST node (user_type wrapping generic).
|
||||
* Kotlin: user_type → [type_identifier, type_arguments → [type_projection → user_type]]
|
||||
* Handles the type_projection wrapper that Kotlin uses for generic type arguments. */
|
||||
const extractKotlinElementTypeFromTypeNode = (typeNode: SyntaxNode, pos: TypeArgPosition = 'last'): string | undefined => {
|
||||
if (typeNode.type === 'user_type') {
|
||||
const argsNode = findChildByType(typeNode, 'type_arguments');
|
||||
if (argsNode && argsNode.namedChildCount >= 1) {
|
||||
const targetArg = pos === 'first'
|
||||
? argsNode.namedChild(0)
|
||||
: argsNode.namedChild(argsNode.namedChildCount - 1);
|
||||
if (!targetArg) return undefined;
|
||||
// Kotlin wraps type args in type_projection — unwrap to get the inner type
|
||||
const inner = targetArg.type === 'type_projection'
|
||||
? targetArg.firstNamedChild
|
||||
: targetArg;
|
||||
if (inner) return extractSimpleTypeName(inner);
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/** Walk up from a for-loop to the enclosing function_declaration and search parameters.
|
||||
* Kotlin parameters use positional children (simple_identifier, user_type), not named fields. */
|
||||
const findKotlinParamElementType = (iterableName: string, startNode: SyntaxNode, pos: TypeArgPosition = 'last'): string | undefined => {
|
||||
let current: SyntaxNode | null = startNode.parent;
|
||||
while (current) {
|
||||
if (current.type === 'function_declaration') {
|
||||
const paramsNode = findChildByType(current, 'function_value_parameters');
|
||||
if (paramsNode) {
|
||||
for (let i = 0; i < paramsNode.namedChildCount; i++) {
|
||||
const param = paramsNode.namedChild(i);
|
||||
if (!param || param.type !== 'parameter') continue;
|
||||
const nameNode = findChildByType(param, 'simple_identifier');
|
||||
if (nameNode?.text !== iterableName) continue;
|
||||
const typeNode = findChildByType(param, 'user_type');
|
||||
if (typeNode) return extractKotlinElementTypeFromTypeNode(typeNode, pos);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
current = current.parent;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/** Kotlin: for (user: User in users) — extract loop variable binding.
|
||||
* Tier 1c: for `for (user in users)` without annotation, resolves from iterable. */
|
||||
const extractKotlinForLoopBinding: ForLoopExtractor = (node, ctx): void => {
|
||||
const { scopeEnv, declarationTypeNodes, scope, returnTypeLookup } = ctx;
|
||||
const varDecl = findChildByType(node, 'variable_declaration');
|
||||
if (!varDecl) return;
|
||||
// Only extract when there is an explicit type annotation (user_type node)
|
||||
const typeNode = findChildByType(varDecl, 'user_type');
|
||||
if (!typeNode) return;
|
||||
const nameNode = findChildByType(varDecl, 'simple_identifier');
|
||||
if (!nameNode) return;
|
||||
const typeName = extractSimpleTypeName(typeNode);
|
||||
const varName = extractVarName(nameNode);
|
||||
if (typeName && varName) scopeEnv.set(varName, typeName);
|
||||
if (!varName) return;
|
||||
|
||||
// Explicit type annotation (existing behavior): for (user: User in users)
|
||||
const typeNode = findChildByType(varDecl, 'user_type');
|
||||
if (typeNode) {
|
||||
const typeName = extractSimpleTypeName(typeNode);
|
||||
if (typeName) scopeEnv.set(varName, typeName);
|
||||
return;
|
||||
}
|
||||
|
||||
// Tier 1c: no annotation — resolve from iterable's container type
|
||||
// Kotlin for-loop children: [variable_declaration, iterable_expr, control_structure_body]
|
||||
// The iterable is the second named child of the for_statement (after variable_declaration)
|
||||
let iterableName: string | undefined;
|
||||
let methodName: string | undefined;
|
||||
let fallbackIterableName: string | undefined;
|
||||
let callExprElementType: string | undefined;
|
||||
let foundVarDecl = false;
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
const child = node.namedChild(i);
|
||||
if (child === varDecl) { foundVarDecl = true; continue; }
|
||||
if (!foundVarDecl || !child) continue;
|
||||
if (child.type === 'simple_identifier') {
|
||||
iterableName = child.text;
|
||||
break;
|
||||
}
|
||||
if (child.type === 'navigation_expression') {
|
||||
// data.keys → navigation_expression > simple_identifier(data) + navigation_suffix > simple_identifier(keys)
|
||||
const obj = child.firstNamedChild;
|
||||
const suffix = findChildByType(child, 'navigation_suffix');
|
||||
const prop = suffix ? findChildByType(suffix, 'simple_identifier') : null;
|
||||
const hasCallSuffix = suffix ? findChildByType(suffix, 'call_suffix') !== null : false;
|
||||
// Always try object as iterable + property as method first (handles data.values, data.keys).
|
||||
// For bare property access without call_suffix, also save property as fallback
|
||||
// (handles this.users, repo.items where the property IS the iterable).
|
||||
if (obj?.type === 'simple_identifier') iterableName = obj.text;
|
||||
if (prop) methodName = prop.text;
|
||||
if (!hasCallSuffix && prop) {
|
||||
fallbackIterableName = prop.text;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (child.type === 'call_expression') {
|
||||
// data.values() → call_expression > navigation_expression > simple_identifier + navigation_suffix
|
||||
const callee = child.firstNamedChild;
|
||||
if (callee?.type === 'navigation_expression') {
|
||||
const obj = callee.firstNamedChild;
|
||||
if (obj?.type === 'simple_identifier') iterableName = obj.text;
|
||||
const suffix = findChildByType(callee, 'navigation_suffix');
|
||||
if (suffix) {
|
||||
const prop = findChildByType(suffix, 'simple_identifier');
|
||||
if (prop) methodName = prop.text;
|
||||
}
|
||||
} else if (callee?.type === 'simple_identifier') {
|
||||
// Direct function call: for (u in getUsers())
|
||||
const rawReturn = returnTypeLookup.lookupRawReturnType(callee.text);
|
||||
if (rawReturn) callExprElementType = extractElementTypeFromString(rawReturn);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!iterableName && !callExprElementType) return;
|
||||
|
||||
let elementType: string | undefined;
|
||||
if (callExprElementType) {
|
||||
elementType = callExprElementType;
|
||||
} else {
|
||||
let containerTypeName = scopeEnv.get(iterableName!);
|
||||
// Fallback: if object has no type in scope, try the property as the iterable name.
|
||||
// Handles patterns like this.users where the property itself is the iterable variable.
|
||||
if (!containerTypeName && fallbackIterableName) {
|
||||
iterableName = fallbackIterableName;
|
||||
methodName = undefined;
|
||||
containerTypeName = scopeEnv.get(iterableName);
|
||||
}
|
||||
const typeArgPos = methodToTypeArgPosition(methodName, containerTypeName);
|
||||
elementType = resolveIterableElementType(
|
||||
iterableName!, node, scopeEnv, declarationTypeNodes, scope,
|
||||
extractKotlinElementTypeFromTypeNode, findKotlinParamElementType,
|
||||
typeArgPos,
|
||||
);
|
||||
}
|
||||
if (elementType) scopeEnv.set(varName, elementType);
|
||||
};
|
||||
|
||||
/** Kotlin: val alias = u → property_declaration or variable_declaration.
|
||||
@@ -314,7 +548,7 @@ const extractKotlinPendingAssignment: PendingAssignmentExtractor = (node, scopeE
|
||||
if (!child) continue;
|
||||
if (child.type === '=') { foundEq = true; continue; }
|
||||
if (foundEq && child.type === 'simple_identifier') {
|
||||
return { lhs, rhs: child.text };
|
||||
return { kind: 'copy', lhs, rhs: child.text };
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
@@ -336,7 +570,7 @@ const extractKotlinPendingAssignment: PendingAssignmentExtractor = (node, scopeE
|
||||
if (!child) continue;
|
||||
if (child.type === '=') { foundEq = true; continue; }
|
||||
if (foundEq && child.type === 'simple_identifier') {
|
||||
return { lhs, rhs: child.text };
|
||||
return { kind: 'copy', lhs, rhs: child.text };
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
@@ -345,13 +579,42 @@ const extractKotlinPendingAssignment: PendingAssignmentExtractor = (node, scopeE
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/** Walk up from a node to find an ancestor of a given type. */
|
||||
const findAncestorByType = (node: SyntaxNode, type: string): SyntaxNode | undefined => {
|
||||
let current = node.parent;
|
||||
while (current) {
|
||||
if (current.type === type) return current;
|
||||
current = current.parent;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const extractKotlinPatternBinding: PatternBindingExtractor = (node) => {
|
||||
if (node.type !== 'type_test') return undefined;
|
||||
const typeNode = node.lastNamedChild;
|
||||
if (!typeNode) return undefined;
|
||||
const typeName = extractSimpleTypeName(typeNode);
|
||||
if (!typeName) return undefined;
|
||||
const whenExpr = findAncestorByType(node, 'when_expression');
|
||||
if (!whenExpr) return undefined;
|
||||
const whenSubject = whenExpr.namedChild(0);
|
||||
const subject = whenSubject?.firstNamedChild ?? whenSubject;
|
||||
if (!subject) return undefined;
|
||||
const varName = extractVarName(subject);
|
||||
if (!varName) return undefined;
|
||||
return { varName, typeName };
|
||||
};
|
||||
|
||||
export const kotlinTypeConfig: LanguageTypeConfig = {
|
||||
allowPatternBindingOverwrite: true,
|
||||
declarationNodeTypes: KOTLIN_DECLARATION_NODE_TYPES,
|
||||
forLoopNodeTypes: KOTLIN_FOR_LOOP_NODE_TYPES,
|
||||
patternBindingNodeTypes: new Set(['type_test']),
|
||||
extractDeclaration: extractKotlinDeclaration,
|
||||
extractParameter: extractKotlinParameter,
|
||||
extractInitializer: extractKotlinInitializer,
|
||||
scanConstructorBinding: scanKotlinConstructorBinding,
|
||||
extractForLoopBinding: extractKotlinForLoopBinding,
|
||||
extractPendingAssignment: extractKotlinPendingAssignment,
|
||||
extractPatternBinding: extractKotlinPatternBinding,
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { SyntaxNode } from '../utils.js';
|
||||
import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, InitializerExtractor, ClassNameLookup, ConstructorBindingScanner, ReturnTypeExtractor, PendingAssignmentExtractor } from './types.js';
|
||||
import { extractSimpleTypeName, extractVarName, extractCalleeName } from './shared.js';
|
||||
import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, InitializerExtractor, ClassNameLookup, ConstructorBindingScanner, ReturnTypeExtractor, PendingAssignmentExtractor, ForLoopExtractor } from './types.js';
|
||||
import { extractSimpleTypeName, extractVarName, extractCalleeName, resolveIterableElementType, extractElementTypeFromString } from './shared.js';
|
||||
|
||||
const DECLARATION_NODE_TYPES: ReadonlySet<string> = new Set([
|
||||
'assignment_expression', // For constructor inference: $x = new User()
|
||||
@@ -61,6 +61,15 @@ const normalizePhpType = (raw: string): string | undefined => {
|
||||
type = segments[segments.length - 1];
|
||||
// Skip uninformative types
|
||||
if (type === 'mixed' || type === 'void' || type === 'self' || type === 'static' || type === 'object') return undefined;
|
||||
// Extract element type from generic: Collection<User> → User
|
||||
// PHPDoc generics encode the element type in angle brackets. Since PHP's Strategy B
|
||||
// uses the scopeEnv value directly as the element type, we must store the inner type,
|
||||
// not the container name. This mirrors how User[] → User is handled by the [] strip above.
|
||||
const genericMatch = type.match(/^(\w+)\s*</);
|
||||
if (genericMatch) {
|
||||
const elementType = extractElementTypeFromString(type);
|
||||
return elementType ?? undefined;
|
||||
}
|
||||
if (/^\w+$/.test(type)) return type;
|
||||
return undefined;
|
||||
};
|
||||
@@ -73,6 +82,67 @@ const SKIP_NODE_TYPES: ReadonlySet<string> = new Set(['attribute_list', 'attribu
|
||||
const PHPDOC_PARAM_RE = /@param\s+(\S+)\s+\$(\w+)/g;
|
||||
/** Alternate PHPDoc order: `@param $name Type` (name first) */
|
||||
const PHPDOC_PARAM_ALT_RE = /@param\s+\$(\w+)\s+(\S+)/g;
|
||||
/** Regex to extract PHPDoc @var annotations: `@var Type` */
|
||||
const PHPDOC_VAR_RE = /@var\s+(\S+)/;
|
||||
|
||||
/**
|
||||
* Extract the element type for a class property from its PHPDoc @var annotation or
|
||||
* PHP 7.4+ native type. Walks backward from the property_declaration node to find
|
||||
* an immediately preceding comment containing @var.
|
||||
*
|
||||
* Returns the normalized element type (e.g. User[] → User, Collection<User> → User).
|
||||
* Returns undefined when no usable type annotation is found.
|
||||
*/
|
||||
const extractClassPropertyElementType = (propDecl: SyntaxNode): string | undefined => {
|
||||
// Strategy 1: PHPDoc @var annotation on a preceding comment sibling
|
||||
let sibling = propDecl.previousSibling;
|
||||
while (sibling) {
|
||||
if (sibling.type === 'comment') {
|
||||
const match = PHPDOC_VAR_RE.exec(sibling.text);
|
||||
if (match) return normalizePhpType(match[1]);
|
||||
} else if (sibling.isNamed && !SKIP_NODE_TYPES.has(sibling.type)) {
|
||||
break;
|
||||
}
|
||||
sibling = sibling.previousSibling;
|
||||
}
|
||||
// Strategy 2: PHP 7.4+ native type field — skip generic 'array' since element type is unknown
|
||||
const typeNode = propDecl.childForFieldName('type');
|
||||
if (!typeNode) return undefined;
|
||||
const typeName = extractSimpleTypeName(typeNode);
|
||||
if (!typeName || typeName === 'array') return undefined;
|
||||
return typeName;
|
||||
};
|
||||
|
||||
/**
|
||||
* Scan a class body for a property_declaration matching the given property name,
|
||||
* and extract its element type. The class body is the `declaration_list` child of
|
||||
* a `class_declaration` node.
|
||||
*
|
||||
* Used as Strategy C in extractForLoopBinding for `$this->property` iterables
|
||||
* where Strategy A (resolveIterableElementType) and Strategy B (scopeEnv lookup)
|
||||
* both fail to find the type.
|
||||
*/
|
||||
const findClassPropertyElementType = (propName: string, classNode: SyntaxNode): string | undefined => {
|
||||
const declList = classNode.childForFieldName('body')
|
||||
?? (classNode.namedChild(classNode.namedChildCount - 1)?.type === 'declaration_list'
|
||||
? classNode.namedChild(classNode.namedChildCount - 1)
|
||||
: null); // fallback: last named child, only if it's a declaration_list
|
||||
if (!declList) return undefined;
|
||||
for (let i = 0; i < declList.namedChildCount; i++) {
|
||||
const child = declList.namedChild(i);
|
||||
if (child?.type !== 'property_declaration') continue;
|
||||
// Check if any property_element has a variable_name matching '$propName'
|
||||
for (let j = 0; j < child.namedChildCount; j++) {
|
||||
const elem = child.namedChild(j);
|
||||
if (elem?.type !== 'property_element') continue;
|
||||
const varNameNode = elem.firstNamedChild; // variable_name node
|
||||
if (varNameNode?.text === '$' + propName) {
|
||||
return extractClassPropertyElementType(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Collect PHPDoc @param type bindings from comment nodes preceding a method/function.
|
||||
@@ -190,8 +260,12 @@ const extractParameter: ParameterExtractor = (node: SyntaxNode, env: Map<string,
|
||||
|
||||
if (!nameNode || !typeNode) return;
|
||||
const varName = extractVarName(nameNode);
|
||||
if (!varName) return;
|
||||
// Don't overwrite PHPDoc-derived types (e.g. @param User[] $users → User)
|
||||
// with the less-specific AST type annotation (e.g. array).
|
||||
if (env.has(varName)) return;
|
||||
const typeName = extractSimpleTypeName(typeNode);
|
||||
if (varName && typeName) env.set(varName, typeName);
|
||||
if (typeName) env.set(varName, typeName);
|
||||
};
|
||||
|
||||
/** PHP: $x = SomeFactory() or $x = $this->getUser() — bind variable to call return type */
|
||||
@@ -229,16 +303,41 @@ const scanConstructorBinding: ConstructorBindingScanner = (node) => {
|
||||
/** Regex to extract PHPDoc @return annotations: `@return User` */
|
||||
const PHPDOC_RETURN_RE = /@return\s+(\S+)/;
|
||||
|
||||
/**
|
||||
* Normalize a PHPDoc return type for storage in the SymbolTable.
|
||||
* Unlike normalizePhpType (which strips User[] → User for scopeEnv), this preserves
|
||||
* array notation so lookupRawReturnType can extract element types for for-loop resolution.
|
||||
* \App\Models\User[] → User[]
|
||||
* ?User → User
|
||||
* Collection<User> → Collection<User> (preserved for extractElementTypeFromString)
|
||||
*/
|
||||
const normalizePhpReturnType = (raw: string): string | undefined => {
|
||||
// Strip nullable prefix: ?User[] → User[]
|
||||
let type = raw.startsWith('?') ? raw.slice(1) : raw;
|
||||
// Strip union with null/false/void: User[]|null → User[]
|
||||
const parts = type.split('|').filter(p => p !== 'null' && p !== 'false' && p !== 'void' && p !== 'mixed');
|
||||
if (parts.length !== 1) return undefined;
|
||||
type = parts[0];
|
||||
// Strip namespace: \App\Models\User[] → User[]
|
||||
const segments = type.split('\\');
|
||||
type = segments[segments.length - 1];
|
||||
// Skip uninformative types
|
||||
if (type === 'mixed' || type === 'void' || type === 'self' || type === 'static' || type === 'object' || type === 'array') return undefined;
|
||||
if (/^\w+(\[\])?$/.test(type) || /^\w+\s*</.test(type)) return type;
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract return type from PHPDoc `@return Type` annotation preceding a method.
|
||||
* Walks backwards through preceding siblings looking for comment nodes.
|
||||
* Preserves array notation (e.g., User[]) for for-loop element type extraction.
|
||||
*/
|
||||
const extractReturnType: ReturnTypeExtractor = (node) => {
|
||||
let sibling = node.previousSibling;
|
||||
while (sibling) {
|
||||
if (sibling.type === 'comment') {
|
||||
const match = PHPDOC_RETURN_RE.exec(sibling.text);
|
||||
if (match) return normalizePhpType(match[1]);
|
||||
if (match) return normalizePhpReturnType(match[1]);
|
||||
} else if (sibling.isNamed && !SKIP_NODE_TYPES.has(sibling.type)) break;
|
||||
sibling = sibling.previousSibling;
|
||||
}
|
||||
@@ -256,15 +355,173 @@ const extractPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) =>
|
||||
const lhs = left.text;
|
||||
const rhs = right.text;
|
||||
if (!lhs || !rhs || scopeEnv.has(lhs)) return undefined;
|
||||
return { lhs, rhs };
|
||||
return { kind: 'copy', lhs, rhs };
|
||||
};
|
||||
|
||||
const FOR_LOOP_NODE_TYPES: ReadonlySet<string> = new Set([
|
||||
'foreach_statement',
|
||||
]);
|
||||
|
||||
/** Extract element type from a PHP type annotation AST node.
|
||||
* PHP has limited AST-level container types — `array` is a primitive_type with no generic args.
|
||||
* Named types (e.g., `Collection`) are returned as-is (container descriptor lookup handles them). */
|
||||
const extractPhpElementTypeFromTypeNode = (_typeNode: SyntaxNode): string | undefined => {
|
||||
// PHP AST type nodes don't carry generic parameters (array<User> is PHPDoc-only).
|
||||
// primitive_type 'array' and named_type 'Collection' don't encode element types.
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/** Walk up from a foreach to the enclosing function and search parameter type annotations.
|
||||
* PHP parameter type hints are limited (array, ClassName) — this extracts element type when possible. */
|
||||
const findPhpParamElementType = (iterableName: string, startNode: SyntaxNode): string | undefined => {
|
||||
let current: SyntaxNode | null = startNode.parent;
|
||||
while (current) {
|
||||
if (current.type === 'method_declaration' || current.type === 'function_definition') {
|
||||
const paramsNode = current.childForFieldName('parameters');
|
||||
if (paramsNode) {
|
||||
for (let i = 0; i < paramsNode.namedChildCount; i++) {
|
||||
const param = paramsNode.namedChild(i);
|
||||
if (!param || param.type !== 'simple_parameter') continue;
|
||||
const nameNode = param.childForFieldName('name');
|
||||
if (nameNode?.text !== iterableName) continue;
|
||||
const typeNode = param.childForFieldName('type');
|
||||
if (typeNode) return extractPhpElementTypeFromTypeNode(typeNode);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
current = current.parent;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* PHP: foreach ($users as $user) — extract loop variable binding.
|
||||
*
|
||||
* AST structure (from tree-sitter-php grammar):
|
||||
* foreach_statement — no named fields for iterable/value (only 'body')
|
||||
* children[0]: expression (iterable, e.g. $users)
|
||||
* children[1]: expression (simple value) OR pair ($key => $value)
|
||||
* pair children: expression (key), expression (value)
|
||||
*
|
||||
* PHP's PHPDoc @param normalizes `User[]` → `User` in the env, so the iterable's
|
||||
* stored type IS the element type. We first try resolveIterableElementType (for
|
||||
* constructor-binding cases that retain container types), then fall back to direct
|
||||
* scopeEnv lookup (for PHPDoc-normalized types).
|
||||
*/
|
||||
const extractForLoopBinding: ForLoopExtractor = (node, { scopeEnv, declarationTypeNodes, scope, returnTypeLookup }): void => {
|
||||
if (node.type !== 'foreach_statement') return;
|
||||
|
||||
// Collect non-body named children: first is the iterable, second is value or pair
|
||||
const children: SyntaxNode[] = [];
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
const child = node.namedChild(i);
|
||||
if (child && child !== node.childForFieldName('body')) {
|
||||
children.push(child);
|
||||
}
|
||||
}
|
||||
if (children.length < 2) return;
|
||||
|
||||
const iterableNode = children[0];
|
||||
const valueOrPair = children[1];
|
||||
|
||||
// Determine the loop variable node
|
||||
let loopVarNode: SyntaxNode;
|
||||
if (valueOrPair.type === 'pair') {
|
||||
// $key => $value — the value is the last named child of the pair
|
||||
const lastChild = valueOrPair.namedChild(valueOrPair.namedChildCount - 1);
|
||||
if (!lastChild) return;
|
||||
// Handle by_ref: foreach ($arr as $k => &$v)
|
||||
loopVarNode = lastChild.type === 'by_ref' ? (lastChild.firstNamedChild ?? lastChild) : lastChild;
|
||||
} else {
|
||||
// Simple: foreach ($users as $user) or foreach ($users as &$user)
|
||||
loopVarNode = valueOrPair.type === 'by_ref' ? (valueOrPair.firstNamedChild ?? valueOrPair) : valueOrPair;
|
||||
}
|
||||
|
||||
const varName = extractVarName(loopVarNode);
|
||||
if (!varName) return;
|
||||
|
||||
// Get iterable variable name (PHP vars include $ prefix)
|
||||
let iterableName: string | undefined;
|
||||
let callExprElementType: string | undefined;
|
||||
if (iterableNode.type === 'variable_name') {
|
||||
iterableName = iterableNode.text;
|
||||
} else if (iterableNode?.type === 'member_access_expression') {
|
||||
const name = iterableNode.childForFieldName('name');
|
||||
// PHP properties are stored in scopeEnv with $ prefix ($users), but
|
||||
// member_access_expression.name returns without $ (users). Add $ to match.
|
||||
if (name) iterableName = '$' + name.text;
|
||||
} else if (iterableNode?.type === 'function_call_expression') {
|
||||
// foreach (getUsers() as $user) — resolve via return type lookup
|
||||
const calleeName = extractCalleeName(iterableNode);
|
||||
if (calleeName) {
|
||||
const rawReturn = returnTypeLookup.lookupRawReturnType(calleeName);
|
||||
if (rawReturn) callExprElementType = extractElementTypeFromString(rawReturn);
|
||||
}
|
||||
} else if (iterableNode?.type === 'member_call_expression') {
|
||||
// foreach ($this->getUsers() as $user) — resolve via return type lookup
|
||||
const methodName = iterableNode.childForFieldName('name');
|
||||
if (methodName) {
|
||||
const rawReturn = returnTypeLookup.lookupRawReturnType(methodName.text);
|
||||
if (rawReturn) callExprElementType = extractElementTypeFromString(rawReturn);
|
||||
}
|
||||
}
|
||||
if (!iterableName && !callExprElementType) return;
|
||||
|
||||
// If we resolved the element type from a call expression, bind and return early
|
||||
if (callExprElementType) {
|
||||
scopeEnv.set(varName, callExprElementType);
|
||||
return;
|
||||
}
|
||||
|
||||
// Strategy A: try resolveIterableElementType (handles constructor-binding container types)
|
||||
const elementType = resolveIterableElementType(
|
||||
iterableName, node, scopeEnv, declarationTypeNodes, scope,
|
||||
extractPhpElementTypeFromTypeNode, findPhpParamElementType,
|
||||
undefined,
|
||||
);
|
||||
if (elementType) {
|
||||
scopeEnv.set(varName, elementType);
|
||||
return;
|
||||
}
|
||||
|
||||
// Strategy B: direct scopeEnv lookup — PHP normalizePhpType strips User[] → User,
|
||||
// so the iterable's stored type is already the element type from PHPDoc annotations.
|
||||
const iterableType = scopeEnv.get(iterableName);
|
||||
if (iterableType) {
|
||||
scopeEnv.set(varName, iterableType);
|
||||
return;
|
||||
}
|
||||
|
||||
// Strategy C: $this->property — scan the enclosing class body for the property
|
||||
// declaration and extract its element type from @var PHPDoc or native type.
|
||||
// This handles the common PHP pattern where the property type is declared on the
|
||||
// class body (/** @var User[] */ private $users) but the foreach is in a method
|
||||
// whose scopeEnv does not contain the property type.
|
||||
if (iterableNode?.type === 'member_access_expression') {
|
||||
const obj = iterableNode.childForFieldName('object');
|
||||
if (obj?.text === '$this') {
|
||||
const nameNode = iterableNode.childForFieldName('name');
|
||||
const propName = nameNode?.text;
|
||||
if (propName) {
|
||||
const classNode = findEnclosingClass(iterableNode);
|
||||
if (classNode) {
|
||||
const elementType = findClassPropertyElementType(propName, classNode);
|
||||
if (elementType) scopeEnv.set(varName, elementType);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const typeConfig: LanguageTypeConfig = {
|
||||
declarationNodeTypes: DECLARATION_NODE_TYPES,
|
||||
forLoopNodeTypes: FOR_LOOP_NODE_TYPES,
|
||||
extractDeclaration,
|
||||
extractParameter,
|
||||
extractInitializer,
|
||||
scanConstructorBinding,
|
||||
extractReturnType,
|
||||
extractForLoopBinding,
|
||||
extractPendingAssignment,
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { SyntaxNode } from '../utils.js';
|
||||
import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, InitializerExtractor, ClassNameLookup, ConstructorBindingScanner, PendingAssignmentExtractor } from './types.js';
|
||||
import { extractSimpleTypeName, extractVarName } from './shared.js';
|
||||
import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, InitializerExtractor, ClassNameLookup, ConstructorBindingScanner, PendingAssignmentExtractor, PatternBindingExtractor, ForLoopExtractor } from './types.js';
|
||||
import { extractSimpleTypeName, extractVarName, extractElementTypeFromString, extractGenericTypeArgs, resolveIterableElementType, methodToTypeArgPosition, type TypeArgPosition } from './shared.js';
|
||||
|
||||
const DECLARATION_NODE_TYPES: ReadonlySet<string> = new Set([
|
||||
'assignment',
|
||||
@@ -134,6 +134,163 @@ const scanConstructorBinding: ConstructorBindingScanner = (node) => {
|
||||
return { varName: left.text, calleeName };
|
||||
};
|
||||
|
||||
const FOR_LOOP_NODE_TYPES: ReadonlySet<string> = new Set([
|
||||
'for_statement',
|
||||
]);
|
||||
|
||||
/** Python function/method node types that carry a parameters list. */
|
||||
const PY_FUNCTION_NODE_TYPES = new Set([
|
||||
'function_definition', 'decorated_definition',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Extract element type from a Python type annotation AST node.
|
||||
* Handles:
|
||||
* subscript "List[User]" → extractElementTypeFromString("List[User]") → "User"
|
||||
* generic_type → extractGenericTypeArgs → first arg
|
||||
* Falls back to text-based extraction.
|
||||
*/
|
||||
const extractPyElementTypeFromAnnotation = (typeNode: SyntaxNode, pos: TypeArgPosition = 'last'): string | undefined => {
|
||||
// Unwrap 'type' wrapper node to get to the actual type (e.g., type > generic_type)
|
||||
const inner = typeNode.type === 'type' ? (typeNode.firstNamedChild ?? typeNode) : typeNode;
|
||||
|
||||
// Python subscript: List[User], Sequence[User] — use raw text
|
||||
if (inner.type === 'subscript') {
|
||||
return extractElementTypeFromString(inner.text, pos);
|
||||
}
|
||||
// generic_type: dict[str, User] — tree-sitter-python uses type_parameter child
|
||||
if (inner.type === 'generic_type') {
|
||||
// Try standard extractGenericTypeArgs first (handles type_arguments)
|
||||
const args = extractGenericTypeArgs(inner);
|
||||
if (args.length >= 1) return pos === 'first' ? args[0] : args[args.length - 1];
|
||||
// Fallback: look for type_parameter child (tree-sitter-python specific)
|
||||
for (let i = 0; i < inner.namedChildCount; i++) {
|
||||
const child = inner.namedChild(i);
|
||||
if (child?.type === 'type_parameter') {
|
||||
if (pos === 'first') {
|
||||
const firstArg = child.firstNamedChild;
|
||||
if (firstArg) return extractSimpleTypeName(firstArg);
|
||||
} else {
|
||||
const lastArg = child.lastNamedChild;
|
||||
if (lastArg) return extractSimpleTypeName(lastArg);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fallback: raw text extraction (handles User[], [User], etc.)
|
||||
return extractElementTypeFromString(inner.text, pos);
|
||||
};
|
||||
|
||||
/**
|
||||
* Walk up the AST from a for-statement to find the enclosing function definition,
|
||||
* then search its parameters for one named `iterableName`.
|
||||
* Returns the element type extracted from its type annotation, or undefined.
|
||||
*
|
||||
* Handles both `parameter` and `typed_parameter` node types in tree-sitter-python.
|
||||
* `typed_parameter` may not expose the name as a `name` field — falls back to
|
||||
* checking the first identifier-type named child.
|
||||
*/
|
||||
const findPyParamElementType = (iterableName: string, startNode: SyntaxNode, pos: TypeArgPosition = 'last'): string | undefined => {
|
||||
let current: SyntaxNode | null = startNode.parent;
|
||||
while (current) {
|
||||
if (current.type === 'function_definition') {
|
||||
const paramsNode = current.childForFieldName('parameters');
|
||||
if (paramsNode) {
|
||||
for (let i = 0; i < paramsNode.namedChildCount; i++) {
|
||||
const param = paramsNode.namedChild(i);
|
||||
if (!param) continue;
|
||||
// Try named `name` field first (parameter node), then first identifier child
|
||||
// (typed_parameter node may store name as first positional child)
|
||||
const nameNode = param.childForFieldName('name')
|
||||
?? (param.firstNamedChild?.type === 'identifier' ? param.firstNamedChild : null);
|
||||
if (nameNode?.text !== iterableName) continue;
|
||||
// Try `type` field, then last named child (typed_parameter stores type last)
|
||||
const typeAnnotation = param.childForFieldName('type')
|
||||
?? (param.namedChildCount >= 2 ? param.namedChild(param.namedChildCount - 1) : null);
|
||||
if (typeAnnotation && typeAnnotation !== nameNode) {
|
||||
return extractPyElementTypeFromAnnotation(typeAnnotation, pos);
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
current = current.parent;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Python: for user in users: where users has a known container type annotation.
|
||||
*
|
||||
* AST node: `for_statement` with `left` (loop variable) and `right` (iterable).
|
||||
*
|
||||
* Tier 1c: resolves the element type via three strategies in priority order:
|
||||
* 1. declarationTypeNodes — raw type annotation AST node (covers stored container types)
|
||||
* 2. scopeEnv string — extractElementTypeFromString on the stored type
|
||||
* 3. AST walk — walks up to the enclosing function's parameters to read List[User] directly
|
||||
*/
|
||||
const extractForLoopBinding: ForLoopExtractor = (node, { scopeEnv, declarationTypeNodes, scope, returnTypeLookup }): void => {
|
||||
if (node.type !== 'for_statement') return;
|
||||
|
||||
// The iterable is the `right` field — may be identifier, attribute, or call.
|
||||
const rightNode = node.childForFieldName('right');
|
||||
let iterableName: string | undefined;
|
||||
let methodName: string | undefined;
|
||||
let callExprElementType: string | undefined;
|
||||
if (rightNode?.type === 'identifier') {
|
||||
iterableName = rightNode.text;
|
||||
} else if (rightNode?.type === 'attribute') {
|
||||
const prop = rightNode.lastNamedChild;
|
||||
if (prop) iterableName = prop.text;
|
||||
} else if (rightNode?.type === 'call') {
|
||||
// data.items() → call > function: attribute > identifier('data') + identifier('items')
|
||||
// get_users() → call > function: identifier (Phase 7.3 — return-type path)
|
||||
const fn = rightNode.childForFieldName('function');
|
||||
if (fn?.type === 'attribute') {
|
||||
const obj = fn.firstNamedChild;
|
||||
if (obj?.type === 'identifier') iterableName = obj.text;
|
||||
// Extract method name: items, keys, values
|
||||
const method = fn.lastNamedChild;
|
||||
if (method?.type === 'identifier' && method !== obj) methodName = method.text;
|
||||
} else if (fn?.type === 'identifier') {
|
||||
// Direct function call: for user in get_users()
|
||||
const rawReturn = returnTypeLookup.lookupRawReturnType(fn.text);
|
||||
if (rawReturn) callExprElementType = extractElementTypeFromString(rawReturn);
|
||||
}
|
||||
}
|
||||
if (!iterableName && !callExprElementType) return;
|
||||
|
||||
let elementType: string | undefined;
|
||||
if (callExprElementType) {
|
||||
elementType = callExprElementType;
|
||||
} else {
|
||||
const containerTypeName = scopeEnv.get(iterableName!);
|
||||
const typeArgPos = methodToTypeArgPosition(methodName, containerTypeName);
|
||||
elementType = resolveIterableElementType(
|
||||
iterableName!, node, scopeEnv, declarationTypeNodes, scope,
|
||||
extractPyElementTypeFromAnnotation, findPyParamElementType,
|
||||
typeArgPos,
|
||||
);
|
||||
}
|
||||
if (!elementType) return;
|
||||
|
||||
// The loop variable is the `left` field — identifier or pattern_list.
|
||||
const leftNode = node.childForFieldName('left');
|
||||
if (!leftNode) return;
|
||||
|
||||
// Handle tuple unpacking: for key, value in data.items()
|
||||
if (leftNode.type === 'pattern_list') {
|
||||
const lastChild = leftNode.lastNamedChild;
|
||||
if (lastChild?.type === 'identifier') {
|
||||
scopeEnv.set(lastChild.text, elementType);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const loopVarName = extractVarName(leftNode);
|
||||
if (loopVarName) scopeEnv.set(loopVarName, elementType);
|
||||
};
|
||||
|
||||
/** Python: alias = u → assignment with left/right fields.
|
||||
* Also handles walrus operator: alias := u → named_expression with name/value fields. */
|
||||
const extractPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) => {
|
||||
@@ -153,15 +310,81 @@ const extractPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) =>
|
||||
if (!left || !right) return undefined;
|
||||
const lhs = left.type === 'identifier' ? left.text : undefined;
|
||||
if (!lhs || scopeEnv.has(lhs)) return undefined;
|
||||
if (right.type === 'identifier') return { lhs, rhs: right.text };
|
||||
if (right.type === 'identifier') return { kind: 'copy', lhs, rhs: right.text };
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Python match/case `as` pattern binding: `case User() as u:`
|
||||
*
|
||||
* AST structure (tree-sitter-python):
|
||||
* as_pattern
|
||||
* alias: as_pattern_target ← the bound variable name (e.g. "u")
|
||||
* children[0]: case_pattern ← wraps class_pattern (or is class_pattern directly)
|
||||
* class_pattern
|
||||
* dotted_name ← the class name (e.g. "User")
|
||||
*
|
||||
* The `alias` field is an `as_pattern_target` node whose `.text` is the identifier.
|
||||
* The class name lives in the first non-alias named child: either a `case_pattern`
|
||||
* wrapping a `class_pattern`, or a direct `class_pattern`.
|
||||
*
|
||||
* Conservative: returns undefined when:
|
||||
* - The node is not an `as_pattern`
|
||||
* - The pattern side is not a class_pattern (e.g. guard or literal match)
|
||||
* - The variable was already bound in scopeEnv
|
||||
*/
|
||||
const extractPatternBinding: PatternBindingExtractor = (node, scopeEnv) => {
|
||||
if (node.type !== 'as_pattern') return undefined;
|
||||
|
||||
// as_pattern: `case User() as u:` — binds matched value to a name.
|
||||
// Try named field first (future grammar versions may expose it), fall back to positional.
|
||||
if (node.namedChildCount < 2) return undefined;
|
||||
|
||||
const patternChild = node.namedChild(0);
|
||||
const varNameNode = node.childForFieldName('alias')
|
||||
?? node.namedChild(node.namedChildCount - 1);
|
||||
if (!patternChild || !varNameNode) return undefined;
|
||||
if (varNameNode.type !== 'identifier') return undefined;
|
||||
|
||||
const varName = varNameNode.text;
|
||||
if (!varName || scopeEnv.has(varName)) return undefined;
|
||||
|
||||
// Find the class_pattern — may be direct or wrapped in case_pattern.
|
||||
let classPattern: SyntaxNode | null = null;
|
||||
if (patternChild.type === 'class_pattern') {
|
||||
classPattern = patternChild;
|
||||
} else if (patternChild.type === 'case_pattern') {
|
||||
// Unwrap one level: case_pattern wraps class_pattern
|
||||
for (let j = 0; j < patternChild.namedChildCount; j++) {
|
||||
const inner = patternChild.namedChild(j);
|
||||
if (inner?.type === 'class_pattern') {
|
||||
classPattern = inner;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!classPattern) return undefined;
|
||||
|
||||
// class_pattern children: dotted_name (the class name) + optional keyword_pattern args.
|
||||
const classNameNode = classPattern.firstNamedChild;
|
||||
if (!classNameNode || (classNameNode.type !== 'dotted_name' && classNameNode.type !== 'identifier')) return undefined;
|
||||
const typeName = classNameNode.text;
|
||||
if (!typeName) return undefined;
|
||||
|
||||
return { varName, typeName };
|
||||
};
|
||||
|
||||
const PATTERN_BINDING_NODE_TYPES: ReadonlySet<string> = new Set(['as_pattern']);
|
||||
|
||||
export const typeConfig: LanguageTypeConfig = {
|
||||
declarationNodeTypes: DECLARATION_NODE_TYPES,
|
||||
forLoopNodeTypes: FOR_LOOP_NODE_TYPES,
|
||||
patternBindingNodeTypes: PATTERN_BINDING_NODE_TYPES,
|
||||
extractDeclaration,
|
||||
extractParameter,
|
||||
extractInitializer,
|
||||
scanConstructorBinding,
|
||||
extractForLoopBinding,
|
||||
extractPendingAssignment,
|
||||
extractPatternBinding,
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, InitializerExtractor, ClassNameLookup, ConstructorBindingScanner, ReturnTypeExtractor } from './types.js';
|
||||
import { extractRubyConstructorAssignment, extractSimpleTypeName } from './shared.js';
|
||||
import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, InitializerExtractor, ClassNameLookup, ConstructorBindingScanner, ReturnTypeExtractor, PendingAssignmentExtractor, ForLoopExtractor } from './types.js';
|
||||
import { extractRubyConstructorAssignment, extractSimpleTypeName, extractElementTypeFromString, extractVarName, resolveIterableElementType } from './shared.js';
|
||||
import type { SyntaxNode } from '../utils.js';
|
||||
|
||||
/**
|
||||
@@ -261,11 +261,146 @@ const scanConstructorBinding: ConstructorBindingScanner = (node) => {
|
||||
return { varName: left.text, calleeName };
|
||||
};
|
||||
|
||||
/** Ruby method node types that carry a parameter list. */
|
||||
const RUBY_METHOD_NODE_TYPES = new Set(['method', 'singleton_method']);
|
||||
|
||||
const FOR_LOOP_NODE_TYPES: ReadonlySet<string> = new Set(['for']);
|
||||
|
||||
/**
|
||||
* Collect raw YARD @param type strings from comment nodes preceding a method.
|
||||
* Unlike collectYardParams which returns simplified type names, this returns the
|
||||
* raw bracket content (e.g., "Array<User>" not "Array") for element type extraction.
|
||||
*/
|
||||
const collectYardRawParams = (methodNode: SyntaxNode): Map<string, string> => {
|
||||
const params = new Map<string, string>();
|
||||
const commentTexts: string[] = [];
|
||||
|
||||
const collectComments = (startNode: SyntaxNode): void => {
|
||||
let sibling = startNode.previousSibling;
|
||||
while (sibling) {
|
||||
if (sibling.type === 'comment') {
|
||||
commentTexts.unshift(sibling.text);
|
||||
} else if (sibling.isNamed) {
|
||||
break;
|
||||
}
|
||||
sibling = sibling.previousSibling;
|
||||
}
|
||||
};
|
||||
|
||||
collectComments(methodNode);
|
||||
if (commentTexts.length === 0 && methodNode.parent?.type === 'body_statement') {
|
||||
collectComments(methodNode.parent);
|
||||
}
|
||||
|
||||
const commentBlock = commentTexts.join('\n');
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
YARD_PARAM_RE.lastIndex = 0;
|
||||
while ((match = YARD_PARAM_RE.exec(commentBlock)) !== null) {
|
||||
params.set(match[1], match[2]);
|
||||
}
|
||||
YARD_PARAM_ALT_RE.lastIndex = 0;
|
||||
while ((match = YARD_PARAM_ALT_RE.exec(commentBlock)) !== null) {
|
||||
if (!params.has(match[2])) params.set(match[2], match[1]);
|
||||
}
|
||||
|
||||
return params;
|
||||
};
|
||||
|
||||
/**
|
||||
* Walk up the AST from a for-statement to find the enclosing method,
|
||||
* then search its YARD @param annotations for one named `iterableName`.
|
||||
* Returns the element type extracted from the raw YARD type string.
|
||||
*
|
||||
* Example: `@param users [Array<User>]` → extracts "User" from "Array<User>".
|
||||
*/
|
||||
const findRubyParamElementType = (iterableName: string, startNode: SyntaxNode): string | undefined => {
|
||||
let current: SyntaxNode | null = startNode.parent;
|
||||
while (current) {
|
||||
if (RUBY_METHOD_NODE_TYPES.has(current.type)) {
|
||||
const rawParams = collectYardRawParams(current);
|
||||
const rawType = rawParams.get(iterableName);
|
||||
if (rawType) return extractElementTypeFromString(rawType);
|
||||
break;
|
||||
}
|
||||
current = current.parent;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Ruby: for user in users ... end
|
||||
*
|
||||
* tree-sitter-ruby `for` node structure:
|
||||
* pattern field: the loop variable (identifier)
|
||||
* value field: `in` node whose child is the iterable expression
|
||||
*
|
||||
* Tier 1c: resolves the element type via:
|
||||
* 1. scopeEnv string — extractElementTypeFromString on the stored type
|
||||
* 2. AST walk — walks up to the enclosing method's YARD @param to read Array<User> directly
|
||||
*
|
||||
* Ruby has no static types on loop variables, so this mainly works when the
|
||||
* iterable has a YARD-annotated container type (e.g., `@param users [Array<User>]`).
|
||||
*/
|
||||
const extractForLoopBinding: ForLoopExtractor = (node, { scopeEnv, declarationTypeNodes, scope }): void => {
|
||||
if (node.type !== 'for') return;
|
||||
|
||||
// The loop variable is the `pattern` field (identifier).
|
||||
const patternNode = node.childForFieldName('pattern');
|
||||
if (!patternNode) return;
|
||||
const loopVarName = extractVarName(patternNode);
|
||||
if (!loopVarName) return;
|
||||
|
||||
// The iterable is inside the `value` field which is an `in` node wrapping the expression.
|
||||
const inNode = node.childForFieldName('value');
|
||||
if (!inNode) return;
|
||||
const iterableNode = inNode.firstNamedChild;
|
||||
let iterableName: string | undefined;
|
||||
if (iterableNode?.type === 'identifier') {
|
||||
iterableName = iterableNode.text;
|
||||
} else if (iterableNode?.type === 'call') {
|
||||
const method = iterableNode.childForFieldName('method');
|
||||
if (method) iterableName = method.text;
|
||||
}
|
||||
if (!iterableName) return;
|
||||
|
||||
// Ruby has no extractFromTypeNode (no AST type annotations), pass a no-op.
|
||||
const noopExtractFromTypeNode = (): string | undefined => undefined;
|
||||
|
||||
const elementType = resolveIterableElementType(
|
||||
iterableName, node, scopeEnv, declarationTypeNodes, scope,
|
||||
noopExtractFromTypeNode, findRubyParamElementType,
|
||||
undefined,
|
||||
);
|
||||
if (!elementType) return;
|
||||
|
||||
scopeEnv.set(loopVarName, elementType);
|
||||
};
|
||||
|
||||
/**
|
||||
* Ruby: alias_user = user → assignment with left/right identifier fields.
|
||||
* Only handles plain identifier RHS (not calls, not literals).
|
||||
* Skips if LHS already has a resolved type in scopeEnv.
|
||||
*/
|
||||
const extractPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) => {
|
||||
if (node.type !== 'assignment') return undefined;
|
||||
const lhsNode = node.childForFieldName('left');
|
||||
if (!lhsNode || lhsNode.type !== 'identifier') return undefined;
|
||||
const varName = lhsNode.text;
|
||||
if (scopeEnv.has(varName)) return undefined;
|
||||
const rhsNode = node.childForFieldName('right');
|
||||
if (!rhsNode || rhsNode.type !== 'identifier') return undefined;
|
||||
return { kind: 'copy', lhs: varName, rhs: rhsNode.text };
|
||||
};
|
||||
|
||||
export const typeConfig: LanguageTypeConfig = {
|
||||
declarationNodeTypes: DECLARATION_NODE_TYPES,
|
||||
forLoopNodeTypes: FOR_LOOP_NODE_TYPES,
|
||||
extractDeclaration,
|
||||
extractParameter,
|
||||
extractInitializer,
|
||||
scanConstructorBinding,
|
||||
extractReturnType,
|
||||
extractForLoopBinding,
|
||||
extractPendingAssignment,
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { SyntaxNode } from '../utils.js';
|
||||
import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, InitializerExtractor, ClassNameLookup, ConstructorBindingScanner, PendingAssignmentExtractor, PatternBindingExtractor } from './types.js';
|
||||
import { extractSimpleTypeName, extractVarName, hasTypeAnnotation, unwrapAwait, extractGenericTypeArgs } from './shared.js';
|
||||
import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, InitializerExtractor, ClassNameLookup, ConstructorBindingScanner, PendingAssignmentExtractor, PatternBindingExtractor, ForLoopExtractor } from './types.js';
|
||||
import { extractSimpleTypeName, extractVarName, hasTypeAnnotation, unwrapAwait, extractGenericTypeArgs, resolveIterableElementType, methodToTypeArgPosition, extractElementTypeFromString, type TypeArgPosition } from './shared.js';
|
||||
|
||||
const DECLARATION_NODE_TYPES: ReadonlySet<string> = new Set([
|
||||
'let_declaration',
|
||||
@@ -35,7 +35,8 @@ const extractStructPatternType = (structPattern: SyntaxNode): string | undefined
|
||||
* Recursively scan a pattern tree for captured_pattern nodes (x @ StructType { .. })
|
||||
* and extract variable → type bindings from them.
|
||||
*/
|
||||
const extractCapturedPatternBindings = (pattern: SyntaxNode, env: Map<string, string>): void => {
|
||||
const extractCapturedPatternBindings = (pattern: SyntaxNode, env: Map<string, string>, depth = 0): void => {
|
||||
if (depth > 50) return;
|
||||
if (pattern.type === 'captured_pattern') {
|
||||
// captured_pattern: identifier @ inner_pattern
|
||||
// The first named child is the identifier, followed by the inner pattern.
|
||||
@@ -57,7 +58,7 @@ const extractCapturedPatternBindings = (pattern: SyntaxNode, env: Map<string, st
|
||||
if (pattern.type === 'tuple_struct_pattern') {
|
||||
for (let i = 0; i < pattern.namedChildCount; i++) {
|
||||
const child = pattern.namedChild(i);
|
||||
if (child) extractCapturedPatternBindings(child, env);
|
||||
if (child) extractCapturedPatternBindings(child, env, depth + 1);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -189,7 +190,7 @@ const extractPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) =>
|
||||
if (!pattern || !value) return undefined;
|
||||
const lhs = extractVarName(pattern);
|
||||
if (!lhs || scopeEnv.has(lhs)) return undefined;
|
||||
if (value.type === 'identifier') return { lhs, rhs: value.text };
|
||||
if (value.type === 'identifier') return { kind: 'copy', lhs, rhs: value.text };
|
||||
return undefined;
|
||||
};
|
||||
|
||||
@@ -215,10 +216,25 @@ const extractPatternBinding: PatternBindingExtractor = (
|
||||
declarationTypeNodes,
|
||||
scope,
|
||||
) => {
|
||||
if (node.type !== 'let_condition') return undefined;
|
||||
let patternNode: SyntaxNode | null = null;
|
||||
let valueNode: SyntaxNode | null = null;
|
||||
|
||||
const patternNode = node.childForFieldName('pattern');
|
||||
const valueNode = node.childForFieldName('value');
|
||||
if (node.type === 'let_condition') {
|
||||
patternNode = node.childForFieldName('pattern');
|
||||
valueNode = node.childForFieldName('value');
|
||||
} else if (node.type === 'match_arm') {
|
||||
// match_arm → pattern field is match_pattern wrapping the actual pattern
|
||||
const matchPatternNode = node.childForFieldName('pattern');
|
||||
// Unwrap match_pattern to get the tuple_struct_pattern inside
|
||||
patternNode = matchPatternNode?.type === 'match_pattern'
|
||||
? matchPatternNode.firstNamedChild
|
||||
: matchPatternNode;
|
||||
// source variable is in the parent match_expression's 'value' field
|
||||
const matchExpr = node.parent?.parent; // match_arm → match_block → match_expression
|
||||
if (matchExpr?.type === 'match_expression') {
|
||||
valueNode = matchExpr.childForFieldName('value');
|
||||
}
|
||||
}
|
||||
if (!patternNode || !valueNode) return undefined;
|
||||
|
||||
// Only handle tuple_struct_pattern: Some(x) or Ok(x)
|
||||
@@ -269,12 +285,136 @@ const extractPatternBinding: PatternBindingExtractor = (
|
||||
return { varName: innerVar, typeName: typeArgs[argIndex] };
|
||||
};
|
||||
|
||||
// --- For-loop Tier 1c ---
|
||||
|
||||
const FOR_LOOP_NODE_TYPES: ReadonlySet<string> = new Set(['for_expression']);
|
||||
|
||||
/** Extract element type from a Rust type annotation AST node.
|
||||
* Handles: generic_type (Vec<User>), reference_type (&[User]), array_type ([User; N]),
|
||||
* slice_type ([User]). For call-graph purposes, strips references (&User → User). */
|
||||
const extractRustElementTypeFromTypeNode = (typeNode: SyntaxNode, pos: TypeArgPosition = 'last', depth = 0): string | undefined => {
|
||||
if (depth > 50) return undefined;
|
||||
// generic_type: Vec<User>, HashMap<K, V> — extract type arg based on position
|
||||
if (typeNode.type === 'generic_type') {
|
||||
const args = extractGenericTypeArgs(typeNode);
|
||||
if (args.length >= 1) return pos === 'first' ? args[0] : args[args.length - 1];
|
||||
}
|
||||
// reference_type: &[User] or &Vec<User> — unwrap the reference and recurse
|
||||
if (typeNode.type === 'reference_type') {
|
||||
const inner = typeNode.lastNamedChild;
|
||||
if (inner) return extractRustElementTypeFromTypeNode(inner, pos, depth + 1);
|
||||
}
|
||||
// array_type: [User; N] — element is the first child
|
||||
if (typeNode.type === 'array_type') {
|
||||
const elemNode = typeNode.firstNamedChild;
|
||||
if (elemNode) return extractSimpleTypeName(elemNode);
|
||||
}
|
||||
// slice_type: [User] — element is the first child
|
||||
if (typeNode.type === 'slice_type') {
|
||||
const elemNode = typeNode.firstNamedChild;
|
||||
if (elemNode) return extractSimpleTypeName(elemNode);
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/** Walk up from a for-loop to the enclosing function_item and search parameters
|
||||
* for one named `iterableName`. Returns the element type from its annotation. */
|
||||
const findRustParamElementType = (iterableName: string, startNode: SyntaxNode, pos: TypeArgPosition = 'last'): string | undefined => {
|
||||
let current: SyntaxNode | null = startNode.parent;
|
||||
while (current) {
|
||||
if (current.type === 'function_item') {
|
||||
const paramsNode = current.childForFieldName('parameters');
|
||||
if (paramsNode) {
|
||||
for (let i = 0; i < paramsNode.namedChildCount; i++) {
|
||||
const param = paramsNode.namedChild(i);
|
||||
if (!param || param.type !== 'parameter') continue;
|
||||
const nameNode = param.childForFieldName('pattern');
|
||||
if (!nameNode) continue;
|
||||
// Unwrap reference patterns: &users, &mut users
|
||||
let identNode = nameNode;
|
||||
if (identNode.type === 'reference_pattern') {
|
||||
identNode = identNode.lastNamedChild ?? identNode;
|
||||
}
|
||||
if (identNode.type === 'mut_pattern') {
|
||||
identNode = identNode.firstNamedChild ?? identNode;
|
||||
}
|
||||
if (identNode.text !== iterableName) continue;
|
||||
const typeNode = param.childForFieldName('type');
|
||||
if (typeNode) return extractRustElementTypeFromTypeNode(typeNode, pos);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
current = current.parent;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/** Rust: for user in &users where users has a known container type.
|
||||
* Unwraps reference_expression (&users, &mut users) to get the iterable name. */
|
||||
const extractForLoopBinding: ForLoopExtractor = (node, { scopeEnv, declarationTypeNodes, scope, returnTypeLookup }): void => {
|
||||
if (node.type !== 'for_expression') return;
|
||||
|
||||
const patternNode = node.childForFieldName('pattern');
|
||||
const valueNode = node.childForFieldName('value');
|
||||
if (!patternNode || !valueNode) return;
|
||||
|
||||
// Extract iterable name + method — may be &users, users, or users.iter()/keys()/values()
|
||||
let iterableName: string | undefined;
|
||||
let methodName: string | undefined;
|
||||
let callExprElementType: string | undefined;
|
||||
if (valueNode.type === 'reference_expression') {
|
||||
const inner = valueNode.lastNamedChild;
|
||||
if (inner?.type === 'identifier') iterableName = inner.text;
|
||||
} else if (valueNode.type === 'identifier') {
|
||||
iterableName = valueNode.text;
|
||||
} else if (valueNode.type === 'field_expression') {
|
||||
const prop = valueNode.lastNamedChild;
|
||||
if (prop) iterableName = prop.text;
|
||||
} else if (valueNode.type === 'call_expression') {
|
||||
const funcExpr = valueNode.childForFieldName('function');
|
||||
if (funcExpr?.type === 'field_expression') {
|
||||
// users.iter() → field_expression > identifier + field_identifier
|
||||
const obj = funcExpr.firstNamedChild;
|
||||
if (obj?.type === 'identifier') iterableName = obj.text;
|
||||
// Extract method name: iter, keys, values, into_iter, etc.
|
||||
const field = funcExpr.lastNamedChild;
|
||||
if (field?.type === 'field_identifier') methodName = field.text;
|
||||
} else if (funcExpr?.type === 'identifier') {
|
||||
// Direct function call: for user in get_users()
|
||||
const rawReturn = returnTypeLookup.lookupRawReturnType(funcExpr.text);
|
||||
if (rawReturn) callExprElementType = extractElementTypeFromString(rawReturn);
|
||||
}
|
||||
}
|
||||
if (!iterableName && !callExprElementType) return;
|
||||
|
||||
let elementType: string | undefined;
|
||||
if (callExprElementType) {
|
||||
elementType = callExprElementType;
|
||||
} else {
|
||||
const containerTypeName = scopeEnv.get(iterableName!);
|
||||
const typeArgPos = methodToTypeArgPosition(methodName, containerTypeName);
|
||||
elementType = resolveIterableElementType(
|
||||
iterableName!, node, scopeEnv, declarationTypeNodes, scope,
|
||||
extractRustElementTypeFromTypeNode, findRustParamElementType,
|
||||
typeArgPos,
|
||||
);
|
||||
}
|
||||
if (!elementType) return;
|
||||
|
||||
const loopVarName = extractVarName(patternNode);
|
||||
if (loopVarName) scopeEnv.set(loopVarName, elementType);
|
||||
};
|
||||
|
||||
export const typeConfig: LanguageTypeConfig = {
|
||||
declarationNodeTypes: DECLARATION_NODE_TYPES,
|
||||
forLoopNodeTypes: FOR_LOOP_NODE_TYPES,
|
||||
patternBindingNodeTypes: new Set(['let_condition', 'match_arm']),
|
||||
extractDeclaration,
|
||||
extractInitializer,
|
||||
extractParameter,
|
||||
scanConstructorBinding,
|
||||
extractForLoopBinding,
|
||||
extractPendingAssignment,
|
||||
extractPatternBinding,
|
||||
};
|
||||
|
||||
@@ -1,9 +1,175 @@
|
||||
import type { SyntaxNode } from '../utils.js';
|
||||
|
||||
/** Which type argument to extract from a multi-arg generic container.
|
||||
* - 'first': key type (e.g., K from Map<K,V>) — used for .keys(), .keySet()
|
||||
* - 'last': value type (e.g., V from Map<K,V>) — used for .values(), .items(), .iter() */
|
||||
export type TypeArgPosition = 'first' | 'last';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Container type descriptors — maps container base names to type parameter
|
||||
// semantics per access method. Replaces the simple KEY_METHODS heuristic.
|
||||
//
|
||||
// For user-defined generics (MyCache<K,V> extends Map<K,V>), heritage-aware
|
||||
// fallback can walk the EXTENDS chain to find a matching descriptor.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Describes which type parameter position each access method yields. */
|
||||
interface ContainerDescriptor {
|
||||
/** Number of type parameters (1 = single-element, 2 = key-value) */
|
||||
arity: number;
|
||||
/** Methods that yield the first type parameter (key type for maps) */
|
||||
keyMethods: ReadonlySet<string>;
|
||||
/** Methods that yield the last type parameter (value type) */
|
||||
valueMethods: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
/** Empty set for containers that have no key-yielding methods */
|
||||
const NO_KEYS: ReadonlySet<string> = new Set();
|
||||
|
||||
/** Standard key-yielding methods across languages */
|
||||
const STD_KEY_METHODS: ReadonlySet<string> = new Set(['keys']);
|
||||
const JAVA_KEY_METHODS: ReadonlySet<string> = new Set(['keySet']);
|
||||
const CSHARP_KEY_METHODS: ReadonlySet<string> = new Set(['Keys']);
|
||||
|
||||
/** Standard value-yielding methods across languages */
|
||||
const STD_VALUE_METHODS: ReadonlySet<string> = new Set(['values', 'get', 'pop', 'remove']);
|
||||
const CSHARP_VALUE_METHODS: ReadonlySet<string> = new Set(['Values', 'TryGetValue']);
|
||||
const SINGLE_ELEMENT_METHODS: ReadonlySet<string> = new Set([
|
||||
'iter', 'into_iter', 'iterator', 'get', 'first', 'last', 'pop',
|
||||
'peek', 'poll', 'find', 'filter', 'map',
|
||||
]);
|
||||
|
||||
const CONTAINER_DESCRIPTORS: ReadonlyMap<string, ContainerDescriptor> = new Map([
|
||||
// --- Map / Dict types (arity 2: key + value) ---
|
||||
['Map', { arity: 2, keyMethods: STD_KEY_METHODS, valueMethods: STD_VALUE_METHODS }],
|
||||
['WeakMap', { arity: 2, keyMethods: STD_KEY_METHODS, valueMethods: STD_VALUE_METHODS }],
|
||||
['HashMap', { arity: 2, keyMethods: STD_KEY_METHODS, valueMethods: STD_VALUE_METHODS }],
|
||||
['BTreeMap', { arity: 2, keyMethods: STD_KEY_METHODS, valueMethods: STD_VALUE_METHODS }],
|
||||
['LinkedHashMap', { arity: 2, keyMethods: JAVA_KEY_METHODS, valueMethods: STD_VALUE_METHODS }],
|
||||
['TreeMap', { arity: 2, keyMethods: JAVA_KEY_METHODS, valueMethods: STD_VALUE_METHODS }],
|
||||
['dict', { arity: 2, keyMethods: STD_KEY_METHODS, valueMethods: STD_VALUE_METHODS }],
|
||||
['Dict', { arity: 2, keyMethods: STD_KEY_METHODS, valueMethods: STD_VALUE_METHODS }],
|
||||
['Dictionary', { arity: 2, keyMethods: CSHARP_KEY_METHODS, valueMethods: CSHARP_VALUE_METHODS }],
|
||||
['SortedDictionary', { arity: 2, keyMethods: CSHARP_KEY_METHODS, valueMethods: CSHARP_VALUE_METHODS }],
|
||||
['Record', { arity: 2, keyMethods: STD_KEY_METHODS, valueMethods: STD_VALUE_METHODS }],
|
||||
['OrderedDict', { arity: 2, keyMethods: STD_KEY_METHODS, valueMethods: STD_VALUE_METHODS }],
|
||||
['ConcurrentHashMap', { arity: 2, keyMethods: JAVA_KEY_METHODS, valueMethods: STD_VALUE_METHODS }],
|
||||
['ConcurrentDictionary', { arity: 2, keyMethods: CSHARP_KEY_METHODS, valueMethods: CSHARP_VALUE_METHODS }],
|
||||
|
||||
// --- Single-element containers (arity 1) ---
|
||||
['Array', { arity: 1, keyMethods: NO_KEYS, valueMethods: SINGLE_ELEMENT_METHODS }],
|
||||
['List', { arity: 1, keyMethods: NO_KEYS, valueMethods: SINGLE_ELEMENT_METHODS }],
|
||||
['ArrayList', { arity: 1, keyMethods: NO_KEYS, valueMethods: SINGLE_ELEMENT_METHODS }],
|
||||
['LinkedList',{ arity: 1, keyMethods: NO_KEYS, valueMethods: SINGLE_ELEMENT_METHODS }],
|
||||
['Vec', { arity: 1, keyMethods: NO_KEYS, valueMethods: SINGLE_ELEMENT_METHODS }],
|
||||
['VecDeque', { arity: 1, keyMethods: NO_KEYS, valueMethods: SINGLE_ELEMENT_METHODS }],
|
||||
['Set', { arity: 1, keyMethods: NO_KEYS, valueMethods: SINGLE_ELEMENT_METHODS }],
|
||||
['HashSet', { arity: 1, keyMethods: NO_KEYS, valueMethods: SINGLE_ELEMENT_METHODS }],
|
||||
['BTreeSet', { arity: 1, keyMethods: NO_KEYS, valueMethods: SINGLE_ELEMENT_METHODS }],
|
||||
['TreeSet', { arity: 1, keyMethods: NO_KEYS, valueMethods: SINGLE_ELEMENT_METHODS }],
|
||||
['Queue', { arity: 1, keyMethods: NO_KEYS, valueMethods: SINGLE_ELEMENT_METHODS }],
|
||||
['Deque', { arity: 1, keyMethods: NO_KEYS, valueMethods: SINGLE_ELEMENT_METHODS }],
|
||||
['Stack', { arity: 1, keyMethods: NO_KEYS, valueMethods: SINGLE_ELEMENT_METHODS }],
|
||||
['Sequence', { arity: 1, keyMethods: NO_KEYS, valueMethods: SINGLE_ELEMENT_METHODS }],
|
||||
['Iterable', { arity: 1, keyMethods: NO_KEYS, valueMethods: SINGLE_ELEMENT_METHODS }],
|
||||
['Iterator', { arity: 1, keyMethods: NO_KEYS, valueMethods: SINGLE_ELEMENT_METHODS }],
|
||||
['IEnumerable', { arity: 1, keyMethods: NO_KEYS, valueMethods: SINGLE_ELEMENT_METHODS }],
|
||||
['IList', { arity: 1, keyMethods: NO_KEYS, valueMethods: SINGLE_ELEMENT_METHODS }],
|
||||
['ICollection', { arity: 1, keyMethods: NO_KEYS, valueMethods: SINGLE_ELEMENT_METHODS }],
|
||||
['Collection', { arity: 1, keyMethods: NO_KEYS, valueMethods: SINGLE_ELEMENT_METHODS }],
|
||||
['ObservableCollection', { arity: 1, keyMethods: NO_KEYS, valueMethods: SINGLE_ELEMENT_METHODS }],
|
||||
['IEnumerator', { arity: 1, keyMethods: NO_KEYS, valueMethods: SINGLE_ELEMENT_METHODS }],
|
||||
['SortedSet', { arity: 1, keyMethods: NO_KEYS, valueMethods: SINGLE_ELEMENT_METHODS }],
|
||||
['Stream', { arity: 1, keyMethods: NO_KEYS, valueMethods: SINGLE_ELEMENT_METHODS }],
|
||||
['MutableList', { arity: 1, keyMethods: NO_KEYS, valueMethods: SINGLE_ELEMENT_METHODS }],
|
||||
['MutableSet', { arity: 1, keyMethods: NO_KEYS, valueMethods: SINGLE_ELEMENT_METHODS }],
|
||||
['LinkedHashSet', { arity: 1, keyMethods: NO_KEYS, valueMethods: SINGLE_ELEMENT_METHODS }],
|
||||
['ArrayDeque', { arity: 1, keyMethods: NO_KEYS, valueMethods: SINGLE_ELEMENT_METHODS }],
|
||||
['PriorityQueue', { arity: 1, keyMethods: NO_KEYS, valueMethods: SINGLE_ELEMENT_METHODS }],
|
||||
['MutableMap', { arity: 2, keyMethods: STD_KEY_METHODS, valueMethods: STD_VALUE_METHODS }],
|
||||
['list', { arity: 1, keyMethods: NO_KEYS, valueMethods: SINGLE_ELEMENT_METHODS }],
|
||||
['set', { arity: 1, keyMethods: NO_KEYS, valueMethods: SINGLE_ELEMENT_METHODS }],
|
||||
['tuple', { arity: 1, keyMethods: NO_KEYS, valueMethods: SINGLE_ELEMENT_METHODS }],
|
||||
['frozenset', { arity: 1, keyMethods: NO_KEYS, valueMethods: SINGLE_ELEMENT_METHODS }],
|
||||
]);
|
||||
|
||||
/** Determine which type arg to extract based on container type name and access method.
|
||||
*
|
||||
* Resolution order:
|
||||
* 1. If container is known and method is in keyMethods → 'first'
|
||||
* 2. If container is known with arity 1 → 'last' (same as 'first' for single-arg)
|
||||
* 3. If container is unknown → fall back to method name heuristic
|
||||
* 4. Default: 'last' (value type)
|
||||
*/
|
||||
export function methodToTypeArgPosition(methodName: string | undefined, containerTypeName?: string): TypeArgPosition {
|
||||
if (containerTypeName) {
|
||||
const desc = CONTAINER_DESCRIPTORS.get(containerTypeName);
|
||||
if (desc) {
|
||||
// Single-element container: always 'last' (= only arg)
|
||||
if (desc.arity === 1) return 'last';
|
||||
// Multi-element: check if method yields key type
|
||||
if (methodName && desc.keyMethods.has(methodName)) return 'first';
|
||||
// Default for multi-element: value type
|
||||
return 'last';
|
||||
}
|
||||
}
|
||||
// Fallback for unknown containers: simple method name heuristic
|
||||
if (methodName && (methodName === 'keys' || methodName === 'keySet' || methodName === 'Keys')) {
|
||||
return 'first';
|
||||
}
|
||||
return 'last';
|
||||
}
|
||||
|
||||
/** Look up the container descriptor for a type name. Exported for heritage-chain lookups. */
|
||||
export function getContainerDescriptor(typeName: string): ContainerDescriptor | undefined {
|
||||
return CONTAINER_DESCRIPTORS.get(typeName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared 3-strategy fallback for resolving the element type of a container variable.
|
||||
* Used by all for-loop extractors to resolve the loop variable's type from the iterable.
|
||||
*
|
||||
* Strategy 1: declarationTypeNodes — raw AST type annotation node (handles container types
|
||||
* where extractSimpleTypeName returned undefined, e.g., User[], List[User])
|
||||
* Strategy 2: scopeEnv string — extractElementTypeFromString on the stored type string
|
||||
* Strategy 3: AST walk — language-specific upward walk to enclosing function parameters
|
||||
*
|
||||
* @param extractFromTypeNode Language-specific function to extract element type from AST node
|
||||
* @param findParamElementType Optional language-specific AST walk to find parameter type
|
||||
* @param typeArgPos Which generic type arg to extract: 'first' for keys, 'last' for values (default)
|
||||
*/
|
||||
export function resolveIterableElementType(
|
||||
iterableName: string,
|
||||
node: SyntaxNode,
|
||||
scopeEnv: ReadonlyMap<string, string>,
|
||||
declarationTypeNodes: ReadonlyMap<string, SyntaxNode>,
|
||||
scope: string,
|
||||
extractFromTypeNode: (typeNode: SyntaxNode, pos?: TypeArgPosition) => string | undefined,
|
||||
findParamElementType?: (name: string, startNode: SyntaxNode, pos?: TypeArgPosition) => string | undefined,
|
||||
typeArgPos: TypeArgPosition = 'last',
|
||||
): string | undefined {
|
||||
// Strategy 1: declarationTypeNodes AST node (check current scope, then file scope)
|
||||
const typeNode = declarationTypeNodes.get(`${scope}\0${iterableName}`)
|
||||
?? (scope !== '' ? declarationTypeNodes.get(`\0${iterableName}`) : undefined);
|
||||
if (typeNode) {
|
||||
const t = extractFromTypeNode(typeNode, typeArgPos);
|
||||
if (t) return t;
|
||||
}
|
||||
// Strategy 2: scopeEnv string → extractElementTypeFromString
|
||||
const iterableType = scopeEnv.get(iterableName);
|
||||
if (iterableType) {
|
||||
const el = extractElementTypeFromString(iterableType, typeArgPos);
|
||||
if (el) return el;
|
||||
}
|
||||
// Strategy 3: AST walk to function parameters
|
||||
if (findParamElementType) return findParamElementType(iterableName, node, typeArgPos);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Known single-arg nullable wrapper types that unwrap to their inner type
|
||||
* for receiver resolution. Optional<User> → "User", Option<User> → "User".
|
||||
* Only nullable wrappers — NOT containers (List, Vec) or async wrappers (Promise, Future).
|
||||
* See call-processor.ts WRAPPER_GENERICS for the full set used in return-type inference. */
|
||||
* See WRAPPER_GENERICS below for the full set used in return-type inference. */
|
||||
const NULLABLE_WRAPPER_TYPES = new Set([
|
||||
'Optional', // Java
|
||||
'Option', // Rust, Scala
|
||||
@@ -16,7 +182,8 @@ const NULLABLE_WRAPPER_TYPES = new Set([
|
||||
* (e.g., models.User → User), and nullable types (e.g., User? → User).
|
||||
* Returns undefined for complex types (unions, intersections, function types).
|
||||
*/
|
||||
export const extractSimpleTypeName = (typeNode: SyntaxNode): string | undefined => {
|
||||
export const extractSimpleTypeName = (typeNode: SyntaxNode, depth = 0): string | undefined => {
|
||||
if (depth > 50 || typeNode.text.length > 2048) return undefined;
|
||||
// Direct type identifier (includes Ruby 'constant' for class names)
|
||||
if (typeNode.type === 'type_identifier' || typeNode.type === 'identifier'
|
||||
|| typeNode.type === 'simple_identifier' || typeNode.type === 'constant') {
|
||||
@@ -40,14 +207,21 @@ export const extractSimpleTypeName = (typeNode: SyntaxNode): string | undefined
|
||||
}
|
||||
}
|
||||
|
||||
// C++ template_type (e.g., vector<User>, map<string, User>): extract base name
|
||||
if (typeNode.type === 'template_type') {
|
||||
const base = typeNode.childForFieldName('name') ?? typeNode.firstNamedChild;
|
||||
if (base) return extractSimpleTypeName(base, depth + 1);
|
||||
}
|
||||
|
||||
// Generic types: extract the base type (e.g., List<User> → List)
|
||||
// For nullable wrappers (Optional<User>, Option<User>), unwrap to inner type.
|
||||
if (typeNode.type === 'generic_type' || typeNode.type === 'parameterized_type') {
|
||||
if (typeNode.type === 'generic_type' || typeNode.type === 'parameterized_type'
|
||||
|| typeNode.type === 'generic_name') {
|
||||
const base = typeNode.childForFieldName('name')
|
||||
?? typeNode.childForFieldName('type')
|
||||
?? typeNode.firstNamedChild;
|
||||
if (!base) return undefined;
|
||||
const baseName = extractSimpleTypeName(base);
|
||||
const baseName = extractSimpleTypeName(base, depth + 1);
|
||||
// Unwrap known nullable wrappers: Optional<User> → User, Option<User> → User
|
||||
if (baseName && NULLABLE_WRAPPER_TYPES.has(baseName)) {
|
||||
const args = extractGenericTypeArgs(typeNode);
|
||||
@@ -59,7 +233,7 @@ export const extractSimpleTypeName = (typeNode: SyntaxNode): string | undefined
|
||||
// Nullable types (Kotlin User?, C# User?)
|
||||
if (typeNode.type === 'nullable_type') {
|
||||
const inner = typeNode.firstNamedChild;
|
||||
if (inner) return extractSimpleTypeName(inner);
|
||||
if (inner) return extractSimpleTypeName(inner, depth + 1);
|
||||
}
|
||||
|
||||
// Nullable union types (TS/JS: User | null, User | undefined, User | null | undefined)
|
||||
@@ -76,7 +250,7 @@ export const extractSimpleTypeName = (typeNode: SyntaxNode): string | undefined
|
||||
}
|
||||
// Only unwrap if exactly one meaningful type remains
|
||||
if (nonNullTypes.length === 1) {
|
||||
return extractSimpleTypeName(nonNullTypes[0]);
|
||||
return extractSimpleTypeName(nonNullTypes[0], depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,24 +258,25 @@ export const extractSimpleTypeName = (typeNode: SyntaxNode): string | undefined
|
||||
if (typeNode.type === 'type_annotation' || typeNode.type === 'type'
|
||||
|| typeNode.type === 'user_type') {
|
||||
const inner = typeNode.firstNamedChild;
|
||||
if (inner) return extractSimpleTypeName(inner);
|
||||
if (inner) return extractSimpleTypeName(inner, depth + 1);
|
||||
}
|
||||
|
||||
// Pointer/reference types (C++, Rust): User*, &User, &mut User
|
||||
if (typeNode.type === 'pointer_type' || typeNode.type === 'reference_type') {
|
||||
const inner = typeNode.firstNamedChild;
|
||||
if (inner) return extractSimpleTypeName(inner);
|
||||
if (inner) return extractSimpleTypeName(inner, depth + 1);
|
||||
}
|
||||
|
||||
// PHP primitive_type (string, int, float, bool)
|
||||
if (typeNode.type === 'primitive_type') {
|
||||
// Primitive/predefined types: string, int, float, bool, number, unknown, any
|
||||
// PHP: primitive_type; TS/JS: predefined_type
|
||||
if (typeNode.type === 'primitive_type' || typeNode.type === 'predefined_type') {
|
||||
return typeNode.text;
|
||||
}
|
||||
|
||||
// PHP named_type / optional_type
|
||||
if (typeNode.type === 'named_type' || typeNode.type === 'optional_type') {
|
||||
const inner = typeNode.childForFieldName('name') ?? typeNode.firstNamedChild;
|
||||
if (inner) return extractSimpleTypeName(inner);
|
||||
if (inner) return extractSimpleTypeName(inner, depth + 1);
|
||||
}
|
||||
|
||||
// Name node (PHP)
|
||||
@@ -119,7 +294,7 @@ export const extractSimpleTypeName = (typeNode: SyntaxNode): string | undefined
|
||||
export const extractVarName = (node: SyntaxNode): string | undefined => {
|
||||
if (node.type === 'identifier' || node.type === 'simple_identifier'
|
||||
|| node.type === 'variable_name' || node.type === 'name'
|
||||
|| node.type === 'constant') {
|
||||
|| node.type === 'constant' || node.type === 'property_identifier') {
|
||||
return node.text;
|
||||
}
|
||||
// variable_declarator (Java/C#): has a 'name' field
|
||||
@@ -141,9 +316,11 @@ export const TYPED_PARAMETER_TYPES = new Set([
|
||||
'optional_parameter', // TS: (x?: Foo)
|
||||
'formal_parameter', // Java/Kotlin
|
||||
'parameter', // C#/Rust/Go/Python/Swift
|
||||
'typed_parameter', // Python: def f(x: Foo) — distinct from 'parameter' in tree-sitter-python
|
||||
'parameter_declaration', // C/C++ void f(Type name)
|
||||
'simple_parameter', // PHP function(Foo $x)
|
||||
'property_promotion_parameter', // PHP 8.0+ constructor promotion: __construct(private Foo $x)
|
||||
'closure_parameter', // Rust: |user: User| — typed closure parameters
|
||||
]);
|
||||
|
||||
/**
|
||||
@@ -164,18 +341,20 @@ export const TYPED_PARAMETER_TYPES = new Set([
|
||||
* returns [] for non-generic types).
|
||||
* @returns Array of resolved type argument names. Unresolvable arguments are omitted.
|
||||
*/
|
||||
export const extractGenericTypeArgs = (typeNode: SyntaxNode): string[] => {
|
||||
export const extractGenericTypeArgs = (typeNode: SyntaxNode, depth = 0): string[] => {
|
||||
if (depth > 50) return [];
|
||||
// Unwrap wrapper nodes that may sit above the generic_type
|
||||
if (typeNode.type === 'type_annotation' || typeNode.type === 'type'
|
||||
|| typeNode.type === 'user_type' || typeNode.type === 'nullable_type'
|
||||
|| typeNode.type === 'optional_type') {
|
||||
const inner = typeNode.firstNamedChild;
|
||||
if (inner) return extractGenericTypeArgs(inner);
|
||||
if (inner) return extractGenericTypeArgs(inner, depth + 1);
|
||||
return [];
|
||||
}
|
||||
|
||||
// Only process generic/parameterized type nodes
|
||||
if (typeNode.type !== 'generic_type' && typeNode.type !== 'parameterized_type') {
|
||||
// Only process generic/parameterized type nodes (includes C#'s generic_name)
|
||||
if (typeNode.type !== 'generic_type' && typeNode.type !== 'parameterized_type'
|
||||
&& typeNode.type !== 'generic_name') {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -316,3 +495,253 @@ export const findChildByType = (node: SyntaxNode, type: string): SyntaxNode | nu
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Internal helper: extract the first comma-separated argument from a string,
|
||||
// respecting nested angle-bracket and square-bracket depth.
|
||||
function extractFirstArg(args: string): string {
|
||||
let depth = 0;
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const ch = args[i];
|
||||
if (ch === '<' || ch === '[') depth++;
|
||||
else if (ch === '>' || ch === ']') depth--;
|
||||
else if (ch === ',' && depth === 0) return args.slice(0, i).trim();
|
||||
}
|
||||
return args.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract element type from a container type string.
|
||||
* Uses bracket-balanced parsing (no regex) for generic argument extraction.
|
||||
* Returns undefined for ambiguous or unparseable strings.
|
||||
*
|
||||
* Handles:
|
||||
* - Array<User> → User (generic angle brackets)
|
||||
* - User[] → User (array suffix)
|
||||
* - []User → User (Go slice prefix)
|
||||
* - List[User] → User (Python subscript)
|
||||
* - [User] → User (Swift array sugar)
|
||||
* - vector<User> → User (C++ container)
|
||||
* - Vec<User> → User (Rust container)
|
||||
*
|
||||
* For multi-argument generics (Map<K, V>), returns the first or last type arg
|
||||
* based on `pos` ('first' for keys, 'last' for values — default 'last').
|
||||
* Returns undefined when the extracted type is not a simple word.
|
||||
*/
|
||||
export function extractElementTypeFromString(typeStr: string, pos: TypeArgPosition = 'last'): string | undefined {
|
||||
if (!typeStr || typeStr.length === 0 || typeStr.length > 2048) return undefined;
|
||||
|
||||
// 1. Array suffix: User[] → User
|
||||
if (typeStr.endsWith('[]')) {
|
||||
const base = typeStr.slice(0, -2).trim();
|
||||
return base && /^\w+$/.test(base) ? base : undefined;
|
||||
}
|
||||
|
||||
// 2. Go slice prefix: []User → User
|
||||
if (typeStr.startsWith('[]')) {
|
||||
const element = typeStr.slice(2).trim();
|
||||
return element && /^\w+$/.test(element) ? element : undefined;
|
||||
}
|
||||
|
||||
// 3. Swift array sugar: [User] → User
|
||||
// Must start with '[', end with ']', and contain no angle brackets
|
||||
// (to avoid confusing with List[User] handled below).
|
||||
if (typeStr.startsWith('[') && typeStr.endsWith(']') && !typeStr.includes('<')) {
|
||||
const element = typeStr.slice(1, -1).trim();
|
||||
return element && /^\w+$/.test(element) ? element : undefined;
|
||||
}
|
||||
|
||||
// 4. Generic bracket-balanced extraction: Array<User> / List[User] / Vec<User>
|
||||
// Find the first opening bracket (< or [) and pick the one that appears first.
|
||||
const openAngle = typeStr.indexOf('<');
|
||||
const openSquare = typeStr.indexOf('[');
|
||||
|
||||
let openIdx = -1;
|
||||
let openChar = '';
|
||||
let closeChar = '';
|
||||
|
||||
if (openAngle >= 0 && (openSquare < 0 || openAngle < openSquare)) {
|
||||
openIdx = openAngle;
|
||||
openChar = '<';
|
||||
closeChar = '>';
|
||||
} else if (openSquare >= 0) {
|
||||
openIdx = openSquare;
|
||||
openChar = '[';
|
||||
closeChar = ']';
|
||||
}
|
||||
|
||||
if (openIdx < 0) return undefined;
|
||||
|
||||
// Walk bracket-balanced from the character after the opening bracket to find
|
||||
// the matching close bracket, tracking depth for nested brackets.
|
||||
// All bracket types (<, >, [, ]) contribute to depth uniformly, but only the
|
||||
// selected closeChar can match at depth 0 (prevents cross-bracket miscounting).
|
||||
let depth = 0;
|
||||
const start = openIdx + 1;
|
||||
let lastCommaIdx = -1; // Track last top-level comma for 'last' position
|
||||
for (let i = start; i < typeStr.length; i++) {
|
||||
const ch = typeStr[i];
|
||||
if (ch === '<' || ch === '[') {
|
||||
depth++;
|
||||
} else if (ch === '>' || ch === ']') {
|
||||
if (depth === 0) {
|
||||
// At depth 0 — only match if it is our selected close bracket.
|
||||
if (ch !== closeChar) return undefined; // mismatched bracket = malformed
|
||||
if (pos === 'last' && lastCommaIdx >= 0) {
|
||||
// Return last arg (text after last comma)
|
||||
const lastArg = typeStr.slice(lastCommaIdx + 1, i).trim();
|
||||
return lastArg && /^\w+$/.test(lastArg) ? lastArg : undefined;
|
||||
}
|
||||
const inner = typeStr.slice(start, i).trim();
|
||||
const firstArg = extractFirstArg(inner);
|
||||
return firstArg && /^\w+$/.test(firstArg) ? firstArg : undefined;
|
||||
}
|
||||
depth--;
|
||||
} else if (ch === ',' && depth === 0) {
|
||||
if (pos === 'first') {
|
||||
// Return first arg (text before first comma)
|
||||
const arg = typeStr.slice(start, i).trim();
|
||||
return arg && /^\w+$/.test(arg) ? arg : undefined;
|
||||
}
|
||||
lastCommaIdx = i;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// ── Return type text helpers ─────────────────────────────────────────────
|
||||
// extractReturnTypeName works on raw return-type text already stored in
|
||||
// SymbolDefinition (e.g. "User", "Promise<User>", "User | null", "*User").
|
||||
// Extracts the base user-defined type name.
|
||||
|
||||
/** Primitive / built-in types that should NOT produce a receiver binding. */
|
||||
const PRIMITIVE_TYPES = new Set([
|
||||
'string', 'number', 'boolean', 'void', 'int', 'float', 'double', 'long',
|
||||
'short', 'byte', 'char', 'bool', 'str', 'i8', 'i16', 'i32', 'i64',
|
||||
'u8', 'u16', 'u32', 'u64', 'f32', 'f64', 'usize', 'isize',
|
||||
'undefined', 'null', 'None', 'nil',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Extract a simple type name from raw return-type text.
|
||||
* Handles common patterns:
|
||||
* "User" → "User"
|
||||
* "Promise<User>" → "User" (unwrap wrapper generics)
|
||||
* "Option<User>" → "User"
|
||||
* "Result<User, Error>" → "User" (first type arg)
|
||||
* "User | null" → "User" (strip nullable union)
|
||||
* "User?" → "User" (strip nullable suffix)
|
||||
* "*User" → "User" (Go pointer)
|
||||
* "&User" → "User" (Rust reference)
|
||||
* Returns undefined for complex types or primitives.
|
||||
*/
|
||||
const WRAPPER_GENERICS = new Set([
|
||||
'Promise', 'Observable', 'Future', 'CompletableFuture', 'Task', 'ValueTask', // async wrappers
|
||||
'Option', 'Some', 'Optional', 'Maybe', // nullable wrappers
|
||||
'Result', 'Either', // result wrappers
|
||||
// Rust smart pointers (Deref to inner type)
|
||||
'Rc', 'Arc', 'Weak', // pointer types
|
||||
'MutexGuard', 'RwLockReadGuard', 'RwLockWriteGuard', // guard types
|
||||
'Ref', 'RefMut', // RefCell guards
|
||||
'Cow', // copy-on-write
|
||||
// Containers (List, Array, Vec, Set, etc.) are intentionally excluded —
|
||||
// methods are called on the container, not the element type.
|
||||
// Non-wrapper generics return the base type (e.g., List) via the else branch.
|
||||
]);
|
||||
|
||||
/**
|
||||
* Extracts the first type argument from a comma-separated generic argument string,
|
||||
* respecting nested angle brackets. For example:
|
||||
* "Result<User, Error>" → "Result<User, Error>" (no top-level comma)
|
||||
* "User, Error" → "User"
|
||||
* "Map<K, V>, string" → "Map<K, V>"
|
||||
*/
|
||||
function extractFirstGenericArg(args: string): string {
|
||||
let depth = 0;
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '<') depth++;
|
||||
else if (args[i] === '>') depth--;
|
||||
else if (args[i] === ',' && depth === 0) return args.slice(0, i).trim();
|
||||
}
|
||||
return args.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the first non-lifetime type argument from a generic argument string.
|
||||
* Skips Rust lifetime parameters (e.g., `'a`, `'_`) to find the actual type.
|
||||
* "'_, User" → "User"
|
||||
* "'a, User" → "User"
|
||||
* "User, Error" → "User" (no lifetime — delegates to extractFirstGenericArg)
|
||||
*/
|
||||
function extractFirstTypeArg(args: string): string {
|
||||
let remaining = args;
|
||||
while (remaining) {
|
||||
const first = extractFirstGenericArg(remaining);
|
||||
if (!first.startsWith("'")) return first;
|
||||
// Skip past this lifetime arg + the comma separator
|
||||
const commaIdx = remaining.indexOf(',', first.length);
|
||||
if (commaIdx < 0) return first; // only lifetimes — fall through
|
||||
remaining = remaining.slice(commaIdx + 1).trim();
|
||||
}
|
||||
return args.trim();
|
||||
}
|
||||
|
||||
const MAX_RETURN_TYPE_INPUT_LENGTH = 2048;
|
||||
const MAX_RETURN_TYPE_LENGTH = 512;
|
||||
|
||||
export const extractReturnTypeName = (raw: string, depth = 0): string | undefined => {
|
||||
if (depth > 10) return undefined;
|
||||
if (raw.length > MAX_RETURN_TYPE_INPUT_LENGTH) return undefined;
|
||||
let text = raw.trim();
|
||||
if (!text) return undefined;
|
||||
|
||||
// Strip pointer/reference prefixes: *User, &User, &mut User
|
||||
text = text.replace(/^[&*]+\s*(mut\s+)?/, '');
|
||||
|
||||
// Strip nullable suffix: User?
|
||||
text = text.replace(/\?$/, '');
|
||||
|
||||
// Handle union types: "User | null" → "User"
|
||||
if (text.includes('|')) {
|
||||
const parts = text.split('|').map(p => p.trim()).filter(p =>
|
||||
p !== 'null' && p !== 'undefined' && p !== 'void' && p !== 'None' && p !== 'nil'
|
||||
);
|
||||
if (parts.length === 1) text = parts[0];
|
||||
else return undefined; // genuine union — too complex
|
||||
}
|
||||
|
||||
// Handle generics: Promise<User> → unwrap if wrapper, else take base
|
||||
const genericMatch = text.match(/^(\w+)\s*<(.+)>$/);
|
||||
if (genericMatch) {
|
||||
const [, base, args] = genericMatch;
|
||||
if (WRAPPER_GENERICS.has(base)) {
|
||||
// Take the first non-lifetime type argument, using bracket-balanced splitting
|
||||
// so that nested generics like Result<User, Error> are not split at the inner
|
||||
// comma. Lifetime parameters (Rust 'a, '_) are skipped.
|
||||
const firstArg = extractFirstTypeArg(args);
|
||||
return extractReturnTypeName(firstArg, depth + 1);
|
||||
}
|
||||
// Non-wrapper generic: return the base type (e.g., Map<K,V> → Map)
|
||||
return PRIMITIVE_TYPES.has(base.toLowerCase()) ? undefined : base;
|
||||
}
|
||||
|
||||
// Bare wrapper type without generic argument (e.g. Task, Promise, Option)
|
||||
// should not produce a binding — these are meaningless without a type parameter
|
||||
if (WRAPPER_GENERICS.has(text)) return undefined;
|
||||
|
||||
// Handle qualified names: models.User → User, Models::User → User, \App\Models\User → User
|
||||
if (text.includes('::') || text.includes('.') || text.includes('\\')) {
|
||||
text = text.split(/::|[.\\]/).pop()!;
|
||||
}
|
||||
|
||||
// Final check: skip primitives
|
||||
if (PRIMITIVE_TYPES.has(text) || PRIMITIVE_TYPES.has(text.toLowerCase())) return undefined;
|
||||
|
||||
// Must start with uppercase (class/type convention) or be a valid identifier
|
||||
if (!/^[A-Z_]\w*$/.test(text)) return undefined;
|
||||
|
||||
// If the final extracted type name is too long, reject it
|
||||
if (text.length > MAX_RETURN_TYPE_LENGTH) return undefined;
|
||||
|
||||
return text;
|
||||
};
|
||||
|
||||
@@ -24,19 +24,49 @@ export type ConstructorBindingScanner = (node: SyntaxNode) => { varName: string;
|
||||
* rather than in AST fields. Returns undefined if no return type can be determined. */
|
||||
export type ReturnTypeExtractor = (node: SyntaxNode) => string | undefined;
|
||||
|
||||
/** Extracts loop variable type binding from a for-each statement. */
|
||||
export type ForLoopExtractor = (
|
||||
node: SyntaxNode,
|
||||
scopeEnv: Map<string, string>,
|
||||
) => void;
|
||||
/** Narrow lookup interface for resolving a callee name → return type name.
|
||||
* Backed by SymbolTable.lookupFuzzyCallable; passed via ForLoopExtractorContext.
|
||||
* Conservative: returns undefined when the callee is ambiguous (0 or 2+ matches). */
|
||||
export interface ReturnTypeLookup {
|
||||
/** Processed type name after stripping wrappers (e.g., 'User' from 'Promise<User>').
|
||||
* Use for call-result variable bindings (`const b = foo()`). */
|
||||
lookupReturnType(callee: string): string | undefined;
|
||||
/** Raw return type as declared in the symbol (e.g., '[]User', 'List<User>').
|
||||
* Use for iterable-element extraction (`for v := range foo()`). */
|
||||
lookupRawReturnType(callee: string): string | undefined;
|
||||
}
|
||||
|
||||
/** Extracts a plain-identifier assignment for Tier 2 propagation.
|
||||
* For `const b = a`, returns { lhs: 'b', rhs: 'a' } when the LHS has no resolved type.
|
||||
* Returns undefined if the node is not a plain identifier assignment. */
|
||||
/** Context object passed to ForLoopExtractor.
|
||||
* Groups the four parameters that were previously positional. */
|
||||
export interface ForLoopExtractorContext {
|
||||
/** Mutable type-env for the current scope — extractor writes bindings here */
|
||||
scopeEnv: Map<string, string>;
|
||||
/** Maps `scope\0varName` to the declaration's type annotation AST node */
|
||||
declarationTypeNodes: ReadonlyMap<string, SyntaxNode>;
|
||||
/** Current scope key, e.g. `"process@42"` */
|
||||
scope: string;
|
||||
/** Resolves a callee name to its declared return type (undefined = unknown/ambiguous) */
|
||||
returnTypeLookup: ReturnTypeLookup;
|
||||
}
|
||||
|
||||
/** Extracts loop variable type binding from a for-each statement. */
|
||||
export type ForLoopExtractor = (node: SyntaxNode, ctx: ForLoopExtractorContext) => void;
|
||||
|
||||
/** Discriminated union for pending Tier-2 propagation items.
|
||||
* - `copy` — `const b = a` (identifier alias, propagate a's type to b)
|
||||
* - `callResult` — `const b = foo()` (bind b to foo's declared return type) */
|
||||
export type PendingAssignment =
|
||||
| { kind: 'copy'; lhs: string; rhs: string }
|
||||
| { kind: 'callResult'; lhs: string; callee: string };
|
||||
|
||||
/** Extracts a pending assignment for Tier 2 propagation.
|
||||
* Returns a PendingAssignment when the RHS is a bare identifier (`copy`) or a
|
||||
* call expression (`callResult`) and the LHS has no resolved type yet.
|
||||
* Returns undefined if the node is not a matching assignment. */
|
||||
export type PendingAssignmentExtractor = (
|
||||
node: SyntaxNode,
|
||||
scopeEnv: ReadonlyMap<string, string>,
|
||||
) => { lhs: string; rhs: string } | undefined;
|
||||
) => PendingAssignment | undefined;
|
||||
|
||||
/** Extracts a typed variable binding from a pattern-matching construct.
|
||||
* Returns { varName, typeName } for patterns that introduce NEW variables.
|
||||
@@ -57,10 +87,20 @@ export type PatternBindingExtractor = (
|
||||
|
||||
/** Per-language type extraction configuration */
|
||||
export interface LanguageTypeConfig {
|
||||
/** Allow pattern binding to overwrite existing scopeEnv entries.
|
||||
* WARNING: Enables function-scope type pollution. Only for languages with
|
||||
* smart-cast semantics (e.g., Kotlin `when/is`) where the subject variable
|
||||
* already exists in scopeEnv from its declaration. */
|
||||
readonly allowPatternBindingOverwrite?: boolean;
|
||||
/** Node types that represent typed declarations for this language */
|
||||
declarationNodeTypes: ReadonlySet<string>;
|
||||
/** AST node types for for-each/for-in statements with explicit element types. */
|
||||
forLoopNodeTypes?: ReadonlySet<string>;
|
||||
/** Optional allowlist of AST node types on which extractPatternBinding should run.
|
||||
* When present, extractPatternBinding is only invoked for nodes whose type is in this set,
|
||||
* short-circuiting the call for all other node types. When absent, every node is passed to
|
||||
* extractPatternBinding (legacy behaviour). */
|
||||
patternBindingNodeTypes?: ReadonlySet<string>;
|
||||
/** Extract a (varName → typeName) binding from a declaration node */
|
||||
extractDeclaration: TypeBindingExtractor;
|
||||
/** Extract a (varName → typeName) binding from a parameter node */
|
||||
@@ -79,9 +119,10 @@ export interface LanguageTypeConfig {
|
||||
extractReturnType?: ReturnTypeExtractor;
|
||||
/** Extract loop variable → type binding from a for-each AST node. */
|
||||
extractForLoopBinding?: ForLoopExtractor;
|
||||
/** Extract plain-identifier assignment (e.g. `const b = a`) for Tier 2 chain propagation.
|
||||
* Called on declaration/assignment nodes; returns {lhs, rhs} when the RHS is a bare identifier
|
||||
* and the LHS has no resolved type yet. Language-specific because AST shapes differ widely. */
|
||||
/** Extract pending assignment for Tier 2 propagation.
|
||||
* Called on declaration/assignment nodes; returns a PendingAssignment when the RHS
|
||||
* is a bare identifier (copy) or call expression (callResult) and the LHS has no
|
||||
* resolved type yet. Language-specific because AST shapes differ widely. */
|
||||
extractPendingAssignment?: PendingAssignmentExtractor;
|
||||
/** Extract a typed variable binding from a pattern-matching construct.
|
||||
* Called on every AST node; returns { varName, typeName } when the node introduces a new
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import type { SyntaxNode } from '../utils.js';
|
||||
import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, InitializerExtractor, ClassNameLookup, ConstructorBindingScanner, ReturnTypeExtractor, PendingAssignmentExtractor } from './types.js';
|
||||
import { extractSimpleTypeName, extractVarName, hasTypeAnnotation, unwrapAwait, extractCalleeName } from './shared.js';
|
||||
import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, InitializerExtractor, ClassNameLookup, ConstructorBindingScanner, ReturnTypeExtractor, PendingAssignmentExtractor, ForLoopExtractor, PatternBindingExtractor } from './types.js';
|
||||
import { extractSimpleTypeName, extractVarName, hasTypeAnnotation, unwrapAwait, extractCalleeName, extractElementTypeFromString, extractGenericTypeArgs, resolveIterableElementType, methodToTypeArgPosition, type TypeArgPosition } from './shared.js';
|
||||
|
||||
const DECLARATION_NODE_TYPES: ReadonlySet<string> = new Set([
|
||||
'lexical_declaration',
|
||||
'variable_declaration',
|
||||
'function_declaration', // JSDoc @param on function declarations
|
||||
'method_definition', // JSDoc @param on class methods
|
||||
'public_field_definition', // class field: private users: User[]
|
||||
]);
|
||||
|
||||
const normalizeJsDocType = (raw: string): string | undefined => {
|
||||
@@ -80,6 +81,18 @@ const extractDeclaration: TypeBindingExtractor = (node: SyntaxNode, env: Map<str
|
||||
return;
|
||||
}
|
||||
|
||||
// Class field: `private users: User[]` — public_field_definition has name + type fields directly.
|
||||
if (node.type === 'public_field_definition') {
|
||||
const nameNode = node.childForFieldName('name');
|
||||
const typeAnnotation = node.childForFieldName('type');
|
||||
if (!nameNode || !typeAnnotation) return;
|
||||
const varName = nameNode.text;
|
||||
if (!varName) return;
|
||||
const typeName = extractSimpleTypeName(typeAnnotation);
|
||||
if (typeName) env.set(varName, typeName);
|
||||
return;
|
||||
}
|
||||
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
const declarator = node.namedChild(i);
|
||||
if (declarator?.type !== 'variable_declarator') continue;
|
||||
@@ -191,6 +204,231 @@ const extractReturnType: ReturnTypeExtractor = (node) => {
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const FOR_LOOP_NODE_TYPES: ReadonlySet<string> = new Set([
|
||||
'for_in_statement',
|
||||
]);
|
||||
|
||||
/** TS function/method node types that carry a parameters list. */
|
||||
const TS_FUNCTION_NODE_TYPES = new Set([
|
||||
'function_declaration', 'function_expression', 'arrow_function',
|
||||
'method_definition', 'generator_function', 'generator_function_declaration',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Extract element type from a TypeScript type annotation AST node.
|
||||
* Handles:
|
||||
* type_annotation ": User[]" → array_type → type_identifier "User"
|
||||
* type_annotation ": Array<User>" → generic_type → extractGenericTypeArgs → "User"
|
||||
* Falls back to text-based extraction via extractElementTypeFromString.
|
||||
*/
|
||||
const extractTsElementTypeFromAnnotation = (typeAnnotation: SyntaxNode, pos: TypeArgPosition = 'last', depth = 0): string | undefined => {
|
||||
if (depth > 50) return undefined;
|
||||
// Unwrap type_annotation (the node text includes ': ' prefix)
|
||||
const inner = typeAnnotation.type === 'type_annotation'
|
||||
? (typeAnnotation.firstNamedChild ?? typeAnnotation)
|
||||
: typeAnnotation;
|
||||
|
||||
// readonly User[] — readonly_type wraps array_type: unwrap and recurse
|
||||
if (inner.type === 'readonly_type') {
|
||||
const wrapped = inner.firstNamedChild;
|
||||
if (wrapped) return extractTsElementTypeFromAnnotation(wrapped, pos, depth + 1);
|
||||
}
|
||||
|
||||
// User[] — array_type: first named child is the element type
|
||||
if (inner.type === 'array_type') {
|
||||
const elem = inner.firstNamedChild;
|
||||
if (elem) return extractSimpleTypeName(elem);
|
||||
}
|
||||
|
||||
// Array<User>, Map<string, User> — generic_type
|
||||
// pos determines which type arg: 'first' for keys, 'last' for values
|
||||
if (inner.type === 'generic_type') {
|
||||
const args = extractGenericTypeArgs(inner);
|
||||
if (args.length >= 1) return pos === 'first' ? args[0] : args[args.length - 1];
|
||||
}
|
||||
|
||||
// Fallback: strip ': ' prefix from type_annotation text and use string extraction
|
||||
const rawText = inner.text;
|
||||
return extractElementTypeFromString(rawText, pos);
|
||||
};
|
||||
|
||||
/**
|
||||
* Search a statement_block (function body) for a variable_declarator named `iterableName`
|
||||
* that has a type annotation, preceding the given `beforeNode`.
|
||||
* Returns the element type from the type annotation, or undefined.
|
||||
*/
|
||||
const findTsLocalDeclElementType = (
|
||||
iterableName: string,
|
||||
blockNode: SyntaxNode,
|
||||
beforeNode: SyntaxNode,
|
||||
pos: TypeArgPosition = 'last',
|
||||
): string | undefined => {
|
||||
for (let i = 0; i < blockNode.namedChildCount; i++) {
|
||||
const stmt = blockNode.namedChild(i);
|
||||
if (!stmt) continue;
|
||||
// Stop when we reach the for-loop itself
|
||||
if (stmt === beforeNode || stmt.startIndex >= beforeNode.startIndex) break;
|
||||
// Look for lexical_declaration or variable_declaration
|
||||
if (stmt.type !== 'lexical_declaration' && stmt.type !== 'variable_declaration') continue;
|
||||
for (let j = 0; j < stmt.namedChildCount; j++) {
|
||||
const decl = stmt.namedChild(j);
|
||||
if (decl?.type !== 'variable_declarator') continue;
|
||||
const nameNode = decl.childForFieldName('name');
|
||||
if (nameNode?.text !== iterableName) continue;
|
||||
const typeAnnotation = decl.childForFieldName('type');
|
||||
if (typeAnnotation) return extractTsElementTypeFromAnnotation(typeAnnotation, pos);
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Walk up the AST from a for-loop node to find the enclosing function scope,
|
||||
* then search (1) its parameter list and (2) local declarations in the body
|
||||
* for a variable named `iterableName` with a container type annotation.
|
||||
* Returns the element type extracted from the annotation, or undefined.
|
||||
*/
|
||||
const findTsIterableElementType = (iterableName: string, startNode: SyntaxNode, pos: TypeArgPosition = 'last'): string | undefined => {
|
||||
let current: SyntaxNode | null = startNode.parent;
|
||||
// Capture the immediate statement_block parent to search local declarations
|
||||
const blockNode = current?.type === 'statement_block' ? current : null;
|
||||
|
||||
while (current) {
|
||||
if (TS_FUNCTION_NODE_TYPES.has(current.type)) {
|
||||
// Search function parameters
|
||||
const paramsNode = current.childForFieldName('parameters')
|
||||
?? current.childForFieldName('formal_parameters');
|
||||
if (paramsNode) {
|
||||
for (let i = 0; i < paramsNode.namedChildCount; i++) {
|
||||
const param = paramsNode.namedChild(i);
|
||||
if (!param) continue;
|
||||
const patternNode = param.childForFieldName('pattern') ?? param.childForFieldName('name');
|
||||
if (patternNode?.text === iterableName) {
|
||||
const typeAnnotation = param.childForFieldName('type');
|
||||
if (typeAnnotation) return extractTsElementTypeFromAnnotation(typeAnnotation, pos);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Search local declarations in the function body (statement_block)
|
||||
if (blockNode) {
|
||||
const result = findTsLocalDeclElementType(iterableName, blockNode, startNode, pos);
|
||||
if (result) return result;
|
||||
}
|
||||
break; // stop at the nearest function boundary
|
||||
}
|
||||
current = current.parent;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* TypeScript/JavaScript: for (const user of users) where users has a known array type.
|
||||
*
|
||||
* Both `for...of` and `for...in` use the same `for_in_statement` AST node in tree-sitter.
|
||||
* We differentiate by checking for the `of` keyword among the unnamed children.
|
||||
*
|
||||
* Tier 1c: resolves the element type via three strategies in priority order:
|
||||
* 1. declarationTypeNodes — raw type annotation AST node (covers Array<User> from declarations)
|
||||
* 2. scopeEnv string — extractElementTypeFromString on the stored type (covers locally annotated vars)
|
||||
* 3. AST walk — walks up to the enclosing function's parameters to read User[] annotations directly
|
||||
* Only handles `for...of`; `for...in` produces string keys, not element types.
|
||||
*/
|
||||
const extractForLoopBinding: ForLoopExtractor = (node, { scopeEnv, declarationTypeNodes, scope, returnTypeLookup }): void => {
|
||||
if (node.type !== 'for_in_statement') return;
|
||||
|
||||
// Confirm this is `for...of`, not `for...in`, by scanning unnamed children for the keyword text.
|
||||
let isForOf = false;
|
||||
for (let i = 0; i < node.childCount; i++) {
|
||||
const child = node.child(i);
|
||||
if (child && !child.isNamed && child.text === 'of') {
|
||||
isForOf = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!isForOf) return;
|
||||
|
||||
// The iterable is the `right` field — may be identifier, member_expression, or call_expression.
|
||||
const rightNode = node.childForFieldName('right');
|
||||
let iterableName: string | undefined;
|
||||
let methodName: string | undefined;
|
||||
let callExprElementType: string | undefined;
|
||||
if (rightNode?.type === 'identifier') {
|
||||
iterableName = rightNode.text;
|
||||
} else if (rightNode?.type === 'member_expression') {
|
||||
const prop = rightNode.childForFieldName('property');
|
||||
if (prop) iterableName = prop.text;
|
||||
} else if (rightNode?.type === 'call_expression') {
|
||||
// entries.values() → call_expression > function: member_expression > object + property
|
||||
// this.repos.values() → nested member_expression: extract property from inner member
|
||||
// getUsers() → call_expression > function: identifier (Phase 7.3 — return-type path)
|
||||
const fn = rightNode.childForFieldName('function');
|
||||
if (fn?.type === 'member_expression') {
|
||||
const obj = fn.childForFieldName('object');
|
||||
const prop = fn.childForFieldName('property');
|
||||
if (obj?.type === 'identifier') {
|
||||
iterableName = obj.text;
|
||||
} else if (obj?.type === 'member_expression') {
|
||||
// this.repos.values() → obj = this.repos → extract 'repos'
|
||||
const innerProp = obj.childForFieldName('property');
|
||||
if (innerProp) iterableName = innerProp.text;
|
||||
}
|
||||
if (prop?.type === 'property_identifier') methodName = prop.text;
|
||||
} else if (fn?.type === 'identifier') {
|
||||
// Direct function call: for (const user of getUsers())
|
||||
const rawReturn = returnTypeLookup.lookupRawReturnType(fn.text);
|
||||
if (rawReturn) callExprElementType = extractElementTypeFromString(rawReturn);
|
||||
}
|
||||
}
|
||||
if (!iterableName && !callExprElementType) return;
|
||||
|
||||
let elementType: string | undefined;
|
||||
if (callExprElementType) {
|
||||
elementType = callExprElementType;
|
||||
} else {
|
||||
// Look up the container's base type name for descriptor-aware resolution
|
||||
const containerTypeName = scopeEnv.get(iterableName!);
|
||||
const typeArgPos = methodToTypeArgPosition(methodName, containerTypeName);
|
||||
elementType = resolveIterableElementType(
|
||||
iterableName!, node, scopeEnv, declarationTypeNodes, scope,
|
||||
extractTsElementTypeFromAnnotation, findTsIterableElementType,
|
||||
typeArgPos,
|
||||
);
|
||||
}
|
||||
if (!elementType) return;
|
||||
|
||||
// The loop variable is the `left` field.
|
||||
const leftNode = node.childForFieldName('left');
|
||||
if (!leftNode) return;
|
||||
|
||||
// Handle destructured for-of: for (const [k, v] of entries)
|
||||
// AST: left = array_pattern directly (no variable_declarator wrapper)
|
||||
// Bind the LAST identifier to the element type (value in [key, value] patterns)
|
||||
if (leftNode.type === 'array_pattern') {
|
||||
const lastChild = leftNode.lastNamedChild;
|
||||
if (lastChild?.type === 'identifier') {
|
||||
scopeEnv.set(lastChild.text, elementType);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (leftNode.type === 'object_pattern') {
|
||||
// Object destructuring (e.g., `for (const { id } of users)`) destructures
|
||||
// into fields of the element type. Without field-level resolution, we cannot
|
||||
// bind individual properties to their correct types. Skip to avoid false bindings.
|
||||
return;
|
||||
}
|
||||
|
||||
let loopVarNode: SyntaxNode | null = leftNode;
|
||||
// `const user` parses as: left → variable_declarator containing an identifier named `user`
|
||||
if (loopVarNode.type === 'variable_declarator') {
|
||||
loopVarNode = loopVarNode.childForFieldName('name') ?? loopVarNode.firstNamedChild;
|
||||
}
|
||||
if (!loopVarNode) return;
|
||||
|
||||
const loopVarName = extractVarName(loopVarNode);
|
||||
if (loopVarName) scopeEnv.set(loopVarName, elementType);
|
||||
};
|
||||
|
||||
/** TS/JS: const alias = u → variable_declarator with name/value fields */
|
||||
const extractPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) => {
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
@@ -201,17 +439,36 @@ const extractPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) =>
|
||||
if (!nameNode || !valueNode) continue;
|
||||
const lhs = nameNode.text;
|
||||
if (scopeEnv.has(lhs)) continue;
|
||||
if (valueNode.type === 'identifier') return { lhs, rhs: valueNode.text };
|
||||
if (valueNode.type === 'identifier') return { kind: 'copy', lhs, rhs: valueNode.text };
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/** TS instanceof narrowing: `x instanceof User` → bind x to User.
|
||||
* Only works when x has no prior type binding (e.g. x: unknown, untyped params).
|
||||
* Typed params (x: Animal) are blocked by the !scopeEnv.has() guard in buildTypeEnv.
|
||||
* Uses first-writer-wins, same as Rust match arm bindings. */
|
||||
const extractPatternBinding: PatternBindingExtractor = (node) => {
|
||||
if (node.type !== 'binary_expression') return undefined;
|
||||
const op = node.children.find(c => !c.isNamed && c.text === 'instanceof');
|
||||
if (!op) return undefined;
|
||||
// binary_expression children are positional — no left/right fields
|
||||
const left = node.namedChild(0);
|
||||
const right = node.namedChild(1);
|
||||
if (left?.type !== 'identifier' || right?.type !== 'identifier') return undefined;
|
||||
return { varName: left.text, typeName: right.text };
|
||||
};
|
||||
|
||||
export const typeConfig: LanguageTypeConfig = {
|
||||
declarationNodeTypes: DECLARATION_NODE_TYPES,
|
||||
forLoopNodeTypes: FOR_LOOP_NODE_TYPES,
|
||||
patternBindingNodeTypes: new Set(['binary_expression']),
|
||||
extractDeclaration,
|
||||
extractParameter,
|
||||
extractInitializer,
|
||||
scanConstructorBinding,
|
||||
extractReturnType,
|
||||
extractForLoopBinding,
|
||||
extractPendingAssignment,
|
||||
extractPatternBinding,
|
||||
};
|
||||
|
||||
@@ -35,7 +35,7 @@ export const DEFINITION_CAPTURE_KEYS = [
|
||||
] as const;
|
||||
|
||||
/** Extract the definition node from a tree-sitter query capture map. */
|
||||
export const getDefinitionNodeFromCaptures = (captureMap: Record<string, any>): any | null => {
|
||||
export const getDefinitionNodeFromCaptures = (captureMap: Record<string, any>): SyntaxNode | null => {
|
||||
for (const key of DEFINITION_CAPTURE_KEYS) {
|
||||
if (captureMap[key]) return captureMap[key];
|
||||
}
|
||||
@@ -351,7 +351,7 @@ export const findEnclosingClassId = (node: any, filePath: string): string | null
|
||||
* Extract function name and label from a function_definition or similar AST node.
|
||||
* Handles C/C++ qualified_identifier (ClassName::MethodName) and other language patterns.
|
||||
*/
|
||||
export const extractFunctionName = (node: any): { funcName: string | null; label: string } => {
|
||||
export const extractFunctionName = (node: SyntaxNode): { funcName: string | null; label: string } => {
|
||||
let funcName: string | null = null;
|
||||
let label = 'Function';
|
||||
|
||||
@@ -366,21 +366,40 @@ export const extractFunctionName = (node: any): { funcName: string | null; label
|
||||
if (FUNCTION_DECLARATION_TYPES.has(node.type)) {
|
||||
// C/C++: function_definition -> [pointer_declarator ->] function_declarator -> qualified_identifier/identifier
|
||||
// Unwrap pointer_declarator / reference_declarator wrappers to reach function_declarator
|
||||
let declarator = node.childForFieldName?.('declarator') ||
|
||||
node.children?.find((c: any) => c.type === 'function_declarator');
|
||||
let declarator = node.childForFieldName?.('declarator');
|
||||
if (!declarator) {
|
||||
for (let i = 0; i < node.childCount; i++) {
|
||||
const c = node.child(i);
|
||||
if (c?.type === 'function_declarator') { declarator = c; break; }
|
||||
}
|
||||
}
|
||||
while (declarator && (declarator.type === 'pointer_declarator' || declarator.type === 'reference_declarator')) {
|
||||
declarator = declarator.childForFieldName?.('declarator') ||
|
||||
declarator.children?.find((c: any) =>
|
||||
c.type === 'function_declarator' || c.type === 'pointer_declarator' || c.type === 'reference_declarator');
|
||||
let nextDeclarator = declarator.childForFieldName?.('declarator');
|
||||
if (!nextDeclarator) {
|
||||
for (let i = 0; i < declarator.childCount; i++) {
|
||||
const c = declarator.child(i);
|
||||
if (c?.type === 'function_declarator' || c?.type === 'pointer_declarator' || c?.type === 'reference_declarator') { nextDeclarator = c; break; }
|
||||
}
|
||||
}
|
||||
declarator = nextDeclarator;
|
||||
}
|
||||
if (declarator) {
|
||||
const innerDeclarator = declarator.childForFieldName?.('declarator') ||
|
||||
declarator.children?.find((c: any) =>
|
||||
c.type === 'qualified_identifier' || c.type === 'identifier' || c.type === 'parenthesized_declarator');
|
||||
let innerDeclarator = declarator.childForFieldName?.('declarator');
|
||||
if (!innerDeclarator) {
|
||||
for (let i = 0; i < declarator.childCount; i++) {
|
||||
const c = declarator.child(i);
|
||||
if (c?.type === 'qualified_identifier' || c?.type === 'identifier' || c?.type === 'parenthesized_declarator') { innerDeclarator = c; break; }
|
||||
}
|
||||
}
|
||||
|
||||
if (innerDeclarator?.type === 'qualified_identifier') {
|
||||
const nameNode = innerDeclarator.childForFieldName?.('name') ||
|
||||
innerDeclarator.children?.find((c: any) => c.type === 'identifier');
|
||||
let nameNode = innerDeclarator.childForFieldName?.('name');
|
||||
if (!nameNode) {
|
||||
for (let i = 0; i < innerDeclarator.childCount; i++) {
|
||||
const c = innerDeclarator.child(i);
|
||||
if (c?.type === 'identifier') { nameNode = c; break; }
|
||||
}
|
||||
}
|
||||
if (nameNode?.text) {
|
||||
funcName = nameNode.text;
|
||||
label = 'Method';
|
||||
@@ -388,11 +407,19 @@ export const extractFunctionName = (node: any): { funcName: string | null; label
|
||||
} else if (innerDeclarator?.type === 'identifier') {
|
||||
funcName = innerDeclarator.text;
|
||||
} else if (innerDeclarator?.type === 'parenthesized_declarator') {
|
||||
const nestedId = innerDeclarator.children?.find((c: any) =>
|
||||
c.type === 'qualified_identifier' || c.type === 'identifier');
|
||||
let nestedId: SyntaxNode | null = null;
|
||||
for (let i = 0; i < innerDeclarator.childCount; i++) {
|
||||
const c = innerDeclarator.child(i);
|
||||
if (c?.type === 'qualified_identifier' || c?.type === 'identifier') { nestedId = c; break; }
|
||||
}
|
||||
if (nestedId?.type === 'qualified_identifier') {
|
||||
const nameNode = nestedId.childForFieldName?.('name') ||
|
||||
nestedId.children?.find((c: any) => c.type === 'identifier');
|
||||
let nameNode = nestedId.childForFieldName?.('name');
|
||||
if (!nameNode) {
|
||||
for (let i = 0; i < nestedId.childCount; i++) {
|
||||
const c = nestedId.child(i);
|
||||
if (c?.type === 'identifier') { nameNode = c; break; }
|
||||
}
|
||||
}
|
||||
if (nameNode?.text) {
|
||||
funcName = nameNode.text;
|
||||
label = 'Method';
|
||||
@@ -405,38 +432,72 @@ export const extractFunctionName = (node: any): { funcName: string | null; label
|
||||
|
||||
// Fallback for other languages (Kotlin uses simple_identifier, Swift uses simple_identifier)
|
||||
if (!funcName) {
|
||||
const nameNode = node.childForFieldName?.('name') ||
|
||||
node.children?.find((c: any) => c.type === 'identifier' || c.type === 'property_identifier' || c.type === 'simple_identifier');
|
||||
let nameNode = node.childForFieldName?.('name');
|
||||
if (!nameNode) {
|
||||
for (let i = 0; i < node.childCount; i++) {
|
||||
const c = node.child(i);
|
||||
if (c?.type === 'identifier' || c?.type === 'property_identifier' || c?.type === 'simple_identifier') { nameNode = c; break; }
|
||||
}
|
||||
}
|
||||
funcName = nameNode?.text;
|
||||
}
|
||||
} else if (node.type === 'impl_item') {
|
||||
const funcItem = node.children?.find((c: any) => c.type === 'function_item');
|
||||
let funcItem: SyntaxNode | null = null;
|
||||
for (let i = 0; i < node.childCount; i++) {
|
||||
const c = node.child(i);
|
||||
if (c?.type === 'function_item') { funcItem = c; break; }
|
||||
}
|
||||
if (funcItem) {
|
||||
const nameNode = funcItem.childForFieldName?.('name') ||
|
||||
funcItem.children?.find((c: any) => c.type === 'identifier');
|
||||
let nameNode = funcItem.childForFieldName?.('name');
|
||||
if (!nameNode) {
|
||||
for (let i = 0; i < funcItem.childCount; i++) {
|
||||
const c = funcItem.child(i);
|
||||
if (c?.type === 'identifier') { nameNode = c; break; }
|
||||
}
|
||||
}
|
||||
funcName = nameNode?.text;
|
||||
label = 'Method';
|
||||
}
|
||||
} else if (node.type === 'method_definition') {
|
||||
const nameNode = node.childForFieldName?.('name') ||
|
||||
node.children?.find((c: any) => c.type === 'property_identifier');
|
||||
let nameNode = node.childForFieldName?.('name');
|
||||
if (!nameNode) {
|
||||
for (let i = 0; i < node.childCount; i++) {
|
||||
const c = node.child(i);
|
||||
if (c?.type === 'property_identifier') { nameNode = c; break; }
|
||||
}
|
||||
}
|
||||
funcName = nameNode?.text;
|
||||
label = 'Method';
|
||||
} else if (node.type === 'method_declaration' || node.type === 'constructor_declaration') {
|
||||
const nameNode = node.childForFieldName?.('name') ||
|
||||
node.children?.find((c: any) => c.type === 'identifier');
|
||||
let nameNode = node.childForFieldName?.('name');
|
||||
if (!nameNode) {
|
||||
for (let i = 0; i < node.childCount; i++) {
|
||||
const c = node.child(i);
|
||||
if (c?.type === 'identifier') { nameNode = c; break; }
|
||||
}
|
||||
}
|
||||
funcName = nameNode?.text;
|
||||
label = 'Method';
|
||||
} else if (node.type === 'arrow_function' || node.type === 'function_expression') {
|
||||
const parent = node.parent;
|
||||
if (parent?.type === 'variable_declarator') {
|
||||
const nameNode = parent.childForFieldName?.('name') ||
|
||||
parent.children?.find((c: any) => c.type === 'identifier');
|
||||
let nameNode = parent.childForFieldName?.('name');
|
||||
if (!nameNode) {
|
||||
for (let i = 0; i < parent.childCount; i++) {
|
||||
const c = parent.child(i);
|
||||
if (c?.type === 'identifier') { nameNode = c; break; }
|
||||
}
|
||||
}
|
||||
funcName = nameNode?.text;
|
||||
}
|
||||
} else if (node.type === 'method' || node.type === 'singleton_method') {
|
||||
const nameNode = node.childForFieldName?.('name') ||
|
||||
node.children?.find((c: any) => c.type === 'identifier');
|
||||
let nameNode = node.childForFieldName?.('name');
|
||||
if (!nameNode) {
|
||||
for (let i = 0; i < node.childCount; i++) {
|
||||
const c = node.child(i);
|
||||
if (c?.type === 'identifier') { nameNode = c; break; }
|
||||
}
|
||||
}
|
||||
funcName = nameNode?.text;
|
||||
label = 'Method';
|
||||
}
|
||||
|
||||
@@ -1115,10 +1115,12 @@ const processFileGroup = (
|
||||
returnType = sig.returnType;
|
||||
|
||||
// Language-specific return type fallback (e.g. Ruby YARD @return [Type])
|
||||
if (!returnType && definitionNode) {
|
||||
// Also upgrades uninformative AST types like PHP `array` with PHPDoc `@return User[]`
|
||||
if ((!returnType || returnType === 'array' || returnType === 'iterable') && definitionNode) {
|
||||
const tc = typeConfigs[language as keyof typeof typeConfigs];
|
||||
if (tc?.extractReturnType) {
|
||||
returnType = tc.extractReturnType(definitionNode);
|
||||
const docReturn = tc.extractReturnType(definitionNode);
|
||||
if (docReturn) returnType = docReturn;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ export const VALID_NODE_LABELS = new Set([
|
||||
]);
|
||||
|
||||
/** Valid relation types for impact analysis filtering */
|
||||
export const VALID_RELATION_TYPES = new Set(['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS']);
|
||||
export const VALID_RELATION_TYPES = new Set(['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS', 'HAS_METHOD', 'OVERRIDES']);
|
||||
|
||||
/** Regex to detect write operations in user-supplied Cypher queries */
|
||||
export const CYPHER_WRITE_RE = /\b(CREATE|DELETE|SET|MERGE|REMOVE|DROP|ALTER|COPY|DETACH)\b/i;
|
||||
@@ -1329,6 +1329,29 @@ export class LocalBackend {
|
||||
relationTypes?: string[];
|
||||
includeTests?: boolean;
|
||||
minConfidence?: number;
|
||||
}): Promise<any> {
|
||||
try {
|
||||
return await this._impactImpl(repo, params);
|
||||
} catch (err: any) {
|
||||
// Return structured error instead of crashing (#321)
|
||||
return {
|
||||
error: (err instanceof Error ? err.message : String(err)) || 'Impact analysis failed',
|
||||
target: { name: params.target },
|
||||
direction: params.direction,
|
||||
impactedCount: 0,
|
||||
risk: 'UNKNOWN',
|
||||
suggestion: 'The graph query failed — try gitnexus context <symbol> as a fallback',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async _impactImpl(repo: RepoHandle, params: {
|
||||
target: string;
|
||||
direction: 'upstream' | 'downstream';
|
||||
maxDepth?: number;
|
||||
relationTypes?: string[];
|
||||
includeTests?: boolean;
|
||||
minConfidence?: number;
|
||||
}): Promise<any> {
|
||||
await this.ensureInitialized(repo.id);
|
||||
|
||||
@@ -1358,6 +1381,7 @@ export class LocalBackend {
|
||||
const impacted: any[] = [];
|
||||
const visited = new Set<string>([symId]);
|
||||
let frontier = [symId];
|
||||
let traversalComplete = true;
|
||||
|
||||
for (let depth = 1; depth <= maxDepth && frontier.length > 0; depth++) {
|
||||
const nextFrontier: string[] = [];
|
||||
@@ -1391,7 +1415,13 @@ export class LocalBackend {
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) { logQueryError('impact:depth-traversal', e); }
|
||||
} catch (e) {
|
||||
logQueryError('impact:depth-traversal', e);
|
||||
// Break out of depth loop on query failure but return partial results
|
||||
// collected so far, rather than silently swallowing the error (#321)
|
||||
traversalComplete = false;
|
||||
break;
|
||||
}
|
||||
|
||||
frontier = nextFrontier;
|
||||
}
|
||||
@@ -1474,6 +1504,7 @@ export class LocalBackend {
|
||||
direction,
|
||||
impactedCount: impacted.length,
|
||||
risk,
|
||||
...(!traversalComplete && { partial: true }),
|
||||
summary: {
|
||||
direct: directCount,
|
||||
processes_affected: processCount,
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
#include "service.h"
|
||||
#include "repo.h"
|
||||
|
||||
void processUser() {
|
||||
UserService svc;
|
||||
svc.getUser().save();
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
#pragma once
|
||||
|
||||
class Repo {
|
||||
public:
|
||||
bool save() { return true; }
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
#pragma once
|
||||
#include "user.h"
|
||||
|
||||
class UserService {
|
||||
public:
|
||||
User getUser() { return User(); }
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
#include "User.h"
|
||||
#include "Repo.h"
|
||||
#include <vector>
|
||||
|
||||
void processUsers(std::vector<User>* usersPtr) {
|
||||
for (auto& user : *usersPtr) {
|
||||
user.save();
|
||||
}
|
||||
}
|
||||
|
||||
void processRepos(std::vector<Repo>* reposPtr) {
|
||||
for (const auto& repo : *reposPtr) {
|
||||
repo.save();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
#pragma once
|
||||
#include <string>
|
||||
|
||||
class Repo {
|
||||
public:
|
||||
Repo(const std::string& name) : name_(name) {}
|
||||
void save() {}
|
||||
private:
|
||||
std::string name_;
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
#pragma once
|
||||
#include <string>
|
||||
|
||||
class User {
|
||||
public:
|
||||
User(const std::string& name) : name_(name) {}
|
||||
void save() {}
|
||||
private:
|
||||
std::string name_;
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
#include "User.h"
|
||||
#include "Repo.h"
|
||||
#include <vector>
|
||||
|
||||
void processUsers(const std::vector<User>& users) {
|
||||
for (auto& user : users) {
|
||||
user.save();
|
||||
}
|
||||
}
|
||||
|
||||
void processRepos(const std::vector<Repo>& repos) {
|
||||
for (const auto& repo : repos) {
|
||||
repo.save();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
#pragma once
|
||||
#include <string>
|
||||
|
||||
class Repo {
|
||||
public:
|
||||
Repo(const std::string& name) : name_(name) {}
|
||||
void save() {}
|
||||
private:
|
||||
std::string name_;
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
#pragma once
|
||||
#include <string>
|
||||
|
||||
class User {
|
||||
public:
|
||||
User(const std::string& name) : name_(name) {}
|
||||
void save() {}
|
||||
private:
|
||||
std::string name_;
|
||||
};
|
||||
@@ -1,8 +0,0 @@
|
||||
#include "user.h"
|
||||
#include <vector>
|
||||
|
||||
void processUsers(std::vector<User> users) {
|
||||
for (User& user : users) {
|
||||
user.save();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
#include "User.h"
|
||||
#include "Repo.h"
|
||||
#include <map>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
void processUserMap(std::map<std::string, User> userMap) {
|
||||
for (auto& [key, user] : userMap) {
|
||||
user.save();
|
||||
}
|
||||
}
|
||||
|
||||
void processRepoMap(std::map<std::string, Repo> repoMap) {
|
||||
for (const auto& [key, repo] : repoMap) {
|
||||
repo.save();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
#pragma once
|
||||
#include <string>
|
||||
|
||||
class Repo {
|
||||
public:
|
||||
Repo(const std::string& name) : name_(name) {}
|
||||
void save() {}
|
||||
private:
|
||||
std::string name_;
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
#pragma once
|
||||
#include <string>
|
||||
|
||||
class User {
|
||||
public:
|
||||
User(const std::string& name) : name_(name) {}
|
||||
void save() {}
|
||||
private:
|
||||
std::string name_;
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace ChainCall.Models;
|
||||
|
||||
public class Repo
|
||||
{
|
||||
public bool Save()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace ChainCall.Models;
|
||||
|
||||
public class User
|
||||
{
|
||||
public bool Save()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using ChainCall.Services;
|
||||
|
||||
public class App
|
||||
{
|
||||
public void ProcessUser()
|
||||
{
|
||||
var svc = new UserService();
|
||||
svc.GetUser().Save();
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
using ChainCall.Models;
|
||||
|
||||
namespace ChainCall.Services;
|
||||
|
||||
public class UserService
|
||||
{
|
||||
public User GetUser()
|
||||
{
|
||||
return new User();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
public class App {
|
||||
public void ProcessValues(Dictionary<string, User> data) {
|
||||
foreach (var user in data.Values) {
|
||||
user.Save();
|
||||
}
|
||||
}
|
||||
|
||||
public void ProcessList(List<User> users) {
|
||||
foreach (var user in users) {
|
||||
user.Save();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
public class Repo {
|
||||
public string Name { get; set; }
|
||||
public void Save() {}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
public class User {
|
||||
public string Name { get; set; }
|
||||
public void Save() {}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
public class App {
|
||||
private Dictionary<string, User> data;
|
||||
|
||||
public void ProcessValues() {
|
||||
foreach (var user in this.data.Values) {
|
||||
user.Save();
|
||||
}
|
||||
}
|
||||
|
||||
public void ProcessKeys() {
|
||||
foreach (var key in this.data.Keys) {
|
||||
key.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
public class Repo {
|
||||
public string Name { get; set; }
|
||||
public void Save() {}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
public class User {
|
||||
public string Name { get; set; }
|
||||
public void Save() {}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
namespace Models;
|
||||
|
||||
public class Repo
|
||||
{
|
||||
public string Name { get; set; } = "";
|
||||
public bool Save() { return true; }
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
namespace Models;
|
||||
|
||||
public class User
|
||||
{
|
||||
public string Name { get; set; } = "";
|
||||
public bool Save() { return true; }
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using Models;
|
||||
|
||||
namespace App;
|
||||
|
||||
public class AppService
|
||||
{
|
||||
public void ProcessWithRecursivePattern(object obj)
|
||||
{
|
||||
if (obj is User { Name: "Alice" } u)
|
||||
{
|
||||
u.Save();
|
||||
}
|
||||
|
||||
var result = obj switch
|
||||
{
|
||||
Repo { Name: "main" } r => r.Save(),
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
}
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace Models;
|
||||
|
||||
public class Repo
|
||||
{
|
||||
public bool Save() { return false; }
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace Models;
|
||||
|
||||
public class User
|
||||
{
|
||||
public bool Save() { return true; }
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using Models;
|
||||
|
||||
namespace App;
|
||||
|
||||
public class AppService
|
||||
{
|
||||
public void Process(object obj)
|
||||
{
|
||||
if (obj is User user)
|
||||
{
|
||||
user.Save();
|
||||
}
|
||||
|
||||
switch (obj)
|
||||
{
|
||||
case Repo repo:
|
||||
repo.Save();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace Models;
|
||||
|
||||
public class Repo
|
||||
{
|
||||
public bool Save() { return false; }
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace Models;
|
||||
|
||||
public class User
|
||||
{
|
||||
public bool Save() { return true; }
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using Models;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace App;
|
||||
|
||||
public class AppService
|
||||
{
|
||||
public void ProcessUsers(List<User> users)
|
||||
{
|
||||
foreach (var user in users)
|
||||
{
|
||||
user.Save();
|
||||
}
|
||||
}
|
||||
|
||||
public void ProcessRepos(List<Repo> repos)
|
||||
{
|
||||
foreach (var repo in repos)
|
||||
{
|
||||
repo.Save();
|
||||
}
|
||||
}
|
||||
|
||||
public void Direct(User u, Repo r)
|
||||
{
|
||||
u.Save();
|
||||
r.Save();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,14 @@
|
||||
package main
|
||||
|
||||
import "example.com/chaincall/models"
|
||||
|
||||
type UserService struct{}
|
||||
|
||||
func (s *UserService) GetUser() *models.User {
|
||||
return &models.User{Name: "alice"}
|
||||
}
|
||||
|
||||
func processUser() {
|
||||
svc := &UserService{}
|
||||
svc.GetUser().Save()
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
module example.com/chaincall
|
||||
|
||||
go 1.21
|
||||
@@ -0,0 +1,9 @@
|
||||
package models
|
||||
|
||||
type Repo struct {
|
||||
Name string
|
||||
}
|
||||
|
||||
func (r *Repo) Save() bool {
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package models
|
||||
|
||||
type User struct {
|
||||
Name string
|
||||
}
|
||||
|
||||
func (u *User) Save() bool {
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package main
|
||||
|
||||
import "example.com/for-call-expr/models"
|
||||
|
||||
func processUsers() {
|
||||
for _, user := range models.GetUsers() {
|
||||
user.Save()
|
||||
}
|
||||
}
|
||||
|
||||
func processRepos() {
|
||||
for _, repo := range models.GetRepos() {
|
||||
repo.Save()
|
||||
}
|
||||
}
|
||||
|
||||
func main() {}
|
||||
@@ -0,0 +1,3 @@
|
||||
module example.com/for-call-expr
|
||||
|
||||
go 1.21
|
||||
@@ -0,0 +1,13 @@
|
||||
package models
|
||||
|
||||
type Repo struct {
|
||||
Name string
|
||||
}
|
||||
|
||||
func (r *Repo) Save() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetRepos() []Repo {
|
||||
return []Repo{{Name: "main"}}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package models
|
||||
|
||||
type User struct {
|
||||
Name string
|
||||
}
|
||||
|
||||
func (u *User) Save() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetUsers() []User {
|
||||
return []User{{Name: "alice"}}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package main
|
||||
|
||||
import "models"
|
||||
|
||||
func processMap(userMap map[string]models.User) {
|
||||
for _, user := range userMap {
|
||||
user.Save()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package models
|
||||
|
||||
type Repo struct {
|
||||
Path string
|
||||
}
|
||||
|
||||
func (r Repo) Save() {}
|
||||
@@ -0,0 +1,7 @@
|
||||
package models
|
||||
|
||||
type User struct {
|
||||
Name string
|
||||
}
|
||||
|
||||
func (u User) Save() {}
|
||||
@@ -0,0 +1,16 @@
|
||||
import models.User;
|
||||
import models.Repo;
|
||||
|
||||
public class Main {
|
||||
void processUsers() {
|
||||
for (User user : User.getUsers()) {
|
||||
user.save();
|
||||
}
|
||||
}
|
||||
|
||||
void processRepos() {
|
||||
for (Repo repo : Repo.getRepos()) {
|
||||
repo.save();
|
||||
}
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package models;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class Repo {
|
||||
private String name;
|
||||
|
||||
public Repo(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public void save() {}
|
||||
|
||||
public static List<Repo> getRepos() {
|
||||
return List.of(new Repo("main"));
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package models;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class User {
|
||||
private String name;
|
||||
|
||||
public User(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public void save() {}
|
||||
|
||||
public static List<User> getUsers() {
|
||||
return List.of(new User("alice"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package src;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.List;
|
||||
|
||||
public class App {
|
||||
public void processValues(Map<String, User> data) {
|
||||
for (var user : data.values()) {
|
||||
user.save();
|
||||
}
|
||||
}
|
||||
|
||||
public void processList(List<User> users) {
|
||||
for (var user : users) {
|
||||
user.save();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package src;
|
||||
|
||||
public class Repo {
|
||||
private String name;
|
||||
public Repo(String name) { this.name = name; }
|
||||
public void save() {}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package src;
|
||||
|
||||
public class User {
|
||||
private String name;
|
||||
public User(String name) { this.name = name; }
|
||||
public void save() {}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import models.User;
|
||||
import models.Repo;
|
||||
|
||||
public class App {
|
||||
public static void processAny(Object obj) {
|
||||
switch (obj) {
|
||||
case User user -> user.save();
|
||||
case Repo repo -> repo.save();
|
||||
default -> {}
|
||||
}
|
||||
}
|
||||
|
||||
public static void handleUser(Object obj) {
|
||||
switch (obj) {
|
||||
case User user -> user.save();
|
||||
default -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package models;
|
||||
|
||||
public class Repo {
|
||||
public void save() {}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package models;
|
||||
|
||||
public class User {
|
||||
public void save() {}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
const { UserService } = require('./service');
|
||||
|
||||
function processUser() {
|
||||
const svc = new UserService();
|
||||
svc.getUser().save();
|
||||
}
|
||||
|
||||
module.exports = { processUser };
|
||||
@@ -0,0 +1,7 @@
|
||||
class Repo {
|
||||
save() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { Repo };
|
||||
@@ -0,0 +1,12 @@
|
||||
const { User } = require('./user');
|
||||
|
||||
class UserService {
|
||||
/**
|
||||
* @returns {User}
|
||||
*/
|
||||
getUser() {
|
||||
return new User();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { UserService };
|
||||
@@ -0,0 +1,7 @@
|
||||
class User {
|
||||
save() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { User };
|
||||
@@ -0,0 +1,16 @@
|
||||
import models.getUsers
|
||||
import models.getRepos
|
||||
|
||||
fun processUsers() {
|
||||
for (user in getUsers()) {
|
||||
user.save()
|
||||
}
|
||||
}
|
||||
|
||||
fun processRepos() {
|
||||
for (repo in getRepos()) {
|
||||
repo.save()
|
||||
}
|
||||
}
|
||||
|
||||
fun main() {}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user