Skip to content

fix: 3 P0 audit findings — orphan-file real-delete path, autosave data loss, unreachable import entry point - #232

Open
KerroKapple wants to merge 14 commits into
mainfrom
worktree-audit-p0-fixes
Open

fix: 3 P0 audit findings — orphan-file real-delete path, autosave data loss, unreachable import entry point#232
KerroKapple wants to merge 14 commits into
mainfrom
worktree-audit-p0-fixes

Conversation

@KerroKapple

@KerroKapple KerroKapple commented Sep 1, 2026

Copy link
Copy Markdown
Owner

Summary

Fixes the 3 P0 findings from the 2026-08-31 full-codebase audit (17-window Codex review + code-based UI/UX walkthrough). The audit reports themselves are now committed under docs/review/2026-08-31/ so every claim in the plan and in this description is checkable in-repo.

  • P0-1: OrphanFileReaper (documented as read-only/dry-run) contained a real, no-recovery File.delete() branch reachable via a dryRun: false parameter no caller passed. Removed the parameter and the delete implementation entirely — the interface is now genuinely parameterless and delete-free.
  • P0-2: The canvas Inspector's debounced autosave silently discarded the user's last edit when switching the selected node within the 500ms debounce window. Two independent mechanisms had this bug (the shared InspectorSubmitController.savePromptDebounced, and ShotConfigInspector's own local Timer) — both fixed by holding a KeepAliveLink for the duration of a pending debounced write. The shot-notes fix generalised the mechanism into a shared saveDebounced(patch) rather than a second widget-local implementation, after the originally planned "flush on dispose" approach turned out to violate a Riverpod framework constraint (confirmed empirically; the plan file now carries an as-built deviation note at Task 3 so nobody re-implements the dead end).
  • P0-3: A user whose only InkFrame content was a project archive had no way to import it. Extracted the import flow into a reusable runProjectImportFlow() and wired it into both the zero-project empty state and the Ctrl/Cmd+K command palette.

Adversarial review pass (2026-09-02)

Six independent review lenses over the whole branch, each finding then put to three adversarial verifiers (correctness / reproducibility / scope). Seven findings survived a majority vote and are fixed here:

  • P2 the new empty-state Import button's busy gating had zero test coverage (the only mutex test goes through the FAB). Added a regression test and mutation-verified it: dropping the gate turns it red, restoring turns it green.
  • P2 the committed plan still described the un-shipped Task 3 design — now marked as an as-built deviation.
  • P3 ×5 dead OrphanCandidate.file field (its only reader was the deleted _reapFile), an over-claiming "touches no disk" contract comment (reap() does write a throttle marker), retired "DRY-RUN v1 / flip to real delete later" wording in five places, a stale command-palette docstring, a wrong test count on the BOARD row, and a machine-local absolute path containing the OS username inside a committed audit report.

Docs synced in-branch per the playbook: BOARD gets a landed row plus debt rows for every deliberately deferred item, docs/CLAUDE.md's structure snapshot gains project_import_flow.dart and loses the stale reaper description, and lib/features/studio/README.md lists the new flow.

Deferred, recorded as debt rather than silently dropped: (a) an already in-flight autosave can still overwrite a submit() write; (b) autosave failures are still silently swallowed; (c) InspectorSubmitController keeps one pending debounce slot per node; (d) quitting the app within the debounce window still loses the pending write, because teardown closes the PG pool before disposing the container. All four are in the BOARD debt table.

Test plan

  • flutter analyze lib test → No issues found!
  • flutter test --exclude-tags golden → 1956 passing / 66 skipped / 0 failing. Baseline at the pre-review branch tip was 1955; the delta is exactly the one test added by the review pass. Two back-to-back runs produced identical counts.
  • Golden studio_empty baseline re-minted on the CI ubuntu runner via update-goldens and visually diffed: the only change is the new Import button.
  • The new empty-state gating test verified red-before / green-after by mutation.

⚠️ Merge-time obligation

This branch carries the update-goldens bot commit, whose message contains the marker that suppresses CI (the one written as square-bracketed skip-ci). Per docs/EXECUTION-PLAYBOOK.md §2.3, a squash merge folds that into main's commit body and silently skips ci / smoke / secret-scan on main. After merging, re-run them:

gh workflow run ci.yml --ref main
gh workflow run smoke.yml --ref main
gh workflow run secret-scan.yml --ref main

🤖 Generated with Claude Code

KerroKapple and others added 14 commits August 31, 2026 23:14
The dry-run-only orphan file reaper had a real File.delete() branch
gated only by a default parameter (reap(dryRun:false)). No caller ever
passed false, but the code compiled and existed in a service that
documents itself as never deleting anything. Removed the parameter and
the delete implementation entirely — a read-only audit service should
not contain delete code at all.

Audit: docs/review/2026-08-31/W12.md P0-1
InspectorSubmitController.savePromptDebounced only cancelled its Timer
on dispose instead of flushing it. Since the controller is
autoDispose-family-scoped per node id, switching the Inspector's
selected node disposes the OLD controller as soon as nothing watches
it anymore — which, within the 500ms debounce window, silently
discarded the user's last edit. Fixed by holding a KeepAliveLink for
the duration of the pending write, so the debounce timer still fires
(and its save still lands) even after the widget stops watching.

Audit: docs/review/2026-08-31/W17.md P0, corroborated by W3.md P1 and
W4.md P1 (same root cause, found independently by three windows).
ShotConfigInspector kept its own local debounce Timer (separate from
InspectorSubmitController's) for the shot_notes field, and its
dispose() only cancelled the pending timer — so switching the selected
canvas node within the 500ms debounce window silently discarded the
last edit.

The originally planned fix (flush the pending save via ref.read()
inside dispose()) turned out to be impossible: Riverpod's
ConsumerStatefulElement rejects any ref access from a widget's own
dispose() once it is unmounting, regardless of whether the underlying
provider container survives (confirmed empirically — the first
implementation attempt threw StateError). Fixed instead by
generalizing the prior commit's InspectorSubmitController's debounce
into a shared saveDebounced(patch) method, and routing the shot_notes
field through it directly. The debounce and its keepAlive now live on
the provider container's lifecycle rather than the widget's, so the
widget's own disposal no longer matters.

Audit: docs/review/2026-08-31/W4.md P1 (independently corroborates the
same root cause as W17.md's P0 finding fixed for the prompt field in
the prior commit)."
…tate

A user whose only InkFrame content is an archive file had no way to
import it: the Import button lived only in the FAB row, which is
hidden entirely when the project list is empty, and the project
card's ⋮ menu (also a possible import surface) doesn't exist yet
either. Extracted the private _importProject flow into a public
runProjectImportFlow() so it can be reused, and wired it into the
empty state's CTA row.

Audit: docs/review/2026-08-31/W17.md P0
Completes the P0 fix for the missing import entry point: a
keyboard-only user (or anyone who doesn't notice the empty-state
button added in the previous commit) can now reach import via Ctrl/Cmd+K
from Studio as well.

Audit: docs/review/2026-08-31/W17.md P0
Three comments still described the pre-generalization design (prompt-
specific debounce, dispose-based flush). Updated to reflect the actual
current mechanism (saveDebounced is generic, shared by prompt and
shot_notes; the widget never flushes from dispose()). Also documented
the one-pending-patch-per-node caveat on saveDebounced so a future
caller adding a third debounced field on the same node type doesn't
silently race with an existing one.

Follow-up from the final whole-branch review of the 2026-08-31 audit
P0 fixes plan.
Kept alongside prior plans in docs/superpowers/plans/ for reference —
records the task breakdown, TDD steps, and rulings made during
execution (see PR description for the summary).
计划文件与 PR 正文都引用 docs/review/2026-08-31/{AUDIT-SUMMARY,W1a..W17,W-UX-live}.md
作为三个 P0 的证据(行号级复现),但这批文件此前只存在于本地工作区——仓库里对不上
就是 PLAYBOOK §5.8 的幽灵引用。194 条 finding(P0×3 / P1×47 / P2×89 / P3×55)后续
排期也要以它为底,随本 PR 一并入库。内容为纯文本审计报告,不含密钥。

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014c1ft3S1sBYwZH2iBzDXVx
- BOARD「近期落地」加本 PR 一行(三个 P0 各自的修法与卡面偏离的原因);债表补三条
  #232 明确不做/裁定的项:在途自动保存 vs submit() 竞序、自动保存失败静默、
  单 node 单防抖槽位——PLAYBOOK §1.5 要求延后项必须进债表,不能只写在 PR 正文里
- CLAUDE.md 结构快照:studio/ 补 project_import_flow.dart(该层按文件枚举);
  orphan_file_reaper 一行由「DRY-RUN v1」改为「只读扫描、无删除代码」——
  "DRY-RUN" 暗示存在一个开关,而 P0-1 的修法恰恰是让开关不存在
- features/studio/README 组成表与数据流各补一行导入流程

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014c1ft3S1sBYwZH2iBzDXVx
- `OrphanCandidate.file`:唯一读者是 #232 已删的 `_reapFile`,删掉读者留着生产者
  = 写而不读的必填字段,而且正是删除代码赖以存在的那只 File 句柄;日志/统计
  只用 relativePath/sizeBytes/ageDays
- 契约注释"不改动磁盘"说过头:reap() 会在 config/ 写节流标记;改成"不碰任何
  媒体文件(唯一落盘 = 节流标记)"
- DI/测试文件头、BOARD 债表两行、MASTERPLAN LB-13b 行仍写着"DRY-RUN v1 / 转真删
  灰度"——那是"以后翻开关"的旧设计,与 P0-1 的修法(开关根本不存在)矛盾,统一
  改为"只读扫描、无删除代码;真删须另立独立评审的实现"

analyze 0 issue;orphan_file_reaper_test 10 例通过

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- 状态表「角色一致性」一行自 #209 起就写着"仅 image 节点…生效",与同表 #209/#230
  两行及代码(video 只看 maxRefImages)自相矛盾——BOARD 是单一事实源,读这一行的人
  会得出"video 不注入"的错误结论;改为 image/video 双分支原话
- CH-2(#230)合并后补跑对抗评审,四条低害残留记债表:存为角色只认 reference 边、
  超 maxRefImages 无提示、await 后无 mounted 守卫、gallery_tile 第三份命名框

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
对抗评审(六视角 + 每条三反驳者)对本 PR 的确认项:

P2 空态导入按钮的 busy 门控没有测试:`studio_home_screen` 里 FAB 与空态各有一份
   busy 表达式,而唯一那条互斥用例走的是 FAB(pump 传 _oneProject),空态那份
   `importBusy ? null : ...` 零覆盖——删掉它全部用例照绿。补一条空态用例断言
   `InkGhostButton.onPressed == null`;**变异验证**:去掉门控后该用例真红(+5 -1),
   恢复后转绿(+6)
P2 计划文件仍整段描述未落地的 Task 3 方案:原方案「widget dispose 里 flush」被
   实测推翻(ConsumerStatefulElement 在自身 unmount 时拒绝该 widget 的 ref 访问),
   实际改为 controller 侧 saveDebounced;在 Task 3 标题下加 as-built 偏离说明,
   免得后来者照着计划稿重做一遍死路
P3 MASTERPLAN LB-13b 行同时写着「真删除待 dry-run 灰度后」与它的撤销——前者正是
   P0-1 移除掉的设计,删掉该从句
P3 BOARD 本 PR 行「+5 例」标注来源(计划 4 + 评审补 1)
P3 命令面板 buildCommandActions 文档注释仍写「studio 首页:设置」
P3 app.dart 启动触发注释仍写「DRY-RUN」(暗示存在开关)
P3 审计报告 W-UX-live 正文嵌了带用户名的本机绝对路径,改为相对描述

analyze 0 issue;全量测试通过(排除 golden,CI 侧跑)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
keepAlive 只挡 autoDispose,挡不住 AppTeardown 的 container.dispose;而且退出序列
在 dispose 之前就 Pool.close 了,补落盘也写不进去。窗口≤500ms,比切换选中窄,
但根因同属 P0-2 那类"挂起写入被丢弃",不记账将来会被当成已修。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant