Skip to content

feat: PowerShell language support (.ps1/.psm1) - #58

Closed
qoole wants to merge 11 commits into
trailhq:mainfrom
qoole:feat/powershell-support-v2
Closed

qoole wants to merge 11 commits into
trailhq:mainfrom
qoole:feat/powershell-support-v2

Conversation

@qoole

@qoole qoole commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Builds on #40 (tree-sitter 0.25) — this branch is stacked on that PR's branch, so its two commits appear here until #40 merges; the PowerShell work is the five commits from chore: add PowerShell tree-sitter grammar onward. Opening as a draft until #40 lands, then I'll rebase onto main and mark it ready.

Tier-1 graph extraction for PowerShell via tree-sitter-powershell (airbus-cert; prebuilds for all six platform/arch combos — the reason #40's 0.25 runtime bump is the prerequisite).

What

  • Definitions: functions (incl. filter/workflow), classes, methods & constructors, enums. exported=true for top-level defs and class members; functions nested inside functions/methods are scope-local → false. (PowerShell has no file-local export syntax; Export-ModuleMember/manifest interpretation is cross-file and deferred — see limitations.)
  • Call edges: command invocations (scope-/module-qualifiers stripped: script:Foo, Mod\Get-Foo), member calls $obj.M(), static calls [Type]::M(), call-operator statics & Get-Foo. Dynamic invocation (& $var) produces no edge.
  • Import edges: dot-sourcing (. ./x.ps1, quoted paths, backslash-normalized), Import-Module with relative paths (module names stay external specifiers, like Go stdlib imports), using module (extracted from its command shape — the grammar has no dedicated node).
  • Case-insensitive resolution: PowerShell is case-insensitive; call/heritage/typed-member lookups case-fold within a PowerShell-only domain (a PS FOO can never link to a Python foo), original casing preserved everywhere, lookup order exact-local → folded-local → exact-global → folded-global (local scope wins, matching PS semantics). Folded matches surface as confidence: "inferred".
  • Typed-receiver bindings: typed params (incl. decorated [Parameter(...)][Widget]$w), typed properties, cast declarations, [Widget]::new(), New-Object (positional and -TypeName), $this → enclosing class.
  • Builtin-cmdlet denylist: calls to the core cmdlet/Pester-DSL surface (Get-ChildItem, Describe, …) never bind to a same-named in-repo definition (usually a Pester mock) — dropped-edge-is-safe, the same posture the resolver already takes for unresolvable member calls.
  • Ingest: UTF-16LE .ps1 (Windows PowerShell 5.1 Out-File default) decoded at the shared readSourceFile site (hash-what-you-parse holds); Pester *.Tests.ps1 classified as test files and de-ranked accordingly; .psd1 deliberately not graph-parsed (manifests yield no definitions).
  • Deep grammar import (tree-sitter-powershell/bindings/node/index.js) avoids a DEP0151 warning on every CLI run (package has extensionless main + no exports map).

Fit with recent main

This was originally built against pre-0.9.0 main and re-anchored after the recent landings; the seams were adapted rather than ported blind:

Evidence

  • Suite: 618/618 green on this branch (35 PowerShell-specific tests: full-pipeline fixtures modeled on the Go tests, bindings, case-collision isolation, malformed-input tolerance, UTF-16, CRLF, >32 KB files, parser alternation, test-path classification pinning).
  • Real-world battery (run on the pre-rebase branch; spot-revalidated post-rebase): 8 repos (Pester, ComputerManagementDsc, dbatools, ImportExcel, posh-git, PSFramework, Plaster, psake) — every build clean, double-build byte-identical determinism 8/8, incremental rebuild correct 8/8 (dbatools: 1,707 files, full 34 s / incremental 2.5 s).
  • Function recall vs an exhaustive regex census: 88–98% per repo, with every miss attributed to upstream grammar parse errors, none to extraction logic.
  • Resolution quality on dbatools: the most-called function (Stop-Function, 2,857 call sites, shadowed by 10 Pester mocks) resolves 726 incoming call edges to its production definition.
  • Import wiring on posh-git: 24/30 import edges resolve to in-repo files via literal $PSScriptRoot resolution (was 0/30); the 6 unresolved are genuinely dynamic (runtime variables) and stay raw by design. Re-verified on this exact branch post-rebase, including byte-identical cold-rebuild determinism.
  • Parse health (upstream grammar, not this integration): 41–94% of files parse error-free per repo; the dominant causes bisect to two airbus-cert grammar gaps (unquoted comma-separated bareword arguments; bare trailing scriptblock arguments). Extraction degrades gracefully — error-containing files still yield correct nodes and edges. Happy to file those upstream grammar issues.

Known limitations (deliberate)

  • Export-ModuleMember / manifest FunctionsToExport not interpreted (uniform exported=true instead) — the common unquoted multi-name idiom doesn't parse in the current grammar anyway.
  • $PSScriptRoot-interpolated, Join-Path-computed, and wildcard import arguments degrade to raw specifiers.
  • .psd1 manifest relationships (RootModule/NestedModules) not graphed.
  • Monorepo scope markers unchanged (PS has no fixed-name manifest file).
  • Class base lists conflate base class and interfaces as extends (PS cannot define interfaces; targets stay external names).

qoole and others added 11 commits August 5, 2026 17:48
Newer grammar releases in the tree-sitter ecosystem require the 0.25
runtime, so bump tree-sitter ^0.21.1→^0.25.0 and the Python/Go grammars
to ^0.25.0 to unlock languages whose grammars need it. tree-sitter-typescript
stays pinned at ^0.23.2 (still the latest release) — it declares
peerOptional tree-sitter@^0.21.0, so an npm `overrides` entry pins its
nested tree-sitter to the root one so the repo-root install resolves
cleanly; verified the grammar's native binding still loads and parses
correctly under tree-sitter 0.25.1.

Also updates the stale-parse-limit comment in extract.ts: the chunked
callback parse predates 0.25 (which lifted the 32KB string-parse limit)
and is kept because it's behavior-identical and exercised by existing
tests. No behavior change; full suite green (493/493).
Use stable syntax-node IDs to avoid duplicate reference edges and align the supported Node version with native Windows prebuilds.
Adds the PowerShell language to extract.ts: describePowerShell recognizes
function/class/enum/method statements, psCalleeName/psImportKind/
psImportSpecifier classify commands (dot-source, Import-Module, using
module) versus real calls, and psExported covers PowerShell's lack of a
file-local export marker. bindings.ts's FileBindings gains a case-insensitive
mode and PowerShell-specific defName/resolveRecvType handling ($this,
scope-qualified definition names).
…, builtin cmdlets

resolve.ts bundles the name-lookup and owner-index maps (NameIndex/
OwnerIndex) and adds a PowerShell case-folded tier alongside the existing
exact-match one: same-file exact wins first, then same-file case-folded,
then whole-repo exact, then whole-repo case-folded. A PowerShell caller
that isn't itself a test file demotes test-path candidates at the two
global tiers before the uniqueness check, so Pester's habit of
redeclaring a prod name across several *.Tests.ps1 files doesn't drop
every cross-file call as ambiguous. PS_BUILTIN_CMDLETS denylists the core
cmdlet/Pester-DSL surface so a call to a builtin never wires to an
unrelated in-repo definition of the same name. $PSScriptRoot resolves as
a literal for import paths, and PS_IMPORT_EXTS keeps PowerShell's module
extensions from cross-matching TS/Python specifiers.

isTestPath moves to its own src/util/testpath.ts (a hard graph-topology
input for resolve.ts now, not just ask.ts's soft rank-penalty); ask.ts
re-exports it for backward compatibility.
CODE_EXTENSIONS picks up .ps1/.psm1/.psd1; isTestPath recognizes
.tests.ps1 (Pester's naming convention). New test/graph-powershell.test.ts,
test/testpath.test.ts, and test/utf16-routing.test.ts cover PowerShell
graph extraction/resolution end-to-end and the UTF-16LE decode path.
Every existing test file touched by the case-folding, test-aware
tie-break, builtin-cmdlet denylist, and ingest changes is extended with
PowerShell-specific coverage alongside its existing cases.
@qoole

qoole commented Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

Added a domain-isolation fix found while extending the language work: PowerShell method nodes were entering the shared member-owner index, so a typed member call from another language could in principle wire cross-language to a PS class method. PS methods (and PS heritage edges) now live only in their own indexes, mirroring how PS bare names were already isolated. Suite is 621/621 on the branch.

Assaf750 added a commit to Assaf750/Graft that referenced this pull request Aug 12, 2026
…nd Rust behaviour

Records the verified starting point before any Rust work: PR trailhq#40/trailhq#58/trailhq#59 heads
double-checked via the GitHub API and git ls-remote, strict-containment proof of
the stack, and the finding that the stack carries no upstream CI signal at all.

Suite on the pinned head is 638/645 with 3 failures. All three are an upstream
locale defect (bare toLocaleString() resolving to ar-SA digits), reproduced
verbatim on main and untouched by any Rust commit, so they are attributed
upstream rather than counted as a Rust regression. Fix is deliberately deferred
to its own commit.

Also records measured behaviour on a Cargo workspace fixture: what already
resolves, and the six gaps that Batches B-J exist to close — chiefly that
cross-crate symbol imports never resolve because module-path resolution maps
paths to files only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@qoole

qoole commented Aug 24, 2026

Copy link
Copy Markdown
Contributor Author

Which tier do you want PowerShell in — if you want it at all? I'd rather rebase once, toward whatever you'd actually merge. Default plan is the breadth row unless you say otherwise.

The constraint that decides it: every published tree-sitter-powershell (0.25.9 through 0.26.4) declares peerDependencies: { "tree-sitter": "^0.25.0" }, and main is still on "tree-sitter": "^0.21.1". That's why this was stacked on #40, and it means the depth version of this branch can't be rebased onto current main at all until the runtime moves. Kotlin could be promoted natively in #130 only because tree-sitter-kotlin still peers on ^0.21.0; PowerShell has no version that does.

The breadth row has no such problem. tree-sitter-powershell.wasm ships in the tree-sitter-wasm 1.1.4 bundle the lockfile already pins, built from the same airbus-cert grammar this branch uses natively — the bundle declares tree-sitter-powershell: ^0.26.4, the identical range. So it's a GENERIC_LANGS row, a hand-written src/graph/queries/powershell.scm, a SNIPPETS case in test/generic-extract.test.ts, and the README Broad list plus its "Twenty-two languages" count — the Nix #159 / Lua #187 shape, with no package.json churn. Two things I checked before proposing it: the .scm has to be written from scratch (airbus-cert ships no tags query), and classifyKind's walker fallback matches DEF_SUFFIX plus a class/struct/enum-ish prefix, neither of which function_statement hits — so a bare row with no query would mint classes, enums and class_method_definition methods, zero functions, and no call edges at all, since the fallback path is symbols-only. I also haven't warmed that wasm under web-tree-sitter 0.25.10 yet; the bundle builds with tree-sitter-cli 0.26.x, this grammar has an external scanner, and #139 shows a bundled grammar in the same 1.1.4 can crash, so I'd smoke-test warm + parse before opening anything.

Worth stating plainly rather than letting it turn up mid-review. A breadth row carries the definitions (functions incl. filter/workflow, classes, methods, constructors, enums) and bare-name command call edges — but those resolve only where case and qualifier match exactly, since the breadth resolver is exact-match and loadQuery strips #gsub! before compiling. The resolver work in this branch is what moves those numbers: scope-qualifier stripping (script:Foo, Mod\Get-Foo), case-folding inside a PS-only domain, dot-source / Import-Module / $PSScriptRoot import edges, typed receivers, and the builtin-cmdlet denylist with its test-aware tie-break. On dbatools, Stop-Function has 2,857 call sites and 10 Pester mocks redeclaring the name; with the tie-break it resolves 726 incoming edges to the production definition, and without it the never-guess ambiguity rule drops all of them. On posh-git, imports are 24/30 with $PSScriptRoot resolution and 0/30 without. Both tiers also inherit the same grammar ceiling — 41–94% of files parse error-free per repo, from two airbus-cert gaps (unquoted comma-separated bareword arguments, bare trailing scriptblock arguments) — so neither option fixes that. UTF-16LE .ps1 decode already works on main through readSourceFile, so that part costs nothing either way.

Independent of the answer: main's isTestPath doesn't classify Pester's *.Tests.ps1 — the \.test\.[a-z]+$ alternative misses it, since the next character is s. One-line regex change plus a test; happy to send it standalone.

This branch predates generic.ts entirely, and the breadth tier gets the symbols-and-calls half of it in four files instead of thirty-one — hard to argue with. Breadth now with a promote-to-depth follow-up once the runtime moves is the path PHP (#64, then #157) and Kotlin (#130) took, so nothing gets burned. And if PowerShell isn't wanted at all — nothing in the tracker asks for it — say so and I'll close this instead.

@github-actions

github-actions Bot commented Aug 25, 2026 •

Copy link
Copy Markdown
Contributor

🌱 graft blast radius

4 areas changed → 6 areas can be affected. 24 dependent symbols, depth 2.
Tests: 2 areas updated their tests.

flowchart TB
  A0(("Workspace Graph Freshness<br/>10 symbols"))
  A1(("CLI Engine Integration<br/>5 symbols"))
  A2(("MCP Tool Dispatch<br/>5 symbols"))
  A3(("Grep Search<br/>2 symbols"))
  A4(("Context Validation<br/>1 symbol"))
  AX(("1 smaller area<br/>1 symbol"))
  classDef reached fill:#D9EDF3,stroke:#3AA7C9,stroke-width:1.5px,color:#0E313C;
  class A0,A1,A2,A3,A4 reached;
  classDef tail fill:#EEF2F3,stroke:#9AA4A9,stroke-width:1px,color:#3A4247;
  class AX tail;
Loading
Can be affected Symbols Nearest hop Reached from
Workspace Graph Freshness 10 src/graph/fingerprint.ts:L1-L185 fingerprint.ts — imports, depth 1 Ask Query, Context Building +2
CLI Engine Integration 5 src/engine.ts:L1-L150 engine.ts — imports, depth 1 Ask Query, Context Building +2
MCP Tool Dispatch 5 src/mcp/tools.ts:L1-L339 tools.ts — imports, depth 1 Ask Query, Context Building +2
Grep Search 2 src/search/grep.ts:L1-L216 grep.ts — imports, depth 1 Source Path Utilities
Context Validation 1 src/context/check.ts:L1-L189 check.ts — imports, depth 1 Context Building, Source Path Utilities
Claude Hooks 1 src/claude/hooks.ts:L1-L238 hooks.ts — imports, depth 2 Context Building, Source Path Utilities
All 24 dependent symbols, grouped by area

Workspace Graph Freshness — 10 symbols in 5 files

  • src/graph/fingerprint.ts:L1-L185 — fingerprint.ts (imports, depth 1)
    5: * unchanged path: a walk + one `stat` per source file, no reads, no parsing.
  • src/graph/refresh.ts:L150-L223 — ensureFreshGraph (calls, depth 1)
    212: await buildGraph(dir, { contextDir: opts.contextDir, graphOnly: true });
  • src/graph/workspace-cli.ts:L1-L132 — workspace-cli.ts (imports, depth 1)
    30: /** Provider/model/key config for child builds — WITHOUT any contextDir
  • src/graph/workspace.ts:L1-L473 — workspace.ts (imports, depth 1)
    23: import { checkGraph } from "./check.js";
  • src/graph/workspace.ts:L378-L403 — federateCheck (calls, depth 1)
    386: const g = checkGraph(join(root, child));
  • src/graph/refresh.ts:L231-L257 — ensureFreshChildren (calls, depth 2)
  • src/graph/refresh.ts:L1-L270 — refresh.ts (imports, depth 2)
    8: * agent (your editor, a branch switch, a stash) set no `dirty` flag at all, so
  • src/graph/seed.ts:L1-L229 — seed.ts (imports, depth 2)
    18: * Copy, not symlink. `node_modules` is the same for every branch; a graph is a set of
  • src/graph/workspace-cli.ts:L45-L57 — buildChild (calls, depth 2)
  • src/graph/workspace-cli.ts:L113-L117 — runWorkspaceCheck (calls, depth 2)

CLI Engine Integration — 5 symbols in 2 files

  • src/engine.ts:L1-L150 — engine.ts (imports, depth 1)
    11: * pipelines; an API key is required for any LLM-backed operation.
  • src/engine.ts:L83-L91 — graph (calls, depth 1)
    84: return buildGraph(dir, {
  • src/engine.ts:L74-L76 — checkGraph (calls, depth 1)
    74: checkGraph(dir: string): GraphCheckResult {
  • src/cli.ts:L1-L626 — cli.ts (imports, depth 2)
    48: .option("--api-key <key>", "provider API key (env GRAFT_API_KEY)")
  • src/cli.ts:L79-L89 — refreshBefore (calls, depth 2)

MCP Tool Dispatch — 5 symbols in 2 files

  • src/mcp/tools.ts:L1-L339 — tools.ts (imports, depth 1)
    82: 'Structural edges for a symbol, over call/reference/import/implements/extends ($0, no LLM). Defaults to direct callers (who depends on it). Set direction:"out" for callees (what it calls); set depth>1 (or depth:"all" for the full closure) to walk transitively for the full blast radius — every source that breaks if it changes. Run before a multi-file refactor to find ALL affected files.',
  • src/mcp/server.ts:L1-L81 — server.ts (imports, depth 2)
    24: * lookup misses. The caller already knows it.
  • src/mcp/tools.ts:L228-L253 — callTool (calls, depth 2)
  • src/mcp/tools.ts:L256-L338 — callSingleTool (calls, depth 2)
    281: const g = engine.checkGraph(root);
  • src/mcp/tools.ts:L151-L197 — callWorkspaceTool (calls, depth 2)

Grep Search — 2 symbols in 2 files

  • src/search/grep.ts:L1-L216 — grep.ts (imports, depth 1)
    130: deg.set(e.target, (deg.get(e.target) ?? 0) + 1);
  • src/search/grep-cli.ts:L1-L119 — grep-cli.ts (imports, depth 2)

Context Validation — 1 symbol in 1 file

  • src/context/check.ts:L1-L189 — check.ts (imports, depth 1)
    71: current.set(relPosix(root, file), contentHash(source));

Claude Hooks — 1 symbol in 1 file

  • src/claude/hooks.ts:L1-L238 — hooks.ts (imports, depth 2)
    121: * `handlePostEdit`), so this is a best-effort lookup against the CURRENT
Test signal per changed area — 2 ✓ · 2 –

Reached = a node under a test path has a resolved edge into the changed symbol. It undercounts anything called indirectly — through a CLI, a spawned process or a dynamic import — so read a low ratio as “look here”, never as a coverage gate.

  • ✓ Source Path Utilities — 1 of 1 reached · 2 test files changed here: test/ask.test.ts, test/testpath.test.ts
  • – Context Building — 2 test files changed here: test/context.test.ts, test/graph-powershell.test.ts
  • ✓ Graph Construction — 3 of 44 reached · 8 test files changed here: test/ask-index.test.ts, test/ask.test.ts, test/graph-go.test.ts, test/graph-incremental.test.ts, test/graph-powershell.test.ts, test/graph-resolve-typed.test.ts, test/graph-scopes.test.ts, test/graph-write.test.ts
    • not reached: constructor, key, set, lookup, defName, resolveRecvType, isClassNode, collectBindings, …33 more
  • – Ask Query — 2 test files changed here: test/ask-index.test.ts, test/ask.test.ts
15 test suites also reference this code

16 symbols, kept out of the diagram and the table so they cannot crowd out the areas a reviewer has to look at.

  • test/ask-fusion.test.ts
  • test/covers.test.ts
  • test/graph-include-dir.test.ts
  • test/graph-languages.test.ts
  • test/graph-load.test.ts
  • test/graph-posix-paths.test.ts
  • test/graph-references.test.ts
  • test/graph-refresh.test.ts
  • test/graph-seed.test.ts
  • test/graph-workspace-include-dir.test.ts
  • test/graphrank.test.ts
  • test/mcp-tools.test.ts
  • test/regression-single-repo.test.ts
  • test/utf16-source.test.ts
  • test/workspace.test.ts

⚠️ 5 changed files not in the graph (.github/dependabot.yml, .github/workflows/ci.yml, CHANGELOG.md, package-lock.json, package.json) — no parser claims the extension, or the index predates the file.

graft blast · origin/main...HEAD · depth 2 · 31 changed files

Open the interactive graph → — click an area to see its dependent symbols at file:line.

github-actions Bot added a commit that referenced this pull request Aug 25, 2026
github-actions Bot added a commit that referenced this pull request Aug 25, 2026
@shhdwi

shhdwi commented Sep 29, 2026

Copy link
Copy Markdown
Collaborator

Thanks for all the work on this, and for the careful write-up on the tier question! This branch is stacked on #40 and now conflicts with main in 9 files, including extract.ts, resolve.ts, bindings.ts and check.ts. As you noted, the native version cannot be rebased onto main until the runtime moves. tree-sitter-powershell.wasm already ships in the tree-sitter-wasm bundle we pin, so the breadth-tier approach you proposed (a GENERIC_LANGS row plus a powershell.scm query) looks like the right path, and we would welcome a fresh PR for it. Closing this one in favour of that.

@shhdwi shhdwi closed this Sep 29, 2026
github-actions Bot added a commit that referenced this pull request Sep 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants