From 20e9357176d8487ed38d20828e0cac63b4c63030 Mon Sep 17 00:00:00 2001 From: Alex Matthews <2862661+qoole@users.noreply.github.com> Date: Fri, 31 Jul 2026 20:42:34 +0100 Subject: [PATCH 01/10] chore: bump tree-sitter to 0.25, Python/Go grammars to match MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Newer grammar releases in the tree-sitter ecosystem require the 0.25 runtime, so bump tree-sitter ^0.21.1→^0.25.0 and the Python/Go grammars to ^0.25.0 to unlock languages whose grammars need it. tree-sitter-typescript stays pinned at ^0.23.2 (still the latest release) — it declares peerOptional tree-sitter@^0.21.0, so an npm `overrides` entry pins its nested tree-sitter to the root one so the repo-root install resolves cleanly; verified the grammar's native binding still loads and parses correctly under tree-sitter 0.25.1. Also updates the stale-parse-limit comment in extract.ts: the chunked callback parse predates 0.25 (which lifted the 32KB string-parse limit) and is kept because it's behavior-identical and exercised by existing tests. No behavior change; full suite green (493/493). --- .github/dependabot.yml | 4 ++++ CHANGELOG.md | 8 +++++++ package-lock.json | 52 +++++++++++++++++++----------------------- package.json | 11 ++++++--- src/graph/extract.ts | 11 +++++---- 5 files changed, 49 insertions(+), 37 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 39240b5d2..a26080a18 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -5,6 +5,10 @@ updates: schedule: interval: weekly open-pull-requests-limit: 10 + ignore: + # Upgrading this independently of the pinned tree-sitter runtime breaks + # peer resolution; see the overrides block in package.json. + - dependency-name: tree-sitter-typescript - package-ecosystem: github-actions directory: / diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d773b6a4..1025ac419 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -96,6 +96,14 @@ matches. Pass a real prefix (`--in server/src/gpu`), a full file path (`--in src/a.ts`), or use `grep`'s pattern to match on content. +- **Bump the tree-sitter runtime to 0.25, and the Python and Go grammars to match.** + Groundwork for adding new language grammars that require the 0.25 runtime. + `tree-sitter-typescript@0.23.2` is still the latest release and declares a + `peerOptional tree-sitter@^0.21.0`, so an npm `overrides` entry pins its + nested `tree-sitter` dependency to the root one and lets the install resolve + cleanly. No behavior change; the grammar's native binding loads and parses + correctly under tree-sitter 0.25.1, and the full suite is green. + [#33]: https://github.com/NanoNets/Graft/issues/33 [#35]: https://github.com/NanoNets/Graft/issues/35 [#36]: https://github.com/NanoNets/Graft/issues/36 diff --git a/package-lock.json b/package-lock.json index 8c9858da6..58c157784 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@nanonets/graft", - "version": "0.8.0", + "version": "0.8.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@nanonets/graft", - "version": "0.8.0", + "version": "0.8.2", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -15,9 +15,9 @@ "dotenv": "^17.4.2", "gray-matter": "^4.0.3", "openai": "^6.45.0", - "tree-sitter": "^0.21.1", - "tree-sitter-go": "^0.23.4", - "tree-sitter-python": "^0.21.0", + "tree-sitter": "^0.25.0", + "tree-sitter-go": "^0.25.0", + "tree-sitter-python": "^0.25.0", "tree-sitter-typescript": "^0.23.2" }, "bin": { @@ -841,28 +841,28 @@ } }, "node_modules/tree-sitter": { - "version": "0.21.1", - "resolved": "https://registry.npmjs.org/tree-sitter/-/tree-sitter-0.21.1.tgz", - "integrity": "sha512-7dxoA6kYvtgWw80265MyqJlkRl4yawIjO7S5MigytjELkX43fV2WsAXzsNfO7sBpPPCF5Gp0+XzHk0DwLCq3xQ==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/tree-sitter/-/tree-sitter-0.25.1.tgz", + "integrity": "sha512-mrcEdkYtHfrK1A6fs3O6FxkBo0Qig5XUXqHhxUOQu0bmPo00QF4XaSx4edpazdHwxnSCjlGKGgIqWdaN4dvTLA==", "hasInstallScript": true, "license": "MIT", "dependencies": { - "node-addon-api": "^8.0.0", - "node-gyp-build": "^4.8.0" + "node-addon-api": "^8.5.0", + "node-gyp-build": "^4.8.4" } }, "node_modules/tree-sitter-go": { - "version": "0.23.4", - "resolved": "https://registry.npmjs.org/tree-sitter-go/-/tree-sitter-go-0.23.4.tgz", - "integrity": "sha512-iQaHEs4yMa/hMo/ZCGqLfG61F0miinULU1fFh+GZreCRtKylFLtvn798ocCZjO2r/ungNZgAY1s1hPFyAwkc7w==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/tree-sitter-go/-/tree-sitter-go-0.25.0.tgz", + "integrity": "sha512-APBc/Dq3xz/e35Xpkhb1blu5UgW+2E3RyGWawZSCNcbGwa7jhSQPS8KsUupuzBla8PCo8+lz9W/JDJjmfRa2tw==", "hasInstallScript": true, "license": "MIT", "dependencies": { - "node-addon-api": "^8.2.1", - "node-gyp-build": "^4.8.2" + "node-addon-api": "^8.3.1", + "node-gyp-build": "^4.8.4" }, "peerDependencies": { - "tree-sitter": "^0.21.1" + "tree-sitter": "^0.25.0" }, "peerDependenciesMeta": { "tree-sitter": { @@ -890,30 +890,24 @@ } }, "node_modules/tree-sitter-python": { - "version": "0.21.0", - "resolved": "https://registry.npmjs.org/tree-sitter-python/-/tree-sitter-python-0.21.0.tgz", - "integrity": "sha512-IUKx7JcTVbByUx1iHGFS/QsIjx7pqwTMHL9bl/NGyhyyydbfNrpruo2C7W6V4KZrbkkCOlX8QVrCoGOFW5qecg==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/tree-sitter-python/-/tree-sitter-python-0.25.0.tgz", + "integrity": "sha512-eCmJx6zQa35GxaCtQD+wXHOhYqBxEL+bp71W/s3fcDMu06MrtzkVXR437dRrCrbrDbyLuUDJpAgycs7ncngLXw==", "hasInstallScript": true, "license": "MIT", "dependencies": { - "node-addon-api": "^7.1.0", - "node-gyp-build": "^4.8.0" + "node-addon-api": "^8.5.0", + "node-gyp-build": "^4.8.4" }, "peerDependencies": { - "tree-sitter": "^0.21.0" + "tree-sitter": "^0.25.0" }, "peerDependenciesMeta": { - "tree_sitter": { + "tree-sitter": { "optional": true } } }, - "node_modules/tree-sitter-python/node_modules/node-addon-api": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", - "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", - "license": "MIT" - }, "node_modules/tree-sitter-typescript": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/tree-sitter-typescript/-/tree-sitter-typescript-0.23.2.tgz", diff --git a/package.json b/package.json index bbf87aee3..f01f6bd7f 100644 --- a/package.json +++ b/package.json @@ -60,11 +60,16 @@ "dotenv": "^17.4.2", "gray-matter": "^4.0.3", "openai": "^6.45.0", - "tree-sitter": "^0.21.1", - "tree-sitter-go": "^0.23.4", - "tree-sitter-python": "^0.21.0", + "tree-sitter": "^0.25.0", + "tree-sitter-go": "^0.25.0", + "tree-sitter-python": "^0.25.0", "tree-sitter-typescript": "^0.23.2" }, + "overrides": { + "tree-sitter-typescript": { + "tree-sitter": "$tree-sitter" + } + }, "devDependencies": { "@types/d3-force": "^3.0.10", "@types/node": "^26.1.0", diff --git a/src/graph/extract.ts b/src/graph/extract.ts index 062720d8a..2b2afad06 100644 --- a/src/graph/extract.ts +++ b/src/graph/extract.ts @@ -194,11 +194,12 @@ interface DefDescriptor { hashNode: Parser.SyntaxNode; // node whose text forms body_hash / span } -/** tree-sitter's string `parse()` fails with "Invalid argument" on any input - * ≥ 32 KB, which silently drops large files — often the most important ones (a - * 2000-line command module, a core tab implementation). The callback form has - * no such limit as long as each returned chunk is under 32 KB, so we always feed - * the source in <32 KB slices. Code-unit indexing matches `String.slice`. */ +/** The chunked-callback parse predates tree-sitter 0.25, which lifted the + * string `parse()` size limit that used to fail with "Invalid argument" on + * any input ≥ 32 KB and silently drop large files — often the most important + * ones (a 2000-line command module, a core tab implementation). Kept because + * it is behavior-identical and exercised by existing tests. Code-unit + * indexing matches `String.slice`. */ const PARSE_CHUNK = 16384; function parseSource(source: string): Parser.SyntaxNode { return parser.parse((index: number) => source.slice(index, index + PARSE_CHUNK)).rootNode; From 8899d2664ac2aeeefabbbe1d2a4e6b9cb4a070a2 Mon Sep 17 00:00:00 2001 From: anirudhkumar-nanonets Date: Thu, 6 Aug 2026 12:59:41 +0530 Subject: [PATCH 02/10] fix: preserve compatibility after tree-sitter upgrade Use stable syntax-node IDs to avoid duplicate reference edges and align the supported Node version with native Windows prebuilds. --- .github/workflows/ci.yml | 2 +- CHANGELOG.md | 6 ++++-- package-lock.json | 2 +- package.json | 2 +- src/graph/extract.ts | 4 ++-- 5 files changed, 9 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 358f49f71..3accce9c1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,7 +33,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: - node-version: '20' + node-version: '22' cache: npm - name: Install dependencies diff --git a/CHANGELOG.md b/CHANGELOG.md index 1025ac419..c9420ac5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -101,8 +101,10 @@ `tree-sitter-typescript@0.23.2` is still the latest release and declares a `peerOptional tree-sitter@^0.21.0`, so an npm `overrides` entry pins its nested `tree-sitter` dependency to the root one and lets the install resolve - cleanly. No behavior change; the grammar's native binding loads and parses - correctly under tree-sitter 0.25.1, and the full suite is green. + cleanly. Syntax-node comparisons now use Tree-sitter's stable node IDs rather + than JavaScript object identity, whose behavior differs between runtime + versions. The minimum Node.js version is now 22.12, matching Commander 15 and + ensuring Windows installs can use Tree-sitter 0.25's prebuilt native module. [#33]: https://github.com/NanoNets/Graft/issues/33 [#35]: https://github.com/NanoNets/Graft/issues/35 diff --git a/package-lock.json b/package-lock.json index 58c157784..9a2750754 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,7 +32,7 @@ "typescript": "^6.0.3" }, "engines": { - "node": ">=20" + "node": ">=22.12.0" } }, "node_modules/@anthropic-ai/sdk": { diff --git a/package.json b/package.json index f01f6bd7f..a5f6a4cf4 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ "postinstall": "node scripts/postinstall.mjs" }, "engines": { - "node": ">=20" + "node": ">=22.12.0" }, "dependencies": { "@anthropic-ai/sdk": "^0.113.0", diff --git a/src/graph/extract.ts b/src/graph/extract.ts index 2b2afad06..d054b9b98 100644 --- a/src/graph/extract.ts +++ b/src/graph/extract.ts @@ -428,13 +428,13 @@ function isFunctionBoundary(node: Parser.SyntaxNode): boolean { /** A direct invocation already emits a stronger `calls` edge. */ function isDirectCallee(node: Parser.SyntaxNode, callType: string): boolean { const parent = node.parent; - return parent?.type === callType && parent.childForFieldName("function") === node; + return parent?.type === callType && parent.childForFieldName("function")?.id === node.id; } /** Definition/declaration identifiers name a new binding; they do not use one. */ function isDeclarationName(node: Parser.SyntaxNode): boolean { const parent = node.parent; - return parent?.childForFieldName("name") === node; + return parent?.childForFieldName("name")?.id === node.id; } /** Recognize the definition shapes: mapped node types, Go's type/method forms, and From b25adaaf88b75901db81742d2fa1f02db2e3c327 Mon Sep 17 00:00:00 2001 From: Alex Matthews <2862661+qoole@users.noreply.github.com> Date: Thu, 6 Aug 2026 17:37:04 +0100 Subject: [PATCH 03/10] chore: sync package-lock version after the 0.9.0 merge --- package-lock.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9a2750754..6ac4e4af8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@nanonets/graft", - "version": "0.8.2", + "version": "0.9.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@nanonets/graft", - "version": "0.8.2", + "version": "0.9.0", "hasInstallScript": true, "license": "MIT", "dependencies": { From ee0aee0eca5e89bb521e6cad712baaf67276ea64 Mon Sep 17 00:00:00 2001 From: Alex Matthews <2862661+qoole@users.noreply.github.com> Date: Sat, 1 Aug 2026 15:17:13 +0100 Subject: [PATCH 04/10] chore: add PowerShell tree-sitter grammar --- package-lock.json | 20 ++++++++++++++++++++ package.json | 1 + 2 files changed, 21 insertions(+) diff --git a/package-lock.json b/package-lock.json index 6ac4e4af8..89c192334 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "openai": "^6.45.0", "tree-sitter": "^0.25.0", "tree-sitter-go": "^0.25.0", + "tree-sitter-powershell": "^0.26.4", "tree-sitter-python": "^0.25.0", "tree-sitter-typescript": "^0.23.2" }, @@ -889,6 +890,25 @@ } } }, + "node_modules/tree-sitter-powershell": { + "version": "0.26.4", + "resolved": "https://registry.npmjs.org/tree-sitter-powershell/-/tree-sitter-powershell-0.26.4.tgz", + "integrity": "sha512-5RwNW/qN/cKArZY0znvTOG+OWSau7Agel5K0A8KQ5Nubw/4SyZ5KC02RKCaYcA2ur5UQ8esyxe9+6yXup2MXkw==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.5.0", + "node-gyp-build": "^4.8.4" + }, + "peerDependencies": { + "tree-sitter": "^0.25.0" + }, + "peerDependenciesMeta": { + "tree-sitter": { + "optional": true + } + } + }, "node_modules/tree-sitter-python": { "version": "0.25.0", "resolved": "https://registry.npmjs.org/tree-sitter-python/-/tree-sitter-python-0.25.0.tgz", diff --git a/package.json b/package.json index fc0475483..416a60d4c 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "openai": "^6.45.0", "tree-sitter": "^0.25.0", "tree-sitter-go": "^0.25.0", + "tree-sitter-powershell": "^0.26.4", "tree-sitter-python": "^0.25.0", "tree-sitter-typescript": "^0.23.2" }, From 76b443980dac09c0b23f1bbc05e88e7eeebbbcac Mon Sep 17 00:00:00 2001 From: Alex Matthews <2862661+qoole@users.noreply.github.com> Date: Sat, 1 Aug 2026 15:17:21 +0100 Subject: [PATCH 05/10] feat: PowerShell graph extraction and bindings Adds the PowerShell language to extract.ts: describePowerShell recognizes function/class/enum/method statements, psCalleeName/psImportKind/ psImportSpecifier classify commands (dot-source, Import-Module, using module) versus real calls, and psExported covers PowerShell's lack of a file-local export marker. bindings.ts's FileBindings gains a case-insensitive mode and PowerShell-specific defName/resolveRecvType handling ($this, scope-qualified definition names). --- src/graph/bindings.ts | 170 ++++++++++++++++++++++++- src/graph/extract.ts | 288 ++++++++++++++++++++++++++++++++++++++---- 2 files changed, 428 insertions(+), 30 deletions(-) diff --git a/src/graph/bindings.ts b/src/graph/bindings.ts index 07e9f1e87..acf6a1278 100644 --- a/src/graph/bindings.ts +++ b/src/graph/bindings.ts @@ -18,14 +18,21 @@ import type { Language, WalkCtx } from "./extract.js"; export class FileBindings { private map = new Map(); + constructor(private readonly caseInsensitive = false) {} + + private key(scopePath: string, name: string): string { + const key = `${scopePath}|${name}`; + return this.caseInsensitive ? key.toLowerCase() : key; + } + set(scopePath: string, name: string, type: string): void { - this.map.set(`${scopePath}|${name}`, type); + this.map.set(this.key(scopePath, name), type); } /** Innermost-first: for scope ["a","b"] name "x", tries `a.b|x`, `a|x`, `|x`. */ lookup(scope: string[], name: string): string | null { for (let i = scope.length; i >= 0; i--) { - const hit = this.map.get(`${scope.slice(0, i).join(".")}|${name}`); + const hit = this.map.get(this.key(scope.slice(0, i).join("."), name)); if (hit) return hit; } return null; @@ -43,6 +50,21 @@ const FN_VALUE_TYPES = new Set(["arrow_function", "function", "function_expressi * same scope key extract.ts's walk will look it up with), or null if `node` * isn't a definition. */ export function defName(node: Parser.SyntaxNode, lang: Language): string | null { + if (lang === "powershell") { + if (node.type === "function_statement") { + const name = node.namedChildren.find((c) => c.type === "function_name")?.text ?? null; + // Lockstep contract: extract.ts's describePowerShell strips this same + // qualifier from its own scope-stack segment. Must match exactly, or a + // binding recorded under one scope key (e.g. "global:Setup") is invisible + // to a lookup keyed by the other ("Setup") — duplicated regex, not + // imported, per this file's no-value-import-of-extract rule (see header). + return name ? name.replace(/^(global|script|local|private):/i, "") : null; + } + if (["class_statement", "class_method_definition", "enum_statement"].includes(node.type)) { + return node.namedChildren.find((c) => c.type === "simple_name")?.text ?? null; + } + return null; + } if (lang === "go") { if (node.type === "method_declaration") { const name = node.childForFieldName("name")?.text; @@ -105,6 +127,11 @@ export function resolveRecvType( ctx: Pick, ): string | undefined { if (!receiver) return undefined; + if (ctx.lang === "powershell") { + if (receiver.toLowerCase() === "$this") return ctx.enclosingClass ?? undefined; + const key = receiver.replace(/^\$this\./i, "self."); + return ctx.bindings.lookup(ctx.scope, key) ?? undefined; + } if (receiver === "self" || receiver === "cls" || receiver === "this") return ctx.enclosingClass ?? undefined; if (receiver.startsWith("self.") || receiver.startsWith("this.")) { return ( @@ -125,12 +152,13 @@ function isClassNode(node: Parser.SyntaxNode, lang: Language): boolean { if (lang === "typescript" || lang === "tsx") { return node.type === "class_declaration" || node.type === "abstract_class_declaration"; } + if (lang === "powershell") return node.type === "class_statement"; return false; } /** Pass 1 over a parsed file: collect variable->type bindings. Pure. */ export function collectBindings(root: Parser.SyntaxNode, lang: Language): FileBindings { - const bindings = new FileBindings(); + const bindings = new FileBindings(lang === "powershell"); const aliases = new Map(); collectAliases(root, lang, aliases); visit(root, lang, [], null, bindings, aliases); @@ -140,6 +168,7 @@ export function collectBindings(root: Parser.SyntaxNode, lang: Language): FileBi /** Import aliases (`... as F`) can be declared anywhere relative to their use * textually, so this scans the whole tree once, ahead of the scope-aware walk. */ function collectAliases(node: Parser.SyntaxNode, lang: Language, aliases: Map): void { + if (lang === "go" || lang === "powershell") return; if (lang === "python" && node.type === "aliased_import") { const nameNode = node.childForFieldName("name"); const aliasNode = node.childForFieldName("alias"); @@ -169,6 +198,7 @@ function visit( ): void { if (lang === "python") handlePy(node, scope, classScope, bindings, aliases); else if (lang === "go") handleGo(node, scope, bindings); + else if (lang === "powershell") handlePs(node, scope, classScope, bindings); else handleTs(node, scope, classScope, bindings, aliases); const name = defName(node, lang); @@ -284,6 +314,140 @@ function handleTs( } } +const PS_EXPR_WRAPPERS = new Set([ + "logical_expression", "bitwise_expression", "comparison_expression", + "additive_expression", "multiplicative_expression", "format_expression", "range_expression", + "logical_argument_expression", "bitwise_argument_expression", "comparison_argument_expression", + "additive_argument_expression", "multiplicative_argument_expression", "format_argument_expression", + "range_argument_expression", "array_literal_expression", "unary_expression", "expression_with_unary_operator", +]); + +function unwrapExpr(node: Parser.SyntaxNode | null | undefined): Parser.SyntaxNode | null { + let current = node ?? null; + while (current && PS_EXPR_WRAPPERS.has(current.type) && current.namedChildCount === 1) { + current = current.namedChild(0); + } + return current; +} + +/** A `type_literal`'s bare type name (`[Widget]` → `"Widget"`). Shared with + * extract.ts (its own psCalleeName needs the same lookup for a static-constructor + * receiver) — mirrors the existing goReceiverVarOf precedent of exporting a + * helper from here rather than duplicating it; the no-value-import rule only + * forbids the reverse direction (this file importing a value from extract.ts). */ +export function psTypeName(node: Parser.SyntaxNode | null | undefined): string | null { + if (node?.type !== "type_literal") return null; + const spec = node.namedChildren.find((c) => c.type === "type_spec"); + const name = spec?.namedChildren.find((c) => c.type === "type_name"); + return name?.namedChildren.find((c) => c.type === "type_identifier")?.text ?? null; +} + +function psAssignmentTarget(node: Parser.SyntaxNode): { name: string; type: string | null } | null { + const left = node.namedChildren.find((c) => c.type === "left_assignment_expression"); + const value = unwrapExpr(left?.namedChild(0)); + if (value?.type === "variable") return { name: value.text, type: null }; + if (value?.type !== "cast_expression") return null; + const type = psTypeName(value.namedChildren.find((c) => c.type === "type_literal")); + const variable = unwrapExpr(value.namedChildren.find((c) => c.type !== "type_literal")); + return type && variable?.type === "variable" ? { name: variable.text, type } : null; +} + +/** `New-Object`'s type argument, parameter-aware: `-TypeName`'s value always wins + * (wherever it falls among the command's elements — `-ArgumentList`'s own value + * must never be mistaken for it just because it comes first). Without an explicit + * `-TypeName`: a switch parameter (e.g. `-Verbose`) takes no value, so when the + * command has exactly one candidate element left (neither a parameter nor + * `-ArgumentList`'s own value), that lone element is the type regardless of which + * parameter it happens to sit next to — `New-Object -Verbose Widget` must still + * bind "Widget". Only `-ArgumentList` is known to actually consume the element + * after it (`-ArgumentList Widget`'s "Widget" is an argument, not a type name), so + * it alone disqualifies an adjacent lone candidate. With multiple candidates + * remaining, position is genuinely ambiguous without cmdlet metadata; fall back to + * the first element that is neither a parameter nor immediately after one. */ +function psNewObjectType(command: Parser.SyntaxNode): string | null { + if (command.childForFieldName("command_name")?.text.toLowerCase() !== "new-object") return null; + const elements = (command.childForFieldName("command_elements")?.namedChildren ?? []).filter( + (c) => c.type !== "command_argument_sep", + ); + const clean = (text: string) => text.replace(/^['"]|['"]$/g, ""); + + const typeNameIdx = elements.findIndex((c) => c.type === "command_parameter" && /^-TypeName$/i.test(c.text)); + if (typeNameIdx !== -1) { + const value = elements[typeNameIdx + 1]; + return value && value.type !== "command_parameter" ? clean(value.text) : null; + } + + const candidates = elements.filter((c) => c.type !== "command_parameter"); + if (candidates.length === 1) { + const only = candidates[0]; + const precedingParam = elements[elements.indexOf(only) - 1]; + const ownedByArgumentList = + precedingParam?.type === "command_parameter" && /^-ArgumentList$/i.test(precedingParam.text); + return ownedByArgumentList ? null : clean(only.text); + } + + for (let i = 0; i < elements.length; i++) { + const el = elements[i]; + if (el.type === "command_parameter") continue; + if (elements[i - 1]?.type === "command_parameter") continue; // that param's own value + return clean(el.text); + } + return null; +} + +function psInvocationType(node: Parser.SyntaxNode | null): string | null { + if (node?.type !== "invokation_expression") return null; + const receiver = node.child(0); + const member = node.namedChildren.find((c) => c.type === "member_name"); + return receiver?.type === "type_literal" && member?.text.toLowerCase() === "new" + ? psTypeName(receiver) + : null; +} + +function psRhsType(node: Parser.SyntaxNode): string | null { + const pipeline = node.childForFieldName("value"); + const chain = pipeline?.namedChildren.find((c) => c.type === "pipeline_chain"); + const value = chain?.namedChild(0) ?? null; + if (value?.type === "command") return psNewObjectType(value); + return psInvocationType(unwrapExpr(value)); +} + +function psParameterBinding(node: Parser.SyntaxNode): { name: string; type: string } | null { + const variable = node.namedChildren.find((c) => c.type === "variable"); + let typeLiteral = node.namedChildren.find((c) => c.type === "type_literal"); + if (!typeLiteral) { + // The type can sit behind ANY attribute, not just the first — e.g. + // `[Parameter(Mandatory=$true)][Widget]$w` puts `Parameter` at index 0 and the + // type_literal at index 1. Scan every attribute for one that carries it. + const attrs = node.namedChildren.find((c) => c.type === "attribute_list"); + typeLiteral = attrs?.namedChildren + .map((a) => a.namedChildren.find((c) => c.type === "type_literal")) + .find(Boolean); + } + const type = psTypeName(typeLiteral); + return variable && type ? { name: variable.text, type } : null; +} + +function handlePs( + node: Parser.SyntaxNode, + scope: string[], + classScope: string | null, + bindings: FileBindings, +): void { + const scopePath = scope.join("."); + if (node.type === "assignment_expression") { + const target = psAssignmentTarget(node); + const type = target?.type ?? psRhsType(node); + if (target && type) bindings.set(scopePath, target.name, type); + } else if (node.type === "script_parameter" || node.type === "class_method_parameter") { + const param = psParameterBinding(node); + if (param) bindings.set(scopePath, param.name, param.type); + } else if (node.type === "class_property_definition") { + const property = psParameterBinding(node); + if (property) bindings.set(classScope ?? scopePath, `self.${property.name.replace(/^\$/, "")}`, property.type); + } +} + function handleGo(node: Parser.SyntaxNode, scope: string[], bindings: FileBindings): void { const scopePath = scope.join("."); if (node.type === "var_spec") { diff --git a/src/graph/extract.ts b/src/graph/extract.ts index ca7acdcf0..bd0864b80 100644 --- a/src/graph/extract.ts +++ b/src/graph/extract.ts @@ -10,12 +10,14 @@ import Parser from "tree-sitter"; import TypeScript from "tree-sitter-typescript"; import Python from "tree-sitter-python"; import Go from "tree-sitter-go"; +// Deep import avoids the package's extensionless-main DEP0151 warning under ESM. +import PowerShell from "tree-sitter-powershell/bindings/node/index.js"; import { basename } from "node:path"; import { contentHash } from "../util/id.js"; -import { collectBindings, goReceiverVarOf, resolveRecvType, type FileBindings } from "./bindings.js"; +import { collectBindings, goReceiverVarOf, psTypeName, resolveRecvType, type FileBindings } from "./bindings.js"; import type { Kind, NodeV1, Relation } from "./types.js"; -export type Language = "typescript" | "tsx" | "python" | "go"; +export type Language = "typescript" | "tsx" | "python" | "go" | "powershell"; /** * Extension → the tree-sitter grammar that parses it, and the label a human expects @@ -44,6 +46,8 @@ const EXTENSIONS: ReadonlyArray<{ ext: string; grammar: Language; label: string { ext: ".pyi", grammar: "python", label: "python" }, { ext: ".py", grammar: "python", label: "python" }, { ext: ".go", grammar: "go", label: "go" }, + { ext: ".psm1", grammar: "powershell", label: "powershell" }, + { ext: ".ps1", grammar: "powershell", label: "powershell" }, ]; function entryFor(path: string): (typeof EXTENSIONS)[number] | undefined { @@ -80,6 +84,7 @@ export interface RawEdge { /** calls with viaMember: the receiver's resolved type name (from bindings / * self / this / Go receiver), when a confident local clue exists. */ recvType?: string; + lang?: Language; // PowerShell edges carry this for case-insensitive resolution. } export interface ExtractResult { @@ -149,11 +154,27 @@ const GO_KINDS: Record = { method_declaration: "method", }; +const PS_KINDS: Record = { + function_statement: "function", + class_statement: "class", + enum_statement: "enum", + class_method_definition: "method", +}; + const KINDS_BY_LANG: Record> = { typescript: TS_KINDS, tsx: TS_KINDS, python: PY_KINDS, go: GO_KINDS, + powershell: PS_KINDS, +}; + +const CALL_NODE_TYPES: Record> = { + typescript: new Set(["call_expression"]), + tsx: new Set(["call_expression"]), + python: new Set(["call"]), + go: new Set(["call_expression"]), + powershell: new Set(["command", "invokation_expression"]), }; const FUNCTION_VALUE_TYPES = new Set([ @@ -169,6 +190,7 @@ const GRAMMARS: Record = { tsx: TypeScript.tsx, python: Python, go: Go, + powershell: PowerShell, }; export interface WalkCtx { @@ -256,20 +278,6 @@ export function extractFile(rel: string, source: string, lang: Language): Extrac return { nodes, rawEdges }; } -/** Mint-time uniqueness: a document-order duplicate (same name reopened, or two - * sibling defs that happen to collide) gets `~2`, `~3`, ... instead of silently - * shadowing the first. The while-loop (not a single `~2` guess) is what makes - * this collision-proof: a source name that itself ends in ~N would collide - * with a single-guess suffix, so this keeps incrementing until it finds a - * truly free id rather than trusting one candidate suffix is unused. */ -export function mintId(base: string, minted: Set): string { - let id = base; - let k = 2; - while (minted.has(id)) id = `${base}~${k++}`; - minted.add(id); - return id; -} - function walk(node: Parser.SyntaxNode, ctx: WalkCtx, out: NodeV1[], edges: RawEdge[], minted: Set): void { const desc = describe(node, ctx); if (desc) { @@ -277,7 +285,16 @@ function walk(node: Parser.SyntaxNode, ctx: WalkCtx, out: NodeV1[], edges: RawEd // `name` stays the bare symbol name so member-call resolution matches it. const idPart = desc.idName ?? desc.name; const base = `${ctx.rel}#${[...ctx.scope, idPart].join(".")}`; - const id = mintId(base, minted); + // Mint-time uniqueness: a document-order duplicate (same name reopened, or two + // sibling defs that happen to collide) gets `~2`, `~3`, ... instead of silently + // shadowing the first. The while-loop (not a single `~2` guess) is what makes + // this collision-proof against a literal source name that already contains + // `~N` (PowerShell can forge one) — it keeps incrementing until truly free + // rather than trusting one candidate suffix is unused. + let id = base; + let k = 2; + while (minted.has(id)) id = `${base}~${k++}`; + minted.add(id); const isGoMethod = ctx.lang === "go" && node.type === "method_declaration"; // The bare name of this node's OWN immediate enclosing class/receiver — for a // Go method that's its receiver type (methods aren't nested, so ctx.enclosingClass @@ -298,7 +315,9 @@ function walk(node: Parser.SyntaxNode, ctx: WalkCtx, out: NodeV1[], edges: RawEd ? !desc.name.startsWith("_") : ctx.lang === "go" ? goExported(desc.name) - : tsExported(node), + : ctx.lang === "powershell" + ? psExported(node, ctx) + : tsExported(node), origin: "ast", body_hash: contentHash(desc.hashNode.text), body_text: searchBody(desc.hashNode.text), @@ -330,8 +349,7 @@ function walk(node: Parser.SyntaxNode, ctx: WalkCtx, out: NodeV1[], edges: RawEd } // not a definition — capture calls/imports/references, then descend with the same context - const callType = ctx.lang === "python" ? "call" : "call_expression"; - if (node.type === callType) { + if (CALL_NODE_TYPES[ctx.lang].has(node.type) && !psSkipsCall(node, ctx.lang)) { const callee = calleeName(node, ctx.lang); if (callee) { const callEdge: RawEdge = { @@ -340,19 +358,30 @@ function walk(node: Parser.SyntaxNode, ctx: WalkCtx, out: NodeV1[], edges: RawEd name: callee.name, viaMember: callee.viaMember, file: ctx.rel, + ...(ctx.lang === "powershell" ? { lang: ctx.lang } : {}), }; - const recvType = resolveRecvType(callee.receiver, ctx); + const recvType = callee.recvType ?? resolveRecvType(callee.receiver, ctx); edges.push(recvType ? { ...callEdge, recvType } : callEdge); } } else if (isImport(node, ctx.lang)) { const spec = importSpecifier(node, ctx.lang); - if (spec) edges.push({ source: ctx.rel, relation: "imports", specifier: spec, file: ctx.rel }); + if (spec) { + edges.push({ + source: ctx.rel, + relation: "imports", + specifier: spec, + file: ctx.rel, + ...(ctx.lang === "powershell" ? { lang: ctx.lang } : {}), + }); + } // Imported identifiers are declarations, not uses. The import-binding pass // above already recorded them, so do not descend and emit false references. - return; + // PowerShell is the exception: a dot-source can wrap a whole script block + // (`. { ... }`), whose body must still be walked for its own calls. + if (ctx.lang !== "powershell") return; } else if ( node.type === "identifier" && - !isDirectCallee(node, callType) && + !isDirectCallee(node, CALL_NODE_TYPES[ctx.lang]) && !isDeclarationName(node) ) { const imported = ctx.importedSymbols.get(node.text); @@ -455,9 +484,9 @@ function isFunctionBoundary(node: Parser.SyntaxNode): boolean { } /** A direct invocation already emits a stronger `calls` edge. */ -function isDirectCallee(node: Parser.SyntaxNode, callType: string): boolean { +function isDirectCallee(node: Parser.SyntaxNode, callTypes: ReadonlySet): boolean { const parent = node.parent; - return parent?.type === callType && parent.childForFieldName("function")?.id === node.id; + return parent != null && callTypes.has(parent.type) && parent.childForFieldName("function")?.id === node.id; } /** Definition/declaration identifiers name a new binding; they do not use one. */ @@ -470,6 +499,7 @@ function isDeclarationName(node: Parser.SyntaxNode): boolean { * TS arrow-consts. */ function describe(node: Parser.SyntaxNode, ctx: WalkCtx): DefDescriptor | null { if (ctx.lang === "go") return describeGo(node, ctx); + if (ctx.lang === "powershell") return describePowerShell(node, ctx); const mapped = ctx.kinds[node.type]; if (mapped) { @@ -501,6 +531,31 @@ function describe(node: Parser.SyntaxNode, ctx: WalkCtx): DefDescriptor | null { return null; } +/** Function-definition scope qualifiers PowerShell allows (`function global:Foo`, + * `script:Foo`, …). Call sites already strip these (see psCalleeName below) — a + * definition's OWN name must too, or a globally-qualified def stores its + * verbatim "global:Foo" name and can never match a call site's stripped "Foo". */ +const PS_SCOPE_QUALIFIER = /^(global|script|local|private):/i; + +/** PowerShell definitions have no fields, so names and header boundaries are positional. */ +function describePowerShell(node: Parser.SyntaxNode, ctx: WalkCtx): DefDescriptor | null { + const kind = ctx.kinds[node.type]; + if (!kind) return null; + const nameNode = + node.type === "function_statement" + ? node.namedChildren.find((c) => c.type === "function_name") + : node.namedChildren.find((c) => c.type === "simple_name"); + if (!nameNode) return null; + const open = node.children.find((c) => c.type === "{"); + const name = node.type === "function_statement" ? nameNode.text.replace(PS_SCOPE_QUALIFIER, "") : nameNode.text; + return { + name, + kind, + headerEnd: open?.startIndex ?? node.endIndex, + hashNode: node, + }; +} + /** Go definition shapes: top-level funcs, receiver methods, and named types * (struct / interface / type alias). Methods carry no nesting — they're qualified * by their receiver type (`User.Save`) so calls can resolve and cards read clearly. */ @@ -573,6 +628,17 @@ function heritageEdges(node: Parser.SyntaxNode, classId: string, ctx: WalkCtx): } return edges; } + if (ctx.lang === "powershell") { + // PS class base lists conflate the base class and any implemented interfaces + // (no separate `implements` clause) — every entry emits "extends" here. + // Harmless: PS cannot itself define an interface, so an interface entry is + // always an external type anyway (never a repo symbol misclassified as a class). + const bases = node.namedChildren.filter((c) => c.type === "simple_name").slice(1); + for (const base of bases) { + edges.push({ source: classId, relation: "extends", name: base.text, file: ctx.rel, lang: ctx.lang }); + } + return edges; + } const heritage = node.namedChildren.find((c) => c.type === "class_heritage"); for (const clause of heritage?.namedChildren ?? []) { const relation: Relation | null = @@ -594,7 +660,8 @@ function heritageEdges(node: Parser.SyntaxNode, classId: string, ctx: WalkCtx): function calleeName( node: Parser.SyntaxNode, lang: Language, -): { name: string; viaMember: boolean; receiver?: string } | null { +): { name: string; viaMember: boolean; receiver?: string; recvType?: string } | null { + if (lang === "powershell") return psCalleeName(node); const fn = node.childForFieldName("function"); if (!fn) return null; if (fn.type === "identifier") return { name: fn.text, viaMember: false }; @@ -616,6 +683,54 @@ function calleeName( return null; } +function psCalleeName( + node: Parser.SyntaxNode, +): { name: string; viaMember: boolean; receiver?: string; recvType?: string } | null { + if (node.type === "command") { + const raw = psStaticCommandName(node.childForFieldName("command_name") ?? null); + if (!raw) return null; + const name = raw + .replace(PS_SCOPE_QUALIFIER, "") + .replace(/^.*\\/, ""); + return name ? { name, viaMember: false } : null; + } + if (node.type !== "invokation_expression") return null; + const receiverNode = node.child(0); + const member = node.namedChildren.find((c) => c.type === "member_name"); + if (!receiverNode || !member) return null; + if (receiverNode.type === "type_literal") { + const recvType = psTypeName(receiverNode); + return recvType ? { name: member.text, viaMember: true, recvType } : null; + } + return { name: member.text, viaMember: true, receiver: receiverNode.text }; +} + +/** The static command name text for a `command` node's `command_name` field, or + * null when it's absent or names dynamic/string content. Plain commands + * (`Get-Helper`) type the field `command_name` directly; the call operator (`&`) + * and dot-source (`.`) both wrap it in `command_name_expr` instead — a static + * bareword through `&` shows up one level deeper as `path_command_name` → + * `path_command_name_token` (a qualified/dotted name like `script:Get-Helper` + * types straight to `command_name` even there). A `variable`/`string_literal`/ + * `script_block_expression`/`parenthesized_expression` child means dynamic or + * non-command content — never treated as static. A `path_command_name` can + * ALSO carry a `variable` sibling of its `path_command_name_token` (`& + * $dir\Get-Helper`) — the trailing token's text alone still reads as a plain + * name, so that variable prefix must be checked explicitly rather than + * inferred from the token shape. */ +function psStaticCommandName(command: Parser.SyntaxNode | null): string | null { + if (!command) return null; + if (command.type === "command_name") return command.text; + if (command.type !== "command_name_expr") return null; + const inner = command.namedChildren[0]; + if (inner?.type === "command_name") return inner.text; + if (inner?.type === "path_command_name") { + if (inner.descendantsOfType("variable").length > 0) return null; + return inner.namedChildren.find((c) => c.type === "path_command_name_token")?.text ?? null; + } + return null; +} + /** py `attribute` node's receiver text: bare identifier, or `self.x` for a * chained `self.x.y()`. Anything else (e.g. a chained call `f().g()`) → none. */ function pyReceiver(fn: Parser.SyntaxNode): string | undefined { @@ -646,6 +761,7 @@ function isImport(node: Parser.SyntaxNode, lang: Language): boolean { // Go: match the per-import leaf, so single (`import "fmt"`) and grouped // (`import ( … )`) forms each yield one edge as the walk recurses into the list. if (lang === "go") return node.type === "import_spec"; + if (lang === "powershell") return psImportKind(node) !== null; return node.type === "import_statement" || node.type === "import_from_statement"; } @@ -661,12 +777,130 @@ function importSpecifier(node: Parser.SyntaxNode, lang: Language): string | null const path = node.childForFieldName("path") ?? node.namedChildren.at(-1); return path ? path.text.replace(/^["`]|["`]$/g, "") : null; } + if (lang === "powershell") return psImportSpecifier(node); const str = node.namedChildren.find((c) => c.type === "string"); if (!str) return null; const frag = str.namedChildren.find((c) => c.type === "string_fragment"); return frag?.text ?? str.text.replace(/^['"]|['"]$/g, ""); } +type PsImportKind = "dotsource" | "import-module" | "using-module"; + +/** PowerShell's grammar represents all imports as commands (there is no + * using_statement). `$PSScriptRoot` interpolation, Join-Path, wildcard/dynamic + * Import-Module, manifest RootModule/NestedModules, and `using namespace` + * intentionally remain raw or absent rather than erroring. */ +function psImportKind(node: Parser.SyntaxNode): PsImportKind | null { + if (node.type !== "command") return null; + const operator = node.namedChildren.find((c) => c.type === "command_invokation_operator"); + if (operator?.text === ".") return "dotsource"; + const command = node.childForFieldName("command_name")?.text.toLowerCase(); + if (command === "import-module") return "import-module"; + if (command !== "using") return null; + const tokens = psCommandElements(node).filter((c) => c.type === "generic_token"); + return tokens[0]?.text.toLowerCase() === "module" && tokens[1] ? "using-module" : null; +} + +function psCommandElements(node: Parser.SyntaxNode): Parser.SyntaxNode[] { + return node.childForFieldName("command_elements")?.namedChildren ?? []; +} + +function psImportSpecifier(node: Parser.SyntaxNode): string | null { + const kind = psImportKind(node); + let raw: string | undefined; + if (kind === "dotsource") { + const commandNameField = node.childForFieldName("command_name") ?? null; + // A dot-sourced script block (`. { … }`) or subexpression (`. ( … )`) has no + // path at all — classify by the field's NODE TYPE rather than guessing from + // its text, so a quoted path that merely starts with `(` (e.g. + // `. "(helpers).ps1"`) is never mistaken for one (see psDotSourcePathShaped). + if (!psDotSourcePathShaped(commandNameField)) return null; + raw = commandNameField?.text; + } else if (kind === "import-module") { + raw = psImportModuleSpecifier(node) ?? undefined; + } else if (kind === "using-module") { + raw = psCommandElements(node).filter((c) => c.type === "generic_token")[1]?.text; + } + if (!raw) return null; + return raw.replace(/^['"]|['"]$/g, "").replace(/\\/g, "/"); +} + +/** Import-Module's module specifier, parameter-aware: a `-Name` parameter's + * value always wins, wherever it falls among the command's elements — in + * `-Force -ErrorAction stop`, `-ErrorAction`'s own value ("stop") must never be + * mistaken for the module target just because it's the first non-parameter + * element left. Without an explicit `-Name`, the specifier is the first element + * that is neither a parameter NOR immediately preceded by one (that element + * belongs to the preceding parameter, not the module) — this also keeps a + * positional specifier before a trailing switch working + * (`Import-Module ./Mod.psm1 -Force`). No qualifying candidate → null (no + * edge), never a guess. Mirrors bindings.ts's psNewObjectType, which solves the + * same parameter-vs-positional ambiguity for `New-Object`. */ +function psImportModuleSpecifier(node: Parser.SyntaxNode): string | null { + const elements = psCommandElements(node).filter((c) => c.type !== "command_argument_sep"); + const nameIdx = elements.findIndex((c) => c.type === "command_parameter" && /^-Name$/i.test(c.text)); + if (nameIdx !== -1) { + const value = elements[nameIdx + 1]; + return value && value.type !== "command_parameter" ? value.text : null; + } + for (let i = 0; i < elements.length; i++) { + const el = elements[i]; + if (el.type === "command_parameter") continue; + if (elements[i - 1]?.type === "command_parameter") continue; // that param's own value + return el.text; + } + return null; +} + +/** Whether a dot-sourced command's `command_name` field names a real path + * rather than a script block or subexpression. A plain bareword path types + * this field `command_name` directly. The call operator's `command_name_expr` + * wrapper (also used here for `.`) is drilled one level: a qualified/dotted + * bareword still types `command_name`, a variable-rooted path (`$dir\Foo.ps1`) + * types `path_command_name` — accepted only when its trailing token actually + * starts with a path separator, so a property-access-like suffix + * (`$obj.Method`, token ".Method") is never mistaken for a path — and a + * quoted path types `string_literal` (its surrounding quotes are stripped by + * the caller). A POSIX-separator variable-rooted path (`$PSScriptRoot/Foo.ps1`) + * parses as `member_access` instead (with a grammar-level ERROR node from the + * bare `/`) — accepted only when its text is a bare `$Var/…` shape, + * mirroring the backslash form's specifier so `$PSScriptRoot` resolution + * sees the same raw text either way. `script_block_expression` (`. { … }`) and + * `parenthesized_expression` (`. ( … )`) carry no path at all and are rejected + * outright. */ +function psDotSourcePathShaped(commandNameField: Parser.SyntaxNode | null): boolean { + if (!commandNameField) return false; + if (commandNameField.type === "command_name") return true; + if (commandNameField.type !== "command_name_expr") return false; + const inner = commandNameField.namedChildren[0]; + if (inner?.type === "command_name" || inner?.type === "string_literal") return true; + if (inner?.type === "path_command_name") { + const token = inner.namedChildren.find((c) => c.type === "path_command_name_token")?.text ?? ""; + return /^[\\/]/.test(token); + } + if (inner?.type === "member_access") return /^\$[A-Za-z_]\w*\//.test(inner.text); + return false; +} + +function psSkipsCall(node: Parser.SyntaxNode, lang: Language): boolean { + if (lang !== "powershell" || node.type !== "command") return false; + // psImportKind already returns non-null for every dot-sourced command (whatever + // it dot-sources), so this alone keeps `.` out of the call graph. `&` is + // deliberately NOT blanket-skipped here — psCalleeName decides per-callee + // whether a call-operator target is static (a real call) or dynamic (none). + if (psImportKind(node)) return true; + return node.childForFieldName("command_name")?.text.toLowerCase() === "using"; +} + +/** PowerShell has no file-local syntactic export marker. Export-ModuleMember and + * `.psd1` FunctionsToExport need cross-file/manifest context extractFile cannot + * see (and the common unquoted Export-ModuleMember list does not parse). Class + * members and top-level definitions are public; a function nested inside another + * function OR a class method body is local. */ +function psExported(node: Parser.SyntaxNode, ctx: WalkCtx): boolean { + return node.type !== "function_statement" || (ctx.enclosingKind !== "function" && ctx.enclosingKind !== "method"); +} + /** Signature = the definition header, whitespace-collapsed, trailing punctuation stripped. */ function clean(raw: string): string | null { const sig = raw From ec43b87cfd300adc9430b0af8bc176dca843f0cf Mon Sep 17 00:00:00 2001 From: Alex Matthews <2862661+qoole@users.noreply.github.com> Date: Sat, 1 Aug 2026 15:17:44 +0100 Subject: [PATCH 06/10] =?UTF-8?q?feat:=20PowerShell=20name=20resolution=20?= =?UTF-8?q?=E2=80=94=20case-folding,=20test-aware=20tie-break,=20builtin?= =?UTF-8?q?=20cmdlets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolve.ts bundles the name-lookup and owner-index maps (NameIndex/ OwnerIndex) and adds a PowerShell case-folded tier alongside the existing exact-match one: same-file exact wins first, then same-file case-folded, then whole-repo exact, then whole-repo case-folded. A PowerShell caller that isn't itself a test file demotes test-path candidates at the two global tiers before the uniqueness check, so Pester's habit of redeclaring a prod name across several *.Tests.ps1 files doesn't drop every cross-file call as ambiguous. PS_BUILTIN_CMDLETS denylists the core cmdlet/Pester-DSL surface so a call to a builtin never wires to an unrelated in-repo definition of the same name. $PSScriptRoot resolves as a literal for import paths, and PS_IMPORT_EXTS keeps PowerShell's module extensions from cross-matching TS/Python specifiers. isTestPath moves to its own src/util/testpath.ts (a hard graph-topology input for resolve.ts now, not just ask.ts's soft rank-penalty); ask.ts re-exports it for backward compatibility. --- src/ask/ask.ts | 13 ++- src/graph/resolve.ts | 263 ++++++++++++++++++++++++++++++++++++++----- src/util/testpath.ts | 20 ++++ 3 files changed, 262 insertions(+), 34 deletions(-) create mode 100644 src/util/testpath.ts diff --git a/src/ask/ask.ts b/src/ask/ask.ts index e4b7408b5..8a2a6e495 100644 --- a/src/ask/ask.ts +++ b/src/ask/ask.ts @@ -32,7 +32,13 @@ import { resolveSymbol } from "../graph/traverse.js"; import type { EdgeV1, GraphV1, NodeV1, Relation } from "../graph/types.js"; import { rankScopesAndFuse } from "./fuse.js"; import { personalizedPageRank } from "./graphrank.js"; +import { isTestPath } from "../util/testpath.js"; import { readSourceFile } from "../util/source.js"; + +// Re-exported for backward compatibility — moved to util/testpath.ts since +// resolve.ts now consumes it too, as a hard graph-topology input rather than +// just ask's own soft rank-penalty. See that file for the dual-contract note. +export { isTestPath }; import { counts, tokenize, type AskIndex, type AskIndexDoc } from "./index-file.js"; export interface AskHit { @@ -148,10 +154,9 @@ function loadCorpus(outDir: string): Corpus { * and land as the top hit (observed: an `ask` returning a `Test…` function first, * sending the agent to the wrong file). A multiplicative de-rank keeps tests in * the results (they still matter for "where are the tests") but below the real - * definition. Covers Go (`_test.go`), JS/TS (`.test.` / `.spec.`), and test dirs. */ -export function isTestPath(path: string): boolean { - return /(^|\/)(tests?|__tests__|spec)\/|(_test|\.test|\.spec)\.[a-z]+$|(^|\/)(test_[^/]+|conftest)\.py$/i.test(path || ""); -} + * definition. `isTestPath` itself now lives in util/testpath.ts — imported + * and re-exported above so this stays the one place ask.ts's own callers (and + * test/ask.test.ts) look for it. */ const TEST_RANK_PENALTY = 0.35; function score( diff --git a/src/graph/resolve.ts b/src/graph/resolve.ts index ea69aacb4..d0757c766 100644 --- a/src/graph/resolve.ts +++ b/src/graph/resolve.ts @@ -15,9 +15,59 @@ import { posix } from "node:path"; import { toPosixPath } from "../util/paths.js"; import type { EdgeV1, Kind, NodeV1, Relation } from "./types.js"; -import type { RawEdge } from "./extract.js"; +import { languageOf, type Language, type RawEdge } from "./extract.js"; +import { isTestPath } from "../util/testpath.js"; -const IMPORT_EXTS = [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs", ".py"]; +/** Extensions resolveImport tries for a same-directory, extensionless specifier. + * Kept separate from {@link PS_IMPORT_EXTS} so the two module systems never + * cross: a PS `Import-Module ./Widget` must not settle for a same-named + * `Widget.ts`, and a TS `import "./Widget"` must not settle for a `Widget.psm1` — + * each extensionless specifier only ever candidates through its OWN language's + * source files. */ +export const IMPORT_EXTS = [ + ".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs", ".py", +]; + +const PS_IMPORT_EXTS = [".psm1", ".ps1"]; + +// The core PowerShell cmdlet/DSL surface (Microsoft.PowerShell.Core/Management/ +// Utility/Security + Pester's own DSL) — ambient names that are never user-defined +// repo symbols, the same posture the resolver already takes for unresolvable +// member calls: drop rather than guess. `Get-ChildItem` called from prod code +// binding to the only in-repo def — a Pester mock — is a wrong edge, not a +// missing one; a dropped edge here is the safe failure mode. +// Lowercased so resolveName's PS path (already case-folding) can check it directly. +const PS_BUILTIN_CMDLETS: ReadonlySet = new Set( + [ + "Get-ChildItem", "Get-Content", "Set-Content", "Test-Path", "Join-Path", "Split-Path", "Resolve-Path", + "Get-Item", "Set-Item", "Remove-Item", "New-Item", "Copy-Item", "Move-Item", "Rename-Item", + "Write-Host", "Write-Output", "Write-Error", "Write-Warning", "Write-Verbose", "Write-Debug", + "Write-Information", "Write-Progress", "Read-Host", "Out-File", "Out-Null", "Out-String", + "Get-Command", "Get-Module", "Import-Module", "Remove-Module", "New-Module", "Get-Help", + "Invoke-Expression", "Invoke-Command", "Invoke-WebRequest", "Invoke-RestMethod", + "Start-Sleep", "Start-Process", "Stop-Process", "Get-Process", + "Get-Service", "Start-Service", "Stop-Service", "Restart-Service", + "Get-Date", "Get-Random", "Measure-Object", "Select-Object", "Where-Object", "ForEach-Object", + "Sort-Object", "Group-Object", "Compare-Object", "Tee-Object", "New-Object", "Get-Member", "Add-Member", + "Select-String", "Format-List", "Format-Table", "Format-Wide", "Format-Custom", + "ConvertTo-Json", "ConvertFrom-Json", "ConvertTo-Csv", "ConvertFrom-Csv", + "Export-Csv", "Import-Csv", "Export-Clixml", "Import-Clixml", "ConvertTo-Xml", "ConvertTo-Html", + "Get-Location", "Set-Location", "Push-Location", "Pop-Location", "Test-Connection", + "Get-Variable", "Set-Variable", "New-Variable", "Remove-Variable", "Clear-Variable", + "Get-Alias", "Set-Alias", "New-Alias", "Get-PSDrive", "New-PSDrive", "Remove-PSDrive", + "Get-ItemProperty", "Set-ItemProperty", "New-ItemProperty", "Remove-ItemProperty", + "Get-EventLog", "Get-WmiObject", "Get-CimInstance", "Invoke-CimMethod", "Register-ObjectEvent", + "Get-Job", "Start-Job", "Stop-Job", "Receive-Job", "Wait-Job", "Remove-Job", + "Get-Credential", "ConvertTo-SecureString", "ConvertFrom-SecureString", + "Get-ExecutionPolicy", "Set-ExecutionPolicy", "Get-Host", "Clear-Host", "Get-History", + "Add-Type", "Update-TypeData", "Get-Unique", "Get-Culture", "Get-UICulture", "Get-TimeZone", + "Set-StrictMode", "Wait-Process", "Exit-PSSession", "Enter-PSSession", "New-PSSession", + "Remove-PSSession", "Get-PSSession", "Export-ModuleMember", "Set-PSDebug", + "Register-EngineEvent", "Unregister-Event", "Get-Event", "New-TimeSpan", "New-Guid", + // Pester DSL surface — ambient for graph purposes too (see rationale above). + "Should", "Describe", "Context", "It", "BeforeAll", "BeforeEach", "AfterAll", "AfterEach", "Mock", + ].map((n) => n.toLowerCase()), +); /** A Go module discovered in the repo: its `module` path from `go.mod` and the repo * directory that `go.mod` lives in (posix, `.` for the repo root). A monorepo may hold @@ -34,6 +84,33 @@ export interface ResolveOptions { goModules?: GoModule[]; } +/** Bundled name-lookup indexes for {@link resolveName}: exact match and PS + * case-folded match, each split into per-file and whole-repo. Bundled instead + * of four positional params — the exact/folded pair each hold a structurally + * identical `Map>` + `Map` + * shape, which a positional call site could swap without any type error. + * `psTestPaths` is every PS node path classified by {@link isTestPath} — + * used only to demote (never remove) test-path candidates at resolveName's two + * global tiers. */ +interface NameIndex { + perFileName: Map>; + globalName: Map; + psPerFileName: Map>; + psGlobalName: Map; + psTestPaths: Set; +} + +/** Bundled owner/heritage indexes for {@link resolveTypedMember}: the + * owner-qualified method index (`"Owner.method"` → candidates) and the + * extends-chain index (class name → declared base names), each exact and PS + * case-folded. Same swap-risk rationale as {@link NameIndex}. */ +interface OwnerIndex { + ownerMethod: Map; + classParents: Map; + psOwnerMethod: Map; + psClassParents: Map; +} + export function resolveEdges( nodes: NodeV1[], rawEdges: RawEdge[], @@ -42,9 +119,14 @@ export function resolveEdges( const byId = new Map(nodes.map((n) => [n.id, n])); const globalName = new Map(); const perFileName = new Map>(); + const psGlobalName = new Map(); + const psPerFileName = new Map>(); + // every PS node path that's itself a test path (Pester spec/mock file). + const psTestPaths = new Set(); // Owner-qualified method index: "Owner.method" → candidate method nodes, for // typed member-call resolution (recvType + name → a specific class's method). const ownerMethod = new Map(); + const psOwnerMethod = new Map(); // Go package resolution: dir (posix) → its `.go` file node ids, for import mapping. const goFilesByDir = new Map(); const hasGoModules = !!opts.goModules?.length; @@ -60,8 +142,17 @@ export function resolveEdges( let fileMap = perFileName.get(n.path); if (!fileMap) perFileName.set(n.path, (fileMap = new Map())); push(fileMap, n.name, n); + if (isPsPath(n.path)) { + push(psGlobalName, n.name.toLowerCase(), n); + let psFileMap = psPerFileName.get(n.path); + if (!psFileMap) psPerFileName.set(n.path, (psFileMap = new Map())); + push(psFileMap, n.name.toLowerCase(), n); + if (isTestPath(n.path)) psTestPaths.add(n.path); + } if (n.kind === "method" && n.owner) { - push(ownerMethod, `${n.owner}.${n.name}`, n); + const key = `${n.owner}.${n.name}`; + push(ownerMethod, key, n); + if (isPsPath(n.path)) push(psOwnerMethod, key.toLowerCase(), n); } } @@ -69,16 +160,24 @@ export function resolveEdges( // `extends` edges (source id's own name → the base name). Used to walk up an // inheritance chain when a receiver's own type has no matching method. const classParents = new Map(); + const psClassParents = new Map(); for (const e of rawEdges) { if (e.relation !== "extends" || !e.name) continue; // The declaring class's own bare name — read from its node (keyed by n.name, set // once at mint time) rather than re-derived by slicing e.source, which breaks once - // ids can carry a dedup ordinal (A3's `Cache~2`). + // ids can carry a dedup ordinal (W2's `Cache~2`). const ownName = byId.get(e.source)?.name; if (!ownName) continue; push(classParents, ownName, e.name); + if (e.lang === "powershell") push(psClassParents, ownName.toLowerCase(), e.name.toLowerCase()); } + const nameIndex: NameIndex = { perFileName, globalName, psPerFileName, psGlobalName, psTestPaths }; + const ownerIndex: OwnerIndex = { ownerMethod, classParents, psOwnerMethod, psClassParents }; + // memoizes isTestPath(callerFile) across the whole rawEdges pass — many + // edges share the same originating file. + const callerIsTestCache = new Map(); + const out: EdgeV1[] = []; const seen = new Set(); const add = (source: string, target: string, relation: Relation, confidence: EdgeV1["confidence"]) => { @@ -99,8 +198,10 @@ export function resolveEdges( add(e.source, target, "imports", "extracted"); } else if (e.relation === "extends" || e.relation === "implements") { const kinds: Kind[] = e.relation === "implements" ? ["interface"] : ["class", "interface"]; - const hit = resolveName(e.name!, e.file, kinds, perFileName, globalName); + const hit = resolveName(e.name!, e.file, kinds, e.lang, nameIndex, callerIsTestCache); // an unresolved base is usually an external/imported type — keep the name. + // (a demoted-then-still-ambiguous global tier lands here too — + // node-target becomes raw-name fallback, same as any other miss. Harmless.) add(e.source, hit?.id ?? e.name!, e.relation, hit?.confidence ?? "inferred"); } else if (e.relation === "references" && e.name && e.specifier) { // A named import gives both halves needed for sound resolution: the module @@ -113,20 +214,24 @@ export function resolveEdges( } else if (e.relation === "calls") { if (e.viaMember) { if (!e.recvType) continue; - const hit = resolveTypedMember(e.recvType, e.name!, e.file, ownerMethod, classParents); + const hit = resolveTypedMember(e.recvType, e.name!, e.file, e.lang, ownerIndex); if (hit === "ambiguous") continue; // drop — never guess past an ambiguous owner if (hit) add(e.source, hit.id, "calls", hit.confidence); // No owner-qualified match means the call is unresolved. A unique bare // method name is not evidence that this receiver has that method. continue; } - const hit = resolveName(e.name!, e.file, ["function"], perFileName, globalName); + const hit = resolveName(e.name!, e.file, ["function"], e.lang, nameIndex, callerIsTestCache); if (hit) add(e.source, hit.id, "calls", hit.confidence); // drop unresolved calls (too noisy) } } return out; } +function isPsPath(path: string): boolean { + return languageOf(path) === "powershell"; +} + function push(map: Map, key: string, val: T): void { const arr = map.get(key); if (arr) arr.push(val); @@ -136,18 +241,73 @@ function push(map: Map, key: string, val: T): void { /** * Resolve a bare symbol name: same-file match first (certain → `extracted`), * else a unique cross-file match (→ `inferred`), else null (ambiguous/unknown). + * + * PS edges consult the case-folded index within the SAME file before ever + * looking cross-file: exact-local → folded-local → exact-global → folded-global. + * Non-PS edges keep the plain exact-local → exact-global order (no folded tier). + * Without this, a same-file case-insensitive match (e.g. A.ps1 calling `Foo` when + * A.ps1 itself defines `foo`) lost to an unrelated exact-cased match in another + * file (B.ps1's `Foo`) — local scope must win regardless of which tier finds it. + * + * Test-aware tie-break: Pester mocks routinely redeclare a prod name + * across several `*.Tests.ps1` files; the never-guess ambiguity rule then + * dropped EVERY cross-file call for that name (dbatools' Stop-Function: 2857 + * call sites, 0 edges — every candidate looked equally "ambiguous"). At the TWO + * GLOBAL tiers ONLY (exact-global and folded-global, symmetrically) — never the + * local tiers above, which already share the caller's own file — a PS caller + * that ISN'T ITSELF a test file demotes test-path candidates before the + * uniqueness check. Demotion breaks ties; it never removes the last candidate: + * an empty filtered set falls back to the unfiltered one, so a symbol living + * ONLY under a tests/ dir still resolves for a non-test caller. Deliberately + * NOT applied in {@link resolveTypedMember} — that function has its own, + * separate owner-qualified tie-breaking (an asymmetry, not an oversight). See + * resolveEdges' extends/implements branch for the harmless fallback-to-raw-name + * a demoted-then-still-ambiguous heritage edge takes. */ function resolveName( name: string, file: string, kinds: Kind[], - perFileName: Map>, - globalName: Map, + lang: Language | undefined, + { perFileName, globalName, psPerFileName, psGlobalName, psTestPaths }: NameIndex, + callerIsTestCache: Map, ): { id: string; confidence: EdgeV1["confidence"] } | null { - const local = (perFileName.get(file)?.get(name) ?? []).filter((n) => kinds.includes(n.kind)); + const isPs = lang === "powershell"; + const localExact = perFileName.get(file)?.get(name) ?? []; + const local = localExact.filter((n) => kinds.includes(n.kind) && (!isPs || isPsPath(n.path))); if (local.length) return { id: local[0].id, confidence: "extracted" }; - const global = (globalName.get(name) ?? []).filter((n) => kinds.includes(n.kind)); + + if (isPs) { + const folded = name.toLowerCase(); + const psLocal = (psPerFileName.get(file)?.get(folded) ?? []).filter((n) => kinds.includes(n.kind)); + if (psLocal.length) return { id: psLocal[0].id, confidence: "extracted" }; + } + + // Builtin-cmdlet denylist sits AFTER both local tiers (a file that locally + // redefines a builtin and calls it still self-links above) but BEFORE either + // global tier — like test-demotion below, it guards only global lookups. + if (isPs && PS_BUILTIN_CMDLETS.has(name.toLowerCase())) return null; + + const demoteTestCandidates = (candidates: NodeV1[]): NodeV1[] => { + if (!isPs) return candidates; + let callerIsTest = callerIsTestCache.get(file); + if (callerIsTest === undefined) { + callerIsTest = isTestPath(file); + callerIsTestCache.set(file, callerIsTest); + } + if (callerIsTest) return candidates; // a test caller's own ambiguity is untouched + const nonTest = candidates.filter((n) => !psTestPaths.has(n.path)); + return nonTest.length ? nonTest : candidates; // empty-set fallback — never drop the last one + }; + + const globalExact = globalName.get(name) ?? []; + const global = demoteTestCandidates(globalExact.filter((n) => kinds.includes(n.kind) && (!isPs || isPsPath(n.path)))); if (global.length === 1) return { id: global[0].id, confidence: "inferred" }; + if (!isPs) return null; + + const folded = name.toLowerCase(); + const psGlobal = demoteTestCandidates((psGlobalName.get(folded) ?? []).filter((n) => kinds.includes(n.kind))); + if (psGlobal.length === 1) return { id: psGlobal[0].id, confidence: "inferred" }; return null; } @@ -163,34 +323,57 @@ function resolveName( * NOT continue up the chain past this level. * - `null` — the whole chain (recvType + ancestors, breadth-first, depth ≤ 3, * cycle-guarded) had zero candidates at every level. + * + * PS edges check the same-file candidate before either index's cross-file result, + * mirroring resolveName: exact-local → folded-local → exact-global → folded-global. + * Non-PS edges keep the original exact-only lookup unchanged. */ function resolveTypedMember( recvType: string, name: string, file: string, - ownerMethod: Map, - classParents: Map, + lang: Language | undefined, + { ownerMethod, classParents, psOwnerMethod, psClassParents }: OwnerIndex, ): { id: string; confidence: EdgeV1["confidence"] } | "ambiguous" | null { const MAX_DEPTH = 3; - const visited = new Set([recvType]); + const isPs = lang === "powershell"; + const visited = new Set([isPs ? recvType.toLowerCase() : recvType]); let frontier = [recvType]; for (let depth = 0; depth <= MAX_DEPTH && frontier.length; depth++) { for (const type of frontier) { - const candidates = ownerMethod.get(`${type}.${name}`); - if (!candidates || candidates.length === 0) continue; // try next ancestor - if (candidates.length === 1) { - const c = candidates[0]; - return { id: c.id, confidence: c.path === file ? "extracted" : "inferred" }; + const key = `${type}.${name}`; + const exact = ownerMethod.get(key) ?? []; + const exactScoped = isPs ? exact.filter((c) => isPsPath(c.path)) : exact; + + if (!isPs) { + if (!exactScoped.length) continue; // try next ancestor + if (exactScoped.length === 1) { + const c = exactScoped[0]; + return { id: c.id, confidence: c.path === file ? "extracted" : "inferred" }; + } + const sameFile = exactScoped.find((c) => c.path === file); + if (sameFile) return { id: sameFile.id, confidence: "extracted" }; + return "ambiguous"; // several, none same-file — drop and stop } - const sameFile = candidates.find((c) => c.path === file); - if (sameFile) return { id: sameFile.id, confidence: "extracted" }; - return "ambiguous"; // several, none same-file — drop and stop + + const folded = psOwnerMethod.get(key.toLowerCase()) ?? []; + const exactLocal = exactScoped.filter((c) => c.path === file); + if (exactLocal.length) return { id: exactLocal[0].id, confidence: "extracted" }; + const foldedLocal = folded.filter((c) => c.path === file); + if (foldedLocal.length) return { id: foldedLocal[0].id, confidence: "extracted" }; + + const candidates = exactScoped.length ? exactScoped : folded; + if (!candidates.length) continue; // try next ancestor + if (candidates.length === 1) return { id: candidates[0].id, confidence: "inferred" }; + return "ambiguous"; // several, none same-file at this level — drop and stop } const next: string[] = []; for (const type of frontier) { - for (const parent of classParents.get(type) ?? []) { - if (visited.has(parent)) continue; - visited.add(parent); + const parents = isPs ? (psClassParents.get(type.toLowerCase()) ?? []) : (classParents.get(type) ?? []); + for (const parent of parents) { + const key = isPs ? parent.toLowerCase() : parent; + if (visited.has(key)) continue; + visited.add(key); next.push(parent); } } @@ -202,18 +385,38 @@ function resolveTypedMember( /** * Resolve a module specifier to a file node id when it points inside the repo; * otherwise return the raw specifier (external package or unresolved path). + * + * `$PSScriptRoot` is PowerShell's own-directory literal — the equivalent of + * TS's `./`. Gated on the FILE's language (via `languageOf`, not a `lang` param — + * this function isn't threaded one, and the file's own extension is the only + * signal it needs), a `$PSScriptRoot/`-prefixed specifier is rewritten to a + * relative `rel` and resolved through the same machinery below as any other + * relative specifier. BOTH failure returns below return the ORIGINAL `spec`, + * never `rel` — an unresolved rewrite must not masquerade as a repo path. + * KNOWN ACCEPTED LIMITATION: a function DEFINED in a dot-sourced file but + * EXECUTED later resolves `$PSScriptRoot` to the CALLER's directory at + * runtime; we wire it to the DEFINING file's directory instead. */ function resolveImport(spec: string, file: string, byId: Map): string { - if (!spec.startsWith(".")) return spec; + const isPs = languageOf(file) === "powershell"; + const rel = isPs ? spec.replace(/^\$psscriptroot\//i, "./") : spec; + if (!rel.startsWith(".")) return spec; // Belt-and-braces: `node.path` is posix by construction (`../util/paths.ts`), // but this also accepts a hand-written or hand-edited graph. const dir = posix.dirname(toPosixPath(file)); - const base = posix.normalize(posix.join(dir, spec)); - const noExt = base.replace(/\.(js|jsx|mjs|cjs|ts|tsx|py)$/, ""); + const base = posix.normalize(posix.join(dir, rel)); + const noExt = base.replace(/\.(js|jsx|mjs|cjs|ts|tsx|py|ps1|psm1)$/, ""); + // The specifier's own file decides which extensions are even candidates — see + // IMPORT_EXTS' doc comment for why the two lists must never mix. + const exts = isPs ? PS_IMPORT_EXTS : IMPORT_EXTS; const candidates = [ base, - ...IMPORT_EXTS.map((e) => noExt + e), - ...IMPORT_EXTS.map((e) => `${noExt}/index${e}`), + ...exts.map((e) => noExt + e), + ...exts.map((e) => `${noExt}/index${e}`), + // PowerShell's directory-module convention — `Modules/Foo` names a + // directory whose module file shares the directory's own basename + // (`Modules/Foo/Foo.psm1`), unlike JS's `index` convention. + ...(isPs ? exts.map((e) => `${noExt}/${posix.basename(noExt)}${e}`) : []), ]; for (const c of candidates) if (byId.has(c)) return c; return spec; diff --git a/src/util/testpath.ts b/src/util/testpath.ts new file mode 100644 index 000000000..6ce27eaef --- /dev/null +++ b/src/util/testpath.ts @@ -0,0 +1,20 @@ +/** + * Whether a path names a test file/directory, by convention: Go (`_test.go`), + * JS/TS (`.test.` / `.spec.`), PowerShell/Pester (`.Tests.ps1`), Python + * (`test_*.py` / `conftest.py`), and a `tests?/`, `__tests__/`, or `spec/` dir + * anywhere in the path. + * + * DUAL CONTRACT — this single classifier feeds two very different consumers, + * and an edit here must consider BOTH: + * - ask.ts (soft rank-penalty): de-ranks test files in lexical search results + * so a test doesn't out-score the real definition on a token tie. A false + * positive/negative here just nudges ranking — low stakes. + * - resolve.ts (hard graph-topology input): demotes test-path candidates + * when tie-breaking an ambiguous PS name match. A false positive here can + * make a real cross-file edge disappear; a false negative lets a Pester + * mock keep poisoning ambiguity. Higher stakes than ask's use — see + * resolve.ts's resolveName for the empty-set fallback that bounds the risk. + */ +export function isTestPath(path: string): boolean { + return /(^|\/)(tests?|__tests__|spec)\/|(_test|\.test|\.spec)\.[a-z]+$|\.tests\.ps1$|(^|\/)(test_[^/]+|conftest)\.py$/i.test(path || ""); +} From 4a16a36ebb7b5e07b5b3a7e33a7f54525fcdf190 Mon Sep 17 00:00:00 2001 From: Alex Matthews <2862661+qoole@users.noreply.github.com> Date: Sat, 1 Aug 2026 15:18:05 +0100 Subject: [PATCH 07/10] feat: ingest PowerShell source conventions, with full test coverage CODE_EXTENSIONS picks up .ps1/.psm1/.psd1; isTestPath recognizes .tests.ps1 (Pester's naming convention). New test/graph-powershell.test.ts, test/testpath.test.ts, and test/utf16-routing.test.ts cover PowerShell graph extraction/resolution end-to-end and the UTF-16LE decode path. Every existing test file touched by the case-folding, test-aware tie-break, builtin-cmdlet denylist, and ingest changes is extended with PowerShell-specific coverage alongside its existing cases. --- src/context/build.ts | 1 + src/graph/build.ts | 2 - src/graph/check.ts | 2 +- src/util/source.ts | 2 +- test/ask-index.test.ts | 2 +- test/ask.test.ts | 2 +- test/context.test.ts | 15 +- test/graph-bindings.test.ts | 81 ++++- test/graph-extract-dedup.test.ts | 108 ++++-- test/graph-go.test.ts | 2 +- test/graph-incremental.test.ts | 2 +- test/graph-map.test.ts | 8 +- test/graph-powershell.test.ts | 567 +++++++++++++++++++++++++++++++ test/graph-scopes.test.ts | 2 +- test/graph-traverse-cli.test.ts | 4 +- test/graph-traverse.test.ts | 2 +- test/graph-write.test.ts | 2 +- test/search-grep.test.ts | 2 +- test/testpath.test.ts | 18 + test/util-state.test.ts | 2 +- 20 files changed, 768 insertions(+), 58 deletions(-) create mode 100644 test/graph-powershell.test.ts create mode 100644 test/testpath.test.ts diff --git a/src/context/build.ts b/src/context/build.ts index 537a56d99..e50c96997 100644 --- a/src/context/build.ts +++ b/src/context/build.ts @@ -42,6 +42,7 @@ export const CODE_EXTENSIONS = [ ".py", ".go", ".rs", ".java", ".kt", ".scala", ".rb", ".php", ".c", ".h", ".cpp", ".hpp", ".cc", ".cs", ".swift", ".sql", ".sh", ".proto", + ".ps1", ".psm1", ".psd1", ]; /** Char budget of summary text per synthesis call (keeps each call in-context). */ diff --git a/src/graph/build.ts b/src/graph/build.ts index 3d5fbd5ad..ac548f6c5 100644 --- a/src/graph/build.ts +++ b/src/graph/build.ts @@ -202,8 +202,6 @@ export async function buildGraph( return; } if (source === null) { - // Unsupported encoding (UTF-16BE) — a skip, never an error: recorded with - // an empty entry so the freshness probe doesn't treat it as new every run. entries[rel] = { size: f.size, mtimeMs: f.mtimeMs, hash: "", nodes: [], rawEdges: [] }; return; } diff --git a/src/graph/check.ts b/src/graph/check.ts index ecd181341..f017f725c 100644 --- a/src/graph/check.ts +++ b/src/graph/check.ts @@ -71,7 +71,7 @@ export function checkGraph(dir: string, opts: GraphCheckOptions = {}): GraphChec } catch { continue; // unreadable now → its nodes show up as `removed` below } - if (source === null) continue; // unsupported encoding (e.g. UTF-16BE) + if (source === null) continue; try { const { nodes } = extractFile(relPosix(root, file), source, lang); for (const n of nodes) current.set(n.id, n.body_hash); diff --git a/src/util/source.ts b/src/util/source.ts index e24d6dde3..d9bf50542 100644 --- a/src/util/source.ts +++ b/src/util/source.ts @@ -1,6 +1,6 @@ import { readFileSync } from "node:fs"; -/** Read source text, decoding Windows tooling's common UTF-16LE output. UTF-16BE +/** Read source text, decoding PowerShell 5.1's common UTF-16LE output. UTF-16BE * is rare and unsupported by Node's built-in decoders, so callers silently skip it. */ export function readSourceFile(path: string): string | null { const bytes = readFileSync(path); diff --git a/test/ask-index.test.ts b/test/ask-index.test.ts index a1be9fa5a..e788dc672 100644 --- a/test/ask-index.test.ts +++ b/test/ask-index.test.ts @@ -221,7 +221,7 @@ test("an unparseable sidecar file falls back to live tokenization", async () => } }); -test("A3: a duplicate-named definition still gets its own ask-index doc (unique ids -> docs.length === nodes.length)", async () => { +test("W2: a duplicate-named definition still gets its own ask-index doc (unique ids -> docs.length === nodes.length)", async () => { const dir = mkdtempSync(join(tmpdir(), "graft-ask-index-dup-")); try { writeFileSync( diff --git a/test/ask.test.ts b/test/ask.test.ts index ae1f8c3e9..ec0f2af38 100644 --- a/test/ask.test.ts +++ b/test/ask.test.ts @@ -15,7 +15,7 @@ import { buildGraph } from "../src/graph/build.js"; import { ask, formatAsk, skeleton, formatSkeleton, isTestPath } from "../src/ask/ask.js"; test("isTestPath: de-ranks test files, not real source", () => { - for (const p of ["server/download_test.go", "packages/x/tests/foo.test.tsx", "a/__tests__/b.ts", "src/api.spec.ts", "pkg/foo/bar_test.go"]) + for (const p of ["server/download_test.go", "packages/x/tests/foo.test.tsx", "a/__tests__/b.ts", "src/api.spec.ts", "pkg/foo/bar_test.go", "scripts/Foo.Tests.ps1"]) assert.ok(isTestPath(p), `${p} should be a test path`); for (const p of ["server/download.go", "packages/element/src/selection.ts", "src/api.ts", "cmd/root.go"]) assert.ok(!isTestPath(p), `${p} should NOT be a test path`); diff --git a/test/context.test.ts b/test/context.test.ts index b54ca2a3a..df0eb3fc1 100644 --- a/test/context.test.ts +++ b/test/context.test.ts @@ -158,13 +158,16 @@ test("check detects a new file not yet in the graph (coverage drift)", async () } }); -// A5 — a persisted `--include-dir` override must reach the Tier-2 markdown +// V2 — a persisted `--include-dir` override must reach the Tier-2 markdown // pipeline (context/build.ts) and its freshness check (context/check.ts), not -// just the Tier-1 wiring graph (graph/build.ts, via source-files.ts). Both -// sides must agree, in both directions: build/ shows up in buildContext's -// file listing, and checkContext neither reports it removed (disagreeing -// about what "current" means) nor as new coverage. -test("A5: a persisted --include-dir override reaches context/build.ts's file listing and context/check.ts's freshness check", async () => { +// just the Tier-1 wiring graph (graph/build.ts, via source-files.ts). Before +// this fix, both modules' own `walkDir(root)` call never saw the persisted +// include list, so an included `build/` stayed invisible to buildContext AND +// checkContext alike — inconsistently with the wiring graph, which already +// read it. Both sides must agree, in both directions: build/ shows up in +// buildContext's file listing, and checkContext neither reports it removed +// (it disagreeing about what "current" means) nor as new coverage. +test("V2: a persisted --include-dir override reaches context/build.ts's file listing and context/check.ts's freshness check", async () => { const dir = mkdtempSync(join(tmpdir(), "ctxgraph-include-dir-")); try { writeFileSync( diff --git a/test/graph-bindings.test.ts b/test/graph-bindings.test.ts index 4d3a7a7ab..6f86b7c22 100644 --- a/test/graph-bindings.test.ts +++ b/test/graph-bindings.test.ts @@ -2,7 +2,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { extractFile } from "../src/graph/extract.js"; -function callEdges(src: string, lang: "python" | "typescript" | "go", file = `x.${lang === "python" ? "py" : lang === "go" ? "go" : "ts"}`) { +function callEdges(src: string, lang: "python" | "typescript" | "go" | "powershell", file = `x.${lang === "python" ? "py" : lang === "go" ? "go" : lang === "powershell" ? "ps1" : "ts"}`) { return extractFile(file, src, lang).rawEdges.filter((e) => e.relation === "calls"); } @@ -79,3 +79,82 @@ test("py: aliased annotation resolves to original name", () => { const edges = callEdges("from r import APIRouter as AR\ndef f(r: AR):\n r.get()\n", "python"); assert.equal(edges.find((e) => e.name === "get")?.recvType, "APIRouter"); }); + +test("ps: typed assignment binds case-insensitive receiver and static constructor", () => { + const edges = callEdges("[widget]$w = [WIDGET]::new()\n$w.Render()\n", "powershell"); + assert.equal(edges.find((e) => e.name === "Render")?.recvType, "widget"); + assert.equal(edges.find((e) => e.name === "new")?.recvType, "WIDGET"); +}); + +test("ps: $this binds case-insensitively to the enclosing class", () => { + const src = "class Widget { [void] Render() { $This.Helper() } [void] Helper() {} }\n"; + const edges = callEdges(src, "powershell"); + assert.equal(edges.find((e) => e.name === "Helper")?.recvType, "Widget"); +}); + +test("ps: New-Object positional and -TypeName forms bind", () => { + const src = "$a = New-Object Widget\n$b = New-Object -TypeName widget\n$a.Render()\n$b.Render()\n"; + const edges = callEdges(src, "powershell").filter((e) => e.name === "Render"); + assert.deepEqual(edges.map((e) => e.recvType), ["Widget", "widget"]); +}); + +test("ps: typed script and class-method parameters bind", () => { + const src = "param([Widget]$outer)\n$outer.Render()\nclass C { [void] Use([Widget]$inner) { $inner.Render() } }\n"; + const edges = callEdges(src, "powershell").filter((e) => e.name === "Render"); + assert.deepEqual(edges.map((e) => e.recvType), ["Widget", "Widget"]); +}); + +test("ps: typed class properties bind through $this member access", () => { + const src = "class C { [Widget]$View; [void] Go() { $THIS.View.Render() } }\n"; + const edges = callEdges(src, "powershell"); + assert.equal(edges.find((e) => e.name === "Render")?.recvType, "Widget"); +}); + +test("ps: decorated parameter's type binds despite a preceding attribute", () => { + const src = "param([Parameter(Mandatory=$true)][Widget]$w)\n$w.Render()\n"; + const edges = callEdges(src, "powershell"); + assert.equal(edges.find((e) => e.name === "Render")?.recvType, "Widget"); +}); + +test("ps: New-Object binds -TypeName by parameter, not position", () => { + const src = [ + "$a = New-Object -ArgumentList 1 -TypeName Widget", + "$b = New-Object -ArgumentList Widget", + "$a.Render()", + "$b.Render()", + "", + ].join("\n"); + const edges = callEdges(src, "powershell").filter((e) => e.name === "Render"); + assert.deepEqual( + edges.map((e) => e.recvType), + ["Widget", undefined], + "-ArgumentList's own value must never be mistaken for the type, whichever side of -TypeName it falls on", + ); +}); + +test("ps: a scope-qualified function keeps binding lookups in lockstep with extract's scope stack (Z1)", () => { + const src = "function global:Setup {\n [Widget]$w = [Widget]::new()\n $w.Render()\n}\n"; + const edges = callEdges(src, "powershell"); + assert.equal( + edges.find((e) => e.name === "Render")?.recvType, + "Widget", + "bindings.ts's defName must strip the same global: qualifier extract.ts's describePowerShell does, or the " + + "scope key the binding was stored under ('global:Setup') never matches the lookup key ('Setup')", + ); +}); + +test("ps: New-Object binds the sole positional type past a valueless switch", () => { + const src = [ + "$a = New-Object -Verbose Widget", + "$b = New-Object -TypeName Widget -Verbose", + "$a.Render()", + "$b.Render()", + "", + ].join("\n"); + const edges = callEdges(src, "powershell").filter((e) => e.name === "Render"); + assert.deepEqual( + edges.map((e) => e.recvType), + ["Widget", "Widget"], + "-Verbose takes no value, so it must not swallow the one positional type argument that follows it", + ); +}); diff --git a/test/graph-extract-dedup.test.ts b/test/graph-extract-dedup.test.ts index 96e8d5a15..b10955339 100644 --- a/test/graph-extract-dedup.test.ts +++ b/test/graph-extract-dedup.test.ts @@ -1,18 +1,18 @@ /** - * A3 — mint-time unique node ids. + * W2 — mint-time unique node ids. * * extract.ts used to mint a node's id purely from its scope + own name, so two * definitions that happen to share a name (a branch-guarded redeclaration, a - * duplicated class, ...) collided onto the SAME id — the second definition's - * node silently overwrote the first's in every id-keyed lookup. + * duplicated class, PowerShell's habit of letting later `function Foo` defs + * silently shadow earlier ones, ...) collided onto the SAME id — the second + * definition's node silently overwrote the first's in every id-keyed lookup. * - * `walk()` now mints ids against a per-file `minted` Set via the extracted - * `mintId(base, minted)` helper: the first occurrence keeps its bare id, and - * every later document-order duplicate gets the next free `~2`, `~3`, ... - * suffix — collision-proof even against a source name that itself ends in - * `~N` (which would collide with a single-guess `~2` suffix), because the - * loop keeps incrementing until it finds a truly free id rather than trusting - * one guessed candidate. + * `walk()` now mints ids against a per-file `minted` Set: the first occurrence + * keeps its bare id, and every later document-order duplicate gets the next + * free `~2`, `~3`, ... suffix — collision-proof even against a literal source + * name that already contains `~N` (see the adversarial PowerShell case below), + * because it keeps incrementing until it finds a truly free id rather than + * trusting one guessed candidate. * * These tests assert the ACTUAL minted ids (not a guessed shape) — the hard * invariants are: every id unique, the first occurrence keeps the bare id, and @@ -20,33 +20,14 @@ */ import { test } from "node:test"; import assert from "node:assert/strict"; -import { extractFile, mintId } from "../src/graph/extract.js"; +import { extractFile } from "../src/graph/extract.js"; function assertUniqueIds(nodes: { id: string }[], label: string): void { const ids = nodes.map((n) => n.id); assert.equal(new Set(ids).size, ids.length, `${label}: every node id must be unique, got ${JSON.stringify(ids)}`); } -test("A3 mintId: the while-loop finds the next truly free ordinal, not a single-guess ~2", () => { - // Pre-seeding both the bare id AND its ~2 ordinal simulates two prior mints - // (occurrences 1 and 2) already having claimed them; a THIRD occurrence of - // the same base must skip the taken ~2 and land on ~3 — proving the loop - // (not a single `~2` guess) is what finds a truly free id. - const base = "dup.ts#helper"; - const minted = new Set([base, `${base}~2`]); - const id = mintId(base, minted); - assert.equal(id, `${base}~3`, "the third occurrence must land on ~3, skipping the already-taken ~2"); - assert.ok(minted.has(`${base}~3`)); -}); - -test("A3 mintId: an unclaimed base id is returned bare (no suffix) and gets added to the set", () => { - const minted = new Set(); - const id = mintId("solo.ts#f", minted); - assert.equal(id, "solo.ts#f"); - assert.ok(minted.has("solo.ts#f")); -}); - -test("A3 ts: branch-guarded duplicate function + duplicate class w/ method mint distinct, unique ids", () => { +test("W2 ts: branch-guarded duplicate function + duplicate class w/ method mint distinct, unique ids", () => { const src = ` if (true) { function f() { return 1; } @@ -72,7 +53,7 @@ class C { ); }); -test("A3 py: duplicate def + duplicate class/method mint distinct, unique ids", () => { +test("W2 py: duplicate def + duplicate class/method mint distinct, unique ids", () => { const src = ` def f(): return 1 @@ -98,7 +79,7 @@ class C: ); }); -test("A3 go: two same-name methods on one receiver mint distinct, unique ids", () => { +test("W2 go: two same-name methods on one receiver mint distinct, unique ids", () => { const src = `package pkg type Worker struct{} @@ -112,3 +93,64 @@ func (w *Worker) Run() {} const ids = nodes.map((n) => n.id); assert.deepEqual(ids, ["dup.go", "dup.go#Worker", "dup.go#Worker.Run", "dup.go#Worker.Run~2"]); }); + +test("W2 ps1: Pester-style triple duplicate `function Stop-Function` mints three unique ids in document order", () => { + const src = `function Stop-Function { } +function Stop-Function { } +function Stop-Function { } +`; + const { nodes } = extractFile("dup.ps1", src, "powershell"); + assertUniqueIds(nodes, "ps1 triple dup function"); + + const ids = nodes.map((n) => n.id); + assert.deepEqual(ids, ["dup.ps1", "dup.ps1#Stop-Function", "dup.ps1#Stop-Function~2", "dup.ps1#Stop-Function~3"]); +}); + +test("W2 ps1: duplicate class + ctor overloads (Widget.Widget x2) all mint unique ids, resolved via the same loop across the duplicate parents", () => { + const src = `class Widget { + Widget() {} + Widget([string]$name) {} +} + +class Widget { + Widget() {} +} +`; + const { nodes } = extractFile("dup2.ps1", src, "powershell"); + assertUniqueIds(nodes, "ps1 dup class + ctor overloads"); + + const ids = nodes.map((n) => n.id); + // First Widget class + its two ctor overloads (Widget.Widget, Widget.Widget~2); + // the SECOND (duplicate) Widget class becomes Widget~2, but its child scope still + // receives the bare "Widget" idPart, so its own ctor collides with BOTH prior + // Widget.Widget mints and lands on Widget.Widget~3 — proving the loop resolves + // collisions across duplicate parents, not just duplicate siblings. + assert.deepEqual(ids, [ + "dup2.ps1", + "dup2.ps1#Widget", + "dup2.ps1#Widget.Widget", + "dup2.ps1#Widget.Widget~2", + "dup2.ps1#Widget~2", + "dup2.ps1#Widget.Widget~3", + ]); +}); + +test("W2 ps1 adversarial: a literal `function Get-Thing~2` keeps its own name-derived id untouched; the real duplicate is pushed to ~3", () => { + const src = `function Get-Thing { } +function Get-Thing~2 { } +function Get-Thing { } +`; + const { nodes } = extractFile("adv.ps1", src, "powershell"); + assertUniqueIds(nodes, "ps1 adversarial literal ~2"); + + const ids = nodes.map((n) => n.id); + assert.deepEqual(ids, ["adv.ps1", "adv.ps1#Get-Thing", "adv.ps1#Get-Thing~2", "adv.ps1#Get-Thing~3"]); + + // Span-verified: the literal `Get-Thing~2` (line 2) really is the one holding + // that id — not the third (duplicate) `Get-Thing` definition (line 3), which a + // naive single-guess `~2` scheme could have collided onto it instead. + const byId = new Map(nodes.map((n) => [n.id, n])); + assert.equal(byId.get("adv.ps1#Get-Thing")?.span, "L1-L1"); + assert.equal(byId.get("adv.ps1#Get-Thing~2")?.span, "L2-L2", "the literal Get-Thing~2 definition, untouched"); + assert.equal(byId.get("adv.ps1#Get-Thing~3")?.span, "L3-L3", "the real duplicate, pushed past the literal's id"); +}); diff --git a/test/graph-go.test.ts b/test/graph-go.test.ts index c02b565ca..01b731c9e 100644 --- a/test/graph-go.test.ts +++ b/test/graph-go.test.ts @@ -167,7 +167,7 @@ test("Go extraction: go.mod in a subdirectory resolves intra-module imports", as } }); -test("A5: a go.mod under a persisted --include-dir override is found, so imports inside it resolve internally", async () => { +test("V3: a go.mod under a persisted --include-dir override is found, so imports inside it resolve internally", async () => { // build/ is in SKIP_DIRS by default — readGoModules must honor the same // persisted include-dir override source-files.ts already reads, or a // go.mod living under an included dir is missed while its .go files (which diff --git a/test/graph-incremental.test.ts b/test/graph-incremental.test.ts index 1b63285ed..efcc96fe5 100644 --- a/test/graph-incremental.test.ts +++ b/test/graph-incremental.test.ts @@ -49,7 +49,7 @@ test("an incremental rebuild writes byte-identical wiring.json to a cold one", a assert.equal(wiringOf(d), cold, "replaying cached parses must not change a single byte of the graph"); }); -test("A3: an incremental rebuild is still byte-identical to a cold one on a duplicate-name-bearing fixture", async () => { +test("W2: an incremental rebuild is still byte-identical to a cold one on a duplicate-name-bearing fixture", async () => { const d = mkdtempSync(join(tmpdir(), "graft-incr-dup-")); mkdirSync(join(d, "src"), { recursive: true }); writeFileSync(join(d, "src", "dup.ts"), "export function helper(): void {}\nexport function helper(): void {}\n"); diff --git a/test/graph-map.test.ts b/test/graph-map.test.ts index be45d4c7c..c6ca6bc40 100644 --- a/test/graph-map.test.ts +++ b/test/graph-map.test.ts @@ -77,14 +77,16 @@ test("buildRepoMap: totals count files, symbols, edges, and languages across the symNode("src/a.ts", "fnA"), fileNode("docs/readme.py"), // deliberately mixed languages symNode("docs/readme.py", "fnB"), + fileNode("scripts/deploy.ps1"), + symNode("scripts/deploy.ps1", "Deploy"), ]; const edges = [edge("src/a.ts#fnA", "docs/readme.py#fnB")]; const map = buildRepoMap(graphOf(nodes, edges)); - assert.equal(map.totals.files, 2); - assert.equal(map.totals.symbols, 2); + assert.equal(map.totals.files, 3); + assert.equal(map.totals.symbols, 3); assert.equal(map.totals.edges, 1); - assert.deepEqual(map.totals.languages, ["python", "typescript"]); + assert.deepEqual(map.totals.languages, ["powershell", "python", "typescript"]); }); test("buildRepoMap: dirs are grouped by first path segment and sorted by symbol count desc", () => { diff --git a/test/graph-powershell.test.ts b/test/graph-powershell.test.ts new file mode 100644 index 000000000..8bbb0a5e9 --- /dev/null +++ b/test/graph-powershell.test.ts @@ -0,0 +1,567 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { buildGraph } from "../src/graph/build.js"; +import { CODE_EXTENSIONS } from "../src/context/build.js"; +import { extractFile, languageOf } from "../src/graph/extract.js"; +import { IMPORT_EXTS } from "../src/graph/resolve.js"; +import { readGraph, wiringPath } from "../src/graph/write.js"; +import type { GraphV1, NodeV1 } from "../src/graph/types.js"; + +const MAIN_PS = String.raw`. .\lib\helpers.ps1 +Import-Module .\Widget.psm1 +Import-Module Az.Accounts + +function Start-Exact { + Get-Helper +} + +function Start-WrongCase { + get-helper +} + +function Start-DomainCheck { + invoke +} +`; + +const HELPERS_PS = `function Get-Helper { + Invoke +} + +function Invoke {} +`; + +const WIDGET_PS = `class BaseWidget { + [void] BaseMethod() {} +} + +class Widget : basewidget { + [string]$Name + + Widget() {} + + [void] Render() { + $This.HELPER() + } + + [void] Helper() {} +} + +enum Color { + Red + Blue +} + +function New-Widget { + [widget]$w = [WIDGET]::new() + $w.render() + $w.basemethod() +} +`; + +function makeFixture(): string { + const dir = mkdtempSync(join(tmpdir(), "graft-powershell-")); + mkdirSync(join(dir, "lib")); + writeFileSync(join(dir, "main.ps1"), MAIN_PS); + writeFileSync(join(dir, "lib", "helpers.ps1"), HELPERS_PS); + writeFileSync(join(dir, "Widget.psm1"), WIDGET_PS); + writeFileSync(join(dir, "collision.py"), "def invoke():\n pass\n"); + return dir; +} + +function nodeById(graph: GraphV1, id: string): NodeV1 | undefined { + return graph.nodes.find((n) => n.id === id); +} + +function hasEdge(graph: GraphV1, source: string, target: string, relation: string): boolean { + return graph.edges.some((e) => e.source === source && e.target === target && e.relation === relation); +} + +test("PowerShell extraction: files, definitions, exports, calls, and imports", async () => { + const dir = makeFixture(); + try { + const result = await buildGraph(dir); + assert.ok(result.languages.includes("powershell")); + + const graph = readGraph(wiringPath(join(dir, "graft")))!; + for (const id of ["main.ps1", "lib/helpers.ps1", "Widget.psm1"]) { + assert.equal(nodeById(graph, id)?.kind, "file", `${id} should be a file node`); + } + + for (const id of [ + "main.ps1#Start-Exact", + "main.ps1#Start-WrongCase", + "lib/helpers.ps1#Get-Helper", + "lib/helpers.ps1#Invoke", + "Widget.psm1#New-Widget", + ]) { + assert.equal(nodeById(graph, id)?.kind, "function", `${id} should be a function`); + assert.equal(nodeById(graph, id)?.exported, true, `${id} should be exported`); + } + assert.equal(nodeById(graph, "Widget.psm1#Widget")?.kind, "class"); + assert.equal(nodeById(graph, "Widget.psm1#Color")?.kind, "enum"); + assert.equal(nodeById(graph, "Widget.psm1#Widget")?.exported, true); + assert.equal(nodeById(graph, "Widget.psm1#Color")?.exported, true); + assert.equal(nodeById(graph, "Widget.psm1#Widget.Widget")?.kind, "method", "constructor is a method"); + assert.equal(nodeById(graph, "Widget.psm1#Widget.Render")?.kind, "method"); + assert.equal(nodeById(graph, "Widget.psm1#Widget.Render")?.exported, true); + assert.equal(nodeById(graph, "Widget.psm1#Widget.Name"), undefined, "properties are not graph defs"); + + assert.ok(hasEdge(graph, "lib/helpers.ps1#Get-Helper", "lib/helpers.ps1#Invoke", "calls")); + assert.ok(hasEdge(graph, "main.ps1#Start-Exact", "lib/helpers.ps1#Get-Helper", "calls")); + assert.ok( + hasEdge(graph, "main.ps1#Start-WrongCase", "lib/helpers.ps1#Get-Helper", "calls"), + "wrong-case command should resolve to the PowerShell definition", + ); + assert.ok( + hasEdge(graph, "main.ps1#Start-DomainCheck", "lib/helpers.ps1#Invoke", "calls"), + "case folding must stay in the PowerShell domain, not select collision.py#invoke", + ); + assert.ok(hasEdge(graph, "Widget.psm1#New-Widget", "Widget.psm1#Widget.Render", "calls")); + assert.ok(hasEdge(graph, "Widget.psm1#Widget", "Widget.psm1#BaseWidget", "extends")); + assert.ok(hasEdge(graph, "Widget.psm1#New-Widget", "Widget.psm1#BaseWidget.BaseMethod", "calls")); + assert.ok(hasEdge(graph, "Widget.psm1#Widget.Render", "Widget.psm1#Widget.Helper", "calls")); + + assert.ok(hasEdge(graph, "main.ps1", "lib/helpers.ps1", "imports")); + assert.ok(hasEdge(graph, "main.ps1", "Widget.psm1", "imports")); + assert.ok(hasEdge(graph, "main.ps1", "Az.Accounts", "imports")); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("PowerShell command imports, qualifiers, and dynamic invocation", () => { + const src = String.raw`. '.\lib\helpers.ps1' +Import-Module -Name .\Widget.psm1 +using module .\Extra.psm1 +using namespace System.Text +script:Get-Helper +Tools\Get-Helper +& $dynamic +`; + const edges = extractFile("main.ps1", src, "powershell").rawEdges; + assert.deepEqual( + edges.filter((e) => e.relation === "imports").map((e) => e.specifier), + ["./lib/helpers.ps1", "./Widget.psm1", "./Extra.psm1"], + ); + assert.deepEqual( + edges.filter((e) => e.relation === "calls").map((e) => e.name), + ["Get-Helper", "Get-Helper"], + ); + assert.ok(edges.every((e) => e.lang === "powershell"), "every PowerShell raw edge carries its language"); +}); + +test("PowerShell nested functions are local while top-level and class defs are exported", () => { + const { nodes } = extractFile( + "scope.ps1", + "function Outer { function Inner {} }\nfilter Pick {}\nworkflow Deploy {}\nclass C { [void] M() {} }\n", + "powershell", + ); + const byId = (id: string) => nodes.find((n) => n.id === id); + assert.equal(byId("scope.ps1#Outer")?.exported, true); + assert.equal(byId("scope.ps1#Outer.Inner")?.exported, false); + assert.equal(byId("scope.ps1#Pick")?.kind, "function"); + assert.equal(byId("scope.ps1#Deploy")?.kind, "function"); + assert.equal(byId("scope.ps1#C")?.exported, true); + assert.equal(byId("scope.ps1#C.M")?.exported, true); +}); + +test("PowerShell malformed Export-ModuleMember input keeps recoverable definitions", async () => { + const dir = mkdtempSync(join(tmpdir(), "graft-powershell-malformed-")); + try { + writeFileSync( + join(dir, "Broken.psm1"), + "function Get-A {}\nfunction Get-B {}\nExport-ModuleMember -Function Get-A, Get-B\n", + ); + const result = await buildGraph(dir); + assert.deepEqual(result.errors, []); + const graph = readGraph(wiringPath(join(dir, "graft")))!; + assert.equal(nodeById(graph, "Broken.psm1")?.kind, "file"); + assert.equal(nodeById(graph, "Broken.psm1#Get-A")?.kind, "function"); + assert.equal(nodeById(graph, "Broken.psm1#Get-B")?.kind, "function"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("PowerShell UTF-16LE source is decoded at graph ingest", async () => { + const dir = mkdtempSync(join(tmpdir(), "graft-powershell-utf16-")); + try { + const body = Buffer.from("function From-Utf16 {}\n", "utf16le"); + writeFileSync(join(dir, "legacy.ps1"), Buffer.concat([Buffer.from([0xff, 0xfe]), body])); + await buildGraph(dir); + const graph = readGraph(wiringPath(join(dir, "graft")))!; + assert.equal(nodeById(graph, "legacy.ps1#From-Utf16")?.kind, "function"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("PowerShell handles CRLF, chunked >32KB input, and parser language switching", () => { + const crlf = extractFile("crlf.ps1", "function From-Crlf {\r\n Get-Thing\r\n}\r\n", "powershell"); + assert.equal(crlf.nodes.find((n) => n.name === "From-Crlf")?.kind, "function"); + + const padding = Array.from({ length: 2400 }, (_, i) => `# padding ${i}`).join("\n"); + const largeSource = `${padding}\nfunction After-Boundary { Get-Thing }\n`; + assert.ok(largeSource.length > 32_768); + assert.equal( + extractFile("large.ps1", largeSource, "powershell").nodes.find((n) => n.name === "After-Boundary")?.kind, + "function", + ); + + assert.equal(extractFile("a.ts", "export function tsFn() {}", "typescript").nodes.at(-1)?.name, "tsFn"); + assert.equal(extractFile("b.ps1", "function PsFn {}", "powershell").nodes.at(-1)?.name, "PsFn"); + assert.equal(extractFile("c.py", "def py_fn():\n pass\n", "python").nodes.at(-1)?.name, "py_fn"); +}); + +test("PowerShell extension policy is consistent across graph and context tiers", () => { + assert.equal(languageOf("x.ps1"), "powershell"); + assert.equal(languageOf("x.PSM1"), "powershell"); + assert.equal(languageOf("x.psd1"), null); + assert.ok(CODE_EXTENSIONS.includes(".ps1")); + assert.ok(CODE_EXTENSIONS.includes(".psm1")); + assert.ok(CODE_EXTENSIONS.includes(".psd1")); + assert.ok(!IMPORT_EXTS.includes(".psd1")); +}); + +test("PowerShell function nested inside a class method body stays scope-local", () => { + const { nodes } = extractFile( + "nested-method.ps1", + "class C { [void] M() { function Inner {} } }\n", + "powershell", + ); + const byId = (id: string) => nodes.find((n) => n.id === id); + assert.equal(byId("nested-method.ps1#C.M")?.exported, true, "the method itself is still public"); + assert.equal(byId("nested-method.ps1#C.M.Inner")?.exported, false, "a function nested in a method body is local"); +}); + +test("PowerShell dot-source of a script block or subexpression drops the junk specifier, not the whole edge set", () => { + const src = String.raw`. { Get-Thing } +. (Join-Path $a b) +. ./helpers.ps1 +. "quoted path.ps1" +. "(helpers).ps1" +`; + const edges = extractFile("dotblock.ps1", src, "powershell").rawEdges; + assert.deepEqual( + edges.filter((e) => e.relation === "imports").map((e) => e.specifier), + ["./helpers.ps1", "quoted path.ps1", "(helpers).ps1"], + "a dot-sourced block/subexpression must not emit its raw source text as an import specifier, but a quoted " + + "path is classified by its string_literal node type, not by text starting with `(` after quote-stripping", + ); + assert.deepEqual( + edges.filter((e) => e.relation === "calls").map((e) => e.name), + ["Get-Thing", "Join-Path"], + "content textually inside a dot-sourced block is still walked for its own calls", + ); +}); + +test("PowerShell call operator on a static bareword yields a call edge; a variable target still doesn't", () => { + const edges = extractFile("amp.ps1", "& Get-Helper\n& $dynamic\n", "powershell").rawEdges; + assert.deepEqual( + edges.filter((e) => e.relation === "calls").map((e) => e.name), + ["Get-Helper"], + ); +}); + +test("PowerShell call operator with a variable-rooted path command name is dynamic, not a call edge", () => { + const edges = extractFile( + "amp2.ps1", + String.raw`& Get-Helper +& script:Get-Helper +& $dir\Get-Helper +`, + "powershell", + ).rawEdges; + assert.deepEqual( + edges.filter((e) => e.relation === "calls").map((e) => e.name), + ["Get-Helper", "Get-Helper"], + "a fully-static bareword or qualified name is a call edge; a $variable\\path is dynamic content, not `Get-Helper`", + ); +}); + +test("PowerShell name resolution prefers a same-file case-fold over a cross-file exact match", async () => { + const dir = mkdtempSync(join(tmpdir(), "graft-powershell-scope-")); + try { + writeFileSync(join(dir, "a.ps1"), "function foo {}\nfunction Caller {\n Foo\n}\n"); + writeFileSync(join(dir, "b.ps1"), "function Foo {}\n"); + await buildGraph(dir); + const graph = readGraph(wiringPath(join(dir, "graft")))!; + assert.ok( + hasEdge(graph, "a.ps1#Caller", "a.ps1#foo", "calls"), + "the local case-folded definition must win over a same-name exact match in another file", + ); + assert.ok(!hasEdge(graph, "a.ps1#Caller", "b.ps1#Foo", "calls")); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("PowerShell typed-member call resolution prefers the local class over a cross-file exact-case match", async () => { + const dir = mkdtempSync(join(tmpdir(), "graft-powershell-typedscope-")); + try { + writeFileSync( + join(dir, "a.ps1"), + "class widget {\n [void] M() {}\n}\nfunction Use-Widget {\n [widget]$w = [widget]::new()\n $w.M()\n}\n", + ); + writeFileSync(join(dir, "b.ps1"), "class Widget {\n [void] M() {}\n}\n"); + await buildGraph(dir); + const graph = readGraph(wiringPath(join(dir, "graft")))!; + assert.ok( + hasEdge(graph, "a.ps1#Use-Widget", "a.ps1#widget.M", "calls"), + "a typed member call must resolve to the same-file class, mirroring resolveName's local-scope-wins rule", + ); + assert.ok(!hasEdge(graph, "a.ps1#Use-Widget", "b.ps1#Widget.M", "calls")); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("PowerShell scope-qualified definition name is stripped so a call can link (defect Z1)", async () => { + const dir = mkdtempSync(join(tmpdir(), "graft-powershell-scopequal-")); + try { + writeFileSync( + join(dir, "profile.ps1"), + "function global:Invoke-Thing {}\nfunction Caller {\n Invoke-Thing\n}\n", + ); + await buildGraph(dir); + const graph = readGraph(wiringPath(join(dir, "graft")))!; + assert.equal( + nodeById(graph, "profile.ps1#Invoke-Thing")?.name, + "Invoke-Thing", + "the node's id/name must have the global: qualifier stripped", + ); + assert.ok( + hasEdge(graph, "profile.ps1#Caller", "profile.ps1#Invoke-Thing", "calls"), + "a call site (already qualifier-stripped) must link to the qualifier-stripped def", + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("PowerShell Import-Module specifier is parameter-aware, not 'first non-parameter element' (defect Z2)", () => { + const noName = extractFile( + "z2a.ps1", + "$outputFile | Import-Module -Force -ErrorAction stop\n", + "powershell", + ).rawEdges.filter((e) => e.relation === "imports"); + assert.deepEqual( + noName, + [], + "-ErrorAction's own value ('stop') must never be mistaken for the module specifier", + ); + + const named = extractFile("z2b.ps1", "Import-Module -Name ./Mod.psm1\n", "powershell").rawEdges.filter( + (e) => e.relation === "imports", + ); + assert.deepEqual(named.map((e) => e.specifier), ["./Mod.psm1"]); + + const positional = extractFile("z2c.ps1", "Import-Module ./Mod.psm1 -Force\n", "powershell").rawEdges.filter( + (e) => e.relation === "imports", + ); + assert.deepEqual( + positional.map((e) => e.specifier), + ["./Mod.psm1"], + "a positional specifier before a trailing switch parameter still works", + ); +}); + +test("PowerShell test-aware tie-break: Pester mocks no longer poison ambiguity for a prod symbol (Z4a)", async () => { + const dir = mkdtempSync(join(tmpdir(), "graft-powershell-testtie-")); + try { + writeFileSync(join(dir, "a.ps1"), "function Stop-Function {}\n"); + writeFileSync(join(dir, "Mock1.Tests.ps1"), "function Stop-Function {}\n"); + writeFileSync(join(dir, "Mock2.Tests.ps1"), "function Stop-Function {}\n"); + writeFileSync(join(dir, "b.ps1"), "function Caller {\n Stop-Function\n}\n"); + await buildGraph(dir); + const graph = readGraph(wiringPath(join(dir, "graft")))!; + assert.ok( + hasEdge(graph, "b.ps1#Caller", "a.ps1#Stop-Function", "calls"), + "a prod caller must link the prod def even though two Pester mocks redeclare the same name (today: dropped as ambiguous)", + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("PowerShell test-aware tie-break leaves the LOCAL tier untouched — a test file's own mock still wins locally (Z4b)", async () => { + const dir = mkdtempSync(join(tmpdir(), "graft-powershell-testtie-local-")); + try { + writeFileSync(join(dir, "a.ps1"), "function Stop-Function {}\n"); + writeFileSync( + join(dir, "Mock1.Tests.ps1"), + "function Stop-Function {}\nfunction Test-It {\n Stop-Function\n}\n", + ); + await buildGraph(dir); + const graph = readGraph(wiringPath(join(dir, "graft")))!; + assert.ok( + hasEdge(graph, "Mock1.Tests.ps1#Test-It", "Mock1.Tests.ps1#Stop-Function", "calls"), + "the same-file local mock must still win — demotion only ever touches the two global tiers", + ); + assert.ok(!hasEdge(graph, "Mock1.Tests.ps1#Test-It", "a.ps1#Stop-Function", "calls")); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("PowerShell test-aware tie-break regression guard: a symbol living only under tests/ still resolves for a non-test caller (Z4c)", async () => { + const dir = mkdtempSync(join(tmpdir(), "graft-powershell-testtie-emptyset-")); + try { + mkdirSync(join(dir, "tests")); + writeFileSync(join(dir, "tests", "Helpers.psm1"), "function Get-Fixture {}\n"); + writeFileSync(join(dir, "build.ps1"), "function Build {\n Get-Fixture\n}\n"); + await buildGraph(dir); + const graph = readGraph(wiringPath(join(dir, "graft")))!; + assert.ok( + hasEdge(graph, "build.ps1#Build", "tests/Helpers.psm1#Get-Fixture", "calls"), + "empty-set fallback: demotion must never remove the last remaining candidate", + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("PowerShell builtin cmdlet denylist: a Pester mock never absorbs a prod call to a core cmdlet (Z5)", async () => { + const dir = mkdtempSync(join(tmpdir(), "graft-powershell-builtin-")); + try { + writeFileSync(join(dir, "Mock.Tests.ps1"), "function Get-ChildItem {}\n"); + writeFileSync(join(dir, "prod.ps1"), "function List-Things {\n Get-ChildItem\n}\n"); + await buildGraph(dir); + const graph = readGraph(wiringPath(join(dir, "graft")))!; + assert.ok( + !hasEdge(graph, "prod.ps1#List-Things", "Mock.Tests.ps1#Get-ChildItem", "calls"), + "a core cmdlet name must never wire to an in-repo mock def, even the sole one in the repo", + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("PowerShell builtin cmdlet denylist only guards the global tiers — a same-file redefinition still self-links (Z5)", async () => { + const dir = mkdtempSync(join(tmpdir(), "graft-powershell-builtin-local-")); + try { + writeFileSync( + join(dir, "wrapper.ps1"), + "function Get-ChildItem {\n Write-Host 'shadowed'\n}\nfunction Use-It {\n Get-ChildItem\n}\n", + ); + await buildGraph(dir); + const graph = readGraph(wiringPath(join(dir, "graft")))!; + assert.ok( + hasEdge(graph, "wrapper.ps1#Use-It", "wrapper.ps1#Get-ChildItem", "calls"), + "a file that locally redefines a builtin cmdlet name must still self-link — the denylist only guards the global tiers", + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("PowerShell builtin cmdlet denylist does not affect a non-cmdlet name like Stop-Function (Z5)", async () => { + const dir = mkdtempSync(join(tmpdir(), "graft-powershell-builtin-notaffected-")); + try { + writeFileSync(join(dir, "a.ps1"), "function Stop-Function {}\n"); + writeFileSync(join(dir, "b.ps1"), "function Caller {\n Stop-Function\n}\n"); + await buildGraph(dir); + const graph = readGraph(wiringPath(join(dir, "graft")))!; + assert.ok( + hasEdge(graph, "b.ps1#Caller", "a.ps1#Stop-Function", "calls"), + "Stop-Function is not a builtin cmdlet and must resolve normally", + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("PowerShell unquoted forward-slash dot-source ($PSScriptRoot/Utils.ps1) emits an edge; a bare member access doesn't (Z3b)", () => { + const scriptRootEdges = extractFile( + "z3b-a.ps1", + ". $PSScriptRoot/Utils.ps1\n", + "powershell", + ).rawEdges.filter((e) => e.relation === "imports"); + assert.deepEqual( + scriptRootEdges.map((e) => e.specifier), + ["$PSScriptRoot/Utils.ps1"], + "the unquoted POSIX-separator form must emit the same raw-variable-path specifier the backslash twin already produces", + ); + + const objMethodEdges = extractFile("z3b-b.ps1", ". $obj.Method\n", "powershell").rawEdges.filter( + (e) => e.relation === "imports", + ); + assert.deepEqual(objMethodEdges, [], "a bare $obj.Method member access (no slash) must stay rejected"); +}); + +test("PowerShell $PSScriptRoot literal resolves relative to the defining file's directory (Z3, Z3c)", async () => { + const dir = mkdtempSync(join(tmpdir(), "graft-powershell-scriptroot-")); + try { + mkdirSync(join(dir, "sub")); + mkdirSync(join(dir, "Common")); + mkdirSync(join(dir, "sub", "Modules", "Foo"), { recursive: true }); + writeFileSync(join(dir, "sub", "Utils.ps1"), "function Get-Util {}\n"); + writeFileSync(join(dir, "Common", "Utils.ps1"), "function Get-Common {}\n"); + writeFileSync(join(dir, "sub", "Mod.psm1"), "function Get-Mod {}\n"); + writeFileSync(join(dir, "sub", "Modules", "Foo", "Foo.psm1"), "function Get-Foo {}\n"); + writeFileSync( + join(dir, "sub", "main.ps1"), + String.raw`. "$PSScriptRoot\Utils.ps1" +. "$PSScriptRoot\..\Common\Utils.ps1" +Import-Module "$PSScriptRoot\Mod.psm1" +Import-Module "$PSScriptRoot\Modules\Foo" +. "$PSScriptRoot\Missing.ps1" +`, + ); + await buildGraph(dir); + const graph = readGraph(wiringPath(join(dir, "graft")))!; + assert.ok( + hasEdge(graph, "sub/main.ps1", "sub/Utils.ps1", "imports"), + "a sibling $PSScriptRoot dot-source resolves", + ); + assert.ok( + hasEdge(graph, "sub/main.ps1", "Common/Utils.ps1", "imports"), + "$PSScriptRoot\\..\\ traversal resolves across directories", + ); + assert.ok( + hasEdge(graph, "sub/main.ps1", "sub/Mod.psm1", "imports"), + "Import-Module $PSScriptRoot resolves", + ); + assert.ok( + hasEdge(graph, "sub/main.ps1", "sub/Modules/Foo/Foo.psm1", "imports"), + "Z3c: the PS directory-module convention (Modules/Foo -> Modules/Foo/Foo.psm1) is tried for extensionless PS specifiers", + ); + assert.ok( + hasEdge(graph, "sub/main.ps1", "$PSScriptRoot/Missing.ps1", "imports"), + "an unresolvable $PSScriptRoot specifier keeps the ORIGINAL raw text, not a mangled ./ rewrite", + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("extensionless import resolution is language-aware: PS prefers its own module files, TS never falls into them", async () => { + const dir = mkdtempSync(join(tmpdir(), "graft-powershell-importext-")); + try { + writeFileSync(join(dir, "Widget.psm1"), "function Get-Widget {}\n"); + writeFileSync(join(dir, "Widget.ts"), "export function getWidget() {}\n"); + writeFileSync(join(dir, "OnlyPs.psm1"), "function Get-OnlyPs {}\n"); + writeFileSync(join(dir, "main.ps1"), "Import-Module ./Widget\n"); + writeFileSync(join(dir, "main.ts"), 'import "./Widget";\nimport "./OnlyPs";\n'); + await buildGraph(dir); + const graph = readGraph(wiringPath(join(dir, "graft")))!; + assert.ok( + hasEdge(graph, "main.ps1", "Widget.psm1", "imports"), + "PS Import-Module ./Widget must resolve to the .psm1 sibling, not the same-named .ts", + ); + assert.ok( + hasEdge(graph, "main.ts", "Widget.ts", "imports"), + "TS import ./Widget must resolve to the .ts sibling", + ); + assert.ok( + hasEdge(graph, "main.ts", "./OnlyPs", "imports"), + "TS import of a PS-only module must stay unresolved (raw specifier), not fall into the .psm1", + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/test/graph-scopes.test.ts b/test/graph-scopes.test.ts index 8dd229a5b..33d27458e 100644 --- a/test/graph-scopes.test.ts +++ b/test/graph-scopes.test.ts @@ -171,7 +171,7 @@ test("discoverWorkspaceChildren finds immediate git children only", () => { rmSync(d, { recursive: true, force: true }); }); -test("A5: discoverScopes finds a marker under a SKIP_DIRS name once persisted via --include-dir state, absent otherwise", () => { +test("W4: discoverScopes finds a marker under a SKIP_DIRS name once persisted via --include-dir state, absent otherwise", () => { const d = fx({ "build/package.json": "{}" }); assert.deepEqual(discoverScopes(d), [{ prefix: "", label: "", markers: [] }], "build/ is skipped by default — no scope found"); diff --git a/test/graph-traverse-cli.test.ts b/test/graph-traverse-cli.test.ts index 8d0122489..a5ccb4a00 100644 --- a/test/graph-traverse-cli.test.ts +++ b/test/graph-traverse-cli.test.ts @@ -121,7 +121,7 @@ function ambiguousRepo(): string { return d; } -test('A6: an ambiguous name (2 definitions) states the candidate count in the zero-hit note', () => { +test('W6: an ambiguous name (2 definitions) states the candidate count in the zero-hit note', () => { const d = ambiguousRepo(); const r = runCli(['callers', 'shared', d]); assert.equal(r.status, 0); @@ -132,7 +132,7 @@ test('A6: an ambiguous name (2 definitions) states the candidate count in the ze assert.match(r.stdout, /dropped rather than guessed/); }); -test('A6 --json: the ambiguous-name note includes the candidate count', () => { +test('W6 --json: the ambiguous-name note includes the candidate count', () => { const d = ambiguousRepo(); const r = runCli(['callers', 'shared', d, '--json']); assert.equal(r.status, 0); diff --git a/test/graph-traverse.test.ts b/test/graph-traverse.test.ts index ebd6c11d8..7beefcc77 100644 --- a/test/graph-traverse.test.ts +++ b/test/graph-traverse.test.ts @@ -156,7 +156,7 @@ test("resolveSymbol: unknown symbol returns empty array", () => { assert.deepEqual(resolveSymbol(g, "NoSuchSymbol"), []); }); -test("resolveSymbol: qualified query matches both the bare and ordinal-suffixed ids of a duplicated definition (A3)", () => { +test("resolveSymbol: qualified query matches both the bare and ordinal-suffixed ids of a duplicated definition (V1)", () => { // extract.ts mints `~2`, `~3`, ... on a document-order duplicate definition // (same name reopened) rather than shadowing the first — see extract.ts's // mint-time uniqueness comment. Before this fix, a qualified query like diff --git a/test/graph-write.test.ts b/test/graph-write.test.ts index d20208898..1bcda5cbc 100644 --- a/test/graph-write.test.ts +++ b/test/graph-write.test.ts @@ -143,7 +143,7 @@ test("buildGraph: the serialized wiring.json has no body_text key on ANY node", } }); -test("A3 PERMANENT gate: duplicate-named definitions still produce unique node ids in the written wiring.json", async () => { +test("W2 PERMANENT gate: duplicate-named definitions still produce unique node ids in the written wiring.json", async () => { const dir = mkdtempSync(join(tmpdir(), "graft-write-dup-")); try { writeFileSync( diff --git a/test/search-grep.test.ts b/test/search-grep.test.ts index f035cc91e..230cd6ac3 100644 --- a/test/search-grep.test.ts +++ b/test/search-grep.test.ts @@ -161,7 +161,7 @@ test('grepGraph: a hit outside every symbol span groups as file-level (symbol: n assert.match(moduleLevel!.hits[0].text, /NEEDLE at module level/); }); -test('A3: a duplicate-named definition displays its minted ordinal in the grouped symbol name', () => { +test('W2: a duplicate-named definition displays its minted ordinal in the grouped symbol name', () => { const d = mkdtempSync(join(tmpdir(), 'graft-grep-dup-')); mkdirSync(join(d, 'src'), { recursive: true }); writeFileSync( diff --git a/test/testpath.test.ts b/test/testpath.test.ts new file mode 100644 index 000000000..1b598c7cc --- /dev/null +++ b/test/testpath.test.ts @@ -0,0 +1,18 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { isTestPath } from "../src/util/testpath.js"; + +// Pinning table for the path-classification rules Z4's resolveName demotion relies +// on (a hard graph-topology input, not just ask's soft rank-penalty) — locks down +// today's exact behavior, including a documented known under-fit, so a future edit +// to the regex can't silently change graph resolution without a test catching it. +test("isTestPath pinning table: PowerShell test-dir/suffix conventions vs a known under-fit", () => { + assert.equal(isTestPath("tests/x.Tests.ps1"), true, "a tests/ directory is a test path"); + assert.equal(isTestPath("Tests/Unit/y.Tests.ps1"), true, "case-insensitive, and nested under the test dir"); + assert.equal( + isTestPath("tst/z.ts.ps1"), + false, + "known under-fit: the abbreviated 'tst' directory name isn't recognized", + ); + assert.equal(isTestPath("functions/Stop-Function.ps1"), false, "a plain prod path is never a test path"); +}); diff --git a/test/util-state.test.ts b/test/util-state.test.ts index 6f3a2c7cf..f4c24b09b 100644 --- a/test/util-state.test.ts +++ b/test/util-state.test.ts @@ -1,5 +1,5 @@ /** - * A5 — persisted --include-dir override. + * W4 — persisted --include-dir override. * * `graft build --include-dir ` needs a per-repo, persisted record of * which SKIP_DIRS names to include, so a LATER no-flag build (or the hooks/ From 7d5bea5a116f4815af66fa04cfb4a0ee3a0bd71e Mon Sep 17 00:00:00 2001 From: Alex Matthews <2862661+qoole@users.noreply.github.com> Date: Sat, 1 Aug 2026 15:18:19 +0100 Subject: [PATCH 08/10] docs: CHANGELOG entry for PowerShell support --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f755f968..9bab0bcad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ ## Unreleased +### Added + +- **PowerShell code graphs.** `.ps1` and `.psm1` files now contribute functions, + classes, methods, enums, calls, imports, and case-insensitive receiver bindings. + Dot-sourcing, `Import-Module`, and `using module` resolve local modules, while + UTF-16LE source from Windows PowerShell is decoded during graph ingestion. + ### Changed - **Bump the tree-sitter runtime to 0.25, and the Python and Go grammars to match.** From cd4934bb9c2895010bee8bb0e2be16b73de7225e Mon Sep 17 00:00:00 2001 From: Alex Matthews <2862661+qoole@users.noreply.github.com> Date: Thu, 6 Aug 2026 21:15:43 +0100 Subject: [PATCH 09/10] test: specify PS method-owner domain isolation --- test/graph-resolve-typed.test.ts | 45 ++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/test/graph-resolve-typed.test.ts b/test/graph-resolve-typed.test.ts index f7ad9dfa6..cb7dd9410 100644 --- a/test/graph-resolve-typed.test.ts +++ b/test/graph-resolve-typed.test.ts @@ -33,6 +33,51 @@ test("typed member call resolves despite global name ambiguity", () => { assert.equal(call?.confidence, "inferred"); }); +test("non-PowerShell typed member call cannot resolve to a PowerShell method owner", () => { + const nodes = [ + n("widget.ps1", "file"), + n("widget.ps1#Widget", "class"), + n("widget.ps1#Widget.run", "method"), + n("use.py", "file"), + n("use.py#use", "function"), + ]; + const edges = resolveEdges(nodes, [ + { source: "use.py#use", relation: "calls", name: "run", viaMember: true, recvType: "Widget", file: "use.py" }, + ]); + assert.equal(edges.filter((e) => e.relation === "calls").length, 0); +}); + +test("PowerShell typed member call still resolves an exact-case method owner", () => { + const nodes = [ + n("widget.ps1", "file"), + n("widget.ps1#Widget", "class"), + n("widget.ps1#Widget.Run", "method"), + n("use.ps1", "file"), + n("use.ps1#Use-Widget", "function"), + ]; + const edges = resolveEdges(nodes, [ + { source: "use.ps1#Use-Widget", relation: "calls", name: "Run", viaMember: true, recvType: "Widget", file: "use.ps1", lang: "powershell" }, + ]); + assert.equal(edges.find((e) => e.relation === "calls")?.target, "widget.ps1#Widget.Run"); +}); + +test("PowerShell extends chain resolves a parent method case-insensitively", () => { + const nodes = [ + n("base.ps1", "file"), + n("base.ps1#BaseWidget", "class"), + n("base.ps1#BaseWidget.Run", "method"), + n("child.ps1", "file"), + n("child.ps1#ChildWidget", "class"), + n("use.ps1", "file"), + n("use.ps1#Use-Widget", "function"), + ]; + const edges = resolveEdges(nodes, [ + { source: "child.ps1#ChildWidget", relation: "extends", name: "BASEWIDGET", file: "child.ps1", lang: "powershell" }, + { source: "use.ps1#Use-Widget", relation: "calls", name: "run", viaMember: true, recvType: "CHILDWIDGET", file: "use.ps1", lang: "powershell" }, + ]); + assert.equal(edges.find((e) => e.relation === "calls")?.target, "base.ps1#BaseWidget.Run"); +}); + test("untyped ambiguous member call still drops (regression guard)", () => { const edges = resolveEdges(NODES, [ { source: "t.py#test_x", relation: "calls", name: "include_router", viaMember: true, file: "t.py" }, From 709d702a7390d8c0db37b6cbf8bdb68e38c171c9 Mon Sep 17 00:00:00 2001 From: Alex Matthews <2862661+qoole@users.noreply.github.com> Date: Thu, 6 Aug 2026 21:15:43 +0100 Subject: [PATCH 10/10] fix: keep PowerShell methods out of the shared member-resolution indexes --- src/graph/resolve.ts | 44 +++++++++++++++++++++++++++----------------- 1 file changed, 27 insertions(+), 17 deletions(-) diff --git a/src/graph/resolve.ts b/src/graph/resolve.ts index d0757c766..4fe9dbd16 100644 --- a/src/graph/resolve.ts +++ b/src/graph/resolve.ts @@ -101,12 +101,15 @@ interface NameIndex { } /** Bundled owner/heritage indexes for {@link resolveTypedMember}: the - * owner-qualified method index (`"Owner.method"` → candidates) and the - * extends-chain index (class name → declared base names), each exact and PS - * case-folded. Same swap-risk rationale as {@link NameIndex}. */ + * owner-qualified method indexes (`"Owner.method"` → candidates) and the + * extends-chain indexes (class name → declared base names), domain-isolated + * between non-PS exact and PS exact/case-folded lookups. Same swap-risk + * rationale as {@link NameIndex}. */ interface OwnerIndex { ownerMethod: Map; classParents: Map; + /** PowerShell-only exact-case owner-qualified method candidates. */ + psOwnerMethodExact: Map; psOwnerMethod: Map; psClassParents: Map; } @@ -126,6 +129,7 @@ export function resolveEdges( // Owner-qualified method index: "Owner.method" → candidate method nodes, for // typed member-call resolution (recvType + name → a specific class's method). const ownerMethod = new Map(); + const psOwnerMethodExact = new Map(); const psOwnerMethod = new Map(); // Go package resolution: dir (posix) → its `.go` file node ids, for import mapping. const goFilesByDir = new Map(); @@ -151,8 +155,12 @@ export function resolveEdges( } if (n.kind === "method" && n.owner) { const key = `${n.owner}.${n.name}`; - push(ownerMethod, key, n); - if (isPsPath(n.path)) push(psOwnerMethod, key.toLowerCase(), n); + if (isPsPath(n.path)) { + push(psOwnerMethodExact, key, n); + push(psOwnerMethod, key.toLowerCase(), n); + } else { + push(ownerMethod, key, n); + } } } @@ -168,12 +176,15 @@ export function resolveEdges( // ids can carry a dedup ordinal (W2's `Cache~2`). const ownName = byId.get(e.source)?.name; if (!ownName) continue; - push(classParents, ownName, e.name); - if (e.lang === "powershell") push(psClassParents, ownName.toLowerCase(), e.name.toLowerCase()); + if (e.lang === "powershell") { + push(psClassParents, ownName.toLowerCase(), e.name.toLowerCase()); + } else { + push(classParents, ownName, e.name); + } } const nameIndex: NameIndex = { perFileName, globalName, psPerFileName, psGlobalName, psTestPaths }; - const ownerIndex: OwnerIndex = { ownerMethod, classParents, psOwnerMethod, psClassParents }; + const ownerIndex: OwnerIndex = { ownerMethod, classParents, psOwnerMethodExact, psOwnerMethod, psClassParents }; // memoizes isTestPath(callerFile) across the whole rawEdges pass — many // edges share the same originating file. const callerIsTestCache = new Map(); @@ -333,7 +344,7 @@ function resolveTypedMember( name: string, file: string, lang: Language | undefined, - { ownerMethod, classParents, psOwnerMethod, psClassParents }: OwnerIndex, + { ownerMethod, classParents, psOwnerMethodExact, psOwnerMethod, psClassParents }: OwnerIndex, ): { id: string; confidence: EdgeV1["confidence"] } | "ambiguous" | null { const MAX_DEPTH = 3; const isPs = lang === "powershell"; @@ -342,27 +353,26 @@ function resolveTypedMember( for (let depth = 0; depth <= MAX_DEPTH && frontier.length; depth++) { for (const type of frontier) { const key = `${type}.${name}`; - const exact = ownerMethod.get(key) ?? []; - const exactScoped = isPs ? exact.filter((c) => isPsPath(c.path)) : exact; + const exact = (isPs ? psOwnerMethodExact : ownerMethod).get(key) ?? []; if (!isPs) { - if (!exactScoped.length) continue; // try next ancestor - if (exactScoped.length === 1) { - const c = exactScoped[0]; + if (!exact.length) continue; // try next ancestor + if (exact.length === 1) { + const c = exact[0]; return { id: c.id, confidence: c.path === file ? "extracted" : "inferred" }; } - const sameFile = exactScoped.find((c) => c.path === file); + const sameFile = exact.find((c) => c.path === file); if (sameFile) return { id: sameFile.id, confidence: "extracted" }; return "ambiguous"; // several, none same-file — drop and stop } const folded = psOwnerMethod.get(key.toLowerCase()) ?? []; - const exactLocal = exactScoped.filter((c) => c.path === file); + const exactLocal = exact.filter((c) => c.path === file); if (exactLocal.length) return { id: exactLocal[0].id, confidence: "extracted" }; const foldedLocal = folded.filter((c) => c.path === file); if (foldedLocal.length) return { id: foldedLocal[0].id, confidence: "extracted" }; - const candidates = exactScoped.length ? exactScoped : folded; + const candidates = exact.length ? exact : folded; if (!candidates.length) continue; // try next ancestor if (candidates.length === 1) return { id: candidates[0].id, confidence: "inferred" }; return "ambiguous"; // several, none same-file at this level — drop and stop