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/.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 900fa842a..bbb515fc0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,39 @@ # Changelog +## 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. + +- **Rust code graphs.** `.rs` files now contribute functions, structs, enums, + traits, type aliases, methods (with impl-block owners), and `macro_rules!` + definitions; calls, method calls with typed-receiver inference, macro + invocations (namespaced with their `!`), imports, and trait-impl heritage. + `mod` declarations and `use` paths resolve through the crate's module tree + (`crate::`/`super::`/`self::`, `x.rs` and `x/mod.rs` layouts), and workspace + crates resolve cross-crate via each `Cargo.toml`'s package name. Rust names + resolve in a Rust-only domain — a Rust symbol never links to a same-named + symbol from another language — and member calls on types with several trait + impls of the same method name are dropped as ambiguous rather than guessed. + Deliberate limitations: UFCS calls such as `::method()` are not + linked, and methods in function-local `impl` blocks remain unowned functions. + +### Changed + +- **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. 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. + ## 0.9.0 ### Added diff --git a/package-lock.json b/package-lock.json index 8c9858da6..9bbdbab6a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@nanonets/graft", - "version": "0.8.0", + "version": "0.9.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@nanonets/graft", - "version": "0.8.0", + "version": "0.9.0", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -15,9 +15,11 @@ "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-powershell": "^0.26.4", + "tree-sitter-python": "^0.25.0", + "tree-sitter-rust": "^0.24.0", "tree-sitter-typescript": "^0.23.2" }, "bin": { @@ -32,7 +34,7 @@ "typescript": "^6.0.3" }, "engines": { - "node": ">=20" + "node": ">=22.12.0" } }, "node_modules/@anthropic-ai/sdk": { @@ -841,28 +843,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": { @@ -889,30 +891,62 @@ } } }, + "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.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-rust": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/tree-sitter-rust/-/tree-sitter-rust-0.24.0.tgz", + "integrity": "sha512-NWemUDf629Tfc90Y0Z55zuwPCAHkLxWnMf2RznYu4iBkkrQl2o/CHGB7Cr52TyN5F1DAx8FmUnDtCy9iUkXZEQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.2.2", + "node-gyp-build": "^4.8.4" + }, + "peerDependencies": { + "tree-sitter": "^0.22.1" + }, + "peerDependenciesMeta": { + "tree-sitter": { + "optional": true + } + } }, "node_modules/tree-sitter-typescript": { "version": "0.23.2", diff --git a/package.json b/package.json index 8f78e0739..5218ccf22 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", @@ -60,11 +60,21 @@ "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-powershell": "^0.26.4", + "tree-sitter-python": "^0.25.0", + "tree-sitter-rust": "^0.24.0", "tree-sitter-typescript": "^0.23.2" }, + "overrides": { + "tree-sitter-rust": { + "tree-sitter": "$tree-sitter" + }, + "tree-sitter-typescript": { + "tree-sitter": "$tree-sitter" + } + }, "devDependencies": { "@types/d3-force": "^3.0.10", "@types/node": "^26.1.0", 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/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/bindings.ts b/src/graph/bindings.ts index 07e9f1e87..0b3f1a9db 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; @@ -55,6 +77,26 @@ export function defName(node: Parser.SyntaxNode, lang: Language): string | null } return null; } + if (lang === "rust") { + if (node.type === "macro_definition") { + const name = node.childForFieldName("name")?.text; + return name ? `${name}!` : null; + } + if ( + [ + "function_item", + "function_signature_item", + "struct_item", + "enum_item", + "trait_item", + "type_item", + "union_item", + ].includes(node.type) + ) { + return node.childForFieldName("name")?.text ?? null; + } + return null; + } const defTypes = lang === "python" ? new Set(["class_definition", "function_definition"]) @@ -105,6 +147,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 +172,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 +188,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" || lang === "rust") return; if (lang === "python" && node.type === "aliased_import") { const nameNode = node.childForFieldName("name"); const aliasNode = node.childForFieldName("alias"); @@ -169,9 +218,11 @@ 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 if (lang === "rust") handleRust(node, scope, bindings); else handleTs(node, scope, classScope, bindings, aliases); - const name = defName(node, lang); + const name = lang === "rust" ? (rustContextName(node) ?? defName(node, lang)) : defName(node, lang); let childScope = scope; let childClassScope = classScope; if (name !== null) { @@ -284,6 +335,186 @@ 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 rustContextName(node: Parser.SyntaxNode): string | null { + if (node.type === "impl_item") return rustTypeName(node.childForFieldName("type")); + if (node.type === "mod_item" && node.childForFieldName("body")) { + return node.childForFieldName("name")?.text ?? null; + } + return null; +} + +function rustTypeName(node: Parser.SyntaxNode | null | undefined): string | null { + if (!node) return null; + if (node.type === "reference_type" || node.type === "generic_type") { + return rustTypeName(node.childForFieldName("type")); + } + if (node.type === "scoped_type_identifier" || node.type === "scoped_identifier") { + return node.childForFieldName("name")?.text ?? null; + } + if (node.type === "type_identifier" || node.type === "identifier") return node.text; + return null; +} + +function rustConstructorType(node: Parser.SyntaxNode | null | undefined): string | null { + if (node?.type !== "call_expression") return null; + let fn = node.childForFieldName("function"); + while (fn?.type === "generic_function") fn = fn.childForFieldName("function"); + if (fn?.type !== "scoped_identifier") return null; + const method = fn.childForFieldName("name")?.text ?? ""; + if (method !== "new" && method !== "default" && method !== "from" && !method.startsWith("with_")) return null; + return rustTypeName(fn.childForFieldName("path")); +} + +function handleRust(node: Parser.SyntaxNode, scope: string[], bindings: FileBindings): void { + const scopePath = scope.join("."); + if (node.type === "parameter") { + const pattern = node.childForFieldName("pattern"); + const type = rustTypeName(node.childForFieldName("type")); + if (pattern?.type === "identifier" && type) bindings.set(scopePath, pattern.text, type); + return; + } + if (node.type !== "let_declaration") return; + const pattern = node.childForFieldName("pattern"); + if (pattern?.type !== "identifier") return; + const type = + rustTypeName(node.childForFieldName("type")) ?? rustConstructorType(node.childForFieldName("value")); + if (type) bindings.set(scopePath, pattern.text, 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/build.ts b/src/graph/build.ts index 3d5fbd5ad..2d1cb8dd5 100644 --- a/src/graph/build.ts +++ b/src/graph/build.ts @@ -31,7 +31,7 @@ import { import { writeFingerprint } from "./fingerprint.js"; import { seedGraph, type SeedResult } from "./seed.js"; import { listSourceStats } from "./source-files.js"; -import { resolveEdges, type GoModule } from "./resolve.js"; +import { resolveEdges, type GoModule, type RustCrate } from "./resolve.js"; import { enrichGraph, type EnrichStats } from "./enrich.js"; import { readGraph, writeGraph, wiringPath } from "./write.js"; import { writeCards, writeIndex, writeCovers, type CardStats } from "./cards.js"; @@ -137,6 +137,47 @@ function readGoModules(root: string, repoFiles: string[]): GoModule[] { return mods; } +/** Every Cargo package in the repo, keyed by its package name and the directory + * containing Cargo.toml. Cargo files can contain several unrelated `name` keys + * (`[[bin]]`, dependencies, package metadata), so only `[package]` contributes. + * Inline-table dependency renames are retained for resolution from the owning + * crate; other TOML dependency forms remain deliberately unsupported. */ +function readCargoCrates(root: string, repoFiles: string[]): RustCrate[] { + const crates: RustCrate[] = []; + for (const f of repoFiles) { + if (basename(f) !== "Cargo.toml") continue; + try { + let section = ""; + let name: string | null = null; + const aliases: Record = {}; + for (const line of readFileSync(f, "utf8").split(/\r?\n/)) { + const header = line.match(/^\s*\[{1,2}\s*([^\]]+?)\s*\]{1,2}\s*(?:#.*)?$/); + if (header) { + section = header[1]; + continue; + } + if (section === "package") { + const declared = line.match(/^\s*name\s*=\s*(["'])(.*?)\1\s*(?:#.*)?$/); + if (declared) name = declared[2]; + continue; + } + if (!/^(?:(?:dev-|build-)?dependencies|target\..+\.(?:dev-|build-)?dependencies)$/.test(section)) { + continue; + } + const dependency = line.match(/^\s*([A-Za-z0-9_-]+)\s*=\s*\{(.*)\}\s*(?:#.*)?$/); + const packageName = dependency?.[2].match(/(?:^|,)\s*package\s*=\s*(["'])(.*?)\1(?:\s*,|\s*$)/); + if (dependency && packageName) aliases[dependency[1]] = packageName[2]; + } + if (!name) continue; + const rel = relPosix(root, dirname(f)); + crates.push({ name, dir: rel === "" ? "." : rel, aliases }); + } catch { + /* unreadable Cargo.toml — skip this crate */ + } + } + return crates.sort((a, b) => a.dir < b.dir ? -1 : a.dir > b.dir ? 1 : 0); +} + export async function buildGraph( dir: string, opts: GraphBuildOptions = {}, @@ -202,8 +243,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; } @@ -249,7 +288,10 @@ export async function buildGraph( files: entries, }); - const edges = resolveEdges(nodes, rawEdges, { goModules: readGoModules(root, repoFiles) }); + const edges = resolveEdges(nodes, rawEdges, { + goModules: readGoModules(root, repoFiles), + rustCrates: readCargoCrates(root, repoFiles), + }); // graph.json is its own Tier-2 cache: fold in the prior meaning layer so an // unchanged body is never re-summarized (and a Tier-1-only run never wipes it). 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/graph/extract.ts b/src/graph/extract.ts index c800283de..b47b75a24 100644 --- a/src/graph/extract.ts +++ b/src/graph/extract.ts @@ -10,12 +10,15 @@ 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 Rust from "tree-sitter-rust/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" | "rust"; /** * Extension → the tree-sitter grammar that parses it, and the label a human expects @@ -44,6 +47,9 @@ 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" }, + { ext: ".rs", grammar: "rust", label: "rust" }, ]; function entryFor(path: string): (typeof EXTENSIONS)[number] | undefined { @@ -80,6 +86,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; // Language-specific name-resolution domain, when required. } export interface ExtractResult { @@ -149,11 +156,40 @@ 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 RUST_KINDS: Record = { + function_item: "function", + function_signature_item: "function", + struct_item: "struct", + enum_item: "enum", + trait_item: "interface", + type_item: "type", + union_item: "struct", + macro_definition: "function", +}; + const KINDS_BY_LANG: Record> = { typescript: TS_KINDS, tsx: TS_KINDS, python: PY_KINDS, go: GO_KINDS, + powershell: PS_KINDS, + rust: RUST_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"]), + rust: new Set(["call_expression", "macro_invocation"]), }; const FUNCTION_VALUE_TYPES = new Set([ @@ -169,6 +205,8 @@ const GRAMMARS: Record = { tsx: TypeScript.tsx, python: Python, go: Go, + powershell: PowerShell, + rust: Rust, }; export interface WalkCtx { @@ -183,6 +221,10 @@ export interface WalkCtx { enclosingClass: string | null; // nearest enclosing class (py/ts `self`/`this`) goReceiverVar: string | null; // Go receiver var, e.g. `w` in `func (w *Worker)` importedSymbols: ReadonlyMap; + rustInImpl: boolean; + rustTraitImpl: boolean; + rustCfgTest: boolean; + rustInlineModDepth: number; } /** A definition we're about to emit, normalized across the shapes we handle. */ @@ -194,11 +236,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; @@ -241,6 +284,10 @@ export function extractFile(rel: string, source: string, lang: Language): Extrac enclosingClass: null, goReceiverVar: null, importedSymbols, + rustInImpl: false, + rustTraitImpl: false, + rustCfgTest: false, + rustInlineModDepth: 0, }; // Every id minted this file, seeded with the file node's own id (`rel`) so a // top-level definition can never collide with it. Threaded as its own @@ -249,26 +296,13 @@ export function extractFile(rel: string, source: string, lang: Language): Extrac // when it's actually accidental shared mutable state across the whole walk. const minted = new Set([rel]); for (const child of root.namedChildren) walk(child, ctx, nodes, rawEdges, minted); + if (lang === "rust") rawEdges.push(...rustHeritageEdges(root, nodes, rel)); // nodes[0] is the file node; the rest are its symbols. Index the module-level // residual on the file node so a term outside every symbol still surfaces it. nodes[0].body_text = fileResidual(source, nodes.slice(1)); 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) { @@ -276,7 +310,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 @@ -297,7 +340,11 @@ 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) + : ctx.lang === "rust" + ? rustExported(node, ctx) + : tsExported(node), origin: "ast", body_hash: contentHash(desc.hashNode.text), body_text: searchBody(desc.hashNode.text), @@ -311,7 +358,12 @@ function walk(node: Parser.SyntaxNode, ctx: WalkCtx, out: NodeV1[], edges: RawEd // class heritage if (desc.kind === "class") edges.push(...heritageEdges(node, id, ctx)); - const enclosingClass = desc.kind === "class" ? desc.name : isGoMethod ? goReceiverType(node) : ctx.enclosingClass; + const enclosingClass = + desc.kind === "class" || (ctx.lang === "rust" && desc.kind === "interface") + ? desc.name + : isGoMethod + ? goReceiverType(node) + : ctx.enclosingClass; const childCtx: WalkCtx = { ...ctx, scope: [...ctx.scope, idPart], @@ -328,10 +380,38 @@ function walk(node: Parser.SyntaxNode, ctx: WalkCtx, out: NodeV1[], edges: RawEd return; } + if (ctx.lang === "rust" && node.type === "impl_item") { + const owner = rustTypeName(node.childForFieldName("type")); + if (owner) { + const childCtx: WalkCtx = { + ...ctx, + scope: [...ctx.scope, owner], + enclosingClass: owner, + rustInImpl: true, + rustTraitImpl: node.childForFieldName("trait") !== null, + }; + for (const child of node.namedChildren) walk(child, childCtx, out, edges, minted); + return; + } + } + + if (ctx.lang === "rust" && node.type === "mod_item" && node.childForFieldName("body")) { + const name = node.childForFieldName("name")?.text; + if (name) { + const childCtx: WalkCtx = { + ...ctx, + scope: [...ctx.scope, name], + rustCfgTest: ctx.rustCfgTest || hasRustAttribute(node, "cfg", "test"), + rustInlineModDepth: ctx.rustInlineModDepth + 1, + }; + for (const child of node.namedChildren) walk(child, childCtx, out, edges, minted); + return; + } + } + // 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) { - const callee = calleeName(node, ctx.lang); + if (CALL_NODE_TYPES[ctx.lang].has(node.type) && !psSkipsCall(node, ctx.lang) && !rustSkipsCall(node, ctx.lang)) { + const callee = calleeName(node, ctx.lang, ctx.rustInlineModDepth); if (callee) { const callEdge: RawEdge = { source: ctx.parentId, @@ -339,19 +419,32 @@ function walk(node: Parser.SyntaxNode, ctx: WalkCtx, out: NodeV1[], edges: RawEd name: callee.name, viaMember: callee.viaMember, file: ctx.rel, + ...(callee.specifier ? { specifier: callee.specifier } : {}), + ...(ctx.lang === "powershell" || ctx.lang === "rust" ? { 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 }); + const specs = ctx.lang === "rust" ? rustImportSpecifiers(node, ctx.scope) : [importSpecifier(node, ctx.lang)]; + for (const spec of specs) { + if (!spec) continue; + edges.push({ + source: ctx.rel, + relation: "imports", + specifier: spec, + file: ctx.rel, + ...(ctx.lang === "powershell" || ctx.lang === "rust" ? { 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) && + (node.type === "identifier" || (ctx.lang === "rust" && node.type === "type_identifier")) && + !isDirectCallee(node, CALL_NODE_TYPES[ctx.lang]) && !isDeclarationName(node) ) { const imported = ctx.importedSymbols.get(node.text); @@ -362,6 +455,7 @@ function walk(node: Parser.SyntaxNode, ctx: WalkCtx, out: NodeV1[], edges: RawEd name: imported.name, specifier: imported.specifier, file: ctx.rel, + ...(ctx.lang === "rust" ? { lang: ctx.lang } : {}), }); } } @@ -379,9 +473,20 @@ function collectImportedSymbols( lang: Language, ): Map { const out = new Map(); - if (lang !== "typescript" && lang !== "tsx") return out; + if (lang !== "typescript" && lang !== "tsx" && lang !== "rust") return out; const visit = (node: Parser.SyntaxNode): void => { + if (lang === "rust" && node.type === "use_declaration") { + const argument = node.childForFieldName("argument"); + if (!argument) return; + for (const leaf of rustUseLeaves(argument)) { + const specifier = rustConsumeInlineModPrefix(leaf.specifier, rustInlineModDepth(node)); + if (specifier && leaf.name && leaf.local && /^[A-Z]/.test(leaf.name) && /^[A-Z]/.test(leaf.local)) { + out.set(leaf.local, { name: leaf.name, specifier }); + } + } + return; + } if (node.type === "import_statement") { const specifier = importSpecifier(node, lang); if (!specifier) return; @@ -408,6 +513,66 @@ function collectTsImportBindings( for (const child of node.namedChildren) collectTsImportBindings(child, specifier, out); } +interface RustUseLeaf { + specifier: string; + name?: string; + local?: string; +} + +function rustUseLeaves(node: Parser.SyntaxNode, prefix = ""): RustUseLeaf[] { + if (node.type === "use_list") return node.namedChildren.flatMap((child) => rustUseLeaves(child, prefix)); + if (node.type === "scoped_use_list") { + const path = node.childForFieldName("path")?.text; + const list = node.namedChildren.find((child) => child.type === "use_list"); + return path && list ? rustUseLeaves(list, rustJoinPath(prefix, path)) : []; + } + if (node.type === "use_as_clause") { + const path = node.childForFieldName("path"); + const local = node.childForFieldName("alias")?.text; + const name = path ? rustPathLastName(path) : null; + return path && local && name + ? [{ specifier: rustJoinPath(prefix, path.text), name, local }] + : []; + } + if (node.type === "use_wildcard" || node.type === "self") { + return [{ specifier: rustJoinPath(prefix, node.text) }]; + } + if (node.type === "identifier" || node.type === "scoped_identifier") { + const name = rustPathLastName(node); + return name + ? [{ specifier: rustJoinPath(prefix, node.text), name, local: name }] + : []; + } + return []; +} + +function rustJoinPath(prefix: string, path: string): string { + return prefix ? `${prefix}::${path}` : path; +} + +function rustPathLastName(node: Parser.SyntaxNode): string | null { + return node.childForFieldName("name")?.text ?? (node.type === "identifier" ? node.text : null); +} + +function rustInlineModDepth(node: Parser.SyntaxNode): number { + let depth = 0; + for (let parent = node.parent; parent; parent = parent.parent) { + if (parent.type === "mod_item" && parent.childForFieldName("body")) depth++; + } + return depth; +} + +function rustConsumeInlineModPrefix(specifier: string, depth: number): string | null { + if (depth === 0) return specifier; + const segments = specifier.split("::"); + if (segments[0] === "self") return null; + let supers = 0; + while (segments[supers] === "super") supers++; + if (supers === 0) return specifier; + if (supers <= depth) return null; + return segments.slice(depth).join("::"); +} + /** * A parameter or local declaration wins over an import inside that function. * Drop that imported binding for the whole function rather than create a false @@ -454,21 +619,23 @@ 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") === node; + 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. */ 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 * 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); + if (ctx.lang === "rust") return describeRust(node, ctx); const mapped = ctx.kinds[node.type]; if (mapped) { @@ -500,6 +667,83 @@ function describe(node: Parser.SyntaxNode, ctx: WalkCtx): DefDescriptor | null { return null; } +function describeRust(node: Parser.SyntaxNode, ctx: WalkCtx): DefDescriptor | null { + const mapped = ctx.kinds[node.type]; + if (!mapped) return null; + const rawName = node.childForFieldName("name")?.text; + if (!rawName) return null; + const name = node.type === "macro_definition" ? `${rawName}!` : rawName; + const directImplMember = + ctx.rustInImpl && ctx.enclosingKind !== "function" && ctx.enclosingKind !== "method"; + const kind = + mapped === "function" && + node.type !== "macro_definition" && + (directImplMember || ctx.enclosingKind === "interface") + ? "method" + : mapped; + const body = node.type === "macro_definition" + ? node.descendantsOfType("token_tree")[0] + : node.childForFieldName("body"); + return { name, kind, headerEnd: body ? body.startIndex : node.endIndex, hashNode: node }; +} + +function rustTypeName(node: Parser.SyntaxNode | null | undefined): string | null { + if (!node) return null; + if (node.type === "generic_type" || node.type === "reference_type") { + return rustTypeName(node.childForFieldName("type")); + } + if (node.type === "scoped_type_identifier") { + return node.childForFieldName("name")?.text ?? null; + } + if (node.type === "type_identifier" || node.type === "identifier") return node.text; + return null; +} + +function rustHeritageEdges(root: Parser.SyntaxNode, nodes: NodeV1[], rel: string): RawEdge[] { + const impls: Array<{ owner: string; trait: string }> = []; + const visit = (node: Parser.SyntaxNode): void => { + if (node.type === "impl_item") { + const owner = rustTypeName(node.childForFieldName("type")); + const trait = rustTypeName(node.childForFieldName("trait")); + if (owner && trait) impls.push({ owner, trait }); + } + for (const child of node.namedChildren) visit(child); + }; + visit(root); + const typeKinds = new Set(["class", "struct", "interface", "type", "enum"]); + return impls.flatMap(({ owner, trait }) => { + const target = nodes.find((node) => node.name === owner && typeKinds.has(node.kind)); + return target + ? [{ source: target.id, relation: "extends", name: trait, file: rel, lang: "rust" as const }] + : []; + }); +} + +/** 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. */ @@ -572,6 +816,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 = @@ -593,7 +848,10 @@ function heritageEdges(node: Parser.SyntaxNode, classId: string, ctx: WalkCtx): function calleeName( node: Parser.SyntaxNode, lang: Language, -): { name: string; viaMember: boolean; receiver?: string } | null { + rustInlineDepth = 0, +): { name: string; viaMember: boolean; receiver?: string; recvType?: string; specifier?: string } | null { + if (lang === "powershell") return psCalleeName(node); + if (lang === "rust") return rustCalleeName(node, rustInlineDepth); const fn = node.childForFieldName("function"); if (!fn) return null; if (fn.type === "identifier") return { name: fn.text, viaMember: false }; @@ -615,6 +873,89 @@ function calleeName( return null; } +function rustCalleeName( + node: Parser.SyntaxNode, + inlineModDepth: number, +): { name: string; viaMember: boolean; receiver?: string; recvType?: string; specifier?: string } | null { + if (node.type === "macro_invocation") { + const macro = node.childForFieldName("macro"); + const name = macro ? rustPathLastName(macro) : null; + return name ? { name: `${name}!`, viaMember: false } : null; + } + let fn = node.childForFieldName("function"); + while (fn?.type === "generic_function") fn = fn.childForFieldName("function"); + if (fn?.type === "identifier") return { name: fn.text, viaMember: false }; + if (fn?.type === "field_expression") { + const field = fn.childForFieldName("field"); + if (field?.type !== "field_identifier") return null; + const value = fn.childForFieldName("value"); + const receiver = value?.type === "identifier" || value?.type === "self" ? value.text : undefined; + return { name: field.text, viaMember: true, receiver }; + } + if (fn?.type !== "scoped_identifier") return null; + const name = fn.childForFieldName("name")?.text; + const path = fn.childForFieldName("path"); + if (!name || !path) return null; + if (path.type === "identifier" && path.text === "Self") { + return { name, viaMember: true, receiver: "self" }; + } + if (path.type === "identifier" && /^[A-Z]/.test(path.text)) { + return { name, viaMember: true, recvType: path.text }; + } + const specifier = rustConsumeInlineModPrefix(path.text, inlineModDepth); + return specifier + ? { name, viaMember: false, specifier } + : { name, viaMember: false }; +} + +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 { @@ -645,9 +986,27 @@ 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; + if (lang === "rust") { + return node.type === "use_declaration" || (node.type === "mod_item" && !node.childForFieldName("body")); + } return node.type === "import_statement" || node.type === "import_from_statement"; } +function rustImportSpecifiers(node: Parser.SyntaxNode, scope: string[]): string[] { + if (node.type === "mod_item") { + if (hasRustAttribute(node, "path")) return []; + const name = node.childForFieldName("name")?.text; + return name ? [`self::${[...scope, name].join("::")}`] : []; + } + const argument = node.childForFieldName("argument"); + if (!argument) return []; + const depth = rustInlineModDepth(node); + return rustUseLeaves(argument) + .map((leaf) => rustConsumeInlineModPrefix(leaf.specifier, depth)) + .filter((specifier): specifier is string => specifier !== null); +} + function importSpecifier(node: Parser.SyntaxNode, lang: Language): string | null { if (lang === "python") { const m = @@ -660,12 +1019,178 @@ 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"; +} + +const RUST_BUILTIN_MACROS = new Set([ + "println", "print", "eprintln", "eprint", "format", "format_args", "write", "writeln", "vec", "panic", + "assert", "assert_eq", "assert_ne", "debug_assert", "debug_assert_eq", "debug_assert_ne", "todo", + "unimplemented", "unreachable", "matches", "dbg", "include", "include_str", "include_bytes", "concat", + "stringify", "env", "option_env", "cfg", "line", "file", "column", "compile_error", "module_path", + "thread_local", "macro_rules", +]); + +function rustSkipsCall(node: Parser.SyntaxNode, lang: Language): boolean { + if (lang !== "rust" || node.type !== "macro_invocation") return false; + const macro = node.childForFieldName("macro"); + const name = macro ? rustPathLastName(macro) : null; + return name !== null && RUST_BUILTIN_MACROS.has(name); +} + +function hasRustAttribute(node: Parser.SyntaxNode, name: string, argument?: string): boolean { + let sibling = node.previousNamedSibling; + while (sibling?.type === "attribute_item") { + const attribute = sibling.namedChildren.find((child) => child.type === "attribute"); + const identifiers = attribute?.descendantsOfType("identifier") ?? []; + const tokenTree = attribute?.namedChildren.find((child) => child.type === "token_tree"); + const argumentMatches = !argument || tokenTree?.text.replace(/\s+/g, "") === `(${argument})`; + if (identifiers[0]?.text === name && argumentMatches) { + return true; + } + sibling = sibling.previousNamedSibling; + } + return false; +} + +function rustExported(node: Parser.SyntaxNode, ctx: WalkCtx): boolean { + if (ctx.rustCfgTest) return false; + if (node.type === "macro_definition") return hasRustAttribute(node, "macro_export"); + if (ctx.enclosingKind === "function" || ctx.enclosingKind === "method") return false; + if (ctx.rustInImpl && ctx.rustTraitImpl) return true; + if (ctx.enclosingKind === "interface") { + let parent = node.parent; + while (parent && parent.type !== "trait_item") parent = parent.parent; + return parent ? rustHasPublicVisibility(parent) : false; + } + return rustHasPublicVisibility(node); +} + +function rustHasPublicVisibility(node: Parser.SyntaxNode): boolean { + const visibility = node.namedChildren.find((child) => child.type === "visibility_modifier"); + return visibility !== undefined && !visibility.namedChildren.some((child) => child.type === "self"); +} + +/** 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 diff --git a/src/graph/resolve.ts b/src/graph/resolve.ts index ea69aacb4..c9e4d5a5f 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 @@ -27,11 +77,55 @@ export interface GoModule { dir: string; } +/** A Cargo package discovered in the repo: its `[package]` name and the + * repo-relative directory containing Cargo.toml. */ +export interface RustCrate { + name: string; + dir: string; + aliases?: Record; +} + export interface ResolveOptions { /** The Go modules found in the repo. Enables mapping Go import package paths * (`example.com/app/pkg/util`) to the in-repo directory they name, relative to the * owning module's `go.mod` location. Empty/absent → Go imports stay external strings. */ goModules?: GoModule[]; + /** Cargo packages found in the repo. Enables `crate::` and workspace-crate + * module paths to resolve against source file nodes already present in byId. */ + rustCrates?: RustCrate[]; +} + +/** 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; + rustGlobalName: Map; + psPerFileName: Map>; + psGlobalName: Map; + psTestPaths: Set; +} + +/** Bundled owner/heritage indexes for {@link resolveTypedMember}: the + * 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; + rustOwnerMethod: Map; + rustClassParents: Map; + psOwnerMethod: Map; + psClassParents: Map; } export function resolveEdges( @@ -41,10 +135,18 @@ export function resolveEdges( ): EdgeV1[] { const byId = new Map(nodes.map((n) => [n.id, n])); const globalName = new Map(); + const rustGlobalName = 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 psOwnerMethodExact = new Map(); + const rustOwnerMethod = 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; @@ -56,12 +158,28 @@ export function resolveEdges( } continue; } - push(globalName, n.name, n); + if (isRustPath(n.path)) push(rustGlobalName, n.name, n); + else push(globalName, n.name, n); 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}`; + if (isRustPath(n.path)) { + push(rustOwnerMethod, key, n); + } else if (isPsPath(n.path)) { + push(psOwnerMethodExact, key, n); + push(psOwnerMethod, key.toLowerCase(), n); + } else { + push(ownerMethod, key, n); + } } } @@ -69,16 +187,45 @@ 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 rustClassParents = 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 === "rust") { + push(rustClassParents, ownName, e.name); + } else if (e.lang === "powershell") { + push(psClassParents, ownName.toLowerCase(), e.name.toLowerCase()); + } else { + push(classParents, ownName, e.name); + } } + const nameIndex: NameIndex = { + perFileName, + globalName, + rustGlobalName, + psPerFileName, + psGlobalName, + psTestPaths, + }; + const ownerIndex: OwnerIndex = { + ownerMethod, + classParents, + psOwnerMethodExact, + rustOwnerMethod, + rustClassParents, + 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"]) => { @@ -95,38 +242,56 @@ export function resolveEdges( const target = hasGoModules && e.file.endsWith(".go") ? resolveGoImport(e.specifier, opts.goModules!, goFilesByDir) - : resolveImport(e.specifier, e.file, byId); + : resolveImport(e.specifier, e.file, byId, opts.rustCrates); 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 // it came from and the exported name. Resolve inside that file only, so a // same-named symbol elsewhere in the repo cannot become a false edge. - const targetFile = resolveImport(e.specifier, e.file, byId); + const targetFile = resolveImport(e.specifier, e.file, byId, opts.rustCrates); if (!byId.has(targetFile)) continue; // external or unresolved module const candidates = perFileName.get(targetFile)?.get(e.name) ?? []; if (candidates.length === 1) add(e.source, candidates[0].id, "references", "extracted"); } else if (e.relation === "calls") { + if (e.lang === "rust" && e.specifier) { + const targetFile = resolveImport(e.specifier, e.file, byId, opts.rustCrates); + if (!byId.has(targetFile)) continue; + const candidates = (perFileName.get(targetFile)?.get(e.name!) ?? []) + .filter((candidate) => candidate.kind === "function"); + if (candidates.length === 1) add(e.source, candidates[0].id, "calls", "extracted"); + continue; + } 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 isRustPath(path: string): boolean { + return languageOf(path) === "rust"; +} + function push(map: Map, key: string, val: T): void { const arr = map.get(key); if (arr) arr.push(val); @@ -136,18 +301,78 @@ 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, rustGlobalName, 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; + + if (lang === "rust") { + const rustGlobal = (rustGlobalName.get(name) ?? []).filter((n) => kinds.includes(n.kind)); + return rustGlobal.length === 1 ? { id: rustGlobal[0].id, confidence: "inferred" } : 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 +388,79 @@ 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, + psOwnerMethodExact, + rustOwnerMethod, + rustClassParents, + 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++) { + if (lang === "rust") { + const level = frontier.flatMap((type) => + rustOwnerMethod.get(`${type}.${name}`) ?? [], + ); + if (level.length > 1) return "ambiguous"; + if (level.length === 1) { + const candidate = level[0]; + return { id: candidate.id, confidence: candidate.path === file ? "extracted" : "inferred" }; + } + } 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" }; + if (lang === "rust") continue; + const key = `${type}.${name}`; + const exact = (isPs ? psOwnerMethodExact : ownerMethod).get(key) ?? []; + + if (!isPs) { + 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 = exact.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 = 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 = 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 } 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 = lang === "rust" + ? (rustClassParents.get(type) ?? []) + : 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,23 +472,203 @@ 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; +function resolveImport( + spec: string, + file: string, + byId: Map, + rustCrates?: RustCrate[], +): string { + if (languageOf(file) === "rust") return resolveRustImport(spec, file, byId, rustCrates ?? []); + 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; } +/** A Rust module's child directory. Crate-root files and mod.rs own their + * containing directory; every other module file owns a same-named directory. */ +function rustModuleDir(file: string, crates: RustCrate[]): string { + const normalized = toPosixPath(file); + const dir = posix.dirname(normalized); + const base = posix.basename(normalized); + if ( + base === "lib.rs" || + base === "main.rs" || + base === "mod.rs" || + isRustAuxiliaryCrateRoot(normalized, crates) + ) { + return dir; + } + return posix.join(dir, base.slice(0, -3)); +} + +function rustAuxiliaryDir(file: string, crate: RustCrate): string | null { + const normalized = toPosixPath(file); + for (const name of ["tests", "benches", "examples"]) { + const dir = crate.dir === "." ? name : posix.join(crate.dir, name); + if (pathIsWithin(normalized, dir)) return dir; + } + return null; +} + +function isRustAuxiliaryCrateRoot(file: string, crates: RustCrate[]): boolean { + const normalized = toPosixPath(file); + const crate = owningRustCrate(normalized, crates); + if (!crate || !normalized.endsWith(".rs")) return false; + const auxiliaryDir = rustAuxiliaryDir(normalized, crate); + return auxiliaryDir !== null && posix.dirname(normalized) === auxiliaryDir; +} + +function rustCrateSrcDir(crate: RustCrate): string { + return crate.dir === "." ? "src" : posix.join(crate.dir, "src"); +} + +function pathIsWithin(path: string, dir: string): boolean { + return dir === "." || path === dir || path.startsWith(`${dir}/`); +} + +function rustCrateRoot(crate: RustCrate, byId: Map): string | null { + const srcDir = rustCrateSrcDir(crate); + const lib = posix.join(srcDir, "lib.rs"); + if (byId.has(lib)) return lib; + const main = posix.join(srcDir, "main.rs"); + return byId.has(main) ? main : null; +} + +function owningRustCrate(file: string, crates: RustCrate[]): RustCrate | null { + const normalized = toPosixPath(file); + let best: { crate: RustCrate; prefixLength: number } | null = null; + for (const crate of crates) { + const srcDir = rustCrateSrcDir(crate); + const prefix = pathIsWithin(normalized, srcDir) + ? srcDir + : pathIsWithin(normalized, crate.dir) + ? crate.dir + : null; + if (prefix === null) continue; + if (!best || prefix.length > best.prefixLength) best = { crate, prefixLength: prefix.length }; + } + return best?.crate ?? null; +} + +function matchingRustCrate(segment: string, file: string, crates: RustCrate[]): RustCrate | null { + const normalized = segment.replace(/-/g, "_"); + const owner = owningRustCrate(file, crates); + const alias = owner + ? Object.entries(owner.aliases ?? {}).find(([name]) => name.replace(/-/g, "_") === normalized)?.[1] + : undefined; + if (alias) { + const aliased = crates.filter((crate) => crate.name.replace(/-/g, "_") === alias.replace(/-/g, "_")); + if (aliased.length === 1) return aliased[0]; + if (aliased.length > 1) return null; + } + const direct = crates.filter((crate) => crate.name.replace(/-/g, "_") === normalized); + return direct.length === 1 ? direct[0] : null; +} + +function resolveRustModule( + baseDir: string, + segments: string[], + byId: Map, +): string | null { + for (let length = segments.length; length >= 1; length--) { + const stem = posix.join(baseDir, ...segments.slice(0, length)); + const flat = `${stem}.rs`; + if (byId.has(flat)) return flat; + const nested = posix.join(stem, "mod.rs"); + if (byId.has(nested)) return nested; + } + return null; +} + +function resolveRustImport( + spec: string, + file: string, + byId: Map, + crates: RustCrate[], +): string { + const segments = spec.split("::").filter(Boolean); + if (segments.length === 0) return spec; + + let baseDir: string; + let remaining: string[]; + let rootFile: string | null = null; + if (segments[0] === "crate") { + const crate = owningRustCrate(file, crates); + if (!crate) return spec; + if (isRustAuxiliaryCrateRoot(file, crates)) { + rootFile = toPosixPath(file); + baseDir = posix.dirname(rootFile); + } else if (rustAuxiliaryDir(file, crate)) { + return spec; + } else { + rootFile = rustCrateRoot(crate, byId); + if (!rootFile) return spec; + baseDir = rustCrateSrcDir(crate); + } + remaining = segments.slice(1); + } else if (segments[0] === "self") { + baseDir = rustModuleDir(file, crates); + remaining = segments.slice(1); + } else if (segments[0] === "super") { + baseDir = rustModuleDir(file, crates); + let index = 0; + while (segments[index] === "super") { + baseDir = posix.dirname(baseDir); + index++; + } + remaining = segments.slice(index); + } else { + const crate = matchingRustCrate(segments[0], file, crates); + if (crate) { + rootFile = rustCrateRoot(crate, byId); + if (!rootFile) return spec; + baseDir = rustCrateSrcDir(crate); + remaining = segments.slice(1); + } else if (segments.length === 1) { + // Bodyless `mod x;` has no syntactic marker by resolution time, but its + // single-segment shape is enough to try the declaring module's child dir. + baseDir = rustModuleDir(file, crates); + remaining = segments; + } else { + return spec; + } + } + + if (remaining.length === 0) return rootFile ?? spec; + return resolveRustModule(baseDir, remaining, byId) ?? spec; +} + /** * Resolve a Go import package path to an in-repo file node when it points inside one of * the repo's modules; otherwise return the raw specifier (stdlib or third-party package). 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/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 || ""); +} 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-languages.test.ts b/test/graph-languages.test.ts index a63fa44c5..7c7829559 100644 --- a/test/graph-languages.test.ts +++ b/test/graph-languages.test.ts @@ -27,13 +27,14 @@ const INDEXED = [ "a.tsx", "a.jsx", "a.py", "a.pyi", "a.go", + "a.rs", ]; test("a file is labelled exactly when it is indexed", () => { // The guard that keeps #36 from coming back: both readings come off one table, so // adding an extension cannot teach the extractor about it and leave the banner // silent (or vice versa — claiming coverage of a file nothing can parse). - for (const f of [...INDEXED, "a.txt", "a.rs", "README.md", "noextension", ""]) { + for (const f of [...INDEXED, "a.txt", "README.md", "noextension", ""]) { assert.equal( languageOf(f) === null, languageLabelOf(f) === null, @@ -53,11 +54,13 @@ test("labels name the language, not the grammar that parses it", () => { assert.equal(languageLabelOf("api/main.py"), "python"); assert.equal(languageLabelOf("api/main.pyi"), "python"); assert.equal(languageLabelOf("cmd/main.go"), "go"); + assert.equal(languageLabelOf("src/lib.rs"), "rust"); // The grammar is unchanged — extraction, the extract cache and every `Language` // switch still see exactly what they saw before. assert.equal(languageOf("scripts/tool.mjs"), "typescript"); assert.equal(languageOf("web/app.jsx"), "tsx"); + assert.equal(languageOf("src/lib.rs"), "rust"); }); test("labels are case-insensitive, like the grammar lookup", () => { 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-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" }, diff --git a/test/graph-rust-resolve.test.ts b/test/graph-rust-resolve.test.ts new file mode 100644 index 000000000..8b44fd83d --- /dev/null +++ b/test/graph-rust-resolve.test.ts @@ -0,0 +1,418 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { buildGraph } from "../src/graph/build.js"; +import { extractFile } from "../src/graph/extract.js"; +import { resolveEdges } from "../src/graph/resolve.js"; +import { readGraph } from "../src/graph/write.js"; +import type { GraphV1, NodeV1 } from "../src/graph/types.js"; + +function write(root: string, rel: string, source: string): void { + const path = join(root, rel); + mkdirSync(join(path, ".."), { recursive: true }); + writeFileSync(path, source); +} + +async function buildFixture(root: string): Promise { + const result = await buildGraph(root, { + contextDir: join(root, ".graft-test"), + graphOnly: true, + reuse: false, + }); + const graph = readGraph(result.graphPath); + assert.ok(graph); + return graph; +} + +function hasEdge(graph: GraphV1, source: string, target: string, relation: string): boolean { + return graph.edges.some((edge) => edge.source === source && edge.target === target && edge.relation === relation); +} + +function node( + id: string, + kind: NodeV1["kind"], + name = id.includes("#") ? id.split("#")[1].split(".").at(-1)! : id, + owner?: string, +): NodeV1 { + return { + id, + name, + kind, + ...(owner ? { owner } : {}), + path: id.split("#")[0], + span: "L1-L1", + signature: null, + exported: true, + origin: "ast", + body_hash: "h", + summary_state: "pending", + summary: null, + crux: null, + }; +} + +test("Rust module paths resolve file modules, items, scoped leaves, and scoped calls", async () => { + const dir = mkdtempSync(join(tmpdir(), "graft-rust-resolve-")); + try { + write(dir, "Cargo.toml", "[package]\nname = 'root-crate'\n"); + write( + dir, + "src/lib.rs", + [ + "mod alpha;", + "mod nested;", + "mod parser;", + "use crate::a::B;", + "use crate::config::env;", + "use crate::bundle::{one::One, two::Two};", + "use serde::Serialize;", + "fn run() {}", + "fn invoke() { crate::worker::run(); crate::missing::run(); }", + "fn use_imports() { let _ = B; let _ = One; let _ = Two; }", + "", + ].join("\n"), + ); + write(dir, "src/main.rs", "mod command;\n"); + write(dir, "src/alpha.rs", "pub fn alpha() {}\n"); + write(dir, "src/nested/mod.rs", "mod leaf;\nuse super::shared;\n"); + write(dir, "src/nested/leaf.rs", "pub fn leaf() {}\n"); + write(dir, "src/shared.rs", "pub fn shared() {}\n"); + write(dir, "src/command.rs", "pub fn command() {}\n"); + write(dir, "src/parser.rs", "mod lexer;\n"); + write(dir, "src/parser/lexer.rs", "pub fn lex() {}\n"); + write(dir, "src/lexer.rs", "pub fn wrong_lexer() {}\n"); + write(dir, "src/a.rs", "pub struct B;\n"); + write(dir, "src/config/env.rs", "pub fn load() {}\n"); + write(dir, "src/bundle/one.rs", "pub struct One;\n"); + write(dir, "src/bundle/two.rs", "pub struct Two;\n"); + write(dir, "src/worker.rs", "pub fn run() {}\n"); + write(dir, "src/feature/mod.rs", "use self::local;\n"); + write(dir, "src/feature/local.rs", "pub fn local() {}\n"); + write(dir, "src/feature/thing.rs", "use super::util::helper;\n"); + write(dir, "src/feature/util.rs", "pub fn helper() {}\n"); + + const graph = await buildFixture(dir); + + assert.ok(hasEdge(graph, "src/lib.rs", "src/alpha.rs", "imports"), "mod x resolves x.rs"); + assert.ok(hasEdge(graph, "src/lib.rs", "src/nested/mod.rs", "imports"), "mod x resolves x/mod.rs"); + assert.ok(hasEdge(graph, "src/parser.rs", "src/parser/lexer.rs", "imports")); + assert.ok(!hasEdge(graph, "src/parser.rs", "src/lexer.rs", "imports"), "non-root modules use their stem dir"); + assert.ok(hasEdge(graph, "src/main.rs", "src/command.rs", "imports")); + assert.ok(hasEdge(graph, "src/nested/mod.rs", "src/nested/leaf.rs", "imports")); + assert.ok(hasEdge(graph, "src/nested/mod.rs", "src/shared.rs", "imports")); + assert.ok(hasEdge(graph, "src/feature/mod.rs", "src/feature/local.rs", "imports")); + assert.ok(hasEdge(graph, "src/feature/thing.rs", "src/feature/util.rs", "imports")); + + assert.ok(hasEdge(graph, "src/lib.rs", "src/a.rs", "imports"), "item suffix retries to its module"); + assert.ok(hasEdge(graph, "src/lib.rs", "src/config/env.rs", "imports"), "module leaf is not stripped early"); + assert.ok(hasEdge(graph, "src/lib.rs", "src/bundle/one.rs", "imports")); + assert.ok(hasEdge(graph, "src/lib.rs", "src/bundle/two.rs", "imports")); + assert.ok(hasEdge(graph, "src/lib.rs", "serde::Serialize", "imports"), "external crates remain raw"); + assert.ok(hasEdge(graph, "src/lib.rs#use_imports", "src/a.rs#B", "references")); + assert.ok(hasEdge(graph, "src/lib.rs#use_imports", "src/bundle/one.rs#One", "references")); + assert.ok(hasEdge(graph, "src/lib.rs#use_imports", "src/bundle/two.rs#Two", "references")); + + assert.ok(hasEdge(graph, "src/lib.rs#invoke", "src/worker.rs#run", "calls")); + assert.ok(!hasEdge(graph, "src/lib.rs#invoke", "src/lib.rs#run", "calls"), "scoped calls never use bare fallback"); + assert.equal( + graph.edges.filter((edge) => edge.source === "src/lib.rs#invoke" && edge.relation === "calls").length, + 1, + "an unresolvable scoped call is dropped", + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("Rust crate paths distinguish source tests, direct auxiliary roots, and deeper support modules", async () => { + const dir = mkdtempSync(join(tmpdir(), "graft-rust-integration-root-")); + try { + write(dir, "Cargo.toml", "[package]\nname = 'root-crate'\n"); + write(dir, "src/lib.rs", "mod shared;\n"); + write(dir, "src/shared.rs", "pub fn helper() {}\n"); + write(dir, "src/tests/foo.rs", "use crate::shared::helper;\n"); + write(dir, "src/tests/shared.rs", "pub fn wrong() {}\n"); + write( + dir, + "tests/foo.rs", + "mod common;\nuse crate::common::helper;\nfn exercise() { helper(); }\n", + ); + write(dir, "tests/common/mod.rs", "use crate::shared::helper;\n"); + + const graph = await buildFixture(dir); + + assert.ok( + hasEdge(graph, "tests/foo.rs", "tests/common/mod.rs", "imports"), + "a direct integration-test crate root resolves mod and crate paths from its own directory", + ); + assert.ok( + hasEdge(graph, "src/tests/foo.rs", "src/shared.rs", "imports"), + "a src/tests child is an ordinary source module anchored to the package crate", + ); + assert.ok(!hasEdge(graph, "src/tests/foo.rs", "src/tests/shared.rs", "imports")); + assert.ok( + hasEdge(graph, "tests/common/mod.rs", "crate::shared::helper", "imports"), + "crate paths in deeper auxiliary support modules stay raw because their declaring root is ambiguous", + ); + assert.ok(!hasEdge(graph, "tests/common/mod.rs", "src/shared.rs", "imports")); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("Rust inline-module super calls resolve from the declaring file", async () => { + const dir = mkdtempSync(join(tmpdir(), "graft-rust-inline-call-")); + try { + write(dir, "Cargo.toml", "[package]\nname = 'root-crate'\n"); + write(dir, "src/lib.rs", "mod foo;\n"); + write(dir, "src/foo.rs", "mod helper;\nmod tests { fn t() { super::helper::go(); } }\n"); + write(dir, "src/foo/helper.rs", "pub fn go() {}\n"); + + const graph = await buildFixture(dir); + assert.ok(hasEdge(graph, "src/foo.rs#tests.t", "src/foo/helper.rs#go", "calls")); + assert.ok(!hasEdge(graph, "src/foo.rs#tests.t", "src/helper.rs#go", "calls")); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("Rust mod declarations stay crate-local and resolve nested inline-module paths", async () => { + const dir = mkdtempSync(join(tmpdir(), "graft-rust-mod-marker-")); + try { + write(dir, "crates/main/Cargo.toml", "[package]\nname = 'main'\n"); + write(dir, "crates/main/src/lib.rs", "mod parser;\nmod inline { mod leaf; }\n"); + write(dir, "crates/main/src/parser.rs", "pub fn local() {}\n"); + write(dir, "crates/main/src/inline/leaf.rs", "pub fn nested() {}\n"); + write(dir, "crates/parser/Cargo.toml", "[package]\nname = 'parser'\n"); + write(dir, "crates/parser/src/lib.rs", "pub fn external() {}\n"); + + const graph = await buildFixture(dir); + assert.ok(hasEdge(graph, "crates/main/src/lib.rs", "crates/main/src/parser.rs", "imports")); + assert.ok(!hasEdge(graph, "crates/main/src/lib.rs", "crates/parser/src/lib.rs", "imports")); + assert.ok(hasEdge(graph, "crates/main/src/lib.rs", "crates/main/src/inline/leaf.rs", "imports")); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("Rust workspace aliases resolve declared packages and duplicate package names stay raw", async () => { + const dir = mkdtempSync(join(tmpdir(), "graft-rust-crate-map-")); + try { + write( + dir, + "crates/app/Cargo.toml", + "[package]\nname = 'app'\n\n[dependencies]\nrenamed = { package = 'real-package', path = '../real' }\n", + ); + write(dir, "crates/app/src/lib.rs", "use renamed::thing;\nuse duplicate::module;\n"); + write(dir, "crates/real/Cargo.toml", "[package]\nname = 'real-package'\n"); + write(dir, "crates/real/src/lib.rs", "pub mod thing;\n"); + write(dir, "crates/real/src/thing.rs", "pub fn work() {}\n"); + write(dir, "crates/dup-a/Cargo.toml", "[package]\nname = 'duplicate'\n"); + write(dir, "crates/dup-a/src/lib.rs", "pub mod module;\n"); + write(dir, "crates/dup-a/src/module.rs", "pub fn first() {}\n"); + write(dir, "crates/dup-b/Cargo.toml", "[package]\nname = 'duplicate'\n"); + write(dir, "crates/dup-b/src/lib.rs", "pub mod module;\n"); + write(dir, "crates/dup-b/src/module.rs", "pub fn second() {}\n"); + + const graph = await buildFixture(dir); + assert.ok(hasEdge(graph, "crates/app/src/lib.rs", "crates/real/src/thing.rs", "imports")); + assert.ok(hasEdge(graph, "crates/app/src/lib.rs", "duplicate::module", "imports")); + assert.ok(!hasEdge(graph, "crates/app/src/lib.rs", "crates/dup-a/src/module.rs", "imports")); + assert.ok(!hasEdge(graph, "crates/app/src/lib.rs", "crates/dup-b/src/module.rs", "imports")); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("Rust owner indexes cannot satisfy a Python typed-member call", () => { + const nodes = [ + node("worker.rs", "file"), + node("worker.rs#Worker.run", "method", "run", "Worker"), + node("caller.py", "file"), + node("caller.py#invoke", "function"), + ]; + const edges = resolveEdges(nodes, [ + { + source: "caller.py#invoke", + relation: "calls", + name: "run", + file: "caller.py", + viaMember: true, + recvType: "Worker", + }, + ]); + assert.equal(edges.filter((edge) => edge.relation === "calls").length, 0); +}); + +test("Rust inline mods resolve bodyless mods and consumed super calls end to end", async () => { + const dir = mkdtempSync(join(tmpdir(), "graft-rust-inline-e2e-")); + try { + write(dir, "Cargo.toml", "[package]\nname = 'root-crate'\n"); + write(dir, "src/lib.rs", "mod outer;\n"); + write(dir, "src/outer.rs", "mod x;\nmod y;\nmod tests { mod x; fn t() { super::y::z(); } }\n"); + write(dir, "src/outer/x.rs", "pub fn root_x() {}\n"); + write(dir, "src/outer/y.rs", "pub fn z() {}\n"); + write(dir, "src/outer/tests/x.rs", "pub fn nested_x() {}\n"); + + const graph = await buildFixture(dir); + assert.ok(hasEdge(graph, "src/outer.rs", "src/outer/tests/x.rs", "imports")); + assert.ok(hasEdge(graph, "src/outer.rs#tests.t", "src/outer/y.rs#z", "calls")); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("Rust graph builds are byte-deterministic", async () => { + const dir = mkdtempSync(join(tmpdir(), "graft-rust-deterministic-")); + try { + write(dir, "Cargo.toml", "[package]\nname = 'root-crate'\n"); + write(dir, "src/lib.rs", "mod worker;\nfn invoke() { crate::worker::run(); }\n"); + write(dir, "src/worker.rs", "pub fn run() {}\n"); + + const first = await buildGraph(dir, { + contextDir: join(dir, ".graft-test"), + graphOnly: true, + reuse: false, + }); + const before = readFileSync(first.graphPath); + const second = await buildGraph(dir, { + contextDir: join(dir, ".graft-test"), + graphOnly: true, + reuse: false, + }); + assert.deepEqual(readFileSync(second.graphPath), before); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("Rust workspace crate names resolve across crates and ignore non-package Cargo names", async () => { + const dir = mkdtempSync(join(tmpdir(), "graft-rust-workspace-")); + try { + write(dir, "crates/foo-bar/Cargo.toml", "[package]\nname = 'foo-bar'\n"); + write(dir, "crates/foo-bar/src/lib.rs", "use baz::local;\npub mod thing;\n"); + write(dir, "crates/foo-bar/src/thing.rs", "pub fn work() {}\n"); + write( + dir, + "crates/baz/Cargo.toml", + "[[bin]]\nname = \"foo_bar\"\npath = \"src/main.rs\"\n\n[package]\nname = \"baz\"\n", + ); + write(dir, "crates/baz/src/main.rs", "use foo_bar::thing;\nmod local;\nfn main() {}\n"); + write(dir, "crates/baz/src/local.rs", "pub fn local() {}\n"); + + const graph = await buildFixture(dir); + + assert.ok( + hasEdge(graph, "crates/baz/src/main.rs", "crates/foo-bar/src/thing.rs", "imports"), + "hyphenated package name matches its underscored Rust path", + ); + assert.ok( + hasEdge(graph, "crates/foo-bar/src/lib.rs", "crates/baz/src/local.rs", "imports"), + "the [[bin]] name does not shadow the [package] name", + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("Rust typed members drop duplicate same-file trait implementations but keep a single candidate", () => { + const singleExtract = extractFile( + "single.rs", + "struct Frame;\nimpl Frame { fn from() {} }\nfn use_frame() { Frame::from(); }\n", + "rust", + ); + const single = resolveEdges(singleExtract.nodes, singleExtract.rawEdges); + assert.equal(single.find((edge) => edge.relation === "calls")?.target, "single.rs#Frame.from"); + + const ambiguousExtract = extractFile( + "frame.rs", + [ + "trait First { fn from(); }", + "trait Second { fn from(); }", + "struct Frame;", + "impl First for Frame { fn from() {} }", + "impl Second for Frame { fn from() {} }", + "fn use_frame() { Frame::from(); }", + "", + ].join("\n"), + "rust", + ); + assert.equal( + ambiguousExtract.nodes.filter((candidate) => candidate.owner === "Frame" && candidate.name === "from").length, + 2, + ); + const ambiguous = resolveEdges(ambiguousExtract.nodes, ambiguousExtract.rawEdges); + assert.equal(ambiguous.filter((edge) => edge.relation === "calls").length, 0); +}); + +test("Rust trait default methods resolve through same-file impl heritage", () => { + const { nodes, rawEdges } = extractFile( + "frame.rs", + "pub trait Paint { fn render(&self) {} }\npub struct Frame;\nimpl Paint for Frame {}\nfn use_frame() { Frame::render(); }\n", + "rust", + ); + const edges = resolveEdges(nodes, rawEdges); + assert.ok( + edges.some( + (edge) => + edge.source === "frame.rs#use_frame" && + edge.target === "frame.rs#Paint.render" && + edge.relation === "calls", + ), + ); + + const twoDefaults = extractFile( + "ambiguous-default.rs", + [ + "trait First { fn render(&self) {} }", + "trait Second { fn render(&self) {} }", + "struct Frame;", + "impl First for Frame {}", + "impl Second for Frame {}", + "fn use_frame() { Frame::render(); }", + "", + ].join("\n"), + "rust", + ); + const ambiguous = resolveEdges(twoDefaults.nodes, twoDefaults.rawEdges); + assert.equal( + ambiguous.filter((edge) => edge.source === "ambiguous-default.rs#use_frame" && edge.relation === "calls").length, + 0, + "two default methods at the same ancestor level are ambiguous", + ); +}); + +test("Rust and non-Rust bare-name resolution stay in separate global domains", () => { + const nodes = [ + node("rust_def.rs", "file"), + node("rust_def.rs#shared", "function"), + node("python_def.py", "file"), + node("python_def.py#shared", "function"), + node("rust_call.rs", "file"), + node("rust_call.rs#invoke", "function"), + node("python_call.py", "file"), + node("python_call.py#invoke", "function"), + ]; + const edges = resolveEdges(nodes, [ + { source: "rust_call.rs#invoke", relation: "calls", name: "shared", file: "rust_call.rs", lang: "rust" }, + { source: "python_call.py#invoke", relation: "calls", name: "shared", file: "python_call.py" }, + ]); + + assert.ok(edges.some((edge) => edge.source === "rust_call.rs#invoke" && edge.target === "rust_def.rs#shared")); + assert.ok(edges.some((edge) => edge.source === "python_call.py#invoke" && edge.target === "python_def.py#shared")); + assert.ok(!edges.some((edge) => edge.source === "rust_call.rs#invoke" && edge.target.endsWith(".py#shared"))); + assert.ok(!edges.some((edge) => edge.source === "python_call.py#invoke" && edge.target.endsWith(".rs#shared"))); +}); + +test("Rust macro calls use the bang namespace while ordinary format functions still resolve", () => { + const { nodes, rawEdges } = extractFile( + "macros.rs", + "macro_rules! log_it { () => {} }\nfn format() {}\nfn invoke() { log_it!(); format(); }\n", + "rust", + ); + const edges = resolveEdges(nodes, rawEdges); + assert.ok(edges.some((edge) => edge.source === "macros.rs#invoke" && edge.target === "macros.rs#log_it!")); + assert.ok(edges.some((edge) => edge.source === "macros.rs#invoke" && edge.target === "macros.rs#format")); +}); diff --git a/test/graph-rust.test.ts b/test/graph-rust.test.ts new file mode 100644 index 000000000..8cbd842c4 --- /dev/null +++ b/test/graph-rust.test.ts @@ -0,0 +1,323 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import Parser from "tree-sitter"; +import Rust from "tree-sitter-rust/bindings/node/index.js"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { collectBindings } from "../src/graph/bindings.js"; +import { buildGraph } from "../src/graph/build.js"; +import { extractFile } from "../src/graph/extract.js"; +import { readGraph } from "../src/graph/write.js"; + +test("Rust extraction: definitions, owners, module scopes, exports, and trait heritage", () => { + const src = ` +pub struct Cache { value: T } +struct Private; +pub(self) struct SelfOnly; +pub(crate) enum Mode { Fast } +pub trait Backend { + fn required(&self); + fn defaulted(&self) {} +} +pub type Alias = Cache; +pub union Bits { raw: u32 } + +impl Cache { + pub fn open(&self) {} + pub(crate) fn crate_visible(&self) {} + pub(self) fn self_only(&self) {} + fn private(&self) {} +} + +impl Backend for Cache { + fn required(&self) {} +} + +#[macro_export] +macro_rules! exported_macro { () => {} } +macro_rules! local_macro { () => {} } + +mod imp { pub fn open() {} } +#[cfg(test)] +mod tests { pub fn hidden() {} } +#[cfg(not(test))] +mod production { pub fn visible() {} } +`; + const { nodes, rawEdges } = extractFile("defs.rs", src, "rust"); + const byId = (id: string) => nodes.find((node) => node.id === id); + + assert.equal(byId("defs.rs#Cache")?.kind, "struct"); + assert.equal(byId("defs.rs#Private")?.kind, "struct"); + assert.equal(byId("defs.rs#SelfOnly")?.kind, "struct"); + assert.equal(byId("defs.rs#Mode")?.kind, "enum"); + assert.equal(byId("defs.rs#Backend")?.kind, "interface"); + assert.equal(byId("defs.rs#Alias")?.kind, "type"); + assert.equal(byId("defs.rs#Bits")?.kind, "struct"); + assert.equal(byId("defs.rs#exported_macro!")?.kind, "function"); + assert.equal(byId("defs.rs#local_macro!")?.kind, "function"); + + assert.equal(byId("defs.rs#Cache.open")?.kind, "method"); + assert.equal(byId("defs.rs#Cache.open")?.owner, "Cache"); + assert.equal(byId("defs.rs#Cache.required")?.kind, "method"); + assert.equal(byId("defs.rs#Cache.required")?.owner, "Cache"); + assert.equal(byId("defs.rs#Backend.required")?.kind, "method"); + assert.equal(byId("defs.rs#Backend.required")?.owner, "Backend"); + assert.equal(byId("defs.rs#Backend.defaulted")?.owner, "Backend"); + assert.equal(byId("defs.rs#imp.open")?.kind, "function"); + assert.equal(byId("defs.rs#tests.hidden")?.exported, false); + assert.equal(byId("defs.rs#production.visible")?.exported, true); + + assert.equal(byId("defs.rs#Cache")?.exported, true); + assert.equal(byId("defs.rs#Private")?.exported, false); + assert.equal(byId("defs.rs#SelfOnly")?.exported, false); + assert.equal(byId("defs.rs#Mode")?.exported, true); + assert.equal(byId("defs.rs#Cache.open")?.exported, true); + assert.equal(byId("defs.rs#Cache.crate_visible")?.exported, true); + assert.equal(byId("defs.rs#Cache.self_only")?.exported, false); + assert.equal(byId("defs.rs#Cache.private")?.exported, false); + assert.equal(byId("defs.rs#Cache.required")?.exported, true); + assert.equal(byId("defs.rs#Backend.required")?.exported, true); + assert.equal(byId("defs.rs#exported_macro!")?.exported, true); + assert.equal(byId("defs.rs#local_macro!")?.exported, false); + + assert.ok( + rawEdges.some( + (edge) => + edge.relation === "extends" && + edge.source === "defs.rs#Cache" && + edge.name === "Backend" && + edge.lang === "rust", + ), + ); +}); + +test("Rust extraction: calls carry receiver types, module specifiers, macro namespaces, and guards", () => { + const src = ` +struct Worker { value: T } +struct Factory; +fn bare() {} +fn compute() {} + +impl Worker { + fn helper(&self) {} + fn inside(&self) { + Self::helper(self); + } +} + +fn exercise(param: &mut Worker, tuple: (fn(),)) { + bare(); + bare::(); + let typed: &mut Worker = param; + typed.run(); + typed.run::(); + param.run(); + let made = Factory::with_name(); + made.make(); + Worker::build(); + crate::worker::run(); + some_mod::run(); + super::run(); + tuple.0(); + println!("ignored"); + assert_eq!(compute(), 1); + log_it!(compute()); + std::println!("ignored"); + crate::macros::local_log!(); +} +`; + const calls = extractFile("calls.rs", src, "rust").rawEdges.filter((edge) => edge.relation === "calls"); + const named = (name: string) => calls.filter((edge) => edge.name === name); + + assert.equal(named("bare").length, 2); + assert.deepEqual(named("run").map((edge) => edge.recvType), ["Worker", "Worker", "Worker", undefined, undefined, undefined]); + assert.deepEqual(named("run").slice(3).map((edge) => edge.specifier), ["crate::worker", "some_mod", "super"]); + assert.ok(named("run").slice(0, 3).every((edge) => edge.viaMember)); + assert.ok(named("run").slice(3).every((edge) => !edge.viaMember)); + assert.equal(named("make")[0]?.recvType, "Factory"); + assert.equal(named("build")[0]?.recvType, "Worker"); + assert.equal(named("helper")[0]?.recvType, "Worker"); + assert.equal(named("log_it!").length, 1); + assert.equal(named("local_log!").length, 1); + assert.equal(named("println!").length, 0); + assert.equal(named("assert_eq!").length, 0); + assert.equal(named("compute").length, 0, "macro token trees stay opaque"); + assert.equal(named("0").length, 0, "tuple indexes are not method names"); + assert.ok(calls.every((edge) => edge.lang === "rust")); +}); + +test("Rust extraction: use forms retain full per-leaf paths and bodyless mods import", () => { + const src = ` +use foo; +use crate::models::Thing; +use crate::models::{Widget, helper, Thing as Renamed, self}; +use crate::models::*; +use crate::Original as Alias; +mod x; +#[path = "alternate.rs"] +mod redirected; +mod inline { + mod leaf; + pub fn nested() {} +} + +fn uses() { + let _a = Thing; + let _b = Renamed; + let _c = Alias; + let _d = helper; +} +`; + const { nodes, rawEdges } = extractFile("imports.rs", src, "rust"); + const imports = rawEdges.filter((edge) => edge.relation === "imports"); + assert.deepEqual( + imports.map((edge) => edge.specifier), + [ + "foo", + "crate::models::Thing", + "crate::models::Widget", + "crate::models::helper", + "crate::models::Thing", + "crate::models::self", + "crate::models::*", + "crate::Original", + "self::x", + "self::inline::leaf", + ], + ); + assert.ok(imports.every((edge) => edge.lang === "rust")); + assert.equal(nodes.find((node) => node.id === "imports.rs#inline.nested")?.kind, "function"); + + const references = rawEdges.filter((edge) => edge.relation === "references"); + assert.deepEqual( + references.map((edge) => ({ name: edge.name, specifier: edge.specifier })), + [ + { name: "Thing", specifier: "crate::models::Thing" }, + { name: "Thing", specifier: "crate::models::Thing" }, + { name: "Original", specifier: "crate::Original" }, + ], + ); + assert.ok(!references.some((edge) => edge.name === "helper")); +}); + +test("Rust extraction: inline-module prefixes are consumed for scoped calls", () => { + const calls = extractFile( + "src/foo.rs", + "mod helper;\nmod tests { fn t() { super::helper::go(); } }\n", + "rust", + ).rawEdges.filter((edge) => edge.relation === "calls"); + + assert.deepEqual( + calls.map((edge) => ({ source: edge.source, name: edge.name, specifier: edge.specifier })), + [{ source: "src/foo.rs#tests.t", name: "go", specifier: undefined }], + ); +}); + +test("Rust extraction: macro signatures stop before bodies and qualified calls use the leaf name", () => { + const { nodes, rawEdges } = extractFile( + "macros.rs", + "macro_rules! local_log { () => { 42 } }\nfn invoke() { std::println!(\"x\"); crate::macros::local_log!(); }\n", + "rust", + ); + + assert.equal(nodes.find((node) => node.id === "macros.rs#local_log!")?.signature, "macro_rules! local_log { ()"); + assert.deepEqual( + rawEdges.filter((edge) => edge.relation === "calls").map((edge) => edge.name), + ["local_log!"], + ); +}); + +test("Rust extraction handles CRLF input", () => { + const result = extractFile("crlf.rs", "pub fn from_crlf() {\r\n helper();\r\n}\r\n", "rust"); + assert.equal(result.nodes.find((node) => node.name === "from_crlf")?.kind, "function"); +}); + +test("Rust extraction parses definitions beyond the 32 KB chunk boundary", () => { + const padding = Array.from({ length: 3_000 }, (_, i) => `// padding ${i}`).join("\n"); + const source = `${padding}\npub fn after_boundary() {}\n`; + assert.ok(source.length > 32_768); + assert.equal( + extractFile("large.rs", source, "rust").nodes.find((node) => node.name === "after_boundary")?.kind, + "function", + ); +}); + +test("Rust builds tolerate malformed files and keep recoverable definitions", async () => { + const dir = mkdtempSync(join(tmpdir(), "graft-rust-malformed-")); + try { + writeFileSync(join(dir, "broken.rs"), "pub fn before() {}\nfn broken( {\npub fn after() {}\n"); + const result = await buildGraph(dir, { + contextDir: join(dir, ".graft-test"), + graphOnly: true, + reuse: false, + }); + assert.deepEqual(result.errors, []); + const graph = readGraph(result.graphPath); + assert.equal(graph?.nodes.find((node) => node.name === "before")?.kind, "function"); + assert.equal(graph?.nodes.find((node) => node.name === "after")?.kind, "function"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("Rust extraction degrades gracefully on &raw syntax", () => { + const { nodes } = extractFile( + "raw.rs", + "static VALUE: u8 = 1;\nfn pointer() { let _ptr = &raw const VALUE; }\npub fn after_raw() {}\n", + "rust", + ); + assert.equal(nodes.find((node) => node.name === "pointer")?.kind, "function"); + assert.equal(nodes.find((node) => node.name === "after_raw")?.kind, "function"); +}); + +test("Rust extraction: inline modules consume same-file self and super prefixes", () => { + const src = ` +struct Item; +mod tests { + use super::*; + use super::Item; + use self::Local; + use super::super::external::Thing; + use crate::absolute::CrateItem; + + fn uses() { + let _item = Item; + let _local = Local; + let _thing = Thing; + let _crate_item = CrateItem; + } +} +`; + const { rawEdges } = extractFile("inline.rs", src, "rust"); + const imports = rawEdges.filter((edge) => edge.relation === "imports"); + assert.deepEqual( + imports.map((edge) => edge.specifier), + ["super::external::Thing", "crate::absolute::CrateItem"], + ); + + const references = rawEdges.filter((edge) => edge.relation === "references"); + assert.deepEqual( + references.map((edge) => ({ name: edge.name, specifier: edge.specifier })), + [ + { name: "Thing", specifier: "super::external::Thing" }, + { name: "CrateItem", specifier: "crate::absolute::CrateItem" }, + ], + ); +}); + +test("Rust bindings: typed lets, constructor lets, and typed parameters strip wrappers and generics", () => { + const parser = new Parser(); + parser.setLanguage(Rust as never); + const root = parser.parse(` +fn use_workers(param: &mut Worker) { + let typed: &Worker = param; + let made = Factory::with_name(); +} +`).rootNode; + const bindings = collectBindings(root, "rust"); + + assert.equal(bindings.lookup(["use_workers"], "param"), "Worker"); + assert.equal(bindings.lookup(["use_workers"], "typed"), "Worker"); + assert.equal(bindings.lookup(["use_workers"], "made"), "Factory"); +}); 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/