Windows architecture audit — DX / UX / boundary backlog
A living tracker for the architecture / developer-experience / terminal-UX audit of this repo. It exists so the backlog stops living in PR descriptions and chat: each item has a stable ID, an impact, and a status, and PRs reference the ID.
Status legend: ✅ shipped · 🟡 partial · ⬜ open
Namespace note: these
B#/U#IDs are the single ID registry for the repo.CHANGELOG.mdused to carry its ownB#/U#headings with colliding numbers (a separate, earlier pass); those tags were retired (B16) so a reader is never tracking two schemes — the changelog is now thematic prose that points here.
Backend / boundary architecture
| ID | Status | Component | Problem | Direction | Impact |
|---|---|---|---|---|---|
| B1 | ✅ | nvim-sync.ps1, parity prose | One-way robocopy /MIR, no recorded source ref, no drift detection — parity can silently rot. | Record synced Core SHA (nvim/.core-ref) ✅; CI parity gate ✅ — tests/Assert-NvimParity.ps1 clones Core @ the recorded commit and diffs nvim/ (incl. lazy-lock.json — the cross-platform plugin pins are synced from Core; excluding only the vendored-copy .core-ref), failing on drift and self-skipping until a sync stamps the ref. Reproducible re-vendor ✅: nvim-sync -Ref <commit-or-tag> fetches an exact Core ref (init + shallow fetch-by-ref + detached checkout) and records the pin in .core-ref; the pure Get-NvimSyncRefPlan (ref-over-branch precedence; rejects a dash-leading ref and the -Ref+-CoreLocal combo) is unit-tested. | High |
| B2 | ✅ | 10-tools.ps1 Get-InitCache | Cache busted only on binary mtime; editing generator flags left a stale cache. | Key on SHA-256(generator text) + tool mtime so a flag change self-invalidates. (PR #7) | High |
| B3 | ✅ | 05-lib.ps1 Invoke-DotSpinner | Start-Job spawned a child pwsh per spinner. | Start-ThreadJob with Start-Job fallback. (PR #7) | Medium |
| B4 | ✅ | packages/* | Only PS modules were version-pinned; scoop/winget apps floated to latest, so two boxes diverge — “reproducible” was partial. | Lockfile machinery shipped ✅: pure PackageLock.ps1 (read/parse-export/drift), an Update-PackageLock.ps1 generator, Install-Packages.ps1 -Frozen (installs exact packages.lock.json versions, skips un-locked apps rather than floating), and a CI drift gate. packages.lock.json was generated on a Windows box and committed ✅ — the drift gate is now armed and -Frozen reproduces a real baseline. Committing it surfaced a line-ending gap (no .gitattributes, so Git-for-Windows checked the tree out as CRLF and the LF validator failed on ~every file): hardened by writing the lock as LF/UTF-8-no-BOM from the generator, adding a * text=auto eol=lf .gitattributes, and scoping the editorconfig gate to git ls-files (so nested worktrees are no longer mis-flagged). | High |
| B5 | ✅ | .github/workflows/ci.yml | Coverage gate measured only 05-lib.ps1; the other pure-helper files (incl. the new Dotfiles/*.Helpers.ps1) were tested but ungated, and the minTotal/minFiles anti-deletion floors were hand-edited literals in ci.yml that drifted (minFiles sat at 13 while the suite grew past 17). | CodeCoverage.Path expanded to the whole pure-helper surface ✅ (PR #13). The gate is now split by what each number is: the test-file count is auto-derived — CI counts tests/**/*.Tests.ps1 and asserts Pester discovered EXACTLY that many, so a suite that fails to load or is removed/renamed trips it with no number to maintain (issue #29). The coverage % bar + test-case floor live in a generated, checked-in baseline (tests/coverage-baseline.json), refreshed by tests/Update-CoverageBaseline.ps1 (same Pester config, written LF/UTF-8-clean per the B4 lesson). CI runs one pure decision through the unit-tested Get-CoverageGateResult/Read-CoverageBaseline (tests/CoverageGate.ps1) — which throws on a missing/corrupt baseline so the gate can’t silently go soft — and Repo.Tests guards that ci.yml keeps both the auto-derived file check and the baseline (no literals creeping back). Coverage % stays a deliberate bar (not auto-ratcheted); only the case floor tracks the suite. | Medium |
| B6 | ✅ | profile.ps1 loader | Fragment order/deps were implicit (NN- sort + shared globals); no declared contract. | Per-fragment provides/requires headers + LoadContract.Tests.ps1, which derives the dependency graph from the AST and asserts every requirement resolves to the module or a strictly-earlier fragment. (PR #14) | Medium |
| B7 | ✅ | All fragments | Nearly every helper was global:, polluting the session with no teardown. | Profile’s pure surface wrapped in a Dotfiles module exporting a curated set; only intended verbs stay global. (PRs #9–#12 + scope tidy) | High |
| B8 | ✅ | install.ps1 transcript, 40-op.ps1 | Transcript captured the whole run unredacted; logs never pruned. | Redact via Test-SensitiveHistoryLine; cap install-*.log at 10. (PR #7) | Medium |
| B9 | ✅ | install.ps1 / uninstall.ps1 / Install-Packages.ps1 | Backslash literals in the script-loading Join-Path calls that dot-source 05-lib.ps1 — a cross-platform footgun if a bootstrap script is run under Linux pwsh. | All three bootstrap lib-loads normalized to forward slashes ✅ (install/uninstall PR #13; Install-Packages follow-up). Windows-only runtime data paths ($env:LOCALAPPDATA\…, Documents\PowerShell\Modules, cache dirs) keep the backslash house style by design — they’re only evaluated on Windows hosts / Windows-specific entrypoints (os/* fragments, the installer’s package step), not under the Linux pwsh that runs the fast CI gate. | Medium |
| B10 | ✅ | bootstrap.ps1 | Setup required a manual git clone + .\install.ps1; no integrity-gated one-liner. | A self-contained bootstrap.ps1 (irm … piped to iex) clones-or-updates the repo, optionally checks out a pinned DOTFILES_REF, and hands off to install.ps1 — it never pipes a further network script into iex, so scoop stays behind the existing DOTFILES_SCOOP_SHA256 gate and every DOTFILES_* env knob is inherited untouched. README documents a hash-verified one-liner; a drift test pins the README’s LF-normalized SHA-256 to the script. Pure resolvers (Get-Bootstrap{RepoUrl,TargetDir,GitAction,InstallArgs}) unit-tested. | Medium |
| B11 | ✅ | 30-windows.ps1 modules-localize | robocopy /E copied modules off OneDrive but never reaped the stale versions each maintenance roll-forward left behind, so the local dir accumulated. | modules-localize -Prune reconciles the local dir against the MANAGED set (packages/modules.ps1): keep the highest version of each managed module, remove older ones, never touch a non-managed module. The decision is the pure, coverage-gated Get-DotModulePrunePlan (Dotfiles module export, unit-tested); the prune is idempotent and reports each removal. | Medium |
| B12 | ✅ | ci.yml lua-lint | luacheck was apt/luarocks-installed unpinned every run — slow, non-hermetic, a supply-chain gap vs. the SHA-pinned Actions. | Pin LUACHECK_VERSION, cache the compiled rock keyed on runner image + Lua line, and install against Lua 5.1. (PR #13) | Medium |
| B13 | ✅ | 20-functions.ps1 serve | serve binds 0.0.0.0 (every interface) with no auth — on purpose per its in-code note (ad-hoc file transfer, in parity with Core’s serve), advertising the host LAN IP. The concern was only that there’s no localhost-only escape hatch, so the cwd was always LAN-exposed. | Kept the LAN bind as the default (the feature, and Core parity, are preserved; exposure is already announced by the printed LAN URL) and added an opt-in serve -Local that binds 127.0.0.1 only and advertises the loopback URL. The bind/URL decision is the pure, unit-tested Get-DotServePlan (Dotfiles module export); the fragment keeps the LAN-IP probe + python spawn. | Medium |
| B14 | ✅ | tests/*, fixtures | Test-suite refinements: shallow assertions in a few suites, duplicated fixture setup, and brittle hand-tuned expectations. (The CI count/coverage floors that used to live here were resolved by B5.) | Deepened assertions where the exact output is derivable from source: Get-DotToolNudge (3 loose -Match → one exact -Be, ×2), Get-InstallSummaryLines (count + 3 substrings → the exact ordered 4-line array, covering the previously-unchecked copied line and (already correct) suffix), and two Should -Not -Throw smoke tests for Write-DotBanner/Write-DotRule now capture the output and assert the title/subtitle survive and the auto-sized rule leads with its prefix. Hoisted the duplicated fixture: a new tests/_TestHelpers.ps1 (New-DotTestTempDir) replaces the identical temp-dir boilerplate in Install/Integration/Uninstall/Completions. The “magic expectations” were assessed individually rather than blanket-rewritten — several flagged literals are legitimate (the Perf ≥5 floor is deliberately loose because only 5 of the 6 cached tools share the if ($cf) fallback shape; the CoverageGate/NvimParity/Packages literals are isolated unit-test fixtures, not drift) and were left as-is. Every new expectation was verified against live function output (Pester isn’t installable on the Linux gate); CI fast-gate green. | Low |
| B15 | ✅ | README.md, TOOLS.md, PORTING-NOTES.md | Docs drift: the README fragment-layout box listed only 5/11 core + 4/6 os fragments (missing 05-lib/25-television/45-crypto/50-completions/55-help/57-health-nudge and 33-psmux-pill/45-doctor). | Rebuilt the box to list all 17 fragments and added a Repo.Tests drift gate that asserts the box’s NN-name tokens equal the on-disk core+os set, so it can’t silently drift again. Also refreshed the serve cheatsheet row for -Local (B13). TOOLS.md / PORTING-NOTES.md were reviewed and found current (television/psmux/gum/2026 tools all tracked) — no change needed. | Low |
| B16 | ✅ | this doc + CHANGELOG.md | Namespace collision: the audit’s B#/U# IDs and CHANGELOG.md’s separate B#/U# headings were different schemes with overlapping numbers (CHANGELOG B5 = uninstall.ps1, audit B5 = the coverage gate), so a reader couldn’t tell which B5 was meant. | Made this doc the single ID registry: retired the _(B#)_/_(U#)_ tags from CHANGELOG.md (they were referenced nowhere) and reframed it as thematic prose that points here for IDs/status. The deferred B13 (serve -Local) changelog bullet was folded in at the same time. | Low |
Terminal UX
| ID | Status | Component | Problem | Direction | Impact |
|---|---|---|---|---|---|
| U1 | ✅ | 05-lib.ps1 renderers | gum was installed but only the confirm path used it. | gum now covers the interactive prompts — confirm ✅ and input ✅ (U11) — which was the goal of this item, so it’s closed at that scope. The remaining renderers are deliberately NOT routed through gum and won’t be (a settled decision, not pending work): gum spin wraps an external command and can’t run our object-returning scriptblock without re-introducing the process spawn B3 removed; there’s no clean gum primitive for the titled Write-DotRule; and a gum style banner is a marginal change for a subprocess-per-banner cost. So spinner/rule/banner stay hand-rolled (with their pure NO_COLOR/ASCII fallbacks). | High |
| U2 | ✅ | Install-Packages.ps1 | Progress was indeterminate (per-phase [n/total] + final seconds); no overall sense of how far / how much longer over a multi-minute install. | A single determinate Write-Progress bar spanning all three phases (scoop + winget + modules), updated per item with done/total and an ETA extrapolated from the average pace. The model is the pure, unit-tested Get-DotInstallProgress + Format-DotDuration; the bar renders only on a live console (skipped under NO_COLOR/redirected/CI, where the per-item log carries the detail) and clears in finally (on completion AND Ctrl-C). | Medium |
| U3 | ✅ | install / packages | No interactive selection; nothing was discoverable or skippable. | Manifest entries carry an optional group tag (gui = Firefox/Obsidian/1Password); the first interactive run picks groups with gum choose --no-limit (opt-out: all preselected) and persists DOTFILES_PKG_GROUPS to local.ps1, so later/CI runs don’t re-prompt. Pure policy (Get-DotOptionalGroups/ConvertFrom/ConvertTo-DotGroupList/Test-DotGroupSelected/Set-DotGroupLine) is unit-tested; non-interactive installs every group (zero regression). | Medium |
| U4 | ✅ | 45-doctor.ps1 | Flat result list, no machine-readable mode. | Grouped sections (pure Get-DoctorGroup) + -Json. (PR #8) | Medium |
| U5 | ✅ | 05-lib.ps1 Write-DotRule, dothelp, 45-doctor.ps1 | Rule width hardcoded; renderers didn’t adapt to terminal width. | Get-DotConsoleWidth-driven rules/hints ✅ (PR #8); the dothelp description column and the doctor detail column now word-wrap to the console via Format-DotWrap, continuation lines aligned under their column (matching the doctor hint already did). Falls back to 80 cols when there’s no console (CI/redirected), so short entries are unchanged. | Medium |
| U6 | ✅ | 05-lib.ps1 | Color was binary on/off over the 16-color ConsoleColor enum; accents couldn’t match the Tokyo Night palette. | Test-DotTrueColor (COLORTERM=truecolor/24bit + a [Console]::IsOutputRedirected guard so ANSI never leaks into captured/CI streams) and a pure Get-DotAnsiSgr (ConsoleColor name → 24-bit Tokyo Night SGR) wired into Write-DotHost/Write-DotBanner — every existing -Color call auto-upgrades on a live truecolor terminal and falls back to ConsoleColor everywhere else. Pure helpers unit-tested. | Medium |
| U7 | ✅ | 05-lib.ps1 Invoke-DotSpinner | Ctrl-C could orphan the background job around the Start-Job/try seam. | Track the job in script scope, clean in finally; ThreadJob is cheaper to abort. (PR #7) | High |
| U8 | ✅ | profile.ps1 degraded-load nudge | Only the count + first failure printed inline. | Name all failing fragments + the one-command fix inline. (PR #7) | Medium |
| U9 | ✅ | 55-help.ps1 dothelp -i | The fzf picker hid the preview, so the description/group columns were unused while choosing. | Get-DotHelpFlatLines now renders an aligned command description [group] display column (in PowerShell) with the bare command in a hidden trailing field; the picker shows the display (--with-nth 1, --nth 1) and extracts the command from the last field on pick. Rendering in PowerShell — rather than a --preview 'echo …' shell — is deliberate: catalog cells like mkbak <f> and groups like Listing & files carry cmd.exe metacharacters (< > &) a preview shell would mis-parse (caught in review). Unit-tested, incl. the metacharacter cases. | Medium |
| U10 | ✅ | profile.ps1 / 00-aliases.ps1 | A half-provisioned box silently lost ls/cat/z with no hint. | Throttled once-per-session “N core tools missing — run dotfiles-doctor” (57-health-nudge.ps1). (PR #7) | High |
| U11 | ✅ | install.ps1 / lib | Only the email loop was validated; other Read-Host calls shared no validation/default/masking pattern. | Shared Read-DotInput (gum input when interactive, else Read-Host; optional validator, default, and --password/-MaskInput secret masking) over a pure, unit-tested Get-DotInputResult. install’s git name/email prompts now use it; -Secret is ready for token prompts. | Medium |
| U12 | ✅ | 05-lib.ps1 renderers | Hint lines weren’t wrapped; long paths overflowed. | Word-wrap hints to width (Format-DotWrap). (PR #8) | Medium |
| U13 | ✅ | Install-Packages.ps1 / lib | During the longest silent ops the spinner label was static — stalled vs. slow was indistinguishable. | Invoke-DotSpinner now ticks a running (Ns) elapsed counter (stopwatch-driven) onto the label, via a pure, unit-tested Format-DotSpinnerLine (suffix appears only past 1s, so quick ops don’t flash (0s)); the line-wipe tracks the widest frame so a grown counter clears cleanly. | Medium |
| U14 | ✅ | starship.toml, psmux/*.conf, ssh/config, git/.gitconfig, docs/TOOLS.md | Deep review of the shipped config files surfaced 6 small findings: a reders→renders typo (starship.toml); two stale claims in TOOLS.md (psmux prefix described as “left at default” and mouse as “on”, both wrong — it’s C-a + mouse off); delta’s syntax-theme = TwoDark drifting from the Tokyo Night palette; a needlessly quoted width = "variable"; a mouse off config whose comment argued for mouse on; and four dead bind -n M-<arrow> pane-nav binds in psmux.reset.conf that Windows Terminal’s own alt+<arrow> MoveFocus intercepts before psmux sees them. | Fixed the typo; reconciled the two TOOLS.md claims to the actual config; set delta syntax-theme = ansi so diffs follow the terminal’s Tokyo Night ANSI palette (same approach as BAT_THEME in 00-aliases.ps1) — a true Tokyo Night .tmTheme would need a per-machine bat cache --build, out of scope for a config tweak; unquoted width; rewrote the mouse comment to document the deliberate keyboard-only mouse off (keeping the paired mouse-selection off for when it’s flipped); removed the dead Alt+arrow binds with a note pointing to the live C-h/j/k/l nav and WT’s ownership of alt+<arrow>. Added IdentitiesOnly yes to ssh Host * (don’t offer every agent key to every host). windows-terminal/settings.json reviewed and reconciled clean. | Low |
| U15 | ✅ | profile fragments | Renderer/UX consistency: several fragments still called raw Write-Host … -ForegroundColor (47 sites across 9 fragments) where the themed Write-DotHost (NO_COLOR/ASCII/truecolor-aware) is intended, so their accents ignored NO_COLOR and never reached the truecolor Tokyo Night palette. | Routed every colored Write-Host through Write-DotHost -Color across 00-aliases, 10-tools, 15-update, 20-functions, 45-crypto, 30-windows, 31-wsl-bridge, 33-psmux-pill, 40-maint — a faithful, behaviour-preserving transform (verified: only the colored half of compound lines changed; 05-lib renderer internals and uncolored Write-Host left alone; parser + CI fast-gate green). The “alias/function name parity” clause was then chased down against Core (dotfiles-core/zsh/): the framework/local helper names use an intentional two-tier scheme with no outlier, but the git aliases had genuinely drifted — Windows gl showed the log while Core’s gl pulls, and gpl/gs diverged from Core’s git.zsh. Aligned them to Core (gl=pull, added glog graph-log, gst/gss/gsb status family, gc=commit -v; dropped the Windows-only gpl; kept gs as a convenience extra), updating the # provides: contract header and the dothelp catalog to match. | Low |
| U16 | ✅ | nvim/ keymaps / nvim-sync.ps1 | The wart: config/keymaps.lua’s <leader>rc (“Edit config”) hardcoded ~/.config/nvim/init.lua — the Unix path — so on the vendored Windows host (nvim reads %LOCALAPPDATA%\nvim) the keymap opened a nonexistent file. | Upstreamed, not forked. A Windows-local override was rejected: it’d be an Extra file the parity gate flags AND robocopy /MIR purges on the next sync. Instead made the shared keymap OS-portable via vim.fn.stdpath("config") (identical to today on Unix, correct on Windows) — matching the codebase’s existing stdpath/runtime-detect pattern (lazy.lua, clipboard.lua). PORTING-NOTES records that the fix must land in Core before a re-vendor so /MIR doesn’t reintroduce it. Parity gate stays clean (.core-ref absent → self-skips; change is upstream-compatible). | Low |
Secondary long-tail
These were promoted to tracked IDs above (B13–B16, U14–U16) so they’re cited like
everything else, and all seven have since shipped. The earlier free-text bucket of
~15–25 items collapsed into those seven coherent work items — when a new structural
issue surfaces, give it the next free ID in its section and scope its sub-findings in
the PR that fixes it.
Maintained as part of the architecture audit. When an item ships, flip its status and cite the PR; when a new structural issue is found, give it the next free ID in its section.