All notable changes to psa.py (the PowerShell static analyzer in this
directory) are documented in this file.
The format is based on Keep a Changelog 1.1.0, and this project adheres to Semantic Versioning 2.0.0. For the public-API scope governed by SemVer (the CLI surface, rule code names, JSON / SARIF output schemas, configuration file schema), see SPEC.md §1.4 Versioning.
This CHANGELOG covers psa.py only. For higher-level repository-wide
changes (documentation policy, sister scripts, etc.), see the root
README.md of ai-generated-artifacts.
-
Rule-count text alignment — hybrid update across stale forward-looking references. The 3.8.0 / 3.9.0 / 4.0.0 releases added
PSA2009,PSA2010,PSA2011, andPSAP0005to the runtimeRULESregistry (now 46 entries: PSA1xxx ×3, PSA2xxx ×11, PSA3xxx ×6, PSA4xxx ×4, PSA5xxx ×4, PSA6xxx ×8, PSA7xxx ×2, PSA8xxx ×1, PSA9xxx ×2, PSAPxxxx ×5), but three in-tree text references continued to spell stale counts (36originally, partially uplifted to42/45in earlier passes, never refreshed during the 4.0.0 / 4.0.2 cycles). This revision applies a hybrid policy: prose comments and test docstrings are parameterised againstRULESdirectly (no embedded count), while the SARIF illustrative example — where the digit is informative to readers sizing their tooling — is updated to the current count.psa.py— Pillar 1 comment block above the self-quality gates section. Dynamic phrasing:"External: test_psa_rules.py covers all 42 rules with ..."→"External: test_psa_rules.py covers every rule in the RULES registry with ...".test_psa_rules.py— module docstring header. Dynamic phrasing:"Each of psa.py's 42 rules has, at minimum, ..."→"Each rule in psa.py's runtime RULES registry has, at minimum, ...".SPEC.md§6.3 (SARIF 2.1.0 format example) — concrete count, kept as a numeric reader hint:"rules": [ /* 45 rule descriptors */ ]→"rules": [ /* 46 rule descriptors */ ].
Documentation-only revision: no behavioural change, no rule catalog change, no test count change.
__version__is not bumped (the runtimeRULESarray andpsa.py --list-rulesoutput have been correct since each rule was added; only the human-readable prose references were stale). The canonical source of truth for the rule count remains the runtimeRULESregistry.psa.py --self-checkvalidatesSPEC.md§4 againstRULESby rule ID rather than by count, which is why this drift was not surfaced by the existing self-quality gates. Future rule-count text references should follow the same hybrid rule: omit the digit in prose, keep it only where the example deliberately illustrates a concrete output size (such as SARIF samples).
This patch release extends PSAP0005's relaxed-mode exemption set
to cover established prose patterns observed in real-world consumer
pipeline scripts (specifically the
usui-tk/Deploy-Drivers-For-WindowsServer r76 baseline). It is
backward-compatible with 4.0.0 / 4.0.1: no new rule is
added, no configuration key is introduced, no public-API change.
Only the relaxed-mode behaviour of PSAP0005 is expanded; strict
mode (psap0005_relaxed_mode: false) is unchanged.
The original 4.0.0 release defined four relaxed-mode exemption
patterns (A SECTION header, B SPEC cross-reference, C Added-in-
release phrasing, D Earlier-revisions prose) based on the SPEC §D.31
conventions in Deploy-Drivers. When psa.py 4.0.0 was applied to
the r76 baseline, the consumer reported 64 PSAP0005 detections
across the four sister scripts that the original A/B/C/D set did not
catch. Analysis showed these were also established prose patterns,
just with surface forms the original regexes had missed.
Notable patterns that 4.0.0 missed but 4.0.2 now exempts in relaxed mode:
- Semi-section headers:
# r71 Pre-check: ... - SPEC cross-reference with slash/dash separator:
(r66 / SPEC D.24),(r75 - SPEC D.33) - SPEC cross-reference with reversed parens:
r68 (SPEC §D.26): - SPEC ref + rNN co-occurrence:
See SPEC SS D.31 ... r71 design contract - Added-in-release variants:
r71 adds ...,the /all addition in r74,documents the r72 follow-on,Until r39, Graphics shipped - Earlier-revisions variants:
prior to r65,predates r65,from an r65 run - Q-Reference patterns:
r69 (QI-6):,(Q-X1, r17),QI-9 (r69, 2026-05-23): - Cross-port markers:
r40 (graphics):,r22 (bthpan): - Follow-up sentence:
r73: this declaration was missing in r71/r72
-
Nine new relaxed-mode exemption patterns (E1-E9) documented in
SPEC.md§4.37. Each pattern is a separate compiled regex insidepsa.pyfor clarity and individual testability. -
Comment-block-level exempt heuristic: In addition to line-level pattern matching,
PSAP0005now checks the entire contiguous comment block (consecutive lines whose first non-whitespace character is#) for an exempt-trigger pattern. If any line of the block matches, the whole block is exempt. This handles the common case where a multi-line narrative comment block opens with an exempt phrase like# WHQL co-signature analysis (added with the r71 release).and continues for several lines that mentionrNNwithout re-triggering on each line. -
test_psa_rules.py: 26 new test cases — 21 relaxed-mode exempt patterns (E1-E9 plus block-level exempt) and 5 strict-mode regression tests verifying the new patterns do NOT leak into strict mode. Total test count:220 → 246(all passing).
SPEC.md§4.37 (PSAP0005) expanded with the E1-E9 exemption table and the comment-block-level exempt description. The original A/B/C/D table is preserved verbatim as4.0.0baseline.
- Rules registry: unchanged at 46 rules. No new rule code.
- Configuration schema: unchanged. No new keys; no removed keys.
psap0005_relaxed_moderemains the only PSAP0005 config flag. - CLI surface: unchanged.
- Strict mode: unchanged. The 4.0.2 extensions affect only
relaxed mode; any repository running
psap0005_relaxed_mode: false(default) sees identical behaviour to 4.0.0 / 4.0.1. - Existing relaxed-mode behaviour: strictly expanded, never narrowed. Any comment that 4.0.0 / 4.0.1 exempted under relaxed mode continues to be exempt under 4.0.2.
- SemVer impact: PATCH increment per SPEC.md §1.4 (false-positive defence widening with no public-API change).
No action required for any consumer. Repositories using
psap0005_relaxed_mode: true will see their PSAP0005 detection
count drop on upgrade from 4.0.0 / 4.0.1 to 4.0.2 if any of
the new exempt patterns are present in their script bodies. The
intent of relaxed mode (migration aid toward strict mode) is
unchanged; the new patterns simply better reflect the established
prose conventions in real consumer pipelines.
This patch release adds a false-positive defence to PSA2009
(PSCustomObject property assigned without prior declaration). It is
backward-compatible with 4.0.0: no existing behaviour changes,
no new rule is added, no configuration key is introduced. Only the
detection logic of PSA2009 is widened to suppress an additional
class of false positives that surfaces in PowerShell scripts that
schedule parallel work via RunspacePool + ArrayList.
PSA2009 performs file-level tracking of variables initialised with
[pscustomobject]@{...}. The same variable name can legitimately
host both a sealed pscustomobject and a hashtable across different
functions in the same script — Step 2c of the 3.8.0 detector
recognises this only for direct initialisations
($var = @{...}). Indirect initialisations through collection-Add
- foreach loop binding were not yet recognised, leading to false positives like:
$job = [pscustomobject]@{ Foo = 1 } # unrelated sealed object
$jobs = New-Object System.Collections.ArrayList
[void]$jobs.Add(@{ Handle = $h }) # $jobs holds hashtables
$newlyDone = $jobs | Where-Object { $_.Handle.IsCompleted }
foreach ($job in $newlyDone) {
$job.Collected = $true # safe (hashtable add), but
# PSA2009 v4.0.0 flagged this
# as undeclared on the sealed $job
}This pattern fires exactly twice on Download-SpeakerDeck.ps1 in
the sister usui-tk/ai-generated-artifacts project (Phase 4 and
Phase 6 reaper loops). With 4.0.1, both warnings clear and the
script's 0 errors / 0 warnings / 0 info baseline is restored.
-
PSA2009Step 2c2 (new) — recognises foreach loop-variables that are bound indirectly through$Coll.Add(@{...})+ laterforeach (...) in $Colland treats them as hashtable elements, not as pscustomobject elements. -
Pipeline / method derivation tracking — when a hashtable-bearing collection is filtered or projected through a pipeline (
$X = $jobs | Where-Object {...}) or LINQ-style method ($X = $jobs.Where({...}),$X = $jobs.Clone()), the derived collection is also labelled as hashtable-bearing. The propagation runs to a fixed point with a 16-iteration cap. -
SPEC.md§4.9c (PSA2009) — the rule walk now describes five passes instead of four. The new pass 4 ("Hashtable-form drop pass (indirect)") documents the v4.0.1 detector and gives three concrete idioms that are now correctly not flagged. The motivation paragraph names theDownload-SpeakerDeck.ps1false-positive as the trigger for the addition.
-
test_psa_rules.py— seven new test cases under "PSA2009 — hashtable-Add + foreach indirect binding (added in 4.0.1)":- Direct
$Coll.Add(@{...})+ foreach. [void]$Coll.Add(@{...})with later foreach.- Pipeline-derived collection (
$newlyDone = $jobs | Where-Object). .Where()method-derived collection.- Two-hop derivation chain.
$Coll.Add([hashtable]@{...})explicit cast form.- Positive control:
$Coll.Add([pscustomobject]@{...})does not trigger the new defence (the cast signals a sealed object, not a hashtable element).
Total test count:
213 → 220(all passing). - Direct
- False positive on
Download-SpeakerDeck.ps1lines 3267 and 4037 (the original motivating example). Verified manually after the Step 2c2 addition; the upstream consumer's CHANGELOG documents the corresponding script-side adoption asr27 — psa-py-v4-llm-governance-baseline.
- Rules registry: unchanged at 46 rules. No new rule code.
- Configuration schema: unchanged. No new keys; no removed keys.
- CLI surface: unchanged. No new flags.
- SemVer impact: PATCH increment per SPEC.md §1.4 (bug-fix / internal-defence-tightening with no public-API change).
No action required for any consumer. Repositories that hit the
PSA2009 false positive will see their 0/0/0 baseline restored
automatically on upgrade from 4.0.0 to 4.0.1. Repositories that
were not affected see no change.
This is the first major version since psa.py 2.0.0. It is a
breaking release: a new opt-in rule (PSAP0005) is added, the
configuration schema gains a new boolean key (psap0005_relaxed_mode),
and the SPEC.md introduces a new §1.5 ("Design philosophy — psa.py
as an LLM-assisted maintenance guardrail") that re-frames the role
of the project-convention rule family (PSAPxxxx) explicitly as the
LLM-governance gate for consumer repositories.
- The new rule
PSAP0005, even though default-off, expands the detection surface materially. Repositories that adopt the rule on their previously-clean baseline may surface previously-tolerated revision-anchored prose at a much higher rate than PSAP0003 (which caught only structured tag forms). - The
SPEC.mdformally codifies the LLM-governance philosophy in a new §1.5 and rewrites portions of §4.35 / §4.36 / §4.37 to make the scope of the inline-comment scanner explicit. This is a documentation-level breaking change for consumers that depended on the older (looser) interpretation. - The
.psa.config.jsonschema gainspsap0005_relaxed_modeand its validator. Older configs without the key continue to work unchanged (the key defaults tofalse), so this is backward-compatible at the config-file level — but consumer SPECs that document their config now have a new field to surface.
-
PSAP0005— Revision reference in comment body (warning, default OFF, new). The broader companion ofPSAP0003. WherePSAP0003catches only the five structured tag forms (# r42:,# r42+:,# r42-update3:,# ---- r42: ----,# (r42)),PSAP0005catches ANYrNNreference inside a comment body. This closes the gap that allowed descriptive-prose anchors like "Added in the r71 release", "As of r74, …", "Before r74, …", and "Earlier revisions (before r74)" to evadePSAP0003while still encoding moving frame-of-reference into the script body. SeeSPEC.md§4.37 for the full specification. -
psap0005_relaxed_modeconfiguration flag (boolean, defaultfalse). When set totruein.psa.config.json, four prose exemption patterns are applied:- A. SECTION header:
# SECTION rNN: … - B. SPEC cross-reference:
(rNN, SPEC §D.YY)/(rNN, SPEC D.YY) - C. Added-in-release phrasing:
(added|introduced|landed| ported) (in|with) (the )? (<script_name> )? rNN - D. Earlier-revisions prose:
(earlier|previous|prior) (revisions|releases) … rNN
Relaxed mode is the migration aid for repositories with significant pre-existing rNN-anchored prose under SPEC §D.31-style conventions (e.g., Deploy-Drivers-For-WindowsServer). The recommended steady- state is
false(strict), with the migration plan documented in the consumer SPEC. - A. SECTION header:
-
SPEC.md§1.5 — Design philosophy: psa.py as an LLM-assisted maintenance guardrail. New normative section that codifies why the rule families exist, mapping each LLM failure-mode pattern (revision-anchored prose, EOF revision history blocks, silent cross-file helper drift, LF-only emission from Python script generators, invented cmdlets, locale-dependent constructs, PSCustomObject sealed-object semantics) to the rule that enforces it. Two policy corollaries follow: language-correctness rules (PSA1xxx–PSA9xxx) default on; project-convention rules (PSAPxxxx) default off but become contractual when opted in. -
SPEC.md§4.35 / §4.37 — Detection scope clarification. Both PSAP0003 and PSAP0005 now have explicit "Detection scope" subsections that document: (1) inline comments only, (2) string literals excluded, (3) block comments<# ... #>out of scope by design (a residual human-review responsibility), (4) word boundary including hyphen so that compound identifiers likeradeon-r9000-seriesdo not fire, (5) at most one report per matching line. -
SPEC.md§5.3 Schema — full configuration field table. The schema table now listspsa8001_ignore_functions,psa2010_known_cmdlets, andpsap0005_relaxed_modealongside the pre-existing four fields.
-
Rule catalog count: 45 → 46. The new
PSAP0005brings the total to 46 (PSA1xxx ×3, PSA2xxx ×11, PSA3xxx ×6, PSA4xxx ×4, PSA5xxx ×4, PSA6xxx ×8, PSA7xxx ×2, PSA8xxx ×1, PSA9xxx ×2, PSAPxxxx ×5).psa.py --list-rulesand--self-checkreflect the new catalog. -
Configuration validator (
--config-check) gains a boolean-key registry. A new_CONFIG_BOOL_KEYSfrozenset enumerates all configuration keys that must be JSON booleans;psap0005_relaxed_modeis its first member. The validator emits an error if the key is present with a non-boolean value.
test_psa_rules.pygains Section 2c (PSAP0005 relaxed-mode tests, 15 cases) and Section 2d (PSAP0003 + PSAP0005 dedupe, 1 case). Total test count: 212 → 213. The relaxed-mode harness drivesanalyze_textdirectly withcfg.psap0005_relaxed_mode = Trueto exercise each of the four exemption patterns plus the three negative cases (As of rNN, parenthesised tag, bare colon tag still fire) and the string-literal escape.
Consumers can adopt 4.0.0 in three steps:
-
No-op upgrade (zero-effort): Bump the local
psa.pyto 4.0.0.PSAP0005is off by default; no behavioural change. -
Opt-in relaxed: Add
"PSAP0005"to theenablelist AND"psap0005_relaxed_mode": trueto.psa.config.json. The rule fires for allrNNreferences in comments EXCEPT the four exemption patterns above. This is the recommended adoption path for repositories with significant SPEC §D.31-style prose (Deploy-Drivers-For-WindowsServer is the canonical example). -
Opt-in strict: Add
"PSAP0005"to theenablelist with either"psap0005_relaxed_mode": false(explicit) or the field omitted (which defaults tofalse). The rule fires for everyrNNreference in comments. This is the recommended steady- state once the legacy prose has been cleaned up.
The Deploy-Drivers-For-WindowsServer repository adopts option (2) at its 4.0.0 baseline release (chipset r76 / graphics r42 / bthpan r24 / npu r20), with a migration roadmap to option (3) documented in its SPEC §A.13.
-
PSA2010— Call to undefined function (error, default ON). Cross-file rule that flags any Verb-Noun-style call whose target is not defined in any scanned file and is not in theKNOWN_CMDLETSwhitelist (Microsoft.PowerShell.Core / Management / Security / Utility / Diagnostics, CimCmdlets, PKI, PnpDevice, Defender, BitLocker, NetTCPIP / NetAdapter, SecureBoot, ScheduledTasks, Storage, Archive, WindowsCapability, ConfigCI, International, WSMan). Designed to catch typos such as the historicalFind-Signtool→Find-KitTool 'signtool.exe'reference defect documented in the consumer repository's r75 release. The rule deliberately limits its reach to names whose verb segment is inAPPROVED_VERBS(false-positive defense against hyphenated domain-specific tokens likePhantom-OK,Multi-OS,Chipset-Driver-CodeSign).Consumers can extend the cmdlet whitelist via the new
.psa.config.jsonfield:{ "psa2010_known_cmdlets": [ "Get-MyModuleFoo", "Set-MyModuleBar", "MyModule\\Reset-Widget" ] }An optional
Module\Nameprefix is permitted for documentation and stripped before lookup.See SPEC.md §4.9d for the detection algorithm; the rule operates at the cross-file driver level (like
PSA8001) and is dispatched frommain()after every per-file analysis completes. -
PSA2011—Split-Path -LiteralPath ... -Parenttriggers AmbiguousParameterSet (error, default ON). File-local rule that flagsSplit-Pathinvocations containing both-LiteralPathand-Parent(in either order). On Windows PowerShell 5.1 ja-JP this combination raisesAmbiguousParameterSet, Microsoft.PowerShell.Commands.SplitPathCommandat runtime; the fix is to use[System.IO.Path]::GetDirectoryName($path)orSplit-Path -Path $path -Parent(without-LiteralPath). Single-line and backtick-continuation forms are both handled.See SPEC.md §4.9e for the detection algorithm. This rule surfaced the proximate cause of the 2026-05-24 r75 release's WHQL co-sign analysis failure on a ja-JP Windows Server 2019 bench host:
Get-InfDriverFileListused this construct in the AmdChipsetDriver / AmdGraphicsDriver / MSBthPanInbox sister scripts, propagating aParameterBindingExceptionthrough the outertry/catchofTest-WhqlCoSignatureand silently degrading every co-sign verdict to the conservative'self-only'fallback. -
KNOWN_CMDLETSset (≈200 entries) andKNOWN_CMDLETS_LOWERderived set. Comprehensive default whitelist forPSA2010. The set is module-organised in source comments for maintainability; consumers add to it via the new config field rather than editing the analyzer source.
-
Rule registry now contains 45 rules (was 43 in 3.8.0). The net change is two additions (
PSA2010,PSA2011); no existing rules were removed or renamed.Get-Command rulesand--list-rulesboth reflect the 45-rule catalog.--self-checkexits 0 against the matching SPEC.md. -
.psa.config.jsonschema gainspsa2010_known_cmdletsas an optional list-of-strings field.--config-checkvalidates the field's type and rejects non-list values with exit 2.
SPEC.md§4.9d, §4.9e (new sections): detailed specification forPSA2010andPSA2011including detection algorithm, motivation, suggested fix, inline suppression syntax, and differences from related rules (PSA2001,PSA3005,PSA8001).SPEC.mdAppendix A: severity matrix extended with three previously-missing rows (PSA2009,PSA7002) and two new rows (PSA2010,PSA2011). Total now 45 entries matching the runtimeRULESregistry.README.md/README.ja.md: rule count text bumped from 43 → 45 across the rule table and the introductory blurb.
test_psa_rules.py: new Section 2b adds 13PSA2010cross-file test cases (positive:Find-Signtooltypo, undefined helper; negative:Find-KitTooldefined locally,Get-Contentin cmdlet whitelist,Get-PnpDevicefrom PnpDevice module, external binary without hyphen, .NET class method call, third-party cmdlet viapsa2010_known_cmdlets, function name appearing only in string literal or comment, bare verb-only token, parameter-like-Verbose, cross-file union satisfying same-file definition).test_psa_rules.py: new in Section 1, 10PSA2011test cases (positive: classic form, reversed switch order, backtick continuation; negative:-Pathwith-Parentonly,-LiteralPathwith-Leaf, positional path, .NETGetDirectoryName, mention in a comment, mention in a here-string; inline suppression).
- Backward compatible. No existing rule behaviour, severity,
default-enabled flag, message format, output schema, exit code,
or config schema key was changed. Consumers on
psa.py3.8.0 can upgrade to 3.9.0 without code changes; the new rules will fire on any pre-existingFind-Signtool-style typos orSplit-Path -LiteralPath -Parentcalls, both of which are latent defects that should be fixed regardless.
-
PSA2009 (Warning) — PSCustomObject property assigned without prior declaration. New file-level rule in the PSA2xxx (variable / scope) family. Fires when a
.-style property assignment ($Var.PropName = value) targets a property that was not declared in any$Var = [pscustomobject]@{...}initialiser in the same file AND was not added later viaAdd-Member -MemberType NoteProperty -Name PropName. The rule models the PowerShell 5.1[pscustomobject]sealed-object semantic: such an assignment raises a terminating exception ("<PropName>" の設定中に例外が 発生しました: "このオブジェクトにプロパティ '<PropName>' が 見つかりません。"in Japanese locales) at runtime, on the first phase that attempts the assignment, which is too late for long-lived pipeline scripts where the assignment site can be hundreds or thousands of lines away from the initialiser. PSA2009 closes the gap at static-analysis time.Detection runs in four passes:
- Initialiser pass — harvest declared property names from
every
$VarName = [pscustomobject]@{...}initialiser, with proper brace-balanced parsing that respects string literals and nested hashtables. Scope qualifiers ($Script:,$Global:,$Local:,$Private:) are stripped so$Script:Ctx = [pscustomobject]@{...}and a later$Ctx.Foo = ...correlate. Add-Memberpass — recognise the two surface forms ($Var | Add-Member ...andAdd-Member -InputObject $Var ...) and extend the declared set for the target variable.- Hashtable-form drop pass — any variable name that is
also assigned somewhere in the file with a plain hashtable
literal (
@{...}/[hashtable]@{...}/[ordered]@{...}) is conservatively dropped from tracking. This false-positive prevention is necessary because psa.py analysis is file-level rather than function-scope-aware, and the same local variable name (e.g.,$result) can legitimately host both pscustomobject and hashtable shapes across different functions of the same file. - Assignment pass — flag every surviving
$VarName.Property = ...assignment whose target property is not in the declared set. Compound operators (+=,-=,*=,/=) and equality comparisons (-eq,-ne) are excluded. Well-known dynamic property bags ($_,$Matches,$PSBoundParameters,$Host,$Error,$PSCmdlet,$MyInvocation,$args,$input,$this) are exempt.
Inline suppression via
# psa-disable-line PSA2009works on the assignment line.Motivation: the rule was developed in response to a reproducible runtime failure on a clean-installed Japanese-locale Windows Server 2019 host where a downstream consumer (
Deploy-AMDChipsetDriverOnWindowsServer.ps1at ther72baseline) hitPHASE P05 -> FAILEDwith the localised property-not-found exception. Both the happy-path assignment ($Ctx.WhqlCoSignAnalysis = New-WhqlCoSignAnalysis ...) and thecatch-block fallback ($Ctx.WhqlCoSignAnalysis = @()) targeted a property that the[pscustomobject]@{...}$Ctxinitialiser did not declare. Neither psa.py v3.7.0 nor PSScriptAnalyzer 1.x detected the defect statically. Adding PSA2009 to psa.py v3.8.0 reproduces the defect at static-analysis time with two warnings on the affected file (the happy-path andcatch-block assignment sites) and zero false positives across the four pipeline scripts in the consumer repository (usui-tk/Deploy-Drivers-For-WindowsServer). - Initialiser pass — harvest declared property names from
every
test_psa_rules.pygrows from 148 to 163 active test cases. PSA2009 contributes 15 cases covering: simple undeclared assignment (positive),catch-block double-fire (positive),$Script:-scoped pscustomobject (positive), declared property (negative),Add-Memberpipe form (negative),Add-Member -InputObjectform (negative), hashtable-form drop pass for@{...}and[ordered]@{...}(negative),$PSBoundParametersexemption (negative), compound+=non-flagging (negative), equality comparison non-flagging (negative), read-only property access non-flagging (negative), file without any pscustomobject initialiser (negative), inline-declared properties (negative), and inline# psa-disable-line PSA2009suppression (negative).
SPEC.md§4.9c (PSA2009) — new rule specification subsection inserted between §4.9b (PSA2008) and §4.10 (PSA3001). The §4.9c numbering follows the existing convention of alphabetic-letter suffixes for rules added between major numbering anchors (cf. §4.9a PSA2007, §4.9b PSA2008).psa.py --self-check— the RULES registry grows from 42 to 43 entries with the addition of PSA2009.SPEC.md§4 grows in lock-step. The self-check report now readsrules : 43 in RULES, 43 in SPEC.md §4.psa.py --list-rules— output gains one row for PSA2009 (warning, on by default).
- PSA2009 does NOT subsume PSA2001. PSA2001 ("undefined variable
reference") operates at the variable level: it flags a reference
to
$Foowhen$Foowas never assigned. PSA2009 operates at the property level: it flags.NewProp = valuewhenNewPropis not part of the variable's[pscustomobject]surface. The two rules are orthogonal — PSA2001 cannot detect the WhqlCoSignAnalysis-class bug because the variable itself is well-defined at every assignment site; only the property is missing. - PSA2009 does NOT subsume PSA8001. PSA8001 ("function body hash
drift") operates at the cross-file function-body level. PSA2009
operates inside a single file. A producer-site missing in one
script (the Graphics P05 case in
usui-tk/Deploy-Drivers-For-WindowsServer) is structurally invisible to both PSA2009 (because there is no assignment to flag) and PSA8001 (because the function bodies legitimately diverge per thepsa8001_ignore_functionsexemption list). That class of "silently absent producer block" defect requires a runtime-level test case in the consumer repository'sTESTING.md, not a new psa.py rule. Seeusui-tk/Deploy-Drivers-For-WindowsServerSPEC §D.31.16.5 for the canonical countermeasure. - Why "warning" severity, not "error". PSA2009 is a high-confidence
defect indicator but has occasional legitimate exceptions
(
Add-Memberpatterns the rule cannot fully model, framework objects with opaque surface contracts). Warning severity allows the rule to be suppressed inline where genuinely needed while still failing CI gates that treat warnings as blockers (the default policy inusui-tk/Deploy-Drivers-For-WindowsServer's.psa.config.json).
-
PSA7002 (Warning) — PowerShell script has LF-only or mixed line endings. New file-level rule in the PSA7xxx (file format / encoding) family. Fires when a
.ps1file contains at least one line terminated by LF without a preceding CR. The canonical Windows form is BOM + CRLF (per.gitattributes*.ps1 text working-tree-encoding=UTF-8 eol=crlfin any Windows-targeted repository); LF-only and mixed line endings are silently accepted by the PowerShell AST parser but rejected by some downstream consumers (signtool on certain catalog inspection paths, MSI authoring tools, older Windows ISE) and produce spurious "modified file" diffs at the nextgit addeven when no content changed. Default: enabled.Two message variants distinguish the two ways the defect arises:
-
All-LF (
cr_count == 0): every line in the file is LF-terminated. Usually means the file was authored on Linux / macOS without newline translation. Remediation is a single bulk conversion. Message:"PowerShell script has LF-only line endings (N line(s)); canonical form is CRLF". -
Mixed (
cr_count > 0 AND lf_only_count > 0): some lines are CRLF, others LF-only. Almost always indicates that a programmatic content-generation step inserted an LF-only block (Python triple-quoted strings, shell heredocs, AI-agent file-write actions) into a CRLF file. Strictly more dangerous than the all-LF case because the defect is invisible to PowerShell's AST parser, to visual diff tools, and to grep-based "line contains CR" counts. Only a byte-level CR-count vs. LF-count equality check reveals it. Message includes up to five 1-based line numbers of the LF-only lines so a reviewer can start inspection at the specific defective region. Real-world motivating occurrence is theDeploy-Drivers-For-WindowsServerrepository'sSPEC.md §D.23write-up (commit587038e→0af5e70).
The rule operates on the post-BOM raw byte buffer and is exact (no false positives). Implementation comprises a new module-level helper
compute_line_ending_stats(raw_bytes)invoked once inmain()per file, a new rule functioncheck_line_endings(file_meta)that readsfile_meta['line_ending_stats'], and dispatch wiring inanalyze_text()after the existingcheck_utf8_bom_missing()call. The two rules are orthogonal: BOM presence and line-ending policy are independent file-level properties. -
- 6 new rule-driver test cases for
PSA7002(positive cases: all-LF, mixed; negative cases: all-CRLF, empty file; edge cases:file_metawithoutline_ending_stats,file_meta=None). - 5 new helper-function test cases for
compute_line_ending_stats()in a new Section 2.5 oftest_psa_rules.py(synthetic byte buffers covering CRLF-only, LF-only, mixed, no-trailing-newline, and empty cases). Section 2.5 has its own dispatcher because the test-tuple shape differs from the Section 1 rule tests. - Total test count: 137 → 148 (no existing tests changed).
--self-check(SPEC ↔ RULES consistency) updated to reflect the new4.28a PSA7002section; passes.--config-check(against shipped.psa.config.json.template) passes.
SPEC.md§4.28a (PSA7002) — full rule specification: rationale, detection algorithm, message-text variants, suppression mechanism, remediation in PowerShell / Bash / VS Code, and an explicit cross-reference to the motivatingDeploy-Drivers-For-WindowsServerSPEC.md §D.23lessons-learned entry. The PSA7001 "Limitations" placeholder mentions of "future PSA7002" (UTF-16 BOM variants) and "future PSA7003" (UTF-8 validity) renumbered to PSA7003 and PSA7004 respectively, since PSA7002 is now claimed for line endings..psa.config.json.template— new comment block documenting PSA7002 and when to disable it (Linux/macOS-only PowerShell 7.x projects with no Windows tooling in the consumption chain).- Header docstring of
psa.py— PSA7002 added to the "File format / encoding (PSA7xxx)" section of the rule list.
The rule is additive in the PSA7xxx family. Existing PSA7001
behavior, PSA8001 / PSA9xxx / PSAPxxxx behavior, CLI flags, JSON /
SARIF schemas, and configuration schema are all unchanged. The
only observable effect for existing consumers is that .ps1
files with LF-only or mixed line endings will produce one new
warning per file (suppressed normally via disable: ["PSA7002"]).
Consumers whose .ps1 files already pass .gitattributes
normalisation see no behavior change. The CLI's --list-rules
output, the rule catalog in psa.py --version --list-rules, and
the SPEC TOC will list one new entry, all of which are documented
in this entry.
- PSA2007 (Warning) — Parameter name shadows a PowerShell automatic
variable. New rule that inspects every
param(...)block (top-level script param and per-function param blocks) and reports any parameter whose name collides with the risky auto-variable list. Mirrors PSScriptAnalyzer'sPSAvoidAssignmentToAutomaticVariablerule. This rule would have caught the v3.5.x miss of an$Eventparameter that silently shadowed the engine's$Eventauto-variable inside an event-subscriber action block. Default: enabled. - PSA3006 (Warning) — Deprecated WMI cmdlet usage. New rule that
detects calls to
Get-WmiObject,Invoke-WmiMethod,Register-WmiEvent,Remove-WmiObject,Set-WmiInstance, and thegwmialias. CIM cmdlets (Get-CimInstance et al.) are the supported successors; PowerShell 6+ has removed the WMI cmdlets entirely. Mirrors PSScriptAnalyzer'sPSAvoidUsingWMICmdlet. Intentional WMI usage in CIM-fallback paths should be silenced with the inline suppression marker# psa-disable-line PSA3006 -- <rationale>. Default: enabled. - PSA6007 (Info) — Advanced function returns a value but does not
declare
[OutputType()]. Mirrors PSScriptAnalyzer'sPSUseOutputTypeCorrectly. Fires when a function has[CmdletBinding()], contains at least onereturn <expr>statement, and does NOT already declare[OutputType(...)]. The[CmdletBinding()]gate keeps the false-positive rate low: only advanced functions are checked. Default: enabled. - PSA6008 (Info) — Function with attributes has no explicit
param()block. Detects functions that have[CmdletBinding()],[OutputType(...)],[Alias(...)], or[Diagnostics.CodeAnalysis.SuppressMessageAttribute(...)]but no explicitparam()declaration. PowerShell silently accepts the omission, but the attributes then have no scope and tools (PSScriptAnalyzer, Get-Help) cannot find them. No direct PSScriptAnalyzer equivalent. Default: enabled. - PSA2008 (Info) —
$Script:Foo++/+=/-=without prior initialization. Detects in-place mutations of script-scoped variables that lack a preceding plain$Script:Foo = ...initialization. Coercion ($null + 1 = 1) is type-fragile; the rule encourages an explicit initial value. No direct PSScriptAnalyzer equivalent. Default: enabled.
- PSA2002 — significantly expanded
RISKY_SHADOW_VARSset. The v3.5.x list contained only 8 auto-variables; v3.6.0 expands it to match the PowerShell engine's actual automatic-variable inventory (38 entries), including the previously-missed$Event,$EventArgs,$EventSubscriber,$Sender,$Error,$PSCmdlet,$PSBoundParameters,$MyInvocation,$Home,$Profile, etc. The$nullauto-variable is deliberately excluded because$null = <expr>is the canonical PowerShell "discard" idiom ($null = $list.Add(1)is the value-suppressing equivalent of[void]$list.Add(1)); PSScriptAnalyzer follows the same exemption. - Header docstring updated to list all 8 new / refined rules
with the
(new in v3.6.0)annotation. __version__bumped from3.5.1to3.6.0. Per SemVer 2.0.0, this is a minor release: new rules are added (additive), and the existing rule set retains its semantics. ThePSA2002expansion ofRISKY_SHADOW_VARSis a behavioural broadening — code that was previously silent may now emit PSA2002 warnings — but the rule's documented intent ("flag assignments to risky auto-variables") is unchanged, and the default-on / Warning level is preserved.VERSIONfile updated to3.6.0.
- All 5 in-repository / sibling-repository scripts continue to pass
the canonical 0/0/0 baseline:
Download-SpeakerDeck.ps1(ai-generated-artifacts): 0/0/0 (after inline PSA3006 suppression for an intentional WMI fallback inShow-PowerShellEnvironmentand the addition of[OutputType([pscustomobject])]to two helpers).Deploy-AMDChipsetDriverOnWindowsServer.ps1,Deploy-AMDGraphicsDriverOnWindowsServer.ps1,Deploy-AMDNpuDriverOnWindowsServer.ps1,Deploy-MSBthPanInboxOnWindowsServer.ps1(Deploy-Drivers-For-WindowsServer): all 0/0/0 after backporting the same fixes (renamed$home→$winHomeLocation,$profile→$osProfile, added[OutputType([...])]to all 23 functions that return a value, added PSA3006 inline suppression to 15 intentional WMI fallback lines).
- PSA8001 (cross-script function-body drift) regression: the 6
shared helpers governed by PSA8001 in Deploy-Drivers retain
byte-for-byte parity across all four scripts after the v3.6.0
uplift. The known per-script helper
Get-OrEnsureSecureBootBaselinecontinues to drift by design (already in.psa.config.jsonpsa8001_ignore_functions).
-
GitHub Actions CI workflow enforcing the three self-quality gates on every push and pull request. New workflow:
.github/workflows/scripts__python__powershell-static-analyzer.yml.- Pillar 1 (
pytest test_psa_rules.py) — runs the full rule self-test suite that ships in this directory. - Pillar 2 (
python3 psa.py --config-check .psa.config.json.template) — validates the shipped configuration template against the documented schema. - Pillar 3 (
python3 psa.py --self-check) — verifies thatSPEC.md§4 rule headings agree with the runtime RULES table. - Triggers: push / pull_request on
mainwhenpsa.py,VERSION,test_psa_rules.py,SPEC.md,.psa.config.json.template, or the workflow file itself changes; plusworkflow_dispatch. - Timeout tier: T1 (30 minutes) per repository-root
/SPEC.md§4.1. Fork-PRif-guard per/SPEC.md§5.
- Pillar 1 (
-
SPEC.md§12.6 Continuous Integration in this repository. A new subsection that records where the CI workflow lives and points to repository-root/SPEC.mdfor governance. -
CI status badge in
README.mdandREADME.ja.mdimmediately after the title. -
Documentation: cross-link to consumer-side adoption.
SPEC.md§12 gained a new informative subsection §12.5 Consumer-side adoption recording how--config-check(§12.2) and--self-check(§12.3) are wired into a downstream repository's workflow.README.mdandREADME.ja.mdVerified consumers table entry forDeploy-Drivers-For-WindowsServernow lists all four pipeline scripts.- Pillar 1 (
pytest test_psa_rules.py) — runs the full rule self-test suite that ships in this directory. - Pillar 2 (
python3 psa.py --config-check .psa.config.json.template) — validates the shipped configuration template against the documented schema. - Pillar 3 (
python3 psa.py --self-check) — verifies thatSPEC.md§4 rule headings agree with the runtime RULES table. - Triggers: push / pull_request on
mainwhenpsa.py,VERSION,test_psa_rules.py,SPEC.md,.psa.config.json.template, or the workflow file itself changes; plusworkflow_dispatch. - Timeout tier: T1 (30 minutes) per repository-root
/SPEC.md§4.1. Fork-PRif-guard per/SPEC.md§5.
- Pillar 1 (
-
SPEC.md§12.6 Continuous Integration in this repository. A new subsection that records where the CI workflow lives and points to repository-root/SPEC.mdfor governance. The four normative subsections §12.1–§12.4 and the informative §12.5 are unchanged. -
CI status badge in
README.mdandREADME.ja.mdimmediately after the title.
- Documentation: cross-link to consumer-side adoption of the
self-quality gates. Following the verified consumer
usui-tk/Deploy-Drivers-For-WindowsServerpublishing its own SPEC §A.11.6 Self-quality gates forpsa.py(consumer-side usage), the upstream documentation in this directory gained the reciprocal cross-references:SPEC.md§12 gained a new (informative, non-normative) subsection §12.5 Consumer-side adoption that records how--config-check(§12.2) and--self-check(§12.3) are wired into a downstream repository's workflow, with a direct link to the verified consumer's §A.11.6. The four normative subsections §12.1–§12.4 are unchanged; §12.5 is purely a pointer to where to read the downstream half of the story.README.mdandREADME.ja.mdVerified consumers table entry forDeploy-Drivers-For-WindowsServernow lists all four pipeline scripts (Deploy-AMDChipsetDriverOnWindowsServer.ps1,Deploy-AMDGraphicsDriverOnWindowsServer.ps1,Deploy-AMDNpuDriverOnWindowsServer.ps1,Deploy-MSBthPanInboxOnWindowsServer.ps1) — the BthPan script was previously omitted — and the Reference column now links both to SPEC §A.11 (analyzer setup, version policy, baseline) and to SPEC §A.11.6 (consumer-side--config-check/--self-checkadoption), giving readers a single hop from the upstream consumer list to the downstream usage docs.
psa.pyitself,test_psa_rules.py,VERSION, and the.psa.config.json.templateare unchanged. The three self-quality gates remain at the 3.5.1 baseline (104 test cases pass,--self-checkgreen,--config-checkclean against the shipped template).
-
PSA5004(HardcodedComputerName) now actually fires. The rule's regular expression matches the pattern-ComputerName "literal"(or single-quoted), butanalyze_text()was passing it the output ofstrip_strings_and_comments()— where every string literal is collapsed to whitespace — so the rule could never observe its intended trigger. The function was changed to accept BOTH the rawtextand the strippedcleanform (matching the existing two-argument pattern used bycheck_balance(text, clean, ...)and other rules):-
The regex now scans the raw
text, so the string literal is visible and its host value can be reported. -
The matched span is then validated against the same range in
clean: the bareword-ComputerNamesurvives the stripper when it appears as actual code, but is blanked when it appears inside a comment or another string literal. This position-based cross-check (sound becausestrip_strings_and_comments()preserves character positions one-to-one) correctly suppresses three classes of false positives:# Invoke-Command -ComputerName "server01"(in comments)$cmd = "Invoke-Command -ComputerName \"server01\""(in outer string literals)$msg = "Use -ComputerName 'foo' carefully"(in any other string literal that happens to contain the keyword)
The whitelisted hosts (
localhost,.,127.0.0.1) and the variable-argument negative case (-ComputerName $target) continue to be silent. SPEC.md §4.21 already specified this behaviour; the implementation was lagging. -
-
test_psa_rules.pyPSA5004 coverage was expanded from the 3.5.0 baseline-locking pair (which pinned the buggy non-firing behaviour) to 10 cases that exercise positive detection, the three whitelist values, both quote styles, case-insensitive matching, and the three false-positive defences listed above.
-
All four
Deploy-Drivers-For-WindowsServerpipeline scripts (Chipset / Graphics / NPU / MSBthPan) remain at the 0 / 0 / 0 baseline underpsa.py 3.5.1, both with the repository-shipped.psa.config.jsonand under--include PSA5004(which forces PSA5004 even though it is on by default). Manually re-verified: none of the four scripts contains a hardcoded-ComputerNameliteral in code paths. -
The three self-quality gates (
test_psa_rules.py,--self-check,--config-check) all exit0on the 3.5.1 mainline tree. The Pillar 1 suite runs 104 test cases at this baseline (96 per-rule + 3 PSA8001 cross-file + 5 CLI self-quality, an increase of 8 from the 3.5.0 baseline of 96 total — entirely the new PSA5004 positive / whitelist / edge fixtures).
-
--config-check PATH_OR_URL: validate the schema of a.psa.config.json(JSONC) file or http(s) URL and exit. The check reports unknown top-level keys, unknown rule IDs inenable/disable,enable↔disableconflicts, badseverityvalues, non-positive integers formax_line_length/max_function_lines, type errors throughout, and uncompilable regex patterns inpsa8001_ignore_functions. Every problem is enumerated; the checker continues to the end rather than stopping at the first violation, so a single CI run sees the full picture. Exits0on a clean config,2on any error. (Pillar 2 of the self-quality gate design — see SPEC.md §12.2.) -
--self-check: verify that the siblingSPEC.md's rule catalog (every### 4.N PSAxxxx — Titleheading in §4) matches theRULEStable compiled into the runningpsa.py. The check is symmetric — both "documented in SPEC.md but missing from RULES" and "in RULES but missing from SPEC.md" are reported. The### 4.32 PSAPxxxx — Project / pipeline convention rulesoverview heading (an ID-less grouping heading covering §4.33–§4.36) is explicitly skipped by the parser. Exits0on agreement,2on drift detected, or2ifSPEC.mdcannot be read. (Pillar 3 of the self-quality gate design — see SPEC.md §12.3.) -
test_psa_rules.py: full-catalog regression suite replacingtest_psap_3_4.py. Ships fixtures for every rule in theRULEStable (positive + negative + edge cases per rule), plus PSA8001 cross-file fixtures driven throughcollect_function_bodies()/check_function_sync(), plus subprocess-driven CLI assertions for--config-checkand--self-check(including dynamically created broken configs in a tempdir). 96 test cases at the 3.5.0 baseline, no third-party dependencies (python3 test_psa_rules.py). (Pillar 1 of the self-quality gate design — see SPEC.md §12.1.) -
SPEC.md §12 "Self-quality gates": new normative section describing the three pillars (Pillar 1 / Pillar 2 / Pillar 3), the design principle that the implementation lives inside
psa.py(not in the test suite) so consumers and CI exercise identical code paths, and the release-process checklist that all three commands (test_psa_rules.py,--self-check,--config-check) must exit0on the mainline before a version is tagged.
-
SPEC.md§3 (Command-line interface) updated:- §3.1 synopsis adds the two new short-form invocations.
- §3.3 options table adds rows for
--config-checkand--self-check. - New §3.6 documents
--config-check(categories reported, short-circuit semantics: it does NOT read the implicit./.psa.config.jsonand runs beforeConfig.load()so a broken config can still be diagnosed). - New §3.7 documents
--self-check(which §4 headings are scanned, which is skipped, and the exit-code contract).
-
ANSI_GRNcolour constant added to the small ANSI palette used by terminal output, so--config-checkand--self-checkcan show a green "all good" line in addition to the existing red / yellow severity colours.
PSA5004(HardcodedComputerName) is currently a no-op in the standard pipeline. While auditing rule coverage for the new test suite, the maintainer observed thatcheck_hardcoded_computername()matches against a quoted string literal, butanalyze_text()passes it the output ofstrip_strings_and_comments()(where every string literal is collapsed to whitespace). The rule therefore never fires on its intended pattern.test_psa_rules.pypins the current (silent) behaviour with a baseline test so any future fix is flagged as an intentional behaviour change rather than a silent regression. This pre-existing gap has not affected theDeploy-Drivers-For-WindowsServersister-repository scripts: none of the four pipeline scripts contains a hardcoded-ComputerNameliteral. Fixing the rule is tracked separately and will land in a future release. (Update: fixed in 3.5.1 — see the entry above.)
3.4.0 — 2026-05-19
-
VERSIONfile alongsidepsa.pyin the same directory. Contains a single ASCII line with the current SemVer (no leadingv, no trailing whitespace beyond the terminating newline). This file is the canonical bytes-only carrier of the analyzer version, consumable without invoking Python:curl -sSL https://raw.githubusercontent.com/usui-tk/ai-generated-artifacts/main/scripts/python/powershell-static-analyzer/VERSION # → 3.4.0This enables AI / LLM-driven and CI workflows to discover the current mainline version of
psa.pycheaply (one HTTP GET, no git clone, no Python interpreter required) before deciding whether to refresh a locally-cached copy. -
Startup self-check: when
psa.pyruns and theVERSIONfile is present in the same directory, the analyzer verifies that the string inVERSIONmatches__version__insidepsa.py. On mismatch it writes a structured warning to stderr containing explicit AI / LLM-facing action items (re-fetch the latestpsa.py+VERSIONpair, re-run the affected PowerShell test suites, re-evaluate.psa.config.jsonenablelists against the latestSPEC.md). Analysis still proceeds — results are flagged provisional in the warning text but the exit code is unchanged. The check is suppressed for--list-rulesand--check-env(purely informational modes) and is a silent no-op whenVERSIONis absent (the supported single-file consumer pattern remains fully compatible).
- Documentation throughout the
ai-generated-artifactsrepository no longer references a fixedpsa.pyversion (e.g. "psa.py 3.3.0"). Consumers are directed to fetch the latest mainline via theVERSIONfile and to re-evaluate their.psa.config.jsonwhenever the version changes. See repository-rootREADME.md"psa.py Versioning Policy" for the canonical workflow.
- This is a non-breaking minor bump. All
3.3.0rules, CLI flags, output formats, and configuration schemas are preserved unchanged. TheVERSIONfile is additive; the startup self-check is additive and produces no behaviour change in the matching-version case. Existing consumers that copiedpsa.pyas a single file without sibling metadata continue to work unchanged (noVERSIONfile → no warning).
3.3.0 — 2026-05-18
PSAP0003(warning, default OFF, opt-in via.psa.config.json): inline revision-tag comments. Fires on# rNN:,# rNN+:,# rNN-update:,# ---- rNN: ----, and# (rNN) ...patterns inside comments. Tags inside string literals and uses ofrNNinside legitimate variables (e.g.$Script:ScriptVersion = '...-r60') are left alone.PSAP0004(warning, default OFF, opt-in via.psa.config.json): end-of-fileREVISION HISTORY/CHANGELOG/VERSION HISTORYcomment blocks. Fires once per matching header line.- New
test_psap_3_4.pyself-test fixture (18 cases) verifying both rules against positive matches, negative cases (prose mentions, string-literal contents, here-strings), and edge combinations.
Both rules share a dedicated PowerShell tokeniser
(_comment_start_positions) that classifies every # in the input
as comment-start vs in-string vs in-here-string vs in-block-comment.
This is more precise than relying on strip_strings_and_comments()
alone because string contents and comment contents are both stripped
to spaces in the latter, making them indistinguishable by position.
Both rules enforce the "revision history lives in CHANGELOG.md, not
in the script body" discipline. They complement the existing
PSAP0001 / PSAP0002 family and codify the discipline described in
Deploy-Drivers-For-WindowsServer SPEC.md §A.13
("Where revision history lives").
- No other rule semantics changed in 3.3.0. Repositories that do not
enable
PSAP0003/PSAP0004see no behaviour difference vs 3.2.0.
3.2.0 — 2026-05-17
-
PSA8xxx— Cross-file consistency (new rule category)PSA8001(warning, default ON, cross-file): function body hash drift across files in the same scan. When two or more files in the samepsa.pyinvocation define a function with the same NAME but with different normalized bodies, every occurrence is flagged. The rule is silent on single-file invocations (no peers to compare against).- New tunable
psa8001_ignore_functions(list of exact names and/orregex:patterns) to suppress drift reports for functions that are intentionally per-script.
-
PSA9xxx— Complexity metrics (new rule category)PSA9001(info, default OFF): function body exceedsmax_function_lines(default 200). Opt-in; threshold is project-dependent.PSA9002(warning, default OFF): external-process invocation (the&operator,msiexec,signtool,inf2cat,pnputil,bcdedit,sc.exe,regsvr32,wevtutil,dism,gpupdate,certutil,reg.exe,cmd.exe,powershell) without a$LASTEXITCODE/$?/.ExitCode/-PassThrureference within 5 lines after.
-
PSAPxxxx— Project / pipeline convention rules (new rule family — all disabled by default; opt in via.psa.config.json)PSAP0001(warning, default OFF): phase function naming conventionInvoke-(Prep|Verify|Inst)PhaseNN_DescriptiveName.PSAP0002(warning, default OFF): required script-identifier variables$Script:ScriptVersion/$Script:ScriptHash/$Script:ScriptShortTag.
The
PSAPxxxxfamily holds opinionated conventions tied to a specific pipeline style. The conventions shipped in 3.2.0 originated in the Deploy-Drivers-For-WindowsServer repository; other repositories can adopt them via the same opt-in mechanism. -
PSA3005(warning, default ON):Start-Transcript -Pathshould be-LiteralPath.Start-Transcript -Pathperforms wildcard expansion on its argument; paths containing PowerShell metacharacters ([,], backtick) are misinterpreted.-LiteralPathdisables expansion and is the safer default. -
New configuration tunables:
max_function_lines(int, default 200): threshold for PSA9001.psa8001_ignore_functions(list, default[]): suppress PSA8001 for the listed function names.
PSA1001(brace balance): the string tokenizer now correctly handles PowerShell's""(double-quote-doubling) escape AND the(double-backtick) escape. The previous implementation could mis-parse strings of the form"...""..."or"...\"..."`, leaving the parser stuck in double-quoted-string state and consequently miscounting braces for the rest of the file.PSA2001(undefined variable): the scope-qualifier set is extended to includescript,global,local, andprivate. References of the form$Script:Fooare now treated as runtime-deferred (the author has explicitly declared a scope, so the analyzer respects that intent) and never produce false-positive "undefined variable" reports.PSA4001(TODO/FIXME marker): the marker-matching now requires a colon or whitespace-then-letter after the marker, and ignores embedded string literals like"XXX"inside comments. Previously, comments mentioning marker words inside quoted strings produced spurious reports.
3.1.0 — Earlier
PSA7xxx— File format / encoding (new rule category, for file-level checks that operate on raw bytes before UTF-8 decoding).PSA7001(warning, default ON): PowerShell script lacks UTF-8 BOM. Windows PowerShell 5.1 falls back to the system Active Code Page (Shift-JIS / cp932 on ja-JP) when a.ps1file has no BOM and contains non-ASCII bytes, causing mojibake. Adding the three-byte UTF-8 BOM (0xEF 0xBB 0xBF) at the start of the file forces correct interpretation regardless of console code page.
analyze_text()signature extended with optionalfile_metaparameter (backward compatible — existing callers passing only(text, cfg)are unaffected; PSA7xxx rules become no-ops whenfile_metais absent).main()file reader switched frompath.read_text()topath.read_bytes()so the BOM can be inspected before decoding (read_textsilently strips BOM, defeating any in-text inspection).
2.3.0 — Earlier
The --config <URL> code path is now production-grade:
- Browser-like User-Agent (Chrome 131) plus
Sec-Ch-Uaclient hints, so CDN / WAF defaults that block obvious bot UAs (notably Cloudflare-fronted sites) do not interfere. - Explicit TLS 1.2 minimum, maximum auto-negotiated to TLS 1.3 against modern servers. Old TLS 1.0 / 1.1 are not offered (RFC 8996). Certificate verification is always on.
- Exponential-backoff retries on transient failures, modelled on
the
Invoke-WebRequestWithRetrypattern from the companion PowerShell project:- 5xx responses → retry, waiting
2^attempt × 3seconds (6 s, 12 s, …) - Network / timeout errors → retry, waiting
2^attemptseconds (2 s, 4 s, …) - 4xx responses → fail immediately (persistent client error)
- 5xx responses → retry, waiting
- Env-var tuning for CI flexibility:
PSA_CONFIG_TIMEOUT— per-attempt timeout (default 30s)PSA_CONFIG_MAX_RETRIES— total attempts (default 3)PSA_CONFIG_QUIET— suppress retry-progress messages on stderr
See SPEC §5.4 for the full contract.
2.2.0 — Earlier
- Configuration files are now JSONC. Add
// line commentsand/* block comments */to.psa.config.jsonas freely as you would in JavaScript. Comment-like text inside string literals is preserved intact.
-
--configaccepts URLs. In addition to a local path, you can point--configat any http(s) URL — most commonly a GitHub raw URL for sharing a team-wide configuration:psa.py --config https://raw.githubusercontent.com/<owner>/<repo>/<branch>/.psa.config.json <script>.ps1
-
Template file shipped. A new file
.psa.config.json.templateships next topsa.pyin this directory.
2.1.0 — Earlier
-
Runtime probe (
--check-env/--show-env) that reports whether PowerShell and PSScriptAnalyzer are available on the current host. The output is purely informational; it never affects exit codes or issue counts.==== psa.py: Environment Detection ==== psa.py : 2.2.0 Python : 3.12.3 (Linux 6.18.5) PowerShell : pwsh 7.4.6 at /usr/bin/pwsh PSScriptAnalyzer : 1.22.0 (available) Info: PSScriptAnalyzer is available in this environment. For comprehensive PowerShell static analysis, consider running Microsoft's analyzer in addition to psa.py: pwsh -Command "Invoke-ScriptAnalyzer -Path <script>.ps1"
- The probe is particularly useful for AI agents (Claude, etc.) and CI
sandboxes where the availability of PowerShell may vary per
execution. It is fast, side-effect-free, and bypasses user profiles
(
-NoProfile -NonInteractive).
2.0.0 — Earlier
Major release inspired by Microsoft's PSScriptAnalyzer and Vidar Holen's shellcheck. It expanded the rule set to 27 rules and added JSON / SARIF output, inline suppression, configuration files, and multi-file scanning — while preserving the single-file, zero-dependency design.
The capability snapshot at the 2.0.0 release time covered:
| Area | At 2.0.0 |
|---|---|
| Rule codes | PSA1001–PSA6006 (27 rules total) |
| Output formats | Text / JSON / SARIF 2.1.0 |
| Suppression | # psa-disable-line, next-line, file |
| Configuration | .psa.config.json + CLI |
| File handling | Multiple files / directories / glob |
| Color output | TTY-aware ANSI (NO_COLOR honored) |
| Heredoc / sub-expr | Full @"…"@, @'…'@, $(), @() |
The rule taxonomy established here (PSA1xxx for structural,
PSA2xxx for variable, PSA3xxx for command, PSA4xxx for comment,
PSA5xxx for security, PSA6xxx for style) has remained stable since
2.0.0. The PSA7xxx (file format / encoding) category was added in
3.1.0, PSA8xxx (cross-file consistency) and PSA9xxx (complexity
metrics) in 3.2.0, and the PSAPxxxx family (project / pipeline
convention) was started in 3.2.0 (PSAP0001 / PSAP0002) and extended
in 3.3.0 (PSAP0003 / PSAP0004).
Pre-2.0.0 history is not recorded in this file. The 2.0.0 release was the first formal versioning milestone; earlier development was single-author exploratory work without numbered releases.