diff --git a/.planning/codebase/ARCHITECTURE.md b/.planning/codebase/ARCHITECTURE.md new file mode 100644 index 0000000..bda9ac0 --- /dev/null +++ b/.planning/codebase/ARCHITECTURE.md @@ -0,0 +1,270 @@ + +# Architecture + +**Analysis Date:** 2026-05-07 + +## System Overview + +snipIT ships as a single PowerShell 7.5+ script (`SnipIT.ps1`, ~2627 lines, 113K) running on .NET 9. The single-file shape is a **headline product feature** — there are no modules, no compile step, no package step. The script *is* the deliverable. Internal organisation is achieved through `#region` blocks acting as logical layers within the one file. + +```text +┌────────────────────────────────────────────────────────────────────────┐ +│ SnipIT.ps1 (single file) │ +├────────────────────────────────────────────────────────────────────────┤ +│ Main loop & cleanup (L2598-2627) process lifecycle │ +│ Tray + Hotkeys (L2433-2596) NotifyIcon, global hotkeys │ +│ Floating Widget (L2355-2431) always-on-top quick-launch │ +│ Capture Orchestration (L2240-2353) Smart/FullScreen/Window/Delay │ +│ Preview Window (L1010-2238) WPF Fluent annotation editor │ +│ Smart Overlay (L797-1008) hover/drag/magnifier loupe │ +│ Capture Core (L613-795) bitmap capture + DPI + save │ +│ First-Run Install (L547-611) Start-Menu shortcut install │ +│ Icon generation (L474-545) runtime-generated .ico │ +│ PInvoke (L411-472) Win32 P/Invoke surface │ +│ Bootstrap (L322-409) diagnostics ring buffer │ +│ Core (pure logic) (L16-317) cross-platform testable │ +└────────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌────────────────────────────────────────────────────────────────────────┐ +│ .NET 9 BCL (System.Drawing · WPF · Windows Forms) │ +│ Win32 user32 / gdi32 / dwmapi (via PInvoke region) │ +└────────────────────────────────────────────────────────────────────────┘ +``` + +## Component Responsibilities + +| Component | Responsibility | File | +|-----------|----------------|------| +| Core region | Pure logic, no UI/no Win32, runs on Linux pwsh; backbone of unit-test suite | `SnipIT.ps1` L16-317 | +| Bootstrap | Diagnostic ring buffer (`Write-SnipDiag` / `Get-SnipDiag`), `$script:DiagRing` | `SnipIT.ps1` L322-409 | +| PInvoke | All `Add-Type` Win32 signatures (user32, gdi32, dwmapi) | `SnipIT.ps1` L411-472 | +| Icon generation | Runtime `.ico` synthesis (`New-SnipITIcon`, `Get-SnipITIconPath`) | `SnipIT.ps1` L474-545 | +| First-Run Install | Start-Menu shortcut writer (`Install-SnipIT` / `Uninstall-SnipIT`); no admin | `SnipIT.ps1` L547-611 | +| Capture Core | Virtual-desktop bitmap capture, self-window registry, DPI, save | `SnipIT.ps1` L613-795 | +| Smart Overlay | Hover-to-highlight, drag selection, magnifier loupe | `SnipIT.ps1` L797-1008 | +| Preview Window | WPF Fluent chromeless preview + annotation editor (single ~1.2K-line function) | `SnipIT.ps1` L1010-2238 | +| Capture Orchestration | Mode dispatch (Smart / FullScreen / Window / Delayed) | `SnipIT.ps1` L2240-2353 | +| Floating Widget | Always-on-top quick-launch dot | `SnipIT.ps1` L2355-2431 | +| Tray + Hotkeys | `NotifyIcon`, context menu, global hotkey registration | `SnipIT.ps1` L2433-2596 | +| Main loop & cleanup | Single-instance mutex, message pump, dispose chain | `SnipIT.ps1` L2598-2627 | + +## Pattern Overview + +**Overall:** Single-file region-layered script with a **WPF event-driven UI core** wrapped around a **pure-functional capture/geometry kernel**. + +**Key Characteristics:** + +- **Region-as-layer.** Each `#region` is a logical layer. Lower regions are dependency-free of higher ones (Core depends on nothing; Capture Core depends on PInvoke + Core; Preview/Overlay depend on Capture Core + PInvoke + Core; Tray/Main wires everything). +- **Pure kernel, impure shell.** All math (rectangles, DPI math, zoom, file-name resolution, capture targeting) lives in `Core` so it is unit-testable on Linux pwsh with no .NET-Windows dependencies. +- **Named-closure UI handlers.** Every WPF event handler in `Build-PreviewWindow` is a one-line shim that calls a named closure captured at window-creation time. Tests drive the same closures directly via `-TestAction`. +- **Single-instance by mutex.** A named OS mutex is acquired in `Main`; a second launch shows a notification and exits. +- **Capture loop ownership contract (RAN-14).** `Invoke-CaptureLoop` (`SnipIT.ps1:286`) takes ownership of each captured `System.Drawing.Bitmap` produced by its `CaptureFactory` closure; the preview disposes it on close. + +## Layers + +**Core (pure logic):** +- Purpose: cross-platform testable kernel — geometry, file paths, capture-target resolution, capture-loop driver +- Location: `SnipIT.ps1` L16-317 +- Contains: 18 pure functions, no UI, no Win32, no `Add-Type` +- Depends on: BCL only +- Used by: every other region; exported set drives `Test-SnipIT.ps1` `-CoreOnly` + +**Bootstrap:** +- Purpose: diagnostics ring buffer for crash triage +- Location: `SnipIT.ps1` L322-409 +- Contains: `Write-SnipDiag` (`L332`), `Get-SnipDiag` (`L341`), `$script:DiagRing` +- Used by: every region that wants to leave a breadcrumb + +**PInvoke:** +- Purpose: Win32 surface — user32/gdi32/dwmapi signatures +- Location: `SnipIT.ps1` L411-472 +- Contains: `Add-Type` declarations only +- Used by: Capture Core, Smart Overlay, Preview, Tray + +**Capture Core:** +- Purpose: bitmap acquisition + self-window exclusion + DPI + persistence +- Location: `SnipIT.ps1` L613-795 +- Key entries: `Get-VirtualScreenBounds` (`L615`), `New-ScreenBitmap` (`L620`), `Register-SelfWindowHandle` (`L634`), `Hide-OwnSnipITWindowsForCapture` (`L650`), `Show-OwnSnipITWindowsForCapture` (`L673`), `Convert-BitmapToBitmapSource` (`L683`), `Save-CaptureToFile` (`L700`), `Set-MicaBackdrop` (`L782`) +- Depends on: PInvoke, Core +- Used by: Smart Overlay, Capture Orchestration, Preview + +**Smart Overlay:** +- Purpose: hover-to-highlight + drag-select + magnifier loupe over a virtual-desktop snapshot +- Location: `SnipIT.ps1` L797-1008 +- Key entry: `Show-SmartOverlay` (`L799`) +- Depends on: Capture Core, PInvoke, Core (`Get-DragRectangle`, `Get-LoupeSourceRect`, `Get-LoupePosition`, `Test-IsClickVsDrag`) + +**Preview Window:** +- Purpose: chromeless WPF Fluent preview + annotation editor with pan/zoom/draw/text/highlight/blur/crop +- Location: `SnipIT.ps1` L1010-2238 (one ~1.2K-line `Show-PreviewWindow`) +- Internal builder: `Build-PreviewWindow` (named-closure factory — see below) +- Depends on: Capture Core, PInvoke, Core (`Copy-AnnotationList`, `Get-ClampedAnnotationRect`, `Get-ZoomCenteredOffset`, `Resolve-SaveImagePath`) + +**Capture Orchestration:** +- Purpose: dispatch table for capture modes +- Location: `SnipIT.ps1` L2240-2353 +- Key entries: `Invoke-PendingCapture` (`L2248`), `Invoke-SmartCapture` (`L2258`), `Invoke-FullScreenCapture` (`L2268`), `Invoke-WindowCapture` (`L2292`), `Start-DelayedCapture` (`L2332`) + +**Floating Widget:** +- Purpose: always-on-top draggable launcher dot +- Location: `SnipIT.ps1` L2355-2431 +- Key entry: `Show-FloatingWidget` (`L2359`) + +**Tray + Hotkeys:** +- Purpose: `System.Windows.Forms.NotifyIcon`, right-click menu, global hotkeys (`PrintScreen`, `Win+Shift+S` family) +- Location: `SnipIT.ps1` L2433-2596 + +**Main loop & cleanup:** +- Purpose: single-instance mutex, WPF dispatcher pump, ordered dispose +- Location: `SnipIT.ps1` L2598-2627 + +## Data Flow + +### Smart capture (primary path) + +1. User presses hotkey → `Tray + Hotkeys` (`SnipIT.ps1` L2433-2596) fires `Invoke-SmartCapture` +2. `Invoke-SmartCapture` (`SnipIT.ps1:2258`) calls `Hide-OwnSnipITWindowsForCapture` (`SnipIT.ps1:650`) — RAN-15 exclusion +3. `New-ScreenBitmap` (`SnipIT.ps1:620`) snapshots the virtual desktop using bounds from `Get-VirtualScreenBounds` (`SnipIT.ps1:615`) +4. `Show-SmartOverlay` (`SnipIT.ps1:799`) presents the snapshot full-screen; user hovers (Win32 `WindowFromPoint` resolved through `Resolve-WindowCaptureTarget` `SnipIT.ps1:266` and filtered by `Test-IsSelfWindowHandle` `SnipIT.ps1:247`) or drag-selects (geometry via `Get-DragRectangle` `SnipIT.ps1:18`) +5. Selection cropped via `Get-CropBounds` (`SnipIT.ps1:129`); validity gated by `Test-CaptureRectValid` (`SnipIT.ps1:120`) +6. Cropped `System.Drawing.Bitmap` handed to `Show-PreviewWindow` (`SnipIT.ps1:1012`) — **ownership transfers to the preview** +7. User annotates → saves via `Save-CaptureToFile` (`SnipIT.ps1:700`) using `Resolve-SaveImagePath` (`SnipIT.ps1:105`) +8. Preview close disposes the bitmap and calls `Show-OwnSnipITWindowsForCapture` (`SnipIT.ps1:673`) to restore widget/tray + +### Capture-loop iteration (RAN-14 contract) + +`Invoke-CaptureLoop` (`SnipIT.ps1:286`) is the contract used by burst/timed capture: + +1. Caller supplies a `CaptureFactory` closure that returns a **fresh** `Bitmap` per iteration +2. The loop owns each returned bitmap; consumer (preview) disposes +3. Factory MUST NOT pre-allocate or dispose — one bitmap per call, no caching +4. Loop terminates on user cancel or iteration-count exhaustion + +### Annotation edit flow + +1. `Show-PreviewWindow` calls internal `Build-PreviewWindow` factory which creates the WPF tree +2. Factory captures named closures over local state: `$beginPan`, `$pickColor`, `$handleMouseDown`, `$handleMouseUp`, `$handleMouseMove` (and siblings) +3. Real WPF event handlers (`MouseLeftButtonDown += { … }`) are one-line wrappers that call those closures +4. Annotations stored in a list cloned via `Copy-AnnotationList` (`SnipIT.ps1:216`) for undo; rect clamping via `Get-ClampedAnnotationRect` (`SnipIT.ps1:166`); zoom/pan recentered via `Get-ZoomCenteredOffset` (`SnipIT.ps1:186`) + +**State Management:** +- All UI state held in PowerShell closures bound to a window instance — no globals, no DI container. +- Cross-region state lives in `$script:` scoped variables (e.g., `$script:DiagRing`, self-window handle registry). + +## Key Abstractions + +**Named-closure UI handlers (Preview Window):** +- Purpose: separate UI-event wiring from event semantics so tests drive the same logic without WPF input +- Examples: `$beginPan`, `$pickColor`, `$handleMouseDown`, `$handleMouseUp`, `$handleMouseMove` inside `Build-PreviewWindow` (`SnipIT.ps1` L1010-2238) +- Pattern: factory returns the window plus the closure set; real handlers are one-line wrappers; `-TestAction` parameter on `Show-PreviewWindow` exposes the closure surface to integration tests in `Test-SnipIT-Interactive.ps1` + +**`-TestAction` hook on `Show-PreviewWindow`:** +- Purpose: deterministic integration testing of the preview without simulated mouse/keyboard +- Mechanism: `Show-PreviewWindow` accepts a scriptblock that runs after the window is laid out but before the dispatcher pumps user input; the harness invokes named closures directly, asserts on state, then closes +- Used by: every test in `Test-SnipIT-Interactive.ps1` (42 tests against a real off-screen WPF window) + +**`Invoke-CaptureLoop` ownership contract (RAN-14):** +- Purpose: prevent double-dispose / reused-bitmap bugs in burst captures +- Pattern: caller passes a `CaptureFactory` closure; loop creates a fresh bitmap per iteration via the factory; consumer (preview) takes ownership and disposes; factory must not pre-allocate + +**`SNIPIT_TEST_MODE=1` short-circuit:** +- Purpose: allow `Test-SnipIT.ps1` and external harnesses to dot-source `SnipIT.ps1` without side effects +- Effect: skips single-instance mutex acquisition, tray icon, hotkey registration, and the main message-pump loop — the script defines functions and exits cleanly +- Checked in: `Main loop & cleanup` region (`SnipIT.ps1` L2598-2627) + +**Self-window registry (RAN-15):** +- Purpose: never capture our own widget/preview/tray windows in a screenshot +- API: `Register-SelfWindowHandle` (`SnipIT.ps1:634`), `Unregister-SelfWindowHandle` (`SnipIT.ps1:645`), `Hide-OwnSnipITWindowsForCapture` (`SnipIT.ps1:650`), `Show-OwnSnipITWindowsForCapture` (`SnipIT.ps1:673`) +- Filter: `Test-IsSelfWindowHandle` (`SnipIT.ps1:247`) is the pure predicate; used by hover-target resolution to skip our own HWNDs +- Contract: any new top-level window MUST be registered via `Register-SelfWindowHandle` at creation + +**Per-monitor DPI & negative-origin virtual desktop:** +- Source of truth: `Get-VirtualScreenBounds` (`SnipIT.ps1:615`) — handles a monitor positioned to the left of the primary (negative X) or above (negative Y) +- Do NOT assume `(0,0)` is the top-left of the virtual desktop +- Capture math elsewhere in the script consumes these bounds for crop/loupe positioning + +**Diagnostics ring buffer:** +- `$script:DiagRing` populated via `Write-SnipDiag` (`SnipIT.ps1:332`); read via `Get-SnipDiag` (`SnipIT.ps1:341`); surfaced in `Show-AboutWindow` (`SnipIT.ps1:726`) + +## Entry Points + +**Production:** +- Location: `SnipIT.ps1` (top-level; `Main loop & cleanup` region L2598-2627) +- Triggered by: `pwsh -Sta -File ./SnipIT.ps1` or double-click on Windows +- Responsibilities: acquire single-instance mutex → bootstrap diagnostics → install icon if missing → register tray + hotkeys → show floating widget → enter WPF dispatcher loop → on exit, dispose tray/widget/mutex + +**Test harness — pure-logic:** +- Location: `Test-SnipIT.ps1` (Linux + Windows pwsh, no Pester) +- Triggered by: `pwsh -NoProfile -File ./Test-SnipIT.ps1` +- Responsibilities: dot-source `SnipIT.ps1` with `SNIPIT_TEST_MODE=1`, exercise the 18 Core-region functions (40 tests) + +**Test harness — interactive WPF:** +- Location: `Test-SnipIT-Interactive.ps1` (Windows-only) +- Triggered by: `pwsh -NoProfile -Sta -File ./Test-SnipIT-Interactive.ps1` +- Responsibilities: dot-source with `SNIPIT_TEST_MODE=1`, then call `Show-PreviewWindow -TestAction { … }` per test (42 tests) against a real off-screen window + +## Architectural Constraints + +- **Single-file rule.** `SnipIT.ps1` is the deliverable; do not split into modules without explicit board reversal (CLAUDE.md, `feedback_single_file.md` memory). +- **PowerShell 7.5+ only.** No PS5.1 fallbacks. No `Add-Type` shims that compile only on Windows PowerShell. +- **Threading.** WPF runs on a single STA dispatcher thread (script launched with `-Sta`). Capture work happens on the dispatcher; bitmaps are released on the same thread that allocated them. +- **Global state.** `$script:` scoped: diagnostics ring (`$script:DiagRing`), self-window handle registry, single-instance mutex handle. No other module-level mutable state. +- **No outbound network at runtime.** No CDN fetches, no telemetry, no auto-update phone-home (per `~/.claude/rules/build.md` air-gap rule). +- **No admin elevation.** First-run install writes Start-Menu shortcuts to the user profile; no HKLM, no `Program Files`. +- **Approved verbs only.** All function names use PowerShell-approved verbs (`Get-`, `Set-`, `New-`, `Show-`, `Invoke-`, `Test-`, `Resolve-`, `Register-`, `Unregister-`, `Hide-`, `Show-`, `Convert-`, `Save-`, `Write-`, `Install-`, `Uninstall-`, `Copy-`, `Start-`). + +## Anti-Patterns + +### Pre-allocating a Bitmap in `CaptureFactory` + +**What happens:** Caller of `Invoke-CaptureLoop` (`SnipIT.ps1:286`) creates one `Bitmap` outside the factory and returns it from every iteration. +**Why it's wrong:** Violates the RAN-14 ownership contract. The preview disposes the bitmap on close; the next iteration returns a disposed handle and crashes. +**Do this instead:** Allocate inside the factory closure — one fresh `Bitmap` per call. See `Invoke-SmartCapture` (`SnipIT.ps1:2258`) and `Invoke-FullScreenCapture` (`SnipIT.ps1:2268`) for the reference pattern. + +### Adding a top-level window without registering it + +**What happens:** New tray menu, settings dialog, or HUD created via `New-Object System.Windows.Window` without calling `Register-SelfWindowHandle`. +**Why it's wrong:** The window will appear in captures; hover-to-highlight will treat it as a capture target. RAN-15 regression. +**Do this instead:** After `window.Show()`, call `Register-SelfWindowHandle` (`SnipIT.ps1:634`); on close, call `Unregister-SelfWindowHandle` (`SnipIT.ps1:645`). `Hide-OwnSnipITWindowsForCapture` (`SnipIT.ps1:650`) then handles the rest. + +### Inlining WPF event logic in `Build-PreviewWindow` + +**What happens:** `MouseLeftButtonDown += { …big multi-line block…}` directly on a control inside the preview. +**Why it's wrong:** That code path is now untestable from `Test-SnipIT-Interactive.ps1` because `-TestAction` only sees named closures. +**Do this instead:** Define a named closure (e.g., `$handleMouseDown`) in the factory; make the WPF handler a one-line wrapper `{ & $handleMouseDown $args }`. Match the existing pattern in `Build-PreviewWindow` (`SnipIT.ps1` L1010-2238). + +### Assuming `(0,0)` is the virtual-desktop origin + +**What happens:** Code uses `Screen.PrimaryScreen.Bounds` or hardcodes `0,0` as the capture top-left. +**Why it's wrong:** Negative-origin layouts (monitor to the left of primary) and per-monitor DPI scaling break. +**Do this instead:** Always go through `Get-VirtualScreenBounds` (`SnipIT.ps1:615`). + +### Dot-sourcing `SnipIT.ps1` without `SNIPIT_TEST_MODE` + +**What happens:** A new test or tool dot-sources the script and is surprised by tray icons appearing, hotkeys binding, or the mutex blocking a real running instance. +**Why it's wrong:** The script is a full app, not a library. Without the test-mode flag, top-level side effects fire. +**Do this instead:** `$env:SNIPIT_TEST_MODE = '1'; . ./SnipIT.ps1` — see `Test-SnipIT.ps1` for the canonical pattern. + +## Error Handling + +**Strategy:** defensive try/catch around every Win32 boundary and every file I/O; failures route to `Write-SnipDiag` and surface in `Show-AboutWindow`. + +**Patterns:** +- Win32 calls wrapped in try/catch — no unhandled exceptions from the PInvoke region +- File I/O (`Save-CaptureToFile` `SnipIT.ps1:700`) returns a result object, not throws, so the preview can render an inline error toast +- The preview window's named closures are idempotent — calling them in any order produces a consistent state machine + +## Cross-Cutting Concerns + +**Logging:** `Write-SnipDiag` (`SnipIT.ps1:332`) writes to a bounded ring buffer in `$script:DiagRing`. Surfaced via `Get-SnipDiag` (`SnipIT.ps1:341`) and the About window. No file-on-disk log by default. + +**Validation:** Pure predicates in Core (`Test-IsClickVsDrag`, `Test-CaptureRectValid`, `Test-IsSelfWindowHandle`) — every UI region calls these rather than re-implementing geometry checks. + +**Authentication:** Not applicable — local desktop tool, no auth surface. + +**Configuration:** First-run only — `Install-SnipIT` (`SnipIT.ps1:576`) writes user-profile shortcuts. No external config file. Test-mode controlled by `SNIPIT_TEST_MODE` env var. + +--- + +*Architecture analysis: 2026-05-07* diff --git a/.planning/codebase/CONCERNS.md b/.planning/codebase/CONCERNS.md new file mode 100644 index 0000000..d14f10b --- /dev/null +++ b/.planning/codebase/CONCERNS.md @@ -0,0 +1,207 @@ +# Codebase Concerns + +**Analysis Date:** 2026-05-07 + +> Authoritative source for the documented gotchas is the **Gotchas** section of `CLAUDE.md` (lines 90–96). This document surfaces those gotchas as first-class concerns and adds findings from a sweep of `SnipIT.ps1`, the test files, and the CI workflows. Tech-debt markers (`TODO`/`FIXME`/`HACK`/`XXX`/`BUG`/`WORKAROUND`) were searched across the whole tree — **zero hits**, which is itself worth recording: any future debt should be marker-tagged so this audit stays cheap. + +--- + +## Tech Debt + +### Single-file size — navigability cost of a headline feature + +- Files: `SnipIT.ps1` (2627 lines / 113K, 54 top-level `function` declarations across 7 regions: Core, Bootstrap, PInvoke, Icon generation, First-Run Install, Capture Core, Smart Overlay, Preview Window, Capture Orchestration, Floating Widget, Tray + Hotkeys, Main loop) +- Issue: the single-file shape is a **deliberate headline product feature** (CLAUDE.md line 9 — "do not split it into modules without explicit board reversal"). It is **not** tech debt. The cost it imposes is **navigability**: `Show-PreviewWindow` alone spans `SnipIT.ps1:1012-2238` (1226 lines — the annotation editor + closures), which is larger than every other region combined. +- Impact: cold-reading agents spend tokens scanning. Tooling-side mitigations are cheap (region-aware Outline, `grep -n '^#region\|^function '`, the per-region map already in this doc). +- Fix approach: **do not split.** Keep regions tight, keep the closure-naming convention in `Build-PreviewWindow` (CLAUDE.md line 52), and prefer a `.planning/codebase/STRUCTURE.md`-style line-range index over module extraction. + +### PowerShell 7.5+ only — deliberate floor that limits install base + +- Files: `SnipIT.ps1:1` (`#requires -Version 7.5`), `CLAUDE.md:49` ("No PS5.1 fallbacks, no `Add-Type` shims that only compile on Windows PowerShell"). +- Issue: Windows 11 ships **Windows PowerShell 5.1** in-box; `pwsh 7.5+` is a separate install. On locked-down corporate boxes where users cannot install pwsh, snipIT will not run. +- Impact: shrinks the addressable install base on managed-fleet Windows. Acceptable per CLAUDE.md (deliberate tradeoff for .NET 9 + modern language features). +- Fix approach: **none — this is a documented, deliberate stance.** Do not introduce PS5.1 polyfills or `Add-Type` shims. Update `README.md` install-prereq language if user reports show recurring confusion. + +### No tagged-release / packaging flow + +- Files: `SECURITY.md:5` ("Security fixes land on `main` and are tagged as the next release"), `CLAUDE.md:74` (Scorecard `Packaging` is the known-not-a-pass). +- Issue: install path is `git clone` + run. There is no published binary, no signed installer, no release-tag-driven artifact pipeline. +- Impact: Scorecard `Packaging` cannot pass; offline / air-gapped install requires the user to clone the repo and bring the script across the boundary themselves. Aligns with the script-as-deliverable design. +- Fix approach: when a tagged-release flow is added (RAN-future), publish a SHA-256-checksummed `SnipIT.ps1` as a Release asset and SHA-pin the release workflow. **Do not** introduce a compile/package step — it would invalidate the headline single-file shape. + +--- + +## Known Bugs / First-Class Gotchas + +These are reproduced verbatim from `CLAUDE.md` lines 90–96 and elevated here so future plans see them when this file is loaded. + +### Capture-loop bitmap ownership (RAN-14 contract) + +- Files: `SnipIT.ps1:286` (`Invoke-CaptureLoop`), `SnipIT.ps1:620` (`New-ScreenBitmap`). +- Symptom on misuse: leaked GDI bitmaps (handle exhaustion across many captures) or `ObjectDisposedException` in the preview window (double-free). +- Trigger: pre-allocating a `System.Drawing.Bitmap` outside the loop, **or** disposing it inside the `CaptureFactory` closure. +- Contract: `Invoke-CaptureLoop` owns each captured bitmap. The preview disposes it on close. The loop creates a fresh bitmap **per iteration** via the `CaptureFactory` closure passed in. +- Workaround: none — follow the contract. Any change touching `Invoke-CaptureLoop` or its callers (`Invoke-SmartCapture`, `Invoke-FullScreenCapture`, `Invoke-WindowCapture`) must preserve it. + +### SnipIT-window exclusion in capture (RAN-15, shipped v0.1.1) + +- Files: `SnipIT.ps1:650` (`Hide-OwnSnipITWindowsForCapture`), `SnipIT.ps1:673` (`Show-OwnSnipITWindowsForCapture`), `SnipIT.ps1:247` (`Test-IsSelfWindowHandle`), `SnipIT.ps1:266` (`Resolve-WindowCaptureTarget`). +- Symptom on regression: SnipIT's own widget / preview / tray window appears baked into the captured frame. +- Trigger: adding a new top-level WPF or Win32 window without registering it via `Hide-OwnSnipITWindowsForCapture`. +- Workaround: every new top-level window **must** be registered through `Hide-OwnSnipITWindowsForCapture` before capture and restored via `Show-OwnSnipITWindowsForCapture` after. The Smart Overlay, Preview, Floating Widget, and Tray paths already comply. + +### Per-monitor DPI on virtual desktops with mixed scaling + +- Files: `SnipIT.ps1:615` (`Get-VirtualScreenBounds`), `SnipIT.ps1:420` (`SetProcessDpiAwarenessContext` PInvoke). +- Symptom on regression: cropped or mis-positioned captures when monitors run at different DPI scales, or when a secondary monitor is positioned to the left of the primary (negative origin). +- Trigger: assuming `(0,0)` is the top-left of the virtual desktop, or using `Screen.PrimaryScreen` bounds in place of virtual-screen bounds. +- Workaround: **always** route capture math through `Get-VirtualScreenBounds`. It returns the negative-origin-aware virtual-screen rectangle. Cursor coords from `GetCursorPos` are virtual-screen absolute and must be translated via `Get-CropBounds` (`SnipIT.ps1:129`) before indexing into the captured bitmap. + +### Single-instance mutex + +- Files: `SnipIT.ps1` Bootstrap region (~line 322 onward), Main loop region (~line 2598). +- Behaviour: a second launch shows a friendly notification and exits. The escape hatch is `SNIPIT_TEST_MODE=1`, which short-circuits the mutex, tray, hotkeys, and main loop so the test harness can dot-source the script without side effects. +- Risk: a future change that bypasses or weakens the mutex (e.g., naming collision with another tool's mutex) could cause two instances to register hotkeys with `RegisterHotKey` — Windows fails the second registration silently and the second-launched instance has dead hotkeys. +- Fix approach: any mutex change must keep the `SNIPIT_TEST_MODE=1` short-circuit and re-run both `Test-SnipIT.ps1` and `Test-SnipIT-Interactive.ps1`. + +--- + +## Security Considerations + +### Unmanaged P/Invoke surface + +- Files: `SnipIT.ps1:411-471` (PInvoke region — `kernel32`, `user32`, `gdi32`, `dwmapi`). +- Surface: `RegisterHotKey` / `UnregisterHotKey`, `GetCursorPos`, `WindowFromPoint`, `GetAncestor`, `GetForegroundWindow`, `GetWindowRect`, `IsWindowVisible`, `ShowWindow`, `SetProcessDpiAwarenessContext`, `DeleteObject`, DWM backdrop calls. +- Risk: classic Win32 — handle leaks (mitigated by the bitmap-ownership contract), focus-stealing (in-scope per `SECURITY.md:45`), and TOCTOU on the install-home write path (in-scope per `SECURITY.md:43`). +- Current mitigation: PSScriptAnalyzer Error gate, Trivy HIGH/CRITICAL block, Semgrep ERROR block, Gitleaks, jscpd < 3% — all enforced in `.github/workflows/security.yml` per `CLAUDE.md:60`. +- Recommendations: any new `DllImport` must come with (a) a unit test in `Test-SnipIT.ps1` for the pure-logic wrapper if one exists, (b) an integration test in `Test-SnipIT-Interactive.ps1` if it touches a window, and (c) a SECURITY.md scope-review if it expands the kernel/user32 surface. + +### Install-home arbitrary-file-write / path-traversal class + +- Files: `SnipIT.ps1:146` (`Get-InstallPaths`), `SnipIT.ps1:547` First-Run Install region, `SECURITY.md:43` (declared in scope). +- Risk: the install flow writes into `%LOCALAPPDATA%\SnipIT\`. A malicious `LOCALAPPDATA` env var (or symlink at the install path) could redirect the script copy. +- Current mitigation: paths derived from `Get-InstallPaths` which composes from `$env:LOCALAPPDATA` + literal `'SnipIT'` segment (`Join-Path` resists separator injection but does not resolve symlinks). +- Recommendations: consider a `Resolve-Path -LiteralPath` + canonical-path comparison before writing the script copy; add a unit test for `Get-InstallPaths` against pathological `LocalAppData` inputs (relative path, UNC, embedded `..`). + +### Supply-chain — workflow action SHA-pinning + +- Files: `.github/workflows/test.yml`, `.github/workflows/security.yml`, `.github/workflows/scorecard.yml`. +- Risk: if any action is downgraded to a tag-ref (e.g., `actions/checkout@v4` instead of a commit SHA), Scorecard `Pinned-Dependencies` regresses **and** the action becomes vulnerable to a tag-rewrite supply-chain attack. +- Current mitigation: Dependabot `github-actions` ecosystem, weekly grouped, opens routine bumps; CLAUDE.md line 96 codifies the "no manual tag-ref downgrade" rule. +- Recommendations: PR-review checklist item — every `uses:` line in `.github/workflows/*.yml` must end in a 40-char SHA. A `gh pr diff | grep -E '^\+.*uses:.*@v[0-9]'` smoke-check on each PR catches accidental tag-ref reintroduction. + +### OpenSSF posture + +- `.bestpractices.json` (project_id 12647) **`passing` is the only OpenSSF gate that blocks merge** (`CLAUDE.md:82`). +- OpenSSF Scorecard is **observational**, not blocking. Target: best-effort, do not regress. Stretch ≥ 8.0 / 10. A material Scorecard regression files a follow-up `type:chore` `area:security` issue but does not block the PR. +- Known not-a-pass: **Packaging** — the script is the deliverable; absent by design until a tagged-release flow exists. + +--- + +## Performance Bottlenecks + +### Capture loop allocation per iteration + +- Files: `SnipIT.ps1:286` (`Invoke-CaptureLoop`), `SnipIT.ps1:620` (`New-ScreenBitmap`). +- Problem: by contract, every iteration allocates a fresh `System.Drawing.Bitmap` sized to the virtual-screen bounds. For a 4-monitor 4K setup that's ~64 MB / iteration. +- Cause: the bitmap-ownership contract (RAN-14) trades reuse for safety — the preview owns it, the loop must hand off a fresh one. +- Improvement path: **do not** pre-allocate (it breaks the contract and risks the double-free symptom listed above). If profiling shows GC pressure under sustained capture, consider an explicit `System.Drawing.Bitmap`-handoff queue with one slot — but only with a redesigned ownership contract and a corresponding test in `Test-SnipIT-Interactive.ps1`. + +### Preview annotation editor — single-window monolith + +- Files: `SnipIT.ps1:1012-2238` (`Show-PreviewWindow`). +- Problem: 1226 lines of WPF + closure setup execute on every preview open. Cold-start of the preview after a capture is the perceptible interactive cost. +- Cause: the named-closure architecture (CLAUDE.md line 52) — handlers are captured at window-creation time so tests can drive them through `-TestAction`. +- Improvement path: do not split the function (it would break the closure-capture contract and the test hook). If preview cold-start becomes a perceptible bottleneck (>100ms on a target machine per `~/.claude/rules/performance.md`), measure first; candidate optimisations are lazy XAML for the annotation toolbar and deferred icon-resource resolution. + +--- + +## Fragile Areas + +### Preview-window named closures + +- Files: `SnipIT.ps1` `Build-PreviewWindow` and the closures `$beginPan`, `$pickColor`, `$handleMouseDown`, … (all inside `Show-PreviewWindow:1012-2238`). +- Why fragile: WPF event handlers are one-line wrappers that call into named closures captured at window-creation time. Refactoring a closure into a free function **breaks the test harness** (`Test-SnipIT-Interactive.ps1` drives every code path through `-TestAction` on `Show-PreviewWindow`). +- Safe modification: extend an existing closure in place. If a new code path is needed, add a new named closure inside `Build-PreviewWindow`, expose it via the test hook, and add the corresponding test case in `Test-SnipIT-Interactive.ps1`. +- Test coverage: 42 WPF integration tests (Windows-only) + 40 pure-logic unit tests (cross-platform). `-CoreOnly` mode allows the Linux test runner to dot-source `SnipIT.ps1` without WPF / Win32 deps. + +### `Add-Type` PInvoke compilation + +- Files: `SnipIT.ps1:354`, `SnipIT.ps1:468`, `SnipIT.ps1:2491`. +- Why fragile: `Add-Type -TypeDefinition` compiles C# at session start. The PowerShell-7.5+-only stance assumes a single compilation path; any reintroduction of a PS5.1 fallback (forbidden per CLAUDE.md line 49) would compound this. +- Safe modification: add new PInvokes to the **single** PInvoke type definition starting at `SnipIT.ps1:411`. Do not introduce a second `Add-Type` block in the PInvoke region — the type-name collision will surface as a hard error on second dot-source in the same session. +- Test coverage: covered indirectly via integration tests; AST-parse gate (`pwsh -NoProfile -Command [System.Management.Automation.Language.Parser]::ParseFile…`) catches syntax errors but not C# compilation errors. + +### Tray + hotkey native-window source + +- Files: `SnipIT.ps1:2433-2596` (Tray + Hotkeys region), with `Add-Type -TypeDefinition $nativeWindowSrc` at line 2491. +- Why fragile: hotkey registration via `RegisterHotKey` requires a HWND. The tray uses a hidden-window NativeWindow source compiled at runtime. Any change to the message-pump loop (`SnipIT.ps1:2598-2627` Main loop region) risks dropping `WM_HOTKEY` messages. +- Safe modification: keep the message pump on the STA thread; do not introduce additional `Add-Type` blocks in the Tray region (see above); preserve the `SNIPIT_TEST_MODE=1` short-circuit. + +--- + +## Scaling Limits + +- **Virtual-desktop size.** `New-ScreenBitmap` allocates one bitmap sized to the full virtual desktop. For very large multi-monitor setups (e.g., 6× 4K = ~200 MB), GDI+ may fail the allocation. No graceful fallback today. +- **Hotkey count.** Two hotkeys registered today (`Ctrl+Shift+S`, `Ctrl+Shift+F`). Windows allows up to `0xC000` per-process IDs — not a near-term limit. +- **Diag ring buffer.** `Get-SnipDiag` (`SnipIT.ps1:341`) backs onto `$script:DiagRing.ToArray()` — bounded ring buffer, fine for in-session diagnostics. + +--- + +## Dependencies at Risk + +- **PowerShell 7.5+** — pinned floor. Dropping below the floor is forbidden per CLAUDE.md. +- **.NET 9** — runtime target. Brings the modern WPF + System.Drawing surface that the script relies on. +- **System.Drawing.Common** — Microsoft has signalled long-term Windows-only support for this package on .NET 6+. Risk is upstream, not in our hands. Already Windows-only by design (the capture path uses `user32` + `gdi32`). +- **No npm / pip / cargo dependency tree** — supply-chain attack surface limited to GitHub Actions (Dependabot-managed, SHA-pinned). + +--- + +## Missing Critical Features + +### No tagged-release / signed-binary flow + +- Problem: `git clone` is the install path. No GPG-signed Release asset, no Winget / Chocolatey / Scoop manifest. +- Blocks: Scorecard `Packaging` pass; one-line install for non-developer Windows users; air-gapped distribution via internal mirror. +- Fix approach: future ticket — publish a SHA-256-checksummed `SnipIT.ps1` as a Release asset on tag push, with the workflow itself SHA-pinned and run from `permissions: read-all` + scoped write-permission for releases only. + +### No telemetry / crash reporting + +- Problem: a desktop tool with no telemetry has no signal on field crashes. Aligns with `~/.claude/rules/build.md` ("no telemetry enabled by default") — surfaced here as a deliberate gap, not debt. +- Blocks: nothing — design choice. Users self-report via GitHub Issues / `SECURITY.md` channels. + +--- + +## Test Coverage Gaps + +### Headless tests cannot exercise WPF / Win32 paths + +- What's not tested on Linux: every function in the PInvoke, Smart Overlay, Preview Window, Tray, and Main loop regions. The `-CoreOnly` switch (`SnipIT.ps1:14`) is the contract that lets the Linux runner skip them. +- Files: `Test-SnipIT.ps1` (40 pure-logic tests, ~646 lines), `Test-SnipIT-Interactive.ps1` (42 WPF integration tests, ~585 lines, Windows-only). +- Risk: a regression in WPF or Win32 code is caught only by the Windows leg of `.github/workflows/test.yml`. If that leg flakes, the regression slips. +- Priority: **Medium**. Mitigated by the AST-parse gate + PSScriptAnalyzer Error gate which run on both legs. + +### Install-home write-path tests + +- What's not tested: `Get-InstallPaths` against pathological `LocalAppData` inputs (relative path, UNC, embedded `..`, symlink target). +- Files: install region `SnipIT.ps1:547-611`. +- Risk: install-home is in-scope for security per `SECURITY.md:43` (arbitrary-file-write, path-traversal, TOCTOU). +- Priority: **Medium**. Add unit tests to `Test-SnipIT.ps1` — pure-logic, runs on Linux pwsh. + +### Multi-monitor mixed-DPI capture math + +- What's not tested under CI: `Get-VirtualScreenBounds` and `Get-CropBounds` against negative-origin layouts. The pure-logic helpers are unit-tested, but the integration with real `GetCursorPos` + DPI awareness only runs on developer machines. +- Files: `SnipIT.ps1:615`, `SnipIT.ps1:129`, `SnipIT.ps1:46-88` (`Get-LoupeSourceRect` / `Get-LoupePosition`). +- Risk: regression on monitor-to-the-left-of-primary configurations (the gotcha in CLAUDE.md line 94). +- Priority: **Medium**. Cross-DPI CI runners are not realistically achievable; add explicit virtual-screen-bounds fixtures (positive origin, negative origin, single monitor) to `Test-SnipIT.ps1` to widen pure-logic coverage. + +### Tech-debt-marker hygiene + +- What's not enforced: there is no CI gate that fails the build on `TODO` / `FIXME` / `HACK` / `XXX` / `BUG` / `WORKAROUND` markers. +- Current state: **zero markers** in the source tree as of this audit (verified via `grep -nE "TODO|FIXME|HACK|XXX|WORKAROUND" SnipIT.ps1 Test-SnipIT.ps1 Test-SnipIT-Interactive.ps1`). +- Risk: low today. Recommend adding a Semgrep custom rule (free in the OSS-CLI stack) that surfaces markers without blocking merge — keeps this file actionable on subsequent audits. +- Priority: **Low**. + +--- + +*Concerns audit: 2026-05-07* diff --git a/.planning/codebase/CONVENTIONS.md b/.planning/codebase/CONVENTIONS.md new file mode 100644 index 0000000..a75367c --- /dev/null +++ b/.planning/codebase/CONVENTIONS.md @@ -0,0 +1,161 @@ +# Coding Conventions + +**Analysis Date:** 2026-05-07 + +## Language & Runtime Baseline + +- **Pure PowerShell 7.5+ on .NET 9.** No PowerShell 5.1 fallbacks anywhere — `#requires -Version 7.5` at the top of script entry points. +- **No `Add-Type` shims that only compile under Windows PowerShell.** Where P/Invoke is needed, the `Add-Type @"…"@` block must compile under pwsh 7.5 on .NET 9. The `Core` region must remain free of `Add-Type` so it loads on Linux pwsh. +- **Single-file shape is a headline product feature.** All production code lives in `SnipIT.ps1`. Do not split into modules without an explicit board reversal (see `CLAUDE.md` "Repo layout"). +- **Strict mode + Stop on error** in test harnesses (`Set-StrictMode -Version Latest`, `$ErrorActionPreference = 'Stop'`). Production script uses `$ErrorActionPreference = 'Stop'` selectively around side-effectful sections. + +## Region Layout + +`SnipIT.ps1` is organised by `#region`/`#endregion` markers. Order is load-bearing — the `Core` region must precede the `if ($CoreOnly) { return }` guard so headless tests can dot-source it. + +| Region | Purpose | Cross-platform? | +|---|---|---| +| `Core` | Pure logic, no UI / no Win32 | Yes (Linux pwsh testable) | +| `Bootstrap` | Install paths, diag ring, log writers | Windows-leaning | +| `PInvoke` | `Add-Type` blocks for `user32.dll` / `gdi32.dll` / `kernel32.dll` | Windows only | +| `Capture` | Screen / window / region capture | Windows only | +| `Preview` | WPF Fluent preview window + annotation editor | Windows only | +| `Tray` | System tray icon + context menu | Windows only | +| `Main` | Single-instance mutex + main message loop | Windows only | + +**Rule:** Any new pure helper (no UI, no Win32, no filesystem side-effects) goes in the `Core` region — `Test-SnipIT.ps1` picks it up automatically via `-CoreOnly`. Source: `SnipIT.ps1:16` (`#region Core`), `SnipIT.ps1:319-320` (`if ($CoreOnly) { return }`). + +## Naming Patterns + +**Functions:** +- `Verb-Noun` PascalCase using [PowerShell approved verbs](https://learn.microsoft.com/powershell/scripting/developer/cmdlet/approved-verbs-for-windows-powershell-commands). +- Examples from `Core`: `Get-DragRectangle`, `Test-IsClickVsDrag`, `Get-LoupeSourceRect`, `Get-LoupePosition`, `Get-DefaultSnipFilename`, `Resolve-SaveImagePath`, `Test-CaptureRectValid`, `Get-CropBounds`, `Copy-AnnotationList`, `Test-IsSelfWindowHandle`, `Resolve-WindowCaptureTarget`, `Invoke-CaptureLoop`. +- `Test-*` for predicate / validation helpers; `Get-*` for value computation; `Invoke-*` for control-flow / side-effect orchestrators; `Resolve-*` for canonicalisation; `Copy-*` for deep-clone helpers. + +**Files:** +- Production: `SnipIT.ps1` (PascalCase, single file). +- Tests: `Test-SnipIT.ps1`, `Test-SnipIT-Interactive.ps1` (verb-prefixed PascalCase, sibling to production). +- Scripts: lowercase-hyphenated under `scripts/` (e.g., `setup-git-signed.sh`). + +**Variables:** +- Local / parameter: PascalCase (`$AnchorX`, `$VsWidth`, `$LoupeHeight`). +- Script-scope state: `$script:Pass`, `$script:Fail`, `$script:Failures`, `$script:Results`. +- Closure handles passed through `-TestAction`: PascalCase property bag (`$kit.LayoutScale`, `$kit.SetZoom`, `$kit.ZoomText`). + +## Function Signatures + +**Hard rule:** every function with > 1 parameter uses `[CmdletBinding()]` + `param()`. Mandatory parameters are explicit: + +```powershell +function Resolve-SaveImagePath { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string]$Path, + [Parameter(Mandatory)] [string]$BaseDirectory + ) + ... +} +``` + +Single-arg helpers may use a bare `param([type]$Name)` (e.g., `Get-DefaultSnipFilename` at `SnipIT.ps1:91`, `Get-ImageFormatNameFromPath` at `SnipIT.ps1:96`). + +Use typed parameters everywhere. `[AllowNull()]` / `[AllowEmptyCollection()]` are used deliberately when nulls are valid input (see `Copy-AnnotationList` at `SnipIT.ps1:221`). + +## Named-Closure Pattern (WPF event wiring) + +Preview-window mouse / keyboard handlers are **named closures captured at window-creation time**, not bodies attached directly to WPF events. + +**Why:** `Test-SnipIT-Interactive.ps1` needs to drive every code path without simulating real OS input. The integration harness invokes the closures directly via the `$kit` handle bag returned through `-TestAction`. + +**Pattern:** + +```powershell +# Inside Show-PreviewWindow / Build-PreviewWindow: +$beginPan = { param($e) ... } # named closure +$pickColor = { param($e) ... } +$handleMouseDown = { param($s, $e) ... } + +# WPF event handlers are one-line wrappers: +$canvas.Add_MouseDown({ & $handleMouseDown @args }) + +# Test-mode hook exposes them through $kit: +if ($TestAction) { + $kit = @{ + SetZoom = $setZoom + ZoomBy = $zoomBy + BeginPan = $beginPan + ... + } + & $TestAction $kit +} +``` + +Sources: `SnipIT.ps1:1013-1018` (`Show-PreviewWindow` `[scriptblock]$TestAction` parameter), `SnipIT.ps1:2185-2224` (`$TestAction` invocation site), `Test-SnipIT-Interactive.ps1:78-122` (driving `$kit.SetZoom`, `$kit.ZoomBy`, `$kit.LayoutScale`, `$kit.ZoomText`). + +**Rule:** Any new preview-window event handler ships as a named closure first, with the WPF `Add_*` registration as a one-line wrapper. Add the closure to the `$kit` bag if it covers a code path tests must exercise. + +## Test-Mode Hooks + +**`SNIPIT_TEST_MODE=1` environment variable** short-circuits all global side effects in `SnipIT.ps1`: + +- Skips the single-instance mutex (`SnipIT.ps1:381`). +- Skips tray icon registration. +- Skips global hotkey registration. +- Skips the main `Dispatcher.Run()` loop (`SnipIT.ps1:2437`: `if ($env:SNIPIT_TEST_MODE) { return }`). + +This lets `Test-SnipIT-Interactive.ps1` dot-source the full script (no `-CoreOnly`) on Windows and exercise WPF code without the app actually launching. + +**`-CoreOnly` switch parameter** on `SnipIT.ps1` itself (`SnipIT.ps1:14`: `param([switch]$CoreOnly)`) is the Linux-pwsh path. After the `Core` region loads, the script early-returns at line 320 (`if ($CoreOnly) { return }`) so no Windows-only `Add-Type` blocks ever execute. + +**Rule:** Any new global side-effect (timer registration, file watcher, P/Invoke setup, network call) must be guarded by an `if (-not $env:SNIPIT_TEST_MODE)` check. Any new pure helper that does not need Windows must be placed above the `-CoreOnly` early-return so the headless suite picks it up. + +## Code Style + +**Formatting:** +- 4-space indent, no tabs. +- K&R-style braces (`function Foo {` on the same line; closing brace on its own line). +- One blank line between top-level functions; no blank lines inside `param()` blocks. + +**Comment-based help:** present at the top of `SnipIT.ps1` and on most `Core` functions. Inline comments explain non-obvious branches (DPI math, virtual-screen origin handling, loupe edge-clamping) — see `Get-LoupeSourceRect` and `Resolve-WindowCaptureTarget`. + +**Linting:** `PSScriptAnalyzer` Error-severity is the merge gate (`.github/workflows/security.yml` `psscriptanalyzer` job, lines 73-102). Warning-severity findings are surfaced but non-blocking. **Fix the code, do not lower the gate.** + +## Error Handling + +- `try { … } catch { … }` around any P/Invoke call, file I/O, or WPF dispatcher work. +- Errors surface to the user via the diag ring + `last-error.txt` in the install home (see `Write-SnipDiag` at `SnipIT.ps1:332`). The diag ring is bounded — no unbounded log buffer. +- The capture loop **owns** disposal of `System.Drawing.Bitmap` (RAN-14 contract): the preview disposes on close, the loop creates a fresh bitmap each iteration via the `CaptureFactory` closure. Do not pre-allocate, do not double-dispose. + +## Imports & P/Invoke Surface + +- `Add-Type @"…"@` blocks live exclusively in the `PInvoke` region (`SnipIT.ps1:411-472`). +- `using namespace` directives are not used — fully-qualified type names instead (e.g., `[System.Drawing.Bitmap]`, `[System.Windows.Window]`). +- Every imported native API is reviewed for input-handle validation per `shared/runbooks/engineering-standards.md` §5.2 — never pass user-controlled HWNDs without owner-check (see `Test-IsSelfWindowHandle` and `Resolve-WindowCaptureTarget`). + +## File / Path Handling + +- User-supplied save paths go through `Resolve-Path` + canonical-form check before write (`Resolve-SaveImagePath` at `SnipIT.ps1:105`). +- Image format is derived from extension via `Get-ImageFormatNameFromPath`. +- The install flow is the only on-disk side-effect outside the user's chosen save path; install paths are computed centrally in `Get-InstallPaths` (`SnipIT.ps1:146`). + +## Comments + +- Use comments to record **invariants and contracts**, not to narrate the code. +- The RAN-14 capture-loop ownership contract, the RAN-15 SnipIT-window exclusion, and the test-mode short-circuit invariants are all flagged inline. New invariants get the same treatment. +- TODO / FIXME comments are not used in production code. Open a Paperclip ticket (`RAN-XX`) and link it in the PR. + +## Module Design + +- No exported module — `SnipIT.ps1` is dot-sourced by tests and run directly by users. +- The function set inside `Core` is the de-facto public API for the headless suite. Adding / renaming / removing a `Core` function is a tested change: update both `Test-SnipIT.ps1` and any caller in the script. + +## Git & Signing + +- All commits on `main` are **signed** (SSH key registered as both auth + signing on GitHub). +- Run `scripts/setup-git-signed.sh` once per fresh worktree to apply local git config; the script honours your existing global signing setup (ssh / openpgp / x509). +- Branch protection blocks force-push and direct-push to `main`. Squash-merge is the only allowed merge style. +- Conventional-commit subjects (`feat:`, `fix:`, `refactor:`, `chore(RAN-XX):`, `docs:`). Link Paperclip tickets as `[RAN-XX](/RAN/issues/RAN-XX)` in PR descriptions. + +--- + +*Convention analysis: 2026-05-07* diff --git a/.planning/codebase/INTEGRATIONS.md b/.planning/codebase/INTEGRATIONS.md new file mode 100644 index 0000000..e119a2b --- /dev/null +++ b/.planning/codebase/INTEGRATIONS.md @@ -0,0 +1,158 @@ +# External Integrations + +**Analysis Date:** 2026-05-07 + +snipIT is an offline desktop application with no runtime network calls, no API keys, and no telemetry. "Integrations" here means **OS-level surfaces the script binds to at runtime** plus **GitHub-side surfaces** consumed by CI and the security/best-practices posture. + +## OS-Level Runtime Integrations (Windows 11) + +### Win32 P/Invoke surfaces (`SnipIT.ps1:411-471`, `Native` static class) + +| Surface | DLL | Used for | Functions | +|---|---|---|---| +| Hotkey registration | `user32.dll` | Three global hotkeys on a hidden message-only form | `RegisterHotKey`, `UnregisterHotKey` (`SnipIT.ps1:2528-2534`, cleanup `2616-2618`) | +| Window discovery / smart capture | `user32.dll` | Hover-to-highlight uses cursor → top-level window resolution | `WindowFromPoint`, `GetAncestor` (root), `GetForegroundWindow`, `GetWindowRect`, `GetCursorPos` | +| SnipIT chrome hide-during-capture | `user32.dll` | Hide own widget/preview/tray windows so they don't bake into the snip (RAN-15 fix) | `IsWindowVisible`, `ShowWindow` (`SW_HIDE`, `SW_SHOWNA` to restore without stealing focus) | +| DWM extended frame bounds | `dwmapi.dll` | Capture true window rect minus drop shadow | `DwmGetWindowAttribute` with `DWMWA_EXTENDED_FRAME_BOUNDS` (`SnipIT.ps1:925-927, 2305-2306`) | +| Mica backdrop / dark mode | `dwmapi.dll` | Win11 Fluent preview chrome | `DwmSetWindowAttribute` with `DWMWA_SYSTEMBACKDROP_TYPE` = `DWMSBT_MAINWINDOW` (Mica) and `DWMWA_USE_IMMERSIVE_DARK_MODE` | +| Per-monitor DPI v2 | `user32.dll` | Accurate capture at 100/125/150/200% across mixed-DPI monitors | `SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2)` (`SnipIT.ps1:470`) | +| GDI handle cleanup | `gdi32.dll` | Free `HBITMAP` returned by `Bitmap.GetHbitmap()` | `DeleteObject` | +| Console hide | `kernel32.dll`, `user32.dll` | Hide stray console window when launched from non-pwsh shell | `GetConsoleWindow`, `ShowWindow` (`SnipIT.ps1:358-359`) | + +### WPF surfaces + +- **`System.Windows.Markup.XamlReader.Load`** parses inline XAML strings to construct: smart-overlay window (`SnipIT.ps1:775`), capture overlay (`856`), preview window (`1338`), floating widget (`2387`). +- **`System.Windows.Clipboard`** - `SetImage` writes the flattened (annotations-baked-in) snip (`SnipIT.ps1:2148`); `SetText` is a fallback messaging path (`1362`). +- **Preview-window architecture** uses named closures (`$beginPan`, `$pickColor`, `$handleMouseDown`, etc.) captured at window creation. Tests drive every code path through `Show-PreviewWindow -TestAction { ... }` (`SnipIT.ps1:1018, 2185-2224`). + +### System tray + hotkey form (`SnipIT.ps1:2433-2596`) + +- `System.Windows.Forms.NotifyIcon` (`2539`) with a `ContextMenuStrip` (`2547`) — full menu (capture modes, open snips folder, about, uninstall, exit). +- Hidden `NativeWindow` subclass (compiled inline at `SnipIT.ps1:2491` via `Add-Type -TypeDefinition $nativeWindowSrc`) receives `WM_HOTKEY` messages. +- Three global hotkeys: `Ctrl+Shift+S` (smart), `Ctrl+Shift+F` (full), `Ctrl+Shift+W` (active window). +- `System.Windows.Forms.Application.Run()` is the main message pump (`SnipIT.ps1:2601`). + +### Single-instance mutex + +- `System.Threading.Mutex` named `Local\SnipIT-SingleInstance` (or similar per session), created at `SnipIT.ps1:383`. +- A second launch shows a friendly notification and exits — *unless* `SNIPIT_TEST_MODE=1` is set, in which case the mutex is skipped entirely (`SnipIT.ps1:381`). +- Released and disposed in the cleanup region (`SnipIT.ps1:2621-2623`). + +### File-system / shell integrations (first-run install, `SnipIT.ps1:547-611`) + +- `WScript.Shell` COM object (`557`) creates two `.lnk` shortcuts: + - Desktop shortcut → `~/Desktop/SnipIT.lnk` + - Auto-start shortcut → `shell:startup` so the app launches at login +- Self-copies to `snipIT-Home/SnipIT.ps1` next to the source script. +- Generates `SnipIT.ico` on the fly via the Icon-generation region (`SnipIT.ps1:474-545`). +- Default save path: `~/Pictures/Snips/snip-yyyyMMdd-HHmmss.{png|jpg|bmp}`. +- **No registry writes outside `HKCU`. No admin elevation. No UAC prompt.** + +## Data Storage + +**Databases:** None. + +**File Storage:** +- Local filesystem only — `~/Pictures/Snips/` for saved snips, `snipIT-Home/` for the installed copy. + +**Caching:** None. No persisted preferences in the current version (rebindable hotkeys / default folder / widget position are on the roadmap per `README.md:181-188`). + +## Authentication & Identity + +Not applicable — single-user desktop app, no remote services, no accounts. + +## APIs & External Services + +Not applicable. No SDK clients, no HTTP calls, no webhooks (incoming or outgoing). + +## Monitoring & Observability + +**Error Tracking:** None. + +**Logs:** No log file. Errors surface as in-app notifications (tray balloon) or are swallowed by `try { ... } catch {}` blocks around best-effort cleanup operations. + +**Telemetry:** None — explicitly out of scope per `~/.claude/rules/build.md` (no auto-updater, no telemetry by default). + +## CI/CD & Deployment + +**Hosting:** GitHub repository at (public, MIT-licensed, secret-scanning + push-protection enabled). + +**Distribution:** No published binary. The script *is* the package — install path is `git clone` + run, or download `SnipIT.ps1` directly. Per CLAUDE.md "Known not-a-pass" note, this is why Scorecard `Packaging` does not pass and is by design. + +**CI Pipeline (GitHub Actions, all actions SHA-pinned):** + +| Workflow | File | Trigger | What it does | +|---|---|---|---| +| `test` | `.github/workflows/test.yml` | push to `main`, PR to `main`, manual | Headless `Test-SnipIT.ps1` on Linux + Windows; AST parse-only on Windows | +| `Security (OSS-CLI)` | `.github/workflows/security.yml` | push, PR, weekly Mon 04:21 UTC | Trivy + Semgrep + PSScriptAnalyzer + Gitleaks + jscpd + SBOM (six independent jobs, fail-fast off) | +| `Scorecard supply-chain security` | `.github/workflows/scorecard.yml` | push to `main`, weekly Mon 06:00 UTC, manual | OpenSSF Scorecard analysis, SARIF → Security tab + public dashboard | + +**Hardening:** +- Top-level `permissions: read-all` in every workflow per Scorecard `Token-Permissions`. +- `step-security/harden-runner` with `egress-policy: audit` in the Scorecard workflow (`.github/workflows/scorecard.yml:33-37`). +- `actions/checkout` and every other action pinned by commit SHA — Dependabot opens routine bumps. +- `FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"` env in `test.yml` to opt into Node 24 ahead of the June 2026 Node 20 deprecation. + +## GitHub-Side Surfaces + +### Dependabot (`.github/dependabot.yml`) + +- Single ecosystem: `github-actions`, weekly Mondays 08:00 UTC. +- Grouped — all action bumps land in one PR (`groups.actions.patterns: ["*"]`). +- `open-pull-requests-limit: 5`. Labels: `type:dependencies`, `area:ci`. Commit prefix `chore(actions)`. +- Repo-level **Dependabot security updates** enabled separately via `gh api` (per `.github/dependabot.yml:12-13`). +- No npm / pip / Maven manifests today, so `github-actions` is the only ecosystem. + +### OpenSSF Scorecard + +- Workflow: `.github/workflows/scorecard.yml` +- Engine: `ossf/scorecard-action@v2.4.3` (SHA `4eaacf0543bb3f2c246792bd56e8cdeffafb205a`) +- Output: SARIF → GitHub Security tab + +- Posture: best-effort, non-blocking. `passing` Best Practices is the only OpenSSF gate that blocks merge (per `CLAUDE.md`). +- Stretch target: ≥ 8.0 / 10. + +### OpenSSF Best Practices + +- Project ID: **12647** +- Public page: +- README badge: `https://www.bestpractices.dev/projects/12647/badge` +- In-repo self-assessment: `.bestpractices.json` (target `passing`) +- **Hard merge gate:** any PR that would fail a passing-level criterion is blocked at review. + +### Branch-protection / signed commits + +- `main` requires signed commits (SSH key registered as both auth + signing key). +- One-shot setup: `scripts/setup-git-signed.sh` (run once per worktree). +- Scorecard `Branch-Protection`, `Code-Review` (TechLead via Codex), `Signed-Releases` (`tag.gpgsign=true`) all configured per CLAUDE.md. + +### Issue tracker + +- **Paperclip** (not GitHub Issues for active work). Issue prefix `RAN` (e.g., RAN-54, RAN-15, RAN-66). +- Cross-project parent issue for OpenSSF rollout: [RAN-50](/RAN/issues/RAN-50). +- Paperclip Project ID for snipIT: `e6a01833-e7df-4068-849c-6a7c5154b70c`. +- Linking convention in PR descriptions and code comments: `[RAN-XX](/RAN/issues/RAN-XX)`. + +## Environment Configuration + +**Required env vars (runtime):** None for normal operation. + +**Optional env vars:** +- `SNIPIT_TEST_MODE=1` — disables mutex, tray, hotkeys, and the main message-loop so harnesses can dot-source the script. + +**Required env vars (CI):** +- `GITHUB_TOKEN` (auto-provided) — used by `gh release download` in the Gitleaks job (`.github/workflows/security.yml:111`). +- `GITLEAKS_VERSION: 8.30.1` (workflow-scoped) — pins the Gitleaks CLI release. + +**Secrets location:** No secrets in repo. Secret scanning + push protection on. Gitleaks scans full git history on every PR. + +## Webhooks & Callbacks + +**Incoming:** None. + +**Outgoing:** None. + +GitHub repo webhook surface is empty — Scorecard `Webhooks` check passes by absence (per CLAUDE.md "Configured-for-pass checks"). + +--- + +*Integration audit: 2026-05-07* diff --git a/.planning/codebase/STACK.md b/.planning/codebase/STACK.md new file mode 100644 index 0000000..f088981 --- /dev/null +++ b/.planning/codebase/STACK.md @@ -0,0 +1,111 @@ +# Technology Stack + +**Analysis Date:** 2026-05-07 + +## Languages + +**Primary:** +- PowerShell 7.5+ - Entire application implemented in `SnipIT.ps1` (`#requires -Version 7.5` at line 1). Test suites also pure PowerShell: `Test-SnipIT.ps1`, `Test-SnipIT-Interactive.ps1`. + +**Secondary:** +- C# (inline via `Add-Type -TypeDefinition`) - Two embedded type definitions only: + - `Native` static class for Win32 P/Invoke signatures (`SnipIT.ps1:354-470`) + - Native window subclass for the message-only hotkey form (`SnipIT.ps1:2491`) +- Bash - One-shot setup script `scripts/setup-git-signed.sh` for signed-commit configuration. +- YAML - GitHub Actions workflows under `.github/workflows/` and `.github/dependabot.yml`. +- JSON - OpenSSF Best Practices self-assessment in `.bestpractices.json`. + +## Runtime + +**Environment:** +- PowerShell 7.5+ on .NET 9 +- Target OS: Windows 11 (Win32 + WPF + DWM Mica APIs) +- STA apartment required (script self-relaunches with `-Sta` in the Bootstrap region) + +**Package Manager:** +- None at runtime — `SnipIT.ps1` has zero external runtime dependencies (no NuGet, no npm, no pip, no Maven manifest) +- CI installs PowerShell via `dotnet tool install --global PowerShell` (`.github/workflows/test.yml:30`) +- Lockfile: not applicable (no managed manifest) + +## Frameworks + +**Core (loaded via `Add-Type -AssemblyName` at `SnipIT.ps1:371-375`):** +- `PresentationFramework` - WPF UI primitives (`Window`, `Border`, `Grid`, etc.) +- `PresentationCore` - WPF rendering, input, ink +- `WindowsBase` - WPF dispatcher and dependency-property infrastructure +- `System.Windows.Forms` - `NotifyIcon` system tray, `ContextMenuStrip`, message-only hotkey form, `Application.Run` message pump +- `System.Drawing` - GDI+ `Bitmap`, `Graphics`, `Color`, used for screen capture (`Graphics.CopyFromScreen`) and runtime `.ico` synthesis + +**XAML loading:** +- `System.Windows.Markup.XamlReader.Load` parses inline XAML strings to build the smart-overlay, preview window, floating widget, and capture overlay (`SnipIT.ps1:775, 856, 1338, 2387`) + +**Testing:** +- No Pester. Two custom zero-dependency harnesses written in pure PowerShell. + - `Test-SnipIT.ps1` (646 lines) - 40 pure-logic unit tests; dot-sources `SnipIT.ps1 -CoreOnly`; runs on Linux + Windows pwsh + - `Test-SnipIT-Interactive.ps1` (585 lines) - 42 WPF integration tests driven through the `-TestAction` hook on `Show-PreviewWindow` against a real off-screen window; Windows-only + +**Build/Dev:** +- No compile step. `SnipIT.ps1` *is* the deliverable. +- AST parse-only verification on Windows: `[System.Management.Automation.Language.Parser]::ParseFile(...)` (`.github/workflows/test.yml:60-69`) + +## Key Dependencies + +**Critical (inline P/Invoke imports — `SnipIT.ps1:411-471`):** +- `user32.dll` - `RegisterHotKey`, `UnregisterHotKey`, `WindowFromPoint`, `GetAncestor`, `GetForegroundWindow`, `GetWindowRect`, `IsWindowVisible`, `ShowWindow`, `GetCursorPos`, `SetProcessDpiAwarenessContext` +- `dwmapi.dll` - `DwmGetWindowAttribute` (extended frame bounds, drop-shadow-aware), `DwmSetWindowAttribute` (Mica backdrop `DWMWA_SYSTEMBACKDROP_TYPE`, immersive dark mode) +- `gdi32.dll` - `DeleteObject` (cleanup for `Bitmap.GetHbitmap()` handles) +- `kernel32.dll` - `GetConsoleWindow` (console hide on launch, `SnipIT.ps1:358`) + +**Infrastructure (.NET BCL types used directly):** +- `System.Threading.Mutex` - Per-session named single-instance mutex (`SnipIT.ps1:383`) +- `System.Windows.Clipboard` - `SetImage` for flattened-snip copy, `SetText` for fallback messaging (`SnipIT.ps1:1362, 2148`) +- `WScript.Shell` (COM) - Desktop and `shell:startup` shortcut creation in first-run install (`SnipIT.ps1:557`) + +## Configuration + +**Environment:** +- `SNIPIT_TEST_MODE=1` - Test escape hatch. Short-circuits the single-instance mutex (`SnipIT.ps1:381`), tray, hotkey registration, and `Application.Run` main loop (`SnipIT.ps1:2437`). Lets a harness dot-source the script without side effects. +- `-CoreOnly` script switch - Loads only the pure-logic Core region; `SnipIT.ps1:319-320` returns early so no WPF/Win32 types are touched. + +**No env vars required for normal operation.** No external endpoints, no API keys, no telemetry, no auto-updater. + +**Build:** +- No build config. The script is plain text loaded by pwsh. +- CI workflow files under `.github/workflows/` (`test.yml`, `security.yml`, `scorecard.yml`) and `.github/dependabot.yml`. + +## Platform Requirements + +**Development:** +- Windows 11 + PowerShell 7.5+ for full app run +- Any OS with PowerShell 7.5+ for Core unit tests (`Test-SnipIT.ps1` runs cross-platform) +- Optional: PSScriptAnalyzer module for local lint (`Invoke-ScriptAnalyzer -Path ./SnipIT.ps1 -Severity Error`) + +**Production:** +- Windows 11 (per-monitor DPI v2, DWM Mica, Segoe Fluent Icons all assumed present) +- PowerShell 7.5+ on .NET 9 +- No admin elevation, no UAC prompt, no registry writes outside `HKCU` (first-run install writes only to `~/Desktop`, `shell:startup`, and `snipIT-Home/`) + +## Code Quality / Security CLI Stack (`.github/workflows/security.yml`) + +OSS-CLI only — no Sonar, no CodeQL, no NVD-direct tools. Six independent jobs, all SHA-pinned: + +- **Trivy** (`aquasecurity/trivy-action`) - Filesystem SCA, fails on `HIGH,CRITICAL` with `ignore-unfixed: true` +- **Semgrep** (`p/security-audit` + `p/owasp-top-ten`, `--severity ERROR`) - Language-agnostic SAST; no first-party PowerShell pack exists +- **PSScriptAnalyzer** - PowerShell-specific lint, fails on `Error` severity, surfaces `Warning` non-blocking +- **Gitleaks** v8.30.1 (CLI installed via `gh release download` to avoid the paid GitHub Action) - Secret scan over full git history +- **jscpd** v4 - Duplication gate, `--threshold 3 --min-tokens 100 --format powershell`, scoped to `SnipIT.ps1` (tests excluded) +- **SBOM** (`anchore/sbom-action`) - Generates SPDX + CycloneDX JSON, uploaded as 90-day retention artifact (surface only, non-blocking) + +## Test Runner Commands + +```bash +pwsh -Sta -File ./SnipIT.ps1 # Run app (Windows) +pwsh -NoProfile -File ./Test-SnipIT.ps1 # Pure-logic tests, any platform +pwsh -NoProfile -Sta -File ./Test-SnipIT-Interactive.ps1 # WPF tests, Windows only +pwsh -c "Invoke-ScriptAnalyzer -Path ./SnipIT.ps1 -Severity Error" # Lint +pwsh -NoProfile -Command "[System.Management.Automation.Language.Parser]::ParseFile(...)" # Parse-only +``` + +--- + +*Stack analysis: 2026-05-07* diff --git a/.planning/codebase/STRUCTURE.md b/.planning/codebase/STRUCTURE.md new file mode 100644 index 0000000..0354900 --- /dev/null +++ b/.planning/codebase/STRUCTURE.md @@ -0,0 +1,219 @@ +# Codebase Structure + +**Analysis Date:** 2026-05-07 + +## Directory Layout + +``` +snipIT/ +├── SnipIT.ps1 # 113K, 2627 lines — the entire app (single-file deliverable) +├── Test-SnipIT.ps1 # 28.6K, 646 lines — pure-logic unit tests (Linux + Windows pwsh) +├── Test-SnipIT-Interactive.ps1 # 25.2K, 585 lines — WPF integration tests (Windows only) +├── README.md # install / hotkeys / usage / architecture / badges +├── CHANGELOG.md # release notes (Keep-a-Changelog) +├── CONTRIBUTING.md # contributor guide +├── SECURITY.md # disclosure policy + scope +├── LICENSE # MIT — Amit Kumar +├── CLAUDE.md # authoritative agent brief (read first) +├── .bestpractices.json # OpenSSF Best Practices self-assessment (project_id 12647) +├── .gitignore # excludes agent-generated *.md, .planning/, build artifacts +├── .github/ +│ ├── workflows/ +│ │ ├── test.yml # headless tests (Linux + Windows) + Windows AST parse +│ │ ├── security.yml # PSScriptAnalyzer · Trivy · Semgrep · Gitleaks · jscpd · SBOM +│ │ └── scorecard.yml # OpenSSF Scorecard (push to main + Mondays 06:00 UTC) +│ └── dependabot.yml # github-actions ecosystem, weekly, grouped +├── docs/ +│ ├── README.md # design notes index +│ └── mockups/ +│ └── preview-redesign.html # preview-window UX mockup +├── scripts/ +│ └── setup-git-signed.sh # one-shot signed-commit setup per worktree +├── shared/ +│ └── runbooks/ +│ └── engineering-standards.md # PowerShell variant of company canonical +└── .planning/ + └── codebase/ # agent-generated maps (this file lives here) +``` + +## Directory Purposes + +**Repo root (`./`):** +- Purpose: house the single-file deliverable + its tests + project-level governance docs +- Contains: `SnipIT.ps1` (the app), two test scripts, README/SECURITY/LICENSE/CHANGELOG/CONTRIBUTING, agent brief (`CLAUDE.md`), OpenSSF self-assessment +- Key rule: `SnipIT.ps1` is **the deliverable** — no `dist/`, no `build/`, no compile step. `git clone` + run is the install path. + +**`.github/workflows/`:** +- Purpose: CI quality gates that block merge on every PR +- Contains: `test.yml` (matrix: Linux + Windows pwsh, plus Windows AST parse), `security.yml` (OSS-CLI security stack), `scorecard.yml` (OpenSSF Scorecard, observational) +- Pinning rule: every action SHA-pinned per Scorecard `Pinned-Dependencies`; Dependabot opens routine bumps + +**`.github/`:** +- Purpose: GitHub-platform config +- Contains: `dependabot.yml` (github-actions ecosystem, weekly, grouped) + +**`docs/`:** +- Purpose: design notes + UX mockups +- Contains: `README.md` index, `mockups/preview-redesign.html` (HTML mockup of the preview window) +- Generated: No — committed source + +**`scripts/`:** +- Purpose: one-shot dev-environment helpers +- Contains: `setup-git-signed.sh` (configures SSH-key signing per worktree — required because all `main` commits must be signed) + +**`shared/runbooks/`:** +- Purpose: company engineering-standards reference, PowerShell variant +- Contains: `engineering-standards.md` (mirrors the canonical at the company runbook path; defines the OSS-CLI quality gate stack) + +**`.planning/codebase/`:** +- Purpose: agent-generated codebase maps consumed by `/gsd-plan-phase` and `/gsd-execute-phase` +- Contains: `ARCHITECTURE.md`, `STRUCTURE.md` (this file), and other focus-area maps when generated +- Generated: Yes — by the GSD codebase mapper +- Committed: gitignored per repo policy on agent-generated files + +## Key File Locations + +**Entry Points:** +- `SnipIT.ps1` — the entire application (`#region Main loop & cleanup` at L2598-2627 is the top-level entry) +- `Test-SnipIT.ps1` — pure-logic test harness (40 tests; runs on Linux + Windows) +- `Test-SnipIT-Interactive.ps1` — WPF integration test harness (42 tests; Windows only) + +**Configuration:** +- `.bestpractices.json` — OpenSSF Best Practices self-assessment, project 12647, target `passing` +- `.gitignore` — excludes agent-generated `*.md`, `.planning/`, build/IDE artifacts +- `.github/dependabot.yml` — Dependabot ecosystem config + +**Core Logic (inside the single file):** +- `SnipIT.ps1` L16-317 — `Core` region (18 pure functions, cross-platform) +- `SnipIT.ps1` L322-409 — `Bootstrap` region (diagnostics) +- `SnipIT.ps1` L411-472 — `PInvoke` region (Win32 surface) +- `SnipIT.ps1` L474-545 — `Icon generation` +- `SnipIT.ps1` L547-611 — `First-Run Install` +- `SnipIT.ps1` L613-795 — `Capture Core` +- `SnipIT.ps1` L797-1008 — `Smart Overlay` +- `SnipIT.ps1` L1010-2238 — `Preview Window` (single ~1.2K-line `Show-PreviewWindow` with named-closure factory) +- `SnipIT.ps1` L2240-2353 — `Capture Orchestration` +- `SnipIT.ps1` L2355-2431 — `Floating Widget` +- `SnipIT.ps1` L2433-2596 — `Tray + Hotkeys` +- `SnipIT.ps1` L2598-2627 — `Main loop & cleanup` + +**Testing:** +- `Test-SnipIT.ps1` — dot-sources `SnipIT.ps1` with `SNIPIT_TEST_MODE=1`, exercises the Core region's exported functions +- `Test-SnipIT-Interactive.ps1` — drives `Show-PreviewWindow -TestAction { … }` against a real off-screen WPF window + +**Governance / Compliance:** +- `CLAUDE.md` — agent standing context (authoritative for any agent touching the repo) +- `SECURITY.md` — disclosure policy +- `shared/runbooks/engineering-standards.md` — quality-gate definitions +- `.bestpractices.json` — OpenSSF self-assessment (only OpenSSF gate that **blocks merge**) + +## Naming Conventions + +**Functions (the only namespace in PowerShell):** +- `Verb-Noun` PascalCase, [PowerShell-approved verbs](https://learn.microsoft.com/powershell/scripting/developer/cmdlet/approved-verbs-for-windows-powershell-commands) only +- Examples: `Get-DragRectangle`, `Test-IsClickVsDrag`, `Resolve-SaveImagePath`, `Show-PreviewWindow`, `Invoke-CaptureLoop`, `Register-SelfWindowHandle`, `Hide-OwnSnipITWindowsForCapture` +- `[CmdletBinding()]` + `param()` block required for any function with more than one argument + +**Variables:** +- Local: `$camelCase` (e.g., `$beginPan`, `$pickColor`, `$handleMouseDown`) +- Script-scoped (cross-region state): `$script:PascalCase` (e.g., `$script:DiagRing`) + +**Files:** +- Top-level scripts: `PascalCase.ps1` (`SnipIT.ps1`, `Test-SnipIT.ps1`, `Test-SnipIT-Interactive.ps1`) +- Workflows: `kebab-case.yml` (`test.yml`, `security.yml`, `scorecard.yml`) +- Docs: `UPPERCASE.md` for top-level (`README.md`, `SECURITY.md`, `CLAUDE.md`, `CHANGELOG.md`, `CONTRIBUTING.md`); `lowercase.md` inside `docs/` + +**Regions (single-file structure):** +- `#region ====` opener, `#endregion` closer; trailing `=` padding for visual scan +- One region == one logical layer; lower regions never depend on higher ones + +## Core region — exported pure functions (Linux-pwsh compatible) + +Defined in `SnipIT.ps1` L16-317. These are the functions the test suite exercises with `-CoreOnly` (no UI, no Win32, no `Add-Type`): + +| # | Function | Line | Purpose | +|---|----------|------|---------| +| 1 | `Get-DragRectangle` | `SnipIT.ps1:18` | Normalise two screen points into a positive-extent rectangle | +| 2 | `Test-IsClickVsDrag` | `SnipIT.ps1:33` | Predicate: distance threshold to disambiguate click from drag | +| 3 | `Get-LoupeSourceRect` | `SnipIT.ps1:46` | Compute magnifier source rectangle around a cursor point | +| 4 | `Get-LoupePosition` | `SnipIT.ps1:64` | Place the loupe so it does not occlude the cursor or leave the screen | +| 5 | `Get-DefaultSnipFilename` | `SnipIT.ps1:90` | Timestamped default file-name | +| 6 | `Get-ImageFormatNameFromPath` | `SnipIT.ps1:95` | Map extension → `System.Drawing.Imaging.ImageFormat` | +| 7 | `Resolve-SaveImagePath` | `SnipIT.ps1:105` | Final save path (handles defaults, collisions, extension coercion) | +| 8 | `Test-CaptureRectValid` | `SnipIT.ps1:120` | Predicate: capture rect has positive area and is within bounds | +| 9 | `Get-CropBounds` | `SnipIT.ps1:129` | Convert selection coords to bitmap-pixel crop bounds | +| 10 | `Get-InstallPaths` | `SnipIT.ps1:146` | Resolve user-profile install + Start-Menu shortcut paths | +| 11 | `Get-ShortcutArguments` | `SnipIT.ps1:161` | Build `pwsh.exe` argument string for shortcut | +| 12 | `Get-ClampedAnnotationRect` | `SnipIT.ps1:166` | Clamp an annotation rect inside the canvas | +| 13 | `Get-ZoomCenteredOffset` | `SnipIT.ps1:186` | Recompute pan offset to keep zoom focused on a point | +| 14 | `Copy-AnnotationList` | `SnipIT.ps1:216` | Deep-clone annotation list for undo | +| 15 | `Get-TrimmedRecent` | `SnipIT.ps1:234` | Trim a recent-items list to N entries | +| 16 | `Test-IsSelfWindowHandle` | `SnipIT.ps1:247` | Predicate: HWND is one of our registered top-level windows (RAN-15) | +| 17 | `Resolve-WindowCaptureTarget` | `SnipIT.ps1:266` | Walk parent chain to the capture-worthy ancestor, skipping self windows | +| 18 | `Invoke-CaptureLoop` | `SnipIT.ps1:286` | RAN-14 burst-capture driver — owns each bitmap from the factory closure | + +CLAUDE.md's "10 pure functions" headline is the canonical sub-set the README quotes; the actual Core region today exports the 18 above. **Adding a new pure helper? Put it in the `Core` region** so the test suite picks it up via `-CoreOnly`. + +## Where to Add New Code + +**New pure helper (geometry / paths / predicates):** +- File: `SnipIT.ps1`, inside `#region Core` (L16-317) +- Tests: `Test-SnipIT.ps1` — add a new `Test-` case; runs on Linux pwsh, no Windows required +- Rule: must have zero dependencies on Win32, WPF, or `Add-Type` + +**New Win32 P/Invoke signature:** +- File: `SnipIT.ps1`, inside `#region PInvoke` (L411-472) +- Constraint: PowerShell 7.5+ on .NET 9 only — no PS5.1 fallback shims + +**New capture mode (e.g., scrolling capture):** +- Wiring: `#region Capture Orchestration` (L2240-2353), follow the `Invoke-SmartCapture` / `Invoke-FullScreenCapture` pattern +- Bitmap acquisition helpers: `#region Capture Core` (L613-795) +- Hotkey: register in `#region Tray + Hotkeys` (L2433-2596) + +**New annotation tool (e.g., arrow):** +- Inside `Show-PreviewWindow` / `Build-PreviewWindow` (`SnipIT.ps1` L1010-2238) +- Add a named closure (e.g., `$handleArrow`) in the factory; expose via the same pattern as `$beginPan` / `$pickColor` / `$handleMouseDown` +- Make the WPF event handler a one-line wrapper that calls the closure +- Tests: add a case in `Test-SnipIT-Interactive.ps1` driving the closure via `-TestAction` + +**New top-level WPF window (HUD, settings, etc.):** +- After `window.Show()`, call `Register-SelfWindowHandle` (`SnipIT.ps1:634`) so the new window is excluded from captures (RAN-15) +- On close, call `Unregister-SelfWindowHandle` (`SnipIT.ps1:645`) + +**New tests:** +- Pure-logic: `Test-SnipIT.ps1` +- WPF / interactive: `Test-SnipIT-Interactive.ps1` (Windows-only; uses `Show-PreviewWindow -TestAction { … }`) + +**New CI gate:** +- File: `.github/workflows/security.yml` (additional OSS-CLI scanner) or `.github/workflows/test.yml` (additional matrix entry) +- Pin the action by commit SHA (Scorecard `Pinned-Dependencies`) +- Update `shared/runbooks/engineering-standards.md` to reflect the new gate + +**New design note / mockup:** +- `docs/` (markdown) or `docs/mockups/` (HTML mockup, like `preview-redesign.html`) + +## Special Directories + +**`.planning/`:** +- Purpose: agent-generated planning + codebase maps +- Generated: Yes — by GSD command suite +- Committed: gitignored + +**`shared/`:** +- Purpose: shared runbook reference (mirror of company canonical) +- Generated: No — committed source, mirrored from `/home/dev/.paperclip/instances/default/companies/31b9e445-1e14-45b6-9457-cfbb5cb17144/shared/runbooks/` +- Committed: Yes + +**`docs/`:** +- Purpose: design notes + UX mockups +- Generated: No +- Committed: Yes + +**`.github/`:** +- Purpose: CI workflows + Dependabot +- Generated: No +- Committed: Yes + +--- + +*Structure analysis: 2026-05-07* diff --git a/.planning/codebase/TESTING.md b/.planning/codebase/TESTING.md new file mode 100644 index 0000000..1564739 --- /dev/null +++ b/.planning/codebase/TESTING.md @@ -0,0 +1,172 @@ +# Testing Patterns + +**Analysis Date:** 2026-05-07 + +## Test Framework + +- **No Pester.** Both test files implement a small in-house DSL with `Describe` / `It` / `Should-*` helpers — keeps the dependency surface at zero (consistent with the single-file deliverable model) and lets the tests run on any pwsh 7.5+ install with no `Install-Module` step. +- **Runner:** `pwsh -NoProfile -File ./Test-SnipIT.ps1` (headless) and `pwsh -NoProfile -Sta -File ./Test-SnipIT-Interactive.ps1` (interactive WPF, Windows only). +- **Assertion helpers** (defined inline in each test file): + - `Test-SnipIT.ps1`: `ShouldBe`, `ShouldBeTrue`, `ShouldBeFalse` (lines 25-29). + - `Test-SnipIT-Interactive.ps1`: `Should-Be` (with optional `-Tol` for float tolerance), `Should-BeTrue`, `Should-BeFalse`, `Should-BeGreaterThan` (lines 51-64). + +## Test Files + +| File | Size | Tests | Platform | Purpose | +|---|---|---|---|---| +| `Test-SnipIT.ps1` | 29 KB / 646 lines | **40 pure-logic unit tests** | Linux + Windows pwsh | Validates `Core`-region functions with no UI, no Win32, no filesystem mutation | +| `Test-SnipIT-Interactive.ps1` | 26 KB / 585 lines | **42 WPF integration tests** | Windows only (STA) | Drives a real off-screen preview window via the `-TestAction` hook | + +## Headless Suite — `Test-SnipIT.ps1` + +**How it loads the production code:** + +```powershell +. (Join-Path $PSScriptRoot 'SnipIT.ps1') -CoreOnly +``` + +The `-CoreOnly` switch (declared at `SnipIT.ps1:14`) causes the script to dot-source only the `Core` region and then early-return at line 320 (`if ($CoreOnly) { return }`) **before** any Windows-only `Add-Type` / `PInvoke` / WPF / tray / hotkey code runs. This is what lets the headless suite execute on Linux pwsh in CI. + +**Function coverage** (one `Describe` block per `Core` function — see `Test-SnipIT.ps1:31-130`): + +- `Get-DragRectangle` — coordinate normalisation, negative-origin (left monitor), zero-size cases. +- `Test-IsClickVsDrag` — threshold logic (default + custom), exact-anchor case, negative deltas. +- `Get-LoupeSourceRect` — center-on-cursor, edge-clamp (left / top / right / bottom), negative virtual-screen origin. +- `Get-LoupePosition` — bottom-right placement, flip-on-edge (right edge, bottom edge). +- `Get-DefaultSnipFilename`, `Get-ImageFormatNameFromPath`, `Resolve-SaveImagePath`, `Test-CaptureRectValid`, `Get-CropBounds`, `Get-InstallPaths`, `Get-ShortcutArguments`, `Get-ClampedAnnotationRect`, `Get-ZoomCenteredOffset`, `Copy-AnnotationList`, `Get-TrimmedRecent`, `Test-IsSelfWindowHandle`, `Resolve-WindowCaptureTarget`, `Invoke-CaptureLoop`. + +**Result accumulator** uses script-scope counters (`$script:Pass`, `$script:Fail`, `$script:Failures`) and exits non-zero on failure for CI. + +## Interactive Suite — `Test-SnipIT-Interactive.ps1` + +**STA bootstrap** (lines 17-21): WPF requires the Single-Threaded Apartment model. If the harness is launched from bash MTA, it relaunches itself with `-Sta`. + +**Test-mode load:** + +```powershell +$env:SNIPIT_TEST_MODE = '1' +. (Join-Path $PSScriptRoot 'SnipIT.ps1') +``` + +Setting `SNIPIT_TEST_MODE=1` short-circuits the single-instance mutex, tray, hotkeys, and main `Dispatcher.Run()` loop (guards at `SnipIT.ps1:381` and `SnipIT.ps1:2437`), so dot-sourcing produces all WPF helpers without launching the app. + +**Synthetic input:** a 1200×800 `System.Drawing.Bitmap` filled with known coloured rectangles (lines 67-72) is the input under test. No screen capture, no real window targeting. + +### The `-TestAction` Hook (Integration Entry Point) + +`Show-PreviewWindow` accepts a `[scriptblock]$TestAction` parameter (declared at `SnipIT.ps1:1013-1018`, invoked at `SnipIT.ps1:2185-2224`). When supplied, the function: + +1. Builds the WPF preview window normally (off-screen, hidden). +2. Wires up every event handler as a named closure (the documented WPF pattern). +3. Packs the closures and live state into a `$kit` hashtable (e.g., `SetZoom`, `ZoomBy`, `LayoutScale`, `ZoomText`, `BeginPan`, `PickColor`, `HandleMouseDown`). +4. Invokes `& $TestAction $kit` inside the dispatcher's `Loaded` callback — the test body runs **inside** `Show-PreviewWindow`'s scope while the window is alive but not blocking. +5. On test-action completion, the harness closes the window and disposes the bitmap (RAN-14 contract). + +**Test bodies** drive every code path through `$kit`: + +```powershell +Show-PreviewWindow -Bitmap $bmp -TestAction { + param($kit) + Describe 'Zoom' { + It 'SetZoom clamps to 10 (upper)' { + & $kit.SetZoom 100.0 + Should-Be $kit.LayoutScale.ScaleX 10.0 + } + ... + } +} +``` + +This is the canonical entry point for any WPF integration test. New preview-window features add their closures to `$kit` and a `Describe` block here. + +### Coverage areas + +`Describe` groups in `Test-SnipIT-Interactive.ps1` (42 tests): +- Zoom (set, by, compounds, clamps, fit-to-viewport, zoom text). +- Pan (begin / move / end, clamping, mouse-anchored zoom). +- Annotation tools (rectangle, ellipse, arrow, text, crop) and the named-closure mouse handlers (`$beginPan`, `$pickColor`, `$handleMouseDown`). +- Color picking (eyedropper closure). +- Save / clipboard / cancel paths. + +## CI Pipeline + +### `.github/workflows/test.yml` + +| Job | Runner | What it does | +|---|---|---| +| `headless` | `ubuntu-latest` | `dotnet tool install --global PowerShell` → `pwsh -NoProfile -File ./Test-SnipIT.ps1` | +| `parse` | `windows-latest` | (1) `[System.Management.Automation.Language.Parser]::ParseFile(…)` — AST parse-only with zero parser errors; (2) re-runs the headless suite on Windows pwsh | + +**Why parse-only on Windows?** The Windows-only `Add-Type` / WPF code paths cannot execute headlessly in CI, so the AST parser is used to verify the entire `SnipIT.ps1` is syntactically valid. Combined with the Linux + Windows headless runs of `Test-SnipIT.ps1`, this gives full-script syntax coverage plus pure-logic execution coverage. + +**The interactive suite (`Test-SnipIT-Interactive.ps1`) is not run in CI** — it requires a real desktop session. Run it locally on Windows before any preview / annotation PR. + +### `.github/workflows/security.yml` + +Six independent jobs, fail-fast off so all signals surface in one run. All actions SHA-pinned per OpenSSF Scorecard `Pinned-Dependencies`. + +| Job | Threshold | Blocks merge? | +|---|---|---| +| `psscriptanalyzer` | Zero **Error**-severity findings on `SnipIT.ps1` (warnings surfaced, non-blocking) | Yes | +| `trivy` (filesystem scan) | Zero `HIGH,CRITICAL` (`exit-code: 1`, `ignore-unfixed: true`) | Yes | +| `semgrep` | Zero ERROR on `p/security-audit` + `p/owasp-top-ten` | Yes | +| `gitleaks` | Zero secrets across **full git history** (`fetch-depth: 0`) | Yes | +| `jscpd` | < 3% duplication on `SnipIT.ps1` (`--min-tokens 100`, `--format powershell`, tests excluded) | Yes | +| `sbom` (SPDX + CycloneDX via `anchore/sbom-action`) | Generated and uploaded as artifact | No (surface only) | + +## Merge-Blocking Quality Gates (full set) + +Per `CLAUDE.md` and `shared/runbooks/engineering-standards.md` §1: + +1. **Headless tests** (`Test-SnipIT.ps1`) — pass on Linux + Windows runners. +2. **AST parse** — zero parser errors on Windows. +3. **PSScriptAnalyzer** — zero Error-severity findings. +4. **Trivy** — zero HIGH / CRITICAL. +5. **Semgrep** — zero ERROR on the two configured packs. +6. **Gitleaks** — zero secrets, full git history. +7. **jscpd** — < 3% duplication on production code. +8. **Signed commits** — every commit on `main` verifies (set up via `scripts/setup-git-signed.sh`). + +Surfaces only (do not block merge): SBOM, OpenSSF Scorecard score, Dependabot PRs. + +## Local Test Commands + +```bash +# Headless (any platform) +pwsh -NoProfile -File ./Test-SnipIT.ps1 + +# Interactive WPF (Windows only) +pwsh -NoProfile -Sta -File ./Test-SnipIT-Interactive.ps1 + +# Lint (Error-only — matches CI gate) +pwsh -c "Invoke-ScriptAnalyzer -Path ./SnipIT.ps1 -Severity Error" + +# Lint (full surface — Warnings + Errors, useful locally) +pwsh -c "Invoke-ScriptAnalyzer -Path ./SnipIT.ps1" + +# AST parse-only (Windows) +pwsh -NoProfile -Command "[System.Management.Automation.Language.Parser]::ParseFile((Resolve-Path ./SnipIT.ps1), [ref]\$null, [ref]\$errors)" +``` + +## Mocking & Fixtures + +- **No mocking framework.** Pure-logic helpers in `Core` are designed to take all inputs as parameters — no globals, no I/O — so tests pass values directly (see `Get-LoupeSourceRect` calls in `Test-SnipIT.ps1:79-102`). +- **WPF fixture:** the synthetic 1200×800 `System.Drawing.Bitmap` constructed at `Test-SnipIT-Interactive.ps1:67-72` is the only test input for the interactive suite. +- **Closure exposure** (`$kit` bag returned through `-TestAction`) is the substitute for traditional dependency injection. New code paths must add their named closure to the kit if the integration suite needs to drive them. + +## Test Authoring Rules + +- **New behaviour ships with at least one headless test** when the logic is testable without a desktop session (`shared/runbooks/engineering-standards.md` §4). +- **Pure logic goes in `Core`** so the headless suite can reach it via `-CoreOnly`. +- **WPF / preview behaviour goes through a named closure** so the interactive suite can drive it via `$kit`. +- **UI-only paths** (e.g., real OS hotkey delivery, real screen capture) are documented in `README.md` under `Tests` and exercised manually. +- A flaky test is a broken test — fix, quarantine, or delete in the same PR. No retry-loop hiding. + +## Coverage + +- Coverage is a **signal, not a target.** No coverage tooling configured today. The `Core` region is exhaustively unit-tested (40 tests across ~20 functions, including edge cases — negative-origin monitors, edge-clamp, exact-anchor, custom thresholds). The interactive suite covers every preview-window feature reachable without OS input simulation. +- Critical paths (capture loop ownership, save-path canonicalisation, single-instance mutex bypass) have explicit tests. + +--- + +*Testing analysis: 2026-05-07*