プレビューと確認を正直に動作させる

ShellBeginner
オンラインで実践に進む

はじめに

部屋変更コマンドは主催者の信頼に応える必要があります。プレビューは作業を残さず、拒否は何も行わず、承認は条件を満たした完全な計画だけを適用すべきです。WhatIf 対応を宣言するだけでは、すべてのファイル書き込み方法を保護できません。

この独立した実験は、専用のイベントと未完成スクリプトから始まります。前のガイド付き実験の ID ベースの検証、JSON、パラメーター、エラー処理を使います。以前の VM のファイルは不要です。PowerShell と合成リソースは準備済みです。既定の Terminal で実際のネイティブ確認を行い、実際に保存したイベントを開き直します。所要時間は、読む時間と復旧を含む作者の見積もりです。

すべての書き込みを 1 つの判断の内側に置く

このステップでは、誤解を招く WhatIf 実装を修正し、プレビューと確認の拒否のどちらでも作業領域が変わらないようにします。

初期スクリプトが安全でない理由を確認する

Terminal を開きます。最初は Linux シェルです。この役割のセッションだけに UTF-8 を設定し、クリーンな PowerShell セッションを起動します。env はこのプログラムだけに設定を渡し、-NoLogo はバナーを非表示にし、-NoProfile は個人用プロファイルを読み込みません。

env LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 pwsh -NoLogo -NoProfile

PS プロンプトを待ちます。以降はすべて PowerShell コマンドです。この実験には、以前の VM とは独立した準備済みの環境があります。

Set-Location /home/labex/project/room-changes

元イベントと要求は前の実験と同じ形式です。スクリプトを変更する前に読みます。

Get-Content ./requests.json -Raw
./Show-RoomSchedule.ps1 -Path ./event.json

初期の apply-room-changes.ps1 は意図的に未完成です。WhatIf 対応を宣言していますが、許可を求めずに .NET で書き込み、部屋変更も完全には実装していません。この新しい使い捨てのデモ出力だけで試してください。

pwsh -NoLogo -NoProfile -File ./apply-room-changes.ps1 -EventPath ./event.json -PlanPath ./requests.json -OutputPath ./unsafe-preview.json -WhatIf

Plan applied. と表示されます。このメッセージだけでは、正しい計画を適用したことも、プレビューを守ったことも証明できません。実際のファイルを確認します。

Test-Path ./unsafe-preview.json
True

共通パラメーターを宣言しても、任意の .NET 書き込みが自動的に保護されるわけではありません。元イベントは無傷ですが、このプレビューでは出力ファイルを作成してはいけませんでした。Remove-Item -LiteralPath でこの使い捨てファイルだけを削除し、入力とノートはそのまま残します。

Remove-Item -LiteralPath ./unsafe-preview.json

イベント全体を保持する

入力の event.json、requests.json、small-request.json、bad-request.json と無関係な notes.txt は、バイト列、ファイルモード、所有者、mtime を保持しなければなりません。準備済みの読み取りツールは変更しないでください。自分の apply-room-changes.ps1 を置き換え、指定された新しい結果を作成できます。イベントと要求は正しい UTF-8 JSON で提供されます。イベントには一意で正確な ID、非負の整数の人数と定員、承認リストがあります。要求は、一意のセッション ID と変更先の部屋 ID を含む空でない配列です。名前は表示用ラベルであり、ID は大文字と小文字を区別します。

未知の値には、ネストしたオブジェクト、順序付き配列、null、ブール値、文字列、範囲の限られた有限 JSON 数値を 20 階層以内で含められます。完全に重複するオブジェクトキーや任意の不正なスキーマは対象外です。ConvertFrom-Json -AsHashtable -DateKind String を使い、大文字と小文字の異なる未知キーと、正確な元オフセットを持つ日付のような文字列を保持します。これらのスイッチは前の実験で説明しました。元データには When と when の両方があります。まとめてはいけません。イベント全体には ConvertTo-Json -Depth 100 を使い、未知のネストしたオブジェクトを保持します。既定の深さ 2 では足りません。1 と 1.0 のような等しい有限数値の表記は許容されます。ブール値はブール値のまま、配列順序は同じに保つ必要があります。オブジェクトのプロパティ順序は見た目だけの違いです。

