You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
The same structural defect classes keep resurfacing one command at a time — the Stop-Function -Continue escape (#10638) alone is 91 sites and ~20 pull requests so far. This issue collects the rules in one place, sizes each with a fresh AST sweep over all of public/ and private/functions/ (run 2026-08-30), and proposes making the enforceable ones a structural test so the classes cannot come back.
The invariants
1. Stop-Function -Continue requires an enclosing loop
Without a loop in the same function body, the continue unwinds out of the command and silently consumes an iteration of the caller's loop. -SilentlyContinue escapes even under EnableException. The fix wave is under way (#10636–#10653); the rule is proven AST-checkable.
2. Stop-Function in stop mode must be followed by return
48 candidate sites in 26 files (upper bound, untriaged).
Stop-Function's own help says the caller should follow with return — without EnableException the call returns normally and the code runs on with invalid state (seen live at Invoke-DbaDbLogShipping:707, fixed in #10644). The sweep counts sites where a stop-mode call sits mid-block with no return/throw/continue/break after it and is not the last statement of its block. Some will be intentional warn-and-fall-through, so enforcement needs a one-time triage into fixes plus an allowlist baseline.
3. A begin block that can stop needs the interrupt guard in process
146 commands can stop in begin — 133 guarded (91 %), 13 missing.
When a begin-block Stop-Function returns normally (the non-EnableException path), process still runs — against null or half-built state (found live in #10652). The established convention is if (Test-FunctionInterrupt) { return } as the first statement of process, and the codebase already follows it 133 times, always in first position. The holdouts:
Export-DbaExecutionPlan
Get-DbaDbStoredProcedure
Import-DbaCsv
Import-DbaParquet
Invoke-DbaDbAzSqlTip
Measure-DbaDiskSpaceRequirement
Remove-DbaXESession
Reset-DbaAdmin
Start-DbaXESession
Stop-DbaXESession
Test-DbaDiskAlignment
Get-BackupAncientHistory (private)
Invoke-DbaAdvancedRestore also lacks the guard but is triaged fine: its process block only accumulates pipeline input and its end block is guarded. Reset-DbaAdmin is the most uncomfortable entry — a command that resets sysadmin access proceeding past a failed begin validation. Per-command triage still applies before fixing: a begin stop that is only reachable with EnableException forced needs no guard.
4. end blocks: guard the work, never the cleanup
39 end blocks with statements — 21 guarded, 18 unguarded, and the 18 are correct.
This one earned its nuance from the sweep: every unguarded end block turned out to be cleanup or bookkeeping that should run after a stop — the Copy-Dba* DAC disconnects, Import-DbaCsv's connection disposal, elapsed-time messages. A naive "end must be guarded" AST rule would demand 18 wrong changes. The real rule: an end block performing the command's main work (the Restore-DbaDatabase pattern) starts with the guard; an end block doing cleanup runs unguarded. That distinction needs a human, so this stays policy text.
5. Close every connection you open, in a path errors cannot skip
The #10554 ownership pattern: a command that opens a connection (or clones one via context switch) disconnects it, placed so error paths and -WhatIf cannot skip it — finally, not after the catch. AST can find candidates but not ownership, so this also stays policy plus the existing leak-detection tooling.
6. No continue semantics inside handed-off scriptblocks
Stop-Function -Continue inside a scriptblock passed to ForEach-Object or Invoke-Command binds to whatever loop frame is live at run time. Invoke-DbaPfRelog does this intentionally and now documents it at the site (#10643); anywhere else it is a latent escape. Rule: intentional dynamic binding gets a comment, everything else gets restructured.
Enforcement proposal
The precedent exists: the "dbatools test file structure" Describe in tests\dbatools.Tests.ps1 already enforces repo-wide structure on test files. A sibling "dbatools command structure" Describe would parse every command once and assert:
Rule 3 — begin-can-stop implies the guard heads process (enforceable after the 12 fixes above; 91 % of the codebase already complies).
Rule 2 — mid-block stop without return, against a triaged baseline that shrinks over time.
Rules 4–6 become policy text. Where the policy lives — repo CLAUDE.md, the structural test, or both — is the maintainers' call; text plus AST test is the strongest combination, since the test stops regressions and the text explains why to a contributor whose PR just failed it.
Found along the way
Export-DbaExecutionPlan's process block reads Test-Bound -ParamterName Path — a misspelled parameter name, so the export-directory bootstrap does not run as intended. Worth its own small fix whenever that command is next touched.
The same structural defect classes keep resurfacing one command at a time — the
Stop-Function -Continueescape (#10638) alone is 91 sites and ~20 pull requests so far. This issue collects the rules in one place, sizes each with a fresh AST sweep over all ofpublic/andprivate/functions/(run 2026-08-30), and proposes making the enforceable ones a structural test so the classes cannot come back.The invariants
1.
Stop-Function -Continuerequires an enclosing loop91 sites found, 73 resolved, 18 remaining — tracked as #10638.
Without a loop in the same function body, the
continueunwinds out of the command and silently consumes an iteration of the caller's loop.-SilentlyContinueescapes even under EnableException. The fix wave is under way (#10636–#10653); the rule is proven AST-checkable.2.
Stop-Functionin stop mode must be followed byreturn48 candidate sites in 26 files (upper bound, untriaged).
Stop-Function's own help says the caller should follow with
return— without EnableException the call returns normally and the code runs on with invalid state (seen live atInvoke-DbaDbLogShipping:707, fixed in #10644). The sweep counts sites where a stop-mode call sits mid-block with noreturn/throw/continue/breakafter it and is not the last statement of its block. Some will be intentional warn-and-fall-through, so enforcement needs a one-time triage into fixes plus an allowlist baseline.3. A
beginblock that can stop needs the interrupt guard inprocess146 commands can stop in begin — 133 guarded (91 %), 13 missing.
When a begin-block
Stop-Functionreturns normally (the non-EnableException path),processstill runs — against null or half-built state (found live in #10652). The established convention isif (Test-FunctionInterrupt) { return }as the first statement of process, and the codebase already follows it 133 times, always in first position. The holdouts:Invoke-DbaAdvancedRestore also lacks the guard but is triaged fine: its process block only accumulates pipeline input and its end block is guarded. Reset-DbaAdmin is the most uncomfortable entry — a command that resets sysadmin access proceeding past a failed begin validation. Per-command triage still applies before fixing: a begin stop that is only reachable with EnableException forced needs no guard.
4.
endblocks: guard the work, never the cleanup39 end blocks with statements — 21 guarded, 18 unguarded, and the 18 are correct.
This one earned its nuance from the sweep: every unguarded end block turned out to be cleanup or bookkeeping that should run after a stop — the
Copy-Dba*DAC disconnects, Import-DbaCsv's connection disposal, elapsed-time messages. A naive "end must be guarded" AST rule would demand 18 wrong changes. The real rule: an end block performing the command's main work (the Restore-DbaDatabase pattern) starts with the guard; an end block doing cleanup runs unguarded. That distinction needs a human, so this stays policy text.5. Close every connection you open, in a path errors cannot skip
The #10554 ownership pattern: a command that opens a connection (or clones one via context switch) disconnects it, placed so error paths and
-WhatIfcannot skip it —finally, not after the catch. AST can find candidates but not ownership, so this also stays policy plus the existing leak-detection tooling.6. No
continuesemantics inside handed-off scriptblocksStop-Function -Continueinside a scriptblock passed toForEach-ObjectorInvoke-Commandbinds to whatever loop frame is live at run time. Invoke-DbaPfRelog does this intentionally and now documents it at the site (#10643); anywhere else it is a latent escape. Rule: intentional dynamic binding gets a comment, everything else gets restructured.Enforcement proposal
The precedent exists: the
"dbatools test file structure"Describe intests\dbatools.Tests.ps1already enforces repo-wide structure on test files. A sibling"dbatools command structure"Describe would parse every command once and assert:-Continue/-SilentlyContinue(enforceable the day the Stop-Function -Continue without an enclosing loop escapes the command and corrupts the caller #10638 fix wave lands; nested helpers via a small allowlist).process(enforceable after the 12 fixes above; 91 % of the codebase already complies).return, against a triaged baseline that shrinks over time.Rules 4–6 become policy text. Where the policy lives — repo
CLAUDE.md, the structural test, or both — is the maintainers' call; text plus AST test is the strongest combination, since the test stops regressions and the text explains why to a contributor whose PR just failed it.Found along the way
Export-DbaExecutionPlan's process block readsTest-Bound -ParamterName Path— a misspelled parameter name, so the export-directory bootstrap does not run as intended. Worth its own small fix whenever that command is next touched.Suggested order
"dbatools command structure"Describe enforcing rules 1–3.Sweeps run against the current development tree; sizes are upper bounds pending per-command triage.
This proposal was created by Claude and reviewed by Andreas Jordan.