Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb5270c260 | ||
|
|
604b575e4b | ||
|
|
02dfab578c | ||
|
|
1326490a5b | ||
|
|
b48cfe9894 | ||
|
|
c1703fc0a9 | ||
|
|
480fae933b | ||
|
|
3879490817 | ||
|
|
50dbd03779 |
@@ -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** (2071 symbols, 4727 relationships, 154 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** (2071 symbols, 4727 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** (2071 symbols, 4727 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** (2071 symbols, 4727 relationships, 154 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** (2071 symbols, 4727 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** (2071 symbols, 4727 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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -2,6 +2,33 @@
|
||||
|
||||
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
|
||||
|
||||
Regular → Executable
Regular → Executable
Regular → Executable
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gitnexus",
|
||||
"version": "1.4.5",
|
||||
"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.
|
||||
@@ -498,143 +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();
|
||||
}
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
// ── 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).
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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, extractVarName, 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';
|
||||
|
||||
/**
|
||||
@@ -376,13 +376,39 @@ export const buildTypeEnv = (
|
||||
const config = typeConfigs[language];
|
||||
const bindings: ConstructorBinding[] = [];
|
||||
|
||||
// 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));
|
||||
const pendingAssignments: Array<{ scope: string; lhs: string; rhs: string }> = [];
|
||||
// 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`).
|
||||
@@ -448,7 +474,10 @@ export const buildTypeEnv = (
|
||||
// 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, declarationTypeNodes, scope);
|
||||
if (config.extractForLoopBinding) {
|
||||
const forLoopCtx: ForLoopExtractorContext = { scopeEnv, declarationTypeNodes, scope, returnTypeLookup };
|
||||
config.extractForLoopBinding(node, forLoopCtx);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (config.declarationNodeTypes.has(node.type)) {
|
||||
@@ -580,7 +609,11 @@ export const buildTypeEnv = (
|
||||
if (scopeEnv) {
|
||||
const pending = config.extractPendingAssignment(node, scopeEnv);
|
||||
if (pending) {
|
||||
pendingAssignments.push({ scope, ...pending });
|
||||
if (pending.kind === 'copy') {
|
||||
pendingCopies.push({ scope, lhs: pending.lhs, rhs: pending.rhs });
|
||||
} else {
|
||||
pendingCallResults.push({ scope, lhs: pending.lhs, callee: pending.callee });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -606,18 +639,28 @@ 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 {
|
||||
|
||||
@@ -179,7 +179,7 @@ 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 ---
|
||||
@@ -266,12 +266,7 @@ const findCppParamElementType = (iterableName: string, startNode: SyntaxNode, po
|
||||
/** 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: SyntaxNode,
|
||||
scopeEnv: Map<string, string>,
|
||||
declarationTypeNodes: ReadonlyMap<string, SyntaxNode>,
|
||||
scope: string,
|
||||
): void => {
|
||||
const extractForLoopBinding: ForLoopExtractor = (node, { scopeEnv, declarationTypeNodes, scope } ): void => {
|
||||
if (node.type !== 'for_range_loop') return;
|
||||
|
||||
const typeNode = node.childForFieldName('type');
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { SyntaxNode } from '../utils.js';
|
||||
import type { ConstructorBindingScanner, ForLoopExtractor, LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, PendingAssignmentExtractor, PatternBindingExtractor } from './types.js';
|
||||
import { extractSimpleTypeName, extractVarName, findChildByType, unwrapAwait, extractGenericTypeArgs, resolveIterableElementType, methodToTypeArgPosition, type TypeArgPosition } from './shared.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']);
|
||||
@@ -191,12 +191,7 @@ const findCSharpParamElementType = (iterableName: string, startNode: SyntaxNode,
|
||||
|
||||
/** 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: SyntaxNode,
|
||||
scopeEnv: Map<string, string>,
|
||||
declarationTypeNodes: ReadonlyMap<string, SyntaxNode>,
|
||||
scope: string,
|
||||
): void => {
|
||||
const extractForLoopBinding: ForLoopExtractor = (node, { scopeEnv, declarationTypeNodes, scope, returnTypeLookup }): void => {
|
||||
const typeNode = node.childForFieldName('type');
|
||||
const nameNode = node.childForFieldName('left');
|
||||
if (!typeNode || !nameNode) return;
|
||||
@@ -214,6 +209,7 @@ const extractForLoopBinding: ForLoopExtractor = (
|
||||
const rightNode = node.childForFieldName('right');
|
||||
let iterableName: string | undefined;
|
||||
let methodName: string | undefined;
|
||||
let callExprElementType: string | undefined;
|
||||
|
||||
if (rightNode?.type === 'identifier') {
|
||||
iterableName = rightNode.text;
|
||||
@@ -238,23 +234,33 @@ const extractForLoopBinding: ForLoopExtractor = (
|
||||
}
|
||||
} 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) return;
|
||||
if (!iterableName && !callExprElementType) return;
|
||||
|
||||
const containerTypeName = scopeEnv.get(iterableName);
|
||||
const typeArgPos = methodToTypeArgPosition(methodName, containerTypeName);
|
||||
const elementType = resolveIterableElementType(
|
||||
iterableName, node, scopeEnv, declarationTypeNodes, scope,
|
||||
extractCSharpElementTypeFromTypeNode, findCSharpParamElementType,
|
||||
typeArgPos,
|
||||
);
|
||||
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);
|
||||
};
|
||||
|
||||
@@ -319,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;
|
||||
|
||||
@@ -286,12 +286,7 @@ const findGoParamElementType = (iterableName: string, startNode: SyntaxNode, pos
|
||||
* 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: SyntaxNode,
|
||||
scopeEnv: Map<string, string>,
|
||||
declarationTypeNodes: ReadonlyMap<string, SyntaxNode>,
|
||||
scope: string,
|
||||
): void => {
|
||||
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.
|
||||
@@ -308,21 +303,41 @@ const extractForLoopBinding: ForLoopExtractor = (
|
||||
// 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) return;
|
||||
if (!iterableName && !callExprElementType) return;
|
||||
|
||||
const containerTypeName = scopeEnv.get(iterableName);
|
||||
const typeArgPos = methodToTypeArgPosition(undefined, containerTypeName);
|
||||
const elementType = resolveIterableElementType(
|
||||
iterableName, node, scopeEnv, declarationTypeNodes, scope,
|
||||
extractGoElementTypeFromTypeNode, findGoParamElementType,
|
||||
typeArgPos,
|
||||
);
|
||||
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.
|
||||
@@ -339,8 +354,11 @@ const extractForLoopBinding: ForLoopExtractor = (
|
||||
// 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
|
||||
if (isChannelType(iterableName, scopeEnv, declarationTypeNodes, scope)) {
|
||||
// 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
|
||||
@@ -348,7 +366,10 @@ const extractForLoopBinding: ForLoopExtractor = (
|
||||
}
|
||||
} else {
|
||||
// Plain identifier (single-var form without expression_list)
|
||||
if (isChannelType(iterableName, scopeEnv, declarationTypeNodes, scope)) {
|
||||
// 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
|
||||
@@ -375,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') {
|
||||
@@ -400,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;
|
||||
|
||||
@@ -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, extractGenericTypeArgs, resolveIterableElementType, methodToTypeArgPosition, type TypeArgPosition } from './shared.js';
|
||||
import { extractSimpleTypeName, extractVarName, findChildByType, extractGenericTypeArgs, resolveIterableElementType, methodToTypeArgPosition, extractElementTypeFromString, type TypeArgPosition } from './shared.js';
|
||||
|
||||
// ── Java ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -128,12 +128,7 @@ const findJavaParamElementType = (iterableName: string, startNode: SyntaxNode, p
|
||||
|
||||
/** 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: SyntaxNode,
|
||||
scopeEnv: Map<string, string>,
|
||||
declarationTypeNodes: ReadonlyMap<string, SyntaxNode>,
|
||||
scope: string,
|
||||
): void => {
|
||||
const extractJavaForLoopBinding: ForLoopExtractor = (node, { scopeEnv, declarationTypeNodes, scope, returnTypeLookup }): void => {
|
||||
const typeNode = node.childForFieldName('type');
|
||||
const nameNode = node.childForFieldName('name');
|
||||
if (!typeNode || !nameNode) return;
|
||||
@@ -153,6 +148,7 @@ const extractJavaForLoopBinding: ForLoopExtractor = (
|
||||
|
||||
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') {
|
||||
@@ -168,18 +164,27 @@ const extractJavaForLoopBinding: ForLoopExtractor = (
|
||||
} 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) return;
|
||||
if (!iterableName && !callExprElementType) return;
|
||||
|
||||
const containerTypeName = scopeEnv.get(iterableName);
|
||||
const typeArgPos = methodToTypeArgPosition(methodName, containerTypeName);
|
||||
const elementType = resolveIterableElementType(
|
||||
iterableName, node, scopeEnv, declarationTypeNodes, scope,
|
||||
extractJavaElementTypeFromTypeNode, findJavaParamElementType,
|
||||
typeArgPos,
|
||||
);
|
||||
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);
|
||||
};
|
||||
|
||||
@@ -193,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;
|
||||
};
|
||||
@@ -431,12 +436,8 @@ const findKotlinParamElementType = (iterableName: string, startNode: SyntaxNode,
|
||||
|
||||
/** 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: SyntaxNode,
|
||||
scopeEnv: Map<string, string>,
|
||||
declarationTypeNodes: ReadonlyMap<string, SyntaxNode>,
|
||||
scope: string,
|
||||
): void => {
|
||||
const extractKotlinForLoopBinding: ForLoopExtractor = (node, ctx): void => {
|
||||
const { scopeEnv, declarationTypeNodes, scope, returnTypeLookup } = ctx;
|
||||
const varDecl = findChildByType(node, 'variable_declaration');
|
||||
if (!varDecl) return;
|
||||
const nameNode = findChildByType(varDecl, 'simple_identifier');
|
||||
@@ -458,6 +459,7 @@ const extractKotlinForLoopBinding: ForLoopExtractor = (
|
||||
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);
|
||||
@@ -494,26 +496,35 @@ const extractKotlinForLoopBinding: ForLoopExtractor = (
|
||||
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) return;
|
||||
if (!iterableName && !callExprElementType) return;
|
||||
|
||||
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);
|
||||
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,
|
||||
);
|
||||
}
|
||||
const typeArgPos = methodToTypeArgPosition(methodName, containerTypeName);
|
||||
const elementType = resolveIterableElementType(
|
||||
iterableName, node, scopeEnv, declarationTypeNodes, scope,
|
||||
extractKotlinElementTypeFromTypeNode, findKotlinParamElementType,
|
||||
typeArgPos,
|
||||
);
|
||||
if (elementType) scopeEnv.set(varName, elementType);
|
||||
};
|
||||
|
||||
@@ -537,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;
|
||||
@@ -559,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;
|
||||
|
||||
@@ -82,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.
|
||||
@@ -242,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;
|
||||
}
|
||||
@@ -269,7 +355,7 @@ 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([
|
||||
@@ -323,12 +409,7 @@ const findPhpParamElementType = (iterableName: string, startNode: SyntaxNode): s
|
||||
* constructor-binding cases that retain container types), then fall back to direct
|
||||
* scopeEnv lookup (for PHPDoc-normalized types).
|
||||
*/
|
||||
const extractForLoopBinding: ForLoopExtractor = (
|
||||
node: SyntaxNode,
|
||||
scopeEnv: Map<string, string>,
|
||||
declarationTypeNodes: ReadonlyMap<string, SyntaxNode>,
|
||||
scope: string,
|
||||
): void => {
|
||||
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
|
||||
@@ -362,6 +443,7 @@ const extractForLoopBinding: ForLoopExtractor = (
|
||||
|
||||
// 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') {
|
||||
@@ -369,8 +451,28 @@ const extractForLoopBinding: ForLoopExtractor = (
|
||||
// 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;
|
||||
}
|
||||
if (!iterableName) return;
|
||||
|
||||
// Strategy A: try resolveIterableElementType (handles constructor-binding container types)
|
||||
const elementType = resolveIterableElementType(
|
||||
@@ -388,6 +490,27 @@ const extractForLoopBinding: ForLoopExtractor = (
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -229,18 +229,14 @@ const findPyParamElementType = (iterableName: string, startNode: SyntaxNode, pos
|
||||
* 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: SyntaxNode,
|
||||
scopeEnv: Map<string, string>,
|
||||
declarationTypeNodes: ReadonlyMap<string, SyntaxNode>,
|
||||
scope: string,
|
||||
): void => {
|
||||
const extractForLoopBinding: ForLoopExtractor = (node, { scopeEnv, declarationTypeNodes, scope, returnTypeLookup }): void => {
|
||||
if (node.type !== 'for_statement') return;
|
||||
|
||||
// The iterable is the `right` field — may be identifier or call (data.items()/keys()/values()).
|
||||
// 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') {
|
||||
@@ -248,6 +244,7 @@ const extractForLoopBinding: ForLoopExtractor = (
|
||||
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;
|
||||
@@ -255,17 +252,26 @@ const extractForLoopBinding: ForLoopExtractor = (
|
||||
// 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) return;
|
||||
if (!iterableName && !callExprElementType) return;
|
||||
|
||||
const containerTypeName = scopeEnv.get(iterableName);
|
||||
const typeArgPos = methodToTypeArgPosition(methodName, containerTypeName);
|
||||
const elementType = resolveIterableElementType(
|
||||
iterableName, node, scopeEnv, declarationTypeNodes, scope,
|
||||
extractPyElementTypeFromAnnotation, findPyParamElementType,
|
||||
typeArgPos,
|
||||
);
|
||||
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.
|
||||
@@ -304,7 +310,7 @@ 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;
|
||||
};
|
||||
|
||||
|
||||
@@ -342,12 +342,7 @@ const findRubyParamElementType = (iterableName: string, startNode: SyntaxNode):
|
||||
* 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: SyntaxNode,
|
||||
scopeEnv: Map<string, string>,
|
||||
declarationTypeNodes: ReadonlyMap<string, SyntaxNode>,
|
||||
scope: string,
|
||||
): void => {
|
||||
const extractForLoopBinding: ForLoopExtractor = (node, { scopeEnv, declarationTypeNodes, scope }): void => {
|
||||
if (node.type !== 'for') return;
|
||||
|
||||
// The loop variable is the `pattern` field (identifier).
|
||||
@@ -395,7 +390,7 @@ const extractPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) =>
|
||||
if (scopeEnv.has(varName)) return undefined;
|
||||
const rhsNode = node.childForFieldName('right');
|
||||
if (!rhsNode || rhsNode.type !== 'identifier') return undefined;
|
||||
return { lhs: varName, rhs: rhsNode.text };
|
||||
return { kind: 'copy', lhs: varName, rhs: rhsNode.text };
|
||||
};
|
||||
|
||||
export const typeConfig: LanguageTypeConfig = {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { SyntaxNode } from '../utils.js';
|
||||
import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, InitializerExtractor, ClassNameLookup, ConstructorBindingScanner, PendingAssignmentExtractor, PatternBindingExtractor, ForLoopExtractor } from './types.js';
|
||||
import { extractSimpleTypeName, extractVarName, hasTypeAnnotation, unwrapAwait, extractGenericTypeArgs, resolveIterableElementType, methodToTypeArgPosition, type TypeArgPosition } from './shared.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',
|
||||
@@ -190,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;
|
||||
};
|
||||
|
||||
@@ -352,12 +352,7 @@ const findRustParamElementType = (iterableName: string, startNode: SyntaxNode, p
|
||||
|
||||
/** 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: SyntaxNode,
|
||||
scopeEnv: Map<string, string>,
|
||||
declarationTypeNodes: ReadonlyMap<string, SyntaxNode>,
|
||||
scope: string,
|
||||
): void => {
|
||||
const extractForLoopBinding: ForLoopExtractor = (node, { scopeEnv, declarationTypeNodes, scope, returnTypeLookup }): void => {
|
||||
if (node.type !== 'for_expression') return;
|
||||
|
||||
const patternNode = node.childForFieldName('pattern');
|
||||
@@ -367,6 +362,7 @@ const extractForLoopBinding: ForLoopExtractor = (
|
||||
// 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;
|
||||
@@ -376,25 +372,34 @@ const extractForLoopBinding: ForLoopExtractor = (
|
||||
const prop = valueNode.lastNamedChild;
|
||||
if (prop) iterableName = prop.text;
|
||||
} else if (valueNode.type === 'call_expression') {
|
||||
// users.iter() → call_expression > function: field_expression > identifier + field_identifier
|
||||
const fieldExpr = valueNode.childForFieldName('function');
|
||||
if (fieldExpr?.type === 'field_expression') {
|
||||
const obj = fieldExpr.firstNamedChild;
|
||||
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 = fieldExpr.lastNamedChild;
|
||||
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) return;
|
||||
if (!iterableName && !callExprElementType) return;
|
||||
|
||||
const containerTypeName = scopeEnv.get(iterableName);
|
||||
const typeArgPos = methodToTypeArgPosition(methodName, containerTypeName);
|
||||
const elementType = resolveIterableElementType(
|
||||
iterableName, node, scopeEnv, declarationTypeNodes, scope,
|
||||
extractRustElementTypeFromTypeNode, findRustParamElementType,
|
||||
typeArgPos,
|
||||
);
|
||||
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);
|
||||
|
||||
@@ -169,7 +169,7 @@ export function resolveIterableElementType(
|
||||
/** 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
|
||||
@@ -608,3 +608,140 @@ export function extractElementTypeFromString(typeStr: string, pos: TypeArgPositi
|
||||
|
||||
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,23 +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.
|
||||
* All parameters are required (aligned with PatternBindingExtractor convention)
|
||||
* to prevent new extractors from silently ignoring declarationTypeNodes/scope. */
|
||||
export type ForLoopExtractor = (
|
||||
node: SyntaxNode,
|
||||
scopeEnv: Map<string, string>,
|
||||
declarationTypeNodes: ReadonlyMap<string, SyntaxNode>,
|
||||
scope: 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.
|
||||
@@ -93,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
|
||||
|
||||
@@ -333,12 +333,7 @@ const findTsIterableElementType = (iterableName: string, startNode: SyntaxNode,
|
||||
* 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: SyntaxNode,
|
||||
scopeEnv: Map<string, string>,
|
||||
declarationTypeNodes: ReadonlyMap<string, SyntaxNode>,
|
||||
scope: string,
|
||||
): void => {
|
||||
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.
|
||||
@@ -352,10 +347,11 @@ const extractForLoopBinding: ForLoopExtractor = (
|
||||
}
|
||||
if (!isForOf) return;
|
||||
|
||||
// The iterable is the `right` field — may be identifier or call_expression.
|
||||
// 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') {
|
||||
@@ -364,6 +360,7 @@ const extractForLoopBinding: ForLoopExtractor = (
|
||||
} 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');
|
||||
@@ -376,18 +373,27 @@ const extractForLoopBinding: ForLoopExtractor = (
|
||||
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) return;
|
||||
if (!iterableName && !callExprElementType) return;
|
||||
|
||||
// Look up the container's base type name for descriptor-aware resolution
|
||||
const containerTypeName = scopeEnv.get(iterableName);
|
||||
const typeArgPos = methodToTypeArgPosition(methodName, containerTypeName);
|
||||
const elementType = resolveIterableElementType(
|
||||
iterableName, node, scopeEnv, declarationTypeNodes, scope,
|
||||
extractTsElementTypeFromAnnotation, findTsIterableElementType,
|
||||
typeArgPos,
|
||||
);
|
||||
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.
|
||||
@@ -433,7 +439,7 @@ 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;
|
||||
};
|
||||
|
||||
@@ -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,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,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,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() {}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package models
|
||||
|
||||
class Repo(val name: String) {
|
||||
fun save() {}
|
||||
}
|
||||
|
||||
fun getRepos(): List<Repo> {
|
||||
return listOf(Repo("main"))
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package models
|
||||
|
||||
class User(val name: String) {
|
||||
fun save() {}
|
||||
}
|
||||
|
||||
fun getUsers(): List<User> {
|
||||
return listOf(User("alice"))
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
class Repo {
|
||||
public string $name;
|
||||
|
||||
public function __construct(string $name) {
|
||||
$this->name = $name;
|
||||
}
|
||||
|
||||
public function save(): void {}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Repo[]
|
||||
*/
|
||||
function getRepos(): array {
|
||||
return [new Repo("main")];
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
class User {
|
||||
public string $name;
|
||||
|
||||
public function __construct(string $name) {
|
||||
$this->name = $name;
|
||||
}
|
||||
|
||||
public function save(): void {}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return User[]
|
||||
*/
|
||||
function getUsers(): array {
|
||||
return [new User("alice")];
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
require_once 'User.php';
|
||||
require_once 'Repo.php';
|
||||
|
||||
function processUsers(): void {
|
||||
foreach (getUsers() as $user) {
|
||||
$user->save();
|
||||
}
|
||||
}
|
||||
|
||||
function processRepos(): void {
|
||||
foreach (getRepos() as $repo) {
|
||||
$repo->save();
|
||||
}
|
||||
}
|
||||
@@ -12,15 +12,11 @@ class App {
|
||||
}
|
||||
|
||||
/**
|
||||
* $this->users member access in foreach — iterableName must use $ prefix
|
||||
* to match how property_declaration stores the variable in scopeEnv ($users).
|
||||
*
|
||||
* Uses a typed parameter to ensure the type is in the method's scopeEnv,
|
||||
* since class property @var types are stored at file scope (not method scope).
|
||||
*
|
||||
* @param User[] $users
|
||||
* $this->users member access in foreach — resolved via Phase 7.4 Strategy C:
|
||||
* scans the class body for the property_declaration and extracts the element
|
||||
* type from the @var PHPDoc annotation without requiring a @param workaround.
|
||||
*/
|
||||
public function processMembers(array $users): void {
|
||||
public function processMembers(): void {
|
||||
foreach ($this->users as $user) {
|
||||
$user->save();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
from models import get_users, get_repos
|
||||
|
||||
def process_users():
|
||||
for user in get_users():
|
||||
user.save()
|
||||
|
||||
def process_repos():
|
||||
for repo in get_repos():
|
||||
repo.save()
|
||||
@@ -0,0 +1,19 @@
|
||||
class User:
|
||||
def __init__(self, name: str):
|
||||
self.name = name
|
||||
|
||||
def save(self) -> None:
|
||||
pass
|
||||
|
||||
class Repo:
|
||||
def __init__(self, name: str):
|
||||
self.name = name
|
||||
|
||||
def save(self) -> None:
|
||||
pass
|
||||
|
||||
def get_users() -> list[User]:
|
||||
return [User("alice")]
|
||||
|
||||
def get_repos() -> list[Repo]:
|
||||
return [Repo("main")]
|
||||
@@ -0,0 +1,18 @@
|
||||
mod user;
|
||||
mod repo;
|
||||
use crate::user::get_users;
|
||||
use crate::repo::get_repos;
|
||||
|
||||
fn process_users() {
|
||||
for user in get_users() {
|
||||
user.save();
|
||||
}
|
||||
}
|
||||
|
||||
fn process_repos() {
|
||||
for repo in get_repos() {
|
||||
repo.save();
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {}
|
||||
@@ -0,0 +1,11 @@
|
||||
pub struct Repo {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
impl Repo {
|
||||
pub fn save(&self) {}
|
||||
}
|
||||
|
||||
pub fn get_repos() -> Vec<Repo> {
|
||||
vec![Repo { name: "main".into() }]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
pub struct User {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
impl User {
|
||||
pub fn save(&self) {}
|
||||
}
|
||||
|
||||
pub fn get_users() -> Vec<User> {
|
||||
vec![User { name: "alice".into() }]
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { getUsers } from './models/user';
|
||||
import { getRepos } from './models/repo';
|
||||
|
||||
function processUsers(): void {
|
||||
for (const user of getUsers()) {
|
||||
user.save();
|
||||
}
|
||||
}
|
||||
|
||||
function processRepos(): void {
|
||||
for (const repo of getRepos()) {
|
||||
repo.save();
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
export class Repo {
|
||||
name: string;
|
||||
constructor(name: string) { this.name = name; }
|
||||
save(): void {}
|
||||
}
|
||||
|
||||
export function getRepos(): Repo[] {
|
||||
return [new Repo("main")];
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
export class User {
|
||||
name: string;
|
||||
constructor(name: string) { this.name = name; }
|
||||
save(): void {}
|
||||
}
|
||||
|
||||
export function getUsers(): User[] {
|
||||
return [new User("alice")];
|
||||
}
|
||||
+14
@@ -10,6 +10,10 @@ export const LOCAL_BACKEND_SEED_DATA = [
|
||||
`CREATE (fn:Function {id: 'func:hash', name: 'hash', filePath: 'src/utils.ts', startLine: 1, endLine: 8, isExported: true, content: 'function hash() {}', description: 'Hash utility'})`,
|
||||
// Class
|
||||
`CREATE (c:Class {id: 'class:AuthService', name: 'AuthService', filePath: 'src/auth.ts', startLine: 30, endLine: 60, isExported: true, content: 'class AuthService {}', description: 'Authentication service'})`,
|
||||
`CREATE (c:Class {id: 'class:BaseService', name: 'BaseService', filePath: 'src/base.ts', startLine: 1, endLine: 20, isExported: true, content: 'class BaseService {}', description: 'Base service class'})`,
|
||||
// Methods
|
||||
`CREATE (m:Method {id: 'method:AuthService.authenticate', name: 'authenticate', filePath: 'src/auth.ts', startLine: 35, endLine: 45, isExported: false, content: 'authenticate() {}', description: 'Authenticate user'})`,
|
||||
`CREATE (m:Method {id: 'method:BaseService.authenticate', name: 'authenticate', filePath: 'src/base.ts', startLine: 5, endLine: 10, isExported: false, content: 'authenticate() {}', description: 'Base authenticate'})`,
|
||||
// Community
|
||||
`CREATE (c:Community {id: 'comm:auth', label: 'Auth', heuristicLabel: 'Authentication', keywords: ['auth', 'login'], description: 'Auth module', enrichedBy: 'heuristic', cohesion: 0.8, symbolCount: 3})`,
|
||||
// Process
|
||||
@@ -25,10 +29,20 @@ export const LOCAL_BACKEND_SEED_DATA = [
|
||||
CREATE (a)-[:CodeRelation {type: 'STEP_IN_PROCESS', confidence: 1.0, reason: '', step: 1}]->(p)`,
|
||||
`MATCH (a:Function), (p:Process) WHERE a.id = 'func:validate' AND p.id = 'proc:login-flow'
|
||||
CREATE (a)-[:CodeRelation {type: 'STEP_IN_PROCESS', confidence: 1.0, reason: '', step: 2}]->(p)`,
|
||||
// HAS_METHOD: AuthService -> authenticate
|
||||
`MATCH (c:Class), (m:Method) WHERE c.id = 'class:AuthService' AND m.id = 'method:AuthService.authenticate'
|
||||
CREATE (c)-[:CodeRelation {type: 'HAS_METHOD', confidence: 1.0, reason: 'class-method', step: 0}]->(m)`,
|
||||
// OVERRIDES: AuthService.authenticate -> BaseService.authenticate
|
||||
`MATCH (a:Method), (b:Method) WHERE a.id = 'method:AuthService.authenticate' AND b.id = 'method:BaseService.authenticate'
|
||||
CREATE (a)-[:CodeRelation {type: 'OVERRIDES', confidence: 1.0, reason: 'mro-resolution', step: 0}]->(b)`,
|
||||
// HAS_METHOD: BaseService -> authenticate
|
||||
`MATCH (c:Class), (m:Method) WHERE c.id = 'class:BaseService' AND m.id = 'method:BaseService.authenticate'
|
||||
CREATE (c)-[:CodeRelation {type: 'HAS_METHOD', confidence: 1.0, reason: 'class-method', step: 0}]->(m)`,
|
||||
];
|
||||
|
||||
export const LOCAL_BACKEND_FTS_INDEXES: FTSIndexDef[] = [
|
||||
{ table: 'Function', indexName: 'function_fts', columns: ['name', 'content', 'description'] },
|
||||
{ table: 'Class', indexName: 'class_fts', columns: ['name', 'content', 'description'] },
|
||||
{ table: 'Method', indexName: 'method_fts', columns: ['name', 'content', 'description'] },
|
||||
{ table: 'File', indexName: 'file_fts', columns: ['name', 'content'] },
|
||||
];
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
* Accepts status === null (timeout) as valid on slow CI runners.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { spawnSync } from 'child_process';
|
||||
import { spawnSync, spawn } from 'child_process';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
@@ -231,4 +231,178 @@ describe('CLI end-to-end', () => {
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
// ─── stdout fd 1 tests (#324) ───────────────────────────────────────
|
||||
// These tests verify that tool output goes to stdout (fd 1), not stderr.
|
||||
// Requires analyze to have run first (the analyze test above populates .gitnexus/).
|
||||
|
||||
// All tool commands pass --repo to disambiguate when the global registry
|
||||
// has multiple indexed repos (e.g. the parent project is also indexed).
|
||||
describe('tool output goes to stdout via fd 1 (#324)', () => {
|
||||
it('cypher: JSON appears on stdout, not stderr', () => {
|
||||
const result = runCliRaw(['cypher', 'MATCH (n) RETURN n.name LIMIT 3', '--repo', 'mini-repo'], MINI_REPO);
|
||||
if (result.status === null) return; // CI timeout tolerance
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
|
||||
// stdout must contain valid JSON (array or object)
|
||||
expect(() => JSON.parse(result.stdout.trim())).not.toThrow();
|
||||
|
||||
// stderr must NOT contain JSON — only human-readable diagnostics allowed
|
||||
const stderrTrimmed = result.stderr.trim();
|
||||
if (stderrTrimmed.length > 0) {
|
||||
expect(() => JSON.parse(stderrTrimmed)).toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
it('query: JSON appears on stdout, not stderr', () => {
|
||||
// "handler" is a generic term likely to match something in mini-repo
|
||||
const result = runCliRaw(['query', 'handler', '--repo', 'mini-repo'], MINI_REPO);
|
||||
if (result.status === null) return;
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(() => JSON.parse(result.stdout.trim())).not.toThrow();
|
||||
});
|
||||
|
||||
it('impact: JSON appears on stdout, not stderr', () => {
|
||||
const result = runCliRaw(
|
||||
['impact', 'handleRequest', '--direction', 'upstream', '--repo', 'mini-repo'],
|
||||
MINI_REPO,
|
||||
);
|
||||
if (result.status === null) return;
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
// impact may return an error object (symbol not found) or a real result —
|
||||
// either way it must be valid JSON on stdout
|
||||
expect(() => JSON.parse(result.stdout.trim())).not.toThrow();
|
||||
});
|
||||
|
||||
it('stdout is pipeable: cypher output parses as valid JSON', () => {
|
||||
const result = runCliRaw(
|
||||
['cypher', 'MATCH (n:Function) RETURN n.name LIMIT 5', '--repo', 'mini-repo'],
|
||||
MINI_REPO,
|
||||
);
|
||||
if (result.status === null) return;
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
|
||||
// Simulate what jq does: parse stdout as JSON
|
||||
const parsed = JSON.parse(result.stdout.trim());
|
||||
expect(Array.isArray(parsed) || typeof parsed === 'object').toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── EPIPE clean exit test (#324) ───────────────────────────────────
|
||||
|
||||
describe('EPIPE handling (#324)', () => {
|
||||
it('cypher: EPIPE exits with code 0, not stderr dump', () => {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
['--import', 'tsx', cliEntry, 'cypher', 'MATCH (n) RETURN n LIMIT 500', '--repo', 'mini-repo'],
|
||||
{
|
||||
cwd: MINI_REPO,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_OPTIONS: `${process.env.NODE_OPTIONS || ''} --max-old-space-size=8192`.trim(),
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
let stderrOutput = '';
|
||||
child.stderr.on('data', (chunk: Buffer) => { stderrOutput += chunk.toString(); });
|
||||
|
||||
// Destroy stdout immediately — simulates `| head -0` (consumer closes early)
|
||||
child.stdout.once('data', () => {
|
||||
child.stdout.destroy(); // triggers EPIPE on next write
|
||||
});
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
child.kill('SIGTERM');
|
||||
// Timeout is acceptable on CI — not a failure
|
||||
resolve();
|
||||
}, 20000);
|
||||
|
||||
child.on('close', (code) => {
|
||||
clearTimeout(timer);
|
||||
try {
|
||||
// Clean EPIPE exit: code 0
|
||||
expect(code).toBe(0);
|
||||
// No JSON payload should appear on stderr
|
||||
const trimmed = stderrOutput.trim();
|
||||
if (trimmed.length > 0) {
|
||||
expect(() => JSON.parse(trimmed)).toThrow();
|
||||
}
|
||||
resolve();
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
});
|
||||
}, 25000);
|
||||
});
|
||||
|
||||
// ─── eval-server READY signal test (#324) ───────────────────────────
|
||||
|
||||
describe('eval-server READY signal (#324)', () => {
|
||||
it('READY signal appears on stdout, not stderr', () => {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
['--import', 'tsx', cliEntry, 'eval-server', '--port', '0', '--idle-timeout', '3'],
|
||||
{
|
||||
cwd: MINI_REPO,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_OPTIONS: `${process.env.NODE_OPTIONS || ''} --max-old-space-size=8192`.trim(),
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
let stdoutBuffer = '';
|
||||
let foundOnStdout = false;
|
||||
let foundOnStderr = false;
|
||||
|
||||
child.stdout.on('data', (chunk: Buffer) => {
|
||||
stdoutBuffer += chunk.toString();
|
||||
if (stdoutBuffer.includes('GITNEXUS_EVAL_SERVER_READY:')) {
|
||||
foundOnStdout = true;
|
||||
child.kill('SIGTERM');
|
||||
}
|
||||
});
|
||||
|
||||
child.stderr.on('data', (chunk: Buffer) => {
|
||||
const text = chunk.toString();
|
||||
if (text.includes('GITNEXUS_EVAL_SERVER_READY:')) {
|
||||
foundOnStderr = true;
|
||||
child.kill('SIGTERM');
|
||||
}
|
||||
});
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
child.kill('SIGTERM');
|
||||
// Timeout is acceptable on CI — not a failure
|
||||
resolve();
|
||||
}, 30000);
|
||||
|
||||
child.on('close', () => {
|
||||
clearTimeout(timer);
|
||||
try {
|
||||
if (foundOnStderr) {
|
||||
reject(new Error('READY signal appeared on stderr instead of stdout'));
|
||||
} else if (foundOnStdout) {
|
||||
resolve();
|
||||
} else {
|
||||
// eval-server may not start on all CI environments — don't fail
|
||||
resolve();
|
||||
}
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
});
|
||||
}, 35000);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -106,6 +106,59 @@ withTestLbugDB('local-backend-calltool', (handle) => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('impact tool relationTypes filtering', () => {
|
||||
let backend: LocalBackend;
|
||||
|
||||
beforeAll(async () => {
|
||||
const ext = handle as typeof handle & { _backend?: LocalBackend };
|
||||
if (!ext._backend) {
|
||||
throw new Error('LocalBackend not initialized — afterSetup did not attach _backend to handle');
|
||||
}
|
||||
backend = ext._backend;
|
||||
});
|
||||
|
||||
it('filters by HAS_METHOD only', async () => {
|
||||
const result = await backend.callTool('impact', {
|
||||
target: 'AuthService',
|
||||
direction: 'downstream',
|
||||
relationTypes: ['HAS_METHOD'],
|
||||
});
|
||||
expect(result).not.toHaveProperty('error');
|
||||
expect(result.impactedCount).toBeGreaterThanOrEqual(1);
|
||||
const d1 = result.byDepth[1] || result.byDepth['1'] || [];
|
||||
const names = d1.map((d: any) => d.name);
|
||||
expect(names).toContain('authenticate');
|
||||
// Should NOT include CALLS-reachable symbols like validate/hash
|
||||
expect(names).not.toContain('validate');
|
||||
expect(names).not.toContain('hash');
|
||||
});
|
||||
|
||||
it('filters by OVERRIDES only', async () => {
|
||||
const result = await backend.callTool('impact', {
|
||||
target: 'authenticate',
|
||||
direction: 'downstream',
|
||||
relationTypes: ['OVERRIDES'],
|
||||
});
|
||||
expect(result).not.toHaveProperty('error');
|
||||
// AuthService.authenticate overrides BaseService.authenticate
|
||||
expect(result.impactedCount).toBeGreaterThanOrEqual(1);
|
||||
const d1 = result.byDepth[1] || result.byDepth['1'] || [];
|
||||
const names = d1.map((d: any) => d.name);
|
||||
expect(names).toContain('authenticate');
|
||||
});
|
||||
|
||||
it('does not return HAS_METHOD results when filtering by CALLS only', async () => {
|
||||
const result = await backend.callTool('impact', {
|
||||
target: 'AuthService',
|
||||
direction: 'downstream',
|
||||
relationTypes: ['CALLS'],
|
||||
});
|
||||
expect(result).not.toHaveProperty('error');
|
||||
// AuthService has no outgoing CALLS edges, only HAS_METHOD
|
||||
expect(result.impactedCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tool parameter edge cases', () => {
|
||||
let backend: LocalBackend;
|
||||
|
||||
@@ -144,6 +197,54 @@ withTestLbugDB('local-backend-calltool', (handle) => {
|
||||
expect(result).toHaveProperty('error');
|
||||
expect(result.error).toMatch(/required/i);
|
||||
});
|
||||
|
||||
// ─── impact error handling tests (#321) ───────────────────────────
|
||||
// Verify that impact() returns structured JSON instead of crashing
|
||||
|
||||
it('impact tool returns structured error for unknown symbol', async () => {
|
||||
const result = await backend.callTool('impact', {
|
||||
target: 'nonexistent_symbol_xyz_999',
|
||||
direction: 'upstream',
|
||||
});
|
||||
// Must return structured JSON, not throw
|
||||
expect(result).toBeDefined();
|
||||
// Should have either an error field (not found) or impactedCount 0
|
||||
// Either outcome is valid — the key is it doesn't crash
|
||||
if (result.error) {
|
||||
expect(typeof result.error).toBe('string');
|
||||
} else {
|
||||
expect(result.impactedCount).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('impact error response has consistent target shape', async () => {
|
||||
const result = await backend.callTool('impact', {
|
||||
target: 'nonexistent_symbol_xyz_999',
|
||||
direction: 'downstream',
|
||||
});
|
||||
// When an error is returned, target must be an object (not raw string)
|
||||
// so downstream API consumers can safely access result.target.name
|
||||
if (result.error && result.target !== undefined) {
|
||||
expect(typeof result.target).toBe('object');
|
||||
expect(result.target).not.toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('impact partial results: traversalComplete flag when depth fails', async () => {
|
||||
// Even if traversal fails at some depth, partial results should be returned
|
||||
// and partial:true should only be set when some results were collected
|
||||
const result = await backend.callTool('impact', {
|
||||
target: 'validate',
|
||||
direction: 'upstream',
|
||||
maxDepth: 10, // Large depth to trigger multi-level traversal
|
||||
});
|
||||
// Should succeed (validate exists in seed data)
|
||||
expect(result).not.toHaveProperty('error');
|
||||
if (result.partial) {
|
||||
// If partial, must still have some results
|
||||
expect(result.impactedCount).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
}, {
|
||||
|
||||
@@ -76,7 +76,7 @@ withTestLbugDB('local-backend', (handle) => {
|
||||
|
||||
describe('relation type filtering', () => {
|
||||
it('only allows valid relation types in queries', () => {
|
||||
const validTypes = ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS'];
|
||||
const validTypes = ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS', 'HAS_METHOD', 'OVERRIDES'];
|
||||
const invalidTypes = ['CONTAINS', 'STEP_IN_PROCESS', 'MEMBER_OF', 'DROP_TABLE'];
|
||||
|
||||
for (const t of validTypes) {
|
||||
|
||||
@@ -884,3 +884,59 @@ describe('Go map range type resolution (Tier 1c)', () => {
|
||||
expect(wrongSave).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Go for-loop with call_expression iterable: for _, user := range GetUsers()
|
||||
// Phase 7.3: call_expression iterable resolution via ReturnTypeLookup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Go for-loop call_expression iterable resolution (Phase 7.3)', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runPipelineFromRepo(
|
||||
path.join(FIXTURES, 'go-for-call-expr'),
|
||||
() => {},
|
||||
);
|
||||
}, 60000);
|
||||
|
||||
it('detects User and Repo structs with competing Save methods', () => {
|
||||
const structs = getNodesByLabel(result, 'Struct');
|
||||
expect(structs).toContain('User');
|
||||
expect(structs).toContain('Repo');
|
||||
const methods = getNodesByLabel(result, 'Method');
|
||||
expect(methods.filter(m => m === 'Save').length).toBe(2);
|
||||
});
|
||||
|
||||
it('resolves user.Save() in range GetUsers() to User#Save', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const userSave = calls.find(c =>
|
||||
c.target === 'Save' && c.source === 'processUsers' && c.targetFilePath?.includes('user.go'),
|
||||
);
|
||||
expect(userSave).toBeDefined();
|
||||
});
|
||||
|
||||
it('resolves repo.Save() in range GetRepos() to Repo#Save', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const repoSave = calls.find(c =>
|
||||
c.target === 'Save' && c.source === 'processRepos' && c.targetFilePath?.includes('repo.go'),
|
||||
);
|
||||
expect(repoSave).toBeDefined();
|
||||
});
|
||||
|
||||
it('does NOT resolve user.Save() to Repo#Save (negative disambiguation)', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const wrongSave = calls.find(c =>
|
||||
c.target === 'Save' && c.source === 'processUsers' && c.targetFilePath?.includes('repo.go'),
|
||||
);
|
||||
expect(wrongSave).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does NOT resolve repo.Save() to User#Save (negative disambiguation)', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const wrongSave = calls.find(c =>
|
||||
c.target === 'Save' && c.source === 'processRepos' && c.targetFilePath?.includes('user.go'),
|
||||
);
|
||||
expect(wrongSave).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1015,3 +1015,56 @@ describe('Java Map .values() for-loop resolution', () => {
|
||||
expect(userSave).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Java enhanced for-loop with call_expression iterable: for (User user : getUsers())
|
||||
// Phase 7.3: call_expression iterable resolution via ReturnTypeLookup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Java foreach call_expression iterable resolution (Phase 7.3)', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runPipelineFromRepo(
|
||||
path.join(FIXTURES, 'java-foreach-call-expr'),
|
||||
() => {},
|
||||
);
|
||||
}, 60000);
|
||||
|
||||
it('detects User and Repo classes with competing save methods', () => {
|
||||
expect(getNodesByLabel(result, 'Class')).toContain('User');
|
||||
expect(getNodesByLabel(result, 'Class')).toContain('Repo');
|
||||
});
|
||||
|
||||
it('resolves user.save() in foreach over User.getUsers() to User#save', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const userSave = calls.find(c =>
|
||||
c.target === 'save' && c.source === 'processUsers' && c.targetFilePath?.includes('User.java'),
|
||||
);
|
||||
expect(userSave).toBeDefined();
|
||||
});
|
||||
|
||||
it('resolves repo.save() in foreach over Repo.getRepos() to Repo#save', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const repoSave = calls.find(c =>
|
||||
c.target === 'save' && c.source === 'processRepos' && c.targetFilePath?.includes('Repo.java'),
|
||||
);
|
||||
expect(repoSave).toBeDefined();
|
||||
});
|
||||
|
||||
it('does NOT resolve user.save() to Repo#save (negative)', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const wrongSave = calls.find(c =>
|
||||
c.target === 'save' && c.source === 'processUsers' && c.targetFilePath?.includes('Repo.java'),
|
||||
);
|
||||
expect(wrongSave).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does NOT resolve repo.save() to User#save (negative)', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const wrongSave = calls.find(c =>
|
||||
c.target === 'save' && c.source === 'processRepos' && c.targetFilePath?.includes('User.java'),
|
||||
);
|
||||
expect(wrongSave).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1163,3 +1163,58 @@ describe('Kotlin when/is complex pattern binding', () => {
|
||||
expect(wrongAdmin).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Kotlin for-loop with call_expression iterable: for (user in getUsers())
|
||||
// Phase 7.3: call_expression iterable resolution via ReturnTypeLookup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Kotlin for-loop call_expression iterable resolution (Phase 7.3)', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runPipelineFromRepo(
|
||||
path.join(FIXTURES, 'kotlin-foreach-call-expr'),
|
||||
() => {},
|
||||
);
|
||||
}, 60000);
|
||||
|
||||
it('detects User and Repo classes with competing save methods', () => {
|
||||
expect(getNodesByLabel(result, 'Class')).toContain('User');
|
||||
expect(getNodesByLabel(result, 'Class')).toContain('Repo');
|
||||
const saveFns = getNodesByLabel(result, 'Function').filter(f => f === 'save');
|
||||
expect(saveFns.length).toBe(2);
|
||||
});
|
||||
|
||||
it('resolves user.save() in for-loop over getUsers() to User#save', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const userSave = calls.find(c =>
|
||||
c.target === 'save' && c.source === 'processUsers' && c.targetFilePath?.includes('User.kt'),
|
||||
);
|
||||
expect(userSave).toBeDefined();
|
||||
});
|
||||
|
||||
it('resolves repo.save() in for-loop over getRepos() to Repo#save', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const repoSave = calls.find(c =>
|
||||
c.target === 'save' && c.source === 'processRepos' && c.targetFilePath?.includes('Repo.kt'),
|
||||
);
|
||||
expect(repoSave).toBeDefined();
|
||||
});
|
||||
|
||||
it('does NOT resolve user.save() to Repo#save (negative)', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const wrongSave = calls.find(c =>
|
||||
c.target === 'save' && c.source === 'processUsers' && c.targetFilePath?.includes('Repo.kt'),
|
||||
);
|
||||
expect(wrongSave).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does NOT resolve repo.save() to User#save (negative)', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const wrongSave = calls.find(c =>
|
||||
c.target === 'save' && c.source === 'processRepos' && c.targetFilePath?.includes('User.kt'),
|
||||
);
|
||||
expect(wrongSave).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1108,3 +1108,56 @@ describe('PHP foreach with $this->property member access', () => {
|
||||
expect(wrongSave).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PHP foreach with call_expression iterable: foreach (getUsers() as $user)
|
||||
// Phase 7.3: function_call_expression iterable resolution via ReturnTypeLookup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('PHP foreach call_expression iterable resolution (Phase 7.3)', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runPipelineFromRepo(
|
||||
path.join(FIXTURES, 'php-foreach-call-expr'),
|
||||
() => {},
|
||||
);
|
||||
}, 60000);
|
||||
|
||||
it('detects User and Repo classes with competing save methods', () => {
|
||||
expect(getNodesByLabel(result, 'Class')).toContain('User');
|
||||
expect(getNodesByLabel(result, 'Class')).toContain('Repo');
|
||||
});
|
||||
|
||||
it('resolves $user->save() in foreach over getUsers() to User#save', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const userSave = calls.find(c =>
|
||||
c.target === 'save' && c.source === 'processUsers' && c.targetFilePath?.includes('User'),
|
||||
);
|
||||
expect(userSave).toBeDefined();
|
||||
});
|
||||
|
||||
it('resolves $repo->save() in foreach over getRepos() to Repo#save', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const repoSave = calls.find(c =>
|
||||
c.target === 'save' && c.source === 'processRepos' && c.targetFilePath?.includes('Repo'),
|
||||
);
|
||||
expect(repoSave).toBeDefined();
|
||||
});
|
||||
|
||||
it('does NOT resolve $user->save() to Repo#save (negative)', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const wrongSave = calls.find(c =>
|
||||
c.target === 'save' && c.source === 'processUsers' && c.targetFilePath?.includes('Repo'),
|
||||
);
|
||||
expect(wrongSave).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does NOT resolve $repo->save() to User#save (negative)', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const wrongSave = calls.find(c =>
|
||||
c.target === 'save' && c.source === 'processRepos' && c.targetFilePath?.includes('User'),
|
||||
);
|
||||
expect(wrongSave).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1171,3 +1171,56 @@ describe('Python member access iterable for-loop', () => {
|
||||
expect(repoSave).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Python for-loop with call_expression iterable: for user in get_users()
|
||||
// Phase 7.3: call_expression iterable resolution via ReturnTypeLookup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Python for-loop call_expression iterable resolution (Phase 7.3)', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runPipelineFromRepo(
|
||||
path.join(FIXTURES, 'python-for-call-expr'),
|
||||
() => {},
|
||||
);
|
||||
}, 60000);
|
||||
|
||||
it('detects User and Repo classes with competing save methods', () => {
|
||||
expect(getNodesByLabel(result, 'Class')).toContain('User');
|
||||
expect(getNodesByLabel(result, 'Class')).toContain('Repo');
|
||||
});
|
||||
|
||||
it('resolves user.save() in for-loop over get_users() to User#save', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const userSave = calls.find(c =>
|
||||
c.target === 'save' && c.source === 'process_users' && c.targetFilePath?.includes('models.py'),
|
||||
);
|
||||
expect(userSave).toBeDefined();
|
||||
});
|
||||
|
||||
it('resolves repo.save() in for-loop over get_repos() to Repo#save', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const repoSave = calls.find(c =>
|
||||
c.target === 'save' && c.source === 'process_repos' && c.targetFilePath?.includes('models.py'),
|
||||
);
|
||||
expect(repoSave).toBeDefined();
|
||||
});
|
||||
|
||||
it('process_users resolves exactly one save call (no cross-binding)', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const saveCalls = calls.filter(c =>
|
||||
c.target === 'save' && c.source === 'process_users',
|
||||
);
|
||||
expect(saveCalls.length).toBe(1);
|
||||
});
|
||||
|
||||
it('process_repos resolves exactly one save call (no cross-binding)', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const saveCalls = calls.filter(c =>
|
||||
c.target === 'save' && c.source === 'process_repos',
|
||||
);
|
||||
expect(saveCalls.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1223,3 +1223,59 @@ describe('Rust .iter() for-loop call_expression resolution', () => {
|
||||
expect(wrongSave).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// for user in get_users() — direct call_expression iterable resolution
|
||||
// Phase 7.3: unlike rust-iter-for-loop (typed variable .iter()), this tests
|
||||
// iterating over a function call's return value directly.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Rust for-loop direct call_expression iterable resolution (Phase 7.3)', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runPipelineFromRepo(
|
||||
path.join(FIXTURES, 'rust-for-call-expr'),
|
||||
() => {},
|
||||
);
|
||||
}, 60000);
|
||||
|
||||
it('detects User and Repo structs with competing save functions', () => {
|
||||
expect(getNodesByLabel(result, 'Struct')).toContain('User');
|
||||
expect(getNodesByLabel(result, 'Struct')).toContain('Repo');
|
||||
const saveFns = getNodesByLabel(result, 'Function').filter(f => f === 'save');
|
||||
expect(saveFns.length).toBe(2);
|
||||
});
|
||||
|
||||
it('resolves user.save() in for-loop over get_users() to User#save', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const userSave = calls.find(c =>
|
||||
c.target === 'save' && c.source === 'process_users' && c.targetFilePath?.includes('user.rs'),
|
||||
);
|
||||
expect(userSave).toBeDefined();
|
||||
});
|
||||
|
||||
it('resolves repo.save() in for-loop over get_repos() to Repo#save', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const repoSave = calls.find(c =>
|
||||
c.target === 'save' && c.source === 'process_repos' && c.targetFilePath?.includes('repo.rs'),
|
||||
);
|
||||
expect(repoSave).toBeDefined();
|
||||
});
|
||||
|
||||
it('does NOT resolve user.save() to Repo#save (negative)', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const wrongSave = calls.find(c =>
|
||||
c.target === 'save' && c.source === 'process_users' && c.targetFilePath?.includes('repo.rs'),
|
||||
);
|
||||
expect(wrongSave).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does NOT resolve repo.save() to User#save (negative)', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const wrongSave = calls.find(c =>
|
||||
c.target === 'save' && c.source === 'process_repos' && c.targetFilePath?.includes('user.rs'),
|
||||
);
|
||||
expect(wrongSave).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1639,3 +1639,56 @@ describe('TypeScript class field foreach resolution (Phase 6.1)', () => {
|
||||
expect(wrong).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TypeScript for-of with call_expression iterable: for (const user of getUsers())
|
||||
// Phase 7.3: call_expression iterable resolution via ReturnTypeLookup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('TypeScript for-of call_expression iterable resolution (Phase 7.3)', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runPipelineFromRepo(
|
||||
path.join(FIXTURES, 'typescript-for-of-call-expr'),
|
||||
() => {},
|
||||
);
|
||||
}, 60000);
|
||||
|
||||
it('detects User and Repo classes with competing save methods', () => {
|
||||
expect(getNodesByLabel(result, 'Class')).toContain('User');
|
||||
expect(getNodesByLabel(result, 'Class')).toContain('Repo');
|
||||
});
|
||||
|
||||
it('resolves user.save() in for-of getUsers() to User#save', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const userSave = calls.find(c =>
|
||||
c.target === 'save' && c.source === 'processUsers' && c.targetFilePath?.includes('user.ts'),
|
||||
);
|
||||
expect(userSave).toBeDefined();
|
||||
});
|
||||
|
||||
it('resolves repo.save() in for-of getRepos() to Repo#save', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const repoSave = calls.find(c =>
|
||||
c.target === 'save' && c.source === 'processRepos' && c.targetFilePath?.includes('repo.ts'),
|
||||
);
|
||||
expect(repoSave).toBeDefined();
|
||||
});
|
||||
|
||||
it('does NOT resolve user.save() to Repo#save (negative)', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const wrongSave = calls.find(c =>
|
||||
c.target === 'save' && c.source === 'processUsers' && c.targetFilePath?.includes('repo.ts'),
|
||||
);
|
||||
expect(wrongSave).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does NOT resolve repo.save() to User#save (negative)', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const wrongSave = calls.find(c =>
|
||||
c.target === 'save' && c.source === 'processRepos' && c.targetFilePath?.includes('user.ts'),
|
||||
);
|
||||
expect(wrongSave).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { processCallsFromExtracted, extractReturnTypeName } from '../../src/core/ingestion/call-processor.js';
|
||||
import { processCallsFromExtracted } from '../../src/core/ingestion/call-processor.js';
|
||||
import { extractReturnTypeName } from '../../src/core/ingestion/type-extractors/shared.js';
|
||||
import { createResolutionContext, type ResolutionContext } from '../../src/core/ingestion/resolution-context.js';
|
||||
import { createKnowledgeGraph } from '../../src/core/graph/graph.js';
|
||||
import type { ExtractedCall, FileConstructorBindings } from '../../src/core/ingestion/workers/parse-worker.js';
|
||||
|
||||
@@ -147,7 +147,34 @@ describe('formatContextResult', () => {
|
||||
|
||||
describe('formatImpactResult', () => {
|
||||
it('returns error message for error input', () => {
|
||||
expect(formatImpactResult({ error: 'bad request' })).toBe('Error: bad request');
|
||||
expect(formatImpactResult({ error: 'bad request' })).toContain('Error: bad request');
|
||||
});
|
||||
|
||||
it('returns error with suggestion when provided', () => {
|
||||
const result = formatImpactResult({
|
||||
error: 'Impact analysis failed',
|
||||
suggestion: 'Try gitnexus context <symbol> as a fallback',
|
||||
});
|
||||
expect(result).toContain('Error: Impact analysis failed');
|
||||
expect(result).toContain('Suggestion: Try gitnexus context');
|
||||
});
|
||||
|
||||
it('shows partial warning when traversal was interrupted', () => {
|
||||
const result = formatImpactResult({
|
||||
target: { kind: 'Function', name: 'foo' },
|
||||
direction: 'upstream',
|
||||
impactedCount: 2,
|
||||
partial: true,
|
||||
byDepth: {
|
||||
1: [
|
||||
{ type: 'Function', name: 'caller1', filePath: 'src/a.ts', relationType: 'CALLS', confidence: 1 },
|
||||
{ type: 'Function', name: 'caller2', filePath: 'src/b.ts', relationType: 'CALLS', confidence: 1 },
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(result).toContain('Partial results');
|
||||
expect(result).toContain('caller1');
|
||||
expect(result).toContain('caller2');
|
||||
});
|
||||
|
||||
it('handles zero impact', () => {
|
||||
|
||||
@@ -96,12 +96,14 @@ describe('isWriteQuery', () => {
|
||||
// ─── Relation type allowlist ──────────────────────────────────────────
|
||||
|
||||
describe('VALID_RELATION_TYPES', () => {
|
||||
it('contains exactly the expected 4 types', () => {
|
||||
expect(VALID_RELATION_TYPES.size).toBe(4);
|
||||
it('contains exactly the expected 6 types', () => {
|
||||
expect(VALID_RELATION_TYPES.size).toBe(6);
|
||||
expect(VALID_RELATION_TYPES.has('CALLS')).toBe(true);
|
||||
expect(VALID_RELATION_TYPES.has('IMPORTS')).toBe(true);
|
||||
expect(VALID_RELATION_TYPES.has('EXTENDS')).toBe(true);
|
||||
expect(VALID_RELATION_TYPES.has('IMPLEMENTS')).toBe(true);
|
||||
expect(VALID_RELATION_TYPES.has('HAS_METHOD')).toBe(true);
|
||||
expect(VALID_RELATION_TYPES.has('OVERRIDES')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects invalid relation types', () => {
|
||||
|
||||
@@ -3509,6 +3509,66 @@ class RepoService {
|
||||
});
|
||||
});
|
||||
|
||||
describe('PHP foreach $this->property (Phase 7.4 — Strategy C)', () => {
|
||||
it('resolves loop variable from @var User[] property without @param workaround', () => {
|
||||
const tree = parse(`<?php
|
||||
class App {
|
||||
/** @var User[] */
|
||||
private $users;
|
||||
public function process(): void {
|
||||
foreach ($this->users as $user) {
|
||||
$user->save();
|
||||
}
|
||||
}
|
||||
}
|
||||
`, PHP.php);
|
||||
const { env } = buildTypeEnv(tree, 'php');
|
||||
expect(flatGet(env, '$user')).toBe('User');
|
||||
});
|
||||
|
||||
it('does not bind from unknown $this->property (conservative)', () => {
|
||||
const tree = parse(`<?php
|
||||
class App {
|
||||
private $unknownProp;
|
||||
public function process(): void {
|
||||
foreach ($this->unknownProp as $item) {
|
||||
$item->save();
|
||||
}
|
||||
}
|
||||
}
|
||||
`, PHP.php);
|
||||
const { env } = buildTypeEnv(tree, 'php');
|
||||
expect(flatGet(env, '$item')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('multi-class file: resolves correct property for each class', () => {
|
||||
const tree = parse(`<?php
|
||||
class A {
|
||||
/** @var User[] */
|
||||
private $items;
|
||||
public function processA(): void {
|
||||
foreach ($this->items as $item) {
|
||||
$item->save();
|
||||
}
|
||||
}
|
||||
}
|
||||
class B {
|
||||
/** @var Order[] */
|
||||
private $items;
|
||||
public function processB(): void {
|
||||
foreach ($this->items as $item) {
|
||||
$item->submit();
|
||||
}
|
||||
}
|
||||
}
|
||||
`, PHP.php);
|
||||
const { env } = buildTypeEnv(tree, 'php');
|
||||
// Both $item bindings exist but may share the same key if scoped to method name
|
||||
// Conservative: just verify at least one resolves correctly
|
||||
expect(flatGet(env, '$item')).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('match arm scoping — first-writer-wins regression', () => {
|
||||
it('Rust: first match arm binding wins, later arms do not overwrite', () => {
|
||||
const tree = parse(`
|
||||
|
||||
+25
-21
@@ -47,13 +47,15 @@ The most valuable next move is to let those signals participate in more places,
|
||||
|
||||
## Phase 7: Cross-Scope and Return-Aware Propagation
|
||||
|
||||
> **Status: COMPLETE** — shipped in `feat/phase7-type-resolution` (commits `ed767e3`, `ca4c6c1`, `d79237e`).
|
||||
|
||||
### Goal
|
||||
|
||||
Allow loop inference and assignment inference to see more than the current function-local environment.
|
||||
|
||||
### Problems this phase addresses
|
||||
|
||||
#### 7A. Iterable expressions in Go and similar cases
|
||||
#### 7A. Iterable expressions in Go and similar cases (shipped as Phase 7.3)
|
||||
|
||||
```go
|
||||
for _, user := range getUsers() {
|
||||
@@ -63,9 +65,9 @@ for _, user := range getUsers() {
|
||||
|
||||
The iterable is a call expression, not an identifier with a local binding.
|
||||
|
||||
To resolve `user`, the loop extractor needs access to a return-type source for `getUsers()`.
|
||||
Resolved: `ReturnTypeLookup` introduced in Phase 7.1 exposes `lookupRawReturnType`. All seven typed-iteration languages (Go, TypeScript, Python, Rust, Java, Kotlin, C#) now unwrap the raw container type string to extract the element type when the iterable is a direct function call.
|
||||
|
||||
#### 7B. File-scope or class-scope iterable typing in PHP
|
||||
#### 7B. File-scope or class-scope iterable typing in PHP (shipped as Phase 7.4)
|
||||
|
||||
```php
|
||||
foreach ($this->users as $user) {
|
||||
@@ -75,30 +77,33 @@ foreach ($this->users as $user) {
|
||||
|
||||
If `$this->users` is typed through a class property annotation or file/class-scope doc-comment information, the current local-scope-only path may not be enough.
|
||||
|
||||
#### 7C. Broader use of already-known return types
|
||||
Resolved: Strategy C in the PHP `extractForLoopBinding` walks up the AST to the enclosing `class_declaration`, scans the `declaration_list` for a matching `property_declaration`, and extracts the element type from the `@var` PHPDoc comment (or PHP 7.4+ native type field). The `@param` workaround previously required in the fixture is gone.
|
||||
|
||||
#### 7C. Broader use of already-known return types (shipped as Phase 7.1 + 7.2)
|
||||
|
||||
The system can already infer receiver types from uniquely resolved call results in `call-processor.ts`. That needs to be generalised so `TypeEnv` can benefit from it too.
|
||||
|
||||
### Engineering direction
|
||||
Resolved: `ReturnTypeLookup` (Phase 7.1) encapsulates `lookupReturnType` / `lookupRawReturnType` and is threaded through `ForLoopExtractorContext` (Phase 7.2) to all for-loop extractors. Phase 7.2 also added the `pendingCallResults` infrastructure (the `PendingAssignment` discriminated union in `types.ts` and the Tier 2b processing loop in `type-env.ts`), but no extractor populates it yet — `var x = f()` propagation is Phase 9 work.
|
||||
|
||||
- extend loop and propagation extractors so they can access more than the current local scope
|
||||
- expose file-scope string bindings where needed
|
||||
- introduce a shared `returnTypeMap` or equivalent lookup mechanism
|
||||
- keep the interface change coordinated across extractors to avoid partial semantics by language
|
||||
### Engineering direction (as implemented)
|
||||
|
||||
### Expected impact
|
||||
- introduced `ReturnTypeLookup` interface and `buildReturnTypeLookup` factory in `type-env.ts`
|
||||
- replaced per-extractor `(node, env)` signature with `ForLoopExtractorContext` context object for extensibility
|
||||
- added `extractElementTypeFromString` to `shared.ts` as the canonical raw-string container unwrapper
|
||||
- added PHP Strategy C helper (`findClassPropertyElementType`) scoped to the PHP extractor
|
||||
- kept all changes backwards-compatible — explicit-type paths are untouched
|
||||
|
||||
This phase should unlock:
|
||||
### Delivered impact
|
||||
|
||||
- loop inference for iterable-producing call expressions
|
||||
- broader propagation from method / function return types
|
||||
- fewer missed bindings in real-world code that avoids explicit variable annotations
|
||||
- loop inference now works for direct function call iterables in all 7 typed-iteration languages
|
||||
- PHP `$this->property` foreach is resolved from class-level `@var` without requiring `@param` workarounds
|
||||
- `pendingCallResults` infrastructure is in place (Tier 2b loop + `PendingAssignment` union) — dormant until an extractor emits `{ kind: 'callResult' }` (Phase 9)
|
||||
|
||||
### Risk level
|
||||
|
||||
**Medium**
|
||||
**Medium** (as predicted)
|
||||
|
||||
This work touches extractor interfaces across multiple languages, so the coordination cost is real. However, the conceptual model is an extension of existing behavior rather than a new analysis paradigm.
|
||||
The interface change touched all extractors but remained additive — no existing paths were changed.
|
||||
|
||||
---
|
||||
|
||||
@@ -250,19 +255,18 @@ Missing or weak areas include:
|
||||
|
||||
Key remaining gap:
|
||||
|
||||
- iterable call expressions in range loops
|
||||
- ~~iterable call expressions in range loops~~ ✓ shipped in Phase 7.3
|
||||
|
||||
**Priority:** High
|
||||
**Reason:** Go codebases frequently rely on return-value-based iteration patterns.
|
||||
**Priority:** Medium (chained property access remains for Phase 8)
|
||||
|
||||
### PHP
|
||||
|
||||
Key remaining gaps:
|
||||
|
||||
- file/class-scope iterable propagation
|
||||
- ~~file/class-scope iterable propagation~~ ✓ shipped in Phase 7.4 (Strategy C)
|
||||
- chained property access
|
||||
|
||||
**Priority:** High
|
||||
**Priority:** High
|
||||
**Reason:** PHP heavily benefits from doc-comment-aware field and property modelling.
|
||||
|
||||
### Rust
|
||||
|
||||
@@ -360,7 +360,7 @@ A key detail is that some initializer bindings are not fully resolved inside `Ty
|
||||
- validated class / struct constructor candidates
|
||||
- uniquely resolved function or method calls that expose a usable return type
|
||||
|
||||
So return-type-aware receiver inference already exists in a constrained downstream form today. What does **not** yet exist is feeding that information back into `TypeEnv` broadly enough to power loop inference, general assignment propagation, and wider expression typing.
|
||||
So return-type-aware receiver inference already exists in a constrained downstream form today. Phase 7.3 extended this by threading `ReturnTypeLookup` into `TypeEnv` via `ForLoopExtractorContext`, enabling for-loop call-expression iterables (e.g., `for (const u of getUsers())`) to resolve element types in 7 languages (TS/JS, Java, Kotlin, C#, Go, Rust, Python, PHP). General assignment propagation (`var x = f()` binding the return type of `f` into the scope env) remains pending — the `pendingCallResults` infrastructure exists but is dormant until Phase 9.
|
||||
|
||||
---
|
||||
|
||||
@@ -406,7 +406,7 @@ Important gaps still remain:
|
||||
- limited branch-sensitive narrowing outside selected pattern constructs
|
||||
- limited Swift support compared with other languages
|
||||
- no complete destructuring-based field typing
|
||||
- no broad expression-level return-type propagation inside `TypeEnv`
|
||||
- no broad expression-level return-type propagation inside `TypeEnv` (for-loop call-expression iterables are resolved in 7 languages via `ReturnTypeLookup`, but general `var x = f()` assignment propagation is pending)
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user