出力は、既に存在する実体のある親ディレクトリ内の新しいファイルで、すべての入力と異なる必要があります。コースのディレクトリチェーンは実体のあるディレクトリで、他の書き込み処理が同時にファイルを変更することはありません。既存のファイル、ディレクトリ、リンクは、リンク先がないリンクも含め、変更せずに拒否します。無効な計画は書き込み前に呼び出し全体を拒否しなければなりません。先頭の有効な行が、後続の未知 ID、未承認セッション、重複、定員超過を許す理由にはなりません。定員との一致は有効です。変更先の部屋はその時刻に空いています。

ネイティブ ShouldProcess を使う

再利用可能な呼び出しは、検証スクリプトと同じく EventPath、PlanPath、OutputPath を受け取ります。これらの引数とインストール済みのコマンドで独立して動作し、隣接する補助ファイルに依存してはいけません。認可判断の前に完全な検証を行い、その後、要求された RoomId フィールドだけを変更した完全なイベントを保存します。

コードを保存する前に、追加事項を読みます。

  • [CmdletBinding(SupportsShouldProcess)] は、ネイティブの -WhatIf と -Confirm 共通パラメーターを追加します。単にプレビューを表示するだけのブールパラメーターを自作してはいけません。
  • $PSCmdlet は PowerShell の自動コマンドオブジェクトです。$PSCmdlet.ShouldProcess(target, action) は操作を実行すべきかを返します。WhatIf では予定する動作を表示して false を返します。Confirm では確認を求め、承認された場合だけ true を返します。
  • 検証済みの完全な計画に対して 1 回だけ呼び出します。対象は出力を識別し、動作には各セッションの正確な ID と変更前後の部屋 ID を列挙します。ForEach-Object で説明を作り、-join '; ' で文字列を結合します。オブジェクトの変更やファイル書き込みは true 分岐だけで行います。拒否では出力ディレクトリ、ファイル、一時作業を作成せず、既存の作業も変更してはいけません。
  • これは全体への 1 回の確認であり、セッションごとの確認ではありません。ここでの .NET メソッドは 2 回目のネイティブ確認を生成しません。後で確認に対応した内部 cmdlet を使う場合、追加の確認を抑制できるのは既に認可された分岐の中だけです。外側の判断を回避してはいけません。
  • 承認後、保存された各 Session はイベント全体の中の対応するオブジェクトを参照します。その RoomId を更新しても、他のすべてのフィールドは保持されます。.NET の WriteAllText 呼び出しと明示的な UTF-8 エンコーディングは前の実験と同じ仕組みで、今回は ShouldProcess で保護されます。

パス解決、元オブジェクトの解析、一致配列、大文字と小文字を区別する比較、エラー処理、使用前の全変更の収集は、前の実験と同じです。リテラルの here-string で完全な置換内容を保存します。自分のスクリプトだけを置き換えます。

Set-Content -Path ./apply-room-changes.ps1 -Encoding utf8 -Value @'
[CmdletBinding(SupportsShouldProcess)]
param(
    [Parameter(Mandatory)][string]$EventPath,
    [Parameter(Mandatory)][string]$PlanPath,
    [Parameter(Mandatory)][string]$OutputPath
)
try {
    $ErrorActionPreference = 'Stop'
    $fullOutput = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($OutputPath)
    if (Get-Item -LiteralPath $fullOutput -Force -ErrorAction SilentlyContinue) {
        throw 'Output already exists; choose a new path.'
    }
    $parent = Split-Path -LiteralPath $fullOutput
    if (-not (Test-Path -LiteralPath $parent -PathType Container)) {
        throw 'Output parent must already exist.'
    }
    $event = Get-Content -LiteralPath $EventPath -Raw | ConvertFrom-Json -AsHashtable -DateKind String
    $requests = Get-Content -LiteralPath $PlanPath -Raw | ConvertFrom-Json -AsHashtable -DateKind String -NoEnumerate
    if ($requests -isnot [array] -or $requests.Count -eq 0) { throw 'Plan must be a nonempty array.' }
    $changes = @()
    $seen = @()
    foreach ($request in $requests) {
        if ($seen -ccontains $request.SessionId) { throw "Duplicate session ID: $($request.SessionId)" }
        $seen += $request.SessionId
        $sessions = @($event.Sessions | Where-Object { $_.SessionId -ceq $request.SessionId })
        $rooms = @($event.Rooms | Where-Object { $_.RoomId -ceq $request.RoomId })
        if ($sessions.Count -ne 1) { throw "Unknown or ambiguous session ID: $($request.SessionId)" }
        if ($rooms.Count -ne 1) { throw "Unknown or ambiguous room ID: $($request.RoomId)" }
        if ($event.ApprovedSessionIds -cnotcontains $request.SessionId) { throw "Unapproved session ID: $($request.SessionId)" }
        if ($sessions[0].Attendees -gt $rooms[0].Capacity) { throw "Capacity exceeded: $($request.SessionId) -> $($request.RoomId)" }
        $changes += [pscustomobject]@{
            Session = $sessions[0]
            SessionId = $request.SessionId
            OldRoomId = $sessions[0].RoomId
            NewRoomId = $request.RoomId
        }
    }
    $description = ($changes | ForEach-Object { "$($_.SessionId): $($_.OldRoomId) -> $($_.NewRoomId)" }) -join '; '
    if ($PSCmdlet.ShouldProcess($fullOutput, "Apply complete room plan [$description]")) {
        foreach ($change in $changes) { $change.Session.RoomId = $change.NewRoomId }
        $json = ConvertTo-Json -InputObject $event -Depth 100
        [System.IO.File]::WriteAllText($fullOutput, $json + [Environment]::NewLine, [System.Text.UTF8Encoding]::new($false))
        Write-Output "Saved: $fullOutput"
    }
}
catch {
    Write-Error ("Room change failed: {0}" -f $_.Exception.Message) -ErrorAction Continue
    exit 1
}
'@

