From pwsh-code-review
How pwsh-code-review computes diff context using PowerShell's AST. Defines the Ring 0/Ring 1 context model, the AST index format, the call graph algorithm, and the diff-context.json schema. Use when computing diff context for a review or when explaining how the reviewer reasons about staged changes.
How this skill is triggered — by the user, by Claude, or both
Slash command
/pwsh-code-review:pwsh-ast-contextThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
The reviewer's job is to reason about *the diff* with enough surrounding context to be correct, and not one line more. This skill defines the context-loading algorithm that makes that work.
The reviewer's job is to reason about the diff with enough surrounding context to be correct, and not one line more. This skill defines the context-loading algorithm that makes that work.
Ring 0: the diff itself. Hunks, with full file context cached but not loaded as input until needed. This is what changed. Every comment must trace back to a Ring 0 line. No comments on unchanged code unless the unchanged code is broken because of the change.
Ring 1: the immediate neighbourhood. For each changed function or script block: the file it lives in, callers within the repo, callees within the repo, type definitions touched, Pester tests that reference it. This is what the agents read to verify the diff is correct.
Ring 2: the project profile. architecture.md, standards.md, patterns/, glossary.md. Loaded as system context.
Anything beyond Ring 1 requires explicit escalation by an agent. Default depth is one hop.
Single JSON file at .pwsh-review/cache/ast-index.json. Keyed by file path, content addressed by SHA256.
{
"schema_version": "1",
"generated": "<ISO timestamp>",
"files": {
"src/Modules/WidgetCore/Public/New-Widget.ps1": {
"hash": "<sha256>",
"functions": [
{
"name": "New-Widget",
"line_start": 1,
"line_end": 87,
"parameters": [
{
"name": "Path",
"type": "string",
"mandatory": true,
"validations": ["ValidateNotNullOrEmpty"],
"value_from_pipeline": false
}
],
"output_type_declared": ["Widget"],
"has_process_block": false,
"supports_should_process": true,
"has_cbh": true,
"calls": ["Test-Path", "Join-Path", "git", "Write-Verbose"],
"scope_writes": [],
"platform_signals": ["IsWindows"]
}
],
"imports": ["Module1"],
"uses_classes": ["Widget"],
"is_test": false,
"manifest": null
}
},
"function_to_file": {
"New-Widget": "src/Modules/WidgetCore/Public/New-Widget.ps1"
},
"callers_of": {
"New-Widget": [
{"file": "server.ps1", "line": 120, "context": "function-body"},
{"file": "tests/New-Widget.Tests.ps1", "line": 55, "context": "test"}
]
},
"tests_for": {
"New-Widget": ["tests/New-Widget.Tests.ps1"]
}
}
function Build-AstIndex {
[CmdletBinding()]
param(
[string]$RepoRoot = (Get-Location).Path,
[switch]$Cold
)
$indexPath = Join-Path $RepoRoot '.pwsh-review/cache/ast-index.json'
$index = if ($Cold -or -not (Test-Path $indexPath)) {
@{ schema_version = '1'; files = @{}; function_to_file = @{}; callers_of = @{}; tests_for = @{} }
} else {
Get-Content $indexPath -Raw | ConvertFrom-Json -AsHashtable
}
$files = Get-ChildItem -Path $RepoRoot -Recurse -File -Include '*.ps1', '*.psm1', '*.psd1' |
Where-Object { $_.FullName -notmatch '\.pwsh-review[/\\]cache' }
foreach ($file in $files) {
$relPath = [System.IO.Path]::GetRelativePath($RepoRoot, $file.FullName)
$hash = (Get-FileHash $file.FullName -Algorithm SHA256).Hash
if ($index.files[$relPath].hash -eq $hash) { continue } # cached
$tokens = $errors = $null
$ast = [System.Management.Automation.Language.Parser]::ParseFile(
$file.FullName, [ref]$tokens, [ref]$errors
)
$index.files[$relPath] = ConvertTo-FileEntry -Ast $ast -Hash $hash
}
# Rebuild cross-references after all files are parsed
$index.function_to_file = Build-FunctionToFile $index
$index.callers_of = Build-CallGraph $index
$index.tests_for = Build-TestMap $index
$index | ConvertTo-Json -Depth 20 | Set-Content $indexPath -Encoding utf8NoBOM
}
The actual implementation is in scripts/Get-AstIndex.ps1. Cold builds parse every file. Warm builds re-parse only changed files (file hash differs from cached) and rebuild cross-references.
For each FunctionDefinitionAst, extract:
Extent.StartLineNumber, EndLineNumber)Parameters or Body.ParamBlock.ParametersName.VariablePath.UserPathStaticType.FullName[Parameter()] attribute with Mandatory = $trueValidate*ValueFromPipeline, ValueFromPipelineByPropertyName[OutputType()]: from attributes on the functionhas_process_block: whether Body.ProcessBlock is non-nullsupports_should_process: from [CmdletBinding(SupportsShouldProcess)]has_cbh: whether the comment immediately preceding the function contains .SYNOPSIScalls: distinct command names invoked inside (from Find over CommandAst)scope_writes: variables written with $script: or $global: prefix (from AssignmentStatementAst)platform_signals: flag if function references $IsWindows/$IsLinux/$IsMacOS, hard-coded \ paths, registry, COM, etc.For each function in the index, scan every other file's calls array. If function Foo appears in file Bar.ps1 line 42, then:
"callers_of": {
"Foo": [
{"file": "Bar.ps1", "line": 42, "context": "function-body"}
]
}
context is one of:
function-body: the call is inside another function definitionscript: top-level call in a scripttest: file is a Pester test (suffix .Tests.ps1 or under tests/)init: file is a .psm1 module initunknownThis context lets agents weight findings: a breaking change with only test callers may be less severe than one with script callers.
For each function Foo, identify Pester tests by:
tests/<FunctionName>.Tests.ps1, <FunctionName>.Tests.ps1 next to sourceDescribe or Context block titles contain the function nameResult:
"tests_for": {
"New-Widget": [
"tests/New-Widget.Tests.ps1",
"tests/integration/Widget.Integration.Tests.ps1"
]
}
Given a diff and the AST index:
(file, line_start, line_end) hunks.index.files[file].functions[*] line ranges. Result: list of changed functions per file.index.callers_of[functionName] -> Ring 1 callersindex.tests_for[functionName] -> Ring 1 testsindex.files[file].functions.calls -> Ring 1 callees (functions called by the changed function)process block presence, etc. This needs the pre-change file too:$preContent = git show HEAD:$file
$preAst = [Parser]::ParseInput($preContent, ...)
$postAst = [Parser]::ParseInput($postContent, ...)
$delta = Compare-FunctionAst $preAst $postAst
.pwsh-review/cache/diff-context.json:
{
"schema_version": "1",
"diff_base": "<sha>",
"diff_head": "<sha>",
"changed_files": [
"src/Modules/WidgetCore/Public/New-Widget.ps1",
"tests/New-Widget.Tests.ps1"
],
"changed_hunks": [
{
"file": "src/Modules/WidgetCore/Public/New-Widget.ps1",
"line_start": 42,
"line_end": 48,
"added": ["..."],
"removed": ["..."]
}
],
"changed_functions": [
{
"name": "New-Widget",
"file": "src/Modules/WidgetCore/Public/New-Widget.ps1",
"line_start": 1,
"line_end": 87,
"delta": {
"signature_changed": true,
"signature_diff": [
{"parameter": "Path", "change": "validation_removed", "old": ["ValidateNotNullOrEmpty"], "new": []}
],
"output_type_changed": false,
"process_block_changed": false,
"should_process_changed": false,
"calls_added": [],
"calls_removed": [],
"scope_writes_added": []
},
"callers": [
{"file": "server.ps1", "line": 120, "context": "function-body", "snippet": "..."},
{"file": "tests/New-Widget.Tests.ps1", "line": 55, "context": "test", "snippet": "..."}
],
"callees": ["Test-Path", "Join-Path"],
"tests": ["tests/New-Widget.Tests.ps1"]
}
],
"static_findings_summary": {
"psscriptanalyzer": 3,
"gitleaks": 0,
"pester_failed": 0
}
}
The agents consume this file. They never re-derive context themselves.
| Repo size | Cold build | Warm build (5 changed files) |
|---|---|---|
| 5k pwsh lines | ~2s | <1s |
| 25k pwsh lines | ~8s | <2s |
| 100k pwsh lines | ~30s | ~5s |
The bulk of cold-build time is [Parser]::ParseFile (which is fast). Cross-reference building is O(n^2) over functions but cheap because the constants are small.
--rebuild-index: cold build, useful after large branch merges.The diff-bug agent reads delta.signature_diff and immediately knows what contracts changed. It iterates callers to verify each call site. It iterates tests to verify coverage.
The conventions agent reads output_type_changed and should_process_changed to check pattern conformance.
The history agent uses changed_hunks to drive git blame queries scoped exactly to the lines that changed.
The pwsh-idioms agent uses calls_added to detect new platform-specific cmdlets.
The security agent uses calls_added to detect new dangerous cmdlets.
This is the structural foundation that lets the agents be fast and precise.
Creates, edits, and optimizes skills for Claude Code, including drafting, evaluating with test prompts, iterating on performance, and improving skill descriptions for better triggering accuracy.
npx claudepluginhub andresharpe/pwsh-code-review-marketplace --plugin pwsh-code-review