diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b1a325de..deb31ca94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Java `static final` constants, C# `const` / `static readonly` constants, Scala `object` vals, and Kotlin top-level / `object` / `companion object` `val`s are now classified as constants rather than generic fields, so they participate in the constant-reader impact analysis above — change a `public static final` table, a `const string`, a Scala `object Config { val Timeout = … }`, or a Kotlin `companion object { const val … }` and the methods that read it now show up as affected. (Per-object Java `final` / C# `readonly` / Scala & Kotlin `class` instance properties are unchanged.) Kotlin constants were previously not indexed as their own symbols at all, so they now also appear in `codegraph search`. - Swift top-level `let`s and `static let` constants (including those namespaced in an `enum`/`struct`, the common Swift pattern) are now indexed as constants and participate in the constant-reader impact analysis above — change a `static let defaultRetryLimit` or an `enum Constants { static let … }` and the same-file code that reads it shows up as affected. Computed properties and per-instance `let`s are not treated as constants. - Dart top-level `const`/`final` and class `static const`/`static final` constants are now indexed as constants and participate in the constant-reader impact analysis above. Instance fields, `var`s, and locals are not treated as constants. (Generated Dart code with the standard `.g.dart`/`.freezed.dart`/`.pb.dart` suffixes is already skipped.) +- CodeGraph now indexes PowerShell scripts and modules (`.ps1`, `.psm1`, and `.psd1`), including functions, classes, methods, properties, enums, module imports, dot-sourced scripts, and command calls, so agents can explore PowerShell automation without reading every script by hand. ### Fixes diff --git a/README.md b/README.md index 354af2463..ef9c9a1a1 100644 --- a/README.md +++ b/README.md @@ -238,7 +238,7 @@ CodeGraph cuts **tokens, tool calls, and wall-clock time on every repo** — acr | **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, Python, Go, Rust, Java, C#, PHP, Ruby, C, C++, Objective-C, Swift, Kotlin, Scala, Dart, Lua, Luau, R, Svelte, Vue, Astro, Liquid, Pascal/Delphi | +| **20+ Languages** | TypeScript, JavaScript, Python, Go, Rust, Java, C#, PHP, Ruby, C, C++, Objective-C, Swift, Kotlin, Scala, Dart, Lua, Luau, R, PowerShell, 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 | @@ -679,6 +679,7 @@ is written): | Lua | `.lua` | Full support (functions, methods with receivers, local variables, `require` imports, call edges) | | R | `.R` `.r` | Full support (functions in every assignment form, S4/R5/R6 classes with methods, `library`/`require` imports, `source()` file references, call edges) | | Luau | `.luau` | Full support (everything in Lua, plus `type`/`export type` aliases, typed signatures, and Roblox instance-path `require`) | +| PowerShell | `.ps1`, `.psm1`, `.psd1` | Full support (functions, classes, methods, properties, enums, module/script imports, and command call edges) | ## Measured cross-file coverage diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts index df825f529..6a73b826a 100644 --- a/__tests__/extraction.test.ts +++ b/__tests__/extraction.test.ts @@ -101,6 +101,13 @@ describe('Language Detection', () => { expect(detectLanguage('stdio.h', '#ifndef STDIO_H\nvoid printf();\n#endif\n')).toBe('c'); }); + it('should detect PowerShell files', () => { + expect(detectLanguage('install.ps1')).toBe('powershell'); + expect(detectLanguage('Modules/Widgets.psm1')).toBe('powershell'); + expect(detectLanguage('Widgets.psd1')).toBe('powershell'); + expect(isSourceFile('install.ps1')).toBe(true); + }); + it('should return unknown for unsupported extensions', () => { expect(detectLanguage('styles.css')).toBe('unknown'); expect(detectLanguage('data.json')).toBe('unknown'); @@ -129,6 +136,101 @@ describe('Language Support', () => { expect(languages).toContain('swift'); expect(languages).toContain('kotlin'); expect(languages).toContain('dart'); + expect(languages).toContain('powershell'); + }); +}); + +describe('PowerShell Extraction', () => { + it('extracts functions, classes, members, enums, imports, and command calls', () => { + const code = ` +using module ./Private/Helpers.psm1 + +$ModuleRoot = $PSScriptRoot + +function Get-Widget { + [CmdletBinding()] + param( + [string]$Name, + [int]$Limit = 10 + ) + + Import-Module ./Private/Helpers.psm1 + $items = Find-Widget -Name $Name | Where-Object { Test-Widget $_ } + foreach ($item in $items) { + Write-Output (Convert-Widget $item) + } +} + +class WidgetRunner : BaseRunner { + [string]$Name + + WidgetRunner([string]$name) { + $this.Name = $name + } + + [void] Run() { + Get-Widget -Name $this.Name + } +} + +enum WidgetState { + Ready = 1 + Disabled = 2 +} +`; + + const result = extractFromSource('Widgets.psm1', code); + expect(result.errors).toHaveLength(0); + + const symbol = (name: string, kind: string) => result.nodes.find((n) => n.name === name && n.kind === kind); + expect(symbol('Get-Widget', 'function')).toMatchObject({ kind: 'function', language: 'powershell' }); + expect(symbol('WidgetRunner', 'class')).toMatchObject({ kind: 'class', language: 'powershell' }); + expect(symbol('Run', 'method')).toMatchObject({ kind: 'method', language: 'powershell' }); + expect(symbol('Name', 'property')).toMatchObject({ kind: 'property', language: 'powershell' }); + expect(symbol('WidgetState', 'enum')).toMatchObject({ kind: 'enum', language: 'powershell' }); + expect(symbol('Ready', 'enum_member')).toMatchObject({ kind: 'enum_member', language: 'powershell' }); + expect(symbol('ModuleRoot', 'variable')).toMatchObject({ kind: 'variable', language: 'powershell' }); + + const refs = result.unresolvedReferences.map((r) => ({ name: r.referenceName, kind: r.referenceKind })); + expect(refs).toContainEqual({ name: './Private/Helpers.psm1', kind: 'imports' }); + expect(refs).toContainEqual({ name: 'Find-Widget', kind: 'calls' }); + expect(refs).toContainEqual({ name: 'Test-Widget', kind: 'calls' }); + expect(refs).toContainEqual({ name: 'Convert-Widget', kind: 'calls' }); + expect(refs).toContainEqual({ name: 'Get-Widget', kind: 'calls' }); + expect(refs).toContainEqual({ name: 'BaseRunner', kind: 'extends' }); + }); + + it('resolves PowerShell module imports and case-insensitive command calls', async () => { + const tempDir = createTempDir(); + let cg: CodeGraph | null = null; + try { + fs.mkdirSync(path.join(tempDir, 'Private'), { recursive: true }); + fs.writeFileSync( + path.join(tempDir, 'Private', 'Helpers.psm1'), + `function Find-Widget { param([string]$Name) Write-Output $Name }\n` + ); + fs.writeFileSync( + path.join(tempDir, 'Widgets.psm1'), + `using module ./Private/Helpers.psm1\nfunction Get-Widget { find-widget -Name "demo" }\n` + ); + + cg = CodeGraph.initSync(tempDir); + await cg.indexAll(); + cg.resolveReferences(); + + const findWidget = cg.getNodesByKind('function').find((n) => n.name === 'Find-Widget'); + expect(findWidget).toBeDefined(); + const callers = cg.getCallers(findWidget!.id).map((entry) => entry.node.name); + expect(callers).toContain('Get-Widget'); + + const helperFile = cg.getNodesByKind('file').find((n) => n.filePath.endsWith('Private/Helpers.psm1')); + expect(helperFile).toBeDefined(); + const helperDependents = cg.getImpactRadius(helperFile!.id, 2).nodes; + expect([...helperDependents.values()].some((n) => n.filePath.endsWith('Widgets.psm1'))).toBe(true); + } finally { + cg?.close(); + cleanupTempDir(tempDir); + } }); }); diff --git a/package-lock.json b/package-lock.json index 205fe9026..31bd47c03 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.1", "license": "MIT", "dependencies": { + "22": "^0.0.0", "@clack/prompts": "^1.3.0", "commander": "^14.0.2", "fast-string-width": "^3.0.2", @@ -957,6 +958,12 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/22": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/22/-/22-0.0.0.tgz", + "integrity": "sha512-MdBPNDaCFY4fZVpp14n3Mt4isZ2yS1DrIiOig/iMLljr4zDa0g/583xf/lFXNPwhxCfGKYvyWJSrYyS8jNk2mQ==", + "license": "MIT" + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", diff --git a/package.json b/package.json index a802f7975..9e7658070 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "author": "", "license": "MIT", "dependencies": { + "22": "^0.0.0", "@clack/prompts": "^1.3.0", "commander": "^14.0.2", "fast-string-width": "^3.0.2", diff --git a/src/extraction/grammars.ts b/src/extraction/grammars.ts index ef6307a92..a69da23f6 100644 --- a/src/extraction/grammars.ts +++ b/src/extraction/grammars.ts @@ -39,6 +39,7 @@ const WASM_GRAMMAR_FILES: Record = { r: 'tree-sitter-r.wasm', luau: 'tree-sitter-luau.wasm', objc: 'tree-sitter-objc.wasm', + powershell: 'tree-sitter-powershell.wasm', }; /** @@ -108,6 +109,9 @@ export const EXTENSION_MAP: Record = { '.luau': 'luau', '.m': 'objc', '.mm': 'objc', + '.ps1': 'powershell', + '.psm1': 'powershell', + '.psd1': 'powershell', // XML: file-level tracking; the MyBatis extractor matches `` // shape and emits SQL-statement nodes (other XML returns empty). '.xml': 'xml', @@ -216,7 +220,7 @@ export async function loadGrammarsForLanguages(languages: Language[]): Promise> = { typescript: typescriptExtractor, @@ -51,4 +52,5 @@ export const EXTRACTORS: Partial> = { r: rExtractor, luau: luauExtractor, objc: objcExtractor, + powershell: powershellExtractor, }; diff --git a/src/extraction/languages/powershell.ts b/src/extraction/languages/powershell.ts new file mode 100644 index 000000000..6cef75199 --- /dev/null +++ b/src/extraction/languages/powershell.ts @@ -0,0 +1,285 @@ +import type { Node as SyntaxNode } from 'web-tree-sitter'; +import { getChildByField, getNodeText } from '../tree-sitter-helpers'; +import type { ExtractorContext, LanguageExtractor } from '../tree-sitter-types'; + +const PATH_LIKE_RE = /(?:^\.?\.?[\\/]|[\\/]|\.ps(?:m|d)?1$)/i; +const COMMAND_PATH_RE = /(?:^\.{1,2}[\\/]|^[\\/]|[/]|\.ps(?:m|d)?1$)/i; + +export const powershellExtractor: LanguageExtractor = { + functionTypes: ['function_statement'], + classTypes: ['class_statement'], + methodTypes: ['class_method_definition'], + interfaceTypes: [], + structTypes: [], + enumTypes: ['enum_statement'], + enumMemberTypes: ['enum_member'], + typeAliasTypes: [], + importTypes: [], + callTypes: [], + variableTypes: ['assignment_expression'], + propertyTypes: ['class_property_definition'], + nameField: 'name', + bodyField: 'script_block_body', + paramsField: 'parameter_list', + resolveName: (node, source) => { + if (node.type === 'function_statement') { + return childText(node, source, 'function_name'); + } + if (node.type === 'class_statement' || node.type === 'enum_statement') { + return childText(node, source, 'simple_name'); + } + if (node.type === 'class_method_definition') { + return childText(node, source, 'simple_name'); + } + if (node.type === 'class_property_definition') { + const variable = firstChildOfType(node, 'variable'); + return variable ? normalizeVariableName(getNodeText(variable, source)) : undefined; + } + if (node.type === 'enum_member') { + return childText(node, source, 'simple_name'); + } + return undefined; + }, + extractPropertyName: (node, source) => { + const variable = firstChildOfType(node, 'variable'); + return variable ? normalizeVariableName(getNodeText(variable, source)) : null; + }, + resolveBody: (node) => { + if (node.type === 'enum_statement') return node; + const scriptBlock = node.type === 'script_block' + ? node + : firstChildOfType(node, 'script_block'); + if (!scriptBlock) return null; + return getChildByField(scriptBlock, 'script_block_body') ?? scriptBlock; + }, + getSignature: (node, source) => { + if (node.type === 'function_statement') { + const inlineParams = firstChildOfType(node, 'function_parameter_declaration'); + const scriptBlock = firstChildOfType(node, 'script_block'); + const paramBlock = scriptBlock ? firstChildOfType(scriptBlock, 'param_block') : null; + const params = inlineParams ?? paramBlock; + return params ? getNodeText(params, source).trim() : undefined; + } + + if (node.type === 'class_method_definition') { + const returnType = firstChildOfType(node, 'type_literal'); + const name = childText(node, source, 'simple_name'); + const params = firstChildOfType(node, 'class_method_parameter_list'); + const returnText = returnType ? `${getNodeText(returnType, source).trim()} ` : ''; + const paramText = params ? getNodeText(params, source).trim() : ''; + return name ? `${returnText}${name}(${paramText})` : undefined; + } + + return undefined; + }, + getVisibility: (node) => { + return hasClassAttribute(node, 'hidden') ? 'private' : 'public'; + }, + isStatic: (node) => hasClassAttribute(node, 'static'), + visitNode: (node, ctx) => { + if (node.type === 'command') { + visitPowerShellCommand(node, ctx); + return true; + } + + if (node.type === 'invokation_expression') { + const callee = extractInvocationMethodName(node, ctx.source); + if (callee) { + addCallReference(ctx, node, callee); + } + return false; + } + + return false; + }, +}; + +function visitPowerShellCommand(node: SyntaxNode, ctx: ExtractorContext): void { + const importPath = extractImportPath(node, ctx.source); + if (importPath) { + ctx.createNode('import', importPath, node, { + signature: getNodeText(node, ctx.source).trim(), + }); + const parentId = ctx.nodeStack[ctx.nodeStack.length - 1]; + if (parentId) { + ctx.addUnresolvedReference({ + fromNodeId: parentId, + referenceName: importPath, + referenceKind: 'imports', + filePath: ctx.filePath, + line: node.startPosition.row + 1, + column: node.startPosition.column, + }); + } + } else { + const commandName = extractCommandName(node, ctx.source); + if (commandName) addCallReference(ctx, node, commandName); + } + + // Command arguments can contain script blocks (`Where-Object { Test-Thing }`) + // or nested expressions. The hook consumes the command node, so explicitly walk + // children that may hold more symbols/calls while skipping the command name + // token itself to avoid treating it as a nested reference. + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (!child) continue; + if (child.type === 'command_name' || child.type === 'command_name_expr' || child.type === 'path_command_name') { + continue; + } + ctx.visitFunctionBody(child, ''); + } +} + +function addCallReference(ctx: ExtractorContext, node: SyntaxNode, calleeName: string): void { + const callerId = ctx.nodeStack[ctx.nodeStack.length - 1]; + if (!callerId) return; + ctx.addUnresolvedReference({ + fromNodeId: callerId, + referenceName: calleeName, + referenceKind: 'calls', + filePath: ctx.filePath, + line: node.startPosition.row + 1, + column: node.startPosition.column, + }); +} + +function extractCommandName(node: SyntaxNode, source: string): string | null { + const commandName = getChildByField(node, 'command_name') + ?? firstChildOfTypes(node, ['command_name', 'command_name_expr', 'path_command_name']); + if (!commandName) return null; + + // Dynamic invocation (`& $cmd`, `${module}\Name`) is intentionally left + // unresolved rather than guessed. + if (hasDescendantType(commandName, 'variable') || hasDescendantType(commandName, 'sub_expression')) { + return null; + } + + let raw = getNodeText(commandName, source).trim(); + raw = stripQuotes(raw); + if (!raw || raw.startsWith('$')) return null; + if (COMMAND_PATH_RE.test(raw)) return null; + + // Module-qualified command names (`Module\Get-Thing`) still call the command + // named by the final segment. The module dependency itself is represented by + // Import-Module / using module / dot-sourcing edges when statically present. + const slash = raw.lastIndexOf('\\'); + if (slash >= 0) raw = raw.slice(slash + 1); + return raw || null; +} + +function extractImportPath(node: SyntaxNode, source: string): string | null { + const text = getNodeText(node, source).trim(); + const words = splitPowerShellWords(text); + if (words.length === 0) return null; + + if (/^using$/i.test(words[0] ?? '') && /^module$/i.test(words[1] ?? '')) { + return normalizeImportPath(words[2]); + } + + if (/^Import-Module$/i.test(words[0] ?? '')) { + const nameFlag = words.findIndex((w) => /^-Name$/i.test(w)); + if (nameFlag >= 0) return normalizeImportPath(words[nameFlag + 1]); + const positional = words.slice(1).find((w) => !w.startsWith('-')); + return normalizeImportPath(positional); + } + + // Dot-sourcing: `. ./Private/Get-Widget.ps1` loads the target script into the + // current scope, so model it as an import edge to that file. + if (words[0] === '.') { + return normalizeImportPath(words[1]); + } + + return null; +} + +function normalizeImportPath(raw: string | undefined): string | null { + if (!raw) return null; + let value = stripQuotes(raw.trim().replace(/[;,]$/, '')); + if (!value || value.includes('*')) return null; + value = value.replace(/\\/g, '/'); + value = value.replace(/^\$PSScriptRoot(?=\/|$)/i, '.'); + value = value.replace(/^\$\{PSScriptRoot\}(?=\/|$)/i, '.'); + if (!PATH_LIKE_RE.test(value)) return null; + return value; +} + +function splitPowerShellWords(text: string): string[] { + const words: string[] = []; + const re = /"([^"]*)"|'([^']*)'|([^\s]+)/g; + let match: RegExpExecArray | null; + while ((match = re.exec(text)) !== null) { + words.push(match[1] ?? match[2] ?? match[3] ?? ''); + } + return words; +} + +function extractInvocationMethodName(node: SyntaxNode, source: string): string | null { + const member = findLastDescendantOfType(node, 'member_name'); + if (!member) return null; + const name = getNodeText(member, source).trim(); + if (!name || name.startsWith('$')) return null; + return name; +} + +function childText(node: SyntaxNode, source: string, type: string): string | undefined { + const child = firstChildOfType(node, type); + return child ? getNodeText(child, source) : undefined; +} + +function normalizeVariableName(text: string): string { + return text.trim().replace(/^\$\{?/, '').replace(/\}$/, ''); +} + +function stripQuotes(text: string): string { + if ((text.startsWith('"') && text.endsWith('"')) || (text.startsWith("'") && text.endsWith("'"))) { + return text.slice(1, -1); + } + return text; +} + +function hasClassAttribute(node: SyntaxNode, name: string): boolean { + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child?.type === 'class_attribute' && child.text.toLowerCase() === name) return true; + } + return false; +} + +function firstChildOfType(node: SyntaxNode, type: string): SyntaxNode | null { + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child?.type === type) return child; + } + return null; +} + +function firstChildOfTypes(node: SyntaxNode, types: string[]): SyntaxNode | null { + const set = new Set(types); + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child && set.has(child.type)) return child; + } + return null; +} + +function findLastDescendantOfType(node: SyntaxNode, type: string): SyntaxNode | null { + let found: SyntaxNode | null = null; + const visit = (n: SyntaxNode): void => { + if (n.type === type) found = n; + for (let i = 0; i < n.namedChildCount; i++) { + const child = n.namedChild(i); + if (child) visit(child); + } + }; + visit(node); + return found; +} + +function hasDescendantType(node: SyntaxNode, type: string): boolean { + if (node.type === type) return true; + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child && hasDescendantType(child, type)) return true; + } + return false; +} diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index 19648e10d..a11d9d004 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -1662,7 +1662,7 @@ export class TreeSitterExtractor { let found = false; for (let i = 0; i < node.namedChildCount; i++) { const child = node.namedChild(i); - if (child && (child.type === 'simple_identifier' || child.type === 'identifier' || child.type === 'property_identifier')) { + if (child && (child.type === 'simple_identifier' || child.type === 'simple_name' || child.type === 'identifier' || child.type === 'property_identifier')) { this.createNode('enum_member', getNodeText(child, this.source), child); found = true; } @@ -2142,6 +2142,31 @@ export class TreeSitterExtractor { const initSignature = initValue ? `= ${initValue}${initValue.length >= 100 ? '...' : ''}` : undefined; this.createNode(kind, name, nameNode, { docstring, signature: initSignature, isExported }); }); + } else if (this.language === 'powershell') { + // PowerShell assignment: left_assignment_expression wraps a variable node + // (`$Name = ...`). Track only top-level assignments (the dispatcher skips + // function/method locals) so module-scope state appears in search/impact. + const left = node.namedChildren.find((c) => c.type === 'left_assignment_expression'); + let variable: SyntaxNode | null = null; + const stack: SyntaxNode[] = left ? [left] : []; + while (stack.length > 0 && !variable) { + const current = stack.pop()!; + if (current.type === 'variable') { + variable = current; + break; + } + for (let i = 0; i < current.namedChildCount; i++) { + const child = current.namedChild(i); + if (child) stack.push(child); + } + } + if (variable) { + const name = getNodeText(variable, this.source).replace(/^\$\{?/, '').replace(/\}$/, ''); + const valueNode = getChildByField(node, 'value'); + const initValue = valueNode ? getNodeText(valueNode, this.source).slice(0, 100) : undefined; + const initSignature = initValue ? `= ${initValue}${initValue.length >= 100 ? '...' : ''}` : undefined; + this.createNode('variable', name, variable, { docstring, signature: initSignature, isExported }); + } } else if (this.language === 'c') { // C: a `declaration` node's name nests inside the `declarator` field — // `init_declarator` (with value) or bare/pointer/array declarators (no @@ -3717,6 +3742,15 @@ export class TreeSitterExtractor { const visitForCallsAndStructure = (node: SyntaxNode): void => { const nodeType = node.type; + // PowerShell commands are both the call syntax and the import syntax + // (`Import-Module`, dot-sourcing). Its extractor hook handles those nodes + // and walks any script-block arguments; the generic body walker normally + // bypasses language hooks, so opt PowerShell in here explicitly. + if (this.language === 'powershell' && this.extractor!.visitNode) { + const handled = this.extractor!.visitNode(node, this.makeExtractorContext()); + if (handled) return; + } + // Function-as-value capture (#756) — function bodies are walked here, // not in visitNode, so the capture hook must fire in both walkers. this.maybeCaptureFnRefs(node, nodeType); @@ -3832,6 +3866,24 @@ export class TreeSitterExtractor { * Extract inheritance relationships */ private extractInheritance(node: SyntaxNode, classId: string): void { + // PowerShell classes declare base types inline: `class Child : Parent, IFace`. + // The grammar exposes every name as a `simple_name`, with the first being the + // class itself. Treat remaining names as supertypes so changes to a base class + // surface dependents. + if (this.language === 'powershell' && node.type === 'class_statement') { + const supers = node.namedChildren.filter((c) => c.type === 'simple_name').slice(1); + for (const target of supers) { + this.unresolvedReferences.push({ + fromNodeId: classId, + referenceName: getNodeText(target, this.source), + referenceKind: 'extends', + line: target.startPosition.row + 1, + column: target.startPosition.column, + }); + } + return; + } + // Objective-C @interface MyClass : NSObject if (node.type === 'class_interface') { const superclass = getChildByField(node, 'superclass'); diff --git a/src/extraction/wasm/tree-sitter-powershell.wasm b/src/extraction/wasm/tree-sitter-powershell.wasm new file mode 100644 index 000000000..2e3e443ac Binary files /dev/null and b/src/extraction/wasm/tree-sitter-powershell.wasm differ diff --git a/src/resolution/import-resolver.ts b/src/resolution/import-resolver.ts index badbe4b02..af0c8cb3d 100644 --- a/src/resolution/import-resolver.ts +++ b/src/resolution/import-resolver.ts @@ -35,6 +35,7 @@ const EXTENSION_RESOLUTION: Record = { php: ['.php'], ruby: ['.rb'], objc: ['.h', '.m', '.mm'], + powershell: ['.ps1', '.psm1', '.psd1'], }; /** @@ -62,6 +63,14 @@ export function resolveImportPath( return resolveRelativeImport(importPath, fromDir, language, context); } + // PowerShell module/script paths are often written as `Private/Foo.ps1` or + // `Helpers.psm1` without a leading `./`. Treat path-shaped values as relative + // to the importing script; bare module names (e.g. `Pester`) still fall through + // and remain external/unresolved. + if (language === 'powershell' && (importPath.includes('/') || /\.ps(?:m|d)?1$/i.test(importPath))) { + return resolveRelativeImport(`./${importPath}`, fromDir, language, context); + } + // Handle absolute/aliased imports (like @/ or src/) const aliased = resolveAliasedImport(importPath, projectRoot, language, context); if (aliased) return aliased; @@ -1195,6 +1204,26 @@ export function resolveViaImport( return null; } + // PowerShell module/script loading (`using module`, `Import-Module`, and + // dot-sourcing) resolves directly to the referenced script/module file. + if (ref.language === 'powershell' && ref.referenceKind === 'imports') { + const resolvedPath = resolveImportPath(ref.referenceName, ref.filePath, ref.language, context); + if (!resolvedPath) return null; + const basename = resolvedPath.split('/').pop()!; + const fileNode = context + .getNodesByName(basename) + .find((n) => n.kind === 'file' && n.filePath === resolvedPath); + if (fileNode) { + return { + original: ref, + targetNodeId: fileNode.id, + confidence: 0.9, + resolvedBy: 'import', + }; + } + return null; + } + // Use cached import mappings (avoids re-reading and re-parsing per ref) const imports = context.getImportMappings(ref.filePath, ref.language); if (imports.length === 0 && !context.readFile(ref.filePath)) { diff --git a/src/resolution/index.ts b/src/resolution/index.ts index 0d7ec4309..11515b630 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -585,35 +585,40 @@ export class ReferenceResolver { * Uses the pre-built knownNames set to skip expensive resolution * for names that definitely don't exist as symbols. */ - private hasAnyPossibleMatch(name: string): boolean { + private hasAnyPossibleMatch(name: string, language?: string): boolean { if (!this.knownNames) return true; // no pre-filter available + const check = (candidate: string): boolean => { + if (this.knownNames!.has(candidate)) return true; + return language === 'powershell' && + this.context.getNodesByLowerName(candidate.toLowerCase()).some((n) => n.language === 'powershell'); + }; // Direct name match - if (this.knownNames.has(name)) return true; + if (check(name)) return true; // For qualified names like "obj.method" or "Class::method", check the parts const dotIdx = name.indexOf('.'); if (dotIdx > 0) { const receiver = name.substring(0, dotIdx); const member = name.substring(dotIdx + 1); - if (this.knownNames.has(receiver) || this.knownNames.has(member)) return true; + if (check(receiver) || check(member)) return true; // Also check capitalized receiver (instance-method resolution) const capitalized = receiver.charAt(0).toUpperCase() + receiver.slice(1); - if (this.knownNames.has(capitalized)) return true; + if (check(capitalized)) return true; // JVM FQN: `com.example.foo.Bar` — the only useful segment is the // last one (`Bar`); the earlier check finds `example.foo.Bar` which // never matches a node name. const lastDot = name.lastIndexOf('.'); if (lastDot > dotIdx) { const tail = name.substring(lastDot + 1); - if (tail && this.knownNames.has(tail)) return true; + if (tail && check(tail)) return true; } } const colonIdx = name.indexOf('::'); if (colonIdx > 0) { const receiver = name.substring(0, colonIdx); const member = name.substring(colonIdx + 2); - if (this.knownNames.has(receiver) || this.knownNames.has(member)) return true; + if (check(receiver) || check(member)) return true; // Multi-segment path `a::b::c` (a Rust/C++ module call like // `database::profiles::find`) — the only segment that names a symbol is // the last (`c`); `member` above is `b::c`, which never matches a node @@ -622,7 +627,7 @@ export class ReferenceResolver { const lastColon = name.lastIndexOf('::'); if (lastColon > colonIdx) { const tail = name.substring(lastColon + 2); - if (tail && this.knownNames.has(tail)) return true; + if (tail && check(tail)) return true; } } @@ -630,7 +635,7 @@ export class ReferenceResolver { const slashIdx = name.lastIndexOf('/'); if (slashIdx > 0) { const fileName = name.substring(slashIdx + 1); - if (this.knownNames.has(fileName)) return true; + if (check(fileName)) return true; } return false; @@ -671,7 +676,7 @@ export class ReferenceResolver { // from './auth'`) intentionally call a name that has no // declaration anywhere — only the renamed upstream symbol does. if ( - !this.hasAnyPossibleMatch(ref.referenceName) && + !this.hasAnyPossibleMatch(ref.referenceName, ref.language) && !this.matchesAnyImport(ref) && !this.frameworks.some((f) => f.claimsReference?.(ref.referenceName)) ) { diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 9990d690d..6bc260d35 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -317,7 +317,18 @@ export function matchByExactName( ref: UnresolvedRef, context: ResolutionContext ): ResolvedRef | null { - const candidates = applyLanguageGate(context.getNodesByName(ref.referenceName), ref); + let candidates = applyLanguageGate(context.getNodesByName(ref.referenceName), ref); + + // PowerShell command and symbol names are case-insensitive. Keep the exact + // lookup first so normal languages stay unchanged, then fall back to the + // lower-name index for PowerShell only. + if (candidates.length === 0 && ref.language === 'powershell') { + candidates = applyLanguageGate( + context.getNodesByLowerName(ref.referenceName.toLowerCase()) + .filter((n) => n.language === 'powershell'), + ref + ); + } if (candidates.length === 0) { return null; diff --git a/src/types.ts b/src/types.ts index 656bb1090..21ed55831 100644 --- a/src/types.ts +++ b/src/types.ts @@ -95,6 +95,7 @@ export const LANGUAGES = [ 'twig', 'xml', 'properties', + 'powershell', 'unknown', ] as const;