プレビューしてから操作全体を拒否する

次のコマンドは、新しい出力パスを対象として、s-a: r-old -> r-equal と s-c: r-old -> r-large の両方を説明するネイティブ WhatIf メッセージを出す必要があります。

pwsh -NoLogo -NoProfile -File ./apply-room-changes.ps1 -EventPath ./event.json -PlanPath ./requests.json -OutputPath ./event-updated.json -WhatIf

子プロセスのネイティブ状態を直ちに読みます。

$LASTEXITCODE
0

次にネイティブ確認を要求します。実際のプロンプトを待ち、動作全体を読み、N を入力して Enter を押します。プロンプトが出る前に N を貼り付けないでください。

pwsh -NoLogo -NoProfile -File ./apply-room-changes.ps1 -EventPath ./event.json -PlanPath ./requests.json -OutputPath ./event-updated.json -Confirm

Confirm、動作全体、および [Y] Yes や [N] No を含む選択肢が表示されるはずです。2 件の変更に対する質問は 1 回です。No は操作をスキップします。検証エラーではないため、子プロセスは何も行わず正常終了します。

$LASTEXITCODE
0
Test-Path ./event-updated.json
False

メモリ内の変数を信用するだけでなく、元ファイルをもう一度読みます。

Get-Content ./event.json -Raw

すべてのセッションの RoomId は r-old のままで、ネストしたノートも残っています。WhatIf と No はどちらも、ファイル、ディレクトリ、臨時の候補ファイルを残してはいけません。機能チェックは元ファイルを読み、使い捨ての PowerShell PTY で実際の N/Y 入力も含めて正確なスクリプトをテストします。これらのテストはプログラムの動作を調べるものであり、実環境で以前行った確認の履歴を作成したり、証明したりするものではありません。

パスを間違えたら Get-Location と Get-ChildItem を確認し、Set-Location /home/labex/project/room-changes で戻ります。スクリプトの入力ミスは、修正した完全な here-string を再保存して直します。誤って早く承認した場合、元データを保ち、Remove-Item -LiteralPath ./event-updated.json で自分の event-updated.json だけを削除してから、拒否を再練習します。間違いを隠すために、元イベント、要求、ノートを削除したり編集したりしてはいけません。

承認して実際のスケジュールを開き直す

このステップでは、検証済みの完全な計画を承認し、保存したイベントを読み、後続の無効な要求が拒否されることを確認します。

同じ PowerShell の Terminal セッションで、/home/labex/project/room-changes から始めます。修正済みスクリプトは前のステップで保存されており、最終出力はまだありません。新しい Terminal を開いた場合は、前のステップと同じ明示的な UTF-8 PowerShell 起動コマンドと Set-Location を使います。すべてのパスが引数なので、このスクリプトにセッション変数は不要です。

完全な承認済み計画を適用する

確認の呼び出しを再実行します。ネイティブプロンプトを待ち、両方の ID 対応関係を読み、Y を入力して Enter を押します。

pwsh -NoLogo -NoProfile -File ./apply-room-changes.ps1 -EventPath ./event.json -PlanPath ./requests.json -OutputPath ./event-updated.json -Confirm

1 回の質問の後、Saved: と出力パスが表示されるはずです。ネイティブ状態を直ちに読みます。

$LASTEXITCODE
0

これで検証済みの計画全体を 1 回確認します。クラッシュ、停電、並行書き込みに対するトランザクション保証ではありません。入力は原本として保持され、結果は独立した完全なイベントです。書き込み失敗はエラーであり、スケジュールを適用したと主張してよい理由にはなりません。

準備済みの読み取りツールは、-Path に渡されたファイルだけを読みます。欠落した結果を再生成したり、event.json にフォールバックしたりしません。

./Show-RoomSchedule.ps1 -Path ./event-updated.json

実際に保存された行では、s-a は r-equal、s-b は引き続き r-old、s-c は r-large です。2 つのセッションのタイトルは引き続き Shared maps、2 つの部屋名は引き続き Studio ですが、ID で区別できます。

出力全体を辞書として開き直し、タイムスタンプのような文字列は文字列のまま保持します。

$saved = Get-Content ./event-updated.json -Raw | ConvertFrom-Json -AsHashtable -DateKind String

ドットでネストしたプロパティをたどります。最初のセッションの元メタデータには、日付のような文字列と、大文字と小文字の異なるキーがあります。角括弧による検索では、各辞書キーを正確に使います。別々に読みます。

$saved.Sessions[0].Metadata.contact['When']
2026-11-07T09:30:00+05:30
$saved.Sessions[0].Metadata.contact['when']
Keep this distinct key

別の未知の値と、無関係な元ノートを読みます。

$saved.Unknown.offset
2027-01-02T12:34:56-07:00
Get-Content ./notes.txt
Venue manager: preserve this personal access note.

これらの観察は完全な意味的比較を補足します。2 つのフィールドが残っただけでは、すべての未知オブジェクトが保持された証明にはなりません。表示列だけの射影からイベントを再構築しないでください。

後続の無効な要求による部分変更の公開を防ぐ

準備済みの bad-request.json は、最初の要求が有効で、2 件目に未知の部屋を含みます。完成済みの結果を保つため、別の新しい出力を使います。次の呼び出しは、確認が行われる前に意図的に失敗します。

pwsh -NoLogo -NoProfile -File ./apply-room-changes.ps1 -EventPath ./event.json -PlanPath ./bad-request.json -OutputPath ./rejected-event.json -Confirm

Room change failed: と未知の部屋についての説明が表示され、確認の質問は出ないはずです。結果を直ちに読みます。

$LASTEXITCODE
1
Test-Path ./rejected-event.json
False

先頭の有効な要求だけが適用または公開されることはありませんでした。今後の正当な作業では引数を requests.json に戻してください。提供された不正例は編集しません。元イベントと完成した event-updated.json は引き続き利用できます。

既存の出力先も意図的に拒否します。WhatIf でも、認可前に前提条件を検証することを確認できます。

pwsh -NoLogo -NoProfile -File ./apply-room-changes.ps1 -EventPath ./event.json -PlanPath ./requests.json -OutputPath ./event-updated.json -WhatIf
$LASTEXITCODE
1

Output already exists; choose a new path. が表示されるはずです。プレビューだからといって、無効な引数を無視してよいわけではありません。既存結果を保持します。新しい呼び出しには新しい出力パスが必要です。スクリプト修正後にこの実験の標準結果を意図的に作り直す場合、自分の出力だけを削除し、有効な確認を再実行します。原本は復旧の基準として残します。

最終チェックは、実際の完全な結果と保持の基準を読みます。異なる ID、定員との一致、同タイトルのレコード、ネストしたデータ、失敗経路を持つ隔離イベントで、元のスクリプトバイト列をテストします。等価なループやオブジェクト実装も認められます。比較するのは JSON 値と型であり、オブジェクトのプロパティ順序、コマンドの書き方、エディター、成功メッセージではありません。チェックを繰り返しても実イベントを適用、リセット、修復することはありません。

まとめ

誤解を招くプレビューを修正し、書き込み前に計画全体を検証して、1 回のネイティブ ShouldProcess 判断を使いました。WhatIf と No は作業を変えず、Yes は要求された ID 関係と他のすべてのネスト値を保持した完全なイベントを保存しました。実ファイルを開き直し、後続の無効な要求や既存出力は、黙って公開または置き換えられるのではなく拒否されることを確認しました。