diff --git a/.claude/skills/agent-eval/corpus.json b/.claude/skills/agent-eval/corpus.json index 150b4a601..5ffb57ecd 100644 --- a/.claude/skills/agent-eval/corpus.json +++ b/.claude/skills/agent-eval/corpus.json @@ -607,5 +607,51 @@ "files": "~3390", "question": "How does programs.git.enable produce the final git config file in the user's home directory? Trace the flow from the git program module to the home-files machinery that links generated files into place." } + ], + "GLSL": [ + { + "name": "glsl-blend", + "repo": "https://github.com/jamieowen/glsl-blend", + "size": "Small", + "files": "~43", + "question": "How does the overlay blend entry function reach the shared channel-level blend helpers across the shader includes?" + }, + { + "name": "gl-transitions", + "repo": "https://github.com/gl-transitions/gl-transitions", + "size": "Medium", + "files": "~145", + "question": "How does a transition shader's main entry point reach its transition function and shared sampling helpers?" + }, + { + "name": "lygia", + "repo": "https://github.com/patriciogonzalezvivo/lygia", + "size": "Large", + "files": "~2860", + "question": "How does a lighting shader reach the material, BRDF, and sampling helpers brought in through Lygia's include hierarchy?" + } + ], + "HLSL": [ + { + "name": "NoiseShader", + "repo": "https://github.com/keijiro/NoiseShader", + "size": "Small", + "files": "~112", + "question": "How do the public noise shader functions reach the shared hash and interpolation helpers?" + }, + { + "name": "NRD", + "repo": "https://github.com/NVIDIA-RTX/NRD", + "size": "Medium", + "files": "~132", + "question": "How does an NRD compute shader entry point reach its macro-declared resources and shared denoising helpers?" + }, + { + "name": "Magpie", + "repo": "https://github.com/Blinue/Magpie", + "size": "Large", + "files": "~631", + "question": "How does a scaling effect entry point reach the common sampling and color-processing helpers across included HLSL files?" + } ] } diff --git a/CHANGELOG.md b/CHANGELOG.md index 18f3b2837..3608e3794 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### New Features +- CodeGraph now indexes GLSL, Vulkan ray-tracing and mesh shaders, HLSL, and OpenUSD GLSLFX files, including shader includes, macros, entry points, descriptor/register metadata, uniform and storage resources, push and specialization constants, and conservative C/C++ links to the shaders and bindings they configure. +- CodeGraph now indexes CMake and PowerShell build scripts, so shader targets, generated SPIR-V names, and build-time variables appear in project search and dependency analysis. - Indexing TypeScript, TSX, JavaScript, JSX, Java, Python, Go, C, and C++ projects is faster: parsing and symbol extraction now run in a native engine when a prebuilt binary is available for your platform (release bundles include one), producing exactly the same graph — verified byte-for-byte against the previous engine on real repositories, from small libraries up to vscode-, dubbo-, django-, git-, and protobuf-scale codebases (Lombok-generated members, C function-pointer tables, and Unreal-Engine-style macro-heavy headers included; CUDA and Metal sources ride the C++ path). The speedup is largest on resource-constrained machines like CI runners. No setup needed: platforms without the native binary, and individual files with syntax errors, automatically use the previous engine, and `CODEGRAPH_KERNEL=0` turns the native path off entirely. - Reference resolution now runs in parallel on large projects. When a project has enough pending references to make it worthwhile (roughly 150k+, typical for big Java/Kotlin/Spring codebases), resolution fans out across worker threads while results are applied in the exact order the single-threaded path would have used — the graph comes out byte-for-byte identical, about twice as fast end-to-end on a 4,000-file Java project in our testing. Small projects keep the single-threaded path automatically (the fan-out costs more than it saves there). Set `CODEGRAPH_NO_PARALLEL_RESOLVE=1` to disable, or `CODEGRAPH_PARALLEL_RESOLVE_MIN=` to tune when it engages. - Indexing large projects got another sizeable speedup — about a quarter less wall-clock on the same 4,000-file Java project, with the graph still byte-for-byte identical. Two changes: the database no longer interleaves expensive checkpoint housekeeping into the middle of resolution on a fresh index (it's folded once at the end instead), and while one batch's results are being written out, the worker threads are already resolving the next batch instead of sitting idle. @@ -24,6 +26,25 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixes +- GLSL extraction now recovers preprocessor-heavy and malformed-but-valid function declarations, while shader call resolution follows equivalent definitions through include closures instead of silently dropping real callers. +- Shader impact queries can be scoped to a file, common variant definitions are presented without duplicate blast-radius entries, unrelated C++ methods are no longer guessed as matches, and deletion-only syncs preserve a fresh index timestamp. +- Common shader names such as `main` now require an explicit file when a project contains many definitions, explicit shader filenames anchor `codegraph_explore` to that file, and conditional shader variants remain visible as separate impact roots. +- Shared shader helpers and constants injected by the build system now resolve when their equivalent declarations have matching contracts, and recovered GLSL signatures retain their return type and parameters. +- PowerShell indexing now records calls between script functions and from script scope, reports exact declaration and call-site lines, and avoids flooding search with repeated or function-local assignment nodes. +- Ambiguous CLI queries now report the complete definition count instead of the first 50 matches, and shader impact output identifies the preprocessor conditions attached to each indexed variant. +- PowerShell `Import-Module` statements now link scripts to local `.psm1` modules, and calls to exported module functions participate in callers, callees, impact, and file dependency analysis. +- Shader exploration now expands an explicitly requested file through its transitive include closure, while PowerShell exports no longer appear as callers and executable launches are no longer misclassified as module imports. +- PowerShell dot-sourced scripts now create file dependencies and expose their shared functions to callers, callees, impact, and exploration without enabling unrelated project-wide name matches. +- Named local C++ lambdas now appear as scoped functions with accurate callers and callees, preventing their invocations from being linked to unrelated same-named methods elsewhere in the project. +- Malformed C++ preprocessor parse ranges no longer let one method consume the rest of a file, while local lambdas retain their namespace and class ownership and can be isolated by source line in callers, callees, and impact queries. +- HLSL compiler-root includes now resolve through unique project suffixes, overloads use include context and call arity, and shared RTXDI-style library calls connect to each application bridge implementation instead of collapsing onto one sample. +- `codegraph status` now distinguishes metadata-only files and reports unresolved attempted references by language and kind, so a current index no longer hides semantic coverage gaps. +- `codegraph affected` now excludes vendored dependency tests for project-owned changes while retaining them for dependency changes or explicit filters. +- Shader exploration and impact now stay inside the explicitly selected compilation target, HLSL overloads use proven argument types when arity alone is ambiguous, and shader compile-test entry files participate in affected-test results. +- Shared shader-header impact now surfaces every proven compilation entry even at shallow depth, while `codegraph affected` follows shader include roots without crossing between application bridges or treating helper headers as tests. +- GLSL overload callers now resolve by their real parameter counts, and C/C++ shader filename checks or documentation strings remain file dependencies instead of being reported as calls to a shader entry point. +- Calls to shader helpers that switch between a function and a function-like macro now remain connected across every preprocessor variant, and `codegraph_explore` keeps both endpoint sources while showing proven direct calls in Flow and Relationships when a query uses `file:line`, `file line N`, or directly names both endpoints. + - TypeScript, TSX, and JavaScript files now parse with up-to-date grammars — modern syntax such as `using` declarations and import attributes no longer trips parse errors that could drop surrounding symbols. (The previously bundled grammars dated from 2023.) - Searching or exploring by field names now finds the code that defines them. A query made of object keys or API field names (`profileInfo isTrialEligible quotaInfo billingMethod`) used to return unrelated results while the defining files never appeared, because three retrieval steps each dropped multi-word camelCase terms: an internal case-comparison bug, a match step that only considered classes (never functions or methods), and exploration seeding that required exact symbol-name matches. All three are fixed — `codegraph_explore` with a bag of field names now surfaces the controllers and services that assemble those fields. (#1196) - `codegraph.json`'s `includeIgnored` works again for the "folder of repos" layout: when one `.gitignore` rule covers a parent directory (`/repos/`) holding several embedded git repositories, opting in the individual repos (`"includeIgnored": ["repos/a/"]` — the exact spelling `codegraph init`'s own hint suggests) previously matched nothing and indexed zero files, looping the same suggestion back at you. Both spellings now work — name the parent directory to opt in everything under it, or name individual repos to opt in just those — and the hint no longer re-suggests repos that are already configured. (#1295) diff --git a/README.md b/README.md index dbaa2ad69..f4ea6883d 100644 --- a/README.md +++ b/README.md @@ -157,6 +157,8 @@ Every language below gets the same treatment — full structural extraction and Objective-C Metal CUDA + GLSL + HLSL Swift Kotlin Scala @@ -317,7 +319,7 @@ The reliable, universal payoff is **surgical context and speed**: CodeGraph coll | **Full-Text Search** | Find code by name instantly across your entire codebase, powered by FTS5 | | **Impact Analysis** | Trace callers, callees, and the full impact radius of any symbol before making changes | | **Always Fresh** | File watcher uses native OS events (FSEvents/inotify/ReadDirectoryChangesW) with debounced auto-sync — the graph stays current as you code, zero config | -| **20+ Languages** | TypeScript, JavaScript, ArkTS, Python, Go, Rust, Java, C#, VB.NET, PHP, Ruby, C, C++, CUDA, Objective-C, Metal, Swift, Kotlin, Scala, Dart, Lua, Luau, R, Nix, Erlang, CFML, COBOL, Solidity, Terraform/OpenTofu, Svelte, Vue, Astro, Liquid, Pascal/Delphi | +| **30+ Languages** | TypeScript, JavaScript, ArkTS, Python, Go, Rust, Java, C#, VB.NET, PHP, Ruby, C, C++, CUDA, GLSL, HLSL, CMake, PowerShell, Objective-C, Metal, Swift, Kotlin, Scala, Dart, Lua, Luau, R, Nix, Erlang, CFML, COBOL, Solidity, Terraform/OpenTofu, Svelte, Vue, Astro, Liquid, Pascal/Delphi | | **Framework-aware Routes** | Recognizes web-framework routing files and links URL patterns to their handlers across 17 frameworks | | **Mixed iOS / React Native / Expo** | Closes cross-language flows that static parsing misses: Swift ↔ ObjC bridging, React Native legacy bridge + TurboModules + Fabric view components, native → JS event emitters, Expo Modules | | **100% Local** | No data leaves your machine. No API keys. No external services. SQLite database only | @@ -556,9 +558,9 @@ codegraph query # Search symbols (--kind, --limit, --json) codegraph explore # Relevant symbols' source + call paths in one shot (same output as the codegraph_explore MCP tool) codegraph node # One symbol's source + callers, or read a file with line numbers (same output as codegraph_node) codegraph files [path] # Show file structure (--format, --filter, --max-depth, --json) -codegraph callers # Find what calls a function/method (--limit, --json) -codegraph callees # Find what a function/method calls (--limit, --json) -codegraph impact # Analyze what code is affected by changing a symbol (--depth, --json) +codegraph callers # Find what calls a function/method (--file, --line, --limit, --json) +codegraph callees # Find what a function/method calls (--file, --line, --limit, --json) +codegraph impact # Analyze what code is affected by changing a symbol (--file, --line, --depth, --json) codegraph affected [files...] # Find test files affected by changes (see below) codegraph daemon # Manage background daemons — pick one to stop (alias: daemons) codegraph telemetry [on|off] # Show or change anonymous usage telemetry @@ -567,6 +569,8 @@ codegraph version # Print the installed version (also -v, --vers codegraph help [command] # Show help, optionally for one command ``` +`codegraph status --json` separates tracked files from graph-producing files and reports unresolved attempted references by language and kind, making semantic gaps visible even when the index is current. + ### `codegraph affected` Traces import dependencies transitively to find which test files are affected by changed source files. @@ -585,6 +589,8 @@ codegraph affected src/auth.ts --filter "e2e/*" # Custom test file pattern | `-j, --json` | Output as JSON | `false` | | `-q, --quiet` | Output file paths only | `false` | +Automatic test discovery excludes common vendored roots for project-owned changes; pass `--filter` to include matching dependency tests explicitly. + **CI/hook example:** ```bash @@ -729,6 +735,24 @@ language or a malformed file is warned about and skipped — it never breaks indexing — and a project with no `codegraph.json` behaves exactly as before. Re-index (`codegraph index`) after adding or changing mappings. +### Shader include roots + +GLSL and HLSL quoted includes resolve relative to the including file, then +through project include roots, then from the project root. Configure compiler +include roots and aliases in the same `codegraph.json` when your build uses them: + +```json +{ + "shaderIncludePaths": ["shaders/include", "third_party/NRD/Shaders/Include"], + "shaderIncludeAliases": { + "$TOOLS": "pxr/imaging" + } +} +``` + +Paths must remain inside the project. Ambiguous include or symbol matches stay +unlinked rather than guessing. + ## Telemetry CodeGraph collects **anonymous usage statistics** — which tools and commands get @@ -820,6 +844,10 @@ is written): | Objective-C | `.m`, `.mm`, `.h` | Partial support (classes, protocols, methods, `@property`, `#import`, message sends; `.mm` ObjC++ may parse incompletely) | | Metal | `.metal` | Full support (vertex/fragment/kernel functions, structs, type aliases, call edges — MSL parses as C++, with `[[attribute]]` annotations handled) | | CUDA | `.cu`, `.cuh` | Full support (kernels and device/host functions, structs, classes, host→kernel call edges through `<<>>` launch syntax — templated launches, function-pointer launches (`auto kernel = &fn<...>`), `dim3{...}` configs, and macro-defined kernels included; `__global__`/`__device__`/`__launch_bounds__` specifiers handled; CUDA in plain `.h`/`.hpp` headers recognized by content) | +| GLSL / Vulkan GLSL | `.glsl`, `.vert`, `.frag`, `.comp`, `.geom`, `.tesc`, `.tese`, `.rgen`, `.rmiss`, `.rchit`, `.rahit`, `.rint`, `.rcall`, `.mesh`, `.task`, `.glslfx` | Full support (functions, structs, macros, transitive includes, call edges, descriptor layouts, uniform/storage/push-constant blocks, specialization constants, ray payloads, mesh/ray stages, and OpenUSD GLSLFX sections/techniques) | +| HLSL | `.hlsl`, `.hlsli`, `.fx`, `.fxh` | Full support (functions and shader entry points, structs/classes, macros/includes, `cbuffer`/`tbuffer`, semantics and thread-group attributes, register/space and Vulkan attributes, texture/buffer/sampler/ray-tracing resources, including NVIDIA NRD-style declaration macros) | +| CMake | `CMakeLists.txt`, `.cmake` | Build-script support (functions/macros, variables, include and subdirectory wiring, shader and SPIR-V references) | +| PowerShell | `.ps1`, `.psm1`, `.psd1` | Build-script support (functions, `Import-Module` dependencies, cross-module and script/function call edges, script-scope variables, tool invocations, shader and SPIR-V references) | | Swift | `.swift` | Full support | | Kotlin | `.kt`, `.kts` | Full support | | Scala | `.scala`, `.sc` | Full support (classes, traits, methods, type aliases, Scala 3 enums) | diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 000000000..115eeeeb7 --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,54 @@ +# Third-party notices + +## tree-sitter-glsl + +Source: `Caellian/tree-sitter-glsl@09b8cbc3a36bce116d641f1bc176268c0899e6e0` + +The MIT License (MIT) + +Copyright (c) 2026 Tin Švagelj + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +## tree-sitter-hlsl + +Source: `tree-sitter-grammars/tree-sitter-hlsl@bab9111922d53d43668fabb61869bec51bbcb915` + +The MIT License (MIT) + +Copyright (c) 2014 Max Brunsfield +Copyright (c) 2022 Stephan Seitz + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/__tests__/affected-vendored.test.ts b/__tests__/affected-vendored.test.ts new file mode 100644 index 000000000..974ff47a2 --- /dev/null +++ b/__tests__/affected-vendored.test.ts @@ -0,0 +1,78 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { execFileSync } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; + +const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js'); +let dir: string; + +function write(relative: string, content: string): void { + const file = path.join(dir, relative); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, content); +} + +function affected(file: string): string[] { + const stdout = execFileSync(process.execPath, [BIN, 'affected', file, '--path', dir, '--json'], { + encoding: 'utf8', + env: { ...process.env, CODEGRAPH_NO_DAEMON: '1' }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + return JSON.parse(stdout).affectedTests as string[]; +} + +beforeAll(async () => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-affected-vendor-')); + write('src/core.ts', 'export function core() { return 1; }\n'); + write('tests/core.test.ts', 'import { core } from "../src/core"; core();\n'); + write('External/lib/source.ts', 'export function external() { return 1; }\n'); + write('External/lib/tests/external.test.ts', 'import { core } from "../../../src/core"; import { external } from "../source"; core(); external();\n'); + write('Shaders/shared.hlsli', 'float sharedShader(){ return 1.0; }\n'); + write('Samples/Minimal/Shaders/Render.hlsl', '#include "../../../Shaders/shared.hlsli"\nfloat main(){ return sharedShader(); }\n'); + write('Samples/Full/Shaders/Bridge/RAB_Surface.hlsli', 'float fullBridge(){ return 1.0; }\n'); + write('Samples/Full/Shaders/Full.hlsl', '#include "Bridge/RAB_Surface.hlsli"\n#include "../../../Shaders/shared.hlsli"\nfloat main(){ return fullBridge() + sharedShader(); }\n'); + write('Support/Tests/Runtime/Shaders/Bridge/RAB_Surface.hlsli', 'float testBridge(){ return 1.0; }\n'); + write('Support/Tests/Runtime/Shaders/RAB_PathTracer.hlsli', 'float helperHeader(){ return 1.0; }\n'); + write('Support/Tests/Runtime/Shaders/CompileTest.hlsl', '#include "../../../../Shaders/shared.hlsli"\n#include "Bridge/RAB_Surface.hlsli"\n#include "RAB_PathTracer.hlsli"\nfloat main(){ return sharedShader() + testBridge() + helperHeader(); }\n'); + const cg = CodeGraph.initSync(dir); + await cg.indexAll(); + cg.close(); +}, 60_000); + +afterAll(() => { + if (dir) fs.rmSync(dir, { recursive: true, force: true }); +}); + +describe('affected vendored-test filtering', () => { + it('omits dependency-suite tests for a project-owned change', () => { + expect(affected('src/core.ts')).toEqual(['tests/core.test.ts']); + }); + + it('keeps vendored tests when the changed file is itself vendored', () => { + expect(affected('External/lib/source.ts')).toContain('External/lib/tests/external.test.ts'); + }); + + it('recognizes shader compile tests under case-insensitive test directories', () => { + expect(affected('Shaders/shared.hlsli')).toContain('Support/Tests/Runtime/Shaders/CompileTest.hlsl'); + }); + + it('does not cross from one shader compilation root into another', () => { + expect(affected('Samples/Minimal/Shaders/Render.hlsl')).toEqual([]); + expect(affected('Samples/Full/Shaders/Bridge/RAB_Surface.hlsli')).toEqual([]); + }); + + it('maps a test bridge to its compile-test entry instead of the helper header itself', () => { + expect(affected('Support/Tests/Runtime/Shaders/Bridge/RAB_Surface.hlsli')).toEqual([ + 'Support/Tests/Runtime/Shaders/CompileTest.hlsl', + ]); + expect(affected('Support/Tests/Runtime/Shaders/RAB_PathTracer.hlsli')).toEqual([ + 'Support/Tests/Runtime/Shaders/CompileTest.hlsl', + ]); + }); + + it('does not classify shader helper headers as tests', () => { + expect(affected('Shaders/shared.hlsli')).not.toContain('Support/Tests/Runtime/Shaders/RAB_PathTracer.hlsli'); + }); +}); diff --git a/__tests__/build-script-extraction.test.ts b/__tests__/build-script-extraction.test.ts new file mode 100644 index 000000000..e2332f589 --- /dev/null +++ b/__tests__/build-script-extraction.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; +import { detectLanguage } from '../src/extraction/grammars'; +import { extractFromSource } from '../src/extraction/tree-sitter'; + +describe('build-script extraction', () => { + it('indexes CMake shader variants and function/variable declarations', () => { + expect(detectLanguage('CMakeLists.txt')).toBe('cmake'); + const result = extractFromSource('CMakeLists.txt', ` +function(add_pathtrace_variant name) + set(SHADER "shaders/pathtrace.rgen") +endfunction() +add_pathtrace_variant(default) +`); + expect(result.nodes.some((node) => node.kind === 'function' && node.name === 'add_pathtrace_variant')).toBe(true); + expect(result.nodes.some((node) => node.kind === 'variable' && node.name === 'SHADER')).toBe(true); + expect(result.unresolvedReferences.some((ref) => ref.referenceName === 'shaders/pathtrace.rgen')).toBe(true); + }); + + it('indexes PowerShell shader build helpers and shader paths', () => { + expect(detectLanguage('tools/CompileShaders.ps1')).toBe('powershell'); + const result = extractFromSource('tools/CompileShaders.ps1', ` +function Build-Shader { + & "glslc.exe" "shaders/pathtrace.rgen" -o "pathtrace.rgen.spv" +} +$ShaderRoot = "shaders" +`); + expect(result.nodes.some((node) => node.kind === 'function' && node.name === 'Build-Shader')).toBe(true); + expect(result.nodes.some((node) => node.kind === 'variable' && node.name === 'ShaderRoot')).toBe(true); + expect(result.unresolvedReferences.some((ref) => ref.referenceName === 'shaders/pathtrace.rgen')).toBe(true); + }); + + it('builds PowerShell call references, preserves declaration lines, and deduplicates assignments', () => { + const result = extractFromSource('run_game_stability_diagnostics.ps1', ` +\$ErrorActionPreference = 'Stop' +\$ErrorActionPreference = 'Stop' +function Resolve-SceneList { + return @() +} +function Invoke-Validation { + Resolve-SceneList +} +\$resolved = Resolve-SceneList +`); + const resolve = result.nodes.find((node) => node.kind === 'function' && node.name === 'Resolve-SceneList'); + expect(resolve?.startLine).toBe(4); + expect(result.nodes.filter((node) => node.kind === 'variable' && node.name === 'ErrorActionPreference')).toHaveLength(1); + expect(result.unresolvedReferences.some((ref) => + ref.fromNodeId === result.nodes.find((node) => node.name === 'Invoke-Validation')?.id && + ref.referenceKind === 'calls' && ref.referenceName === 'Resolve-SceneList' && ref.line === 8 + )).toBe(true); + expect(result.unresolvedReferences.some((ref) => + ref.fromNodeId === result.nodes.find((node) => node.kind === 'file')?.id && + ref.referenceName === 'Resolve-SceneList' && ref.line === 10 + )).toBe(true); + }); + + it('does not report exported functions as callers or executable launches as imports', () => { + const result = extractFromSource('scripts/EditorToolingCommon.psm1', ` +function Read-EditorJson { return @{} } +function Get-EntityCount { Read-EditorJson } +Export-ModuleMember -Function Read-EditorJson, Get-EntityCount +& "$BuildDir\\rtvulkan.exe" +`); + const file = result.nodes.find((node) => node.kind === 'file')!; + expect(result.unresolvedReferences.some((ref) => + ref.fromNodeId === file.id && ref.referenceKind === 'calls' && + (ref.referenceName === 'Read-EditorJson' || ref.referenceName === 'Get-EntityCount') + )).toBe(false); + expect(result.unresolvedReferences.some((ref) => ref.referenceKind === 'imports' && ref.referenceName.includes('rtvulkan.exe'))).toBe(false); + expect(result.unresolvedReferences.some((ref) => + ref.fromNodeId === result.nodes.find((node) => node.name === 'Get-EntityCount')?.id && + ref.referenceKind === 'calls' && ref.referenceName === 'Read-EditorJson' + )).toBe(true); + }); + + it('resolves Import-Module dependencies and calls to exported module functions', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-powershell-module-')); + fs.mkdirSync(path.join(dir, 'scripts')); + fs.writeFileSync(path.join(dir, 'scripts', 'EditorToolingCommon.psm1'), `function Read-EditorJson { param([string]\$Path) return @{} } +Export-ModuleMember -Function Read-EditorJson +`); + fs.writeFileSync(path.join(dir, 'scripts', 'audit.ps1'), `Import-Module (Join-Path \$PSScriptRoot 'EditorToolingCommon.psm1') -Force +function Get-Block { return '\\{' } +\$manifest = Read-EditorJson -Path 'manifest.json' +`); + const cg = CodeGraph.initSync(dir); + try { + await cg.indexAll(); + const moduleFile = cg.getNodesInFile('scripts/EditorToolingCommon.psm1').find((node) => node.kind === 'file')!; + const readJson = cg.getNodesInFile('scripts/EditorToolingCommon.psm1').find((node) => node.name === 'Read-EditorJson')!; + expect(cg.getIncomingEdges(moduleFile.id).some((edge) => edge.kind === 'imports')).toBe(true); + expect(cg.getCallers(readJson.id).some((caller) => caller.node.kind === 'file' && caller.node.filePath === 'scripts/audit.ps1' && caller.edge.line === 3)).toBe(true); + } finally { + cg.close(); + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it('resolves dot-sourced script dependencies and their shared functions', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-powershell-dot-source-')); + fs.mkdirSync(path.join(dir, 'scripts')); + fs.writeFileSync(path.join(dir, 'scripts', 'restir_reference_common.ps1'), `function Write-RestirJsonAndCsv { param([string]\$Path) } +function Invoke-PrivateRestirHelper { } +`); + fs.writeFileSync(path.join(dir, 'scripts', 'restir_reference_quality_matrix.ps1'), `. (Join-Path \$PSScriptRoot "restir_reference_common.ps1") +Write-RestirJsonAndCsv -Path 'quality.json' +Invoke-PrivateRestirHelper +`); + const cg = CodeGraph.initSync(dir); + try { + await cg.indexAll(); + const commonFile = cg.getNodesInFile('scripts/restir_reference_common.ps1').find((node) => node.kind === 'file')!; + const writer = cg.getNodesInFile('scripts/restir_reference_common.ps1').find((node) => node.name === 'Write-RestirJsonAndCsv')!; + const privateHelper = cg.getNodesInFile('scripts/restir_reference_common.ps1').find((node) => node.name === 'Invoke-PrivateRestirHelper')!; + expect(cg.getIncomingEdges(commonFile.id).some((edge) => edge.kind === 'imports')).toBe(true); + expect(cg.getCallers(writer.id).some((caller) => caller.node.filePath === 'scripts/restir_reference_quality_matrix.ps1' && caller.edge.line === 2)).toBe(true); + expect(cg.getCallers(privateHelper.id).some((caller) => caller.node.filePath === 'scripts/restir_reference_quality_matrix.ps1' && caller.edge.line === 3)).toBe(true); + } finally { + cg.close(); + fs.rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/__tests__/cpp-local-lambda.test.ts b/__tests__/cpp-local-lambda.test.ts new file mode 100644 index 000000000..72b578ebe --- /dev/null +++ b/__tests__/cpp-local-lambda.test.ts @@ -0,0 +1,83 @@ +import { afterEach, beforeAll, describe, expect, it } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; +import { initGrammars, loadGrammarsForLanguages } from '../src/extraction/grammars'; +import { extractFromSource } from '../src/extraction/tree-sitter'; + +beforeAll(async () => { + await initGrammars(); + await loadGrammarsForLanguages(['cpp']); +}); + +const tempDirs: string[] = []; +afterEach(() => { + for (const dir of tempDirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true }); +}); + +describe('C++ named local lambdas', () => { + it('indexes lambda-valued locals as scoped functions without indexing ordinary locals', () => { + const result = extractFromSource('renderer.cpp', `int helper() { return 1; } +void recordGraph() { + int ordinaryLocal = 0; + auto imageResource = [](int binding) { return helper() + binding; }; + auto bufferResource = [&](int binding) { return imageResource(binding); }; + bufferResource(3); +} +`); + const image = result.nodes.find((node) => node.kind === 'function' && node.name === 'imageResource'); + const buffer = result.nodes.find((node) => node.kind === 'function' && node.name === 'bufferResource'); + expect(image?.qualifiedName).toBe('recordGraph::imageResource'); + expect(buffer?.qualifiedName).toBe('recordGraph::bufferResource'); + expect(image?.startLine).toBe(4); + expect(result.nodes.some((node) => node.name === 'ordinaryLocal')).toBe(false); + expect(result.unresolvedReferences.some((ref) => ref.fromNodeId === image?.id && ref.referenceName === 'helper')).toBe(true); + expect(result.unresolvedReferences.some((ref) => ref.fromNodeId === buffer?.id && ref.referenceName === 'imageResource')).toBe(true); + }); + + it('resolves calls to the enclosing function lambda instead of an unrelated method', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-cpp-local-lambda-')); + tempDirs.push(dir); + fs.writeFileSync(path.join(dir, 'cache.h'), `class NativeGpuAssetCache { +public: + int bufferResource(); + bool empty(); +}; +`); + fs.writeFileSync(path.join(dir, 'cache.cpp'), `#include "cache.h" +int NativeGpuAssetCache::bufferResource() { return 0; } +bool NativeGpuAssetCache::empty() { return false; } +`); + fs.writeFileSync(path.join(dir, 'renderer.cpp'), `#include "cache.h" +#include +namespace rtv { +class PathTracerRenderer { public: void recordGraph(); private: std::string status_; }; +int helper() { return 1; } +void PathTracerRenderer::recordGraph() { + auto bufferResource = [](int binding) { return helper() + binding; }; + bufferResource(3); + status_.empty(); +} +} +`); + const cg = CodeGraph.initSync(dir); + try { + await cg.indexAll(); + const rendererNodes = cg.getNodesInFile('renderer.cpp'); + const lambda = rendererNodes.find((node) => node.kind === 'function' && node.name === 'bufferResource')!; + const helper = rendererNodes.find((node) => node.kind === 'function' && node.name === 'helper')!; + const cacheMethod = cg.getNodesInFile('cache.cpp').find((node) => node.kind === 'method' && node.name === 'bufferResource')!; + const unrelatedEmpty = cg.getNodesInFile('cache.cpp').find((node) => node.kind === 'method' && node.name === 'empty')!; + + expect(lambda.qualifiedName).toBe('rtv::PathTracerRenderer::recordGraph::bufferResource'); + expect(lambda.decorators).toContain('cpp:lambda'); + expect(cg.getCallers(lambda.id).some((caller) => caller.node.name === 'recordGraph' && caller.edge.line === 8)).toBe(true); + expect(cg.getCallees(lambda.id).some((callee) => callee.node.id === helper.id)).toBe(true); + expect(cg.getCallers(cacheMethod.id).some((caller) => caller.node.filePath === 'renderer.cpp')).toBe(false); + expect(cg.getCallers(unrelatedEmpty.id).some((caller) => caller.node.filePath === 'renderer.cpp')).toBe(false); + } finally { + cg.close(); + } + }); +}); diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts index 5c5dc6bdc..62df81084 100644 --- a/__tests__/extraction.test.ts +++ b/__tests__/extraction.test.ts @@ -69,6 +69,22 @@ describe('Language Detection', () => { expect(detectLanguage('class.hpp')).toBe('cpp'); }); + it('should detect GLSL, Vulkan shader stages, GLSLFX, and HLSL files', () => { + for (const file of ['shader.glsl', 'shader.vert', 'shader.frag', 'shader.comp', 'shader.geom', + 'shader.tesc', 'shader.tese', 'shader.rgen', 'shader.rmiss', 'shader.rchit', + 'shader.rahit', 'shader.rint', 'shader.rcall', 'shader.mesh', 'shader.task', 'shader.glslfx']) { + expect(detectLanguage(file)).toBe('glsl'); + expect(isSourceFile(file)).toBe(true); + } + for (const file of ['shader.hlsl', 'shader.hlsli', 'shader.fx', 'shader.fxh']) { + expect(detectLanguage(file)).toBe('hlsl'); + expect(isSourceFile(file)).toBe(true); + } + expect(isLanguageSupported('glsl')).toBe(true); + expect(isLanguageSupported('hlsl')).toBe(true); + expect(getSupportedLanguages()).toEqual(expect.arrayContaining(['glsl', 'hlsl'])); + }); + it('should detect C# files', () => { expect(detectLanguage('Program.cs')).toBe('csharp'); }); diff --git a/__tests__/same-name-disambiguation.test.ts b/__tests__/same-name-disambiguation.test.ts index 7c260c7c4..4b77c9ba6 100644 --- a/__tests__/same-name-disambiguation.test.ts +++ b/__tests__/same-name-disambiguation.test.ts @@ -15,6 +15,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; +import { execFileSync } from 'child_process'; import { CodeGraph } from '../src'; import { ToolHandler } from '../src/mcp/tools'; import { initGrammars, loadAllGrammars } from '../src/extraction/grammars'; @@ -22,6 +23,7 @@ import { initGrammars, loadAllGrammars } from '../src/extraction/grammars'; let tmpDir: string; let cg: CodeGraph; let handler: ToolHandler; +const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js'); const text = async (tool: string, args: Record): Promise => { const res = await handler.execute(tool, args); @@ -68,6 +70,24 @@ beforeAll(async () => { ); } + mk( + 'src/lambdas.cpp', + [ + 'int firstLeaf() { return 1; }', + 'int secondLeaf() { return 2; }', + 'void record() {', + ' {', + ' auto bufferResource = []() { return firstLeaf(); };', + ' bufferResource();', + ' }', + ' {', + ' auto bufferResource = []() { return secondLeaf(); };', + ' bufferResource();', + ' }', + '}', + ].join('\n') + ); + cg = CodeGraph.initSync(tmpDir); await cg.indexAll(); handler = new ToolHandler(cg); @@ -138,4 +158,66 @@ describe('same-named symbols across apps (#764)', () => { const out = await text('codegraph_callees', { symbol: 'list' }); expect(out).toContain('2 distinct definitions'); }); + + it('line narrows repeated same-name lambdas within one file and scope', async () => { + const lambdas = cg.getNodesByName('bufferResource'); + expect(lambdas.map((node) => node.startLine).sort((a, b) => a - b)).toEqual([5, 9]); + expect(new Set(lambdas.map((node) => node.qualifiedName)).size).toBe(1); + expect(lambdas.every((node) => node.decorators?.includes('cpp:lambda'))).toBe(true); + + const fileOnly = await text('codegraph_impact', { + symbol: 'bufferResource', + file: 'src/lambdas.cpp', + }); + expect(fileOnly).toContain('2 distinct definitions'); + expect(fileOnly).toContain('src/lambdas.cpp:5'); + expect(fileOnly).toContain('src/lambdas.cpp:9'); + + const callees = await text('codegraph_callees', { + symbol: 'bufferResource', + file: 'src/lambdas.cpp', + line: 5, + }); + expect(callees).toContain('firstLeaf'); + expect(callees).not.toContain('secondLeaf'); + + const impact = await text('codegraph_impact', { + symbol: 'bufferResource', + file: 'src/lambdas.cpp', + line: 5, + }); + expect(impact).toContain('bufferResource'); + expect(impact).not.toContain(':9'); + }); + + it('CLI impact accepts --line to select one same-file definition', () => { + const output = execFileSync(process.execPath, [ + BIN, + 'impact', + 'bufferResource', + '--file', + 'src/lambdas.cpp', + '--line', + '5', + '--path', + tmpDir, + '--json', + ], { + encoding: 'utf8', + env: { ...process.env, CODEGRAPH_WASM_RELAUNCHED: '1' }, + }); + const result = JSON.parse(output) as { line: number; affected: Array<{ name: string; startLine?: number }> }; + expect(result.line).toBe(5); + expect(result.affected).toContainEqual(expect.objectContaining({ name: 'bufferResource', startLine: 5 })); + expect(result.affected).not.toContainEqual(expect.objectContaining({ name: 'bufferResource', startLine: 9 })); + }); + + it('line misses are success-shaped and do not fall back to another definition', async () => { + const out = await text('codegraph_callers', { + symbol: 'bufferResource', + file: 'src/lambdas.cpp', + line: 99, + }); + expect(out).toContain('not found at file "src/lambdas.cpp" at line 99'); + }); }); diff --git a/__tests__/shader-support.test.ts b/__tests__/shader-support.test.ts new file mode 100644 index 000000000..800a74812 --- /dev/null +++ b/__tests__/shader-support.test.ts @@ -0,0 +1,848 @@ +import { afterEach, beforeAll, describe, expect, it } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; +import { extractFromSource } from '../src/extraction/tree-sitter'; +import { initGrammars, loadGrammarsForLanguages } from '../src/extraction/grammars'; +import { clearProjectConfigCache, loadShaderIncludeAliases, loadShaderIncludePaths } from '../src/project-config'; +import { ShaderResolver } from '../src/resolution/shader-resolver'; +import { shaderIntegrationEdges } from '../src/resolution/shader-synthesizer'; +import { ToolHandler } from '../src/mcp/tools'; +import type { Language, Node } from '../src/types'; +import type { ResolutionContext, UnresolvedRef } from '../src/resolution/types'; + +beforeAll(async () => { + await initGrammars(); + await loadGrammarsForLanguages(['glsl', 'hlsl']); +}); + +const tempDirs: string[] = []; +afterEach(() => { + clearProjectConfigCache(); + for (const dir of tempDirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true }); +}); + +function node(partial: Partial & Pick): Node { + return { + qualifiedName: partial.name, + startLine: 1, + endLine: 1, + startColumn: 0, + endColumn: 0, + updatedAt: 1, + ...partial, + } as Node; +} + +describe('shader extraction', () => { + it('extracts Vulkan GLSL resources, macros, structs, specialization constants, and calls', () => { + const source = `#version 460 +#include "common.glsl" +#define ENABLE_GI 1 +#define WRAP(x) shade(x) +struct Payload { vec3 color; float depth; }; +layout(set = 1, binding = 4) uniform accelerationStructureEXT scene; +layout(push_constant) uniform Push { uint frame; } pc; +layout(constant_id = 3) const int MODE = 0; +layout(location = 0) rayPayloadEXT Payload payload; +vec3 shade(vec3 value) { return value; } +void main() { payload.color = WRAP(shade(vec3(MODE))); } +`; + const result = extractFromSource('shaders/path.rgen', source); + expect(result.errors).toEqual([]); + expect(result.nodes.find((n) => n.kind === 'import' && n.name === 'common.glsl')).toBeDefined(); + expect(result.nodes.find((n) => n.kind === 'constant' && n.name === 'ENABLE_GI')).toBeDefined(); + expect(result.nodes.find((n) => n.kind === 'function' && n.name === 'WRAP')).toBeDefined(); + expect(result.nodes.find((n) => n.kind === 'struct' && n.name === 'Payload')).toBeDefined(); + expect(result.nodes.filter((n) => n.kind === 'field').map((n) => n.name)).toEqual(expect.arrayContaining(['color', 'depth', 'frame'])); + expect(result.nodes.find((n) => n.name === 'scene')?.decorators).toEqual(expect.arrayContaining(['set:1', 'binding:4', 'resource:acceleration-structure'])); + expect(result.nodes.find((n) => n.name === 'Push')?.decorators).toContain('push_constant'); + expect(result.nodes.find((n) => n.name === 'MODE')?.decorators).toContain('constant_id:3'); + expect(result.nodes.find((n) => n.name === 'main')?.decorators).toEqual(expect.arrayContaining(['entrypoint', 'shader:ray-generation'])); + expect(result.unresolvedReferences.some((ref) => ref.referenceKind === 'calls' && ref.referenceName === 'shade')).toBe(true); + const macro = result.nodes.find((n) => n.kind === 'function' && n.name === 'WRAP'); + expect(result.unresolvedReferences.some((ref) => ref.fromNodeId === macro?.id && ref.referenceName === 'shade')).toBe(true); + expect(result.unresolvedReferences.some((ref) => ref.referenceName === 'vec3')).toBe(false); + }); + + it('indexes every conditional branch with its normalized condition', () => { + const result = extractFromSource('shaders/branches.comp', `#if USE_PRIMARY +layout(binding = 0) uniform sampler2D primaryTexture; +#elif USE_FALLBACK +layout(binding = 0) uniform sampler2D fallbackTexture; +#else +layout(binding = 0) uniform sampler2D debugTexture; +#endif +`); + expect(result.nodes.find((n) => n.name === 'primaryTexture')?.decorators).toContain('pp:USE_PRIMARY'); + expect(result.nodes.find((n) => n.name === 'fallbackTexture')?.decorators).toContain('pp:!(USE_PRIMARY)&&(USE_FALLBACK)'); + expect(result.nodes.find((n) => n.name === 'debugTexture')?.decorators).toContain('pp:!(USE_PRIMARY||USE_FALLBACK)'); + }); + + it('extracts HLSL cbuffers, registers, semantics, entry points, and NRD macro resources', () => { + const source = `#define BINDING(x) [[vk::binding(x, 0)]] +#define DECL_RESOURCE_IMPL(name, slot) Texture2D name : register(t##slot); +#define DECL_RESOURCE(name, slot) DECL_RESOURCE_IMPL(name, slot) +BINDING(0) cbuffer Globals : register(b0) { float exposure; uint frame; } +BINDING(1) Texture2D inputTex : register(t1, space2); +RWTexture2D outputTex : register(u2); +DECL_RESOURCE(gHistory, 7) +NRD_INPUT(Texture2D, float4, gIn_Normal, t, 3) +NRD_OUTPUT(RWTexture2D, float4, gOut_Color, u, 4) +[numthreads(8, 8, 1)] +void main(uint3 id : SV_DispatchThreadID) { outputTex[id.xy] = inputTex.Load(int3(id.xy, 0)); } +`; + const result = extractFromSource('shaders/filter.cs.hlsl', source); + expect(result.errors).toEqual([]); + expect(result.nodes.find((n) => n.kind === 'struct' && n.name === 'Globals')?.decorators).toEqual(expect.arrayContaining(['register:b0', 'resource:constant-buffer'])); + expect(result.nodes.filter((n) => n.kind === 'field').map((n) => n.name)).toEqual(expect.arrayContaining(['exposure', 'frame'])); + expect(result.nodes.find((n) => n.name === 'inputTex')?.decorators).toEqual(expect.arrayContaining(['register:t1', 'space:2', 'resource:texture'])); + expect(result.nodes.find((n) => n.name === 'gHistory')?.decorators).toEqual(expect.arrayContaining(['register:t7', 'resource:texture'])); + expect(result.nodes.find((n) => n.name === 'gIn_Normal')?.decorators).toContain('register:t3'); + expect(result.nodes.find((n) => n.name === 'gOut_Color')?.decorators).toContain('register:u4'); + expect(result.nodes.find((n) => n.name === 'main')?.decorators).toEqual(expect.arrayContaining(['entrypoint', 'shader:compute', 'numthreads:8,8,1'])); + expect(result.nodes.find((n) => n.name === 'main')?.decorators).toContain('semantic:SV_DispatchThreadID'); + expect(result.unresolvedReferences.some((ref) => /(?:Load|int3)/.test(ref.referenceName))).toBe(false); + }); + + it('keeps calls to user-defined functions that shadow intrinsic names', () => { + const result = extractFromSource('shaders/shadow.frag', 'float clamp(float x){return x;}\nvoid main(){clamp(1.0);}\n'); + expect(result.unresolvedReferences.some((ref) => ref.referenceKind === 'calls' && ref.referenceName === 'clamp')).toBe(true); + }); + + it('records shader call arity so same-file overloads can be disambiguated', () => { + const result = extractFromSource('shaders/overloads.hlsl', ` +float pick(float a) { return a; } +float pick(float a, float b) { return a + b; } +float run() { return pick(1.0, 2.0); } +`); + expect(result.nodes.find((candidate) => candidate.name === 'pick')?.signature).toMatch(/^float pick\(float a\)$/); + const ref = result.unresolvedReferences.find((candidate) => candidate.referenceName === 'pick'); + expect(ref?.candidates).toContain('arity:2'); + }); + + it('keeps GLSL function names and parameter parentheses in signatures', () => { + const result = extractFromSource('shaders/overloads.glsl', ` +bool resolve(Item item, out uint index) { index = 0u; return true; } +`); + expect(result.nodes.find((candidate) => candidate.name === 'resolve')?.signature) + .toBe('bool resolve(Item item, out uint index)'); + }); + + it('records conservative HLSL argument types for overload resolution', () => { + const result = extractFromSource('shaders/checkerboard.hlsl', ` +void activate(inout uint2 pixel, bool previous, uint field) {} +void activate(inout int2 pixel, bool previous, uint field) {} +void run(uint2 pixel) { activate(pixel, false, 0); } +`); + const run = result.nodes.find((candidate) => candidate.name === 'run'); + const ref = result.unresolvedReferences.find((candidate) => candidate.fromNodeId === run?.id && candidate.referenceName === 'activate'); + expect(ref?.candidates).toEqual(expect.arrayContaining(['arity:3', 'argtype:0:uint2', 'argtype:1:bool'])); + }); + + it('recovers functions whose bodies are detached by GLSL preprocessor error recovery', () => { + const result = extractFromSource('shaders/pathtrace_integrator.glsl', `#ifndef RTV_PATHTRACE_INTEGRATOR_GLSL +#define RTV_PATHTRACE_INTEGRATOR_GLSL +#ifndef RTV_MATERIAL_RAY_CONE_LOD +#define RTV_MATERIAL_RAY_CONE_LOD 0 +// Main path integration routine. +vec3 trace_path(Ray ray, inout uint rng, uint pixelIndex, ivec2 coords, ivec2 dims, out bool did_hit, out float first_depth, out vec3 first_normal, out vec3 first_position, out PathComponents components) { + vec3 radiance = vec3(0.0); + renderer_debug_view(); +`); + const tracePath = result.nodes.find((candidate) => candidate.kind === 'function' && candidate.name === 'trace_path'); + expect(tracePath).toBeDefined(); + expect(tracePath!.endLine).toBeGreaterThan(tracePath!.startLine); + expect(tracePath!.decorators ?? []).not.toEqual(expect.arrayContaining(['interface:in', 'interface:out'])); + expect(result.unresolvedReferences.some((ref) => + ref.fromNodeId === tracePath!.id && ref.referenceKind === 'calls' && ref.referenceName === 'renderer_debug_view' + )).toBe(true); + expect(result.unresolvedReferences.filter((ref) => ref.referenceName === 'renderer_debug_view')).toHaveLength(1); + }); + + it('recovers complete signatures for detached declarations', () => { + const result = extractFromSource('shaders/pathtrace_ray_queries.glsl', ` +vec3 direct_shadow_transmittance_stats( + vec3 origin, vec3 direction, float tMax, float rayTime, + Material receiverMaterial, out uint transmissiveHits, + out uint visiblePath, out uint blockedPath) { + return caustic_shadow_transmittance_stats(origin, direction, tMax, rayTime, transmissiveHits, visiblePath, blockedPath); +} +`); + const fn = result.nodes.find((candidate) => candidate.name === 'direct_shadow_transmittance_stats'); + expect(fn?.signature).toContain('vec3 origin'); + expect(fn?.signature).toContain('Material receiverMaterial'); + expect(fn?.signature).not.toMatch(/^}/); + }); + + it('extracts OpenUSD GLSLFX sections, techniques, embedded symbols, and imports with original lines', () => { + const source = `-- glslfx version 0.1 +#import "$TOOLS/hdSt/shared.glslfx" +-- configuration +{"techniques":{"default":{"vertexShader":{"source":"Surface.Vertex"},"fragmentShader":{"source":"Surface.Fragment"}}}} +-- glsl Surface.Vertex +void vertexMain() {} +-- glsl Surface.Fragment +vec4 shade() { return vec4(1.0); } +`; + const result = extractFromSource('pxr/shaders/material.glslfx', source); + expect(result.errors).toEqual([]); + expect(result.nodes.find((n) => n.kind === 'file')?.decorators).toContain('glslfx:0.1'); + expect(result.nodes.filter((n) => n.kind === 'module').map((n) => n.name)).toEqual(['Surface.Vertex', 'Surface.Fragment']); + expect(result.nodes.find((n) => n.kind === 'component' && n.name === 'default')).toBeDefined(); + expect(result.nodes.find((n) => n.kind === 'function' && n.name === 'vertexMain')?.startLine).toBe(6); + expect(result.nodes.find((n) => n.kind === 'import')?.name).toBe('$TOOLS/hdSt/shared.glslfx'); + expect(result.edges.filter((edge) => edge.kind === 'references')).toHaveLength(2); + }); + + it('keeps useful symbols from partial shaders and leaves ambiguous GLSLFX sections unresolved', () => { + const partial = extractFromSource('shaders/partial.rchit', 'layout(set=0,binding=2) uniform accelerationStructureEXT scene;\nvoid main( {\n'); + expect(partial.nodes.find((node) => node.name === 'scene')?.decorators).toEqual(expect.arrayContaining(['set:0', 'binding:2'])); + + const glslfx = extractFromSource('pxr/shaders/ambiguous.glslfx', `-- glslfx version 0.2 +-- configuration +{"techniques":{"default":{"source":"Shared.Surface"}}} +-- glsl Shared.Surface +void first() {} +-- glsl Shared.Surface +void second() {} +`); + expect(glslfx.errors.some((error) => error.code === 'glslfx_version' && error.severity === 'warning')).toBe(true); + expect(glslfx.edges.filter((edge) => edge.kind === 'references')).toEqual([]); + }); +}); + +describe('shader exploration', () => { + it('expands an explicit shader through its transitive include closure when requested', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-shader-explore-')); + tempDirs.push(dir); + const shaders = path.join(dir, 'shaders'); + fs.mkdirSync(shaders); + fs.writeFileSync(path.join(shaders, 'root.comp'), `#version 460 +#include "common.glsl" +void main() { shade(); } +`); + fs.writeFileSync(path.join(shaders, 'common.glsl'), `#include "nested.glsl" +void shade() { helper(); } +`); + fs.writeFileSync(path.join(shaders, 'nested.glsl'), `void helper() {} +`); + const cg = CodeGraph.initSync(dir, { config: { include: ['shaders/**/*'], exclude: [] } }); + try { + await cg.indexAll(); + const result = await new ToolHandler(cg).execute('codegraph_explore', { + query: 'root.comp main and its included GLSL files', + maxFiles: 6, + }); + const text = result.content[0]!.text; + expect(text).toContain('root.comp'); + expect(text).toContain('common.glsl'); + expect(text).toContain('nested.glsl'); + expect(text).toContain('void shade()'); + expect(text).toContain('void helper()'); + } finally { + cg.destroy(); + } + }); + + it('treats a full shader path as exact instead of matching every same basename', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-shader-path-anchor-')); + tempDirs.push(dir); + fs.mkdirSync(path.join(dir, 'a'), { recursive: true }); + fs.mkdirSync(path.join(dir, 'b'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'a', 'bridge.hlsli'), 'float SelectedBridge(){ return 1.0; }\n'); + fs.writeFileSync(path.join(dir, 'b', 'bridge.hlsli'), 'float UnrelatedBridge(){ return 2.0; }\n'); + const cg = CodeGraph.initSync(dir); + try { + await cg.indexAll(); + const result = await new ToolHandler(cg).execute('codegraph_explore', { + query: 'a/bridge.hlsli SelectedBridge callers and impact', + maxFiles: 4, + }); + const text = result.content[0]!.text; + expect(text).toContain('a/bridge.hlsli'); + expect(text).toContain('SelectedBridge'); + expect(text).not.toContain('b/bridge.hlsli'); + expect(text).not.toContain('UnrelatedBridge'); + } finally { + cg.destroy(); + } + }); + + it('keeps explored bridge symbols inside the explicitly named shader translation unit', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-shader-context-explore-')); + tempDirs.push(dir); + for (const target of ['A', 'B']) { + fs.mkdirSync(path.join(dir, 'Targets', target, 'Bridge'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'Targets', target, 'main.hlsl'), '#include "../../Shared/algorithm.hlsli"\n#include "Bridge/bridge.hlsli"\nvoid main(){ sharedAlgorithm(); }\n'); + fs.writeFileSync(path.join(dir, 'Targets', target, 'Bridge', 'bridge.hlsli'), `float BridgeSurface(int2 p){ return ${target === 'A' ? '1.0' : '2.0'}; }\n`); + } + fs.mkdirSync(path.join(dir, 'Shared'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'Shared', 'algorithm.hlsli'), 'void sharedLeaf(){}\nvoid sharedAlgorithm(){ sharedLeaf(); BridgeSurface(int2(0, 0)); }\n'); + const cg = CodeGraph.initSync(dir); + try { + await cg.indexAll(); + const result = await new ToolHandler(cg).execute('codegraph_explore', { + query: 'Trace BridgeSurface from Targets/A/main.hlsl through Targets/A/Bridge/bridge.hlsli', + maxFiles: 8, + }); + const text = result.content[0]!.text; + expect(text).toContain('Targets/A/Bridge/bridge.hlsli'); + expect(text).toContain('Targets/A/main.hlsl'); + expect(text).not.toContain('Targets/B/Bridge/bridge.hlsli'); + expect(text).not.toContain('Targets/B/main.hlsl'); + } finally { + cg.destroy(); + } + }); + + it('shows direct shader call paths pinned by explicit file lines', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-shader-line-flow-')); + tempDirs.push(dir); + const shaders = path.join(dir, 'shaders'); + fs.mkdirSync(shaders); + fs.writeFileSync(path.join(shaders, 'common.glsl'), 'bool resolve_light(uint value){return true;}\nbool resolve_light(uint value,uint version){return true;}\n'); + fs.writeFileSync(path.join(shaders, 'a.comp'), '#include "common.glsl"\nvoid main(){\n resolve_light(1u, 2u);\n}\n'); + fs.writeFileSync(path.join(shaders, 'b.comp'), '#include "common.glsl"\nvoid evaluate(){\n resolve_light(3u, 4u);\n}\n'); + fs.writeFileSync(path.join(shaders, 'temporal.hlsli'), 'void RTXDI_DITemporalResampling(uint2 pixel){}\n'); + fs.writeFileSync(path.join(shaders, 'di.hlsl'), '#include "temporal.hlsli"\nvoid main(){\n RTXDI_DITemporalResampling(uint2(0, 0));\n}\n'); + const largeCalleeBody = Array.from({ length: 700 }, (_, index) => + ` value += ${index}u; // keep this endpoint larger than the explore output budget` + ).join('\n'); + fs.writeFileSync(path.join(shaders, 'huge_trace.glsl'), `void huge_trace(){\n uint value = 0u;\n${largeCalleeBody}\n}\n`); + fs.writeFileSync(path.join(shaders, 'huge.rgen'), '#include "huge_trace.glsl"\nvoid main(){ huge_trace(); }\n'); + const fillers = path.join(dir, 'fillers'); + fs.mkdirSync(fillers); + for (let index = 0; index < 500; index++) fs.writeFileSync(path.join(fillers, `f${index}.ts`), ''); + const cg = CodeGraph.initSync(dir); + try { + await cg.indexAll(); + const result = await new ToolHandler(cg).execute('codegraph_explore', { + query: 'trace resolve_light from shaders/a.comp line 3 and shaders/b.comp (line 3)', + maxFiles: 5, + }); + const text = result.content[0]!.text; + expect(text).toContain('Flow (call paths at the source locations you queried)'); + expect(text).toContain('@shaders/a.comp:3'); + expect(text).toContain('@shaders/b.comp:3'); + expect(text).toContain('**Relationships**'); + expect(text).toContain('- main → resolve_light'); + expect(text).toContain('- evaluate → resolve_light'); + expect(text).toContain('**`shaders/a.comp`**'); + expect(text).toContain('**`shaders/b.comp`**'); + + const namedResult = await new ToolHandler(cg).execute('codegraph_explore', { + query: 'main resolve_light from shaders/a.comp', + maxFiles: 5, + }); + const namedText = namedResult.content[0]!.text; + expect(namedText).toContain('**Relationships**'); + expect(namedText).toContain('- main → resolve_light'); + + const directNamedResult = await new ToolHandler(cg).execute('codegraph_explore', { + query: 'show main → resolve_light', + maxFiles: 5, + }); + const directNamedText = directNamedResult.content[0]!.text; + expect(directNamedText).toContain('Flow (direct call paths among the symbols you queried)'); + expect(directNamedText).toContain('- main → resolve_light'); + expect(directNamedText).toContain('**`shaders/a.comp`**'); + expect(directNamedText).toContain('**`shaders/common.glsl`**'); + + const hlslResult = await new ToolHandler(cg).execute('codegraph_explore', { + query: 'trace main to RTXDI_DITemporalResampling from shaders/di.hlsl line 3', + maxFiles: 5, + }); + const hlslText = hlslResult.content[0]!.text; + expect(hlslText).toContain('@shaders/di.hlsl:3'); + expect(hlslText).toContain('- main → RTXDI_DITemporalResampling'); + + const directHlslResult = await new ToolHandler(cg).execute('codegraph_explore', { + query: 'show main → RTXDI_DITemporalResampling', + maxFiles: 5, + }); + const directHlslText = directHlslResult.content[0]!.text; + expect(directHlslText).toContain('Flow (direct call paths among the symbols you queried)'); + expect(directHlslText).toContain('- main → RTXDI_DITemporalResampling'); + expect(directHlslText).toContain('**`shaders/di.hlsl`**'); + expect(directHlslText).toContain('**`shaders/temporal.hlsli`**'); + + const largeEndpointResult = await new ToolHandler(cg).execute('codegraph_explore', { + query: 'show main → huge_trace', + maxFiles: 5, + }); + const largeEndpointText = largeEndpointResult.content[0]!.text; + expect(largeEndpointText).toContain('- main → huge_trace'); + expect(largeEndpointText).toContain('**`shaders/huge.rgen`**'); + expect(largeEndpointText).toContain('**`shaders/huge_trace.glsl`**'); + } finally { + cg.destroy(); + } + }); +}); + +describe('shader config and include-scoped resolution', () => { + it('loads include roots and aliases from codegraph.json', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-shader-config-')); + tempDirs.push(dir); + fs.writeFileSync(path.join(dir, 'codegraph.json'), JSON.stringify({ + shaderIncludePaths: ['shaders/include', './vendor/shaders/'], + shaderIncludeAliases: { '$TOOLS': 'pxr/imaging' }, + })); + expect(loadShaderIncludePaths(dir)).toEqual(['shaders/include', 'vendor/shaders']); + expect(loadShaderIncludeAliases(dir)).toEqual({ '$TOOLS': 'pxr/imaging' }); + }); + + it('applies quoted, angle, root, and alias include rules without escaping the project', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-shader-includes-')); + tempDirs.push(dir); + fs.writeFileSync(path.join(dir, 'codegraph.json'), JSON.stringify({ + shaderIncludePaths: ['shared'], + shaderIncludeAliases: { '$TOOLS': 'vendor/tools' }, + })); + const files = new Map([ + ['shaders/main.comp', '#include "common.glsl"\n#include \n#include "$TOOLS/lib.glsl"\n#include "../../outside.glsl"\nvoid main(){}'], + ['shaders/common.glsl', 'float localHelper(){}'], + ['shared/common.glsl', 'float wrongHelper(){}'], + ['shared/root.glsl', 'float rootHelper(){}'], + ['vendor/tools/lib.glsl', 'float aliasHelper(){}'], + ['../outside.glsl', 'float outsideHelper(){}'], + ]); + const nodes = new Map(); + for (const file of files.keys()) nodes.set(file, [node({ id: `file:${file}`, kind: 'file', name: path.basename(file), filePath: file, language: 'glsl' })]); + const context = fakeContext(files, nodes, [...nodes.values()].flat(), dir); + const closure = new ShaderResolver(context).getClosure('shaders/main.comp', 'glsl'); + expect([...closure.keys()]).toEqual(expect.arrayContaining(['shaders/main.comp', 'shaders/common.glsl', 'shared/root.glsl', 'vendor/tools/lib.glsl'])); + expect(closure.has('shared/common.glsl')).toBe(false); + expect(closure.has('../outside.glsl')).toBe(false); + }); + + it('resolves a compiler-root include by unique project suffix but leaves duplicate bridge suffixes ambiguous', () => { + const files = new Map([ + ['Tests/Compile.hlsl', '#include \n#include '], + ['Libraries/Rtxdi/Include/Rtxdi/DI/Reservoir.hlsli', 'void finalize(){}'], + ['Samples/A/Bridge/RAB_Surface.hlsli', 'void surface(){}'], + ['Samples/B/Bridge/RAB_Surface.hlsli', 'void surface(){}'], + ]); + const nodes = new Map(); + for (const file of files.keys()) nodes.set(file, [node({ id: `file:${file}`, kind: 'file', name: path.basename(file), filePath: file, language: 'hlsl' })]); + const closure = new ShaderResolver(fakeContext(files, nodes, [...nodes.values()].flat(), process.cwd())) + .getClosure('Tests/Compile.hlsl', 'hlsl'); + expect(closure.has('Libraries/Rtxdi/Include/Rtxdi/DI/Reservoir.hlsli')).toBe(true); + expect(closure.has('Samples/A/Bridge/RAB_Surface.hlsli')).toBe(false); + expect(closure.has('Samples/B/Bridge/RAB_Surface.hlsli')).toBe(false); + }); + + it('resolves calls only through the transitive include closure and drops equal-depth ambiguity', () => { + const files = new Map([ + ['shaders/main.rgen', '#include "a.glsl"\nvoid main(){ helper(); }'], + ['shaders/a.glsl', '#include "nested/b.glsl"'], + ['shaders/nested/b.glsl', 'void helper(){}'], + ['other/helper.glsl', 'void helper(){}'], + ]); + const nodes = new Map([ + ['shaders/main.rgen', [node({ id: 'f-main', kind: 'file', name: 'main.rgen', filePath: 'shaders/main.rgen', language: 'glsl' }), node({ id: 'main', kind: 'function', name: 'main', filePath: 'shaders/main.rgen', language: 'glsl' })]], + ['shaders/a.glsl', [node({ id: 'f-a', kind: 'file', name: 'a.glsl', filePath: 'shaders/a.glsl', language: 'glsl' })]], + ['shaders/nested/b.glsl', [node({ id: 'f-b', kind: 'file', name: 'b.glsl', filePath: 'shaders/nested/b.glsl', language: 'glsl' }), node({ id: 'included-helper', kind: 'function', name: 'helper', filePath: 'shaders/nested/b.glsl', language: 'glsl' })]], + ['other/helper.glsl', [node({ id: 'f-other', kind: 'file', name: 'helper.glsl', filePath: 'other/helper.glsl', language: 'glsl' }), node({ id: 'global-helper', kind: 'function', name: 'helper', filePath: 'other/helper.glsl', language: 'glsl' })]], + ]); + const allNodes = [...nodes.values()].flat(); + const context = fakeContext(files, nodes, allNodes, process.cwd()); + const resolver = new ShaderResolver(context); + const ref: UnresolvedRef = { fromNodeId: 'main', referenceName: 'helper', referenceKind: 'calls', line: 2, column: 0, filePath: 'shaders/main.rgen', language: 'glsl' }; + expect(resolver.resolve(ref)?.targetNodeId).toBe('included-helper'); + + files.set('shaders/c.glsl', 'void helper(){}'); + files.set('shaders/a.glsl', 'void helper(){}'); + files.set('shaders/main.rgen', '#include "a.glsl"\n#include "c.glsl"\nvoid main(){ helper(); }'); + nodes.set('shaders/a.glsl', [node({ id: 'f-a', kind: 'file', name: 'a.glsl', filePath: 'shaders/a.glsl', language: 'glsl' }), node({ id: 'a-helper', kind: 'function', name: 'helper', filePath: 'shaders/a.glsl', language: 'glsl' })]); + nodes.set('shaders/c.glsl', [node({ id: 'f-c', kind: 'file', name: 'c.glsl', filePath: 'shaders/c.glsl', language: 'glsl' }), node({ id: 'c-helper', kind: 'function', name: 'helper', filePath: 'shaders/c.glsl', language: 'glsl' })]); + const ambiguousContext = fakeContext(files, nodes, [...nodes.values()].flat(), process.cwd()); + expect(new ShaderResolver(ambiguousContext).resolve(ref)).toBeNull(); + }); + + it('resolves equivalent injected shader declarations when no textual include edge exists', () => { + const files = new Map([ + ['shaders/use.glsl', 'float use(inout uint state) { return rand_f32(state); }'], + ['shaders/a.glsl', 'float rand_f32(inout uint seed) { return 0.0; }'], + ['shaders/b.glsl', 'float rand_f32(inout uint state) { return 1.0; }'], + ]); + const nodes = new Map([ + ['shaders/use.glsl', [node({ id: 'use-file', kind: 'file', name: 'use.glsl', filePath: 'shaders/use.glsl', language: 'glsl' })]], + ['shaders/a.glsl', [node({ id: 'a-file', kind: 'file', name: 'a.glsl', filePath: 'shaders/a.glsl', language: 'glsl' }), node({ id: 'rand-a', kind: 'function', name: 'rand_f32', filePath: 'shaders/a.glsl', language: 'glsl', signature: 'float inout uint seed' })]], + ['shaders/b.glsl', [node({ id: 'b-file', kind: 'file', name: 'b.glsl', filePath: 'shaders/b.glsl', language: 'glsl' }), node({ id: 'rand-b', kind: 'function', name: 'rand_f32', filePath: 'shaders/b.glsl', language: 'glsl', signature: 'float inout uint state' })]], + ]); + const ref: UnresolvedRef = { fromNodeId: 'use-fn', referenceName: 'rand_f32', referenceKind: 'calls', line: 1, column: 0, filePath: 'shaders/use.glsl', language: 'glsl' }; + const resolved = new ShaderResolver(fakeContext(files, nodes, [...nodes.values()].flat(), process.cwd())).resolve(ref); + expect(resolved?.targetNodeId).toBe('rand-a'); + }); + + it('resolves sibling includes through their shared shader translation unit', () => { + const files = new Map([ + ['shaders/path.rgen', '#include "rt_common.glsl"\n#include "pathtrace_integrator.glsl"\nvoid main(){}'], + ['shaders/rt_common.glsl', 'uint renderer_debug_view(){ return 0u; }'], + ['shaders/pathtrace_integrator.glsl', 'vec3 trace_path(){ return vec3(renderer_debug_view()); }'], + ['other/rt_common.glsl', 'uint renderer_debug_view(){ return 1u; }'], + ]); + const nodes = new Map([ + ['shaders/path.rgen', [node({ id: 'root-file', kind: 'file', name: 'path.rgen', filePath: 'shaders/path.rgen', language: 'glsl' })]], + ['shaders/rt_common.glsl', [node({ id: 'common-file', kind: 'file', name: 'rt_common.glsl', filePath: 'shaders/rt_common.glsl', language: 'glsl' }), node({ id: 'debug-view', kind: 'function', name: 'renderer_debug_view', filePath: 'shaders/rt_common.glsl', language: 'glsl' })]], + ['shaders/pathtrace_integrator.glsl', [node({ id: 'integrator-file', kind: 'file', name: 'pathtrace_integrator.glsl', filePath: 'shaders/pathtrace_integrator.glsl', language: 'glsl' }), node({ id: 'trace-path', kind: 'function', name: 'trace_path', filePath: 'shaders/pathtrace_integrator.glsl', language: 'glsl' })]], + ['other/rt_common.glsl', [node({ id: 'other-file', kind: 'file', name: 'rt_common.glsl', filePath: 'other/rt_common.glsl', language: 'glsl' }), node({ id: 'other-debug-view', kind: 'function', name: 'renderer_debug_view', filePath: 'other/rt_common.glsl', language: 'glsl' })]], + ]); + const allNodes = [...nodes.values()].flat(); + const ref: UnresolvedRef = { fromNodeId: 'trace-path', referenceName: 'renderer_debug_view', referenceKind: 'calls', line: 1, column: 33, filePath: 'shaders/pathtrace_integrator.glsl', language: 'glsl' }; + expect(new ShaderResolver(fakeContext(files, nodes, allNodes, process.cwd())).resolve(ref)?.targetNodeId).toBe('debug-view'); + + files.set('shaders/path-alt.rgen', '#include "../other/rt_common.glsl"\n#include "pathtrace_integrator.glsl"\nvoid main(){}'); + nodes.set('shaders/path-alt.rgen', [node({ id: 'alt-root-file', kind: 'file', name: 'path-alt.rgen', filePath: 'shaders/path-alt.rgen', language: 'glsl' })]); + const ambiguousNodes = [...nodes.values()].flat(); + expect(new ShaderResolver(fakeContext(files, nodes, ambiguousNodes, process.cwd())).resolve(ref)).toBeNull(); + }); + + it('selects the HLSL overload reachable through a compiler-root include suffix', () => { + const files = new Map([ + ['Tests/DiCompile.hlsl', '#include \n#include '], + ['Libraries/Rtxdi/Include/Rtxdi/DI/Reservoir.hlsli', 'void RTXDI_FinalizeResampling(inout DIReservoir r, float n, float d){}'], + ['Libraries/Rtxdi/Include/Rtxdi/DI/TemporalResampling.hlsli', 'void run(){ RTXDI_FinalizeResampling(r, 1, 1); }'], + ['Libraries/Rtxdi/Include/Rtxdi/PT/Reservoir.hlsli', 'void RTXDI_FinalizeResampling(inout PTReservoir r, float n, float d){}'], + ]); + const nodes = new Map([ + ['Tests/DiCompile.hlsl', [node({ id: 'root', kind: 'file', name: 'DiCompile.hlsl', filePath: 'Tests/DiCompile.hlsl', language: 'hlsl' })]], + ['Libraries/Rtxdi/Include/Rtxdi/DI/Reservoir.hlsli', [node({ id: 'di-file', kind: 'file', name: 'Reservoir.hlsli', filePath: 'Libraries/Rtxdi/Include/Rtxdi/DI/Reservoir.hlsli', language: 'hlsl' }), node({ id: 'di-finalize', kind: 'function', name: 'RTXDI_FinalizeResampling', filePath: 'Libraries/Rtxdi/Include/Rtxdi/DI/Reservoir.hlsli', language: 'hlsl', signature: 'void (inout DIReservoir r, float n, float d)' })]], + ['Libraries/Rtxdi/Include/Rtxdi/DI/TemporalResampling.hlsli', [node({ id: 'temporal-file', kind: 'file', name: 'TemporalResampling.hlsli', filePath: 'Libraries/Rtxdi/Include/Rtxdi/DI/TemporalResampling.hlsli', language: 'hlsl' }), node({ id: 'run', kind: 'function', name: 'run', filePath: 'Libraries/Rtxdi/Include/Rtxdi/DI/TemporalResampling.hlsli', language: 'hlsl' })]], + ['Libraries/Rtxdi/Include/Rtxdi/PT/Reservoir.hlsli', [node({ id: 'pt-file', kind: 'file', name: 'Reservoir.hlsli', filePath: 'Libraries/Rtxdi/Include/Rtxdi/PT/Reservoir.hlsli', language: 'hlsl' }), node({ id: 'pt-finalize', kind: 'function', name: 'RTXDI_FinalizeResampling', filePath: 'Libraries/Rtxdi/Include/Rtxdi/PT/Reservoir.hlsli', language: 'hlsl', signature: 'void (inout PTReservoir r, float n, float d)' })]], + ]); + const ref: UnresolvedRef = { fromNodeId: 'run', referenceName: 'RTXDI_FinalizeResampling', referenceKind: 'calls', line: 1, column: 12, filePath: 'Libraries/Rtxdi/Include/Rtxdi/DI/TemporalResampling.hlsli', language: 'hlsl' }; + expect(new ShaderResolver(fakeContext(files, nodes, [...nodes.values()].flat(), process.cwd())).resolve(ref)?.targetNodeId).toBe('di-finalize'); + }); + + it('selects a same-file HLSL overload by call arity', () => { + const files = new Map([['shaders/use.hlsl', 'float load(float a){}\nfloat load(float a,float b){}\nfloat run(){return load(1,2);}']]); + const nodes = new Map([['shaders/use.hlsl', [ + node({ id: 'file', kind: 'file', name: 'use.hlsl', filePath: 'shaders/use.hlsl', language: 'hlsl' }), + node({ id: 'one', kind: 'function', name: 'load', filePath: 'shaders/use.hlsl', language: 'hlsl', signature: 'float (float a)' }), + node({ id: 'two', kind: 'function', name: 'load', filePath: 'shaders/use.hlsl', language: 'hlsl', signature: 'float (float a, float b)' }), + node({ id: 'run', kind: 'function', name: 'run', filePath: 'shaders/use.hlsl', language: 'hlsl' }), + ]]]); + const ref: UnresolvedRef = { fromNodeId: 'run', referenceName: 'load', referenceKind: 'calls', line: 3, column: 19, filePath: 'shaders/use.hlsl', language: 'hlsl', candidates: ['arity:2'] }; + expect(new ShaderResolver(fakeContext(files, nodes, [...nodes.values()].flat(), process.cwd())).resolve(ref)?.targetNodeId).toBe('two'); + }); + + it('selects same-arity HLSL overloads using exact argument-type evidence', () => { + const files = new Map([['shaders/use.hlsl', 'void activate(inout uint2 p,bool prev,uint field){}\nvoid activate(inout int2 p,bool prev,uint field){}\nvoid run(uint2 p){activate(p,false,0);}\n']]); + const nodes = new Map([['shaders/use.hlsl', [ + node({ id: 'file', kind: 'file', name: 'use.hlsl', filePath: 'shaders/use.hlsl', language: 'hlsl' }), + node({ id: 'uint', kind: 'function', name: 'activate', filePath: 'shaders/use.hlsl', language: 'hlsl', signature: 'void (inout uint2 p, bool prev, uint field)' }), + node({ id: 'int', kind: 'function', name: 'activate', filePath: 'shaders/use.hlsl', language: 'hlsl', signature: 'void (inout int2 p, bool prev, uint field)' }), + node({ id: 'run', kind: 'function', name: 'run', filePath: 'shaders/use.hlsl', language: 'hlsl' }), + ]]]); + const ref: UnresolvedRef = { fromNodeId: 'run', referenceName: 'activate', referenceKind: 'calls', line: 3, column: 18, filePath: 'shaders/use.hlsl', language: 'hlsl', candidates: ['arity:3', 'argtype:0:uint2'] }; + expect(new ShaderResolver(fakeContext(files, nodes, [...nodes.values()].flat(), process.cwd())).resolve(ref)?.targetNodeId).toBe('uint'); + }); + + it('unions same-name shader implementations from mutually exclusive preprocessor branches', () => { + const files = new Map([ + ['shaders/main.comp', '#include "common.glsl"\nvoid main(){ recordCounter(1u); }'], + ['shaders/common.glsl', '#if DIAGNOSTICS\nvoid recordCounter(uint index){}\n#else\n#define recordCounter(index)\n#endif\n'], + ]); + const nodes = new Map([ + ['shaders/main.comp', [ + node({ id: 'main-file', kind: 'file', name: 'main.comp', filePath: 'shaders/main.comp', language: 'glsl' }), + node({ id: 'main', kind: 'function', name: 'main', filePath: 'shaders/main.comp', language: 'glsl', startLine: 2 }), + ]], + ['shaders/common.glsl', [ + node({ id: 'common-file', kind: 'file', name: 'common.glsl', filePath: 'shaders/common.glsl', language: 'glsl' }), + node({ id: 'enabled', kind: 'function', name: 'recordCounter', filePath: 'shaders/common.glsl', language: 'glsl', startLine: 2, signature: 'void recordCounter(uint index)', decorators: ['pp:DIAGNOSTICS'] }), + node({ id: 'disabled', kind: 'function', name: 'recordCounter', filePath: 'shaders/common.glsl', language: 'glsl', startLine: 4, signature: '#define recordCounter(index)', decorators: ['pp:!(DIAGNOSTICS)'] }), + ]], + ]); + const ref: UnresolvedRef = { fromNodeId: 'main', referenceName: 'recordCounter', referenceKind: 'calls', line: 2, column: 13, filePath: 'shaders/main.comp', language: 'glsl', candidates: ['arity:1'] }; + const resolver = new ShaderResolver(fakeContext(files, nodes, [...nodes.values()].flat(), process.cwd())); + expect(resolver.resolve(ref)).toBeNull(); + expect(resolver.getConditionalCallTargets(ref).map((target) => target.id)).toEqual(['enabled', 'disabled']); + }); + + it('does not union overloads from independent shader conditionals', () => { + const files = new Map([ + ['shaders/main.comp', '#include "common.glsl"\nvoid main(){ choose(value); }'], + ['shaders/common.glsl', '#if FEATURE_A\nvoid choose(uint value){}\n#endif\n#if FEATURE_B\nvoid choose(float value){}\n#endif\n'], + ]); + const nodes = new Map([ + ['shaders/main.comp', [node({ id: 'main-file', kind: 'file', name: 'main.comp', filePath: 'shaders/main.comp', language: 'glsl' }), node({ id: 'main', kind: 'function', name: 'main', filePath: 'shaders/main.comp', language: 'glsl', startLine: 2 })]], + ['shaders/common.glsl', [ + node({ id: 'common-file', kind: 'file', name: 'common.glsl', filePath: 'shaders/common.glsl', language: 'glsl' }), + node({ id: 'a', kind: 'function', name: 'choose', filePath: 'shaders/common.glsl', language: 'glsl', startLine: 2, signature: 'void choose(uint value)', decorators: ['pp:FEATURE_A'] }), + node({ id: 'b', kind: 'function', name: 'choose', filePath: 'shaders/common.glsl', language: 'glsl', startLine: 5, signature: 'void choose(float value)', decorators: ['pp:FEATURE_B'] }), + ]], + ]); + const ref: UnresolvedRef = { fromNodeId: 'main', referenceName: 'choose', referenceKind: 'calls', line: 2, column: 13, filePath: 'shaders/main.comp', language: 'glsl', candidates: ['arity:1'] }; + expect(new ShaderResolver(fakeContext(files, nodes, [...nodes.values()].flat(), process.cwd())).getConditionalCallTargets(ref)).toEqual([]); + }); + + it('returns every equivalent application-bridge target for a shared HLSL call site', () => { + const files = new Map([ + ['Samples/A/main.hlsl', '#include "../../Shared/Algorithm.hlsli"\n#include "Bridge/RAB_Surface.hlsli"'], + ['Samples/B/main.hlsl', '#include "../../Shared/Algorithm.hlsli"\n#include "Bridge/RAB_Surface.hlsli"'], + ['Shared/Algorithm.hlsli', 'void run(){ RAB_GetGBufferSurface(0, false); }'], + ['Samples/A/Bridge/RAB_Surface.hlsli', 'Surface RAB_GetGBufferSurface(int2 p, bool prev){return 0;}'], + ['Samples/B/Bridge/RAB_Surface.hlsli', 'Surface RAB_GetGBufferSurface(int2 p, bool prev){return 0;}'], + ]); + const nodes = new Map([ + ['Samples/A/main.hlsl', [node({ id: 'a-root', kind: 'file', name: 'main.hlsl', filePath: 'Samples/A/main.hlsl', language: 'hlsl' })]], + ['Samples/B/main.hlsl', [node({ id: 'b-root', kind: 'file', name: 'main.hlsl', filePath: 'Samples/B/main.hlsl', language: 'hlsl' })]], + ['Shared/Algorithm.hlsli', [node({ id: 'shared-file', kind: 'file', name: 'Algorithm.hlsli', filePath: 'Shared/Algorithm.hlsli', language: 'hlsl' }), node({ id: 'run', kind: 'function', name: 'run', filePath: 'Shared/Algorithm.hlsli', language: 'hlsl' })]], + ['Samples/A/Bridge/RAB_Surface.hlsli', [node({ id: 'a-file', kind: 'file', name: 'RAB_Surface.hlsli', filePath: 'Samples/A/Bridge/RAB_Surface.hlsli', language: 'hlsl' }), node({ id: 'a-surface', kind: 'function', name: 'RAB_GetGBufferSurface', filePath: 'Samples/A/Bridge/RAB_Surface.hlsli', language: 'hlsl', signature: 'Surface (int2 p, bool prev)' })]], + ['Samples/B/Bridge/RAB_Surface.hlsli', [node({ id: 'b-file', kind: 'file', name: 'RAB_Surface.hlsli', filePath: 'Samples/B/Bridge/RAB_Surface.hlsli', language: 'hlsl' }), node({ id: 'b-surface', kind: 'function', name: 'RAB_GetGBufferSurface', filePath: 'Samples/B/Bridge/RAB_Surface.hlsli', language: 'hlsl', signature: 'Surface (int2, bool previousFrame)' })]], + ]); + const ref: UnresolvedRef = { fromNodeId: 'run', referenceName: 'RAB_GetGBufferSurface', referenceKind: 'calls', line: 1, column: 12, filePath: 'Shared/Algorithm.hlsli', language: 'hlsl' }; + const resolver = new ShaderResolver(fakeContext(files, nodes, [...nodes.values()].flat(), process.cwd())); + expect(resolver.resolve(ref)).toBeNull(); + expect(resolver.getContextualCallTargets(ref).map((target) => target.id)).toEqual(['a-surface', 'b-surface']); + expect(resolver.getContextualCallTargetContexts(ref).map((target) => [target.node.id, target.contextRoots])).toEqual([ + ['a-surface', ['Samples/A/main.hlsl']], + ['b-surface', ['Samples/B/main.hlsl']], + ]); + }); + + it('keeps shader impact inside the selected translation-unit context', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-shader-context-impact-')); + + tempDirs.push(dir); + for (const target of ['A', 'B']) { + fs.mkdirSync(path.join(dir, 'Targets', target, 'Bridge'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'Targets', target, 'main.hlsl'), '#include "../../Shared/algorithm.hlsli"\n#include "Bridge/bridge.hlsli"\nvoid main(){ sharedAlgorithm(); }\n'); + fs.writeFileSync(path.join(dir, 'Targets', target, 'Bridge', 'bridge.hlsli'), 'float BridgeSurface(int2 p){ return 1.0; }\n'); + } + fs.mkdirSync(path.join(dir, 'Shared'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'Shared', 'algorithm.hlsli'), 'void sharedLeaf(){}\nvoid sharedAlgorithm(){ sharedLeaf(); BridgeSurface(int2(0, 0)); }\n'); + const cg = CodeGraph.initSync(dir); + try { + await cg.indexAll(); + const bridge = cg.getNodesInFile('Targets/A/Bridge/bridge.hlsli').find((candidate) => candidate.name === 'BridgeSurface'); + expect(bridge).toBeDefined(); + const impact = cg.getImpactRadius(bridge!.id, 6); + const files = new Set([...impact.nodes.values()].map((candidate) => candidate.filePath)); + expect(files).toContain('Targets/A/main.hlsl'); + expect(files).not.toContain('Targets/B/main.hlsl'); + expect([...impact.nodes.values()].some((candidate) => candidate.filePath === 'Targets/A/main.hlsl' && candidate.kind === 'import' && candidate.startLine === 2)).toBe(true); + + const leaf = cg.getNodesInFile('Shared/algorithm.hlsli').find((candidate) => candidate.name === 'sharedLeaf'); + expect(leaf).toBeDefined(); + const sharedImpact = cg.getImpactRadius(leaf!.id, 2); + expect([...sharedImpact.nodes.values()].some((candidate) => candidate.filePath === 'Targets/A/main.hlsl' && candidate.kind === 'import' && candidate.startLine === 1)).toBe(true); + expect([...sharedImpact.nodes.values()].some((candidate) => candidate.filePath === 'Targets/B/main.hlsl' && candidate.kind === 'import' && candidate.startLine === 1)).toBe(true); + expect([...sharedImpact.nodes.values()].filter((candidate) => candidate.filePath.endsWith('/main.hlsl') && candidate.name === 'main')).toHaveLength(2); + expect(sharedImpact.edges.filter((edge) => edge.metadata?.synthesizedBy === 'shader-context-entry').map((edge) => edge.line)).toEqual([3, 3]); + } finally { + cg.destroy(); + } + }); + + it('persists include-scoped HLSL overload edges selected by call arity', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-hlsl-overload-')); + tempDirs.push(dir); + const shaders = path.join(dir, 'shaders'); + fs.mkdirSync(shaders); + fs.writeFileSync(path.join(shaders, 'main.hlsl'), '#include "library.hlsli"\nfloat run(){return load(1.0, 2.0);}\n'); + fs.writeFileSync(path.join(shaders, 'library.hlsli'), 'float load(float a){return a;}\nfloat load(float a,float b){return a+b;}\n'); + const cg = CodeGraph.initSync(dir); + try { + await cg.indexAll(); + const twoArg = cg.getNodesInFile('shaders/library.hlsli').find((candidate) => candidate.name === 'load' && candidate.startLine === 2); + expect(twoArg).toBeDefined(); + expect(cg.getCallers(twoArg!.id).map((caller) => caller.node.name)).toContain('run'); + } finally { + cg.close(); + } + }); + + it('persists include-scoped GLSL overload edges selected by call arity', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-glsl-overload-')); + tempDirs.push(dir); + const shaders = path.join(dir, 'shaders'); + fs.mkdirSync(shaders); + fs.writeFileSync(path.join(shaders, 'main.comp'), '#include "library.glsl"\nvoid main(){uint index; Item item; resolve(item, index, 1u, 2u);}\n'); + fs.writeFileSync(path.join(shaders, 'library.glsl'), 'bool resolve(Item item,out uint index){return true;}\nbool resolve(Item item,out uint index,uint version,uint environment){return true;}\n'); + const cg = CodeGraph.initSync(dir); + try { + await cg.indexAll(); + const fourArg = cg.getNodesInFile('shaders/library.glsl').find((candidate) => candidate.name === 'resolve' && candidate.startLine === 2); + expect(fourArg?.signature).toBe('bool resolve(Item item,out uint index,uint version,uint environment)'); + expect(cg.getCallers(fourArg!.id).map((caller) => caller.node.name)).toContain('main'); + } finally { + cg.close(); + } + }); + + it('persists calls to every mutually exclusive function/macro shader variant', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-glsl-conditional-call-')); + tempDirs.push(dir); + const shaders = path.join(dir, 'shaders'); + fs.mkdirSync(shaders); + fs.writeFileSync(path.join(shaders, 'main.comp'), '#include "common.glsl"\nvoid main(){\n recordCounter(1u);\n recordCounter(2u);\n}\n'); + fs.writeFileSync(path.join(shaders, 'common.glsl'), '#if DIAGNOSTICS\nvoid recordCounter(uint index){}\n#else\n#define recordCounter(index)\n#endif\n'); + const cg = CodeGraph.initSync(dir); + try { + await cg.indexAll(); + const main = cg.getNodesInFile('shaders/main.comp').find((candidate) => candidate.name === 'main'); + const variants = cg.getNodesInFile('shaders/common.glsl').filter((candidate) => candidate.name === 'recordCounter'); + expect(variants).toHaveLength(2); + for (const variant of variants) { + expect(cg.getCallers(variant.id).map((caller) => caller.node.name)).toContain('main'); + } + const variantIds = new Set(variants.map((variant) => variant.id)); + expect(cg.getOutgoingEdges(main!.id).filter((edge) => edge.kind === 'calls' && variantIds.has(edge.target))).toHaveLength(4); + } finally { + cg.close(); + } + }); + + it('re-resolves shader include closures after incremental sync', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-shader-sync-')); + tempDirs.push(dir); + const shaders = path.join(dir, 'shaders'); + fs.mkdirSync(shaders); + fs.writeFileSync(path.join(shaders, 'main.comp'), '#include "common.glsl"\nvoid main() { helper(); }\n'); + fs.writeFileSync(path.join(shaders, 'common.glsl'), 'float oldLeaf(){return 1.0;}\nfloat newLeaf(){return 2.0;}\nfloat helper(){return oldLeaf();}\n'); + const cg = CodeGraph.initSync(dir); + try { + await cg.indexAll(); + let main = cg.getNodesInFile('shaders/main.comp').find((candidate) => candidate.name === 'main'); + let helper = cg.getNodesInFile('shaders/common.glsl').find((candidate) => candidate.name === 'helper'); + expect(cg.getCallees(main!.id).map((callee) => callee.node.name)).toContain('helper'); + expect(cg.getCallees(helper!.id).map((callee) => callee.node.name)).toContain('oldLeaf'); + + fs.writeFileSync(path.join(shaders, 'common.glsl'), 'float oldLeaf(){return 1.0;}\nfloat newLeaf(){return 2.0;}\nfloat helper(){return newLeaf();}\n'); + const synced = await cg.sync(); + expect(synced.filesModified).toBe(1); + main = cg.getNodesInFile('shaders/main.comp').find((candidate) => candidate.name === 'main'); + helper = cg.getNodesInFile('shaders/common.glsl').find((candidate) => candidate.name === 'helper'); + expect(cg.getCallees(main!.id).map((callee) => callee.node.name)).toContain('helper'); + expect(cg.getCallees(helper!.id).map((callee) => callee.node.name)).toContain('newLeaf'); + expect(cg.getCallees(helper!.id).map((callee) => callee.node.name)).not.toContain('oldLeaf'); + } finally { + cg.close(); + } + }); +}); + +describe('C++ shader integration synthesis', () => { + it('links shader filenames, entry points, bindings, specialization IDs, and push constants', async () => { + const cpp = `void buildPipeline() { + loadShader("compiled/path.rgen.variant.spv"); + VkDescriptorSetLayoutBinding binding = { 4, VK_DESCRIPTOR_TYPE_ACCELERATION_STRUCTURE_KHR }; + VkSpecializationMapEntry spec{ 3, 0, 4 }; + VkVertexInputAttributeDescription attribute{ 2, 0, VK_FORMAT_R32G32B32_SFLOAT, 0 }; + vkCmdPushConstants(cmd, layout, stages, 0, sizeof(Push), &push); +}`; + const files = new Map([ + ['src/pipeline.cpp', cpp], + ['shaders/path.rgen', 'void main() {}'], + ]); + const cppFn = node({ id: 'cpp-fn', kind: 'function', name: 'buildPipeline', qualifiedName: 'buildPipeline', filePath: 'src/pipeline.cpp', language: 'cpp', startLine: 1, endLine: 7 }); + const shaderFile = node({ id: 'shader-file', kind: 'file', name: 'path.rgen', filePath: 'shaders/path.rgen', language: 'glsl' }); + const entry = node({ id: 'entry', kind: 'function', name: 'main', filePath: 'shaders/path.rgen', language: 'glsl', decorators: ['entrypoint', 'shader:ray-generation'] }); + const resource = node({ id: 'resource', kind: 'variable', name: 'scene', filePath: 'shaders/path.rgen', language: 'glsl', decorators: ['binding:4', 'resource:acceleration-structure'] }); + const spec = node({ id: 'spec', kind: 'constant', name: 'MODE', filePath: 'shaders/path.rgen', language: 'glsl', decorators: ['constant_id:3'] }); + const vertex = node({ id: 'vertex', kind: 'variable', name: 'position', filePath: 'shaders/path.rgen', language: 'glsl', decorators: ['location:2', 'interface:in'] }); + const push = node({ id: 'push', kind: 'struct', name: 'Push', filePath: 'shaders/path.rgen', language: 'glsl', decorators: ['push_constant'] }); + const byFile = new Map([['src/pipeline.cpp', [node({ id: 'cpp-file', kind: 'file', name: 'pipeline.cpp', filePath: 'src/pipeline.cpp', language: 'cpp' }), cppFn]], ['shaders/path.rgen', [shaderFile, entry, resource, spec, vertex, push]]]); + const context = fakeContext(files, byFile, [...byFile.values()].flat(), process.cwd()); + const queries = { getOutgoingEdges: () => [], getNodeById: () => null } as any; + const edges = await shaderIntegrationEdges(queries, context, async () => {}); + expect(edges.some((edge) => edge.kind === 'calls' && edge.target === 'entry')).toBe(true); + expect(edges.some((edge) => edge.target === 'shader-file' && edge.metadata?.synthesizedBy === 'shader-file')).toBe(true); + expect(edges.some((edge) => edge.target === 'resource')).toBe(true); + expect(edges.some((edge) => edge.target === 'spec')).toBe(true); + expect(edges.some((edge) => edge.target === 'vertex')).toBe(true); + expect(edges.some((edge) => edge.target === 'push')).toBe(true); + }); + + it('keeps passive shader filenames as file references without calling the entry point', async () => { + const cpp = `bool resolveProjectRoot() { + return std::filesystem::exists("shaders/pathtrace.rgen"); +} +Contract buildPipelineContract() { + const char* dependency = "shaders/pathtrace.rgen"; + return rendererContractArray({"shaders/pathtrace.rgen"}); +} +void buildPipeline() { + compileShaderVariant(shaderDirectory / "shaders/pathtrace.rgen", ".variant", defines); +}`; + const files = new Map([ + ['src/pipeline.cpp', cpp], + ['shaders/pathtrace.rgen', 'void main() {}'], + ]); + const passive = node({ id: 'passive', kind: 'function', name: 'resolveProjectRoot', filePath: 'src/pipeline.cpp', language: 'cpp', startLine: 1, endLine: 3 }); + const contract = node({ id: 'contract', kind: 'function', name: 'buildPipelineContract', filePath: 'src/pipeline.cpp', language: 'cpp', startLine: 4, endLine: 7 }); + const loader = node({ id: 'loader', kind: 'function', name: 'buildPipeline', filePath: 'src/pipeline.cpp', language: 'cpp', startLine: 8, endLine: 10 }); + const shaderFile = node({ id: 'shader-file', kind: 'file', name: 'pathtrace.rgen', filePath: 'shaders/pathtrace.rgen', language: 'glsl' }); + const entry = node({ id: 'entry', kind: 'function', name: 'main', filePath: 'shaders/pathtrace.rgen', language: 'glsl', decorators: ['entrypoint'] }); + const byFile = new Map([ + ['src/pipeline.cpp', [node({ id: 'cpp-file', kind: 'file', name: 'pipeline.cpp', filePath: 'src/pipeline.cpp', language: 'cpp' }), passive, contract, loader]], + ['shaders/pathtrace.rgen', [shaderFile, entry]], + ]); + const edges = await shaderIntegrationEdges( + { getOutgoingEdges: () => [], getNodeById: () => null } as any, + fakeContext(files, byFile, [...byFile.values()].flat(), process.cwd()), + async () => {}, + ); + + expect(edges.filter((edge) => edge.target === 'shader-file').map((edge) => edge.source).sort()) + .toEqual(['contract', 'loader', 'passive']); + expect(edges.filter((edge) => edge.target === 'entry').map((edge) => edge.source)).toEqual(['loader']); + expect(edges.find((edge) => edge.source === 'loader' && edge.target === 'entry')?.line).toBe(9); + }); + + it('emits no interface edge when a C++ symbol is associated with multiple shaders', async () => { + const cpp = `void buildPipelines() { + loadShader("shaders/a.comp"); + loadShader("shaders/b.comp"); + VkDescriptorSetLayoutBinding binding = { 0, VK_DESCRIPTOR_TYPE_STORAGE_BUFFER }; +}`; + const files = new Map([ + ['src/pipeline.cpp', cpp], + ['shaders/a.comp', 'void main() {}'], + ['shaders/b.comp', 'void main() {}'], + ]); + const cppFn = node({ id: 'cpp-fn', kind: 'function', name: 'buildPipelines', filePath: 'src/pipeline.cpp', language: 'cpp', startLine: 1, endLine: 5 }); + const shaderNodes = (name: string) => [ + node({ id: `${name}-file`, kind: 'file', name: `${name}.comp`, filePath: `shaders/${name}.comp`, language: 'glsl' }), + node({ id: `${name}-entry`, kind: 'function', name: 'main', filePath: `shaders/${name}.comp`, language: 'glsl', decorators: ['entrypoint'] }), + node({ id: `${name}-resource`, kind: 'struct', name: `${name}Buffer`, filePath: `shaders/${name}.comp`, language: 'glsl', decorators: ['binding:0', 'storage:buffer'] }), + ]; + const byFile = new Map([ + ['src/pipeline.cpp', [node({ id: 'cpp-file', kind: 'file', name: 'pipeline.cpp', filePath: 'src/pipeline.cpp', language: 'cpp' }), cppFn]], + ['shaders/a.comp', shaderNodes('a')], + ['shaders/b.comp', shaderNodes('b')], + ]); + const context = fakeContext(files, byFile, [...byFile.values()].flat(), process.cwd()); + const edges = await shaderIntegrationEdges({ getOutgoingEdges: () => [], getNodeById: () => null } as any, context, async () => {}); + expect(edges.filter((edge) => edge.metadata?.synthesizedBy === 'shader-interface')).toEqual([]); + }); +}); + +function fakeContext( + files: Map, + byFile: Map, + allNodes: Node[], + root: string, +): ResolutionContext { + const byName = (name: string) => allNodes.filter((candidate) => candidate.name === name); + return { + getNodesInFile: (file) => byFile.get(file) ?? [], + getNodesByName: byName, + getNodesByQualifiedName: (name) => allNodes.filter((candidate) => candidate.qualifiedName === name), + getNodesByKind: (kind) => allNodes.filter((candidate) => candidate.kind === kind), + iterateNodesByKind: function* (kind) { yield* allNodes.filter((candidate) => candidate.kind === kind); }, + fileExists: (file) => files.has(file), + readFile: (file) => files.get(file) ?? null, + getFileLines: (file) => files.get(file)?.split(/\r?\n/) ?? null, + getProjectRoot: () => root, + getAllFiles: () => [...files.keys()], + getNodesByLowerName: (name) => allNodes.filter((candidate) => candidate.name.toLowerCase() === name), + getImportMappings: () => [], + }; +} diff --git a/__tests__/status-json.test.ts b/__tests__/status-json.test.ts index 292ca209f..1ecab5aa7 100644 --- a/__tests__/status-json.test.ts +++ b/__tests__/status-json.test.ts @@ -59,6 +59,21 @@ describe('codegraph status --json — CI fields (#329)', () => { cg.close(); }); + it('advances lastIndexed after a pure deletion sync', async () => { + fs.writeFileSync(path.join(tempDir, 'a.ts'), 'export const a = 1;\n'); + fs.writeFileSync(path.join(tempDir, 'b.ts'), 'export const b = 2;\n'); + const cg = CodeGraph.initSync(tempDir); + await cg.indexAll(); + const indexed = cg.getLastIndexedAt()!; + + fs.unlinkSync(path.join(tempDir, 'b.ts')); + const beforeDelete = Date.now(); + const result = await cg.sync(); + expect(result.filesRemoved).toBe(1); + expect(cg.getLastIndexedAt()).toBeGreaterThanOrEqual(Math.max(indexed, beforeDelete)); + cg.close(); + }); + it('status --json on an UNINITIALIZED project reports version + indexPath + lastIndexed:null', () => { const out = runStatusJson(tempDir); expect(out.initialized).toBe(false); @@ -81,6 +96,15 @@ describe('codegraph status --json — CI fields (#329)', () => { expect(out.version).toBe(PKG_VERSION); expect(out.indexPath as string).toContain('.codegraph'); expect(typeof out.lastIndexed).toBe('string'); + expect(out.graphFileNodeCount).toBe(out.fileCount); + expect(out.filesWithoutNodes).toBe(0); + expect(out.index).toMatchObject({ + failedRefs: { + total: expect.any(Number), + byLanguage: expect.any(Object), + byKind: expect.any(Object), + }, + }); // ISO string that round-trips back into the index window. const ms = Date.parse(out.lastIndexed as string); expect(ms).toBeGreaterThanOrEqual(before - 1000); diff --git a/assets/generate-language-tiles.py b/assets/generate-language-tiles.py index 0e78fb485..4722c3b1d 100644 --- a/assets/generate-language-tiles.py +++ b/assets/generate-language-tiles.py @@ -76,6 +76,8 @@ ("objective-c", "Objective-C", ("devicon", "objectivec", "plain")), ("metal", "Metal", ("si", "apple")), ("cuda", "CUDA", ("si", "nvidia")), + ("glsl", "GLSL", ("custom", "glsl")), + ("hlsl", "HLSL", ("custom", "hlsl")), ("swift", "Swift", ("si", "swift")), ("kotlin", "Kotlin", ("si", "kotlin")), ("scala", "Scala", ("si", "scala")), @@ -174,6 +176,12 @@ def custom_glyph(key, out): baseline = round(cy + out.cap_height(size, wt) / 2, 2) d = out.centered("", size, wt, cx, baseline, track=0.4) return f'' + if key in ("glsl", "hlsl"): + size, wt = 18.0, 750 + baseline = round(cy + out.cap_height(size, wt) / 2, 2) + d = out.centered("GL" if key == "glsl" else "HL", size, wt, cx, baseline, track=0.5) + color = "#5586A4" if key == "glsl" else "#2B579A" + return f'' if key == "cobol": # A punched card: manila stock, clipped corner, punched rows. w, h = GLYPH_BOX, 28 diff --git a/assets/languages/glsl.svg b/assets/languages/glsl.svg new file mode 100644 index 000000000..92b1d4dee --- /dev/null +++ b/assets/languages/glsl.svg @@ -0,0 +1,7 @@ + + GLSL + + + GL + GLSL + diff --git a/assets/languages/hlsl.svg b/assets/languages/hlsl.svg new file mode 100644 index 000000000..194b3c06e --- /dev/null +++ b/assets/languages/hlsl.svg @@ -0,0 +1,7 @@ + + HLSL + + + HL + HLSL + diff --git a/docs/design/dynamic-dispatch-coverage-playbook.md b/docs/design/dynamic-dispatch-coverage-playbook.md index 63603a920..d9e9ec3ee 100644 --- a/docs/design/dynamic-dispatch-coverage-playbook.md +++ b/docs/design/dynamic-dispatch-coverage-playbook.md @@ -630,6 +630,21 @@ Status legend: ✅ done+validated · 🔬 hole identified · ⬜ not started. frontier — no named target); React lazy data-router (variable paths + lazy imports); C++ pure-virtual base methods (extracting bodyless decls risks duplicate decl/def nodes for modest gain). Forcing these would add noise, violating "partial coverage worse than none." +- **GLSL / HLSL / GLSLFX shaders (validated 2026-07-13, engine corpus + six public corpora + OpenUSD).** + The local v1.4.1 build indexed the Vulkan engine's 99 first-party GLSL files with zero extraction failures + and stable reindex counts (416 total indexed files and 15,139 nodes). The explicit corpus harness also + parsed ignored/vendor inputs: 191 shader files across GLSL, Vulkan ray-tracing stages, HLSL/HLSLI, and + GLSLFX, with zero failures and stable symbol IDs. Public corpus coverage: glsl-blend 31 shader files / 267 + nodes / 880 edges; gl-transitions 125 / 847 / 1,178; lygia 1,235 / 17,486 / 27,369; NoiseShader 7 / 125 / + 188; NRD 72 / 2,128 / 6,868; Magpie 146 / 7,821 / 52,207. Representative include-to-call probes resolve + across files (`backInOut -> backIn`, `bounceIn -> bounceOut`, `blendColor -> rgb2hsv`, + `ClassicNoise_impl -> wglnoise_fade`, `NRD_CS_MAIN -> WithRectOrigin`, and `Pass2 -> MulAdd`). The canonical + OpenUSD sparse corpus parsed 55 GLSLFX files twice with zero failures (1,079 nodes / 1,050 extraction edges), + preserving embedded GLSL line mappings. C++ integration produced 93 exact shader-file references plus 93 + entry-point call edges in the engine. A real-corpus precision audit caught broad binding-0 links through a + generic helper; interface synthesis now requires one unique shader association, leaving zero ambiguous + bindless links. Agent A/B was not run in this Windows implementation session; these are deterministic + extraction, graph, reindex, retrieval, and precision probes only. - **Difficulty gradient is real:** named-ref dispatch (resolver) is cheap; anonymous callback dispatch (synthesizer) is medium; **anonymous-arrow handlers are the hard remaining gap** (no identity → need synthesizer link-through-body, not yet built). diff --git a/docs/grammars/tree-sitter-shaders.md b/docs/grammars/tree-sitter-shaders.md new file mode 100644 index 000000000..ea3adaf44 --- /dev/null +++ b/docs/grammars/tree-sitter-shaders.md @@ -0,0 +1,20 @@ +# Shader grammar provenance + +CodeGraph vendors two MIT-licensed Tree-sitter WASM grammars for shader parsing. +Their complete license texts ship in `THIRD_PARTY_NOTICES.md`. + +| Language | Upstream revision | Build | +|---|---|---| +| GLSL | `Caellian/tree-sitter-glsl@09b8cbc3a36bce116d641f1bc176268c0899e6e0` | Tree-sitter CLI 0.26.8, ABI 15, SHA-256 `EBFB8027DF9DC4B31DD967DB4B119D224ECC798740E76790EE186A78F198F117` | +| HLSL | `tree-sitter-grammars/tree-sitter-hlsl@bab9111922d53d43668fabb61869bec51bbcb915` | Parser regenerated with Tree-sitter CLI 0.26.8, ABI 15, SHA-256 `DEFDC4C7ED6971A9C6A7B8411237DD001CA94F79FABD7383866DE5F6433D7F12` | + +Rebuild from a clean checkout of each revision with: + +```sh +tree-sitter generate # required for HLSL to replace the upstream ABI-14 parser +tree-sitter build --wasm +``` + +The resulting files are stored as `tree-sitter-glsl.wasm` and +`tree-sitter-hlsl.wasm` under the extraction WASM assets and are copied into the +published build by the normal `copy-assets` step. diff --git a/package.json b/package.json index 2defeb11b..02d1e5f94 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,8 @@ "files": [ "dist", "scripts", - "README.md" + "README.md", + "THIRD_PARTY_NOTICES.md" ], "scripts": { "build": "tsc && npm run copy-assets && node -e \"require('fs').chmodSync('dist/bin/codegraph.js', 0o755)\"", diff --git a/scripts/add-lang/verify-shader-corpus.mjs b/scripts/add-lang/verify-shader-corpus.mjs new file mode 100644 index 000000000..73eb020b2 --- /dev/null +++ b/scripts/add-lang/verify-shader-corpus.mjs @@ -0,0 +1,66 @@ +#!/usr/bin/env node + +import fs from 'node:fs'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +const root = path.resolve(process.argv[2] || '.'); +const expectedIndex = process.argv.indexOf('--expect-glsl'); +const expectedGlsl = expectedIndex >= 0 ? Number(process.argv[expectedIndex + 1]) : null; +const shaderExts = new Set([ + '.glsl', '.vert', '.frag', '.comp', '.geom', '.tesc', '.tese', '.rgen', '.rmiss', + '.rchit', '.rahit', '.rint', '.rcall', '.mesh', '.task', '.glslfx', + '.hlsl', '.hlsli', '.fx', '.fxh', +]); +const glslExts = new Set([...shaderExts].filter((ext) => !['.hlsl', '.hlsli', '.fx', '.fxh'].includes(ext))); + +const files = []; +function walk(dir) { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (entry.name === '.git' || entry.name === '.codegraph' || entry.name === 'node_modules') continue; + const absolute = path.join(dir, entry.name); + if (entry.isDirectory()) walk(absolute); + else if (shaderExts.has(path.extname(entry.name).toLowerCase())) files.push(absolute); + } +} +walk(root); + +const grammars = await import(pathToFileURL(path.resolve('dist/extraction/grammars.js')).href); +const extraction = await import(pathToFileURL(path.resolve('dist/extraction/tree-sitter.js')).href); +await grammars.loadGrammarsForLanguages(['glsl', 'hlsl']); + +const byExtension = {}; +let filesErrored = 0; +let nodes = 0; +let edges = 0; +let refs = 0; +const failures = []; +for (const absolute of files) { + const relative = path.relative(root, absolute).replace(/\\/g, '/'); + const extension = path.extname(relative).toLowerCase(); + byExtension[extension] = (byExtension[extension] || 0) + 1; + const source = fs.readFileSync(absolute, 'utf8'); + const first = extraction.extractFromSource(relative, source); + const second = extraction.extractFromSource(relative, source); + const fatal = first.errors.filter((error) => error.severity === 'error'); + const stable = + first.nodes.length === second.nodes.length && + first.edges.length === second.edges.length && + first.unresolvedReferences.length === second.unresolvedReferences.length && + first.nodes.map((node) => node.id).join('\0') === second.nodes.map((node) => node.id).join('\0'); + if (fatal.length > 0 || !stable) { + filesErrored++; + failures.push({ file: relative, errors: fatal.map((error) => error.message), stable }); + } + nodes += first.nodes.length; + edges += first.edges.length; + refs += first.unresolvedReferences.length; +} + +const firstPartyGlsl = files.filter((file) => { + const relative = path.relative(root, file).replace(/\\/g, '/'); + return relative.startsWith('shaders/') && glslExts.has(path.extname(relative).toLowerCase()); +}).length; +const summary = { root, files: files.length, firstPartyGlsl, filesErrored, nodes, edges, unresolvedReferences: refs, byExtension, failures }; +console.log(JSON.stringify(summary, null, 2)); +if (filesErrored > 0 || (expectedGlsl !== null && firstPartyGlsl !== expectedGlsl)) process.exit(1); diff --git a/site/src/content/docs/guides/affected-tests.md b/site/src/content/docs/guides/affected-tests.md index b51910561..83730f26c 100644 --- a/site/src/content/docs/guides/affected-tests.md +++ b/site/src/content/docs/guides/affected-tests.md @@ -21,6 +21,12 @@ codegraph affected src/auth.ts --filter "e2e/*" # custom test-file pattern | `-j, --json` | Output as JSON | `false` | | `-q, --quiet` | Output file paths only | `false` | +For project-owned changes, automatic test discovery excludes common vendored roots such as `External`, `vendor`, `third_party`, and `deps`. Changes inside those roots still return their own tests, and an explicit `--filter` includes any matching path. + +Test-directory matching is case-insensitive and includes shader entry files, so paths such as `Support/Tests/.../CompileTest.hlsl` can be returned when an included shader library changes. + +For shader changes, CodeGraph follows reverse include relationships to concrete compilation roots before looking for tests. This keeps application bridges isolated and classifies only entry shaders as tests; supporting `.hlsli` and `.fxh` headers are not returned merely because they live under a test directory. + ## CI / hook example ```bash diff --git a/site/src/content/docs/reference/cli.md b/site/src/content/docs/reference/cli.md index 0bf4c1ffe..1f21e9a53 100644 --- a/site/src/content/docs/reference/cli.md +++ b/site/src/content/docs/reference/cli.md @@ -17,9 +17,9 @@ codegraph query # Search symbols (--kind, --limit, --json) codegraph explore # Relevant symbols' source + call paths in one shot (same output as the codegraph_explore MCP tool) codegraph node # One symbol's source + callers, or read a file with line numbers (same output as codegraph_node) codegraph files [path] # Show file structure (--format, --filter, --pattern, --max-depth, --json) -codegraph callers # Find what calls a function/method (--limit, --json) -codegraph callees # Find what a function/method calls (--limit, --json) -codegraph impact # Analyze what code is affected by changing a symbol (--depth, --json) +codegraph callers # Find what calls a function/method (--file, --line, --limit, --json) +codegraph callees # Find what a function/method calls (--file, --line, --limit, --json) +codegraph impact # Analyze what code is affected by changing a symbol (--file, --line, --depth, --json) codegraph affected [files...] # Find test files affected by changes (see below) codegraph daemon # Manage background daemons — pick one to stop (alias: daemons) codegraph telemetry [on|off] # Show or change anonymous usage telemetry @@ -30,6 +30,10 @@ codegraph help [command] # Show help, optionally for one command The MCP server (`codegraph serve --mcp`) is launched automatically by your agent — you don't run it by hand. See [MCP Server](/codegraph/reference/mcp-server/). +## status + +`codegraph status --json` distinguishes tracked files from graph-producing files with `fileCount`, `graphFileNodeCount`, and `filesWithoutNodes`. It also reports attempted references that could not be resolved under `index.failedRefs`, grouped by language and reference kind, so a current index does not hide incomplete semantic coverage. + ## init, index, and sync `codegraph init` creates the local `.codegraph/` directory **and** builds the full graph in one step. (The old `-i`/`--index` flag is now a no-op, accepted only so existing scripts don't break.) After that the file watcher keeps the graph current automatically — `index` (a full rebuild from scratch) and `sync` (an incremental update) are only needed when the watcher is disabled or you're scripting against the index outside an agent session. @@ -42,8 +46,11 @@ The MCP server (`codegraph serve --mcp`) is launched automatically by your agent codegraph query UserService --kind class --limit 10 codegraph callers handleRequest --json codegraph impact AuthMiddleware --depth 3 +codegraph impact bufferResource --file src/renderer.cpp --line 8311 ``` +Use `--file` and `--line` together when one file contains repeated local functions or lambdas with the same name. + `explore` and `node` are the CLI faces of the `codegraph_explore` and `codegraph_node` MCP tools — same output — so subagents and non-MCP harnesses can reach the graph from a shell. ## affected diff --git a/site/src/content/docs/reference/languages.md b/site/src/content/docs/reference/languages.md index 0c5587773..20fc8b60b 100644 --- a/site/src/content/docs/reference/languages.md +++ b/site/src/content/docs/reference/languages.md @@ -18,6 +18,10 @@ Language support is automatic from the file extension — there's nothing to con | Ruby | `.rb` | Full support | | C | `.c`, `.h` | Full support | | C++ | `.cpp`, `.hpp`, `.cc` | Full support | +| GLSL / Vulkan GLSL | `.glsl`, `.vert`, `.frag`, `.comp`, `.geom`, `.tesc`, `.tese`, `.rgen`, `.rmiss`, `.rchit`, `.rahit`, `.rint`, `.rcall`, `.mesh`, `.task`, `.glslfx` | Full support (OpenUSD GLSLFX included) | +| HLSL | `.hlsl`, `.hlsli`, `.fx`, `.fxh` | Full support | +| CMake | `CMakeLists.txt`, `.cmake` | Build-script support | +| PowerShell | `.ps1`, `.psm1`, `.psd1` | Build-script support with `Import-Module`, cross-module functions, and script-scope call edges | | Objective-C | `.m`, `.mm`, `.h` | Partial support (classes, protocols, methods, `@property`, `#import`, message sends; `.mm` ObjC++ may parse incompletely) | | Swift | `.swift` | Full support | | Kotlin | `.kt`, `.kts` | Full support | diff --git a/src/bin/codegraph.ts b/src/bin/codegraph.ts index eefb5d907..ded712f0f 100644 --- a/src/bin/codegraph.ts +++ b/src/bin/codegraph.ts @@ -53,6 +53,7 @@ import { relaunchWithWasmRuntimeFlagsIfNeeded } from '../extraction/wasm-runtime import { installCommandSupervision } from './command-supervision'; import { EXTRACTION_VERSION } from '../extraction/extraction-version'; import { getTelemetry, TELEMETRY_DOCS, recordIndexEvent } from '../telemetry'; +import type { Node } from '../types'; // Decided once, before `--color`/`--no-color` are stripped from argv below // (#1281). Piped/redirected stdout, NO_COLOR, or --no-color -> plain output. @@ -947,6 +948,8 @@ program // Zero on a healthy index; non-zero at rest means a resolution pass was // interrupted, so some files' call edges are missing (#1187). const pendingRefs = cg.getPendingReferenceCount(); + const failedRefs = cg.getFailedReferenceStats(); + const graphFileNodeCount = stats.nodesByKind.file ?? 0; // JSON output mode if (options.json) { @@ -958,6 +961,8 @@ program indexPath: getCodeGraphDir(projectPath), lastIndexed: lastIndexedMs != null ? new Date(lastIndexedMs).toISOString() : null, fileCount: stats.fileCount, + graphFileNodeCount, + filesWithoutNodes: Math.max(0, stats.fileCount - graphFileNodeCount), nodeCount: stats.nodeCount, edgeCount: stats.edgeCount, dbSizeBytes: stats.dbSizeBytes, @@ -986,6 +991,10 @@ program // interrupted resolution pass left edges missing; the next // sync sweeps them (#1187). pendingRefs, + // Attempted but unresolved references are expected to include + // external APIs/intrinsics; expose them for semantic auditing + // without mislabeling the structurally complete index as partial. + failedRefs, }, })); cg.destroy(); @@ -1013,7 +1022,8 @@ program // Index stats console.log(chalk.bold('Index Statistics:')); - console.log(` Files: ${formatNumber(stats.fileCount)}`); + const fileNodeGap = Math.max(0, stats.fileCount - graphFileNodeCount); + console.log(` Files: ${formatNumber(stats.fileCount)}${fileNodeGap ? ` (${formatNumber(graphFileNodeCount)} graph nodes; ${formatNumber(fileNodeGap)} metadata-only)` : ''}`); console.log(` Nodes: ${formatNumber(stats.nodeCount)}`); console.log(` Edges: ${formatNumber(stats.edgeCount)}`); console.log(` DB Size: ${(stats.dbSizeBytes / 1024 / 1024).toFixed(2)} MB`); @@ -1029,6 +1039,7 @@ program ? chalk.green('wal') : chalk.yellow(`${journalMode || 'unknown'} ${getGlyphs().dash} WAL inactive; reads can block on writes`); console.log(` Journal: ${journalLabel}`); + console.log(` Unresolved:${String(formatNumber(failedRefs.total)).padStart(10)} attempted refs`); console.log(); // Node breakdown @@ -1585,6 +1596,33 @@ function normalizeIndexPath(filePath: string, projectPath: string): string { return f; } +function isGloballyAmbiguous(matches: Array<{ node: { filePath: string } }>): boolean { + return matches.length > 20 && new Set(matches.map((m) => m.node.filePath)).size > 8; +} + +function searchSymbolMatches(cg: { getNodesByName(name: string): Node[]; searchNodes(name: string, options: { limit: number }): Array<{ node: Node }> }, symbol: string): Array<{ node: Node }> { + return /^[A-Za-z_$][\w$]*$/.test(symbol) + ? cg.getNodesByName(symbol).map((node) => ({ node })) + : cg.searchNodes(symbol, { limit: 50 }); +} + +function narrowSymbolMatchesByLine( + matches: Array<{ node: Node }>, + line: number | undefined +): Array<{ node: Node }> { + if (!line || !Number.isFinite(line) || line < 1) return matches; + const exact = matches.filter((match) => match.node.startLine === line); + if (exact.length > 0) return exact; + return matches.filter( + (match) => match.node.startLine <= line && match.node.endLine >= line + ); +} + +function repeatedCppLambdaMatches(matches: Array<{ node: Node }>): Array<{ node: Node }> { + const lambdas = matches.filter((match) => match.node.decorators?.includes('cpp:lambda')); + return lambdas.length > 1 ? lambdas : []; +} + /** * Convert glob pattern to regex */ @@ -1834,9 +1872,11 @@ program .command('callers ') .description('Find all functions/methods that call a specific symbol') .option('-p, --path ', 'Project path') + .option('--file ', 'Narrow to the definition in this file (path or suffix)') + .option('--line ', 'Narrow to the definition starting at or containing this line') .option('-l, --limit ', 'Maximum results', '20') .option('-j, --json', 'Output as JSON') - .action(async (symbol: string, options: { path?: string; limit?: string; json?: boolean }) => { + .action(async (symbol: string, options: { path?: string; file?: string; line?: string; limit?: string; json?: boolean }) => { const projectPath = resolveProjectPath(options.path); try { @@ -1848,16 +1888,44 @@ program const { default: CodeGraph } = await loadCodeGraph(); const cg = await CodeGraph.open(projectPath); const limit = parseInt(options.limit || '20', 10); + const lineFilter = options.line ? parseInt(options.line, 10) : undefined; - const matches = cg.searchNodes(symbol, { limit: 50 }); + const fileFilter = options.file ? normalizeIndexPath(options.file, projectPath) : undefined; + let matches = searchSymbolMatches(cg, symbol).filter((match) => + !fileFilter || match.node.filePath === fileFilter || match.node.filePath.endsWith(`/${fileFilter}`) + ); + matches = narrowSymbolMatchesByLine(matches, lineFilter); if (matches.length === 0) { - info(`Symbol "${symbol}" not found`); + const location = fileFilter + ? ` in ${fileFilter}${lineFilter ? ` at line ${lineFilter}` : ''}` + : lineFilter ? ` at line ${lineFilter}` : ''; + info(`Symbol "${symbol}" not found${location}`); + cg.destroy(); + return; + } + const repeatedLambdas = fileFilter && !lineFilter ? repeatedCppLambdaMatches(matches) : []; + if (repeatedLambdas.length > 0) { + const definitions = repeatedLambdas.map((match) => ({ + qualifiedName: match.node.qualifiedName, + filePath: match.node.filePath, + startLine: match.node.startLine, + })); + const hint = 'Pass --line to select one local lambda definition'; + if (options.json) console.log(JSON.stringify({ symbol, ambiguous: true, definitionCount: definitions.length, hint, definitions }, null, 2)); + else info(`"${symbol}" has ${definitions.length} local lambda definitions in ${fileFilter}; pass --line to select one: ${definitions.map((definition) => definition.startLine).join(', ')}`); + cg.destroy(); + return; + } + if (!fileFilter && isGloballyAmbiguous(matches)) { + const definitions = matches.slice(0, 50).map((m) => ({ name: m.node.name, kind: m.node.kind, filePath: m.node.filePath, startLine: m.node.startLine })); + if (options.json) console.log(JSON.stringify({ symbol, ambiguous: true, definitionCount: matches.length, hint: 'Pass --file to select one definition', definitions }, null, 2)); + else info(`"${symbol}" has ${matches.length} definitions across the project; pass --file to select one.`); cg.destroy(); return; } const seen = new Set(); - const allCallers: Array<{ name: string; kind: string; filePath: string; startLine?: number }> = []; + const allCallers: Array<{ name: string; kind: string; filePath: string; startLine?: number; callLine?: number }> = []; for (const match of matches) { const exactMatch = match.node.name === symbol || match.node.name.endsWith(`.${symbol}`) || match.node.name.endsWith(`::${symbol}`); @@ -1865,7 +1933,7 @@ program for (const c of cg.getCallers(match.node.id)) { if (!seen.has(c.node.id)) { seen.add(c.node.id); - allCallers.push({ name: c.node.name, kind: c.node.kind, filePath: c.node.filePath, startLine: c.node.startLine }); + allCallers.push({ name: c.node.name, kind: c.node.kind, filePath: c.node.filePath, startLine: c.node.startLine, callLine: c.edge.line ?? undefined }); } } } @@ -1875,7 +1943,7 @@ program for (const c of cg.getCallers(matches[0].node.id)) { if (!seen.has(c.node.id)) { seen.add(c.node.id); - allCallers.push({ name: c.node.name, kind: c.node.kind, filePath: c.node.filePath, startLine: c.node.startLine }); + allCallers.push({ name: c.node.name, kind: c.node.kind, filePath: c.node.filePath, startLine: c.node.startLine, callLine: c.edge.line ?? undefined }); } } } @@ -1883,13 +1951,13 @@ program const limited = allCallers.slice(0, limit); if (options.json) { - console.log(JSON.stringify({ symbol, callers: limited }, null, 2)); + console.log(JSON.stringify({ symbol, file: fileFilter, line: lineFilter, callers: limited }, null, 2)); } else if (limited.length === 0) { info(`No callers found for "${symbol}"`); } else { console.log(chalk.bold(`\nCallers of "${symbol}" (${limited.length}):\n`)); for (const node of limited) { - const loc = node.startLine ? `:${node.startLine}` : ''; + const loc = node.callLine || node.startLine ? `:${node.callLine ?? node.startLine}` : ''; console.log( chalk.cyan(node.kind.padEnd(12)) + chalk.white(node.name) @@ -1913,9 +1981,11 @@ program .command('callees ') .description('Find all functions/methods that a specific symbol calls') .option('-p, --path ', 'Project path') + .option('--file ', 'Narrow to the definition in this file (path or suffix)') + .option('--line ', 'Narrow to the definition starting at or containing this line') .option('-l, --limit ', 'Maximum results', '20') .option('-j, --json', 'Output as JSON') - .action(async (symbol: string, options: { path?: string; limit?: string; json?: boolean }) => { + .action(async (symbol: string, options: { path?: string; file?: string; line?: string; limit?: string; json?: boolean }) => { const projectPath = resolveProjectPath(options.path); try { @@ -1927,16 +1997,44 @@ program const { default: CodeGraph } = await loadCodeGraph(); const cg = await CodeGraph.open(projectPath); const limit = parseInt(options.limit || '20', 10); + const lineFilter = options.line ? parseInt(options.line, 10) : undefined; - const matches = cg.searchNodes(symbol, { limit: 50 }); + const fileFilter = options.file ? normalizeIndexPath(options.file, projectPath) : undefined; + let matches = searchSymbolMatches(cg, symbol).filter((match) => + !fileFilter || match.node.filePath === fileFilter || match.node.filePath.endsWith(`/${fileFilter}`) + ); + matches = narrowSymbolMatchesByLine(matches, lineFilter); if (matches.length === 0) { - info(`Symbol "${symbol}" not found`); + const location = fileFilter + ? ` in ${fileFilter}${lineFilter ? ` at line ${lineFilter}` : ''}` + : lineFilter ? ` at line ${lineFilter}` : ''; + info(`Symbol "${symbol}" not found${location}`); + cg.destroy(); + return; + } + const repeatedLambdas = fileFilter && !lineFilter ? repeatedCppLambdaMatches(matches) : []; + if (repeatedLambdas.length > 0) { + const definitions = repeatedLambdas.map((match) => ({ + qualifiedName: match.node.qualifiedName, + filePath: match.node.filePath, + startLine: match.node.startLine, + })); + const hint = 'Pass --line to select one local lambda definition'; + if (options.json) console.log(JSON.stringify({ symbol, ambiguous: true, definitionCount: definitions.length, hint, definitions }, null, 2)); + else info(`"${symbol}" has ${definitions.length} local lambda definitions in ${fileFilter}; pass --line to select one: ${definitions.map((definition) => definition.startLine).join(', ')}`); + cg.destroy(); + return; + } + if (!fileFilter && isGloballyAmbiguous(matches)) { + const definitions = matches.slice(0, 50).map((m) => ({ name: m.node.name, kind: m.node.kind, filePath: m.node.filePath, startLine: m.node.startLine })); + if (options.json) console.log(JSON.stringify({ symbol, ambiguous: true, definitionCount: matches.length, hint: 'Pass --file to select one definition', definitions }, null, 2)); + else info(`"${symbol}" has ${matches.length} definitions across the project; pass --file to select one.`); cg.destroy(); return; } const seen = new Set(); - const allCallees: Array<{ name: string; kind: string; filePath: string; startLine?: number }> = []; + const allCallees: Array<{ name: string; kind: string; filePath: string; startLine?: number; callLine?: number }> = []; for (const match of matches) { const exactMatch = match.node.name === symbol || match.node.name.endsWith(`.${symbol}`) || match.node.name.endsWith(`::${symbol}`); @@ -1944,7 +2042,7 @@ program for (const c of cg.getCallees(match.node.id)) { if (!seen.has(c.node.id)) { seen.add(c.node.id); - allCallees.push({ name: c.node.name, kind: c.node.kind, filePath: c.node.filePath, startLine: c.node.startLine }); + allCallees.push({ name: c.node.name, kind: c.node.kind, filePath: c.node.filePath, startLine: c.node.startLine, callLine: c.edge.line ?? undefined }); } } } @@ -1953,7 +2051,7 @@ program for (const c of cg.getCallees(matches[0].node.id)) { if (!seen.has(c.node.id)) { seen.add(c.node.id); - allCallees.push({ name: c.node.name, kind: c.node.kind, filePath: c.node.filePath, startLine: c.node.startLine }); + allCallees.push({ name: c.node.name, kind: c.node.kind, filePath: c.node.filePath, startLine: c.node.startLine, callLine: c.edge.line ?? undefined }); } } } @@ -1961,13 +2059,13 @@ program const limited = allCallees.slice(0, limit); if (options.json) { - console.log(JSON.stringify({ symbol, callees: limited }, null, 2)); + console.log(JSON.stringify({ symbol, file: fileFilter, line: lineFilter, callees: limited }, null, 2)); } else if (limited.length === 0) { info(`No callees found for "${symbol}"`); } else { console.log(chalk.bold(`\nCallees of "${symbol}" (${limited.length}):\n`)); for (const node of limited) { - const loc = node.startLine ? `:${node.startLine}` : ''; + const loc = node.callLine || node.startLine ? `:${node.callLine ?? node.startLine}` : ''; console.log( chalk.cyan(node.kind.padEnd(12)) + chalk.white(node.name) @@ -1991,9 +2089,11 @@ program .command('impact ') .description('Analyze what code is affected by changing a symbol') .option('-p, --path ', 'Project path') + .option('--file ', 'Narrow to the definition in this file (path or suffix)') + .option('--line ', 'Narrow to the definition starting at or containing this line') .option('-d, --depth ', 'Traversal depth', '2') .option('-j, --json', 'Output as JSON') - .action(async (symbol: string, options: { path?: string; depth?: string; json?: boolean }) => { + .action(async (symbol: string, options: { path?: string; file?: string; line?: string; depth?: string; json?: boolean }) => { const projectPath = resolveProjectPath(options.path); try { @@ -2005,16 +2105,44 @@ program const { default: CodeGraph } = await loadCodeGraph(); const cg = await CodeGraph.open(projectPath); const depth = Math.min(Math.max(parseInt(options.depth || '2', 10), 1), 10); + const lineFilter = options.line ? parseInt(options.line, 10) : undefined; - const matches = cg.searchNodes(symbol, { limit: 50 }); + const fileFilter = options.file ? normalizeIndexPath(options.file, projectPath) : undefined; + let matches = searchSymbolMatches(cg, symbol).filter((match) => + !fileFilter || match.node.filePath === fileFilter || match.node.filePath.endsWith(`/${fileFilter}`) + ); + matches = narrowSymbolMatchesByLine(matches, lineFilter); if (matches.length === 0) { - info(`Symbol "${symbol}" not found`); + const location = fileFilter + ? ` in ${fileFilter}${lineFilter ? ` at line ${lineFilter}` : ''}` + : lineFilter ? ` at line ${lineFilter}` : ''; + info(`Symbol "${symbol}" not found${location}`); + cg.destroy(); + return; + } + const repeatedLambdas = fileFilter && !lineFilter ? repeatedCppLambdaMatches(matches) : []; + if (repeatedLambdas.length > 0) { + const definitions = repeatedLambdas.map((match) => ({ + qualifiedName: match.node.qualifiedName, + filePath: match.node.filePath, + startLine: match.node.startLine, + })); + const hint = 'Pass --line to select one local lambda definition'; + if (options.json) console.log(JSON.stringify({ symbol, ambiguous: true, definitionCount: definitions.length, hint, definitions }, null, 2)); + else info(`"${symbol}" has ${definitions.length} local lambda definitions in ${fileFilter}; pass --line to select one: ${definitions.map((definition) => definition.startLine).join(', ')}`); + cg.destroy(); + return; + } + if (!fileFilter && isGloballyAmbiguous(matches)) { + const definitions = matches.slice(0, 50).map((m) => ({ name: m.node.name, kind: m.node.kind, filePath: m.node.filePath, startLine: m.node.startLine })); + if (options.json) console.log(JSON.stringify({ symbol, ambiguous: true, definitionCount: matches.length, hint: 'Pass --file to select one definition', definitions }, null, 2)); + else info(`"${symbol}" has ${matches.length} definitions across the project; pass --file to select one.`); cg.destroy(); return; } // Merge impact subgraphs across all exact-matching symbols - const mergedNodes = new Map(); + const mergedNodes = new Map(); const seenEdges = new Set(); let edgeCount = 0; @@ -2023,7 +2151,7 @@ program if (!exactMatch && matches.length > 1) continue; const impact = cg.getImpactRadius(match.node.id, depth); for (const [id, n] of impact.nodes) { - mergedNodes.set(id, { name: n.name, kind: n.kind, filePath: n.filePath, startLine: n.startLine }); + mergedNodes.set(id, { name: n.name, kind: n.kind, filePath: n.filePath, startLine: n.startLine, decorators: n.decorators }); } for (const e of impact.edges) { const key = `${e.source}->${e.target}:${e.kind}`; @@ -2038,17 +2166,23 @@ program if (mergedNodes.size === 0 && matches[0]) { const impact = cg.getImpactRadius(matches[0].node.id, depth); for (const [id, n] of impact.nodes) { - mergedNodes.set(id, { name: n.name, kind: n.kind, filePath: n.filePath, startLine: n.startLine }); + mergedNodes.set(id, { name: n.name, kind: n.kind, filePath: n.filePath, startLine: n.startLine, decorators: n.decorators }); } edgeCount = impact.edges.length; } if (options.json) { + const variantRoots = Array.from(mergedNodes.values()) + .filter((node) => node.decorators?.some((decorator) => decorator.startsWith('pp:'))) + .map((node) => ({ filePath: node.filePath, name: node.name, startLine: node.startLine, conditions: node.decorators?.filter((decorator) => decorator.startsWith('pp:')) })); console.log(JSON.stringify({ symbol, + file: fileFilter, + line: lineFilter, depth, nodeCount: mergedNodes.size, edgeCount, + ...(variantRoots.length > 0 ? { preprocessorVariants: variantRoots } : {}), affected: Array.from(mergedNodes.values()), }, null, 2)); } else if (mergedNodes.size === 0) { @@ -2140,9 +2274,10 @@ program /\.spec\./, /\.test\./, /\/__tests__\//, - /\/tests?\//, + /\/tests?\//i, /\/e2e\//, /\/spec\//, + /(?:^|\/)[^/]*(?:Test|Tests)\.(?:c|cc|cpp|cxx|m|mm|h|hpp|glsl|vert|frag|comp|geom|tesc|tese|rgen|rmiss|rchit|rahit|rint|rcall|mesh|task|hlsl|hlsli|fx|fxh)$/i, ]; // Custom filter pattern @@ -2157,16 +2292,85 @@ program customFilter = new RegExp(regex); } + const vendoredPath = /(?:^|\/)(?:External|vendor|vendors|third[_-]?party|deps)(?:\/|$)/i; + const changedVendoredCode = changedFiles.some((file) => vendoredPath.test(file)); + const shaderPath = /\.(?:glsl|vert|frag|comp|geom|tesc|tese|rgen|rmiss|rchit|rahit|rint|rcall|mesh|task|glslfx|hlsl|hlsli|fx|fxh)$/i; + const shaderEntryPath = /\.(?:glsl|vert|frag|comp|geom|tesc|tese|rgen|rmiss|rchit|rahit|rint|rcall|mesh|task|glslfx|hlsl|fx)$/i; + + function isShaderTestEntry(filePath: string): boolean { + if (!shaderEntryPath.test(filePath)) return false; + const inTestDirectory = /(?:^|\/)(?:tests?|specs?|__tests__)(?:\/|$)/i.test(filePath); + if (!inTestDirectory) return false; + if (/(?:Test|Tests)\.[^/]+$/i.test(filePath)) return true; + return cg.getNodesInFile(filePath).some((node) => + node.kind === 'function' && (node.decorators?.includes('entrypoint') || node.name === 'main') + ); + } + function isTestFile(filePath: string): boolean { if (customFilter) return customFilter.test(filePath); + // A project-owned change should not return dozens of dependency-suite + // tests merely because an `External/` subtree is indexed. Keep vendored + // tests available when the changed file itself is vendored, and let an + // explicit --filter opt back in for custom workflows. + if (!changedVendoredCode && vendoredPath.test(filePath)) return false; + if (shaderPath.test(filePath)) return isShaderTestEntry(filePath); + if (/(?:^|\/)CMakeLists\.txt$/i.test(filePath)) return false; return defaultTestPatterns.some(p => p.test(filePath)); } + function shaderCompilationRoots(filePath: string): string[] { + const fileNode = cg.getNodesInFile(filePath).find((node) => node.kind === 'file'); + if (!fileNode || (fileNode.language !== 'glsl' && fileNode.language !== 'hlsl')) return []; + const files = new Set([filePath]); + const hasShaderParent = new Set(); + const queue = [filePath]; + for (let index = 0; index < queue.length && index < 4096; index++) { + const current = queue[index]!; + const currentFile = cg.getNodesInFile(current).find((node) => node.kind === 'file'); + if (!currentFile) continue; + for (const edge of cg.getIncomingEdges(currentFile.id)) { + if (edge.kind !== 'imports') continue; + const source = cg.getNode(edge.source); + if (source?.kind !== 'file' || (source.language !== 'glsl' && source.language !== 'hlsl')) continue; + hasShaderParent.add(current); + if (!files.has(source.filePath)) { + files.add(source.filePath); + queue.push(source.filePath); + } + } + } + return [...files].filter((candidate) => !hasShaderParent.has(candidate) && shaderEntryPath.test(candidate)).sort(); + } + // BFS to find all transitive dependents of changed files, filtered to test files const affectedTests = new Set(); const allDependents = new Set(); for (const file of changedFiles) { + if (shaderPath.test(file)) { + const roots = shaderCompilationRoots(file); + if (roots.length > 0) { + for (const root of roots) { + allDependents.add(root); + if (isTestFile(root)) affectedTests.add(root); + const queue: Array<{ file: string; depth: number }> = [{ file: root, depth: 0 }]; + const visited = new Set([root]); + while (queue.length > 0) { + const current = queue.shift()!; + if (current.depth >= maxDepth) continue; + for (const dep of cg.getFileDependents(current.file)) { + if (visited.has(dep) || shaderPath.test(dep)) continue; + visited.add(dep); + allDependents.add(dep); + if (isTestFile(dep)) affectedTests.add(dep); + else queue.push({ file: dep, depth: current.depth + 1 }); + } + } + } + continue; + } + } // If the changed file is itself a test file, include it if (isTestFile(file)) { affectedTests.add(file); diff --git a/src/db/queries.ts b/src/db/queries.ts index 3f6b14de9..7b77fac02 100644 --- a/src/db/queries.ts +++ b/src/db/queries.ts @@ -1915,7 +1915,11 @@ export class QueryBuilder { const row = this.db .prepare('SELECT MAX(indexed_at) AS last FROM files') .get() as { last: number | null } | undefined; - return row?.last ?? null; + const fileLast = row?.last ?? null; + const operationRaw = this.getMetadata('last_indexed_at'); + const operationLast = operationRaw == null ? NaN : Number(operationRaw); + if (!Number.isFinite(operationLast) || operationLast <= 0) return fileLast; + return fileLast == null ? operationLast : Math.max(fileLast, operationLast); } /** @@ -2055,6 +2059,28 @@ export class QueryBuilder { return row.count; } + /** Attempted references that could not be resolved, summarized for status. */ + getFailedReferenceStats(): { + total: number; + byLanguage: Record; + byKind: Record; + } { + const total = (this.db.prepare( + "SELECT COUNT(*) AS count FROM unresolved_refs WHERE status = 'failed'" + ).get() as { count: number }).count; + const languageRows = this.db.prepare( + "SELECT language, COUNT(*) AS count FROM unresolved_refs WHERE status = 'failed' GROUP BY language ORDER BY count DESC" + ).all() as Array<{ language: string; count: number }>; + const kindRows = this.db.prepare( + "SELECT reference_kind AS kind, COUNT(*) AS count FROM unresolved_refs WHERE status = 'failed' GROUP BY reference_kind ORDER BY count DESC" + ).all() as Array<{ kind: string; count: number }>; + return { + total, + byLanguage: Object.fromEntries(languageRows.map((row) => [row.language, row.count])), + byKind: Object.fromEntries(kindRows.map((row) => [row.kind, row.count])), + }; + } + /** * Get a batch of PENDING unresolved references using LIMIT/OFFSET * pagination. Used to process references in bounded memory chunks; failed diff --git a/src/extraction/build-script-extractors.ts b/src/extraction/build-script-extractors.ts new file mode 100644 index 000000000..68bf8fe31 --- /dev/null +++ b/src/extraction/build-script-extractors.ts @@ -0,0 +1,245 @@ +import * as path from 'path'; +import type { ExtractionResult, Language, Node, NodeKind, UnresolvedReference } from '../types'; +import { extractPowershellDependencies } from '../powershell-dependencies'; +import { generateNodeId } from './tree-sitter-helpers'; + +const SHADER_PATH_RE = /\b[^\s"']+\.(?:glsl|vert|frag|comp|geom|tesc|tese|rgen|rmiss|rchit|rahit|rint|rcall|mesh|task|hlsl|hlsli|fx|fxh|spv)\b/gi; + +function lineOf(source: string, index: number): number { + return source.slice(0, index).split(/\r?\n/).length; +} + +function findMatchingBrace(source: string, open: number): number { + let depth = 0; + let quote: "'" | '"' | null = null; + let lineComment = false; + let blockComment = false; + for (let i = open; i < source.length; i++) { + const ch = source[i]!; + const next = source[i + 1]; + if (lineComment) { + if (ch === '\n') lineComment = false; + continue; + } + if (blockComment) { + if (ch === '#' && next === '>') { blockComment = false; i++; } + continue; + } + if (quote) { + if (quote === '"' && ch === '`') { i++; continue; } + if (ch === quote) { + if (quote === "'" && next === "'") { i++; continue; } + quote = null; + } + continue; + } + if (ch === '<' && next === '#') { blockComment = true; i++; continue; } + if (ch === '#') { lineComment = true; continue; } + if (ch === "'" || ch === '"') { quote = ch; continue; } + if (ch === '{') depth++; + else if (ch === '}' && --depth === 0) return i; + } + return source.length; +} + +function fileNode(filePath: string, source: string, language: Language): Node { + const lines = source.split(/\r?\n/); + return { + id: generateNodeId(filePath, 'file', filePath, 1), + kind: 'file', + name: path.basename(filePath), + qualifiedName: filePath, + filePath, + language, + startLine: 1, + endLine: lines.length, + startColumn: 0, + endColumn: lines.at(-1)?.length ?? 0, + updatedAt: Date.now(), + }; +} + +function createNode(filePath: string, language: Language, kind: NodeKind, name: string, line: number, signature?: string): Node { + return { + id: generateNodeId(filePath, kind, name, line), + kind, + name, + qualifiedName: name, + filePath, + language, + signature, + startLine: line, + endLine: line, + startColumn: 0, + endColumn: signature?.length ?? name.length, + updatedAt: Date.now(), + }; +} + +function addShaderReferences(filePath: string, language: Language, source: string, fromNodeId: string, refs: UnresolvedReference[]): void { + for (const match of source.matchAll(SHADER_PATH_RE)) { + const name = match[0]; + refs.push({ + fromNodeId, + referenceName: name, + referenceKind: 'references', + line: lineOf(source, match.index ?? 0), + column: 0, + filePath, + language, + }); + } +} + +export function extractCmake(filePath: string, source: string): ExtractionResult { + const language: Language = 'cmake'; + const file = fileNode(filePath, source, language); + const nodes: Node[] = [file]; + const edges: ExtractionResult['edges'] = []; + const unresolvedReferences: UnresolvedReference[] = []; + const add = (kind: NodeKind, name: string, line: number, signature?: string) => { + const n = createNode(filePath, language, kind, name, line, signature); + nodes.push(n); + edges.push({ source: file.id, target: n.id, kind: 'contains' as const }); + return n; + }; + + for (const match of source.matchAll(/\b(function|macro)\s*\(\s*([A-Za-z_]\w*)/gi)) { + add('function', match[2]!, lineOf(source, match.index ?? 0), match[0]); + } + for (const match of source.matchAll(/\b(?:set|option|set_property)\s*\(\s*([A-Za-z_]\w*)/gi)) { + add('variable', match[1]!, lineOf(source, match.index ?? 0), match[0]); + } + addShaderReferences(filePath, language, source, file.id, unresolvedReferences); + for (const match of source.matchAll(/\b(?:include|add_subdirectory|find_package)\s*\(\s*["']?([^\s"')]+)/gi)) { + unresolvedReferences.push({ + fromNodeId: file.id, + referenceName: match[1]!, + referenceKind: 'imports', + line: lineOf(source, match.index ?? 0), + column: 0, + filePath, + language, + }); + } + return { nodes, edges, unresolvedReferences, errors: [], durationMs: 0 }; +} + +export function extractPowershell(filePath: string, source: string): ExtractionResult { + const language: Language = 'powershell'; + const file = fileNode(filePath, source, language); + const nodes: Node[] = [file]; + const edges: ExtractionResult['edges'] = []; + const unresolvedReferences: UnresolvedReference[] = []; + const importedScripts = new Set(); + for (const dependency of extractPowershellDependencies(source)) { + const dependencyPath = path.posix.normalize(path.posix.join( + path.posix.dirname(filePath.replace(/\\/g, '/')), + dependency.relativePath.replace(/\\/g, '/'), + )); + if (dependencyPath.startsWith('../') || importedScripts.has(dependencyPath.toLowerCase())) continue; + importedScripts.add(dependencyPath.toLowerCase()); + const line = lineOf(source, dependency.index); + const imported = createNode(filePath, language, 'import', dependency.kind === 'module' ? 'Import-Module' : 'dot-source', line, dependency.signature); + nodes.push(imported); + edges.push({ source: file.id, target: imported.id, kind: 'contains' as const }); + unresolvedReferences.push({ + fromNodeId: file.id, + referenceName: dependencyPath, + referenceKind: 'imports', + line, + column: 0, + filePath, + language, + }); + } + const functionNodes = new Map(); + const functionRanges = new Map(); + for (const match of source.matchAll(/^[ \t]*function\s+([A-Za-z_]\w*(?:-[A-Za-z_]\w*)*)/gim)) { + const start = match.index ?? 0; + const name = match[1]!; + const open = source.indexOf('{', start + match[0].length); + const close = open >= 0 ? findMatchingBrace(source, open) : start + match[0].length; + const n = createNode(filePath, language, 'function', name, lineOf(source, start + match[0].indexOf('function')), match[0].trim()); + n.endLine = lineOf(source, close); + n.endColumn = close - source.lastIndexOf('\n', close) - 1; + nodes.push(n); + edges.push({ source: file.id, target: n.id, kind: 'contains' as const }); + functionNodes.set(name.toLowerCase(), n); + functionRanges.set(n.id, { start: open, end: close }); + } + const variables = new Set(); + for (const match of source.matchAll(/(?:^|[;\r\n])[ \t]*\$([A-Za-z_]\w*)\s*=/g)) { + const name = match[1]!; + const tokenOffset = match[0].lastIndexOf(`$${name}`); + const absolute = (match.index ?? 0) + Math.max(0, tokenOffset); + if ([...functionRanges.values()].some((range) => absolute >= range.start && absolute <= range.end)) continue; + if (variables.has(name.toLowerCase())) continue; + variables.add(name.toLowerCase()); + const n = createNode(filePath, language, 'variable', name, lineOf(source, absolute), match[0].trim()); + nodes.push(n); + edges.push({ source: file.id, target: n.id, kind: 'contains' as const }); + } + // Resolve direct PowerShell command invocations against functions declared in + // the same file. The resolver then extends this to same-language definitions + // in included/project scripts without treating every external cmdlet as user code. + for (const fn of functionNodes.values()) { + const range = functionRanges.get(fn.id); + const bodyStart = range?.start ?? -1; + const bodyEnd = range?.end ?? source.length; + const body = source.slice(bodyStart + 1, bodyEnd); + for (const call of body.matchAll(/(? absolute >= range.start && absolute <= range.end)) continue; + const lineStart = source.lastIndexOf('\n', absolute) + 1; + if (/^[ \t]*function\s+/i.test(source.slice(lineStart, absolute))) continue; + const lineEnd = source.indexOf('\n', absolute); + const lineText = source.slice(lineStart, lineEnd < 0 ? source.length : lineEnd); + if (/^[ \t]*Export-ModuleMember\b/i.test(lineText)) continue; + unresolvedReferences.push({ + fromNodeId: file.id, + referenceName: call[1]!, + referenceKind: 'calls', + line: lineOf(source, absolute), + column: 0, + filePath, + language, + }); + } + addShaderReferences(filePath, language, source, file.id, unresolvedReferences); + for (const match of source.matchAll(/(?:&|Start-Process|Invoke-Expression)\s+["']([^"']+)["']/gi)) { + const invokedPath = match[1]!; + if (invokedPath.includes('$') || /\.(?:exe|cmd|bat|com)$/i.test(invokedPath)) continue; + if (!/\.(?:ps1|psm1)$/i.test(invokedPath)) continue; + unresolvedReferences.push({ + fromNodeId: file.id, + referenceName: invokedPath, + referenceKind: 'references', + line: lineOf(source, match.index ?? 0), + column: 0, + filePath, + language, + }); + } + return { nodes, edges, unresolvedReferences, errors: [], durationMs: 0 }; +} diff --git a/src/extraction/extraction-version.ts b/src/extraction/extraction-version.ts index 618a1b1c3..754afe274 100644 --- a/src/extraction/extraction-version.ts +++ b/src/extraction/extraction-version.ts @@ -21,4 +21,4 @@ * turns the re-index hint into noise — keep it honest (see CLAUDE.md, "Honesty * in the product is load-bearing"). */ -export const EXTRACTION_VERSION = 24; +export const EXTRACTION_VERSION = 52; diff --git a/src/extraction/glslfx-extractor.ts b/src/extraction/glslfx-extractor.ts new file mode 100644 index 000000000..91f217d25 --- /dev/null +++ b/src/extraction/glslfx-extractor.ts @@ -0,0 +1,257 @@ +import { Edge, ExtractionError, ExtractionResult, Node, UnresolvedReference } from '../types'; +import { generateNodeId } from './tree-sitter-helpers'; +import { TreeSitterExtractor } from './tree-sitter'; + +interface Section { + name: string; + content: string; + startLine: number; + markerLine: number; +} + +export class GlslfxExtractor { + private readonly nodes: Node[] = []; + private readonly edges: Edge[] = []; + private readonly unresolvedReferences: UnresolvedReference[] = []; + private readonly errors: ExtractionError[] = []; + private readonly fileNode: Node; + + constructor(private readonly filePath: string, private readonly source: string) { + const lines = source.split('\n'); + this.fileNode = { + id: `file:${filePath}`, + kind: 'file', + name: filePath.split(/[/\\]/).pop() || filePath, + qualifiedName: filePath, + filePath, + language: 'glsl', + startLine: 1, + endLine: lines.length, + startColumn: 0, + endColumn: lines[lines.length - 1]?.length || 0, + updatedAt: Date.now(), + }; + } + + extract(): ExtractionResult { + const started = Date.now(); + this.extractVersion(); + this.nodes.push(this.fileNode); + try { + this.extractImports(); + const sections = this.sections(); + const sectionNodes = new Map(); + for (const section of sections) { + const node = this.extractSection(section); + sectionNodes.set(section.name, [...(sectionNodes.get(section.name) ?? []), node]); + } + this.extractConfiguration(sectionNodes); + } catch (error) { + this.errors.push({ + message: `GLSLFX extraction error: ${error instanceof Error ? error.message : String(error)}`, + filePath: this.filePath, + severity: 'error', + code: 'parse_error', + }); + } + return { + nodes: this.nodes, + edges: this.edges, + unresolvedReferences: this.unresolvedReferences, + errors: this.errors, + durationMs: Date.now() - started, + }; + } + + private extractVersion(): void { + const match = this.source.match(/^--\s+glslfx\s+version\s+([^\s]+)\s*$/m); + if (!match) return; + const version = match[1]!; + this.fileNode.decorators = [`glslfx:${version}`]; + this.fileNode.signature = match[0]; + if (version !== '0.1') { + this.errors.push({ + message: `Unsupported GLSLFX version ${version}; parsing as the 0.1 container dialect`, + filePath: this.filePath, + line: this.lineAt(match.index ?? 0), + severity: 'warning', + code: 'glslfx_version', + }); + } + } + + private sections(): Section[] { + const markers = [...this.source.matchAll(/^--\s+glsl\s+([^\r\n]+?)\s*$/gm)]; + return markers.map((marker, index) => { + const contentStart = (marker.index ?? 0) + marker[0].length; + const next = markers[index + 1]?.index ?? this.source.length; + const content = this.source.slice(contentStart, next).replace(/^\r?\n/, ''); + const markerLine = this.lineAt(marker.index ?? 0); + return { name: marker[1]!.trim(), content, markerLine, startLine: markerLine + 1 }; + }); + } + + private extractSection(section: Section): Node { + const sectionId = generateNodeId(this.filePath, 'module', section.name, section.markerLine); + const sectionNode: Node = { + id: sectionId, + kind: 'module', + name: section.name, + qualifiedName: `${this.filePath}::${section.name}`, + filePath: this.filePath, + language: 'glsl', + signature: `-- glsl ${section.name}`, + decorators: ['glslfx:section'], + startLine: section.markerLine, + endLine: section.startLine + section.content.split('\n').length - 1, + startColumn: 0, + endColumn: 0, + updatedAt: Date.now(), + }; + this.nodes.push(sectionNode); + this.edges.push({ source: this.fileNode.id, target: sectionId, kind: 'contains' }); + + const embedded = new TreeSitterExtractor(`${this.filePath}#${section.name}.glsl`, section.content, 'glsl').extract(); + const idMap = new Map(); + const embeddedFileId = `file:${this.filePath}#${section.name}.glsl`; + idMap.set(embeddedFileId, sectionId); + for (const node of embedded.nodes) { + if (node.kind === 'file') continue; + const oldId = node.id; + node.startLine += section.startLine - 1; + node.endLine += section.startLine - 1; + node.filePath = this.filePath; + node.language = 'glsl'; + node.qualifiedName = `${section.name}::${node.qualifiedName}`; + node.id = generateNodeId(this.filePath, node.kind, `${section.name}::${node.name}`, node.startLine); + idMap.set(oldId, node.id); + this.nodes.push(node); + } + for (const edge of embedded.edges) { + const source = idMap.get(edge.source); + const target = idMap.get(edge.target); + if (!source || !target) continue; + this.edges.push({ ...edge, source, target, line: edge.line ? edge.line + section.startLine - 1 : undefined }); + } + for (const ref of embedded.unresolvedReferences) { + const fromNodeId = idMap.get(ref.fromNodeId); + if (!fromNodeId) continue; + this.unresolvedReferences.push({ + ...ref, + fromNodeId, + filePath: this.filePath, + language: 'glsl', + line: ref.line + section.startLine - 1, + }); + } + for (const error of embedded.errors) { + this.errors.push({ ...error, filePath: this.filePath, line: error.line ? error.line + section.startLine - 1 : undefined }); + } + return sectionNode; + } + + private extractImports(): void { + for (const match of this.source.matchAll(/^\s*#import\s+[<"]?([^>"\r\n]+)[>"]?\s*$/gm)) { + const moduleName = match[1]!.trim(); + const line = this.lineAt(match.index ?? 0); + const id = generateNodeId(this.filePath, 'import', moduleName, line); + this.nodes.push({ + id, + kind: 'import', + name: moduleName, + qualifiedName: `${this.filePath}::import:${moduleName}`, + filePath: this.filePath, + language: 'glsl', + signature: match[0].trim(), + startLine: line, + endLine: line, + startColumn: 0, + endColumn: match[0].length, + updatedAt: Date.now(), + }); + this.edges.push({ source: this.fileNode.id, target: id, kind: 'contains' }); + this.unresolvedReferences.push({ + fromNodeId: this.fileNode.id, + referenceName: moduleName, + referenceKind: 'imports', + filePath: this.filePath, + language: 'glsl', + line, + column: 0, + }); + } + } + + private extractConfiguration(sectionNodes: Map): void { + const marker = /^--\s+configuration\s*$/gm.exec(this.source); + if (!marker) return; + const start = marker.index + marker[0].length; + const tail = this.source.slice(start); + const nextMarker = /^--\s+/m.exec(tail); + const raw = tail.slice(0, nextMarker?.index ?? tail.length).trim(); + if (!raw) return; + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + this.errors.push({ + message: 'GLSLFX configuration block is not valid JSON', + filePath: this.filePath, + line: this.lineAt(marker.index), + severity: 'warning', + code: 'glslfx_config', + }); + return; + } + const techniques = (parsed as { techniques?: Record }).techniques; + if (!techniques || typeof techniques !== 'object') return; + for (const [name, value] of Object.entries(techniques)) { + const line = this.lineAt(marker.index); + const id = generateNodeId(this.filePath, 'component', name, line); + const technique: Node = { + id, + kind: 'component', + name, + qualifiedName: `${this.filePath}::technique:${name}`, + filePath: this.filePath, + language: 'glsl', + signature: JSON.stringify(value).slice(0, 400), + decorators: ['glslfx:technique'], + startLine: line, + endLine: line, + startColumn: 0, + endColumn: 0, + updatedAt: Date.now(), + }; + this.nodes.push(technique); + this.edges.push({ source: this.fileNode.id, target: id, kind: 'contains' }); + const strings: string[] = []; + const walk = (item: unknown): void => { + if (typeof item === 'string') strings.push(item); + else if (Array.isArray(item)) item.forEach(walk); + else if (item && typeof item === 'object') Object.values(item).forEach(walk); + }; + walk(value); + for (const sectionName of new Set(strings)) { + const sections = sectionNodes.get(sectionName) ?? []; + if (sections.length === 1) { + this.edges.push({ source: id, target: sections[0]!.id, kind: 'references' }); + } else if (/\.(?:glslfx|glsl|hlsl|hlsli)$/i.test(sectionName)) { + this.unresolvedReferences.push({ + fromNodeId: id, + referenceName: sectionName, + referenceKind: 'imports', + filePath: this.filePath, + language: 'glsl', + line, + column: 0, + }); + } + } + } + } + + private lineAt(index: number): number { + return (this.source.slice(0, index).match(/\n/g) || []).length + 1; + } +} diff --git a/src/extraction/grammars.ts b/src/extraction/grammars.ts index 2c435b9cc..dca1f0f0a 100644 --- a/src/extraction/grammars.ts +++ b/src/extraction/grammars.ts @@ -11,7 +11,7 @@ import * as fsp from 'fs/promises'; import { Parser, Language as WasmLanguage } from 'web-tree-sitter'; import { Language } from '../types'; -export type GrammarLanguage = Exclude; +export type GrammarLanguage = Exclude; /** * WASM filename map — maps each language to its .wasm grammar file @@ -50,6 +50,8 @@ const WASM_GRAMMAR_FILES: Record = { terraform: 'tree-sitter-terraform.wasm', arkts: 'tree-sitter-arkts.wasm', nix: 'tree-sitter-nix.wasm', + glsl: 'tree-sitter-glsl.wasm', + hlsl: 'tree-sitter-hlsl.wasm', }; /** @@ -170,6 +172,32 @@ export const EXTENSION_MAP: Record = { '.tf': 'terraform', '.tfvars': 'terraform', '.tofu': 'terraform', + // OpenGL/Vulkan shading languages, including Vulkan ray tracing and mesh stages. + '.glsl': 'glsl', + '.vert': 'glsl', + '.frag': 'glsl', + '.comp': 'glsl', + '.geom': 'glsl', + '.tesc': 'glsl', + '.tese': 'glsl', + '.rgen': 'glsl', + '.rmiss': 'glsl', + '.rchit': 'glsl', + '.rahit': 'glsl', + '.rint': 'glsl', + '.rcall': 'glsl', + '.mesh': 'glsl', + '.task': 'glsl', + // OpenUSD GLSLFX is a container around named GLSL sections. + '.glslfx': 'glsl', + '.hlsl': 'hlsl', + '.hlsli': 'hlsl', + '.fx': 'hlsl', + '.fxh': 'hlsl', + '.cmake': 'cmake', + '.ps1': 'powershell', + '.psm1': 'powershell', + '.psd1': 'powershell', }; /** @@ -182,6 +210,7 @@ export const EXTENSION_MAP: Record = { * to the built-ins. Omitting it is byte-identical to the zero-config behavior. */ export function isSourceFile(filePath: string, overrides?: Record): boolean { + if (/(^|[\\/])CMakeLists\.txt$/i.test(filePath)) return true; if (isPlayRoutesFile(filePath)) return true; // Play `conf/routes` is extensionless if (isShopifyLiquidJson(filePath)) return true; // Shopify OS 2.0 JSON templates / section groups if (isErlangAppFile(filePath)) return true; // OTP `.app`/`.app.src` resource files @@ -297,6 +326,7 @@ const VENDORED_WASM_LANGS: ReadonlySet = new Set([ // the crates.io tarballs. `.metal`/`.cu` map to language 'cpp', so the // dialects ride the same (single, coherent) upgraded grammar. 'c', 'cpp', + 'glsl', 'hlsl', ]); /** Absolute path of a language's grammar WASM (vendored or tree-sitter-wasms). */ @@ -434,6 +464,7 @@ export function getParser(language: Language): Parser | null { * `EXTENSION_MAP`. Omitting it is byte-identical to the zero-config behavior. */ export function detectLanguage(filePath: string, source?: string, overrides?: Record): Language { + if (/(^|[\\/])CMakeLists\.txt$/i.test(filePath)) return 'cmake'; // Play `conf/routes` has no grammar — route through the no-symbol path; the // Play framework resolver extracts route nodes from it. if (isPlayRoutesFile(filePath)) return 'yaml'; @@ -503,7 +534,7 @@ export function isLanguageSupported(language: Language): boolean { * Check if a grammar has been loaded and is ready for parsing. */ export function isGrammarLoaded(language: Language): boolean { - if (language === 'svelte' || language === 'vue' || language === 'astro' || language === 'liquid' || language === 'razor') return true; + if (language === 'svelte' || language === 'vue' || language === 'astro' || language === 'liquid' || language === 'razor' || language === 'cmake' || language === 'powershell') return true; if (language === 'yaml' || language === 'twig') return true; // no WASM grammar needed if (language === 'xml' || language === 'properties') return true; // no WASM grammar needed return languageCache.has(language); @@ -526,7 +557,7 @@ export function isFileLevelOnlyLanguage(language: Language): boolean { * Get all supported languages (those with grammar definitions). */ export function getSupportedLanguages(): Language[] { - return [...(Object.keys(WASM_GRAMMAR_FILES) as GrammarLanguage[]), 'svelte', 'vue', 'astro', 'liquid']; + return [...(Object.keys(WASM_GRAMMAR_FILES) as GrammarLanguage[]), 'svelte', 'vue', 'astro', 'liquid', 'cmake', 'powershell']; } /** @@ -614,6 +645,10 @@ export function getLanguageDisplayName(language: Language): string { erlang: 'Erlang', terraform: 'Terraform', arkts: 'ArkTS', + glsl: 'GLSL / Vulkan GLSL', + hlsl: 'HLSL', + cmake: 'CMake', + powershell: 'PowerShell', unknown: 'Unknown', }; return names[language] || language; diff --git a/src/extraction/languages/glsl.ts b/src/extraction/languages/glsl.ts new file mode 100644 index 000000000..a77ecdf6d --- /dev/null +++ b/src/extraction/languages/glsl.ts @@ -0,0 +1,264 @@ +import type { Node as SyntaxNode } from 'web-tree-sitter'; +import type { LanguageExtractor } from '../tree-sitter-types'; +import { getChildByField, getNodeText, getPrecedingDocstring } from '../tree-sitter-helpers'; +import { + isShaderBuiltinCall, + previousShaderPrefix, + shaderDeclarationNames, + shaderDecorators, + shaderFunctionName, + shaderImport, + shaderMacroName, + recoverShaderMacroBodyReferences, + recoverShaderMacroDeclarations, + shaderSignature, +} from './shader-common'; + +function createFields(node: SyntaxNode, ctx: Parameters>[1]): void { + for (const field of node.descendantsOfType('field_declaration')) { + for (const item of shaderDeclarationNames(field, ctx.source)) { + ctx.createNode('field', item.name, item.node, { + signature: shaderSignature(field, ctx.source), + decorators: shaderDecorators(field, ctx.source, ctx.filePath, 'glsl'), + }); + } + } +} + +const recoveredSiblingKeys = new Set(); + +function recoveryKey(node: SyntaxNode): string { + return `${node.type}:${node.startIndex}:${node.endIndex}`; +} + +function isOrphanFunctionNode(node: SyntaxNode, source: string): boolean { + if (node.type === 'function_declarator') { + return node.parent?.type !== 'function_definition' && node.parent?.type !== 'function_declaration'; + } + return node.type === 'ERROR' && + node.namedChild(0)?.type === 'declarator' && + !!shaderFunctionName(node, source); +} + +function containsOrphanFunction(node: SyntaxNode, source: string): boolean { + return node.descendantsOfType(['function_declarator', 'ERROR']).some((candidate) => + isOrphanFunctionNode(candidate, source) + ); +} + +function functionSignature(node: SyntaxNode, source: string): string | undefined { + const declarator = node.type === 'function_declarator' + ? node + : node.namedChildren.find((child) => child.type === 'function_declarator'); + const params = declarator ? getChildByField(declarator, 'parameters') : getChildByField(node, 'parameters'); + const ret = declarator ? getChildByField(declarator, 'return_type') : getChildByField(node, 'return_type'); + const name = shaderFunctionName(node, source); + if (!name) return undefined; + const parameterText = params ? getNodeText(params, source).trim() : ''; + const parameterList = parameterText.startsWith('(') ? parameterText : `(${parameterText})`; + return `${ret ? getNodeText(ret, source).trim() + ' ' : ''}${name}${parameterList}`.trim(); +} + +function recoveredFunctionSignature(node: SyntaxNode, source: string): string | undefined { + const name = shaderFunctionName(node, source); + if (!name) return undefined; + const text = source.slice(node.startIndex, node.endIndex); + const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const match = text.match(new RegExp(`(?:^|[;{}\\n])\\s*(.*?)\\b${escaped}\\s*\\(([\\s\\S]*?)\\)`, 'm')); + let returnType = match?.[1] ?? ''; + let parameters = match?.[2] ?? ''; + if (!match) { + // ERROR nodes can end before the declarator's closing parenthesis. Fall + // back to the source span around the recovered name so the signature is + // still useful even when the grammar's node boundaries are incomplete. + const nameIndex = source.indexOf(`${name}(`, node.startIndex); + const open = nameIndex >= 0 ? source.indexOf('(', nameIndex + name.length) : -1; + const close = open >= 0 ? source.indexOf(')', open + 1) : -1; + if (nameIndex < 0 || open < 0 || close < 0) return undefined; + const before = source.slice(Math.max(0, nameIndex - 120), nameIndex); + returnType = before.match(/(?:^|[;{}])\s*([A-Za-z_]\w*(?:\s+[A-Za-z_]\w*)*)\s*$/)?.[1] ?? ''; + parameters = source.slice(open + 1, close); + } + returnType = returnType.replace(/[{};]/g, ' ').replace(/\s+/g, ' ').trim(); + parameters = parameters.replace(/\s+/g, ' ').trim(); + return `${returnType ? `${returnType} ` : ''}${name}(${parameters})`.trim(); +} + +/** + * The GLSL grammar can detach a valid function declarator from its body when + * error recovery spans preprocessor-heavy code. Recover only the narrow AST + * shape it emits: an orphan declarator immediately followed by a statement list. + */ +function recoverOrphanFunction( + node: SyntaxNode, + ctx: Parameters>[1], +): boolean { + if (node.type !== 'function_declarator' && node.type !== 'ERROR') return false; + if (node.type === 'function_declarator' && + (node.parent?.type === 'function_definition' || node.parent?.type === 'function_declaration')) return false; + if (node.type === 'ERROR' && node.namedChild(0)?.type !== 'declarator') return false; + const parts: SyntaxNode[] = []; + const directBody = node.nextNamedSibling; + if (directBody && (directBody.type === 'statement_list' || directBody.type === 'compound_statement')) { + parts.push(directBody); + } else { + const parent = node.parent; + if (!parent) return false; + let after = false; + for (let i = 0; i < parent.namedChildCount; i++) { + const child = parent.namedChild(i); + if (!child) continue; + if (child.equals(node)) { + after = true; + continue; + } + if (!after) continue; + if ( + child.type === 'function_definition' || + child.type === 'function_declaration' || + child.type === 'function_declarator' || + (child.type === 'ERROR' && child.namedChild(0)?.type === 'declarator') + ) break; + if (child.type === 'comment' && /^#\s*endif\b/.test(getNodeText(child, ctx.source).trim())) break; + parts.push(child); + } + } + if (parts.length === 0) return false; + const name = shaderFunctionName(node, ctx.source); + if (!name) return false; + const lastPart = parts[parts.length - 1]!; + const created = ctx.createNode('function', name, node, { + docstring: getPrecedingDocstring(node, ctx.source), + signature: node.type === 'ERROR' ? recoveredFunctionSignature(node, ctx.source) : functionSignature(node, ctx.source), + endLine: lastPart.endPosition.row + 1, + endColumn: lastPart.endPosition.column, + }); + if (created) { + ctx.pushScope(created.id); + for (const part of parts) { + recoveredSiblingKeys.add(recoveryKey(part)); + visitRecoveredRegion(part, ctx, created.id); + } + ctx.popScope(); + } + return true; +} + +function visitRecoveredRegion( + node: SyntaxNode, + ctx: Parameters>[1], + ownerId: string, +): void { + if (!containsOrphanFunction(node, ctx.source)) { + ctx.visitFunctionBody(node, ownerId); + return; + } + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (!child || recoveredSiblingKeys.delete(recoveryKey(child))) continue; + if (isOrphanFunctionNode(child, ctx.source)) { + recoverOrphanFunction(child, ctx); + } else if (containsOrphanFunction(child, ctx.source)) { + visitRecoveredRegion(child, ctx, ownerId); + } else { + ctx.visitFunctionBody(child, ownerId); + } + } +} + +export const glslExtractor: LanguageExtractor = { + functionTypes: ['function_definition', 'function_declaration'], + classTypes: [], + methodTypes: [], + interfaceTypes: [], + structTypes: [], + enumTypes: [], + typeAliasTypes: [], + importTypes: ['preproc_call'], + callTypes: ['function_call', 'macro_invocation'], + variableTypes: [], + fieldTypes: [], + nameField: 'name', + bodyField: 'body', + resolveBody: (node) => node.namedChildren.find((child) => child.type === 'compound_statement') ?? null, + paramsField: 'parameters', + returnField: 'return_type', + resolveName: shaderFunctionName, + getSignature: functionSignature, + extractDecorators: (node, source, filePath) => { + const decorators = shaderDecorators(node, source, filePath, 'glsl') ?? []; + if (node.type.startsWith('function_') && shaderFunctionName(node, source) === 'main') decorators.push('entrypoint'); + return [...new Set(decorators)]; + }, + extractImport: shaderImport, + isBuiltinCall: isShaderBuiltinCall, + visitNode: (node, ctx) => { + if (recoveredSiblingKeys.delete(recoveryKey(node))) return true; + if (node.type === 'translation_unit') recoverShaderMacroDeclarations(node, ctx, 'glsl'); + if (recoverOrphanFunction(node, ctx)) return true; + + if (node.type === 'struct_specifier') { + const nameNode = getChildByField(node, 'name') || node.namedChildren.find((c) => c.type === 'identifier'); + const fieldList = node.namedChildren.find((c) => c.type === 'field_declaration_list'); + if (!nameNode || !fieldList) return false; + const struct = ctx.createNode('struct', getNodeText(nameNode, ctx.source), node, { + signature: shaderSignature(node, ctx.source), + decorators: shaderDecorators(node, ctx.source, ctx.filePath, 'glsl'), + }); + if (struct) { + ctx.pushScope(struct.id); + createFields(fieldList, ctx); + ctx.popScope(); + } + return true; + } + + if (node.type === 'preproc_def' || node.type === 'preproc_function_def') { + const name = shaderMacroName(node, ctx.source); + if (!name) return false; + const kind = node.type === 'preproc_function_def' ? 'function' : 'constant'; + const created = ctx.createNode(kind, name, node, { + signature: shaderSignature(node, ctx.source), + decorators: shaderDecorators(node, ctx.source, ctx.filePath, 'glsl'), + }); + if (created && kind === 'function') { + recoverShaderMacroBodyReferences(node, created.id, ctx); + } + return true; + } + + if (node.type === 'field_declaration') { + createFields(node, ctx); + return true; + } + + if (node.type !== 'declaration') return false; + if (node.descendantsOfType('struct_specifier').length > 0) return false; + + const fieldList = node.namedChildren.find((c) => c.type === 'field_declaration_list'); + if (fieldList) { + const nameNode = getChildByField(node, 'name') || node.namedChildren.find((c) => c.type === 'identifier'); + if (!nameNode) return false; + const block = ctx.createNode('struct', getNodeText(nameNode, ctx.source), node, { + signature: shaderSignature(node, ctx.source), + decorators: shaderDecorators(node, ctx.source, ctx.filePath, 'glsl', previousShaderPrefix(node, ctx.source)), + }); + if (block) { + ctx.pushScope(block.id); + createFields(fieldList, ctx); + ctx.popScope(); + } + return true; + } + + const decorators = shaderDecorators(node, ctx.source, ctx.filePath, 'glsl', previousShaderPrefix(node, ctx.source)); + const isConst = /\bconst\b/.test(getNodeText(node, ctx.source)); + for (const item of shaderDeclarationNames(node, ctx.source)) { + ctx.createNode(isConst ? 'constant' : 'variable', item.name, item.node, { + signature: shaderSignature(node, ctx.source), + decorators, + }); + } + return true; + }, +}; diff --git a/src/extraction/languages/hlsl.ts b/src/extraction/languages/hlsl.ts new file mode 100644 index 000000000..7b7d62592 --- /dev/null +++ b/src/extraction/languages/hlsl.ts @@ -0,0 +1,198 @@ +import type { Node as SyntaxNode } from 'web-tree-sitter'; +import type { LanguageExtractor } from '../tree-sitter-types'; +import { getChildByField, getNodeText } from '../tree-sitter-helpers'; +import { + isShaderBuiltinCall, + previousShaderPrefix, + recoverShaderMacroBodyReferences, + recoverShaderMacroDeclarations, + shaderDeclarationNames, + shaderDecorators, + shaderFunctionName, + shaderImport, + shaderMacroName, + shaderSignature, + splitMacroArgs, +} from './shader-common'; + +type ShaderCtx = Parameters>[1]; + +function declarationType(node: SyntaxNode, source: string): string { + const type = getChildByField(node, 'type') || node.namedChildren.find((c) => c.type === 'type_identifier' || c.type === 'primitive_type' || c.type === 'template_type'); + return type ? getNodeText(type, source).trim() : ''; +} + +function createBufferFields(body: SyntaxNode, ctx: ShaderCtx): void { + for (const declaration of body.namedChildren.filter((c) => c.type === 'declaration')) { + for (const item of shaderDeclarationNames(declaration, ctx.source)) { + ctx.createNode('field', item.name, item.node, { + signature: shaderSignature(declaration, ctx.source), + decorators: shaderDecorators(declaration, ctx.source, ctx.filePath, 'hlsl'), + }); + } + } +} + +function bufferDeclarationBefore(body: SyntaxNode, source: string): SyntaxNode | null { + const previous = body.previousNamedSibling; + return previous?.type === 'declaration' && /^(?:cbuffer|tbuffer)\b/.test(declarationType(previous, source)) ? previous : null; +} + +function recoverNrdResourceText(text: string, node: SyntaxNode, ctx: ShaderCtx): boolean { + text = text.trim(); + const name = text.match(/^(NRD_(?:INPUT|OUTPUT|SAMPLER|CONSTANTS_START))\s*\(/)?.[1]; + if (!name) return false; + const args = splitMacroArgs(text); + if (name === 'NRD_CONSTANTS_START') { + if (args[0]) ctx.createNode('struct', args[0], node, { signature: text, decorators: ['resource:constant-buffer'] }); + return true; + } + if (args.length < 4) return false; + const resourceName = args[2] || args[1]; + const registerClass = args[args.length - 2]; + const registerIndex = args[args.length - 1]; + if (!resourceName) return false; + const decorators = shaderDecorators(node, ctx.source, ctx.filePath, 'hlsl') ?? []; + if (/^[tsub]$/.test(registerClass || '') && /^\d+$/.test(registerIndex || '')) decorators.push(`register:${registerClass}${registerIndex}`); + decorators.push(name === 'NRD_OUTPUT' ? 'resource:storage' : name === 'NRD_SAMPLER' ? 'resource:sampler' : 'resource:texture'); + ctx.createNode('variable', resourceName, node, { signature: text, decorators: [...new Set(decorators)] }); + return true; +} + +function recoverNrdResourcesFromRoot(root: SyntaxNode, ctx: ShaderCtx): void { + const lines = ctx.source.split(/\r?\n/); + for (let row = 0; row < lines.length; row++) { + const text = lines[row]!.trim(); + if (!/^NRD_(?:INPUT|OUTPUT|SAMPLER|CONSTANTS_START)\s*\(/.test(text)) continue; + const positionNode = root.namedDescendantForPosition({ row, column: Math.max(0, lines[row]!.search(/\S/)) }); + if (positionNode) recoverNrdResourceText(text, positionNode, ctx); + } +} + +function containsNrdResourceMacro(node: SyntaxNode, source: string): boolean { + return /\bNRD_(?:INPUT|OUTPUT|SAMPLER|CONSTANTS_START)\s*\(/.test(getNodeText(node, source)); +} + +export const hlslExtractor: LanguageExtractor = { + functionTypes: ['function_definition'], + classTypes: ['class_specifier'], + methodTypes: [], + interfaceTypes: [], + structTypes: [], + enumTypes: ['enum_specifier'], + typeAliasTypes: ['alias_declaration', 'type_definition'], + importTypes: ['preproc_include'], + callTypes: ['call_expression', 'macro_invocation'], + variableTypes: [], + fieldTypes: [], + nameField: 'declarator', + bodyField: 'body', + paramsField: 'parameters', + returnField: 'type', + resolveName: shaderFunctionName, + getSignature: (node, source) => { + const declarator = getChildByField(node, 'declarator') || node.namedChildren.find((c) => c.type === 'function_declarator'); + const params = declarator ? getChildByField(declarator, 'parameters') : null; + const ret = getChildByField(node, 'type'); + const name = shaderFunctionName(node, source); + if (!name) return undefined; + const parameterText = params ? getNodeText(params, source).trim() : ''; + const parameterList = parameterText.startsWith('(') ? parameterText : `(${parameterText})`; + return `${ret ? getNodeText(ret, source).trim() + ' ' : ''}${name}${parameterList}`.trim(); + }, + extractDecorators: (node, source, filePath) => { + const prefix = source.slice(Math.max(0, node.startIndex - 240), node.startIndex); + const attributes = `${prefix} ${node.descendantsOfType('hlsl_attribute').map((attr) => getNodeText(attr, source)).join(' ')}`; + const decorators = shaderDecorators(node, source, filePath, 'hlsl', attributes) ?? []; + const name = shaderFunctionName(node, source); + if (node.type === 'function_definition' && (name === 'main' || name === 'NRD_CS_MAIN' || /\b(?:numthreads|shader\s*\()/.test(getNodeText(node, source)))) { + decorators.push('entrypoint'); + } + return [...new Set(decorators)]; + }, + extractImport: shaderImport, + isBuiltinCall: isShaderBuiltinCall, + visitNode: (node, ctx) => { + if (node.type === 'translation_unit') { + recoverNrdResourcesFromRoot(node, ctx); + recoverShaderMacroDeclarations(node, ctx, 'hlsl'); + } + + if (node.type === 'struct_specifier') { + const nameNode = getChildByField(node, 'name') || node.namedChildren.find((c) => c.type === 'type_identifier' || c.type === 'identifier'); + const body = getChildByField(node, 'body') || node.namedChildren.find((c) => c.type === 'field_declaration_list'); + if (!nameNode || !body) return false; + const struct = ctx.createNode('struct', getNodeText(nameNode, ctx.source), node, { + signature: shaderSignature(node, ctx.source), + decorators: shaderDecorators(node, ctx.source, ctx.filePath, 'hlsl'), + }); + if (struct) { + ctx.pushScope(struct.id); + for (const field of body.descendantsOfType(['field_declaration', 'declaration'])) { + for (const item of shaderDeclarationNames(field, ctx.source)) { + ctx.createNode('field', item.name, item.node, { signature: shaderSignature(field, ctx.source) }); + } + } + ctx.popScope(); + } + return true; + } + + if (node.type === 'preproc_def' || node.type === 'preproc_function_def') { + const name = shaderMacroName(node, ctx.source); + if (!name) return false; + const kind = node.type === 'preproc_function_def' ? 'function' : 'constant'; + const created = ctx.createNode(kind, name, node, { + signature: shaderSignature(node, ctx.source), + decorators: shaderDecorators(node, ctx.source, ctx.filePath, 'hlsl'), + }); + if (created && kind === 'function') { + recoverShaderMacroBodyReferences(node, created.id, ctx); + } + return true; + } + + if ( + containsNrdResourceMacro(node, ctx.source) && + (node.type === 'expression_statement' || node.type === 'declaration' || node.type === 'ERROR' || node.type === 'call_expression') + ) return true; + + if (node.type === 'compound_statement' && bufferDeclarationBefore(node, ctx.source)) return true; + if (node.type !== 'declaration') return false; + + if (node.parent?.type === 'compound_statement' && bufferDeclarationBefore(node.parent, ctx.source)) return true; + for (let parent = node.parent; parent; parent = parent.parent) { + if (parent.type === 'function_definition') return false; + } + if (node.descendantsOfType(['struct_specifier', 'class_specifier', 'enum_specifier']).length > 0) return false; + + const type = declarationType(node, ctx.source); + if (/^(?:cbuffer|tbuffer)\b/.test(type)) { + const names = shaderDeclarationNames(node, ctx.source); + const name = names[0]?.name; + if (!name) return false; + const block = ctx.createNode('struct', name, node, { + signature: shaderSignature(node, ctx.source), + decorators: shaderDecorators(node, ctx.source, ctx.filePath, 'hlsl', previousShaderPrefix(node, ctx.source)), + }); + const body = node.nextNamedSibling; + if (block && body?.type === 'compound_statement') { + ctx.pushScope(block.id); + createBufferFields(body, ctx); + ctx.popScope(); + } + return true; + } + + const text = getNodeText(node, ctx.source); + const decorators = shaderDecorators(node, ctx.source, ctx.filePath, 'hlsl', previousShaderPrefix(node, ctx.source)); + const isConst = /\b(?:const|static\s+const)\b/.test(text); + for (const item of shaderDeclarationNames(node, ctx.source)) { + ctx.createNode(isConst ? 'constant' : 'variable', item.name, item.node, { + signature: shaderSignature(node, ctx.source), + decorators, + }); + } + return true; + }, +}; diff --git a/src/extraction/languages/index.ts b/src/extraction/languages/index.ts index 6b760b01d..abc045240 100644 --- a/src/extraction/languages/index.ts +++ b/src/extraction/languages/index.ts @@ -36,6 +36,8 @@ import { solidityExtractor } from './solidity'; import { terraformExtractor } from './terraform'; import { arktsExtractor } from './arkts'; import { nixExtractor } from './nix'; +import { glslExtractor } from './glsl'; +import { hlslExtractor } from './hlsl'; export const EXTRACTORS: Partial> = { typescript: typescriptExtractor, @@ -69,4 +71,6 @@ export const EXTRACTORS: Partial> = { terraform: terraformExtractor, arkts: arktsExtractor, nix: nixExtractor, + glsl: glslExtractor, + hlsl: hlslExtractor, }; diff --git a/src/extraction/languages/shader-common.ts b/src/extraction/languages/shader-common.ts new file mode 100644 index 000000000..582ae808b --- /dev/null +++ b/src/extraction/languages/shader-common.ts @@ -0,0 +1,445 @@ +import type { Node as SyntaxNode } from 'web-tree-sitter'; +import type { Language } from '../../types'; +import type { ExtractorContext } from '../tree-sitter-types'; +import { getChildByField, getNodeText } from '../tree-sitter-helpers'; + +const SHADER_STAGES: Record = { + '.vert': 'vertex', + '.frag': 'fragment', + '.comp': 'compute', + '.geom': 'geometry', + '.tesc': 'tess-control', + '.tese': 'tess-evaluation', + '.rgen': 'ray-generation', + '.rmiss': 'ray-miss', + '.rchit': 'closest-hit', + '.rahit': 'any-hit', + '.rint': 'intersection', + '.rcall': 'callable', + '.mesh': 'mesh', + '.task': 'task', +}; + +const BUILTIN_CALLS = new Set([ + 'abs', 'acos', 'all', 'any', 'asfloat', 'asint', 'asuint', 'atan', 'atan2', + 'barrier', 'bitCount', 'ceil', 'clamp', 'cos', 'cross', 'ddx', 'ddy', + 'determinant', 'discard', 'distance', 'dot', 'dFdx', 'dFdy', 'exp', 'exp2', + 'faceforward', 'floor', 'fma', 'frac', 'fract', 'frexp', 'fwidth', 'InterlockedAdd', + 'InterlockedAnd', 'InterlockedCompareExchange', 'InterlockedExchange', 'InterlockedMax', + 'InterlockedMin', 'InterlockedOr', 'InterlockedXor', 'isinf', 'isnan', 'ldexp', + 'length', 'lerp', 'lit', 'log', 'log2', 'mad', 'max', 'min', 'mix', 'mod', + 'modf', 'mul', 'normalize', 'pow', 'printf', 'reflect', 'refract', 'round', + 'rsqrt', 'saturate', 'sign', 'sin', 'sincos', 'smoothstep', 'sqrt', 'step', + 'tan', 'texelFetch', 'texture', 'textureGrad', 'textureLod', 'textureSize', + 'transpose', 'trunc', 'WaveActiveAllTrue', 'WaveActiveAnyTrue', 'WaveActiveBallot', + 'WaveActiveCountBits', 'WaveActiveMax', 'WaveActiveMin', 'WaveActiveProduct', + 'WaveActiveSum', 'WaveGetLaneCount', 'WaveGetLaneIndex', 'WaveReadLaneAt', + 'WaveReadLaneFirst', 'GroupMemoryBarrier', 'GroupMemoryBarrierWithGroupSync', + 'DeviceMemoryBarrier', 'DeviceMemoryBarrierWithGroupSync', 'AllMemoryBarrier', + 'AllMemoryBarrierWithGroupSync', 'Sample', 'SampleBias', 'SampleCmp', 'SampleCmpLevelZero', + 'SampleGrad', 'SampleLevel', 'Gather', 'GatherRed', 'GatherGreen', 'GatherBlue', + 'GatherAlpha', 'Load', 'Store', 'GetDimensions', 'CalculateLevelOfDetail', + 'traceRayEXT', 'executeCallableEXT', 'rayQueryInitializeEXT', 'rayQueryProceedEXT', +]); + +export function shaderStageForPath(filePath: string): string | undefined { + const lower = filePath.toLowerCase(); + for (const [ext, stage] of Object.entries(SHADER_STAGES)) { + if (lower.endsWith(ext)) return stage; + } + const hlslStage = lower.match(/\.([a-z]{2,4})\.hlsl$/)?.[1]; + if (!hlslStage) return undefined; + return ({ vs: 'vertex', ps: 'fragment', fs: 'fragment', cs: 'compute', gs: 'geometry', + hs: 'tess-control', ds: 'tess-evaluation', ms: 'mesh', as: 'task', + rgen: 'ray-generation', rmiss: 'ray-miss', rchit: 'closest-hit', + rahit: 'any-hit', rint: 'intersection', rcall: 'callable' } as Record)[hlslStage]; +} + +function normalizeToken(value: string): string { + return value.trim().replace(/\s+/g, ''); +} + +let cachedConditionSource = ''; +let cachedConditions: string[][] = []; +let cachedFunctionSource = ''; +let cachedFunctionNames = new Set(); + +function buildConditionCache(source: string): string[][] { + if (source === cachedConditionSource) return cachedConditions; + const lines = source.split(/\r?\n/); + const result: string[][] = Array.from({ length: lines.length + 1 }, () => []); + const stack: Array<{ alternatives: string[]; active: string }> = []; + for (let index = 0; index < lines.length; index++) { + result[index + 1] = stack.map((frame) => `pp:${frame.active}`); + const line = lines[index]!; + let match = line.match(/^\s*#\s*if\s+(.+)$/); + if (match) { + const condition = normalizeToken(match[1]!); + stack.push({ alternatives: [condition], active: condition }); + continue; + } + match = line.match(/^\s*#\s*ifdef\s+([A-Za-z_]\w*)/); + if (match) { + const condition = `defined(${match[1]})`; + stack.push({ alternatives: [condition], active: condition }); + continue; + } + match = line.match(/^\s*#\s*ifndef\s+([A-Za-z_]\w*)/); + if (match) { + const condition = `!defined(${match[1]})`; + stack.push({ alternatives: [condition], active: condition }); + continue; + } + match = line.match(/^\s*#\s*elif\s+(.+)$/); + if (match && stack.length > 0) { + const frame = stack[stack.length - 1]!; + const condition = normalizeToken(match[1]!); + frame.active = `!(${frame.alternatives.join('||')})&&(${condition})`; + frame.alternatives.push(condition); + continue; + } + if (/^\s*#\s*else\b/.test(line) && stack.length > 0) { + const frame = stack[stack.length - 1]!; + frame.active = `!(${frame.alternatives.join('||')})`; + continue; + } + if (/^\s*#\s*endif\b/.test(line)) stack.pop(); + } + cachedConditionSource = source; + cachedConditions = result; + return result; +} + +function conditionDecorators(node: SyntaxNode, source: string): string[] { + return buildConditionCache(source)[node.startPosition.row + 1] ?? []; +} + +export function shaderDecorators( + node: SyntaxNode, + source: string, + filePath: string, + language: Extract, + extraText = '', +): string[] | undefined { + const text = `${extraText}\n${getNodeText(node, source)}`; + const out = new Set(conditionDecorators(node, source)); + const stage = shaderStageForPath(filePath); + if (stage) out.add(`shader:${stage}`); + + for (const match of text.matchAll(/layout\s*\(([^)]*)\)/g)) { + for (const raw of match[1]!.split(',')) { + const part = raw.trim(); + if (!part) continue; + const kv = part.match(/^([A-Za-z_]\w*)\s*=\s*([^,]+)$/); + if (kv) out.add(`${kv[1]}:${normalizeToken(kv[2]!)}`); + else out.add(`layout:${normalizeToken(part)}`); + } + } + + for (const match of text.matchAll(/register\s*\(\s*([tsub]\d+)\s*(?:,\s*space(\d+))?\s*\)/gi)) { + out.add(`register:${match[1]!.toLowerCase()}`); + if (match[2]) out.add(`space:${match[2]}`); + } + for (const match of text.matchAll(/vk::binding\s*\(\s*(\d+)\s*,\s*(\d+)\s*\)/g)) { + out.add(`binding:${match[1]}`); + out.add(`set:${match[2]}`); + } + for (const match of text.matchAll(/vk::constant_id\s*\(\s*(\d+)\s*\)/g)) out.add(`constant_id:${match[1]}`); + for (const match of text.matchAll(/vk::([A-Za-z_]\w*)\s*(?:\(([^)]*)\))?/g)) { + out.add(`vk:${match[1]}${match[2] ? ':' + normalizeToken(match[2]) : ''}`); + } + if (language === 'hlsl') { + for (const match of text.matchAll(/:\s*([A-Za-z_]\w*)\b/g)) { + if (!/^(?:register|packoffset)$/i.test(match[1]!)) out.add(`semantic:${match[1]}`); + } + for (const match of text.matchAll(/(? d.startsWith('constant_id:'))) out.add('specialization_constant'); + if (/\buniform\b/.test(text)) out.add('storage:uniform'); + if (/\bbuffer\b/.test(text)) out.add('storage:buffer'); + if (/\b(groupshared|shared)\b/.test(text)) out.add('storage:shared'); + if (/\bin\b/.test(text)) out.add('interface:in'); + if (/\bout\b/.test(text)) out.add('interface:out'); + if (/\b(rayPayload|rayPayloadEXT)\b/.test(text)) out.add('resource:ray-payload'); + if (/\b(hitAttribute|hitAttributeEXT)\b/.test(text)) out.add('resource:hit-attribute'); + if (/\b(callableData|callableDataEXT)\b/.test(text)) out.add('resource:callable-data'); + if (/\b(accelerationStructureEXT|RaytracingAccelerationStructure)\b/.test(text)) out.add('resource:acceleration-structure'); + if (/\bRW(?:Texture|Buffer)|\bimage\w*\b/.test(text)) out.add('resource:storage'); + else if (/\bTexture\w*\b|\btexture\w*\b/.test(text)) out.add('resource:texture'); + if (/\bSampler\w*\b|\bsampler\w*\b/.test(text)) out.add('resource:sampler'); + if (/\b(?:StructuredBuffer|ByteAddressBuffer|buffer_reference)\b/.test(text)) out.add('resource:buffer'); + if (language === 'hlsl' && /\bcbuffer\b/.test(text)) out.add('resource:constant-buffer'); + if (language === 'hlsl' && /\btbuffer\b/.test(text)) out.add('resource:texture-buffer'); + } + + return out.size > 0 ? [...out] : undefined; +} + +export function shaderFunctionName(node: SyntaxNode, source: string): string | undefined { + const declarator = getChildByField(node, 'declarator') || node.namedChildren.find((c) => c.type === 'function_declarator'); + const name = declarator ? getChildByField(declarator, 'name') || getChildByField(declarator, 'declarator') : getChildByField(node, 'name'); + if (name) return getNodeText(name, source).replace(/\s*\(.*/, '').trim(); + const id = declarator?.descendantsOfType('identifier')[0] || node.descendantsOfType('identifier')[0]; + return id ? getNodeText(id, source) : undefined; +} + +function unwrapDeclarator(node: SyntaxNode | null): SyntaxNode | null { + let current = node; + for (let i = 0; current && i < 10; i++) { + if (current.type === 'identifier' || current.type === 'field_identifier') return current; + current = getChildByField(current, 'name') || getChildByField(current, 'declarator') || current.namedChild(0); + } + return null; +} + +export function shaderDeclarationNames(node: SyntaxNode, source: string): Array<{ name: string; node: SyntaxNode }> { + const out: Array<{ name: string; node: SyntaxNode }> = []; + const seen = new Set(); + const add = (candidate: SyntaxNode | null): void => { + const id = unwrapDeclarator(candidate); + if (!id) return; + const name = getNodeText(id, source).trim(); + if (!name || seen.has(name)) return; + seen.add(name); + out.push({ name, node: id }); + }; + + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (!child) continue; + const field = node.fieldNameForNamedChild(i); + if (field === 'declarator' && child.type !== 'semantics') add(child); + } + for (const decl of node.descendantsOfType(['declarator', 'field_declarator'])) add(getChildByField(decl, 'name') || decl); + return out; +} + +export function shaderSignature(node: SyntaxNode, source: string, max = 400): string { + return getNodeText(node, source).trim().replace(/\s+/g, ' ').slice(0, max); +} + +export function shaderImport(node: SyntaxNode, source: string): { moduleName: string; signature: string } | null { + const text = getNodeText(node, source).trim(); + const match = text.match(/^#\s*(?:include|import)\s*[<"]([^>"]+)[>"]/m); + return match ? { moduleName: match[1]!, signature: text } : null; +} + +export function shaderMacroName(node: SyntaxNode, source: string): string | undefined { + const name = getChildByField(node, 'name') || node.namedChildren.find((c) => c.type === 'identifier'); + return name ? getNodeText(name, source) : undefined; +} + +export function previousShaderPrefix(node: SyntaxNode, source: string): string { + const previous = node.previousNamedSibling; + if (!previous) return ''; + const text = getNodeText(previous, source).trim(); + return /^(?:\[\[|[A-Z][A-Z0-9_]*\s*\()/.test(text) ? text : ''; +} + +export function isShaderBuiltinCall(name: string, node: SyntaxNode): boolean { + const simple = name.includes('.') ? name.slice(name.lastIndexOf('.') + 1) : name; + let root = node; + while (root.parent) root = root.parent; + const rootSource = root.text; + if (rootSource !== cachedFunctionSource) { + cachedFunctionSource = rootSource; + cachedFunctionNames = new Set(root.descendantsOfType(['function_definition', 'function_declaration', 'function_declarator']) + .map((candidate) => shaderFunctionName(candidate, rootSource)).filter((value): value is string => !!value)); + } + if (cachedFunctionNames.has(simple)) return false; + const fn = getChildByField(node, 'function') || node.namedChild(0); + if (fn && (fn.type === 'type_specifier' || fn.type === 'type_identifier' || fn.type === 'primitive_type' || fn.type === 'template_type')) return true; + if (/^(?:[biud]?vec[234]|d?mat[234](?:x[234])?|bool|int|uint|float|double|half)(?:[1-4](?:x[1-4])?)?$/.test(simple)) return true; + return BUILTIN_CALLS.has(simple); +} + +function isShaderBuiltinName(name: string): boolean { + return BUILTIN_CALLS.has(name) || /^(?:[biud]?vec[234]|d?mat[234](?:x[234])?|bool|int|uint|float|double|half)(?:[1-4](?:x[1-4])?)?$/.test(name); +} + +interface ShaderMacro { + name: string; + params: string[] | null; + body: string; +} + +function shaderMacros(source: string): Map { + const macros = new Map(); + const lines = source.split(/\r?\n/); + for (let index = 0; index < lines.length; index++) { + let logical = lines[index]!; + while (/\\\s*$/.test(logical) && index + 1 < lines.length) { + logical = logical.replace(/\\\s*$/, '') + '\n' + lines[++index]!; + } + const match = logical.match(/^\s*#\s*define\s+([A-Za-z_]\w*)(?:\s*\(([^)]*)\))?\s*([\s\S]*)$/); + if (!match) continue; + macros.set(match[1]!, { + name: match[1]!, + params: match[2] === undefined ? null : match[2].split(',').map((value) => value.trim()).filter(Boolean), + body: match[3] ?? '', + }); + } + return macros; +} + +function invocationAt(text: string, open: number): { args: string[]; end: number } | null { + let depth = 0; + for (let index = open; index < text.length; index++) { + const char = text[index]!; + if (char === '(') depth++; + else if (char === ')' && --depth === 0) { + return { args: splitMacroArgs(text.slice(open, index + 1)), end: index + 1 }; + } + } + return null; +} + +function expandShaderMacros(text: string, macros: Map, depth = 0): string { + if (depth >= 8) return text; + let output = text; + let changed = false; + for (const macro of macros.values()) { + if (macro.params) { + const regex = new RegExp(`\\b${macro.name}\\s*\\(`, 'g'); + let match: RegExpExecArray | null; + while ((match = regex.exec(output))) { + const open = output.indexOf('(', match.index); + const invocation = invocationAt(output, open); + if (!invocation) break; + let replacement = macro.body; + macro.params.forEach((param, index) => { + replacement = replacement.replace(new RegExp(`\\b${param}\\b`, 'g'), invocation.args[index] ?? ''); + }); + replacement = replacement.replace(/\s*##\s*/g, ''); + output = output.slice(0, match.index) + replacement + output.slice(invocation.end); + regex.lastIndex = match.index + replacement.length; + changed = true; + } + } else { + const next = output.replace(new RegExp(`\\b${macro.name}\\b`, 'g'), macro.body); + if (next !== output) { + output = next; + changed = true; + } + } + } + return changed ? expandShaderMacros(output, macros, depth + 1) : output; +} + +function addTextReferences(ownerId: string, text: string, line: number, ctx: ExtractorContext, params: string[] = []): void { + const ignored = new Set([...params, 'if', 'for', 'while', 'switch', 'return', 'sizeof']); + const calls = new Set(); + for (const match of text.matchAll(/\b([A-Za-z_]\w*)\s*\(/g)) { + const name = match[1]!; + if (ignored.has(name) || isShaderBuiltinName(name)) continue; + calls.add(name); + ctx.addUnresolvedReference({ fromNodeId: ownerId, referenceName: name, referenceKind: 'calls', + filePath: ctx.filePath, language: ctx.nodes.find((node) => node.id === ownerId)?.language ?? 'glsl', line, column: match.index }); + } + for (const match of text.matchAll(/\b[A-Z][A-Z0-9_]{2,}\b/g)) { + const name = match[0]; + if (ignored.has(name) || calls.has(name)) continue; + ctx.addUnresolvedReference({ fromNodeId: ownerId, referenceName: name, referenceKind: 'references', + filePath: ctx.filePath, language: ctx.nodes.find((node) => node.id === ownerId)?.language ?? 'glsl', line, column: match.index }); + } +} + +export function recoverShaderMacroBodyReferences(node: SyntaxNode, createdId: string, ctx: ExtractorContext): void { + if (node.descendantsOfType(['function_call', 'call_expression', 'macro_invocation']).length > 0) return; + const text = getNodeText(node, ctx.source); + const body = text.replace(/^\s*#\s*define\s+[A-Za-z_]\w*(?:\s*\(([^)]*)\))?\s*/, ''); + const params = text.match(/^\s*#\s*define\s+[A-Za-z_]\w*\s*\(([^)]*)\)/)?.[1] + ?.split(',').map((value) => value.trim()).filter(Boolean) ?? []; + addTextReferences(createdId, body, node.startPosition.row + 1, ctx, params); +} + +export function recoverShaderMacroDeclarations( + root: SyntaxNode, + ctx: ExtractorContext, + language: Extract, +): void { + const macros = shaderMacros(ctx.source); + const lines = ctx.source.split(/\r?\n/); + for (let row = 0; row < lines.length; row++) { + const first = lines[row]!.match(/^\s*([A-Z][A-Z0-9_]*)\s*\(/); + if (!first || !macros.get(first[1]!)?.params || /^NRD_/.test(first[1]!)) continue; + let invocation = lines[row]!; + let balance = (invocation.match(/\(/g) || []).length - (invocation.match(/\)/g) || []).length; + let endRow = row; + while (balance > 0 && endRow + 1 < lines.length && endRow - row < 16) { + invocation += '\n' + lines[++endRow]!; + balance += (lines[endRow]!.match(/\(/g) || []).length - (lines[endRow]!.match(/\)/g) || []).length; + } + const expanded = expandShaderMacros(invocation, macros).trim(); + if (!expanded || expanded === invocation.trim()) continue; + const column = Math.max(0, lines[row]!.search(/\S/)); + const positionNode = root.namedDescendantForPosition({ row, column }); + if (!positionNode) continue; + let insideFunction = false; + for (let parent: SyntaxNode | null = positionNode; parent; parent = parent.parent) { + if (parent.type === 'function_definition') { insideFunction = true; break; } + } + if (insideFunction) continue; + + let kind: 'function' | 'struct' | 'type_alias' | 'variable' | 'constant' | null = null; + let name = ''; + let match = expanded.match(/\b(?:struct|class)\s+([A-Za-z_]\w*)/); + if (match) { kind = 'struct'; name = match[1]!; } + if (!kind && (match = expanded.match(/\b(?:cbuffer|tbuffer|uniform|buffer)\s+([A-Za-z_]\w*)\s*\{/))) { + kind = 'struct'; name = match[1]!; + } + if (!kind && (match = expanded.match(/\b([A-Za-z_]\w*)\s*\([^;{}]*\)\s*\{/))) { + kind = 'function'; name = match[1]!; + } + if (!kind && (match = expanded.match(/\btypedef\b[\s\S]*?\b([A-Za-z_]\w*)\s*;/))) { + kind = 'type_alias'; name = match[1]!; + } + if (!kind && (match = expanded.match(/(?:^|[;}])\s*(?:layout\s*\([^)]*\)\s*)?(?:\[\[[^\]]+\]\]\s*)*(?:static\s+|const\s+|uniform\s+|groupshared\s+|in\s+|out\s+)*[A-Za-z_]\w*(?:\s*<[^;{}]+>)?(?:\s*[*&])?\s+([A-Za-z_]\w*)\b[^;{}]*;/))) { + kind = /\bconst\b/.test(expanded) ? 'constant' : 'variable'; + name = match[1]!; + } + if (!kind || !name || ctx.nodes.some((node) => node.kind === kind && node.name === name && node.startLine === row + 1)) continue; + const created = ctx.createNode(kind, name, positionNode, { + signature: `${invocation.trim()} => ${expanded.replace(/\s+/g, ' ').slice(0, 300)}`, + decorators: shaderDecorators(positionNode, ctx.source, ctx.filePath, language, expanded), + }); + if (created && kind === 'function') addTextReferences(created.id, expanded, row + 1, ctx); + row = endRow; + } +} + +export function splitMacroArgs(text: string): string[] { + const open = text.indexOf('('); + const close = text.lastIndexOf(')'); + if (open < 0 || close <= open) return []; + const out: string[] = []; + let depth = 0; + let start = open + 1; + for (let i = open + 1; i < close; i++) { + const ch = text[i]!; + if (ch === '(' || ch === '<' || ch === '[') depth++; + else if (ch === ')' || ch === '>' || ch === ']') depth--; + else if (ch === ',' && depth === 0) { + out.push(text.slice(start, i).trim()); + start = i + 1; + } + } + out.push(text.slice(start, close).trim()); + return out; +} diff --git a/src/extraction/tree-sitter-types.ts b/src/extraction/tree-sitter-types.ts index 2a02b47b7..9833c0b5b 100644 --- a/src/extraction/tree-sitter-types.ts +++ b/src/extraction/tree-sitter-types.ts @@ -168,6 +168,10 @@ export interface LanguageExtractor { * implementations across source sets. */ extractModifiers?: (node: SyntaxNode) => string[] | undefined; + /** Extract decorators with access to source and file path (shader stage/layout metadata). */ + extractDecorators?: (node: SyntaxNode, source: string, filePath: string) => string[] | undefined; + /** Return true for compiler intrinsics/type constructors that should not emit call refs. */ + isBuiltinCall?: (name: string, node: SyntaxNode, source: string) => boolean; // --- New config properties --- diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index 05b13a9c1..1fb17e91f 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -30,6 +30,8 @@ import { DfmExtractor } from './dfm-extractor'; import { VueExtractor } from './vue-extractor'; import { MyBatisExtractor } from './mybatis-extractor'; import { CfmlExtractor } from './cfml-extractor'; +import { GlslfxExtractor } from './glslfx-extractor'; +import { extractCmake, extractPowershell } from './build-script-extractors'; import { tryKernelExtract, takeDeferredPreParse } from './kernel'; import { getAllFrameworkResolvers, @@ -1089,6 +1091,13 @@ export class TreeSitterExtractor { this.scanFnRefSubtree(node, 0); skipChildren = true; } + // C++ named lambdas are local variables syntactically, but callable symbols + // semantically: `auto parse = [](auto x) { ... };`. Handle them before the + // top-level-variable gate so lambdas inside both free functions and methods + // become scoped function nodes without indexing ordinary local variables. + else if (this.language === 'cpp' && nodeType === 'declaration' && this.extractCppLambdaDeclarations(node)) { + skipChildren = true; + } // Check for variable declarations (const, let, var, etc.) // Only extract top-level variables (not inside functions/methods) — plus // class/module-scope CONSTANTS, which Ruby (and other const-in-class @@ -1356,6 +1365,10 @@ export class TreeSitterExtractor { if (mods && mods.length > 0) { newNode.decorators = [...(newNode.decorators ?? []), ...mods]; } + const decorators = this.extractor?.extractDecorators?.(node, this.source, this.filePath); + if (decorators && decorators.length > 0) { + newNode.decorators = [...new Set([...(newNode.decorators ?? []), ...decorators])]; + } this.nodes.push(newNode); @@ -1514,7 +1527,11 @@ export class TreeSitterExtractor { /** * Extract a function */ - private extractFunction(node: SyntaxNode, nameOverride?: string): void { + private extractFunction( + node: SyntaxNode, + nameOverride?: string, + extraProps?: Partial + ): void { if (!this.extractor) return; // If the language provides getReceiverType and this function has a receiver @@ -1578,6 +1595,9 @@ export class TreeSitterExtractor { const isAsync = this.extractor.isAsync?.(node); const isStatic = this.extractor.isStatic?.(node); const returnType = this.extractor.getReturnType?.(node, this.source); + const body = this.extractor.resolveBody?.(node, this.extractor.bodyField) + ?? getChildByField(node, this.extractor.bodyField); + const correctedBody = body ? this.correctMalformedCppBodyRange(body) : null; const funcNode = this.createNode('function', name, node, { docstring, @@ -1587,6 +1607,11 @@ export class TreeSitterExtractor { isAsync, isStatic, returnType, + ...(correctedBody ? { + endLine: correctedBody.endLine, + endColumn: correctedBody.endColumn, + } : {}), + ...extraProps, }); if (!funcNode) return; @@ -1600,12 +1625,13 @@ export class TreeSitterExtractor { // Push to stack and visit body this.nodeStack.push(funcNode.id); - const body = this.extractor.resolveBody?.(node, this.extractor.bodyField) - ?? getChildByField(node, this.extractor.bodyField); if (body) { - this.visitFunctionBody(body, funcNode.id); + this.visitFunctionBody(body, funcNode.id, correctedBody?.endIndex); } this.nodeStack.pop(); + if (body && correctedBody) { + this.recoverCppFunctionsAfterMalformedBody(body, correctedBody.endIndex); + } } /** @@ -1779,6 +1805,9 @@ export class TreeSitterExtractor { const isAsync = this.extractor.isAsync?.(node); const isStatic = this.extractor.isStatic?.(node); const returnType = this.extractor.getReturnType?.(node, this.source); + const body = this.extractor.resolveBody?.(node, this.extractor.bodyField) + ?? getChildByField(node, this.extractor.bodyField); + const correctedBody = body ? this.correctMalformedCppBodyRange(body) : null; const extraProps: Partial = { docstring, signature, @@ -1786,6 +1815,10 @@ export class TreeSitterExtractor { isAsync, isStatic, returnType, + ...(correctedBody ? { + endLine: correctedBody.endLine, + endColumn: correctedBody.endColumn, + } : {}), }; if (receiverType) { extraProps.qualifiedName = this.composeReceiverQualifiedName(receiverType, name); @@ -1820,12 +1853,13 @@ export class TreeSitterExtractor { // Push to stack and visit body this.nodeStack.push(methodNode.id); - const body = this.extractor.resolveBody?.(node, this.extractor.bodyField) - ?? getChildByField(node, this.extractor.bodyField); if (body) { - this.visitFunctionBody(body, methodNode.id); + this.visitFunctionBody(body, methodNode.id, correctedBody?.endIndex); } this.nodeStack.pop(); + if (body && correctedBody) { + this.recoverCppFunctionsAfterMalformedBody(body, correctedBody.endIndex); + } } /** @@ -2535,6 +2569,38 @@ export class TreeSitterExtractor { * Extracts top-level and module-level variable declarations. * Captures the variable name and first 100 chars of initializer in signature for searchability. */ + private extractCppLambdaDeclarations(node: SyntaxNode): boolean { + let extracted = false; + for (let i = 0; i < node.namedChildCount; i++) { + const declarator = node.namedChild(i); + if (declarator?.type !== 'init_declarator') continue; + const value = getChildByField(declarator, 'value'); + if (value?.type !== 'lambda_expression') continue; + const nameNode = cDeclaratorIdentifier(getChildByField(declarator, 'declarator')); + if (!nameNode) continue; + const name = getNodeText(nameNode, this.source); + if (!name) continue; + const ownerId = this.nodeStack[this.nodeStack.length - 1]; + const owner = ownerId ? this.nodes.find((candidate) => candidate.id === ownerId) : undefined; + let ownerQualifiedName = owner && owner.kind !== 'file' ? owner.qualifiedName : ''; + const namespace = this.namespacePrefix.join('::'); + if ( + namespace && + ownerQualifiedName && + ownerQualifiedName !== namespace && + !ownerQualifiedName.startsWith(`${namespace}::`) + ) { + ownerQualifiedName = `${namespace}::${ownerQualifiedName}`; + } + this.extractFunction(value, name, { + ...(ownerQualifiedName ? { qualifiedName: `${ownerQualifiedName}::${name}` } : {}), + decorators: ['cpp:lambda'], + }); + extracted = true; + } + return extracted; + } + private extractVariable(node: SyntaxNode): void { if (!this.extractor) return; @@ -3681,6 +3747,48 @@ export class TreeSitterExtractor { return this.erlangAtomMacros.get(macroName) ?? null; } + private isPlausibleShaderType(name: string): boolean { + return /^(?:(?:bool|int|uint|dword|half|float|double|min16float|min10float|min16int|min12int|min16uint)(?:[1-4](?:x[1-4])?)?|[A-Z][A-Za-z0-9_]*|(?:RTXDI|RAB)_[A-Za-z0-9_]+)$/.test(name); + } + + private inferShaderArgumentType(argument: SyntaxNode, call: SyntaxNode): string | null { + let arg = argument; + while (arg.type === 'parenthesized_expression' && arg.namedChildCount === 1) { + const child = arg.namedChild(0); + if (!child) break; + arg = child; + } + if (arg.type === 'true' || arg.type === 'false') return 'bool'; + if (/^(?:float|double)_literal$/.test(arg.type)) return 'float'; + if (arg.type === 'call_expression') { + const fn = getChildByField(arg, 'function'); + if (fn?.type === 'identifier') { + const name = getNodeText(fn, this.source); + if (this.isPlausibleShaderType(name)) return name; + } + } + if (arg.type !== 'identifier') return null; + const identifier = getNodeText(arg, this.source); + if (!identifier) return null; + let scope: SyntaxNode | null = call; + while (scope.parent && !this.extractor?.functionTypes.includes(scope.type) && !this.extractor?.methodTypes.includes(scope.type)) { + scope = scope.parent; + } + const before = this.source.slice(scope?.startIndex ?? 0, call.startIndex); + const escaped = identifier.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const declaration = new RegExp( + `(?:^|[({;,])\\s*(?:(?:const|static|uniform|groupshared|inout|out|in)\\s+)*([A-Za-z_]\\w*)\\s+${escaped}\\b`, + 'gm', + ); + let inferred: string | null = null; + let match: RegExpExecArray | null; + while ((match = declaration.exec(before))) { + const type = match[1]!; + if (this.isPlausibleShaderType(type)) inferred = type; + } + return inferred; + } + private extractCall(node: SyntaxNode): void { if (this.nodeStack.length === 0) return; @@ -4569,13 +4677,37 @@ export class TreeSitterExtractor { } } + if ( + calleeName && + this.extractor?.isBuiltinCall?.(calleeName, node, this.source) && + !this.nodes.some((n) => + (n.kind === 'function' || n.kind === 'method') && + (n.name === calleeName || n.name === calleeName.slice(calleeName.lastIndexOf('.') + 1)) + ) + ) { + return; + } + if (calleeName) { + const shaderArgs = (this.language === 'glsl' || this.language === 'hlsl') + ? getChildByField(node, 'arguments') + : null; + const shaderCandidates = shaderArgs ? [`arity:${shaderArgs.namedChildCount}`] : undefined; + if (shaderArgs && shaderCandidates) { + for (let index = 0; index < shaderArgs.namedChildCount; index++) { + const argument = shaderArgs.namedChild(index); + if (!argument) continue; + const type = this.inferShaderArgumentType(argument, node); + if (type) shaderCandidates.push(`argtype:${index}:${type}`); + } + } this.unresolvedReferences.push({ fromNodeId: callerId, referenceName: calleeName, referenceKind: 'calls', line: node.startPosition.row + 1, column: node.startPosition.column, + candidates: shaderCandidates, }); } } @@ -5126,10 +5258,126 @@ export class TreeSitterExtractor { targets.add(target); } - private visitFunctionBody(body: SyntaxNode, _functionId: string): void { + /** + * tree-sitter-cpp can pair a nested conditional directive with a distant + * `#endif`, causing one function body to consume the rest of a file. When the + * body is erroneous, prefer the first lexically balanced closing brace. The + * scanner ignores comments and every C++ string form so braces in text do not + * truncate a valid body. + */ + private correctMalformedCppBodyRange( + body: SyntaxNode + ): { endIndex: number; endLine: number; endColumn: number } | null { + if (this.language !== 'cpp' || !body.hasError) return null; + + const openIndex = this.source.indexOf('{', body.startIndex); + if (openIndex < 0 || openIndex >= body.endIndex) return null; + const closeIndex = this.findMatchingCppBrace(openIndex); + if (closeIndex < 0 || closeIndex + 1 >= body.endIndex) return null; + + const throughClose = this.source.slice(body.startIndex, closeIndex + 1); + const newlineCount = (throughClose.match(/\n/g) ?? []).length; + const lastNewline = throughClose.lastIndexOf('\n'); + const endColumn = lastNewline >= 0 + ? throughClose.length - lastNewline - 1 + : body.startPosition.column + throughClose.length; + return { + endIndex: closeIndex + 1, + endLine: body.startPosition.row + newlineCount + 1, + endColumn, + }; + } + + private findMatchingCppBrace(openIndex: number): number { + let depth = 0; + let i = openIndex; + while (i < this.source.length) { + const rawPrefix = ['u8R"', 'uR"', 'UR"', 'LR"', 'R"'] + .find((prefix) => this.source.startsWith(prefix, i)); + if (rawPrefix) { + const delimiterStart = i + rawPrefix.length; + const paren = this.source.indexOf('(', delimiterStart); + if (paren >= 0 && paren - delimiterStart <= 16) { + const delimiter = this.source.slice(delimiterStart, paren); + const rawEnd = this.source.indexOf(`)${delimiter}"`, paren + 1); + if (rawEnd >= 0) { + i = rawEnd + delimiter.length + 2; + continue; + } + } + } + + const char = this.source[i]; + const next = this.source[i + 1]; + if (char === '/' && next === '/') { + const newline = this.source.indexOf('\n', i + 2); + i = newline < 0 ? this.source.length : newline + 1; + continue; + } + if (char === '/' && next === '*') { + const commentEnd = this.source.indexOf('*/', i + 2); + i = commentEnd < 0 ? this.source.length : commentEnd + 2; + continue; + } + if (char === '"' || char === "'") { + const quote = char; + i++; + while (i < this.source.length) { + if (this.source[i] === '\\') { + i += 2; + continue; + } + if (this.source[i] === quote) { + i++; + break; + } + i++; + } + continue; + } + if (char === '{') depth++; + if (char === '}' && --depth === 0) return i; + i++; + } + return -1; + } + + /** + * Later top-level definitions are descendants of the corrupted body in the + * AST, so the normal root walk never sees them. Revisit only the outermost + * function definitions after the recovered close at the original file scope. + */ + private recoverCppFunctionsAfterMalformedBody(body: SyntaxNode, cutoff: number): void { + const visit = (node: SyntaxNode): void => { + if (node.endIndex <= cutoff) return; + if ( + node.startIndex >= cutoff && + this.extractor?.functionTypes.includes(node.type) + ) { + this.visitNode(node); + return; + } + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child) visit(child); + } + }; + + for (let i = 0; i < body.namedChildCount; i++) { + const child = body.namedChild(i); + if (child) visit(child); + } + } + + private visitFunctionBody( + body: SyntaxNode, + _functionId: string, + maxEndIndex?: number + ): void { if (!this.extractor) return; const visitForCallsAndStructure = (node: SyntaxNode): void => { + if (maxEndIndex !== undefined && node.startIndex >= maxEndIndex) return; const nodeType = node.type; // Function-as-value capture (#756) — function bodies are walked here, @@ -5172,6 +5420,24 @@ export class TreeSitterExtractor { } } + if ( + (this.language === 'glsl' || this.language === 'hlsl') && + nodeType === 'identifier' && + this.nodeStack.length > 0 + ) { + const macroName = getNodeText(node, this.source); + if (/^[A-Z][A-Z0-9_]{2,}$/.test(macroName)) { + const ownerId = this.nodeStack[this.nodeStack.length - 1]; + if (ownerId) this.unresolvedReferences.push({ + fromNodeId: ownerId, + referenceName: macroName, + referenceKind: 'references', + line: node.startPosition.row + 1, + column: node.startPosition.column, + }); + } + } + // C++ stack / direct-initialization construction — `Calculator calc(0)` // and `Widget w{1, 2}`. Unlike heap `new Calculator(0)` (a new_expression // handled above), these carry the constructor arguments directly on the @@ -5242,6 +5508,10 @@ export class TreeSitterExtractor { // recursion below, keeping their inner calls attributed to the enclosing // function: this bounds the new nodes to NAMED functions only (no explosion, // no lost edges). extractFunction walks the nested body itself, so we return. + if (this.language === 'cpp' && nodeType === 'declaration' && this.extractCppLambdaDeclarations(node)) { + return; + } + if (this.extractor!.functionTypes.includes(nodeType)) { const nestedName = extractName(node, this.source, this.extractor!); if (nestedName && nestedName !== '') { @@ -6698,6 +6968,13 @@ export function extractFromSource( // CFML never spells one in source) stays ``. const extractor = new CfmlExtractor(filePath, source, detectedLanguage); result = extractor.extract(); + } else if (detectedLanguage === 'glsl' && fileExtension === '.glslfx') { + const extractor = new GlslfxExtractor(filePath, source); + result = extractor.extract(); + } else if (detectedLanguage === 'cmake') { + result = extractCmake(filePath, source); + } else if (detectedLanguage === 'powershell') { + result = extractPowershell(filePath, source); } else if (isFileLevelOnlyLanguage(detectedLanguage)) { // No symbol extraction at this stage — files are tracked at the file-record // level only. Framework extractors (Drupal routing yml, Spring `@Value` diff --git a/src/extraction/wasm/tree-sitter-glsl.wasm b/src/extraction/wasm/tree-sitter-glsl.wasm new file mode 100644 index 000000000..f9bec788e Binary files /dev/null and b/src/extraction/wasm/tree-sitter-glsl.wasm differ diff --git a/src/extraction/wasm/tree-sitter-hlsl.wasm b/src/extraction/wasm/tree-sitter-hlsl.wasm new file mode 100644 index 000000000..9c7241092 Binary files /dev/null and b/src/extraction/wasm/tree-sitter-hlsl.wasm differ diff --git a/src/graph/traversal.ts b/src/graph/traversal.ts index 6cb00ac6b..744421a5c 100644 --- a/src/graph/traversal.ts +++ b/src/graph/traversal.ts @@ -28,6 +28,12 @@ interface TraversalStep { depth: number; } +interface ShaderImpactContext { + roots: Set; + files: Set; + rootSites: Map; +} + /** * Graph traverser for BFS and DFS traversal */ @@ -527,11 +533,54 @@ export class GraphTraverser { const edges: Edge[] = []; const visited = new Set(); + const shaderContext = this.getShaderImpactContext(focalNode); + // Add focal node nodes.set(focalNode.id, focalNode); // Traverse incoming edges to find all dependents - this.getImpactRecursive(nodeId, maxDepth, 0, nodes, edges, visited); + this.getImpactRecursive(nodeId, maxDepth, 0, nodes, edges, visited, shaderContext); + if (shaderContext) { + for (const root of shaderContext.roots) { + const line = shaderContext.rootSites.get(root); + const rootNodes = this.queries.getNodesByFile(root); + const source = rootNodes.find((node) => node.kind === 'import' && line !== undefined && node.startLine === line) + ?? rootNodes.find((node) => node.kind === 'file'); + if (source && !nodes.has(source.id)) { + nodes.set(source.id, source); + edges.push({ + source: source.id, + target: focalNode.id, + kind: 'imports', + line, + provenance: 'heuristic', + metadata: { + synthesizedBy: 'shader-context-root', + contextRoot: root, + targetFile: focalNode.filePath, + registeredAt: `${root}:${line ?? source.startLine}`, + }, + }); + } + const entry = this.findShaderRootEntryCaller(focalNode.id, root); + if (entry && !nodes.has(entry.node.id)) nodes.set(entry.node.id, entry.node); + if (entry) { + edges.push({ + source: entry.node.id, + target: focalNode.id, + kind: 'calls', + line: entry.line, + provenance: 'heuristic', + metadata: { + synthesizedBy: 'shader-context-entry', + contextRoot: root, + targetFile: focalNode.filePath, + registeredAt: `${root}:${entry.line ?? entry.node.startLine}`, + }, + }); + } + } + } return { nodes, @@ -540,13 +589,138 @@ export class GraphTraverser { }; } + private getShaderFileClosure(root: string): Set { + const files = new Set(); + const queue = [root]; + for (let index = 0; index < queue.length && index < 4096; index++) { + const filePath = queue[index]!; + if (files.has(filePath)) continue; + files.add(filePath); + const fileNode = this.queries.getNodesByFile(filePath).find((node) => node.kind === 'file'); + if (!fileNode) continue; + const imports = this.queries.getOutgoingEdges(fileNode.id, ['imports']); + const targets = this.queries.getNodesByIds(imports.map((edge) => edge.target)); + for (const edge of imports) { + const target = targets.get(edge.target); + if (target?.kind === 'file' && (target.language === 'glsl' || target.language === 'hlsl') && !files.has(target.filePath)) { + queue.push(target.filePath); + } + } + } + return files; + } + + private findShaderRootEntryCaller(focalId: string, root: string): { node: Node; line?: number } | null { + const allowedFiles = this.getShaderFileClosure(root); + const queue = [focalId]; + const visited = new Set([focalId]); + for (let index = 0; index < queue.length && index < 4096; index++) { + const current = queue[index]!; + const incoming = this.queries.getIncomingEdges(current).filter((edge) => edge.kind === 'calls'); + const sources = this.queries.getNodesByIds(incoming.map((edge) => edge.source)); + for (const edge of incoming) { + const source = sources.get(edge.source); + if (!source || (source.language !== 'glsl' && source.language !== 'hlsl') || !allowedFiles.has(source.filePath)) continue; + if (source.filePath === root && source.kind === 'function' && (source.name === 'main' || source.decorators?.includes('entrypoint'))) { + return { node: source, line: edge.line }; + } + if (!visited.has(source.id)) { + visited.add(source.id); + queue.push(source.id); + } + } + } + return null; + } + + private getShaderImpactContext(focalNode: Node): ShaderImpactContext | null { + if (focalNode.language !== 'glsl' && focalNode.language !== 'hlsl') return null; + const roots = new Set(); + for (const edge of this.queries.getIncomingEdges(focalNode.id)) { + const metadata = edge.metadata as Record | undefined; + if (metadata?.synthesizedBy !== 'shader-context-variant' || !Array.isArray(metadata.contextRoots)) continue; + for (const root of metadata.contextRoots) if (typeof root === 'string') roots.add(root); + } + if (roots.size === 0) { + const reverseFiles = new Set([focalNode.filePath]); + const hasShaderParent = new Set(); + const queue = [focalNode.filePath]; + for (let index = 0; index < queue.length && index < 4096; index++) { + const filePath = queue[index]!; + const fileNode = this.queries.getNodesByFile(filePath).find((node) => node.kind === 'file'); + if (!fileNode) continue; + const incoming = this.queries.getIncomingEdges(fileNode.id).filter((edge) => edge.kind === 'imports'); + const sources = this.queries.getNodesByIds(incoming.map((edge) => edge.source)); + for (const edge of incoming) { + const source = sources.get(edge.source); + if (source?.kind !== 'file' || (source.language !== 'glsl' && source.language !== 'hlsl')) continue; + hasShaderParent.add(filePath); + if (!reverseFiles.has(source.filePath)) { + reverseFiles.add(source.filePath); + queue.push(source.filePath); + } + } + } + const entryExtension = /\.(?:glsl|vert|frag|comp|geom|tesc|tese|rgen|rmiss|rchit|rahit|rint|rcall|mesh|task|glslfx|hlsl|fx)$/i; + for (const filePath of reverseFiles) { + if (!hasShaderParent.has(filePath) && entryExtension.test(filePath)) roots.add(filePath); + } + } + if (roots.size === 0) return null; + const files = new Set(); + const queue = [...roots]; + for (let index = 0; index < queue.length && index < 4096; index++) { + const filePath = queue[index]!; + if (files.has(filePath)) continue; + files.add(filePath); + const fileNode = this.queries.getNodesByFile(filePath).find((node) => node.kind === 'file'); + if (!fileNode) continue; + const imports = this.queries.getOutgoingEdges(fileNode.id, ['imports']); + const targets = this.queries.getNodesByIds(imports.map((edge) => edge.target)); + for (const edge of imports) { + const target = targets.get(edge.target); + if (target?.kind === 'file' && (target.language === 'glsl' || target.language === 'hlsl') && !files.has(target.filePath)) { + queue.push(target.filePath); + } + } + } + files.add(focalNode.filePath); + const rootSites = new Map(); + for (const root of roots) { + const queue: Array<{ filePath: string; firstLine?: number }> = [{ filePath: root }]; + const visited = new Set(); + for (let index = 0; index < queue.length && index < 4096; index++) { + const current = queue[index]!; + if (visited.has(current.filePath)) continue; + visited.add(current.filePath); + const fileNode = this.queries.getNodesByFile(current.filePath).find((node) => node.kind === 'file'); + if (!fileNode) continue; + const imports = this.queries.getOutgoingEdges(fileNode.id, ['imports']); + const targets = this.queries.getNodesByIds(imports.map((edge) => edge.target)); + for (const edge of imports) { + const target = targets.get(edge.target); + if (target?.kind !== 'file' || (target.language !== 'glsl' && target.language !== 'hlsl')) continue; + const firstLine = current.firstLine ?? edge.line; + if (target.filePath === focalNode.filePath) { + if (firstLine !== undefined) rootSites.set(root, firstLine); + queue.length = 0; + break; + } + if (!visited.has(target.filePath)) queue.push({ filePath: target.filePath, firstLine }); + } + } + } + return { roots, files, rootSites }; + } + private getImpactRecursive( nodeId: string, maxDepth: number, currentDepth: number, nodes: Map, edges: Edge[], - visited: Set + visited: Set, + shaderContext: ShaderImpactContext | null, ): void { // Mark visited before the depth check so a node collected at the depth // boundary still lands in `visited`. Otherwise it could sit in `nodes` but @@ -575,7 +749,7 @@ export class GraphTraverser { nodes.set(childNode.id, childNode); edges.push(edge); // Recurse into children at the same depth (they're part of the same symbol) - this.getImpactRecursive(childNode.id, maxDepth, currentDepth, nodes, edges, visited); + this.getImpactRecursive(childNode.id, maxDepth, currentDepth, nodes, edges, visited, shaderContext); } } } @@ -593,6 +767,14 @@ export class GraphTraverser { for (const edge of incomingEdges) { const sourceNode = sources.get(edge.source); if (!sourceNode) continue; + if (shaderContext && (sourceNode.language === 'glsl' || sourceNode.language === 'hlsl')) { + if (!shaderContext.files.has(sourceNode.filePath)) continue; + const metadata = edge.metadata as Record | undefined; + if (metadata?.synthesizedBy === 'shader-context-variant' && Array.isArray(metadata.contextRoots)) { + const overlaps = metadata.contextRoots.some((root) => typeof root === 'string' && shaderContext.roots.has(root)); + if (!overlaps) continue; + } + } // Record the dependency edge unconditionally. The gate used to also gate // edge collection (`!nodes.has(...)`), so a second incoming edge into a // node already collected via another path was silently dropped from @@ -601,7 +783,7 @@ export class GraphTraverser { edges.push(edge); if (!visited.has(sourceNode.id)) { nodes.set(sourceNode.id, sourceNode); - this.getImpactRecursive(sourceNode.id, maxDepth, currentDepth + 1, nodes, edges, visited); + this.getImpactRecursive(sourceNode.id, maxDepth, currentDepth + 1, nodes, edges, visited, shaderContext); } } } diff --git a/src/index.ts b/src/index.ts index f3aca0c89..b48314319 100644 --- a/src/index.ts +++ b/src/index.ts @@ -644,6 +644,7 @@ export class CodeGraph { try { this.queries.setMetadata('indexed_with_version', CodeGraphPackageVersion); this.queries.setMetadata('indexed_with_extraction_version', String(EXTRACTION_VERSION)); + this.queries.setMetadata('last_indexed_at', String(Date.now())); } catch { /* metadata is advisory — never fail an index over it */ } } @@ -933,11 +934,15 @@ export class CodeGraph { // rows the sync just wrote are fine). Batched + yielding — sync can // run on the daemon's liveness-watchdog thread (#850/#1091). try { - if (vocabWasEmpty && this.queries.getNodeAndEdgeCount().nodes > 0) { + if (vocabWasEmpty && this.queries.getNodeAndEdgeCount().nodes > 0) { await this.rebuildNameSegmentVocab(); } } catch { /* vocab is advisory — never fail a sync over it */ } + if (result.filesAdded > 0 || result.filesModified > 0 || result.filesRemoved > 0) { + try { this.queries.setMetadata('last_indexed_at', String(Date.now())); } catch { /* advisory */ } + } + return result; } finally { // Mirror indexAll's teardown: stop the valve, then restore the @@ -1180,6 +1185,15 @@ export class CodeGraph { return this.queries.getUnresolvedReferencesCount(); } + /** Attempted references with no target, exposed so status is semantically honest. */ + getFailedReferenceStats(): { + total: number; + byLanguage: Record; + byKind: Record; + } { + return this.queries.getFailedReferenceStats(); + } + /** * Get detected frameworks in the project */ diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index b31c64fc7..263a21a23 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -126,6 +126,27 @@ export function normalizeQuerySpelling(query: string): string { ); } +interface SourceLocationHint { + file: string; + line: number; +} + +function sourceLocationHints(query: string): SourceLocationHint[] { + const pattern = /(?:^|\s)([A-Za-z0-9_./\\-]+\.(?:java|kt|kts|ts|tsx|js|jsx|mjs|cjs|cs|py|go|rb|php|swift|rs|cpp|cc|cxx|c|h|hpp|scala|lua|dart|vue|svelte|astro|erl|hrl|glsl|vert|frag|comp|geom|tesc|tese|rgen|rmiss|rchit|rahit|rint|rcall|mesh|task|glslfx|hlsl|hlsli|fx|fxh))(?::(\d+)|\s+(?:at\s+)?line\s+(\d+)|\s*\(\s*line\s+(\d+)\s*\))/gi; + const hints: SourceLocationHint[] = []; + const seen = new Set(); + for (const match of query.matchAll(pattern)) { + const file = match[1]!.replace(/\\/g, '/').replace(/^\.\//, '').toLowerCase(); + const line = Number(match[2] ?? match[3] ?? match[4]); + if (!Number.isFinite(line) || line <= 0) continue; + const key = `${file}:${line}`; + if (seen.has(key)) continue; + seen.add(key); + hints.push({ file, line }); + } + return hints; +} + /** * Calculate the recommended number of codegraph_explore calls based on project size. * Larger codebases need more exploration calls to cover their surface area, @@ -574,6 +595,10 @@ export const tools: ToolDefinition[] = [ type: 'string', description: 'Narrow to the definition in this file (path or suffix) when several same-named symbols exist (e.g. one UserService per app in a monorepo)', }, + line: { + type: 'number', + description: 'Narrow to the definition starting at, or containing, this 1-based line (use with file for repeated local lambdas)', + }, limit: { type: 'number', description: 'Maximum number of callers to return (default: 20)', @@ -599,6 +624,10 @@ export const tools: ToolDefinition[] = [ type: 'string', description: 'Narrow to the definition in this file (path or suffix) when several same-named symbols exist', }, + line: { + type: 'number', + description: 'Narrow to the definition starting at, or containing, this 1-based line (use with file for repeated local lambdas)', + }, limit: { type: 'number', description: 'Maximum number of callees to return (default: 20)', @@ -624,6 +653,10 @@ export const tools: ToolDefinition[] = [ type: 'string', description: 'Narrow to the definition in this file (path or suffix) when several same-named symbols exist', }, + line: { + type: 'number', + description: 'Narrow to the definition starting at, or containing, this 1-based line (use with file for repeated local lambdas)', + }, depth: { type: 'number', description: 'How many levels of dependencies to traverse (default: 2)', @@ -1530,11 +1563,12 @@ export class ToolHandler { * (filePath, qualifiedName), so same-file overloads stay together while * unrelated same-named classes across a monorepo's apps (#764: one * `UserService` per NestJS app) are kept apart. Optionally narrowed by a - * `file` path/suffix first. + * `file` path/suffix and then a precise source `line` when supplied. */ private groupDefinitions( nodes: Node[], - fileFilter: string | undefined + fileFilter: string | undefined, + lineFilter: number | undefined ): { groups: Node[][]; filteredOut: boolean } { let pool = nodes; let filteredOut = false; @@ -1547,11 +1581,32 @@ export class ToolHandler { pool = narrowed; } else { filteredOut = true; + // Preserve the historical fallback for ordinary application symbols, + // but never fall back for shader names: a non-matching shader file hint + // must not resurrect an unrelated `main`/`luminance` definition. A line + // is also an exact location request, so never fall back past it. + if (lineFilter || pool.some((n) => n.language === 'glsl' || n.language === 'hlsl')) pool = []; + } + } + if (lineFilter && pool.length > 0) { + const exact = pool.filter((n) => n.startLine === lineFilter); + const containing = exact.length > 0 + ? exact + : pool.filter((n) => n.startLine <= lineFilter && n.endLine >= lineFilter); + if (containing.length > 0) pool = containing; + else { + pool = []; + filteredOut = true; } } const byDef = new Map(); for (const n of pool) { - const key = `${n.filePath}|${n.qualifiedName}`; + // Repeated local C++ lambdas can share both file and qualified name (two + // block-local `bufferResource` declarations in one method). Their source + // line is part of their identity; overloads and conditional variants keep + // the historical grouping behavior. + const lineIdentity = n.decorators?.includes('cpp:lambda') ? `|${n.startLine}` : ''; + const key = `${n.filePath}|${n.qualifiedName}${lineIdentity}`; const group = byDef.get(key); if (group) group.push(n); else byDef.set(key, [n]); @@ -1576,13 +1631,22 @@ export class ToolHandler { const cg = this.getCodeGraph(args.projectPath as string | undefined); const limit = clamp((args.limit as number) || 20, 1, 100); const fileFilter = typeof args.file === 'string' ? args.file : undefined; + const lineFilter = typeof args.line === 'number' && Number.isFinite(args.line) && args.line >= 1 + ? Math.floor(args.line) + : undefined; const allMatches = this.findAllSymbols(cg, symbol); if (allMatches.nodes.length === 0) { return this.textResult(`Symbol "${symbol}" not found in the codebase`); } - const { groups, filteredOut } = this.groupDefinitions(allMatches.nodes, fileFilter); + const { groups, filteredOut } = this.groupDefinitions(allMatches.nodes, fileFilter, lineFilter); + if (filteredOut && groups.length === 0) { + const location = fileFilter + ? `file "${fileFilter}"${lineFilter ? ` at line ${lineFilter}` : ''}` + : `line ${lineFilter}`; + return this.textResult(`Symbol "${symbol}" not found at ${location}`); + } const filterNote = filteredOut ? `\n\n> **Note:** no definition of "${symbol}" matches file "${fileFilter}" — showing all definitions instead.` : ''; @@ -1612,7 +1676,7 @@ export class ToolHandler { } // A successful `file` narrowing makes the multi-symbol aggregation note // stale — suppress it. - const note = fileFilter && !filteredOut ? '' : allMatches.note; + const note = (fileFilter || lineFilter) && !filteredOut ? '' : allMatches.note; const formatted = this.formatNodeList(callers.slice(0, limit), `Callers of ${symbol}`, labels) + note + filterNote; return this.textResult(this.truncateOutput(formatted)); } @@ -1621,7 +1685,7 @@ export class ToolHandler { // agent never mistakes one app's callers for another's. Narrow with // `file` to focus a single definition. const lines: string[] = [ - `**Callers of ${symbol} — ${groups.length} distinct definitions (narrow with \`file\`)**`, + `**Callers of ${symbol} — ${groups.length} distinct definitions (narrow with \`file\` + \`line\`)**`, ]; for (const group of groups) { const { callers, labels } = collect(group); @@ -1649,13 +1713,22 @@ export class ToolHandler { const cg = this.getCodeGraph(args.projectPath as string | undefined); const limit = clamp((args.limit as number) || 20, 1, 100); const fileFilter = typeof args.file === 'string' ? args.file : undefined; + const lineFilter = typeof args.line === 'number' && Number.isFinite(args.line) && args.line >= 1 + ? Math.floor(args.line) + : undefined; const allMatches = this.findAllSymbols(cg, symbol); if (allMatches.nodes.length === 0) { return this.textResult(`Symbol "${symbol}" not found in the codebase`); } - const { groups, filteredOut } = this.groupDefinitions(allMatches.nodes, fileFilter); + const { groups, filteredOut } = this.groupDefinitions(allMatches.nodes, fileFilter, lineFilter); + if (filteredOut && groups.length === 0) { + const location = fileFilter + ? `file "${fileFilter}"${lineFilter ? ` at line ${lineFilter}` : ''}` + : `line ${lineFilter}`; + return this.textResult(`Symbol "${symbol}" not found at ${location}`); + } const filterNote = filteredOut ? `\n\n> **Note:** no definition of "${symbol}" matches file "${fileFilter}" — showing all definitions instead.` : ''; @@ -1684,14 +1757,14 @@ export class ToolHandler { } // A successful `file` narrowing makes the multi-symbol aggregation note // stale — suppress it. - const note = fileFilter && !filteredOut ? '' : allMatches.note; + const note = (fileFilter || lineFilter) && !filteredOut ? '' : allMatches.note; const formatted = this.formatNodeList(callees.slice(0, limit), `Callees of ${symbol}`, labels) + note + filterNote; return this.textResult(this.truncateOutput(formatted)); } // Multiple DISTINCT definitions (#764): per-definition sections. const lines: string[] = [ - `**Callees of ${symbol} — ${groups.length} distinct definitions (narrow with \`file\`)**`, + `**Callees of ${symbol} — ${groups.length} distinct definitions (narrow with \`file\` + \`line\`)**`, ]; for (const group of groups) { const { callees, labels } = collect(group); @@ -1719,13 +1792,22 @@ export class ToolHandler { const cg = this.getCodeGraph(args.projectPath as string | undefined); const depth = clamp((args.depth as number) || 2, 1, 10); const fileFilter = typeof args.file === 'string' ? args.file : undefined; + const lineFilter = typeof args.line === 'number' && Number.isFinite(args.line) && args.line >= 1 + ? Math.floor(args.line) + : undefined; const allMatches = this.findAllSymbols(cg, symbol); if (allMatches.nodes.length === 0) { return this.textResult(`Symbol "${symbol}" not found in the codebase`); } - const { groups, filteredOut } = this.groupDefinitions(allMatches.nodes, fileFilter); + const { groups, filteredOut } = this.groupDefinitions(allMatches.nodes, fileFilter, lineFilter); + if (filteredOut && groups.length === 0) { + const location = fileFilter + ? `file "${fileFilter}"${lineFilter ? ` at line ${lineFilter}` : ''}` + : `line ${lineFilter}`; + return this.textResult(`Symbol "${symbol}" not found at ${location}`); + } const filterNote = filteredOut ? `\n\n> **Note:** no definition of "${symbol}" matches file "${fileFilter}" — showing all definitions instead.` : ''; @@ -1752,7 +1834,7 @@ export class ToolHandler { // Single definition (or same-file overloads): the familiar merged report. if (groups.length === 1) { - const formatted = this.formatImpact(symbol, impactOf(groups[0]!)) + (fileFilter && !filteredOut ? "" : allMatches.note) + filterNote; + const formatted = this.formatImpact(symbol, impactOf(groups[0]!)) + ((fileFilter || lineFilter) && !filteredOut ? "" : allMatches.note) + filterNote; return this.textResult(this.truncateOutput(formatted)); } @@ -1760,7 +1842,7 @@ export class ToolHandler { // merging unrelated same-named classes (one UserService per monorepo app) // overstated impact and confused agents. Narrow with `file`. const sections: string[] = [ - `**Impact of ${symbol} — ${groups.length} distinct definitions (each with its own blast radius; narrow with \`file\`)**`, + `**Impact of ${symbol} — ${groups.length} distinct definitions (each with its own blast radius; narrow with \`file\` + \`line\`)**`, ]; for (const group of groups) { const head = group[0]!; @@ -1879,6 +1961,102 @@ export class ToolHandler { * whose qualifiedName contains another named token (`PmsProductServiceImpl::list`), * dropping unrelated `OmsOrderService::list`. */ + private sourceLocationCallPaths(cg: CodeGraph, query: string): Array<{ source: Node; target: Node; edge: Edge }> { + const CALLABLE = new Set(['method', 'function', 'component', 'constructor']); + const paths: Array<{ source: Node; target: Node; edge: Edge }> = []; + const seen = new Set(); + const files = cg.getFiles().map((file) => file.path); + for (const hint of sourceLocationHints(query)) { + const matchingFiles = files.filter((filePath) => { + const normalized = filePath.replace(/\\/g, '/').toLowerCase(); + return normalized === hint.file || normalized.endsWith(`/${hint.file}`) || + (!hint.file.includes('/') && normalized.endsWith(`/${hint.file}`)); + }); + if (matchingFiles.length !== 1) continue; + let candidates: Node[] = []; + try { + candidates = cg.getNodesInFile(matchingFiles[0]!).filter((node) => + CALLABLE.has(node.kind) && node.startLine <= hint.line && (node.endLine ?? node.startLine) >= hint.line + ); + } catch { continue; } + if (candidates.length === 0) continue; + const source = [...candidates].sort((a, b) => + ((a.endLine ?? a.startLine) - a.startLine) - ((b.endLine ?? b.startLine) - b.startLine) || + Math.abs(a.startLine - hint.line) - Math.abs(b.startLine - hint.line) + )[0]!; + for (const callee of cg.getCallees(source.id)) { + if (callee.edge.kind !== 'calls' || callee.edge.line !== hint.line) continue; + const key = `${source.id}>${callee.node.id}@${hint.line}`; + if (seen.has(key)) continue; + seen.add(key); + paths.push({ source, target: callee.node, edge: callee.edge }); + } + } + return paths; + } + + /** + * Resolve direct calls between symbol names explicitly present in the query. + * This lets a proven edge disambiguate a large family such as shader `main` + * without relaxing the normal natural-language stopword safeguards. + */ + private queryDirectCallPaths(cg: CodeGraph, query: string): Array<{ source: Node; target: Node; edge: Edge }> { + const CALLABLE = new Set(['method', 'function', 'component', 'constructor']); + const FILE_EXT = /\.(?:java|kt|kts|ts|tsx|js|jsx|mjs|cjs|cs|py|go|rb|php|swift|rs|cpp|cc|cxx|c|h|hpp|scala|lua|dart|vue|svelte|astro|erl|hrl|glsl|vert|frag|comp|geom|tesc|tese|rgen|rmiss|rchit|rahit|rint|rcall|mesh|task|glslfx|hlsl|hlsli|fx|fxh)$/i; + const fileHints = [...query.matchAll(/(?:^|\s)([A-Za-z0-9_./\\-]+\.(?:java|kt|kts|ts|tsx|js|jsx|mjs|cjs|cs|py|go|rb|php|swift|rs|cpp|cc|cxx|c|h|hpp|scala|lua|dart|vue|svelte|astro|erl|hrl|glsl|vert|frag|comp|geom|tesc|tese|rgen|rmiss|rchit|rahit|rint|rcall|mesh|task|glslfx|hlsl|hlsli|fx|fxh))\b/gi)] + .map((match) => match[1]!.replace(/\\/g, '/').replace(/^\.\//, '').toLowerCase()); + const matchesHint = (filePath: string): boolean => { + const normalized = filePath.replace(/\\/g, '/').toLowerCase(); + return fileHints.some((hint) => + normalized === hint || normalized.endsWith(`/${hint}`) || + (!hint.includes('/') && normalized.endsWith(`/${hint}`)) + ); + }; + const tokens = [...new Set( + query.split(/(?:->|[\s,()[\]\u2192]+)/) + .map((token) => token.replace(/^[`'"{]+|[`'"}:;?!]+$/g, '').trim()) + .filter((token) => token.length >= 3 && !FILE_EXT.test(token) && /^[A-Za-z_$][\w$]*(?:(?:::|\.)[\w$]+)*$/.test(token)) + )].slice(0, 16); + const families = tokens.map((token) => { + const qualified = /::|\./.test(token); + const nodes = (qualified ? this.findAllSymbols(cg, token).nodes : cg.getNodesByName(token)) + .filter((node) => CALLABLE.has(node.kind)); + return { token, nodes }; + }).filter((family) => family.nodes.length > 0); + if (families.length < 2) return []; + + const paths = new Map(); + const addPath = (source: Node, target: Node, edge: Edge) => { + // Heuristic calls describe dynamic dispatch and need their synthesized + // label + wiring site; never flatten them into a proven static call path. + if (edge.kind !== 'calls' || edge.provenance === 'heuristic') return; + if (fileHints.length > 0 && !matchesHint(source.filePath) && !matchesHint(target.filePath)) return; + paths.set(`${source.id}\0${target.id}`, { source, target, edge }); + }; + for (let left = 0; left < families.length; left++) { + for (let right = left + 1; right < families.length; right++) { + const a = families[left]!; + const b = families[right]!; + // One endpoint must be narrow enough to inspect without turning a generic + // natural-language query into an all-to-all overload scan. + if (a.nodes.length > 6 && b.nodes.length > 6) continue; + const inspected = a.nodes.length <= b.nodes.length ? a : b; + const other = inspected === a ? b : a; + const otherIds = new Set(other.nodes.map((node) => node.id)); + for (const node of inspected.nodes) { + for (const callee of cg.getCallees(node.id)) { + if (otherIds.has(callee.node.id)) addPath(node, callee.node, callee.edge); + } + for (const caller of cg.getCallers(node.id)) { + if (otherIds.has(caller.node.id)) addPath(caller.node, node, caller.edge); + } + if (paths.size > 12) return []; + } + } + } + return [...paths.values()]; + } + private buildFlowFromNamedSymbols(cg: CodeGraph, query: string): { text: string; pathNodeIds: Set; namedNodeIds: Set; uniqueNamedNodeIds: Set; spineCallSites: Map } { // spineCallSites: for each spine node, the line where it CALLS the next hop — // lets the source assembler window an oversize spine method (e.g. n8n's 962-line @@ -1890,13 +2068,44 @@ export class ToolHandler { // names (Class.method / Class::method) — the agent's most precise input, // resolved exactly by findAllSymbols. (The old strip mangled Class.method // into Class, throwing the method away.) - const FILE_EXT = /\.(?:java|kt|kts|ts|tsx|js|jsx|mjs|cjs|cs|py|go|rb|php|swift|rs|cpp|cc|cxx|c|h|hpp|scala|lua|dart|vue|svelte|astro|erl|hrl)$/i; + const FILE_EXT = /\.(?:java|kt|kts|ts|tsx|js|jsx|mjs|cjs|cs|py|go|rb|php|swift|rs|cpp|cc|cxx|c|h|hpp|scala|lua|dart|vue|svelte|astro|erl|hrl|glsl|vert|frag|comp|geom|tesc|tese|rgen|rmiss|rchit|rahit|rint|rcall|mesh|task|glslfx|hlsl|hlsli|fx|fxh)$/i; + const fileHints = [...query.matchAll(/(?:^|\s)([A-Za-z0-9_./\\-]+\.(?:java|kt|kts|ts|tsx|js|jsx|mjs|cjs|cs|py|go|rb|php|swift|rs|cpp|cc|cxx|c|h|hpp|scala|lua|dart|vue|svelte|astro|erl|hrl|glsl|vert|frag|comp|geom|tesc|tese|rgen|rmiss|rchit|rahit|rint|rcall|mesh|task|glslfx|hlsl|hlsli|fx|fxh))\b/gi)].map((m) => m[1]!.replace(/\\/g, '/').replace(/^\.\//, '').toLowerCase()); + const sourceCallPaths = this.sourceLocationCallPaths(cg, query); + const namedDirectCallPaths = this.queryDirectCallPaths(cg, query).filter((path) => + !sourceCallPaths.some((sourcePath) => sourcePath.source.id === path.source.id && sourcePath.target.id === path.target.id) + ); + const pinnedCallPaths = [...sourceCallPaths, ...namedDirectCallPaths]; + const matchesHint = (filePath: string): boolean => { + const normalized = filePath.replace(/\\/g, '/').toLowerCase(); + return fileHints.some((hint) => + normalized === hint || normalized.endsWith(`/${hint}`) || + (!hint.includes('/') && normalized.endsWith(`/${hint}`)) + ); + }; + const shaderContextFiles = new Set(); + const shaderRoots = cg.getFiles().filter((file) => + (file.language === 'glsl' || file.language === 'hlsl') && matchesHint(file.path) + ).map((file) => file.path); + const shaderQueue = [...shaderRoots]; + for (let index = 0; index < shaderQueue.length && index < 4096; index++) { + const filePath = shaderQueue[index]!; + if (shaderContextFiles.has(filePath)) continue; + shaderContextFiles.add(filePath); + const fileNode = cg.getNodesInFile(filePath).find((node) => node.kind === 'file'); + if (!fileNode) continue; + for (const edge of cg.getOutgoingEdges(fileNode.id).filter((candidate) => candidate.kind === 'imports')) { + const target = cg.getNode(edge.target); + if (target?.kind === 'file' && (target.language === 'glsl' || target.language === 'hlsl') && !shaderContextFiles.has(target.filePath)) { + shaderQueue.push(target.filePath); + } + } + } const tokens = [...new Set( query.split(/[\s,()[\]]+/) .map((t) => t.replace(FILE_EXT, '').trim()) .filter((t) => t.length >= 3 && /^[A-Za-z_$][\w$]*(?:(?:::|\.)[\w$]+)*$/.test(t)) )].slice(0, 16); - if (tokens.length < 2) return EMPTY; + if (tokens.length < 2 && pinnedCallPaths.length === 0) return EMPTY; // Pool of name SEGMENTS (Class + method from every token) used to // disambiguate an ambiguous SIMPLE name: keep a candidate only if its // CONTAINER class is itself named in the query. @@ -1926,11 +2135,25 @@ export class ToolHandler { // fed only to the dynamic-dispatch-links scan below. const dynNamed = new Map(); const DYN_KINDS = new Set(['constant', 'variable', 'field', 'property']); + for (const path of pinnedCallPaths) { + named.set(path.source.id, path.source); + named.set(path.target.id, path.target); + uniqueNamedNodeIds.add(path.source.id); + uniqueNamedNodeIds.add(path.target.id); + } const hasHeuristicEdge = (id: string): boolean => [...cg.getCallers(id), ...cg.getCallees(id)].some(({ edge }) => edge.provenance === 'heuristic'); for (const t of tokens) { const hits = this.findAllSymbols(cg, t).nodes; - const cands = hits.filter((n) => CALLABLE.has(n.kind)); + const allCands = hits.filter((n) => CALLABLE.has(n.kind)); + const hintedCands = fileHints.length ? allCands.filter((n) => matchesHint(n.filePath)) : []; + const shaderCands = allCands.filter((n) => n.language === 'glsl' || n.language === 'hlsl'); + const contextualCands = shaderContextFiles.size > 0 + ? shaderCands.filter((n) => shaderContextFiles.has(n.filePath)) + : []; + const cands = shaderContextFiles.size > 0 && shaderCands.length > 0 + ? contextualCands + : (hintedCands.length > 0 ? hintedCands : allCands); tokenFamily.set(t, cands); // A qualified or otherwise-specific name (<=3 hits) keeps all; an // ambiguous simple name keeps only candidates whose container is named. @@ -1966,6 +2189,15 @@ export class ToolHandler { } if (named.size > 40) break; } + const sourcePinnedPaths: Array> = sourceCallPaths.map((path) => [ + { node: path.source, edge: null }, + { node: path.target, edge: path.edge }, + ]); + const namedPinnedPaths: Array> = namedDirectCallPaths.map((path) => [ + { node: path.source, edge: null }, + { node: path.target, edge: path.edge }, + ]); + const pinnedPaths = [...sourcePinnedPaths, ...namedPinnedPaths]; // Surface synthesized (heuristic) edges incident to a named symbol — INCLUDING // the non-callable CONSTANT endpoints in `dynNamed`. `skipInChain` drops a hop // already shown in the rendered main chain (a 2-node chain renders nothing, so a @@ -2039,8 +2271,11 @@ export class ToolHandler { chain.reverse(); if (!best || chain.length > best.length) best = chain; } - const hasMain = !!best && best.length >= 3; - const pathIds = new Set((best ?? []).map((s) => s.node.id)); + const hasMainChain = !!best && best.length >= 3; + const hasPinnedFlow = pinnedPaths.length > 0; + const hasMain = hasMainChain || hasPinnedFlow; + const pathIds = new Set((hasMainChain ? best ?? [] : []).map((s) => s.node.id)); + for (const path of pinnedPaths) for (const step of path) pathIds.add(step.node.id); // Where each spine node calls the NEXT hop (best[i+1].edge is the edge from // best[i] → best[i+1]; its line is the call site inside best[i]'s body). Lets // the assembler window an oversize spine method to the call instead of dumping it. @@ -2049,6 +2284,10 @@ export class ToolHandler { const ln = best[i + 1]?.edge?.line; if (ln && ln > 0 && !spineCallSites.has(best[i]!.node.id)) spineCallSites.set(best[i]!.node.id, ln); } + for (const path of pinnedPaths) { + const line = path[1]?.edge?.line; + if (line && !spineCallSites.has(path[0]!.node.id)) spineCallSites.set(path[0]!.node.id, line); + } // Dynamic-boundary scan (#687) — fires ONLY when the flow the agent // asked about did not fully connect: some token resolved to nodes but @@ -2073,7 +2312,7 @@ export class ToolHandler { } if (uncovered.length > 0) { const scanList: Node[] = []; - if (hasMain) scanList.push(best![best!.length - 1]!.node); + if (hasMainChain) scanList.push(best![best!.length - 1]!.node); scanList.push(...uncovered.sort((a, b) => (uniqueNamedNodeIds.has(b.id) ? 1 : 0) - (uniqueNamedNodeIds.has(a.id) ? 1 : 0))); boundaryText = this.buildDynamicBoundaries(cg, scanList, named); @@ -2115,7 +2354,29 @@ export class ToolHandler { if (!hasMain && synthLines.length === 0 && !boundaryText && !polyText) return EMPTY; const out: string[] = []; - if (hasMain) { + if (sourcePinnedPaths.length > 0) { + out.push('**Flow (call paths at the source locations you queried)**', ''); + for (const path of sourcePinnedPaths) { + for (let i = 0; i < path.length; i++) { + const step = path[i]!; + if (step.edge) out.push(` ↓ ${step.edge.kind} @${path[0]!.node.filePath}:${step.edge.line}`); + out.push(`${i + 1}. ${step.node.name} (${step.node.filePath}:${step.node.startLine})`); + } + out.push(''); + } + } + if (namedPinnedPaths.length > 0) { + out.push('**Flow (direct call paths among the symbols you queried)**', ''); + for (const path of namedPinnedPaths) { + for (let i = 0; i < path.length; i++) { + const step = path[i]!; + if (step.edge) out.push(` ↓ ${step.edge.kind} @${path[0]!.node.filePath}:${step.edge.line}`); + out.push(`${i + 1}. ${step.node.name} (${step.node.filePath}:${step.node.startLine})`); + } + out.push(''); + } + } + if (hasMainChain) { out.push('**Flow (call path among the symbols you queried)**', ''); for (let i = 0; i < best!.length; i++) { const step = best![i]!; @@ -2535,6 +2796,98 @@ export class ToolHandler { return this.textResult(`No relevant code found for "${query}"`); } + // An explicit source filename is a hard disambiguator. Keep the initial + // context anchored to that file before relevance ranking can let a common + // symbol such as shader `main` pull in unrelated entry points. When the + // query also asks for includes/dependencies, retain the transitive shader + // include closure so explore can answer the multi-file question directly. + const explicitShaderRootFiles = new Set(); + const explicitShaderContextFiles = new Set(); + const requestedShaderIncludeFiles = new Set(); + const explicitFileHints = [...query.matchAll(/(?:^|\s)([A-Za-z0-9_./\\-]+\.(?:glsl|vert|frag|comp|geom|tesc|tese|rgen|rmiss|rchit|rahit|rint|rcall|mesh|task|glslfx|hlsl|hlsli|fx|fxh))\b/gi)] + .map((m) => m[1]!.replace(/\\/g, '/').replace(/^\.\//, '').toLowerCase()); + if (explicitFileHints.length > 0) { + const expandIncludes = /\b(?:include|included|imports?|dependencies|dependency|closure|transitive|glsl files?|hlsl files?|shader files?)\b/i.test(query); + const isShaderPath = (filePath: string) => /\.(?:glsl|vert|frag|comp|geom|tesc|tese|rgen|rmiss|rchit|rahit|rint|rcall|mesh|task|glslfx|hlsl|hlsli|fx|fxh)$/i.test(filePath); + const matchesHint = (filePath: string) => { + const normalized = filePath.replace(/\\/g, '/').toLowerCase(); + return explicitFileHints.some((hint) => + normalized === hint || + normalized.endsWith(`/${hint}`) || + (!hint.includes('/') && normalized.endsWith(`/${hint}`)) + ); + }; + const hintedPaths = cg.getFiles().map((f) => f.path).filter(matchesHint); + for (const filePath of hintedPaths) explicitShaderRootFiles.add(filePath); + const hintedNodes = hintedPaths.flatMap((filePath) => { + try { return cg.getNodesInFile(filePath); } catch { return []; } + }); + const contextQueue = hintedNodes.filter((node) => node.kind === 'file'); + for (let index = 0; index < contextQueue.length && index < 4096; index++) { + const current = contextQueue[index]!; + if (explicitShaderContextFiles.has(current.filePath)) continue; + explicitShaderContextFiles.add(current.filePath); + let outgoing: Edge[] = []; + try { outgoing = cg.getOutgoingEdges(current.id); } catch { continue; } + for (const edge of outgoing) { + if (edge.kind !== 'imports') continue; + const target = cg.getNode(edge.target); + if (!target || target.kind !== 'file' || !isShaderPath(target.filePath) || explicitShaderContextFiles.has(target.filePath)) continue; + contextQueue.push(target); + } + } + const keptById = new Map(); + for (const n of [...subgraph.nodes.values()].filter((n) => matchesHint(n.filePath))) keptById.set(n.id, n); + for (const n of hintedNodes) keptById.set(n.id, n); + const includeRootIds = new Set(); + if (expandIncludes) { + const queue = hintedNodes.filter((node) => node.kind === 'file'); + const visited = new Set(queue.map((node) => node.id)); + const includeFileCap = Math.min(20, Math.max(maxFiles, 8)); + while (queue.length > 0 && visited.size < includeFileCap) { + const current = queue.shift()!; + let outgoing: Edge[] = []; + try { outgoing = cg.getOutgoingEdges(current.id); } catch { continue; } + for (const edge of outgoing) { + if (edge.kind !== 'imports' || visited.size >= includeFileCap) continue; + const target = cg.getNode(edge.target); + if (!target || target.kind !== 'file' || !isShaderPath(target.filePath)) continue; + if (visited.has(target.id)) continue; + visited.add(target.id); + includeRootIds.add(target.id); + requestedShaderIncludeFiles.add(target.filePath); + queue.push(target); + let importedNodes: Node[] = []; + try { importedNodes = cg.getNodesInFile(target.filePath); } catch { /* stale file row */ } + for (const node of importedNodes) keptById.set(node.id, node); + } + } + } + const kept = [...keptById.values()]; + if (kept.length > 0) { + const keepIds = new Set(kept.map((n) => n.id)); + subgraph.nodes = new Map(kept.map((n) => [n.id, n])); + const edgeByKey = new Map(); + const retainEdge = (edge: Edge) => { + if (!keepIds.has(edge.source) || !keepIds.has(edge.target)) return; + edgeByKey.set(`${edge.source}\0${edge.target}\0${edge.kind}`, edge); + }; + for (const edge of subgraph.edges) retainEdge(edge); + if (expandIncludes) { + for (const node of kept) { + let outgoing: Edge[] = []; + try { outgoing = cg.getOutgoingEdges(node.id); } catch { continue; } + for (const edge of outgoing) retainEdge(edge); + } + } + subgraph.edges = [...edgeByKey.values()]; + subgraph.roots = [...new Set([ + ...subgraph.roots.filter((id) => keepIds.has(id)), + ...includeRootIds, + ])]; + } + } + // Graph-aware glue: findRelevantContext builds the subgraph from name/text // search, so a method that BRIDGES named symbols — e.g. App.tsx's // triggerRender, which calls the named triggerUpdate — is never a search hit @@ -2585,7 +2938,16 @@ export class ToolHandler { // overloads (the query also named the type) all earn it. (#1064) const tierSeedIds = new Set(); { - const FILE_EXT = /\.(?:java|kt|kts|ts|tsx|js|jsx|mjs|cjs|cs|py|go|rb|php|swift|rs|cpp|cc|cxx|c|h|hpp|scala|lua|dart|vue|svelte|astro|erl|hrl)$/i; + const FILE_EXT = /\.(?:java|kt|kts|ts|tsx|js|jsx|mjs|cjs|cs|py|go|rb|php|swift|rs|cpp|cc|cxx|c|h|hpp|scala|lua|dart|vue|svelte|astro|erl|hrl|glsl|vert|frag|comp|geom|tesc|tese|rgen|rmiss|rchit|rahit|rint|rcall|mesh|task|glslfx|hlsl|hlsli|fx|fxh)$/i; + const fileHints = [...query.matchAll(/(?:^|\s)([A-Za-z0-9_./\\-]+\.(?:java|kt|kts|ts|tsx|js|jsx|mjs|cjs|cs|py|go|rb|php|swift|rs|cpp|cc|cxx|c|h|hpp|scala|lua|dart|vue|svelte|astro|erl|hrl|glsl|vert|frag|comp|geom|tesc|tese|rgen|rmiss|rchit|rahit|rint|rcall|mesh|task|glslfx|hlsl|hlsli|fx|fxh))\b/gi)].map((m) => m[1]!.replace(/\\/g, '/').replace(/^\.\//, '').toLowerCase()); + const matchesHint = (filePath: string): boolean => { + const normalized = filePath.replace(/\\/g, '/').toLowerCase(); + return fileHints.some((hint) => + normalized === hint || + normalized.endsWith(`/${hint}`) || + (!hint.includes('/') && normalized.endsWith(`/${hint}`)) + ); + }; const CALLABLE = new Set(['method', 'function', 'component', 'constructor']); const isTestPath = (p: string) => /(^|\/)(tests?|specs?|__tests__|testdata|mocks?|fixtures?)\//i.test(p) || /\.(test|spec)\.[a-z]+$/i.test(p); const bodyLines = (n: Node) => Math.max(0, (n.endLine ?? n.startLine) - n.startLine); @@ -2651,7 +3013,7 @@ export class ToolHandler { const isQual = /[.\/]|::/.test(t); const raw = isQual ? this.findAllSymbols(cg, t).nodes : cg.getNodesByName(t); let cands = raw - .filter((n) => CALLABLE.has(n.kind) && !isTestPath(n.filePath)) + .filter((n) => CALLABLE.has(n.kind) && (!isTestPath(n.filePath) || matchesHint(n.filePath) || explicitShaderContextFiles.has(n.filePath))) .sort((a, b) => (bodyLines(b) > 1 ? 1 : 0) - (bodyLines(a) > 1 ? 1 : 0) || bodyLines(b) - bodyLines(a)); // Field-name seeding fallback (#1196): a camelCase token that names NO // definition of its own is usually an object-literal key / API field @@ -2678,13 +3040,19 @@ export class ToolHandler { .sort((a, b) => a.name.length - b.name.length) .slice(0, 3); } + const shaderCands = cands.filter((n) => n.language === 'glsl' || n.language === 'hlsl'); + if (explicitShaderContextFiles.size > 0 && shaderCands.length > 0) { + cands = shaderCands.filter((n) => explicitShaderContextFiles.has(n.filePath)); + } + const hintedCands = fileHints.length ? cands.filter((n) => matchesHint(n.filePath)) : []; + if (hintedCands.length > 0) cands = hintedCands; // Bare lowercase words only seed defs their query-siblings corroborate // (see the NL-stopword guard above). Filtering CANDS (not picks) applies // the guard uniformly to both branches below, including the >3-def // single-pick fallback — an uncorroborated bare `run` must not tier its // most-substantive namesake any more than a 1-def `check` may. if (!isPreciseToken(t)) { - cands = cands.filter((n) => coNamedInFile(t, n.filePath)); + cands = cands.filter((n) => matchesHint(n.filePath) || coNamedInFile(t, n.filePath)); } // A specific name (<=3 defs) injects all its defs. An overloaded name // (`validate` = 10, `request` = 44) would flood the subgraph, so inject @@ -2723,7 +3091,102 @@ export class ToolHandler { } } + // Exact source locations are stronger than fuzzy relevance. Reattach the + // proven full-graph call at that line before Relationships and file ranking + // run, so natural queries such as `shader.rgen line 228` surface the same + // edge that callers already knows about. + const explicitSourceCallPaths = this.sourceLocationCallPaths(cg, query); + const explicitNamedCallPaths = this.queryDirectCallPaths(cg, query).filter((path) => + !explicitSourceCallPaths.some((sourcePath) => sourcePath.source.id === path.source.id && sourcePath.target.id === path.target.id) + ); + const priorityCalls = new Map(); + for (const path of [...explicitSourceCallPaths, ...explicitNamedCallPaths]) { + subgraph.nodes.set(path.source.id, path.source); + subgraph.nodes.set(path.target.id, path.target); + namedSeedIds.add(path.source.id); + namedSeedIds.add(path.target.id); + tierSeedIds.add(path.source.id); + tierSeedIds.add(path.target.id); + subgraph.roots.push(path.source.id); + priorityCalls.set(`${path.edge.source}\0${path.edge.target}\0${path.edge.kind}`, path.edge); + } + // A direct edge between symbols the query named is itself part of the + // requested answer. Fuzzy context gathering may omit that edge even after + // finding both endpoints; restore only exact named-to-named calls. + for (const sourceId of namedSeedIds) { + let outgoing: Edge[] = []; + try { outgoing = cg.getOutgoingEdges(sourceId); } catch { continue; } + for (const edge of outgoing) { + if (edge.kind !== 'calls' || !namedSeedIds.has(edge.target)) continue; + const key = `${edge.source}\0${edge.target}\0${edge.kind}`; + if (!priorityCalls.has(key)) priorityCalls.set(key, edge); + } + } + if (priorityCalls.size > 0) { + subgraph.edges = [ + ...priorityCalls.values(), + ...subgraph.edges.filter((edge) => + !priorityCalls.has(`${edge.source}\0${edge.target}\0${edge.kind}`) + ), + ]; + subgraph.roots = [...new Set(subgraph.roots)]; + } + // Step 2: Group nodes by file, score by relevance + // Include closures can contain large utility libraries with hundreds of + // declarations. Render the symbols that actually connect files, plus two + // intra-file hops from that cross-file spine, instead of letting one dense + // include consume the whole explore response. + const shaderIncludeRelevantNodeIds = new Set(); + const shaderClosureLineHints = new Map(); + const addShaderLineHint = (filePath: string, line: number | undefined) => { + if (!line || line <= 0) return; + const lines = shaderClosureLineHints.get(filePath) ?? []; + if (!lines.includes(line)) lines.push(line); + shaderClosureLineHints.set(filePath, lines); + }; + for (const location of sourceLocationHints(query)) { + for (const filePath of explicitShaderRootFiles) { + const normalized = filePath.replace(/\\/g, '/').toLowerCase(); + if (normalized === location.file || normalized.endsWith(`/${location.file}`) || + (!location.file.includes('/') && normalized.endsWith(`/${location.file}`))) { + addShaderLineHint(filePath, location.line); + } + } + } + if (requestedShaderIncludeFiles.size > 0) { + for (const edge of subgraph.edges) { + if (edge.kind === 'contains') continue; + const source = subgraph.nodes.get(edge.source); + const target = subgraph.nodes.get(edge.target); + if (!source || !target || source.filePath === target.filePath) continue; + if (requestedShaderIncludeFiles.has(source.filePath)) { + shaderIncludeRelevantNodeIds.add(source.id); + addShaderLineHint(source.filePath, edge.line ?? source.startLine); + } + if (requestedShaderIncludeFiles.has(target.filePath)) { + shaderIncludeRelevantNodeIds.add(target.id); + addShaderLineHint(target.filePath, target.startLine); + } + if (explicitShaderRootFiles.has(source.filePath)) addShaderLineHint(source.filePath, edge.line ?? source.startLine); + } + for (let depth = 0; depth < 2; depth++) { + const add = new Set(); + for (const edge of subgraph.edges) { + if (edge.kind !== 'calls' && edge.kind !== 'references') continue; + const source = subgraph.nodes.get(edge.source); + const target = subgraph.nodes.get(edge.target); + if (!source || !target || source.filePath !== target.filePath || !requestedShaderIncludeFiles.has(source.filePath)) continue; + if (shaderIncludeRelevantNodeIds.has(source.id)) add.add(target.id); + if (shaderIncludeRelevantNodeIds.has(target.id)) add.add(source.id); + } + for (const id of add) shaderIncludeRelevantNodeIds.add(id); + } + for (const id of shaderIncludeRelevantNodeIds) { + const node = subgraph.nodes.get(id); + if (node) addShaderLineHint(node.filePath, node.startLine); + } + } const fileGroups = new Map(); const entryNodeIds = new Set([...subgraph.roots, ...namedSeedIds]); @@ -2769,6 +3232,9 @@ export class ToolHandler { for (const node of subgraph.nodes.values()) { // Skip import/export nodes — they add noise without information if (node.kind === 'import' || node.kind === 'export') continue; + if (requestedShaderIncludeFiles.has(node.filePath) + && node.kind !== 'file' + && !shaderIncludeRelevantNodeIds.has(node.id)) continue; // SECURITY (#383): never render the on-disk source of a config-leaf // (Spring application.{yml,properties} key) — its line is `key = `, // so whole-file/cluster rendering here would push secrets into context @@ -2797,6 +3263,10 @@ export class ToolHandler { // Only include files that have entry points or nodes directly connected to entry points let relevantFiles = [...fileGroups.entries()].filter(([, group]) => group.score >= 3); + for (const filePath of new Set([...requestedShaderIncludeFiles, ...explicitShaderRootFiles])) { + const group = fileGroups.get(filePath); + if (group && !relevantFiles.some(([candidate]) => candidate === filePath)) relevantFiles.push([filePath, group]); + } // Extract query terms for relevance checking const queryTerms = query.toLowerCase().split(/\s+/).filter(t => t.length >= 3); @@ -2895,6 +3365,8 @@ export class ToolHandler { const n = subgraph.nodes.get(id); if (n) entryFiles.add(n.filePath); } + for (const filePath of requestedShaderIncludeFiles) entryFiles.add(filePath); + for (const filePath of explicitShaderRootFiles) entryFiles.add(filePath); // Buried-rescue pass (#1064): surface a named method's signature type ONLY // when its file is genuinely buried — near-zero graph mass AND not lexically // matched. That is the invisible case (grpc's `DialOption` → `dialoptions.go`, @@ -2960,6 +3432,7 @@ export class ToolHandler { // buried-rescue pass) is the lexically-dissimilar answer; give it the named // tier so it isn't buried under files that merely share surface words (#1064). for (const fp of changeSurfaceFiles) namedSeedFiles.add(fp); + for (const fp of explicitShaderRootFiles) namedSeedFiles.add(fp); // Multi-term corroboration tier: a file that is BOTH (a) an entry/central file // (a search root, named seed, or graph-central hub — i.e. structurally part of @@ -3158,7 +3631,8 @@ export class ToolHandler { // after the build + validators-exec files and never reached the ranked-in // validate-logic file (Alamofire's Validation.swift). const fileNecessary = group.nodes.some(n => - entryNodeIds.has(n.id) || flow.pathNodeIds.has(n.id) || flow.uniqueNamedNodeIds.has(n.id)); + entryNodeIds.has(n.id) || flow.pathNodeIds.has(n.id) || flow.uniqueNamedNodeIds.has(n.id)) + || requestedShaderIncludeFiles.has(filePath); if (!fileNecessary && totalChars > budget.maxOutputChars * 0.9) continue; const absPath = validatePathWithinRoot(projectRoot, filePath); @@ -3376,7 +3850,7 @@ export class ToolHandler { const n = cg.getNode(id); if (n && n.filePath === filePath && n.startLine > 0 && n.endLine > 0) rangeNodes.set(id, n); } - const ranges: Array<{ start: number; end: number; name: string; kind: string; importance: number; spine: boolean; spineCallLine?: number }> = [...rangeNodes.values()] + const ranges: Array<{ start: number; end: number; name: string; kind: string; importance: number; spine: boolean; spineStartLine?: number; spineCallLine?: number }> = [...rangeNodes.values()] // Drop whole-file envelope nodes (containers covering >50% of the file). .filter(n => !(ENVELOPE_KINDS.has(n.kind) && (n.endLine - n.startLine + 1) > fileLines.length * 0.5)) .map(n => { @@ -3390,7 +3864,8 @@ export class ToolHandler { // processRunExecutionData, the named flow ENTRY at L1562, is a large // low-density method that lost the budget to denser blocks and got cut, so // the agent Read it back — the very thing explore exists to prevent). - return { start: n.startLine, end: n.endLine, name: n.name, kind: n.kind, importance, spine: flow.pathNodeIds.has(n.id), spineCallLine: flow.spineCallSites.get(n.id) }; + const spine = flow.pathNodeIds.has(n.id); + return { start: n.startLine, end: n.endLine, name: n.name, kind: n.kind, importance, spine, spineStartLine: spine ? n.startLine : undefined, spineCallLine: flow.spineCallSites.get(n.id) }; }); // Add edge source locations in this file — captures template references @@ -3416,8 +3891,10 @@ export class ToolHandler { if (ranges.length === 0) continue; - const gapThreshold = budget.gapThreshold; - const clusters: Array<{ start: number; end: number; symbols: string[]; score: number; maxImportance: number; hasSpine: boolean; spineCallLine?: number }> = []; + const shaderClosureView = (requestedShaderIncludeFiles.size > 0 || shaderClosureLineHints.has(filePath)) + && (explicitShaderRootFiles.has(filePath) || requestedShaderIncludeFiles.has(filePath)); + const gapThreshold = shaderClosureView ? 1 : budget.gapThreshold; + const clusters: Array<{ start: number; end: number; symbols: string[]; score: number; maxImportance: number; hasSpine: boolean; spineStartLine?: number; spineCallLine?: number }> = []; let current = { start: ranges[0]!.start, end: ranges[0]!.end, @@ -3425,6 +3902,7 @@ export class ToolHandler { score: ranges[0]!.importance, maxImportance: ranges[0]!.importance, hasSpine: ranges[0]!.spine, + spineStartLine: ranges[0]!.spineStartLine, spineCallLine: ranges[0]!.spineCallLine, }; @@ -3436,6 +3914,7 @@ export class ToolHandler { current.score += r.importance; current.maxImportance = Math.max(current.maxImportance, r.importance); current.hasSpine = current.hasSpine || r.spine; + current.spineStartLine = current.spineStartLine ?? r.spineStartLine; current.spineCallLine = current.spineCallLine ?? r.spineCallLine; } else { clusters.push(current); @@ -3446,6 +3925,7 @@ export class ToolHandler { score: r.importance, maxImportance: r.importance, hasSpine: r.spine, + spineStartLine: r.spineStartLine, spineCallLine: r.spineCallLine, }; } @@ -3472,7 +3952,29 @@ export class ToolHandler { // the spine's call still appears in context. const OVERSIZE_SPINE_LINES = 200; const SPINE_WINDOW = 28; // lines each side of the next-hop call site - const buildSection = (c: { start: number; end: number; hasSpine?: boolean; spineCallLine?: number }): string => { + const buildSection = (c: { start: number; end: number; hasSpine?: boolean; spineStartLine?: number; spineCallLine?: number }): string => { + if (shaderClosureView && (c.end - c.start + 1) > 160) { + const hints = (shaderClosureLineHints.get(filePath) ?? []) + .filter((line) => line >= c.start && line <= c.end) + .sort((a, b) => a - b) + .filter((line, index, all) => index === 0 || line - all[index - 1]! >= 20) + .slice(0, 4); + if (hints.length > 0) { + anyFileTrimmed = true; + const parts: string[] = []; + const headEnd = Math.min(c.end, c.start + 4); + const head = fileLines.slice(c.start - 1, headEnd).join('\n'); + parts.push(withLineNumbers ? numberSourceLines(head, c.start) : head); + for (const line of hints) { + const winStart = Math.max(c.start, line - 10); + const winEnd = Math.min(c.end, line + 10); + if (winStart <= headEnd + 1) continue; + const win = fileLines.slice(winStart - 1, winEnd).join('\n'); + parts.push(withLineNumbers ? numberSourceLines(win, winStart) : win); + } + return parts.join(GAP_MARKER); + } + } if (c.hasSpine && c.spineCallLine && (c.end - c.start + 1) > OVERSIZE_SPINE_LINES) { const call = c.spineCallLine; const winStart = Math.max(c.start, call - SPINE_WINDOW); @@ -3489,6 +3991,15 @@ export class ToolHandler { parts.push(withLineNumbers ? numberSourceLines(win, winStart) : win); return parts.join(GAP_MARKER); } + if (c.hasSpine && c.spineStartLine && (c.end - c.start + 1) > OVERSIZE_SPINE_LINES) { + // A terminal path endpoint has no next-hop call site. Window around its + // definition instead of letting one enormous callee consume the entire + // response and displace the caller source from the same proven path. + const winStart = Math.max(c.start, c.spineStartLine - 3); + const winEnd = Math.min(c.end, c.spineStartLine + SPINE_WINDOW * 2); + const win = fileLines.slice(winStart - 1, winEnd).join('\n'); + return withLineNumbers ? numberSourceLines(win, winStart) : win; + } const startIdx = Math.max(0, c.start - 1 - contextPadding); const endIdx = Math.min(fileLines.length, c.end + contextPadding); const slice = fileLines.slice(startIdx, endIdx).join('\n'); @@ -4516,7 +5027,11 @@ export class ToolHandler { return { nodes, note: '' }; } } - let results = cg.searchNodes(symbol, { limit: 50 }); + // Bare names need the uncapped exact index: shader entry points commonly + // all share `main`, and the FTS limit would hide the file the query named. + let results = /^[A-Za-z_$][\w$]*$/.test(symbol) + ? cg.getNodesByName(symbol).map((node) => ({ node })) + : cg.searchNodes(symbol, { limit: 50 }); // Mirror the fallback in `findSymbol` for qualified queries — FTS // strips colons, so a module-qualified lookup needs a second pass @@ -4635,7 +5150,10 @@ export class ToolHandler { for (const [file, nodes] of byFile) { lines.push(`**${file}:**`); // Compact: inline list - const nodeList = nodes.map(n => `${n.name}:${n.startLine}`).join(', '); + const nodeList = nodes.map((n) => { + const conditions = n.decorators?.filter((d) => d.startsWith('pp:')); + return `${n.name}:${n.startLine}${conditions?.length ? ` [${conditions.join(' && ')}]` : ''}`; + }).join(', '); lines.push(nodeList); lines.push(''); } diff --git a/src/powershell-dependencies.ts b/src/powershell-dependencies.ts new file mode 100644 index 000000000..e39bb8bd2 --- /dev/null +++ b/src/powershell-dependencies.ts @@ -0,0 +1,29 @@ +export interface PowershellDependencySpec { + kind: 'module' | 'dot-source'; + relativePath: string; + index: number; + signature: string; +} + +function relativeScriptPath(args: string): string | undefined { + return args.match(/Join-Path\s+\$PSScriptRoot\s+['"]([^'"]+)['"]/i)?.[1] + ?? args.match(/['"]\$PSScriptRoot[\\/]([^'"]+)['"]/i)?.[1] + ?? args.match(/^['"]([^'"]+\.(?:psm1|psd1|ps1))['"]/i)?.[1] + ?? args.match(/^([^\s)]+\.(?:psm1|psd1|ps1))\b/i)?.[1]; +} + +/** Static local PowerShell dependencies that establish a callable scope. */ +export function extractPowershellDependencies(source: string): PowershellDependencySpec[] { + const result: PowershellDependencySpec[] = []; + for (const match of source.matchAll(/^[ \t]*Import-Module\s+([^\r\n]+)/gim)) { + const relativePath = relativeScriptPath(match[1]!); + if (!relativePath) continue; + result.push({ kind: 'module', relativePath, index: match.index ?? 0, signature: match[0].trim() }); + } + for (const match of source.matchAll(/^[ \t]*\.[ \t]+([^\r\n]+)/gim)) { + const relativePath = relativeScriptPath(match[1]!); + if (!relativePath) continue; + result.push({ kind: 'dot-source', relativePath, index: match.index ?? 0, signature: match[0].trim() }); + } + return result.sort((a, b) => a.index - b.index); +} diff --git a/src/project-config.ts b/src/project-config.ts index ec388daf7..003ce61e3 100644 --- a/src/project-config.ts +++ b/src/project-config.ts @@ -67,6 +67,10 @@ export interface ProjectConfig { * wins. Absent/empty (the default) forces nothing in. */ include?: string[]; + /** Project-relative include roots used by GLSL/HLSL preprocessors. */ + shaderIncludePaths?: string[]; + /** Shader include prefix aliases, e.g. "$TOOLS": "pxr/imaging". */ + shaderIncludeAliases?: Record; } /** Parsed, validated view of a project's `codegraph.json`. */ @@ -75,6 +79,8 @@ interface ParsedConfig { includeIgnored: string[]; exclude: string[]; include: string[]; + shaderIncludePaths: string[]; + shaderIncludeAliases: Record; } interface CacheEntry { @@ -97,6 +103,8 @@ const EMPTY_CONFIG: ParsedConfig = Object.freeze({ includeIgnored: Object.freeze([]) as unknown as string[], exclude: Object.freeze([]) as unknown as string[], include: Object.freeze([]) as unknown as string[], + shaderIncludePaths: Object.freeze([]) as unknown as string[], + shaderIncludeAliases: Object.freeze({}) as Record, }); /** @@ -149,15 +157,19 @@ function parseConfig(file: string): ParsedConfig { const includeIgnored = extractIncludeIgnored(parsed, file); const exclude = extractExclude(parsed, file); const include = extractInclude(parsed, file); + const shaderIncludePaths = extractShaderIncludePaths(parsed, file); + const shaderIncludeAliases = extractShaderIncludeAliases(parsed, file); if ( extensions === EMPTY_EXTENSIONS && includeIgnored.length === 0 && exclude.length === 0 && - include.length === 0 + include.length === 0 && + shaderIncludePaths.length === 0 && + Object.keys(shaderIncludeAliases).length === 0 ) { return EMPTY_CONFIG; } - return { extensions, includeIgnored, exclude, include }; + return { extensions, includeIgnored, exclude, include, shaderIncludePaths, shaderIncludeAliases }; } /** @@ -264,6 +276,42 @@ function extractInclude(parsed: object, file: string): string[] { return out; } +function extractShaderIncludePaths(parsed: object, file: string): string[] { + const raw = (parsed as ProjectConfig).shaderIncludePaths; + if (raw === undefined) return []; + if (!Array.isArray(raw)) { + logWarn(`Ignoring "shaderIncludePaths" in ${PROJECT_CONFIG_FILENAME}: must be an array of project-relative paths`, { file }); + return []; + } + const out: string[] = []; + for (const entry of raw) { + if (typeof entry !== 'string' || !entry.trim()) { + logWarn(`Ignoring a "shaderIncludePaths" entry in ${PROJECT_CONFIG_FILENAME}: every path must be a non-empty string`, { file }); + continue; + } + out.push(entry.trim().replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/$/, '')); + } + return [...new Set(out)]; +} + +function extractShaderIncludeAliases(parsed: object, file: string): Record { + const raw = (parsed as ProjectConfig).shaderIncludeAliases; + if (raw === undefined) return {}; + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + logWarn(`Ignoring "shaderIncludeAliases" in ${PROJECT_CONFIG_FILENAME}: must be an object of prefix-to-path mappings`, { file }); + return {}; + } + const out: Record = {}; + for (const [prefix, target] of Object.entries(raw)) { + if (!prefix.trim() || typeof target !== 'string' || !target.trim()) { + logWarn(`Ignoring shader include alias "${prefix}" in ${PROJECT_CONFIG_FILENAME}: aliases require non-empty string targets`, { file }); + continue; + } + out[prefix.trim()] = target.trim().replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/$/, ''); + } + return out; +} + /** * Load the parsed `codegraph.json` for a project, mtime-cached. A missing or * malformed file yields the zero-config default. One `stat` (and at most one @@ -338,6 +386,14 @@ export function loadIncludePatterns(rootDir: string): string[] { return loadParsedConfig(rootDir).include; } +export function loadShaderIncludePaths(rootDir: string): string[] { + return loadParsedConfig(rootDir).shaderIncludePaths; +} + +export function loadShaderIncludeAliases(rootDir: string): Record { + return loadParsedConfig(rootDir).shaderIncludeAliases; +} + /** Test/maintenance hook: forget cached config (e.g. after rewriting it in a test). */ export function clearProjectConfigCache(): void { cache.clear(); diff --git a/src/resolution/callback-synthesizer.ts b/src/resolution/callback-synthesizer.ts index 143681c87..8376d50a2 100644 --- a/src/resolution/callback-synthesizer.ts +++ b/src/resolution/callback-synthesizer.ts @@ -28,6 +28,7 @@ import { isGeneratedFile } from '../extraction/generated-detection'; import { stripCommentsForRegex } from './strip-comments'; import { cFnPointerDispatchEdges } from './c-fnptr-synthesizer'; import { goframeRouteEdges } from './goframe-synthesizer'; +import { shaderIntegrationEdges } from './shader-synthesizer'; import { createYielder, type MaybeYield } from './cooperative-yield'; const REGISTRAR_NAME = /^(on[A-Z]\w*|subscribe|addListener|addEventListener|register|watch|listen|addCallback)$/; @@ -3541,6 +3542,7 @@ export const SYNTH_PASSES: SynthPassDef[] = [ }, { name: 'goframeEdges', gate: (has) => has('go'), run: (_q, c, y) => goframeRouteEdges(c, y) }, { name: 'nixOptionEdges', gate: (has) => has('nix'), run: (q, _c, y) => nixOptionPathEdges(q, y) }, + { name: 'shaderEdges', gate: (has) => has('glsl', 'hlsl'), run: (q, c, y) => shaderIntegrationEdges(q, c, y) }, ]; /** Fixed non-registry steps: goMethodContains, goImplements, dedupe-merge, insertMergedEdges. */ diff --git a/src/resolution/import-resolver.ts b/src/resolution/import-resolver.ts index 75056d3ce..ef733508e 100644 --- a/src/resolution/import-resolver.ts +++ b/src/resolution/import-resolver.ts @@ -10,6 +10,7 @@ import { Language, Node } from '../types'; import { UnresolvedRef, ResolvedRef, ResolutionContext, ImportMapping, ReExport } from './types'; import { applyAliases } from './path-aliases'; import { resolveWorkspaceImport } from './workspace-packages'; +import { extractPowershellDependencies } from '../powershell-dependencies'; import { resolveMethodOnType, localReceiverTypePatterns, @@ -1276,10 +1277,54 @@ function pickClosestJvmCandidate(candidates: Node[], fromPath: string): Node { return best; } +function powershellDependencyPaths(filePath: string, context: ResolutionContext): Array<{ filePath: string; exportedOnly: boolean }> { + const source = context.readFile(filePath); + if (!source) return []; + const files = new Set(context.getAllFiles()); + const result = new Map(); + for (const dependency of extractPowershellDependencies(source)) { + const candidate = path.posix.normalize(path.posix.join( + path.posix.dirname(filePath.replace(/\\/g, '/')), + dependency.relativePath.replace(/\\/g, '/'), + )); + if (candidate.startsWith('../') || !files.has(candidate)) continue; + const exportedOnly = dependency.kind === 'module'; + result.set(candidate, (result.get(candidate) ?? true) && exportedOnly); + } + return [...result].map(([dependencyPath, exportedOnly]) => ({ filePath: dependencyPath, exportedOnly })); +} + +function powershellExports(modulePath: string, context: ResolutionContext): Set | null { + const source = context.readFile(modulePath); + const match = source?.match(/Export-ModuleMember\b[^\r\n]*?-Function\s+([^\r\n]+)/i); + if (!match) return null; + return new Set(match[1]!.split(',').map((name) => name.trim().replace(/^['"]|['"]$/g, '').toLowerCase()).filter(Boolean)); +} + export function resolveViaImport( ref: UnresolvedRef, context: ResolutionContext ): ResolvedRef | null { + if (ref.language === 'powershell') { + if (ref.referenceKind === 'imports') { + const target = context.getNodesInFile(ref.referenceName).find((node) => node.kind === 'file'); + return target ? { original: ref, targetNodeId: target.id, confidence: 0.98, resolvedBy: 'file-path' } : null; + } + if (ref.referenceKind === 'calls') { + const matches: Node[] = []; + for (const dependency of powershellDependencyPaths(ref.filePath, context)) { + const exports = dependency.exportedOnly ? powershellExports(dependency.filePath, context) : null; + if (exports && !exports.has(ref.referenceName.toLowerCase())) continue; + for (const node of context.getNodesInFile(dependency.filePath)) { + if (node.kind === 'function' && node.name.toLowerCase() === ref.referenceName.toLowerCase()) matches.push(node); + } + } + return matches.length === 1 + ? { original: ref, targetNodeId: matches[0]!.id, confidence: 0.97, resolvedBy: 'import' } + : null; + } + } + // C/C++ #include references — resolve directly to the included file // (file→file edge), bypassing symbol lookup. The extractor emits these // with `referenceKind: 'imports'` and `referenceName: ` diff --git a/src/resolution/index.ts b/src/resolution/index.ts index e218c5f3a..6ad2319dc 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -28,6 +28,7 @@ import { loadWorkspacePackages, type WorkspacePackages } from './workspace-packa import { logDebug } from '../errors'; import type { ReExport } from './types'; import { LRUCache } from './lru-cache'; +import { isShaderLanguage, ShaderResolver } from './shader-resolver'; /** Node kinds that can declare supertypes (extends/implements). */ const SUPERTYPE_BEARING_KINDS = new Set([ @@ -253,6 +254,7 @@ export class ReferenceResolver { private goModule: GoModule | null | undefined = undefined; // Monorepo workspace member packages. Same lazy/immutable convention. private workspacePackages: WorkspacePackages | null | undefined = undefined; + private shaderResolver: ShaderResolver; constructor(projectRoot: string, queries: QueryBuilder) { this.projectRoot = projectRoot; @@ -275,6 +277,7 @@ export class ReferenceResolver { this.methodMatchCache = new LRUCache(limit); this.context = this.createContext(); + this.shaderResolver = new ShaderResolver(this.context); } /** @@ -373,6 +376,7 @@ export class ReferenceResolver { this.knownNames = null; this.knownFiles = null; this.cachesWarmed = false; + this.shaderResolver.clear(); // The import-resolver's per-context memos assume the same stable window // as the caches above — drop them together. if (this.context) clearImportResolverMemos(this.context); @@ -619,6 +623,7 @@ export class ReferenceResolver { const resolved: ResolvedRef[] = []; const unresolved: UnresolvedRef[] = []; const byMethod: Record = {}; + let resolvedRefCount = 0; // Convert to our internal format, using denormalized fields when available const refs: UnresolvedRef[] = unresolvedRefs.map((ref) => ({ @@ -627,6 +632,7 @@ export class ReferenceResolver { referenceKind: ref.referenceKind, line: ref.line, column: ref.column, + candidates: ref.candidates, filePath: ref.filePath || this.getFilePathFromNodeId(ref.fromNodeId), language: ref.language || this.getLanguageFromNodeId(ref.fromNodeId), rowId: ref.rowId, @@ -637,11 +643,12 @@ export class ReferenceResolver { for (let i = 0; i < refs.length; i++) { const ref = refs[i]!; // Array index is guaranteed to be in bounds - const result = this.resolveOneTimed(ref); + const results = this.resolveTargetsTimed(ref); - if (result) { - resolved.push(result); - byMethod[result.resolvedBy] = (byMethod[result.resolvedBy] || 0) + 1; + if (results.length > 0) { + resolved.push(...results); + resolvedRefCount++; + byMethod[results[0]!.resolvedBy] = (byMethod[results[0]!.resolvedBy] || 0) + 1; } else { unresolved.push(ref); } @@ -666,7 +673,7 @@ export class ReferenceResolver { unresolved, stats: { total: refs.length, - resolved: resolved.length, + resolved: resolvedRefCount, unresolved: unresolved.length, byMethod, }, @@ -773,6 +780,11 @@ export class ReferenceResolver { return null; } + // Shader symbols live in a translation unit assembled from explicit includes. + // Never fall through to global path-proximity matching: duplicate helpers and + // resources are common across shader variants, so ambiguity must stay silent. + if (isShaderLanguage(ref.language)) return this.shaderResolver.resolve(ref); + // CFML component paths in inheritance (#1152): `extends="coldbox.system.web. // Controller"` names the supertype by its dot-separated path (or `extends= // "../base"` by relative file path) — the graph indexes the class under its @@ -851,6 +863,28 @@ export class ReferenceResolver { if (razorResult) return razorResult; } + // A named local C++ lambda is lexically closer than anything introduced by + // an included header. Resolve that scoped child before import resolution can + // capture the bare call with an unrelated same-named method declaration. + if (ref.language === 'cpp' && ref.referenceKind === 'calls') { + const lexical = this.gateLanguage(matchReference(ref, this.context), ref); + if (lexical) { + const caller = this.queries.getNodeById(ref.fromNodeId); + const target = this.queries.getNodeById(lexical.targetNodeId); + if ( + caller && target && + (caller.kind === 'function' || caller.kind === 'method') && + target.kind === 'function' && + target.filePath === ref.filePath && + (target.qualifiedName.startsWith(`${caller.qualifiedName}::`) || + target.qualifiedName.startsWith(`${caller.name}::`) || + target.qualifiedName.includes(`::${caller.name}::`)) + ) { + return lexical; + } + } + } + const candidates: ResolvedRef[] = []; // Strategy 1: Try framework-specific resolution. Cross-language bridges @@ -891,6 +925,21 @@ export class ReferenceResolver { : null; } + // PowerShell has no ambient project-wide function namespace. A command is + // callable from the same script or from an explicitly imported module; + // never let the generic matcher jump to a same-named function in an + // unrelated audit script. + if (ref.language === 'powershell' && ref.referenceKind === 'calls') { + const local = this.gateLanguage(matchReference(ref, this.context), ref); + if (local) { + const target = this.queries.getNodeById(local.targetNodeId); + if (target?.filePath === ref.filePath) return local; + } + return candidates.length > 0 + ? candidates.reduce((best, curr) => curr.confidence > best.confidence ? curr : best) + : null; + } + // Strategy 3: Try name matching let nameResult = this.gateLanguage(matchReference(ref, this.context), ref); // Nix has no ambient cross-file namespace — a callee binds lexically @@ -946,6 +995,18 @@ export class ReferenceResolver { ); } + private resolveTargets(ref: UnresolvedRef): ResolvedRef[] { + const single = this.resolveOne(ref); + if (single) return [single]; + if (!isShaderLanguage(ref.language)) return []; + return this.shaderResolver.getConditionalCallTargets(ref).map((target) => ({ + original: ref, + targetNodeId: target.id, + confidence: 0.95, + resolvedBy: 'import' as const, + })); + } + /** * Create edges from resolved references */ @@ -1221,6 +1282,7 @@ export class ReferenceResolver { const resolved: ResolvedRef[] = []; const unresolved: UnresolvedRef[] = []; const byMethod: Record = {}; + let resolvedRefCount = 0; for (const raw of batch) { const ref: UnresolvedRef = { @@ -1229,14 +1291,16 @@ export class ReferenceResolver { referenceKind: raw.referenceKind, line: raw.line, column: raw.column, + candidates: raw.candidates, filePath: raw.filePath || this.getFilePathFromNodeId(raw.fromNodeId), language: raw.language || this.getLanguageFromNodeId(raw.fromNodeId), rowId: raw.rowId, }; - const result = this.resolveOneTimed(ref); - if (result) { - resolved.push(result); - byMethod[result.resolvedBy] = (byMethod[result.resolvedBy] || 0) + 1; + const results = this.resolveTargetsTimed(ref); + if (results.length > 0) { + resolved.push(...results); + resolvedRefCount++; + byMethod[results[0]!.resolvedBy] = (byMethod[results[0]!.resolvedBy] || 0) + 1; } else { unresolved.push(ref); } @@ -1251,7 +1315,7 @@ export class ReferenceResolver { unresolved, stats: { total: batch.length, - resolved: resolved.length, + resolved: resolvedRefCount, unresolved: unresolved.length, byMethod, }, @@ -1278,12 +1342,12 @@ export class ReferenceResolver { private resolveProfile: Map | null = process.env.CODEGRAPH_RESOLVE_PROFILE ? new Map() : null; - private resolveOneTimed(ref: UnresolvedRef): ResolvedRef | null { - if (!this.resolveProfile) return this.resolveOne(ref); + private resolveTargetsTimed(ref: UnresolvedRef): ResolvedRef[] { + if (!this.resolveProfile) return this.resolveTargets(ref); const t0 = process.hrtime.bigint(); - const result = this.resolveOne(ref); + const results = this.resolveTargets(ref); const dt = process.hrtime.bigint() - t0; - const key = result ? result.resolvedBy : `fail:${ref.referenceKind}`; + const key = results.length > 0 ? results[0]!.resolvedBy : `fail:${ref.referenceKind}`; const slot = this.resolveProfile.get(key); if (slot) { slot.n++; @@ -1291,7 +1355,7 @@ export class ReferenceResolver { } else { this.resolveProfile.set(key, { n: 1, ns: dt }); } - return result; + return results; } /** Dump the CODEGRAPH_RESOLVE_PROFILE histogram to stderr (no-op when off). */ @@ -1329,10 +1393,10 @@ export class ReferenceResolver { language: raw.language || this.getLanguageFromNodeId(raw.fromNodeId), rowId: raw.rowId, }; - const result = this.resolveOneTimed(ref); - if (result) { - resolved.push(result); - byMethod[result.resolvedBy] = (byMethod[result.resolvedBy] || 0) + 1; + const results = this.resolveTargetsTimed(ref); + if (results.length > 0) { + resolved.push(...results); + byMethod[results[0]!.resolvedBy] = (byMethod[results[0]!.resolvedBy] || 0) + 1; } else { unresolved.push(ref); } @@ -2185,6 +2249,11 @@ export class ReferenceResolver { private gateLanguage(result: ResolvedRef | null, ref: UnresolvedRef): ResolvedRef | null { if (!result) return result; + if ( + (ref.language === 'cmake' || ref.language === 'powershell') && + result.resolvedBy === 'file-path' && + (ref.referenceKind === 'references' || ref.referenceKind === 'imports') + ) return result; const tgt = this.getLanguageFromNodeId(result.targetNodeId); if (!tgt || !ref.language) return result; if ((ref.referenceKind === 'references' || ref.referenceKind === 'function_ref') && !sameLanguageFamily(tgt, ref.language)) return null; diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 329dc5d40..b81ef232a 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -412,6 +412,47 @@ export function matchByExactName( }; } + // A named local C++ lambda is indexed as a function nested under its + // enclosing function/method (`recordGraph::bufferResource`). Prefer that + // lexical child over same-named project methods before path proximity can + // select an unrelated API symbol. Multiple children remain unresolved. + if (ref.language === 'cpp' && ref.referenceKind === 'calls' && context.getNodeById) { + const caller = context.getNodeById(ref.fromNodeId); + if (caller && (caller.kind === 'function' || caller.kind === 'method')) { + const prefix = `${caller.qualifiedName}::`; + const nameScope = `::${caller.name}::`; + const local = candidates.filter((candidate) => + candidate.kind === 'function' && + candidate.filePath === ref.filePath && + (candidate.qualifiedName.startsWith(prefix) || + candidate.qualifiedName.startsWith(`${caller.name}::`) || + candidate.qualifiedName.includes(nameScope)) + ); + if (local.length === 1) { + return { + original: ref, + targetNodeId: local[0]!.id, + confidence: 0.98, + resolvedBy: 'exact-match', + }; + } + } + } + + // A bare C/C++ method call such as `std::string::empty()` may arrive as + // just `empty` when the extractor cannot retain the receiver chain. Never + // guess among unrelated method declarations in that case; a false edge is + // worse than an unresolved standard-library call. Same-file methods remain + // eligible because the enclosing type is a strong local signal. + if ( + (ref.language === 'cpp' || ref.language === 'c') && + ref.referenceKind === 'calls' && + candidates.every((candidate) => candidate.kind === 'method') && + !candidates.some((candidate) => candidate.filePath === ref.filePath) + ) { + return null; + } + // Ubiquitous-name ceiling (#999): above it, picking one target among K // same-named defs by directory proximity is unreliable AND O(K) per ref — the // quadratic behind the "Resolving refs" wedge on theme/SDK-vendoring repos. @@ -1589,6 +1630,11 @@ export function matchMethodCall( if (typedMatch) { return typedMatch; } + // C++ receiver typing is strong evidence. If the inferred type's method + // is not indexed (most commonly `std::string::empty()`), do not discard + // that evidence and fall through to a unique same-named project method. + // Missing is safer than manufacturing a cross-type call edge. + if (ref.language === 'cpp') return null; } } @@ -1702,6 +1748,12 @@ export function matchMethodCall( } } + // `empty()` is ubiquitous on standard-library containers and strings, whose + // declarations are intentionally outside the project index. An untyped C++ + // receiver must not capture the only project method named `empty`; precise + // class-name and inferred-type strategies already had their chance above. + if (ref.language === 'cpp' && dotMatch && methodName === 'empty') return null; + // Strategy 3: Find methods by name across the codebase, match by receiver // name similarity with the containing class. Handles abbreviated variable // names like permissionEngine → PermissionRuleEngine. diff --git a/src/resolution/shader-resolver.ts b/src/resolution/shader-resolver.ts new file mode 100644 index 000000000..49ef5c149 --- /dev/null +++ b/src/resolution/shader-resolver.ts @@ -0,0 +1,496 @@ +import * as path from 'path'; +import type { Language, Node, ReferenceKind } from '../types'; +import { loadShaderIncludeAliases, loadShaderIncludePaths } from '../project-config'; +import type { ResolutionContext, ResolvedRef, UnresolvedRef } from './types'; + +export const SHADER_EXTENSIONS = [ + '.glsl', '.vert', '.frag', '.comp', '.geom', '.tesc', '.tese', + '.rgen', '.rmiss', '.rchit', '.rahit', '.rint', '.rcall', '.mesh', '.task', + '.glslfx', '.hlsl', '.hlsli', '.fx', '.fxh', +] as const; + +export function isShaderLanguage(language: Language): language is 'glsl' | 'hlsl' { + return language === 'glsl' || language === 'hlsl'; +} + +function normalizeRelative(root: string, candidate: string): string | null { + const absolute = path.resolve(root, candidate); + const relative = path.relative(root, absolute); + if (!relative || relative === '.') return null; + if (relative.startsWith('..') || path.isAbsolute(relative)) return null; + return relative.replace(/\\/g, '/'); +} + +function withExtensionFallback(value: string): string[] { + if (path.posix.extname(value)) return [value]; + return [value, ...SHADER_EXTENSIONS.map((ext) => value + ext)]; +} + +export function resolveShaderPath( + includePath: string, + fromFile: string, + context: ResolutionContext, + line?: number, +): string | null { + const projectRoot = context.getProjectRoot(); + const aliases = loadShaderIncludeAliases(projectRoot); + const roots = loadShaderIncludePaths(projectRoot); + let requested = includePath.replace(/\\/g, '/').trim(); + if (!requested) return null; + + for (const [prefix, target] of Object.entries(aliases)) { + if (requested === prefix || requested.startsWith(prefix + '/')) { + requested = path.posix.join(target, requested.slice(prefix.length).replace(/^\//, '')); + const aliased = withExtensionFallback(requested) + .map((p) => normalizeRelative(projectRoot, p)) + .filter((p): p is string => !!p && context.fileExists(p)); + return aliased.length === 1 ? aliased[0]! : null; + } + } + + const sourceLine = line ? context.getFileLines?.(fromFile)?.[line - 1] : undefined; + const angleInclude = !!sourceLine && /#\s*(?:include|import)\s* path.posix.join(root, requested))); + tiers.push([requested]); + + for (const tier of tiers) { + const matches = new Set(); + for (const base of tier) { + for (const candidate of withExtensionFallback(base)) { + const relative = normalizeRelative(projectRoot, candidate); + if (relative && context.fileExists(relative)) matches.add(relative); + } + } + if (matches.size === 1) return [...matches][0]!; + if (matches.size > 1) return null; + } + + // Compiler include roots are often declared only in CMake and are not always + // mirrored into codegraph.json. A project-wide suffix is still precise when + // it identifies exactly one indexed file (`Rtxdi/DI/Reservoir.hlsli` -> + // `Libraries/Rtxdi/Include/Rtxdi/DI/Reservoir.hlsli`). Ambiguous application + // bridges deliberately remain unresolved. + const suffixMatches = new Set(); + for (const requestedPath of withExtensionFallback(requested)) { + const normalized = requestedPath.replace(/^\.\//, '').replace(/^(?:\.\.\/)+/, ''); + const comparableRequest = process.platform === 'win32' ? normalized.toLowerCase() : normalized; + for (const file of context.getAllFiles()) { + const candidate = file.replace(/\\/g, '/'); + if (!normalizeRelative(projectRoot, candidate)) continue; + const comparableCandidate = process.platform === 'win32' ? candidate.toLowerCase() : candidate; + if (comparableCandidate === comparableRequest || comparableCandidate.endsWith('/' + comparableRequest)) { + suffixMatches.add(candidate); + } + } + } + if (suffixMatches.size === 1) return [...suffixMatches][0]!; + return null; +} + +function targetKinds(kind: ReferenceKind): Set | null { + if (kind === 'calls' || kind === 'function_ref') return new Set(['function', 'method']); + if (kind === 'extends' || kind === 'implements') return new Set(['struct', 'class', 'interface', 'type_alias']); + return null; +} + +function normalizedSignature(node: Node): string | null { + let signature = node.signature?.replace(/\s+/g, ' ').trim(); + if (!signature) return null; + // Parameter names differ across equivalent shader variants (`seed` vs + // `state`) but their type/qualifier contract is what makes a call safe. + signature = signature.replace( + /\b(inout|out|in|const)\s+([A-Za-z_]\w*(?:\s*\[\s*\])?)\s+[A-Za-z_]\w*/g, + '$1 $2 _', + ); + signature = signature.replace(/\b([A-Za-z_]\w*(?:\s*\[\s*\])?)\s+[A-Za-z_]\w*(?=\s*(?:,|\)))/g, '$1 _'); + // Some bridge declarations omit parameter names entirely. Normalize those + // to the same contract as a named parameter without erasing real type data. + signature = signature.replace( + /([,(]\s*)((?:inout|out|in|const)\s+)?([A-Za-z_]\w*(?:\s*\[\s*\])?)(?=\s*(?:,|\)))/g, + '$1$2$3 _', + ); + return signature ? signature : null; +} + +function callArityHint(ref: UnresolvedRef): number | null { + for (const candidate of ref.candidates ?? []) { + const match = candidate.match(/^arity:(\d+)$/); + if (match) return Number(match[1]); + } + return null; +} + +function signatureParameters(node: Node): { params: string[]; variadic: boolean } | null { + const signature = node.signature; + if (!signature) return null; + const open = signature.indexOf('('); + if (open < 0) return null; + let paren = 0; + let square = 0; + let angle = 0; + let current = ''; + const params: string[] = []; + for (let i = open + 1; i < signature.length; i++) { + const char = signature[i]!; + if (char === '(') paren++; + else if (char === ')') { + if (paren === 0) { + if (current.trim()) params.push(current.trim()); + const meaningful = params.filter((param) => param !== 'void'); + const variadic = meaningful.some((param) => param.includes('...')); + return { params: meaningful, variadic }; + } + paren--; + } else if (char === '[') square++; + else if (char === ']') square = Math.max(0, square - 1); + else if (char === '<') angle++; + else if (char === '>') angle = Math.max(0, angle - 1); + if (char === ',' && paren === 0 && square === 0 && angle === 0) { + params.push(current.trim()); + current = ''; + } else { + current += char; + } + } + return null; +} + +function signatureArity(node: Node): { minimum: number; variadic: boolean } | null { + const parsed = signatureParameters(node); + if (!parsed) return null; + return { + minimum: parsed.params.filter((param) => !param.includes('...')).length, + variadic: parsed.variadic, + }; +} + +function callTypeHints(ref: UnresolvedRef): Map { + const hints = new Map(); + for (const candidate of ref.candidates ?? []) { + const match = candidate.match(/^argtype:(\d+):([A-Za-z_]\w*)$/); + if (match) hints.set(Number(match[1]), match[2]!.toLowerCase()); + } + return hints; +} + +function parameterType(param: string): string | null { + const cleaned = param + .replace(/\s*:\s*[A-Za-z_]\w*\s*$/, '') + .replace(/\s*=.*$/, '') + .replace(/\b(?:inout|out|in|const|static|uniform|groupshared)\b/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + if (!cleaned || cleaned.includes('...')) return null; + const tokens = cleaned.split(' '); + const type = tokens.length > 1 ? tokens.slice(0, -1).join(' ') : tokens[0]!; + return type.replace(/\s+/g, '').toLowerCase(); +} + +function applyCallEvidence(candidates: Node[], ref: UnresolvedRef): Node[] { + const arity = callArityHint(ref); + let narrowed = candidates; + if (arity !== null) { + const matching = narrowed.filter((node) => { + const parsed = signatureArity(node); + return parsed && (parsed.variadic ? arity >= parsed.minimum : arity === parsed.minimum); + }); + if (matching.length > 0) narrowed = matching; + } + const typeHints = callTypeHints(ref); + if (typeHints.size > 0) { + const matching = narrowed.filter((node) => { + const parsed = signatureParameters(node); + if (!parsed) return false; + for (const [index, expected] of typeHints) { + const actual = parsed.params[index] ? parameterType(parsed.params[index]!) : null; + if (!actual || actual !== expected) return false; + } + return true; + }); + if (matching.length > 0) narrowed = matching; + } + return narrowed; +} + +export interface ContextualShaderTarget { + node: Node; + contextRoots: string[]; +} + +interface ConditionalBranchFrame { + groupLine: number; + branch: number; +} + +function conditionalBranchesByLine(source: string): ConditionalBranchFrame[][] { + const lines = source.split(/\r?\n/); + const result: ConditionalBranchFrame[][] = Array.from({ length: lines.length + 1 }, () => []); + const stack: ConditionalBranchFrame[] = []; + for (let index = 0; index < lines.length; index++) { + result[index + 1] = stack.map((frame) => ({ ...frame })); + const line = lines[index]!; + if (/^\s*#\s*(?:if|ifdef|ifndef)\b/.test(line)) { + stack.push({ groupLine: index + 1, branch: 0 }); + } else if (/^\s*#\s*(?:elif|else)\b/.test(line) && stack.length > 0) { + stack[stack.length - 1]!.branch++; + } else if (/^\s*#\s*endif\b/.test(line)) { + stack.pop(); + } + } + return result; +} + +export class ShaderResolver { + private closureCache = new Map>(); + private directIncludeCache = new Map(); + private reverseIncludeCache = new Map<'glsl' | 'hlsl', Map>>(); + private contextRootCache = new Map>(); + private conditionalBranchCache = new Map(); + + constructor(private readonly context: ResolutionContext) {} + + clear(): void { + this.closureCache.clear(); + this.directIncludeCache.clear(); + this.reverseIncludeCache.clear(); + this.contextRootCache.clear(); + this.conditionalBranchCache.clear(); + } + + resolve(ref: UnresolvedRef): ResolvedRef | null { + if (!isShaderLanguage(ref.language)) return null; + if (ref.referenceKind === 'imports') { + const file = resolveShaderPath(ref.referenceName, ref.filePath, this.context, ref.line); + if (!file) return null; + const target = this.context.getNodesInFile(file).find((n) => n.kind === 'file'); + return target ? { original: ref, targetNodeId: target.id, confidence: 1, resolvedBy: 'import' } : null; + } + + const kinds = targetKinds(ref.referenceKind); + const roots = this.getContextRoots(ref.filePath, ref.language); + const candidates = applyCallEvidence(this.context.getNodesByName(ref.referenceName).filter((node) => { + if (node.language !== ref.language || node.id === ref.fromNodeId) return false; + if (node.kind === 'file' || node.kind === 'import') return false; + return !kinds || kinds.has(node.kind); + }), ref); + if (candidates.length === 0) return null; + const scored = candidates.map((node) => ({ node, distance: this.contextDistance(node.filePath, ref.language as 'glsl' | 'hlsl', roots) })) + .filter((item) => Number.isFinite(item.distance)); + if (scored.length === 0) { + // Some shared shader headers are injected by the build system rather than + // included textually. If all matching declarations have the same callable + // contract, resolve the equivalent variant deterministically instead of + // leaving every call in that header unresolved. + const signatures = new Set(candidates.map(normalizedSignature)); + if (signatures.size !== 1 || signatures.has(null)) return null; + const target = [...candidates].sort((a, b) => a.filePath.localeCompare(b.filePath) || a.startLine - b.startLine)[0]!; + return { original: ref, targetNodeId: target.id, confidence: 0.72, resolvedBy: 'import' }; + } + const minDepth = Math.min(...scored.map((item) => item.distance)); + const nearest = scored.filter((item) => item.distance === minDepth).map((item) => item.node); + const files = new Set(nearest.map((node) => node.filePath)); + if (files.size !== 1) { + // A shared HLSL library can be compiled under several application bridge + // roots. One reference node therefore legitimately targets one callback + // per translation-unit context; choosing a single file conflates those + // implementations. Leave the ref for contextual edge synthesis. + return null; + } + if (nearest.length > 1) { + // Distinct overload contracts in one closure require type inference we do + // not have. Equivalent conditional declarations can still collapse. + const signatures = new Set(nearest.map(normalizedSignature)); + if (signatures.size !== 1 || signatures.has(null)) return null; + } + const target = [...nearest].sort((a, b) => a.filePath.localeCompare(b.filePath) || a.startLine - b.startLine)[0]!; + return { original: ref, targetNodeId: target.id, confidence: minDepth === 0 ? 0.98 : 0.95, resolvedBy: 'import' }; + } + + /** + * Return one equivalent callable per nearest shader translation-unit context. + * Used after ordinary resolution deliberately leaves a multi-root bridge call + * unresolved; differing signatures stay ambiguous. + */ + getContextualCallTargets(ref: UnresolvedRef): Node[] { + return this.getContextualCallTargetContexts(ref).map((target) => target.node); + } + + /** + * Return one target from each mutually exclusive branch of the same shader + * preprocessor conditional. This is a deliberate variant union: the active + * compiler defines choose one branch, while source-only analysis must retain + * every possible call edge. Independent guarded overloads remain ambiguous. + */ + getConditionalCallTargets(ref: UnresolvedRef): Node[] { + if (!isShaderLanguage(ref.language) || ref.referenceKind !== 'calls') return []; + const language = ref.language as 'glsl' | 'hlsl'; + const roots = this.getContextRoots(ref.filePath, language); + const candidates = applyCallEvidence(this.context.getNodesByName(ref.referenceName).filter((node) => + node.language === ref.language && + node.id !== ref.fromNodeId && + (node.kind === 'function' || node.kind === 'method') + ), ref); + const scored = candidates.map((node) => ({ + node, + distance: this.contextDistance(node.filePath, language, roots), + })).filter((item) => Number.isFinite(item.distance)); + if (scored.length < 2) return []; + const minDepth = Math.min(...scored.map((item) => item.distance)); + const nearest = scored.filter((item) => item.distance === minDepth).map((item) => item.node); + if (nearest.length < 2 || new Set(nearest.map((node) => node.filePath)).size !== 1) return []; + + const filePath = nearest[0]!.filePath; + let branches = this.conditionalBranchCache.get(filePath); + if (!branches) { + const source = this.context.readFile(filePath); + if (!source) return []; + branches = conditionalBranchesByLine(source); + this.conditionalBranchCache.set(filePath, branches); + } + const stacks = nearest.map((node) => branches![node.startLine] ?? []); + if (stacks.some((stack) => stack.length === 0)) return []; + + for (const frame of stacks[0]!) { + const selected = stacks.map((stack) => stack.find((candidate) => candidate.groupLine === frame.groupLine)); + if (selected.some((candidate) => !candidate)) continue; + const branchIds = selected.map((candidate) => candidate!.branch); + // One candidate per branch proves this is a conditional implementation + // family, not an unresolved overload set inside one branch. + if (new Set(branchIds).size !== nearest.length) continue; + return [...nearest].sort((a, b) => a.startLine - b.startLine); + } + return []; + } + + getContextualCallTargetContexts(ref: UnresolvedRef): ContextualShaderTarget[] { + if (!isShaderLanguage(ref.language) || ref.referenceKind !== 'calls') return []; + const language = ref.language as 'glsl' | 'hlsl'; + const roots = this.getContextRoots(ref.filePath, language); + const candidates = applyCallEvidence(this.context.getNodesByName(ref.referenceName).filter((node) => + node.language === ref.language && + node.id !== ref.fromNodeId && + (node.kind === 'function' || node.kind === 'method') + ), ref); + const scored = candidates.map((node) => ({ + node, + distance: this.contextDistance(node.filePath, language, roots), + })).filter((item) => Number.isFinite(item.distance)); + if (scored.length < 2) return []; + const minDepth = Math.min(...scored.map((item) => item.distance)); + const nearest = scored.filter((item) => item.distance === minDepth).map((item) => item.node); + if (new Set(nearest.map((node) => node.filePath)).size < 2) return []; + const signatures = new Set(nearest.map(normalizedSignature)); + if (signatures.size !== 1 || signatures.has(null)) return []; + + const byFile = new Map(); + for (const node of [...nearest].sort((a, b) => a.filePath.localeCompare(b.filePath) || a.startLine - b.startLine)) { + if (!byFile.has(node.filePath)) byFile.set(node.filePath, node); + } + const reverse = this.getReverseIncludes(language); + return [...byFile.values()].map((node) => { + const matching = new Set([...roots.keys()].filter((root) => this.getClosure(root, language).has(node.filePath))); + const topLevel = [...matching].filter((root) => + ![...(reverse.get(root) ?? [])].some((parent) => matching.has(parent)) + ); + return { node, contextRoots: (topLevel.length > 0 ? topLevel : [...matching]).sort() }; + }); + } + + getClosure(filePath: string, language: 'glsl' | 'hlsl'): Map { + const key = `${language}\0${filePath}`; + const cached = this.closureCache.get(key); + if (cached) return cached; + const depth = new Map([[filePath, 0]]); + const queue = [filePath]; + for (let index = 0; index < queue.length && index < 4096; index++) { + const file = queue[index]!; + const currentDepth = depth.get(file)!; + if (currentDepth >= 32) continue; + for (const target of this.getDirectIncludes(file, language)) { + if (!target || depth.has(target)) continue; + depth.set(target, currentDepth + 1); + queue.push(target); + } + } + this.closureCache.set(key, depth); + return depth; + } + + private getDirectIncludes(filePath: string, language: 'glsl' | 'hlsl'): string[] { + const key = `${language}\0${filePath}`; + const cached = this.directIncludeCache.get(key); + if (cached) return cached; + const targets = new Set(); + const content = this.context.readFile(filePath); + if (content) { + const lines = content.split(/\r?\n/); + for (let row = 0; row < lines.length; row++) { + const match = lines[row]!.match(/^\s*#\s*(?:include|import)\s*[<"]([^>"]+)[>"]/); + if (!match) continue; + const target = resolveShaderPath(match[1]!, filePath, this.context, row + 1); + if (!target) continue; + const targetLanguage = this.context.getNodesInFile(target).find((node) => node.kind === 'file')?.language; + if (targetLanguage === language) targets.add(target); + } + } + const result = [...targets]; + this.directIncludeCache.set(key, result); + return result; + } + + private getReverseIncludes(language: 'glsl' | 'hlsl'): Map> { + const cached = this.reverseIncludeCache.get(language); + if (cached) return cached; + const reverse = new Map>(); + for (const file of this.context.getAllFiles()) { + const fileLanguage = this.context.getNodesInFile(file).find((node) => node.kind === 'file')?.language; + if (fileLanguage !== language) continue; + for (const target of this.getDirectIncludes(file, language)) { + let parents = reverse.get(target); + if (!parents) { + parents = new Set(); + reverse.set(target, parents); + } + parents.add(file); + } + } + this.reverseIncludeCache.set(language, reverse); + return reverse; + } + + private getContextRoots(filePath: string, language: 'glsl' | 'hlsl'): Map { + const key = `${language}\0${filePath}`; + const cached = this.contextRootCache.get(key); + if (cached) return cached; + const reverse = this.getReverseIncludes(language); + const roots = new Map([[filePath, 0]]); + const queue = [filePath]; + for (let index = 0; index < queue.length && index < 4096; index++) { + const file = queue[index]!; + const currentDepth = roots.get(file)!; + if (currentDepth >= 32) continue; + for (const parent of reverse.get(file) ?? []) { + if (roots.has(parent)) continue; + roots.set(parent, currentDepth + 1); + queue.push(parent); + } + } + this.contextRootCache.set(key, roots); + return roots; + } + + private contextDistance( + targetFile: string, + language: 'glsl' | 'hlsl', + roots: Map, + ): number { + let best = Number.POSITIVE_INFINITY; + for (const [root, reverseDepth] of roots) { + const forwardDepth = this.getClosure(root, language).get(targetFile); + if (forwardDepth !== undefined) best = Math.min(best, reverseDepth + forwardDepth); + } + return best; + } +} diff --git a/src/resolution/shader-synthesizer.ts b/src/resolution/shader-synthesizer.ts new file mode 100644 index 000000000..9518c75c4 --- /dev/null +++ b/src/resolution/shader-synthesizer.ts @@ -0,0 +1,276 @@ +import * as path from 'path'; +import type { Edge, Node } from '../types'; +import type { QueryBuilder } from '../db/queries'; +import type { MaybeYield } from './cooperative-yield'; +import type { ResolutionContext } from './types'; +import { isShaderLanguage, ShaderResolver, SHADER_EXTENSIONS } from './shader-resolver'; + +const SHADER_LITERAL_RE = new RegExp( + `["']([^"']+(?:${SHADER_EXTENSIONS.map((ext) => ext.replace('.', '\\.')).join('|')})(?:\\.[A-Za-z0-9_-]+)*(?:\\.spv)?)["']`, + 'gi', +); + +function enclosingCallNames(source: string, literalOffset: number): string[] { + const names: string[] = []; + let closedParens = 0; + const start = Math.max(0, literalOffset - 768); + for (let index = literalOffset - 1; index >= start; index--) { + const char = source[index]!; + if (char === ')') { + closedParens++; + continue; + } + if (char === '(') { + if (closedParens > 0) { + closedParens--; + continue; + } + const prefix = source.slice(start, index); + const match = /([A-Za-z_]\w*(?:(?:::|->|\.)[A-Za-z_]\w*)*)\s*$/.exec(prefix); + if (match) names.push(match[1]!.split(/::|->|\./).pop()!); + continue; + } + if (closedParens === 0 && (char === ';' || char === '{' || char === '}')) break; + } + return names; +} + +function createsShaderEntryAssociation(source: string, literalOffset: number): boolean { + return enclosingCallNames(source, literalOffset).some((name) => { + const lower = name.toLowerCase(); + const hasShaderObject = /shader|spirv|spv|pipeline|program/.test(lower); + const hasLoaderVerb = /compile|load|create|build|make|open|read|register|attach|add|set/.test(lower); + return hasShaderObject && hasLoaderVerb; + }); +} + +function sourceFor(node: Node, ctx: ResolutionContext): string { + const lines = ctx.getFileLines?.(node.filePath) ?? ctx.readFile(node.filePath)?.split(/\r?\n/) ?? []; + return lines.slice(Math.max(0, node.startLine - 1), node.endLine).join('\n'); +} + +function resolveShaderAsset(literal: string, fromFile: string, shaderFiles: string[]): string | null { + let requested = literal.replace(/\\/g, '/').replace(/^\.\//, ''); + const derivedSpv = requested.toLowerCase().endsWith('.spv'); + if (derivedSpv) requested = requested.slice(0, -4); + const fromDir = path.posix.dirname(fromFile); + const tiers = [ + shaderFiles.filter((file) => file === path.posix.normalize(path.posix.join(fromDir, requested))), + shaderFiles.filter((file) => file === requested), + shaderFiles.filter((file) => file.endsWith('/' + requested)), + ...(derivedSpv ? [shaderFiles.filter((file) => { + const base = path.posix.basename(file); + const requestedBase = path.posix.basename(requested); + return requested.startsWith(file + '.') || requestedBase.startsWith(base + '.'); + })] : []), + shaderFiles.filter((file) => path.posix.basename(file) === path.posix.basename(requested)), + ]; + for (const tier of tiers) { + const unique = [...new Set(tier)]; + if (unique.length === 1) return unique[0]!; + if (unique.length > 1) return null; + } + return null; +} + +function decoratorValue(node: Node, prefix: string): string | undefined { + return node.decorators?.find((value) => value.startsWith(prefix + ':'))?.slice(prefix.length + 1); +} + +function resourceClass(node: Node): string | undefined { + const explicit = node.decorators?.find((value) => value.startsWith('resource:'))?.slice('resource:'.length); + if (explicit === 'buffer') return 'storage'; + if (explicit) return explicit; + if (node.decorators?.includes('storage:buffer')) return 'storage'; + if (node.decorators?.includes('storage:uniform')) return 'constant-buffer'; + return undefined; +} + +function descriptorClass(text: string): string | undefined { + if (/STORAGE_(?:IMAGE|BUFFER)|STORAGE_BUFFER/i.test(text)) return 'storage'; + if (/COMBINED_IMAGE_SAMPLER|SAMPLED_IMAGE/i.test(text)) return 'texture'; + if (/\bSAMPLER\b/i.test(text)) return 'sampler'; + if (/UNIFORM_BUFFER/i.test(text)) return 'constant-buffer'; + if (/ACCELERATION_STRUCTURE/i.test(text)) return 'acceleration-structure'; + return undefined; +} + +function resourcesFor(shaderFile: string, resolver: ShaderResolver, ctx: ResolutionContext): Node[] { + const language = ctx.getNodesInFile(shaderFile).find((node) => node.kind === 'file')?.language; + if (!language || !isShaderLanguage(language)) return []; + const closure = resolver.getClosure(shaderFile, language); + const resources: Node[] = []; + for (const file of closure.keys()) { + resources.push(...ctx.getNodesInFile(file).filter((node) => + node.kind === 'variable' || node.kind === 'constant' || node.kind === 'struct' + )); + } + return resources; +} + +function uniqueResource(candidates: Node[]): Node | null { + const unique = [...new Map(candidates.map((node) => [node.id, node])).values()]; + return unique.length === 1 ? unique[0]! : null; +} + +function interfaceEdges(owner: Node, shaderFile: string, entry: Node, body: string, resolver: ShaderResolver, ctx: ResolutionContext): Edge[] { + const out: Edge[] = []; + const resources = resourcesFor(shaderFile, resolver, ctx); + const registeredAt = `${owner.filePath}:${owner.startLine}`; + const add = (target: Node | null, metadata: Record): void => { + if (!target) return; + out.push({ source: owner.id, target: target.id, kind: 'references', provenance: 'heuristic', line: owner.startLine, + metadata: { synthesizedBy: 'shader-interface', shaderEntry: entry.qualifiedName, registeredAt, ...metadata } }); + }; + + const bindingRe = /\b(?:dstBinding|binding)\b\s*(?:=\s*)?(?:\{\s*)?(\d+)/g; + let match: RegExpExecArray | null; + while ((match = bindingRe.exec(body))) { + const binding = match[1]!; + const nearby = body.slice(Math.max(0, match.index - 140), Math.min(body.length, match.index + 280)); + const expectedClass = descriptorClass(nearby); + const expectedSet = nearby.match(/\b(?:set|space)\b\s*(?:=\s*)?(\d+)/i)?.[1]; + const candidates = resources.filter((node) => { + const explicit = decoratorValue(node, 'binding'); + const register = decoratorValue(node, 'register')?.match(/^[tsub](\d+)$/)?.[1]; + if (explicit !== binding && register !== binding) return false; + const actualSet = decoratorValue(node, 'set') ?? decoratorValue(node, 'space'); + if (expectedSet !== undefined && actualSet !== expectedSet) return false; + const actualClass = resourceClass(node); + return !expectedClass || !actualClass || actualClass === expectedClass || + (expectedClass === 'constant-buffer' && actualClass === 'buffer'); + }); + add(uniqueResource(candidates), { binding: Number(binding), set: expectedSet === undefined ? undefined : Number(expectedSet), descriptorType: expectedClass }); + } + + const constantIds = new Set(); + for (const id of body.matchAll(/\bconstantID\b\s*=\s*(\d+)/g)) constantIds.add(id[1]!); + for (const id of body.matchAll(/VkSpecializationMapEntry(?:\s+[A-Za-z_]\w*)?\s*\{\s*(\d+)/g)) constantIds.add(id[1]!); + for (const id of constantIds) add(uniqueResource(resources.filter((node) => decoratorValue(node, 'constant_id') === id)), { constantId: Number(id) }); + + const locations = new Set(); + for (const location of body.matchAll(/\blocation\b\s*=\s*(\d+)/g)) locations.add(location[1]!); + for (const location of body.matchAll(/VkVertexInputAttributeDescription(?:\s+[A-Za-z_]\w*)?\s*(?:=\s*)?\{\s*(\d+)/g)) locations.add(location[1]!); + for (const location of locations) add(uniqueResource(resources.filter((node) => decoratorValue(node, 'location') === location)), { location: Number(location) }); + + if (/\bvkCmdPushConstants\b|\bVkPushConstantRange\b/.test(body)) { + add(uniqueResource(resources.filter((node) => node.decorators?.includes('push_constant'))), { pushConstant: true }); + } + return out; +} + +export async function shaderIntegrationEdges( + queries: QueryBuilder, + ctx: ResolutionContext, + onYield: MaybeYield, +): Promise { + const shaderFiles = ctx.getAllFiles().filter((file) => { + const language = ctx.getNodesInFile(file).find((node) => node.kind === 'file')?.language; + return !!language && isShaderLanguage(language); + }); + if (shaderFiles.length === 0) return []; + + const resolver = new ShaderResolver(ctx); + const edges: Edge[] = []; + const seen = new Set(); + + // A shared shader library is compiled once per application bridge. The same + // indexed call site can therefore target several equivalent implementations, + // one in each include-root context. Ordinary resolution intentionally leaves + // that multi-target ref unresolved; represent every proven context here. + let contextualScanned = 0; + for (const ref of queries.getUnresolvedReferences?.() ?? []) { + if (!ref.language || !ref.filePath || !isShaderLanguage(ref.language) || ref.referenceKind !== 'calls') continue; + if ((++contextualScanned & 127) === 0) await onYield(); + for (const target of resolver.getContextualCallTargetContexts({ + ...ref, + filePath: ref.filePath, + language: ref.language, + })) { + edges.push({ + source: ref.fromNodeId, + target: target.node.id, + kind: 'calls', + line: ref.line, + provenance: 'heuristic', + metadata: { + synthesizedBy: 'shader-context-variant', + reference: ref.referenceName, + targetFile: target.node.filePath, + contextRoots: target.contextRoots, + registeredAt: `${ref.filePath}:${ref.line}`, + }, + }); + } + } + + const interfaceOwners = new Map }>(); + const callable = [ + ...(ctx.iterateNodesByKind?.('function') ?? ctx.getNodesByKind('function')), + ...(ctx.iterateNodesByKind?.('method') ?? ctx.getNodesByKind('method')), + ]; + let scanned = 0; + for (const node of callable) { + if (node.language !== 'c' && node.language !== 'cpp') continue; + if ((++scanned & 127) === 0) await onYield(); + const body = sourceFor(node, ctx); + SHADER_LITERAL_RE.lastIndex = 0; + const associated = new Map(); + let match: RegExpExecArray | null; + while ((match = SHADER_LITERAL_RE.exec(body))) { + const shaderFile = resolveShaderAsset(match[1]!, node.filePath, shaderFiles); + if (!shaderFile) continue; + const fileNode = ctx.getNodesInFile(shaderFile).find((candidate) => candidate.kind === 'file'); + if (!fileNode) continue; + const literalLine = node.startLine + body.slice(0, match.index).split('\n').length - 1; + edges.push({ source: node.id, target: fileNode.id, kind: 'references', provenance: 'heuristic', line: literalLine, + metadata: { synthesizedBy: 'shader-file', literal: match[1], registeredAt: `${node.filePath}:${literalLine}` } }); + + if (!createsShaderEntryAssociation(body, match.index)) continue; + const entry = ctx.getNodesInFile(shaderFile).find((candidate) => candidate.kind === 'function' && candidate.decorators?.includes('entrypoint')) + ?? ctx.getNodesInFile(shaderFile).find((candidate) => candidate.kind === 'function' && candidate.name === 'main'); + if (!entry) continue; + associated.set(shaderFile, entry); + edges.push({ source: node.id, target: entry.id, kind: 'calls', provenance: 'heuristic', line: literalLine, + metadata: { synthesizedBy: 'shader-entry', shaderFile, registeredAt: `${node.filePath}:${literalLine}` } }); + } + if (associated.size === 0) continue; + + const owners = new Map([[node.id, node]]); + let frontier = [node]; + for (let depth = 0; depth < 2; depth++) { + const next: Node[] = []; + for (const owner of frontier) { + for (const edge of queries.getOutgoingEdges(owner.id, ['calls'])) { + const target = queries.getNodeById(edge.target); + if (!target || (target.language !== 'c' && target.language !== 'cpp') || owners.has(target.id)) continue; + owners.set(target.id, target); + next.push(target); + } + } + frontier = next; + } + for (const owner of owners.values()) { + const aggregate = interfaceOwners.get(owner.id) ?? { owner, shaders: new Map() }; + for (const [shaderFile, entry] of associated) aggregate.shaders.set(shaderFile, entry); + interfaceOwners.set(owner.id, aggregate); + } + } + + // Descriptor coordinates without a uniquely associated shader are unsafe: + // binding 0 is common across unrelated pipelines and generic helper calls. + for (const { owner, shaders } of interfaceOwners.values()) { + if (shaders.size !== 1) continue; + const association = shaders.entries().next().value; + if (!association) continue; + const [shaderFile, entry] = association; + edges.push(...interfaceEdges(owner, shaderFile, entry, sourceFor(owner, ctx), resolver, ctx)); + } + + return edges.filter((edge) => { + const key = `${edge.source}>${edge.target}>${edge.kind}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} diff --git a/src/types.ts b/src/types.ts index 5b0e407c5..5371bd353 100644 --- a/src/types.ts +++ b/src/types.ts @@ -116,6 +116,10 @@ export const LANGUAGES = [ 'vbnet', 'erlang', 'terraform', + 'glsl', + 'hlsl', + 'cmake', + 'powershell', 'unknown', ] as const;