From 57f61d2a18527b5d1b9d91c43c02b239e909fc39 Mon Sep 17 00:00:00 2001 From: Kiro Date: Mon, 31 Aug 2026 23:14:48 +1000 Subject: [PATCH 01/14] fix: remove unreachable real-delete path from OrphanFileReaper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- lib/core/di/orphan_reaper.dart | 4 +- lib/core/interfaces/orphan_file_reaper.dart | 18 ++++----- lib/services/orphan_file_reaper.dart | 42 +++++---------------- test/services/orphan_file_reaper_test.dart | 2 +- 4 files changed, 19 insertions(+), 47 deletions(-) diff --git a/lib/core/di/orphan_reaper.dart b/lib/core/di/orphan_reaper.dart index d5f5eb5f..8e102c63 100644 --- a/lib/core/di/orphan_reaper.dart +++ b/lib/core/di/orphan_reaper.dart @@ -22,8 +22,8 @@ final orphanFileReaperProvider = FutureProvider((ref) async { ); }, name: 'orphanFileReaperProvider'); -/// 启动首帧后触发一次孤儿回收(DRY-RUN + 节流)。housekeeping:任何失败只 warn, -/// 绝不阻断启动或其它流程。**刻意不传 dryRun**——保持默认 true(本卡绝不删文件)。 +/// 启动首帧后触发一次孤儿回收(只读扫描 + 节流)。housekeeping:任何失败只 warn, +/// 绝不阻断启动或其它流程。DiskOrphanFileReaper 里没有删除实现,reap() 恒只读。 final orphanReapStartupProvider = FutureProvider((ref) async { final logger = ref.watch(loggerProvider); try { diff --git a/lib/core/interfaces/orphan_file_reaper.dart b/lib/core/interfaces/orphan_file_reaper.dart index 9d95b4e9..625338ca 100644 --- a/lib/core/interfaces/orphan_file_reaper.dart +++ b/lib/core/interfaces/orphan_file_reaper.dart @@ -1,23 +1,19 @@ // OrphanFileReaper 契约:磁盘孤儿媒体文件回收(GC)。 // // 「孤儿」= projects/*/canvases/*/{images,videos} 下、画布相对路径不在引用集、 -// 且 mtime 早于阈值(默认 7 天)的文件。本卡(LB-13 slice B)为 DRY-RUN v1: -// 只识别 + 记 orphan.reap.dryrun 日志,**绝不删除任何文件**。 -// -// 实际删除藏在 reap(dryRun:) 的显式开关后,且默认 true;本卡任何调用点都不传 -// dryRun=false,故删除路径不可达。未来卡在 dry-run 日志验证无误后再翻开关。 +// 且 mtime 早于阈值(默认 7 天)的文件。本服务只识别 + 记 orphan.reap.dryrun 日志, +// **没有任何删除实现**——不是"默认关闭的开关",是这个类里根本不存在删除代码。 +// 真正的删除需要独立实现、独立评审,不在本契约里。 abstract class OrphanFileReaper { - /// 扫描并识别孤儿文件。 - /// - /// [dryRun]=true(默认,也是本卡唯一取值)只记 orphan.reap.dryrun 日志、不删除。 + /// 扫描并识别孤儿文件,只记 orphan.reap.dryrun 日志、不删除、不改动磁盘。 /// 节流:距上次成功回收不足阈值则直接跳过(返回 [OrphanReapReport.skipped])。 /// 引用集构建失败(InkError)向上抛——由启动兜底 swallow 成 warn,绝不阻断。 - Future reap({bool dryRun = true}); + Future reap(); } /// 一次回收的结果快照(供启动日志 / 测试断言)。 /// -/// 本卡从不删除文件,故此处不含「已删列表」——只有识别统计。 +/// 本服务从不删除文件,故此处不含「已删列表」——只有识别统计。 class OrphanReapReport { const OrphanReapReport({ required this.throttledSkip, @@ -36,7 +32,7 @@ class OrphanReapReport { /// 本次因节流被跳过(未扫描)。 final bool throttledSkip; - /// 本次是否为 dry-run(本卡恒 true)。 + /// 恒 true——本服务没有删除实现,报告字段保留以标记"这是一次只读扫描"。 final bool dryRun; /// 识别出的孤儿文件数。 diff --git a/lib/services/orphan_file_reaper.dart b/lib/services/orphan_file_reaper.dart index 8949c7c7..ead7e6f6 100644 --- a/lib/services/orphan_file_reaper.dart +++ b/lib/services/orphan_file_reaper.dart @@ -1,4 +1,4 @@ -// DiskOrphanFileReaper:OrphanFileReaper 的磁盘实现(LB-13 slice B,DRY-RUN v1)。 +// DiskOrphanFileReaper:OrphanFileReaper 的磁盘实现(LB-13 slice B)。 // // 只扫 projects/

/canvases//{images,videos}——绝不碰其它任何目录(安全#3)。 // 三重安全: @@ -8,8 +8,10 @@ // 的节点也算引用——软删可 LB-15 恢复,其产物必须留。 // #3 目录白名单:只列 images/ 与 videos/,其余一律不扫描、不识别。 // -// DRY-RUN:识别到的孤儿只 logger.info('orphan.reap.dryrun', ...),**绝不删除**。 -// 真实删除在 reap(dryRun:false) 分支——本卡任何调用点都不传 false,故该分支不可达。 +// 只读:识别到的孤儿只 logger.info('orphan.reap.dryrun', ...),**这个类里没有任何 +// 删除代码**——2026-08-31 审计 P0:曾经的 reap(dryRun:false) 分支是一条真实可达、 +// 无恢复机制的删除路径,即便当时没有调用点传 false,也不该让删除实现待在一个号称 +// "只读审计"的服务里。真正的删除功能必须是独立评审的另一个实现。 import 'dart:io'; import 'package:path/path.dart' as p; @@ -61,10 +63,10 @@ class DiskOrphanFileReaper implements OrphanFileReaper { File(p.join(_paths.config.path, kOrphanReapMarkerName)); @override - Future reap({bool dryRun = true}) async { + Future reap() async { final now = _clock.nowUtc(); - // 节流:距上次成功回收不足阈值直接跳过(dry-run 也节流,免得每次启动刷屏)。 + // 节流:距上次成功回收不足阈值直接跳过(免得每次启动刷屏)。 final last = _readLastReap(); if (last != null && now.difference(last) < kOrphanReapThrottle) { return const OrphanReapReport.skipped(); @@ -89,16 +91,6 @@ class DiskOrphanFileReaper implements OrphanFileReaper { 'age_days': c.ageDays, }, ); - // ── DRY-RUN 边界 ────────────────────────────────────────────────── - // 真实删除只在 dryRun=false 时发生。本卡任何调用点都不传 false(默认 true),故此 - // 分支不可达——保证本卡「永不删除」。未来卡验证 dry-run 日志无误后翻开关。下段整体 - // 排除覆盖率统计(不为覆盖率 wire dryRun:false,那会真的删文件)。marker 须独占整行, - // 尾部不能带注释文字,否则 coverage 解析器(锚定行尾)识别不到 start → 报 unmatched。 - // coverage:ignore-start - if (!dryRun) { - await _reapFile(c.file); - } - // coverage:ignore-end } _logger?.info( @@ -107,7 +99,7 @@ class DiskOrphanFileReaper implements OrphanFileReaper { extra: { 'orphan_count': candidates.length, 'total_bytes': totalBytes, - 'dry_run': dryRun, + 'dry_run': true, }, ); @@ -115,7 +107,7 @@ class DiskOrphanFileReaper implements OrphanFileReaper { return OrphanReapReport( throttledSkip: false, - dryRun: dryRun, + dryRun: true, orphanCount: candidates.length, totalBytes: totalBytes, ); @@ -219,22 +211,6 @@ class DiskOrphanFileReaper implements OrphanFileReaper { // 写标记失败:仅影响下次节流(最坏重复一次 dry-run),绝不阻断。 } } - - // 未来卡的删除路径,本卡不可达(无处 wire dryRun:false);下方方法整体排除覆盖率统计。 - // coverage:ignore-start - /// 真实删除——仅 reap(dryRun:false) 触达。本卡不可达(无处 wire false)。 - Future _reapFile(File file) async { - try { - await file.delete(); - } on FileSystemException catch (e) { - _logger?.warn( - kOrphanReapModule, - 'orphan.reap.delete_failed', - extra: {'reason': e.message}, - ); - } - } - // coverage:ignore-end } /// 孤儿候选:一个被判定为孤儿的文件 + 元信息。 diff --git a/test/services/orphan_file_reaper_test.dart b/test/services/orphan_file_reaper_test.dart index 44c96354..efa38971 100644 --- a/test/services/orphan_file_reaper_test.dart +++ b/test/services/orphan_file_reaper_test.dart @@ -336,5 +336,5 @@ class _ThrowingReaper implements OrphanFileReaper { final Object _error; @override - Future reap({bool dryRun = true}) async => throw _error; + Future reap() async => throw _error; } From 0c70e9ac8c89b64948a7776ff2dc14bc85de4c4b Mon Sep 17 00:00:00 2001 From: Kiro Date: Mon, 31 Aug 2026 23:22:08 +1000 Subject: [PATCH 02/14] fix: keep pending prompt autosave alive across selection changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .../inspector_submit_controller.dart | 28 ++++++++++++++++--- .../inspector_submit_controller_test.dart | 27 ++++++++++++++++++ 2 files changed, 51 insertions(+), 4 deletions(-) diff --git a/lib/features/canvas/providers/inspector_submit_controller.dart b/lib/features/canvas/providers/inspector_submit_controller.dart index 247f99c5..18b6fdec 100644 --- a/lib/features/canvas/providers/inspector_submit_controller.dart +++ b/lib/features/canvas/providers/inspector_submit_controller.dart @@ -90,18 +90,27 @@ final inspectorSubmitControllerProvider = AutoDisposeNotifierProviderFamily< class InspectorSubmitController extends AutoDisposeFamilyNotifier { Timer? _debounce; + KeepAliveLink? _debounceKeepAlive; static const debounceDuration = Duration(milliseconds: 500); @override InspectorSubmitState build(String configNodeId) { - ref.onDispose(() => _debounce?.cancel()); + ref.onDispose(_cancelPendingDebounce); return const InspectorSubmitIdle(); } bool get isBusy => state is InspectorSubmitSubmitting || state is InspectorSubmitRunning; + /// 取消挂起的防抖计时器并释放其 keepAlive(不落盘)。 + void _cancelPendingDebounce() { + _debounce?.cancel(); + _debounce = null; + _debounceKeepAlive?.close(); + _debounceKeepAlive = null; + } + /// 立即持久化 type_config patch。失败静默——单次保存失败不打断输入流, /// 下次保存覆盖(与生成提交路径不同,这里没有用户可见的失败面)。 Future saveConfig(Map patch) async { @@ -114,10 +123,20 @@ class InspectorSubmitController } /// prompt 防抖保存:连续输入只落最后一次。 + /// + /// 挂起期间用 [Ref.keepAlive] 挂起本 provider 的 autoDispose—— + /// 否则切换 Inspector 选中的节点会在计时器触发前就把它回收,onDispose 只 + /// cancel 计时器、不落盘,编辑内容直接丢失(2026-08-31 审计 P0)。 void savePromptDebounced(String prompt) { - _debounce?.cancel(); + _cancelPendingDebounce(); + _debounceKeepAlive = ref.keepAlive(); _debounce = Timer(debounceDuration, () { - unawaited(saveConfig({'prompt': prompt})); + final link = _debounceKeepAlive; + _debounceKeepAlive = null; + unawaited( + saveConfig({'prompt': prompt}) + .whenComplete(() => link?.close()), + ); }); } @@ -127,7 +146,8 @@ class InspectorSubmitController /// 终态结果/失败由 CanvasScreen 的 registry listener 反映。 Future submit(Map finalConfig) async { if (isBusy) return; - _debounce?.cancel(); + // 即将写完整 finalConfig:丢弃挂起的局部 prompt patch,不需要它再补落一次。 + _cancelPendingDebounce(); // 提交期间挂起 autoDispose:widget 中途关闭也要把状态机走完。 final link = ref.keepAlive(); state = const InspectorSubmitSubmitting(); diff --git a/test/features/canvas/providers/inspector_submit_controller_test.dart b/test/features/canvas/providers/inspector_submit_controller_test.dart index c10185ec..2ed25a6f 100644 --- a/test/features/canvas/providers/inspector_submit_controller_test.dart +++ b/test/features/canvas/providers/inspector_submit_controller_test.dart @@ -179,6 +179,33 @@ void main() { ]); }); + test( + 'savePromptDebounced:切换选中(无监听器)也不能丢掉挂起的写入——回归 2026-08-31 审计 P0', + () async { + final container = makeContainer(_FakeGenerationController()); + // 关键:不调用 container.listen(...)。真实 app 里,切换 Inspector 的 + // 选中节点会让旧的 inspectorSubmitControllerProvider(oldId) 失去最后一个 + // watcher;如果防抖挂起期间没有 keepAlive,autoDispose 会在计时器触发前 + // 就把它回收,onDispose 只 cancel 了计时器,编辑内容直接丢失。 + final ctrl = container.read(inspectorSubmitControllerProvider('n1').notifier); + + ctrl.savePromptDebounced('final draft'); + + // 让 autoDispose 有机会在防抖窗口内触发(不保活的话,这里就会被回收)。 + await Future.delayed(Duration.zero); + await Future.delayed( + InspectorSubmitController.debounceDuration + const Duration(milliseconds: 100), + ); + + expect( + repo.patches, + [ + {'prompt': 'final draft'}, + ], + reason: 'ref.keepAlive() 应挂起 autoDispose 直到挂起的防抖写入完成', + ); + }); + test('saveConfig:立即落盘一次', () async { final container = makeContainer(_FakeGenerationController()); final ctrl = container.read(inspectorSubmitControllerProvider('n1').notifier); From 28bd0ece5ebb2082fd5d5807ea029275c1ae43dc Mon Sep 17 00:00:00 2001 From: Kiro Date: Mon, 31 Aug 2026 23:38:46 +1000 Subject: [PATCH 03/14] fix: stop shot-notes autosave from losing edits on selection change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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)." --- .../inspector_submit_controller.dart | 24 +++++++---- .../canvas/widgets/shot_config_inspector.dart | 11 ++--- .../widgets/shot_config_inspector_test.dart | 42 +++++++++++++++++++ 3 files changed, 60 insertions(+), 17 deletions(-) diff --git a/lib/features/canvas/providers/inspector_submit_controller.dart b/lib/features/canvas/providers/inspector_submit_controller.dart index 18b6fdec..1836c334 100644 --- a/lib/features/canvas/providers/inspector_submit_controller.dart +++ b/lib/features/canvas/providers/inspector_submit_controller.dart @@ -122,24 +122,30 @@ class InspectorSubmitController } } - /// prompt 防抖保存:连续输入只落最后一次。 + /// 通用防抖保存:连续调用只落最后一次 patch。 /// - /// 挂起期间用 [Ref.keepAlive] 挂起本 provider 的 autoDispose—— - /// 否则切换 Inspector 选中的节点会在计时器触发前就把它回收,onDispose 只 - /// cancel 计时器、不落盘,编辑内容直接丢失(2026-08-31 审计 P0)。 - void savePromptDebounced(String prompt) { + /// 挂起期间用 [Ref.keepAlive] 挂起本 provider 的 autoDispose——否则切换 + /// Inspector 选中的节点会在计时器触发前就把它回收,编辑内容直接丢失 + /// (2026-08-31 审计 P0)。prompt 字段与 shot_notes 字段共用这份实现——不能 + /// 各自维护一份本地 Timer:widget 的 dispose() 里不允许再用 ref 去 flush + /// (Riverpod 的 ConsumerStatefulElement 在 widget 自身 unmount 时就会拒绝 + /// 该 widget 发起的任何 ref 访问,与 provider 容器是否还活着无关),所以 + /// debounce 必须整个挂在 controller(跟着 provider 容器的生命周期走)上, + /// 而不是 widget 的 State 上。 + void saveDebounced(Map patch) { _cancelPendingDebounce(); _debounceKeepAlive = ref.keepAlive(); _debounce = Timer(debounceDuration, () { final link = _debounceKeepAlive; _debounceKeepAlive = null; - unawaited( - saveConfig({'prompt': prompt}) - .whenComplete(() => link?.close()), - ); + unawaited(saveConfig(patch).whenComplete(() => link?.close())); }); } + /// prompt 防抖保存:连续输入只落最后一次。 + void savePromptDebounced(String prompt) => + saveDebounced({'prompt': prompt}); + /// 提交生成。先把最终 config 落盘,再发起生成。 /// /// fire-and-forget:提交成功即回 idle;进度看画布渲染队列面板, diff --git a/lib/features/canvas/widgets/shot_config_inspector.dart b/lib/features/canvas/widgets/shot_config_inspector.dart index a6900935..f064172c 100644 --- a/lib/features/canvas/widgets/shot_config_inspector.dart +++ b/lib/features/canvas/widgets/shot_config_inspector.dart @@ -40,7 +40,6 @@ const List kShotDurationOptions = [3, 5, 10, 15]; class _ShotConfigInspectorState extends ConsumerState { final TextEditingController _notesCtrl = TextEditingController(); - Timer? _debounce; bool _busy = false; int? _durationSec; CameraMovement? _camera; @@ -73,7 +72,6 @@ class _ShotConfigInspectorState extends ConsumerState { @override void dispose() { - _debounce?.cancel(); _notesCtrl.dispose(); super.dispose(); } @@ -96,12 +94,9 @@ class _ShotConfigInspectorState extends ConsumerState { void _onChanged(String value) { setState(() {}); // 备注是否为空 → 生成按钮可用性 - _debounce?.cancel(); - _debounce = Timer(const Duration(milliseconds: 500), () { - ref - .read(inspectorSubmitControllerProvider(widget.node.id).notifier) - .saveConfig({'shot_notes': value}); - }); + ref + .read(inspectorSubmitControllerProvider(widget.node.id).notifier) + .saveDebounced({'shot_notes': value}); } bool get _canGenerate => diff --git a/test/features/canvas/widgets/shot_config_inspector_test.dart b/test/features/canvas/widgets/shot_config_inspector_test.dart index 177c6f02..3f5e710b 100644 --- a/test/features/canvas/widgets/shot_config_inspector_test.dart +++ b/test/features/canvas/widgets/shot_config_inspector_test.dart @@ -143,6 +143,48 @@ void main() { await tester.pump(); }); + testWidgets( + '输入备注后立即切换选中(dispose)——挂起的防抖写入仍应落盘(回归 2026-08-31 审计 P0)', + (tester) async { + final id = await nodeRepo.create( + canvasId: canvasId, + type: 'shot', + nodeRole: 'config', + ); + final node = CanvasNode( + id: id, + label: '', + type: CanvasNodeType.shot, + role: NodeRole.config, + canvasId: canvasId, + typeConfig: const {}, + ); + await pump(tester, node); + + await tester.enterText(find.byType(TextField), 'dolly in on face'); + await tester.pump(); + + // 切换选中:把 ShotConfigInspector 从树里换掉,在 500ms 防抖窗口内触发 + // 其 State.dispose()——不能只 cancel 计时器,必须先把最后一次输入落盘。 + await pumpInkApp( + tester, + const Scaffold(body: SizedBox.shrink()), + overrides: [ + nodeRepositoryProvider.overrideWith((ref) async => nodeRepo), + edgeRepositoryProvider.overrideWith((ref) async => edgeRepo), + ], + ); + + await tester.pump(const Duration(milliseconds: 600)); + await tester.pump(); + + expect( + (nodeRepo.rows[id]!['type_config'] as Map)[ + 'shot_notes'], + 'dolly in on face', + ); + }); + testWidgets('连线失败 → 节点已创建 + 专用连线失败 snackbar', (tester) async { edgeRepo = _FailingEdgeRepository(); const node = CanvasNode( From b3a87415436187d2a7542b8a85dcf67d82391c60 Mon Sep 17 00:00:00 2001 From: Kiro Date: Mon, 31 Aug 2026 23:51:58 +1000 Subject: [PATCH 04/14] fix: make Import project reachable from Studio's zero-project empty state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- lib/features/studio/project_import_flow.dart | 116 ++++++++++++++ lib/features/studio/studio_home_screen.dart | 141 ++++-------------- .../studio/widgets/studio_import_test.dart | 53 +++++-- 3 files changed, 185 insertions(+), 125 deletions(-) create mode 100644 lib/features/studio/project_import_flow.dart diff --git a/lib/features/studio/project_import_flow.dart b/lib/features/studio/project_import_flow.dart new file mode 100644 index 00000000..912fdf34 --- /dev/null +++ b/lib/features/studio/project_import_flow.dart @@ -0,0 +1,116 @@ +// runProjectImportFlow:LB-12 项目包导入——picker → barrier 模态 → service → +// 成功选中新项目。三大重操作(导入/还原/导出)互斥;依赖首 await 前 read 持有 +// (#188 P1-1)。 +// +// 抽成公开顶层函数(而非 studio_home_screen.dart 里的私有方法),因为 +// 2026-08-31 审计 P0 发现零项目空态和命令面板都够不到这个入口——两处都要能调用 +// 同一份逻辑,不能只挂在 FAB 按钮的私有回调里。 +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../core/di/database_restore.dart'; +import '../../core/di/logger.dart'; +import '../../core/di/project_archive.dart'; +import '../../core/interfaces/project_import_service.dart'; +import '../../l10n/l10n_x.dart'; +import '../../theme/app_theme.dart'; +import '../../theme/tokens.dart'; +import '../generation/services/toast_service.dart'; +import 'controllers/studio_state.dart'; +import 'providers/project_export_busy.dart'; +import 'providers/workspace_projects_provider.dart'; + +const String _logModule = 'studio.import'; + +Future runProjectImportFlow(BuildContext context, WidgetRef ref) async { + final importBusy = ref.read(projectImportBusyProvider.notifier); + if (importBusy.state || + ref.read(databaseRestoreBusyProvider) || + ref.read(projectExportBusyProvider)) { + return; + } + final toast = ref.read(toastServiceProvider); + final logger = ref.read(loggerProvider); + final picker = ref.read(openFilePickerProvider); + final serviceFuture = ref.read(projectImportServiceProvider.future); + final selected = ref.read(selectedProjectIdProvider.notifier); + final container = ProviderScope.containerOf(context, listen: false); + final navigator = Navigator.of(context, rootNavigator: true); + final l10n = context.l10n; + final progressMsg = l10n.importInProgress; + final doneMsg = l10n.importDone; + importBusy.state = true; + try { + final String? path; + try { + path = await picker(); + } catch (e, st) { + // 放行点:平台 picker 异常不得静默(#192 评审 P3-5)。 + logger.error(_logModule, 'import picker failed', cause: e, stackTrace: st); + toast.show(l10n.importFailed, kind: ToastKind.error); + return; + } + if (path == null || !context.mounted) return; + + // barrier 模态罩全程(导入分钟级;LB-22 同款)。 + BuildContext? barrierCtx; + unawaited(showDialog( + context: context, + barrierDismissible: false, + barrierColor: context.inkColors.scrim, + builder: (ctx) { + barrierCtx = ctx; + return PopScope( + canPop: false, + child: AlertDialog( + content: Row( + children: [ + const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 2), + ), + const SizedBox(width: InkSpacing.md), + Text(progressMsg), + ], + ), + ), + ); + }, + )); + ImportResult result; + try { + final service = await serviceFuture; + result = await service.importArchive(zipPath: path); + } catch (e, st) { + // 放行点:service 已收敛所有已知失败——这里兜装配错误,失败必须可见。 + logger.error(_logModule, 'import unexpected', cause: e, stackTrace: st); + result = const ImportResult(outcome: ImportOutcome.failed); + } finally { + final ctx = barrierCtx; + if (ctx != null && ctx.mounted) { + Navigator.of(ctx).pop(); + } else { + navigator.pop(); + } + } + + if (result.outcome == ImportOutcome.imported) { + container.invalidate(workspaceProjectsProvider); + selected.state = result.newProjectId; + toast.show(doneMsg, kind: ToastKind.success); + } else { + final String msg = switch (result.outcome) { + ImportOutcome.failedFormat => l10n.importFailedFormat, + ImportOutcome.failedVersionNewer => l10n.importFailedVersionNewer, + ImportOutcome.failedCorrupt => l10n.importFailedCorrupt, + ImportOutcome.failed || ImportOutcome.imported => l10n.importFailed, + }; + toast.show(msg, kind: ToastKind.error); + } + } finally { + importBusy.state = false; + } +} diff --git a/lib/features/studio/studio_home_screen.dart b/lib/features/studio/studio_home_screen.dart index 0a101176..470340a7 100644 --- a/lib/features/studio/studio_home_screen.dart +++ b/lib/features/studio/studio_home_screen.dart @@ -2,8 +2,6 @@ // // 布局:Column(chrome, Expanded(Row(LibrarySidebar 280, Expanded(Stack(main, fab))))) // 状态:workspaceProjectsProvider 的 loading / error / empty / data 四态。 -import 'dart:async'; - import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -13,7 +11,6 @@ import '../../core/di/logger.dart'; import '../../core/di/project_archive.dart'; import '../../core/di/repositories.dart'; import '../../core/errors/ink_error.dart'; -import '../../core/interfaces/project_import_service.dart'; import '../../l10n/l10n_x.dart'; import '../../theme/app_theme.dart'; import '../../theme/components/ink_error_banner.dart'; @@ -31,6 +28,7 @@ import 'providers/trashed_items_providers.dart'; import 'controllers/studio_state.dart'; import 'models/project_with_canvases.dart'; import 'open_canvas.dart'; +import 'project_import_flow.dart'; import 'providers/workspace_projects_provider.dart'; import 'widgets/library_sidebar.dart'; import 'widgets/project_card.dart'; @@ -116,17 +114,26 @@ class _StudioMainArea extends ConsumerWidget { onRetry: () => ref.invalidate(workspaceProjectsProvider), ), - data: (projects) => projects.isEmpty - ? _StudioEmptyState( - onCreate: () => - _showNewProjectDialog(context, ref, const []), - onCreateSample: () => - _createSampleProject(context, ref), - onOpenShowcase: () => ref - .read(currentScreenProvider.notifier) - .state = AppScreen.showcase, - ) - : _ProjectGrid(projects: projects), + data: (projects) { + final importBusy = + ref.watch(projectImportBusyProvider) || + ref.watch(databaseRestoreBusyProvider) || + ref.watch(projectExportBusyProvider); + return projects.isEmpty + ? _StudioEmptyState( + onCreate: () => _showNewProjectDialog( + context, ref, const []), + onCreateSample: () => + _createSampleProject(context, ref), + onOpenShowcase: () => ref + .read(currentScreenProvider.notifier) + .state = AppScreen.showcase, + onImport: importBusy + ? null + : () => runProjectImportFlow(context, ref), + ) + : _ProjectGrid(projects: projects); + }, ), ), ], @@ -147,7 +154,7 @@ class _StudioMainArea extends ConsumerWidget { ref.watch(databaseRestoreBusyProvider) || ref.watch(projectExportBusyProvider) ? null - : () => _importProject(context, ref), + : () => runProjectImportFlow(context, ref), ), const SizedBox(width: InkSpacing.sm), InkAmberButton( @@ -170,100 +177,6 @@ class _StudioMainArea extends ConsumerWidget { ); } - /// LB-12:项目包导入——picker → barrier 模态 → service → 成功选中新项目。 - /// 三大重操作(导入/还原/导出)互斥;依赖首 await 前 read 持有(#188 P1-1)。 - Future _importProject(BuildContext context, WidgetRef ref) async { - final importBusy = ref.read(projectImportBusyProvider.notifier); - if (importBusy.state || - ref.read(databaseRestoreBusyProvider) || - ref.read(projectExportBusyProvider)) { - return; - } - final toast = ref.read(toastServiceProvider); - final logger = ref.read(loggerProvider); - final picker = ref.read(openFilePickerProvider); - final serviceFuture = ref.read(projectImportServiceProvider.future); - final selected = ref.read(selectedProjectIdProvider.notifier); - final container = ProviderScope.containerOf(context, listen: false); - final navigator = Navigator.of(context, rootNavigator: true); - final l10n = context.l10n; - final progressMsg = l10n.importInProgress; - final doneMsg = l10n.importDone; - importBusy.state = true; - try { - final String? path; - try { - path = await picker(); - } catch (e, st) { - // 放行点:平台 picker 异常不得静默(#192 评审 P3-5)。 - logger.error(_logModule, 'import picker failed', - cause: e, stackTrace: st); - toast.show(l10n.importFailed, kind: ToastKind.error); - return; - } - if (path == null || !context.mounted) return; - - // barrier 模态罩全程(导入分钟级;LB-22 同款)。 - BuildContext? barrierCtx; - unawaited(showDialog( - context: context, - barrierDismissible: false, - barrierColor: context.inkColors.scrim, - builder: (ctx) { - barrierCtx = ctx; - return PopScope( - canPop: false, - child: AlertDialog( - content: Row( - children: [ - const SizedBox( - width: 24, - height: 24, - child: CircularProgressIndicator(strokeWidth: 2), - ), - const SizedBox(width: InkSpacing.md), - Text(progressMsg), - ], - ), - ), - ); - }, - )); - ImportResult result; - try { - final service = await serviceFuture; - result = await service.importArchive(zipPath: path); - } catch (e, st) { - // 放行点:service 已收敛所有已知失败——这里兜装配错误,失败必须可见。 - logger.error(_logModule, 'import unexpected', cause: e, stackTrace: st); - result = const ImportResult(outcome: ImportOutcome.failed); - } finally { - final ctx = barrierCtx; - if (ctx != null && ctx.mounted) { - Navigator.of(ctx).pop(); - } else { - navigator.pop(); - } - } - - if (result.outcome == ImportOutcome.imported) { - container.invalidate(workspaceProjectsProvider); - selected.state = result.newProjectId; - toast.show(doneMsg, kind: ToastKind.success); - } else { - final String msg = switch (result.outcome) { - ImportOutcome.failedFormat => l10n.importFailedFormat, - ImportOutcome.failedVersionNewer => l10n.importFailedVersionNewer, - ImportOutcome.failedCorrupt => l10n.importFailedCorrupt, - ImportOutcome.failed || ImportOutcome.imported => l10n.importFailed, - }; - toast.show(msg, kind: ToastKind.error); - } - } finally { - importBusy.state = false; - } - } - /// ON-2:示例项目入口。createSample 内部会切 currentCanvasId 直达画布。 Future _createSampleProject(BuildContext context, WidgetRef ref) async { final l10n = context.l10n; @@ -375,11 +288,13 @@ class _StudioErrorState extends StatelessWidget { class _StudioEmptyState extends StatelessWidget { const _StudioEmptyState({ required this.onCreate, + required this.onImport, required this.onCreateSample, required this.onOpenShowcase, }); final VoidCallback onCreate; + final VoidCallback? onImport; final VoidCallback onCreateSample; final VoidCallback onOpenShowcase; @@ -430,6 +345,14 @@ class _StudioEmptyState extends StatelessWidget { onPressed: onCreate, ), const SizedBox(height: InkSpacing.sm), + // 2026-08-31 审计 P0:零项目用户手里只有归档文件时,此前完全没有 + // 入口能导入——项目卡 ⋮ 菜单此时不存在,FAB 也只在非空态渲染。 + InkGhostButton( + label: context.l10n.studioImportProject, + icon: Icons.unarchive_outlined, + onPressed: onImport, + ), + const SizedBox(height: InkSpacing.sm), InkGhostButton( label: context.l10n.studioCreateSampleProject, icon: Icons.auto_awesome_outlined, diff --git a/test/features/studio/widgets/studio_import_test.dart b/test/features/studio/widgets/studio_import_test.dart index 2ef0c827..76abe5f0 100644 --- a/test/features/studio/widgets/studio_import_test.dart +++ b/test/features/studio/widgets/studio_import_test.dart @@ -44,6 +44,15 @@ class _RecordingToast implements ToastService { } } +List get _oneProject => [ + ProjectWithCanvases( + id: 'p1', + name: 'Alpha', + createdAt: DateTime.utc(2026, 5, 1), + canvases: const [], + ), + ]; + void main() { late _FakeImportService service; late _RecordingToast toast; @@ -53,22 +62,16 @@ void main() { toast = _RecordingToast(); }); - Future pump(WidgetTester tester, - {String? pickedPath}) async { + Future pump( + WidgetTester tester, { + String? pickedPath, + List projects = const [], + }) async { await tester.binding.setSurfaceSize(const Size(1440, 900)); addTearDown(() => tester.binding.setSurfaceSize(null)); final container = ProviderContainer( overrides: [ - workspaceProjectsProvider.overrideWith( - (_) async => [ - ProjectWithCanvases( - id: 'p1', - name: 'Alpha', - createdAt: DateTime.utc(2026, 5, 1), - canvases: const [], - ), - ], - ), + workspaceProjectsProvider.overrideWith((_) async => projects), openFilePickerProvider.overrideWithValue(() async => pickedPath), projectImportServiceProvider.overrideWith((ref) async => service), toastServiceProvider.overrideWithValue(toast), @@ -94,7 +97,7 @@ void main() { testWidgets('导入成功:service 收 path、barrier 在途、选中新项目、成功 toast', (tester) async { service.gate = Completer(); - final container = await pump(tester, pickedPath: 'C:/tmp/p.zip'); + final container = await pump(tester, pickedPath: 'C:/tmp/p.zip', projects: _oneProject); await tester.tap(find.text('Import project…')); await tester.pump(); @@ -111,7 +114,7 @@ void main() { }); testWidgets('picker 取消 → 零调用零 toast', (tester) async { - await pump(tester, pickedPath: null); + await pump(tester, pickedPath: null, projects: _oneProject); await tester.tap(find.text('Import project…')); await tester.pumpAndSettle(); expect(service.paths, isEmpty); @@ -120,7 +123,7 @@ void main() { testWidgets('outcome 文案:failedFormat / failedCorrupt', (tester) async { service.outcome = ImportOutcome.failedFormat; - await pump(tester, pickedPath: 'C:/tmp/p.zip'); + await pump(tester, pickedPath: 'C:/tmp/p.zip', projects: _oneProject); await tester.tap(find.text('Import project…')); await tester.pumpAndSettle(); expect(toast.shown.single.message, 'Not an InkFrame project archive'); @@ -128,7 +131,7 @@ void main() { }); testWidgets('还原 busy 时导入禁用(三大重操作互斥)', (tester) async { - final container = await pump(tester, pickedPath: 'C:/tmp/p.zip'); + final container = await pump(tester, pickedPath: 'C:/tmp/p.zip', projects: _oneProject); container.read(databaseRestoreBusyProvider.notifier).state = true; await tester.pumpAndSettle(); @@ -136,4 +139,22 @@ void main() { await tester.pumpAndSettle(); expect(service.paths, isEmpty); }); + + testWidgets('零项目空态也能看到并使用 Import project(回归 2026-08-31 审计 P0)', + (tester) async { + service.gate = Completer(); + final container = await pump(tester, pickedPath: 'C:/tmp/p.zip'); + + expect(find.text('Import project…'), findsOneWidget); + await tester.tap(find.text('Import project…')); + await tester.pump(); + expect(find.text('Importing…'), findsOneWidget); + + service.gate!.complete(); + await tester.pumpAndSettle(); + + expect(service.paths, ['C:/tmp/p.zip']); + expect(toast.shown.single.message, 'Project imported'); + expect(container.read(selectedProjectIdProvider), 'new-proj'); + }); } From 3154fc5d85268bf9f49b7f24d5e55347b01950b1 Mon Sep 17 00:00:00 2001 From: Kiro Date: Tue, 1 Sep 2026 00:03:22 +1000 Subject: [PATCH 05/14] fix: add Import project to the Studio command-palette context 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 --- .../command_palette/command_actions.dart | 17 +++++++- .../command_palette/command_palette_test.dart | 43 +++++++++++++++++++ 2 files changed, 58 insertions(+), 2 deletions(-) diff --git a/lib/features/command_palette/command_actions.dart b/lib/features/command_palette/command_actions.dart index ca12e7e3..a2650013 100644 --- a/lib/features/command_palette/command_actions.dart +++ b/lib/features/command_palette/command_actions.dart @@ -24,6 +24,7 @@ import '../canvas/util/node_position.dart'; import '../export/util/export_order.dart'; import '../export/widgets/export_video_dialog.dart'; import '../gallery/providers/current_gallery_project.dart'; +import '../studio/project_import_flow.dart'; /// 单个可执行命令:图标 + 已本地化 label + 执行闭包。 @immutable @@ -96,8 +97,13 @@ List buildCommandActions(BuildContext context, WidgetRef ref) { AppScreen.settings => [_backToStudio(l)], AppScreen.showcase => [_backToStudio(l), _openSettings(l)], // studio:内置示例是全局动作,项目卡菜单在零项目空态下不存在——命令面板 - // 与空态 CTA 一起保证零项目用户也够得到(评审 P1-1)。 - AppScreen.studio => [_openShowcase(l), _openSettings(l)], + // 与空态 CTA 一起保证零项目用户也够得到(评审 P1-1)。2026-08-31 审计 P0: + // 导入项目此前在这里完全够不到,见 studio/project_import_flow.dart。 + AppScreen.studio => [ + _importProject(l), + _openShowcase(l), + _openSettings(l), + ], }; } @@ -172,6 +178,13 @@ CommandAction _openShowcase(AppLocalizations l) => CommandAction( }, ); +CommandAction _importProject(AppLocalizations l) => CommandAction( + id: 'importProject', + icon: Icons.unarchive_outlined, + label: l.studioImportProject, + run: (context, ref) => runProjectImportFlow(context, ref), + ); + CommandAction _openSettings(AppLocalizations l) => CommandAction( id: 'openSettings', icon: Icons.settings_outlined, diff --git a/test/features/command_palette/command_palette_test.dart b/test/features/command_palette/command_palette_test.dart index f0fd1ee6..c2a37b35 100644 --- a/test/features/command_palette/command_palette_test.dart +++ b/test/features/command_palette/command_palette_test.dart @@ -5,14 +5,20 @@ import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:inkframe/core/di/current_screen.dart'; +import 'package:inkframe/core/di/logger.dart'; +import 'package:inkframe/core/di/project_archive.dart'; +import 'package:inkframe/core/interfaces/project_import_service.dart'; import 'package:inkframe/features/canvas/models/canvas_node.dart'; import 'package:inkframe/features/canvas/providers/canvas_nodes_controller.dart'; import 'package:inkframe/features/canvas/providers/current_canvas_id.dart'; import 'package:inkframe/features/command_palette/widgets/command_palette_dialog.dart'; import 'package:inkframe/features/command_palette/widgets/command_palette_shortcuts.dart'; +import 'package:inkframe/features/generation/services/toast_service.dart'; import 'package:inkframe/l10n/generated/app_localizations.dart'; import 'package:inkframe/theme/app_theme.dart'; +import '../../helpers/recording_logger.dart'; + /// 空画布节点集(隔离 DB DI)。 class _EmptyNodesController extends CanvasNodesController { @override @@ -60,6 +66,23 @@ class _RecordingNodesController extends CanvasNodesController { } } +/// 导入流程测试用 fake service。 +class _FakeImportService implements ProjectImportService { + @override + Future importArchive({required String zipPath}) async => + const ImportResult(outcome: ImportOutcome.failed); +} + +/// 记录 toast 调用的 fake。 +class _RecordingToast implements ToastService { + final List messages = []; + + @override + void show(String message, {ToastKind kind = ToastKind.info}) { + messages.add(message); + } +} + Future _pumpShell( WidgetTester tester, { List overrides = const [], @@ -128,6 +151,26 @@ void main() { expect(container.read(currentScreenProvider), AppScreen.settings); }); + testWidgets('studio 上下文能直接执行 Import project(回归 2026-08-31 审计 P0)', + (tester) async { + final toast = _RecordingToast(); + final container = await _pumpShell(tester, overrides: [ + openFilePickerProvider.overrideWithValue(() async => null), + projectImportServiceProvider.overrideWith((ref) async => _FakeImportService()), + toastServiceProvider.overrideWithValue(toast), + loggerProvider.overrideWithValue(RecordingLogger()), + ]); + await _pressCtrlK(tester); + + expect(find.text('Import project…'), findsOneWidget); + await tester.tap(find.text('Import project…')); + await tester.pumpAndSettle(); + + // picker 返回 null(用户取消):面板已经关闭,且没有崩溃/挂起。 + expect(find.byType(CommandPaletteDialog), findsNothing); + expect(container.read(projectImportBusyProvider), isFalse); + }); + testWidgets('showcase 上下文可返回 Studio 或打开设置', (tester) async { final container = await _pumpShell(tester); container.read(currentScreenProvider.notifier).state = AppScreen.showcase; From 45b40c38ccf408fb897b651d84b4adab09b0a65a Mon Sep 17 00:00:00 2001 From: Kiro Date: Tue, 1 Sep 2026 00:18:30 +1000 Subject: [PATCH 06/14] docs: fix stale debounce comments after prompt/shot_notes generalization 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. --- .../canvas/providers/inspector_submit_controller.dart | 11 +++++++++-- .../canvas/widgets/shot_config_inspector.dart | 2 +- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/lib/features/canvas/providers/inspector_submit_controller.dart b/lib/features/canvas/providers/inspector_submit_controller.dart index 1836c334..f651d110 100644 --- a/lib/features/canvas/providers/inspector_submit_controller.dart +++ b/lib/features/canvas/providers/inspector_submit_controller.dart @@ -2,7 +2,8 @@ // // 从 Image/VideoConfigInspector 抽出的共享逻辑: // - 四态状态机:idle / submitting / running / failure -// - type_config 持久化(含 prompt 防抖保存)——widget 不再直写 repository +// - type_config 持久化(含防抖保存,prompt/shot_notes 共用同一实现)—— +// widget 不再直写 repository // - submit:先落最终 config,再经 GenerationController.submitFromConfigNode // - 失败携带结构化 InspectorSubmitError,文案由 view 层映射 ARB // @@ -132,6 +133,12 @@ class InspectorSubmitController /// 该 widget 发起的任何 ref 访问,与 provider 容器是否还活着无关),所以 /// debounce 必须整个挂在 controller(跟着 provider 容器的生命周期走)上, /// 而不是 widget 的 State 上。 + /// + /// 每个 node 只有一个挂起中的 Timer/patch 槽位:同一 node 上第二次 + /// saveDebounced/savePromptDebounced 调用会在第一次落盘前把它替换掉。 + /// 因此同一 node 上两个不同的防抖字段会互相打架——目前不是活 bug(prompt + /// 与 shot_notes 分属不相交的节点类型),但后续若有 node 类型要同时防抖 + /// 两个字段,需要另外处理。 void saveDebounced(Map patch) { _cancelPendingDebounce(); _debounceKeepAlive = ref.keepAlive(); @@ -152,7 +159,7 @@ class InspectorSubmitController /// 终态结果/失败由 CanvasScreen 的 registry listener 反映。 Future submit(Map finalConfig) async { if (isBusy) return; - // 即将写完整 finalConfig:丢弃挂起的局部 prompt patch,不需要它再补落一次。 + // 即将写完整 finalConfig:丢弃挂起的局部 patch,不需要它再补落一次。 _cancelPendingDebounce(); // 提交期间挂起 autoDispose:widget 中途关闭也要把状态机走完。 final link = ref.keepAlive(); diff --git a/lib/features/canvas/widgets/shot_config_inspector.dart b/lib/features/canvas/widgets/shot_config_inspector.dart index f064172c..8600a35a 100644 --- a/lib/features/canvas/widgets/shot_config_inspector.dart +++ b/lib/features/canvas/widgets/shot_config_inspector.dart @@ -2,7 +2,7 @@ // // shot 是真实节点类型(image/text/video/shot),此前无编辑面板。本面板先提供分镜 // 备注(type_config.shot_notes),作为后续 storyboard→shot→序列 流水线的编辑起点。 -// 持久化经 InspectorSubmitController.saveConfig(防抖),与 image/video 面板同构。 +// 持久化经 InspectorSubmitController.saveDebounced(防抖),与 image/video 面板同构。 // 「用本镜备注生成图像」:以 shot_notes 为 prompt 在旁侧新建 image config 节点, // 并挂一条 narrative 边(shot→image),复用现有生成链路(M3 §1 首切片)。 import 'dart:async'; From 853c0643446bca2d71dec60de3981a7372087b34 Mon Sep 17 00:00:00 2001 From: Kiro Date: Tue, 1 Sep 2026 16:38:58 +1000 Subject: [PATCH 07/14] docs: add implementation plan for the 2026-08-31 audit P0 fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .../plans/2026-08-31-audit-p0-fixes.md | 1204 +++++++++++++++++ 1 file changed, 1204 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-31-audit-p0-fixes.md diff --git a/docs/superpowers/plans/2026-08-31-audit-p0-fixes.md b/docs/superpowers/plans/2026-08-31-audit-p0-fixes.md new file mode 100644 index 00000000..b88748f6 --- /dev/null +++ b/docs/superpowers/plans/2026-08-31-audit-p0-fixes.md @@ -0,0 +1,1204 @@ +# Audit P0 Fixes Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Fix the 3 P0 findings from the 2026-08-31 full audit: a real (non-dry-run) file-delete path in `OrphanFileReaper`, silent loss of the user's last edit in the canvas Inspector's debounced autosave, and the missing "Import project" entry point when Studio has zero projects. + +**Architecture:** Three independent, surgical fixes — no shared code between them. (1) Delete the dead `dryRun:false` branch and its `_reapFile` implementation from `DiskOrphanFileReaper`, narrowing the interface so the real-delete path no longer exists in this class at all. (2) Make the two independent debounce mechanisms (`InspectorSubmitController.savePromptDebounced`, and `_ShotConfigInspectorState`'s local `Timer`) flush their pending write before the timer's owner is torn down, instead of just cancelling it. (3) Extract the private `_importProject` flow in `studio_home_screen.dart` into a public, reusable function so it can be wired into both the zero-project empty state and the Studio command-palette context, not just the FAB. + +**Tech Stack:** Flutter (Dart), Riverpod (manual providers, `AutoDisposeFamilyNotifier` + `KeepAliveLink`), `flutter_test` + `ProviderContainer`/`pumpInkApp` test harness. + +**Spec:** `docs/review/2026-08-31/AUDIT-SUMMARY.md` (§一 P0 list), with full evidence in `docs/review/2026-08-31/W12.md` (P0-1), `docs/review/2026-08-31/W17.md` + `W3.md` + `W4.md` (P0-2), `docs/review/2026-08-31/W17.md` (P0-3). + +## Global Constraints + +- TDD: write the failing test first, watch it fail, then implement (per `docs/CLAUDE.md` Testing section). +- Local gate before considering any task done: `flutter analyze lib test` must report "No issues found!" and `flutter test --exclude-tags golden` must pass for every test file touched or added. +- No hardcoded user-facing strings — reuse the existing `context.l10n.studioImportProject` ARB key for Task 4/5 (already present; no new ARB keys needed for this plan). +- No `--no-verify`, no skipping hooks. Conventional commit messages (`fix:`). +- Every commit must compile clean and pass its own new/updated tests before moving to the next task. +- **Non-goal (explicitly out of scope for this plan):** the deeper race where an *already in-flight* (already fired, awaiting the repository write) debounced autosave can still land its stale patch *after* `submit()`'s own write completes. That requires a write-sequencing/versioning mechanism and is part of the broader "autoDispose vs. async write races" systemic pattern called out in the audit summary §二A — deliberately deferred to a separate, larger plan. This plan only fixes the concretely reproducible bug: a pending (not-yet-fired) debounced write being discarded outright when its owner is disposed. +- **Non-goal:** making autosave failures visible to the user with a retry affordance (`saveConfig`'s `catch (_) {}` stays as-is). That is a UX feature, not a data-loss bug, and is tracked separately in the audit (W17). + +--- + +## File Structure + +**Modify:** +- `lib/core/interfaces/orphan_file_reaper.dart` — drop the `dryRun` parameter from the `reap()` contract. +- `lib/services/orphan_file_reaper.dart` — delete the `if (!dryRun)` delete branch and the `_reapFile` method entirely. +- `lib/core/di/orphan_reaper.dart` — update the stale comment referencing `dryRun`. +- `test/services/orphan_file_reaper_test.dart` — update the `_ThrowingReaper` fake's signature to match. +- `lib/features/canvas/providers/inspector_submit_controller.dart` — make the shared prompt-debounce keep its provider alive until the pending write lands. +- `lib/features/canvas/widgets/shot_config_inspector.dart` — flush the local shot-notes debounce on `dispose()`. +- `lib/features/studio/studio_home_screen.dart` — remove the private `_importProject` method; wire the extracted flow into the FAB and the new empty-state button. +- `lib/features/command_palette/command_actions.dart` — add an `importProject` command in the Studio context. +- `test/features/canvas/providers/inspector_submit_controller_test.dart` — add the dispose-flush regression test. +- `test/features/canvas/widgets/shot_config_inspector_test.dart` — add the dispose-flush regression test. +- `test/features/studio/widgets/studio_import_test.dart` — add the empty-state import test. +- `test/features/command_palette/command_palette_test.dart` — add the Studio-context import command test. + +**Create:** +- `lib/features/studio/project_import_flow.dart` — the extracted, public `runProjectImportFlow(BuildContext, WidgetRef)`, so both the widget layer and the command palette can call it without a private-member coupling. + +--- + +### Task 1: Remove the real-delete path from `OrphanFileReaper` + +**Files:** +- Modify: `lib/core/interfaces/orphan_file_reaper.dart` +- Modify: `lib/services/orphan_file_reaper.dart` +- Modify: `lib/core/di/orphan_reaper.dart` +- Test: `test/services/orphan_file_reaper_test.dart` + +**Interfaces:** +- Consumes: nothing new. +- Produces: `Future reap()` (no parameters) on `OrphanFileReaper` / `DiskOrphanFileReaper` — used by `lib/core/di/orphan_reaper.dart:31` (`await reaper.reap();`, already parameterless at the call site, so that line does not need to change). + +- [ ] **Step 1: Write the failing test — analyzer-level proof the delete path is gone** + +Open `test/services/orphan_file_reaper_test.dart` and update the `_ThrowingReaper` fake at the bottom of the file (currently line 339) so it matches the interface we're about to narrow. This makes the test file fail to *compile* against the current interface only after Step 3 removes the parameter — so first confirm today's baseline compiles, then make this edit, which will fail to compile until Task 1's implementation change lands (that compile failure *is* the "red" step for an interface-narrowing change): + +```dart +/// reap() 抛给定异常的 reaper——验证启动兜底吞成 warn(InkError 与非 InkError 皆可)。 +class _ThrowingReaper implements OrphanFileReaper { + _ThrowingReaper(this._error); + + final Object _error; + + @override + Future reap() async => throw _error; +} +``` + +- [ ] **Step 2: Run the test file to verify it fails** + +Run: `flutter test test/services/orphan_file_reaper_test.dart` +Expected: FAIL to compile — `The method 'reap' isn't overriding an inherited method with a compatible signature` (or similar), because `lib/core/interfaces/orphan_file_reaper.dart` still declares `reap({bool dryRun = true})`. + +- [ ] **Step 3: Narrow the interface** + +In `lib/core/interfaces/orphan_file_reaper.dart`, replace the whole file with: + +```dart +// OrphanFileReaper 契约:磁盘孤儿媒体文件回收(GC)。 +// +// 「孤儿」= projects/*/canvases/*/{images,videos} 下、画布相对路径不在引用集、 +// 且 mtime 早于阈值(默认 7 天)的文件。本服务只识别 + 记 orphan.reap.dryrun 日志, +// **没有任何删除实现**——不是"默认关闭的开关",是这个类里根本不存在删除代码。 +// 真正的删除需要独立实现、独立评审,不在本契约里。 +abstract class OrphanFileReaper { + /// 扫描并识别孤儿文件,只记 orphan.reap.dryrun 日志、不删除、不改动磁盘。 + /// 节流:距上次成功回收不足阈值则直接跳过(返回 [OrphanReapReport.skipped])。 + /// 引用集构建失败(InkError)向上抛——由启动兜底 swallow 成 warn,绝不阻断。 + Future reap(); +} + +/// 一次回收的结果快照(供启动日志 / 测试断言)。 +/// +/// 本服务从不删除文件,故此处不含「已删列表」——只有识别统计。 +class OrphanReapReport { + const OrphanReapReport({ + required this.throttledSkip, + required this.dryRun, + required this.orphanCount, + required this.totalBytes, + }); + + /// 因节流未执行本次扫描。 + const OrphanReapReport.skipped() + : throttledSkip = true, + dryRun = true, + orphanCount = 0, + totalBytes = 0; + + /// 本次因节流被跳过(未扫描)。 + final bool throttledSkip; + + /// 恒 true——本服务没有删除实现,报告字段保留以标记"这是一次只读扫描"。 + final bool dryRun; + + /// 识别出的孤儿文件数。 + final int orphanCount; + + /// 孤儿文件总字节数。 + final int totalBytes; +} +``` + +- [ ] **Step 4: Run the test file again to verify the interface-level failure is gone and see the real target** + +Run: `flutter test test/services/orphan_file_reaper_test.dart` +Expected: Still FAIL to compile — now `lib/services/orphan_file_reaper.dart`'s `DiskOrphanFileReaper.reap({bool dryRun = true})` no longer matches the (now parameterless) abstract method. + +- [ ] **Step 5: Remove the delete branch and `_reapFile` from the implementation** + +In `lib/services/orphan_file_reaper.dart`, replace lines 1–12 (the file-level doc comment) with: + +```dart +// DiskOrphanFileReaper:OrphanFileReaper 的磁盘实现(LB-13 slice B)。 +// +// 只扫 projects/

/canvases//{images,videos}——绝不碰其它任何目录(安全#3)。 +// 三重安全: +// #1 mtime 守卫:只有 mtime 早于 kOrphanMinAge(7d)的文件才可能是孤儿—— +// 保护刚写盘、DB 行还在提交中的新文件。 +// #2 引用集含软删节点:NodeRepository.listAllMediaUrls 连 deleted_at IS NOT NULL +// 的节点也算引用——软删可 LB-15 恢复,其产物必须留。 +// #3 目录白名单:只列 images/ 与 videos/,其余一律不扫描、不识别。 +// +// 只读:识别到的孤儿只 logger.info('orphan.reap.dryrun', ...),**这个类里没有任何 +// 删除代码**——2026-08-31 审计 P0:曾经的 reap(dryRun:false) 分支是一条真实可达、 +// 无恢复机制的删除路径,即便当时没有调用点传 false,也不该让删除实现待在一个号称 +// "只读审计"的服务里。真正的删除功能必须是独立评审的另一个实现。 +``` + +Then replace the `reap` method (currently lines 63–122) with: + +```dart + @override + Future reap() async { + final now = _clock.nowUtc(); + + // 节流:距上次成功回收不足阈值直接跳过(免得每次启动刷屏)。 + final last = _readLastReap(); + if (last != null && now.difference(last) < kOrphanReapThrottle) { + return const OrphanReapReport.skipped(); + } + + // 引用集:节点全量 url(含软删)∪ batch_results.output_url。 + // 构建失败必须中止——拿不到引用集就无法安全判孤儿(否则全部文件误判无引用、 + // 触发大规模误报)。此处不 try:InkError 直接上抛给启动兜底 swallow 成 warn。 + final referenceSet = await _buildReferenceSet(); + + final candidates = identifyOrphans(referenceSet: referenceSet, now: now); + + var totalBytes = 0; + for (final c in candidates) { + totalBytes += c.sizeBytes; + _logger?.info( + kOrphanReapModule, + kOrphanDryRunMsg, + extra: { + 'path': c.relativePath, + 'size_bytes': c.sizeBytes, + 'age_days': c.ageDays, + }, + ); + } + + _logger?.info( + kOrphanReapModule, + 'orphan.reap.summary', + extra: { + 'orphan_count': candidates.length, + 'total_bytes': totalBytes, + 'dry_run': true, + }, + ); + + _writeLastReap(now); + + return OrphanReapReport( + throttledSkip: false, + dryRun: true, + orphanCount: candidates.length, + totalBytes: totalBytes, + ); + } +``` + +Then delete the `_reapFile` method entirely (currently lines 223–237, including its two `coverage:ignore-start`/`coverage:ignore-end` markers and the two comment lines directly above it) — the class ends right after `_writeLastReap`'s closing brace and the `OrphanCandidate` class declaration. + +- [ ] **Step 6: Fix the DI comment that references the now-removed parameter** + +In `lib/core/di/orphan_reaper.dart`, replace: + +```dart +/// 启动首帧后触发一次孤儿回收(DRY-RUN + 节流)。housekeeping:任何失败只 warn, +/// 绝不阻断启动或其它流程。**刻意不传 dryRun**——保持默认 true(本卡绝不删文件)。 +``` + +with: + +```dart +/// 启动首帧后触发一次孤儿回收(只读扫描 + 节流)。housekeeping:任何失败只 warn, +/// 绝不阻断启动或其它流程。DiskOrphanFileReaper 里没有删除实现,reap() 恒只读。 +``` + +- [ ] **Step 7: Run the full test file to verify everything passes** + +Run: `flutter test test/services/orphan_file_reaper_test.dart` +Expected: PASS — all existing cases (identification, mtime guard, throttle, dry-run logging, reference-set-build failure) still hold, and the interface no longer has a `dryRun` parameter anywhere. + +- [ ] **Step 8: Run the full static analysis to catch any other stale reference** + +Run: `flutter analyze lib test` +Expected: `No issues found!` — confirms nothing else in the tree still calls `.reap(dryRun: ...)` or references `_reapFile`. + +- [ ] **Step 9: Commit** + +```bash +git add lib/core/interfaces/orphan_file_reaper.dart lib/services/orphan_file_reaper.dart lib/core/di/orphan_reaper.dart test/services/orphan_file_reaper_test.dart +git commit -m "fix: remove unreachable real-delete path from OrphanFileReaper + +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" +``` + +--- + +### Task 2: Stop `InspectorSubmitController`'s prompt autosave from losing edits on selection change + +**Files:** +- Modify: `lib/features/canvas/providers/inspector_submit_controller.dart` +- Test: `test/features/canvas/providers/inspector_submit_controller_test.dart` + +**Interfaces:** +- Consumes: `AutoDisposeFamilyNotifier.ref.keepAlive()` (already used by `submit()` in this same file — no new dependency). +- Produces: `InspectorSubmitController.savePromptDebounced(String prompt)` — same public signature as before; callers (`image_config_inspector.dart:158`, `video_config_inspector.dart:117`) do not change. + +- [ ] **Step 1: Write the failing test** + +In `test/features/canvas/providers/inspector_submit_controller_test.dart`, add this test right after the existing `'savePromptDebounced:窗口内多次输入只落最后一次'` test (before `'saveConfig:立即落盘一次'`): + +```dart + test( + 'savePromptDebounced:切换选中(无监听器)也不能丢掉挂起的写入——回归 2026-08-31 审计 P0', + () async { + final container = makeContainer(_FakeGenerationController()); + // 关键:不调用 container.listen(...)。真实 app 里,切换 Inspector 的 + // 选中节点会让旧的 inspectorSubmitControllerProvider(oldId) 失去最后一个 + // watcher;如果防抖挂起期间没有 keepAlive,autoDispose 会在计时器触发前 + // 就把它回收,onDispose 只 cancel 了计时器,编辑内容直接丢失。 + final ctrl = container.read(inspectorSubmitControllerProvider('n1').notifier); + + ctrl.savePromptDebounced('final draft'); + + // 让 autoDispose 有机会在防抖窗口内触发(不保活的话,这里就会被回收)。 + await Future.delayed(Duration.zero); + await Future.delayed( + InspectorSubmitController.debounceDuration + const Duration(milliseconds: 100), + ); + + expect( + repo.patches, + [ + {'prompt': 'final draft'}, + ], + reason: 'ref.keepAlive() 应挂起 autoDispose 直到挂起的防抖写入完成', + ); + }); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `flutter test test/features/canvas/providers/inspector_submit_controller_test.dart --plain-name "切换选中(无监听器)"` +Expected: FAIL — `repo.patches` is empty because the provider was disposed (no listener) before the 500ms timer fired, and `ref.onDispose` cancelled it. + +- [ ] **Step 3: Implement the fix** + +In `lib/features/canvas/providers/inspector_submit_controller.dart`, replace: + +```dart +class InspectorSubmitController + extends AutoDisposeFamilyNotifier { + Timer? _debounce; + + static const debounceDuration = Duration(milliseconds: 500); + + @override + InspectorSubmitState build(String configNodeId) { + ref.onDispose(() => _debounce?.cancel()); + return const InspectorSubmitIdle(); + } +``` + +with: + +```dart +class InspectorSubmitController + extends AutoDisposeFamilyNotifier { + Timer? _debounce; + KeepAliveLink? _debounceKeepAlive; + + static const debounceDuration = Duration(milliseconds: 500); + + @override + InspectorSubmitState build(String configNodeId) { + ref.onDispose(_cancelPendingDebounce); + return const InspectorSubmitIdle(); + } + + /// 取消挂起的防抖计时器并释放其 keepAlive(不落盘)。 + void _cancelPendingDebounce() { + _debounce?.cancel(); + _debounce = null; + _debounceKeepAlive?.close(); + _debounceKeepAlive = null; + } +``` + +Then replace: + +```dart + /// prompt 防抖保存:连续输入只落最后一次。 + void savePromptDebounced(String prompt) { + _debounce?.cancel(); + _debounce = Timer(debounceDuration, () { + unawaited(saveConfig({'prompt': prompt})); + }); + } +``` + +with: + +```dart + /// prompt 防抖保存:连续输入只落最后一次。 + /// + /// 挂起期间用 [AutoDisposeRef.keepAlive] 挂起本 provider 的 autoDispose—— + /// 否则切换 Inspector 选中的节点会在计时器触发前就把它回收,onDispose 只 + /// cancel 计时器、不落盘,编辑内容直接丢失(2026-08-31 审计 P0)。 + void savePromptDebounced(String prompt) { + _cancelPendingDebounce(); + _debounceKeepAlive = ref.keepAlive(); + _debounce = Timer(debounceDuration, () { + final link = _debounceKeepAlive; + _debounceKeepAlive = null; + unawaited( + saveConfig({'prompt': prompt}) + .whenComplete(() => link?.close()), + ); + }); + } +``` + +Finally, in `submit()`, replace: + +```dart + Future submit(Map finalConfig) async { + if (isBusy) return; + _debounce?.cancel(); +``` + +with: + +```dart + Future submit(Map finalConfig) async { + if (isBusy) return; + // 即将写完整 finalConfig:丢弃挂起的局部 prompt patch,不需要它再补落一次。 + _cancelPendingDebounce(); +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `flutter test test/features/canvas/providers/inspector_submit_controller_test.dart` +Expected: PASS — all existing tests in the file still pass, plus the new regression test. + +- [ ] **Step 5: Run static analysis** + +Run: `flutter analyze lib test` +Expected: `No issues found!` + +- [ ] **Step 6: Commit** + +```bash +git add lib/features/canvas/providers/inspector_submit_controller.dart test/features/canvas/providers/inspector_submit_controller_test.dart +git commit -m "fix: keep pending prompt autosave alive across selection changes + +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)." +``` + +--- + +### Task 3: Stop `ShotConfigInspector`'s local notes debounce from losing edits on dispose + +**Files:** +- Modify: `lib/features/canvas/widgets/shot_config_inspector.dart` +- Test: `test/features/canvas/widgets/shot_config_inspector_test.dart` + +**Interfaces:** +- Consumes: `InspectorSubmitController.saveConfig(Map)` (unchanged signature, already used by this widget). +- Produces: nothing new — this is a private `State` fix, no public API changes. + +- [ ] **Step 1: Write the failing test** + +In `test/features/canvas/widgets/shot_config_inspector_test.dart`, add this test inside the existing `group('用本镜备注生成图像', () { ... })` block, after the `'输入备注后按钮从禁用变可用'` test and before `'连线失败 → 节点已创建 + 专用连线失败 snackbar'`: + +```dart + testWidgets( + '输入备注后立即切换选中(dispose)——挂起的防抖写入仍应落盘(回归 2026-08-31 审计 P0)', + (tester) async { + final id = await nodeRepo.create( + canvasId: canvasId, + type: 'shot', + nodeRole: 'config', + ); + final node = CanvasNode( + id: id, + label: '', + type: CanvasNodeType.shot, + role: NodeRole.config, + canvasId: canvasId, + typeConfig: const {}, + ); + await pump(tester, node); + + await tester.enterText(find.byType(TextField), 'dolly in on face'); + await tester.pump(); + + // 切换选中:把 ShotConfigInspector 从树里换掉,在 500ms 防抖窗口内触发 + // 其 State.dispose()——不能只 cancel 计时器,必须先把最后一次输入落盘。 + await pumpInkApp( + tester, + const Scaffold(body: SizedBox.shrink()), + overrides: [ + nodeRepositoryProvider.overrideWith((ref) async => nodeRepo), + edgeRepositoryProvider.overrideWith((ref) async => edgeRepo), + ], + ); + + await tester.pump(const Duration(milliseconds: 600)); + await tester.pump(); + + expect( + (nodeRepo.rows[id]!['type_config'] as Map)[ + 'shot_notes'], + 'dolly in on face', + ); + }); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `flutter test test/features/canvas/widgets/shot_config_inspector_test.dart --plain-name "切换选中(dispose)"` +Expected: FAIL — `nodeRepo.rows[id]!['type_config']` does not contain `'shot_notes'` because `dispose()` cancelled the pending `Timer` without saving. + +- [ ] **Step 3: Implement the fix** + +In `lib/features/canvas/widgets/shot_config_inspector.dart`, replace: + +```dart + @override + void dispose() { + _debounce?.cancel(); + _notesCtrl.dispose(); + super.dispose(); + } +``` + +with: + +```dart + @override + void dispose() { + _flushPendingNotes(); + _notesCtrl.dispose(); + super.dispose(); + } + + /// 切换选中会在 500ms 防抖窗口内 dispose 本 State——不能只 cancel 计时器 + /// 了事,得先把最后一次输入落盘,否则打完字立刻切走就把编辑丢了 + /// (2026-08-31 审计 P0,与 InspectorSubmitController.savePromptDebounced + /// 同一类问题的本地计时器版本)。fire-and-forget,与 saveConfig 本身的 + /// best-effort 语义一致。 + void _flushPendingNotes() { + if (_debounce == null) return; + _debounce!.cancel(); + _debounce = null; + ref + .read(inspectorSubmitControllerProvider(widget.node.id).notifier) + .saveConfig({'shot_notes': _notesCtrl.text}); + } +``` + +Then replace: + +```dart + void _onChanged(String value) { + setState(() {}); // 备注是否为空 → 生成按钮可用性 + _debounce?.cancel(); + _debounce = Timer(const Duration(milliseconds: 500), () { + ref + .read(inspectorSubmitControllerProvider(widget.node.id).notifier) + .saveConfig({'shot_notes': value}); + }); + } +``` + +with: + +```dart + void _onChanged(String value) { + setState(() {}); // 备注是否为空 → 生成按钮可用性 + _debounce?.cancel(); + _debounce = Timer(const Duration(milliseconds: 500), () { + _debounce = null; + ref + .read(inspectorSubmitControllerProvider(widget.node.id).notifier) + .saveConfig({'shot_notes': value}); + }); + } +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `flutter test test/features/canvas/widgets/shot_config_inspector_test.dart` +Expected: PASS — all existing tests in the file still pass, plus the new regression test. + +- [ ] **Step 5: Run static analysis** + +Run: `flutter analyze lib test` +Expected: `No issues found!` + +- [ ] **Step 6: Commit** + +```bash +git add lib/features/canvas/widgets/shot_config_inspector.dart test/features/canvas/widgets/shot_config_inspector_test.dart +git commit -m "fix: flush pending shot-notes autosave on dispose instead of discarding it + +ShotConfigInspector keeps its own local debounce Timer (separate from +InspectorSubmitController's) for the shot_notes field. Its dispose() +only cancelled the timer, so switching the selected canvas node within +the 500ms debounce window silently discarded the last edit. Now +dispose() flushes the current text before cancelling. + +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)." +``` + +--- + +### Task 4: Extract the import flow and add it to Studio's empty state + +**Files:** +- Create: `lib/features/studio/project_import_flow.dart` +- Modify: `lib/features/studio/studio_home_screen.dart` +- Test: `test/features/studio/widgets/studio_import_test.dart` + +**Interfaces:** +- Produces: `Future runProjectImportFlow(BuildContext context, WidgetRef ref)` in `lib/features/studio/project_import_flow.dart` — this is what Task 5 (command palette) will also call. +- Consumes (unchanged, just relocated): `openFilePickerProvider`, `projectImportServiceProvider`, `projectImportBusyProvider`, `databaseRestoreBusyProvider`, `projectExportBusyProvider`, `selectedProjectIdProvider`, `workspaceProjectsProvider`, `toastServiceProvider`, `loggerProvider` — all already used by the code being moved, from `lib/core/di/project_archive.dart`, `lib/core/di/database_restore.dart`, `lib/core/di/logger.dart`, `lib/features/studio/controllers/studio_state.dart`, `lib/features/studio/providers/project_export_busy.dart`, `lib/features/studio/providers/workspace_projects_provider.dart`, `lib/features/generation/services/toast_service.dart`. + +- [ ] **Step 1: Write the failing test** + +In `test/features/studio/widgets/studio_import_test.dart`, add this test after the existing `'还原 busy 时导入禁用(三大重操作互斥)'` test, and change the `pump()` helper's `workspaceProjectsProvider` override to accept an empty-list variant for this one test (add a `projects` parameter to `pump` with a default matching today's single-project list, so the existing 4 tests don't change behavior): + +Replace the `pump` helper: + +```dart + Future pump(WidgetTester tester, + {String? pickedPath}) async { + await tester.binding.setSurfaceSize(const Size(1440, 900)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + final container = ProviderContainer( + overrides: [ + workspaceProjectsProvider.overrideWith( + (_) async => [ + ProjectWithCanvases( + id: 'p1', + name: 'Alpha', + createdAt: DateTime.utc(2026, 5, 1), + canvases: const [], + ), + ], + ), + openFilePickerProvider.overrideWithValue(() async => pickedPath), + projectImportServiceProvider.overrideWith((ref) async => service), + toastServiceProvider.overrideWithValue(toast), + loggerProvider.overrideWithValue(RecordingLogger()), + ], + ); + addTearDown(container.dispose); + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: MaterialApp( + theme: buildAppTheme(variant: InkThemeVariant.dark, textScale: 1), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const StudioHomeScreen(), + ), + ), + ); + await tester.pumpAndSettle(); + return container; + } +``` + +with: + +```dart + Future pump( + WidgetTester tester, { + String? pickedPath, + List projects = const [], + }) async { + await tester.binding.setSurfaceSize(const Size(1440, 900)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + final container = ProviderContainer( + overrides: [ + workspaceProjectsProvider.overrideWith((_) async => projects), + openFilePickerProvider.overrideWithValue(() async => pickedPath), + projectImportServiceProvider.overrideWith((ref) async => service), + toastServiceProvider.overrideWithValue(toast), + loggerProvider.overrideWithValue(RecordingLogger()), + ], + ); + addTearDown(container.dispose); + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: MaterialApp( + theme: buildAppTheme(variant: InkThemeVariant.dark, textScale: 1), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const StudioHomeScreen(), + ), + ), + ); + await tester.pumpAndSettle(); + return container; + } +``` + +`_oneProject` must be a **top-level** getter, not declared inside `main()` (Dart does not allow local getter/setter declarations inside a function body). Add it at file scope, right after the `_RecordingToast` class and before `void main() {`: + +```dart +List get _oneProject => [ + ProjectWithCanvases( + id: 'p1', + name: 'Alpha', + createdAt: DateTime.utc(2026, 5, 1), + canvases: const [], + ), + ]; +``` + +Then update each of the 4 existing calls that build a non-empty-Studio scenario, adding `projects: _oneProject` as the last named argument. All 4 are calls to the `pump` helper inside `testWidgets` bodies — find each by its enclosing test name and change exactly as shown: + +In `'导入成功:service 收 path、barrier 在途、选中新项目、成功 toast'`, replace: + +```dart + final container = await pump(tester, pickedPath: 'C:/tmp/p.zip'); +``` + +with: + +```dart + final container = await pump(tester, pickedPath: 'C:/tmp/p.zip', projects: _oneProject); +``` + +In `'picker 取消 → 零调用零 toast'`, replace: + +```dart + await pump(tester, pickedPath: null); +``` + +with: + +```dart + await pump(tester, pickedPath: null, projects: _oneProject); +``` + +In `'outcome 文案:failedFormat / failedCorrupt'`, replace: + +```dart + service.outcome = ImportOutcome.failedFormat; + await pump(tester, pickedPath: 'C:/tmp/p.zip'); +``` + +with: + +```dart + service.outcome = ImportOutcome.failedFormat; + await pump(tester, pickedPath: 'C:/tmp/p.zip', projects: _oneProject); +``` + +In `'还原 busy 时导入禁用(三大重操作互斥)'`, replace: + +```dart + final container = await pump(tester, pickedPath: 'C:/tmp/p.zip'); +``` + +with: + +```dart + final container = await pump(tester, pickedPath: 'C:/tmp/p.zip', projects: _oneProject); +``` + +Now add the new empty-state test at the end of `main()`, before the closing `}`: + +```dart + testWidgets('零项目空态也能看到并使用 Import project(回归 2026-08-31 审计 P0)', + (tester) async { + service.gate = Completer(); + final container = await pump(tester, pickedPath: 'C:/tmp/p.zip'); + + expect(find.text('Import project…'), findsOneWidget); + await tester.tap(find.text('Import project…')); + await tester.pump(); + expect(find.text('Importing…'), findsOneWidget); + + service.gate!.complete(); + await tester.pumpAndSettle(); + + expect(service.paths, ['C:/tmp/p.zip']); + expect(toast.shown.single.message, 'Project imported'); + expect(container.read(selectedProjectIdProvider), 'new-proj'); + }); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `flutter test test/features/studio/widgets/studio_import_test.dart --plain-name "零项目空态"` +Expected: FAIL — `find.text('Import project…')` finds nothing, because the empty state currently only renders New/Sample/Showcase buttons. + +- [ ] **Step 3: Create the extracted flow file** + +Create `lib/features/studio/project_import_flow.dart`: + +```dart +// runProjectImportFlow:LB-12 项目包导入——picker → barrier 模态 → service → +// 成功选中新项目。三大重操作(导入/还原/导出)互斥;依赖首 await 前 read 持有 +// (#188 P1-1)。 +// +// 抽成公开顶层函数(而非 studio_home_screen.dart 里的私有方法),因为 +// 2026-08-31 审计 P0 发现零项目空态和命令面板都够不到这个入口——两处都要能调用 +// 同一份逻辑,不能只挂在 FAB 按钮的私有回调里。 +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../core/di/database_restore.dart'; +import '../../core/di/logger.dart'; +import '../../core/di/project_archive.dart'; +import '../../core/interfaces/project_import_service.dart'; +import '../../l10n/l10n_x.dart'; +import '../../theme/app_theme.dart'; +import '../../theme/tokens.dart'; +import '../generation/services/toast_service.dart'; +import 'controllers/studio_state.dart'; +import 'providers/project_export_busy.dart'; +import 'providers/workspace_projects_provider.dart'; + +const String _logModule = 'studio.import'; + +Future runProjectImportFlow(BuildContext context, WidgetRef ref) async { + final importBusy = ref.read(projectImportBusyProvider.notifier); + if (importBusy.state || + ref.read(databaseRestoreBusyProvider) || + ref.read(projectExportBusyProvider)) { + return; + } + final toast = ref.read(toastServiceProvider); + final logger = ref.read(loggerProvider); + final picker = ref.read(openFilePickerProvider); + final serviceFuture = ref.read(projectImportServiceProvider.future); + final selected = ref.read(selectedProjectIdProvider.notifier); + final container = ProviderScope.containerOf(context, listen: false); + final navigator = Navigator.of(context, rootNavigator: true); + final l10n = context.l10n; + final progressMsg = l10n.importInProgress; + final doneMsg = l10n.importDone; + importBusy.state = true; + try { + final String? path; + try { + path = await picker(); + } catch (e, st) { + // 放行点:平台 picker 异常不得静默(#192 评审 P3-5)。 + logger.error(_logModule, 'import picker failed', cause: e, stackTrace: st); + toast.show(l10n.importFailed, kind: ToastKind.error); + return; + } + if (path == null || !context.mounted) return; + + // barrier 模态罩全程(导入分钟级;LB-22 同款)。 + BuildContext? barrierCtx; + unawaited(showDialog( + context: context, + barrierDismissible: false, + barrierColor: context.inkColors.scrim, + builder: (ctx) { + barrierCtx = ctx; + return PopScope( + canPop: false, + child: AlertDialog( + content: Row( + children: [ + const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 2), + ), + const SizedBox(width: InkSpacing.md), + Text(progressMsg), + ], + ), + ), + ); + }, + )); + ImportResult result; + try { + final service = await serviceFuture; + result = await service.importArchive(zipPath: path); + } catch (e, st) { + // 放行点:service 已收敛所有已知失败——这里兜装配错误,失败必须可见。 + logger.error(_logModule, 'import unexpected', cause: e, stackTrace: st); + result = const ImportResult(outcome: ImportOutcome.failed); + } finally { + final ctx = barrierCtx; + if (ctx != null && ctx.mounted) { + Navigator.of(ctx).pop(); + } else { + navigator.pop(); + } + } + + if (result.outcome == ImportOutcome.imported) { + container.invalidate(workspaceProjectsProvider); + selected.state = result.newProjectId; + toast.show(doneMsg, kind: ToastKind.success); + } else { + final String msg = switch (result.outcome) { + ImportOutcome.failedFormat => l10n.importFailedFormat, + ImportOutcome.failedVersionNewer => l10n.importFailedVersionNewer, + ImportOutcome.failedCorrupt => l10n.importFailedCorrupt, + ImportOutcome.failed || ImportOutcome.imported => l10n.importFailed, + }; + toast.show(msg, kind: ToastKind.error); + } + } finally { + importBusy.state = false; + } +} +``` + +- [ ] **Step 4: Wire the FAB to the extracted function and delete the old private method** + +In `lib/features/studio/studio_home_screen.dart`, add the import near the other local imports: + +```dart +import 'open_canvas.dart'; +``` + +becomes (insert alphabetically among the existing relative imports): + +```dart +import 'open_canvas.dart'; +import 'project_import_flow.dart'; +``` + +Replace the FAB's `onPressed`: + +```dart + : () => _importProject(context, ref), +``` + +with: + +```dart + : () => runProjectImportFlow(context, ref), +``` + +Delete the entire `_importProject` method (the block starting at the comment `/// LB-12:项目包导入……` through its closing `}`, immediately before the `/// ON-2:示例项目入口……` comment for `_createSampleProject`) — that logic now lives entirely in `project_import_flow.dart`. + +- [ ] **Step 5: Add the import button to the empty state** + +In `lib/features/studio/studio_home_screen.dart`, inside `_StudioMainArea.build`, find where `_StudioEmptyState` is constructed: + +```dart + data: (projects) => projects.isEmpty + ? _StudioEmptyState( + onCreate: () => + _showNewProjectDialog(context, ref, const []), + onCreateSample: () => + _createSampleProject(context, ref), + onOpenShowcase: () => ref + .read(currentScreenProvider.notifier) + .state = AppScreen.showcase, + ) + : _ProjectGrid(projects: projects), +``` + +Replace it with (adding a computed `importBusy` guard consistent with the FAB's, and a new `onImport` callback): + +```dart + data: (projects) { + final importBusy = + ref.watch(projectImportBusyProvider) || + ref.watch(databaseRestoreBusyProvider) || + ref.watch(projectExportBusyProvider); + return projects.isEmpty + ? _StudioEmptyState( + onCreate: () => _showNewProjectDialog( + context, ref, const []), + onCreateSample: () => + _createSampleProject(context, ref), + onOpenShowcase: () => ref + .read(currentScreenProvider.notifier) + .state = AppScreen.showcase, + onImport: importBusy + ? null + : () => runProjectImportFlow(context, ref), + ) + : _ProjectGrid(projects: projects); + }, +``` + +Add the needed import for `projectImportBusyProvider` (it comes from `../../core/di/project_archive.dart`, already imported in this file for the FAB — no new import needed). + +- [ ] **Step 6: Add the `onImport` field and button to `_StudioEmptyState`** + +Replace: + +```dart +class _StudioEmptyState extends StatelessWidget { + const _StudioEmptyState({ + required this.onCreate, + required this.onCreateSample, + required this.onOpenShowcase, + }); + + final VoidCallback onCreate; + final VoidCallback onCreateSample; + final VoidCallback onOpenShowcase; +``` + +with: + +```dart +class _StudioEmptyState extends StatelessWidget { + const _StudioEmptyState({ + required this.onCreate, + required this.onImport, + required this.onCreateSample, + required this.onOpenShowcase, + }); + + final VoidCallback onCreate; + final VoidCallback? onImport; + final VoidCallback onCreateSample; + final VoidCallback onOpenShowcase; +``` + +Then, in the same widget's `build`, add the import button right after the "New project" button and before "Sample project" — replace: + +```dart + InkAmberButton( + label: context.l10n.studioNewProject, + icon: Icons.add, + onPressed: onCreate, + ), + const SizedBox(height: InkSpacing.sm), + InkGhostButton( + label: context.l10n.studioCreateSampleProject, + icon: Icons.auto_awesome_outlined, + onPressed: onCreateSample, + ), +``` + +with: + +```dart + InkAmberButton( + label: context.l10n.studioNewProject, + icon: Icons.add, + onPressed: onCreate, + ), + const SizedBox(height: InkSpacing.sm), + // 2026-08-31 审计 P0:零项目用户手里只有归档文件时,此前完全没有 + // 入口能导入——项目卡 ⋮ 菜单此时不存在,FAB 也只在非空态渲染。 + InkGhostButton( + label: context.l10n.studioImportProject, + icon: Icons.unarchive_outlined, + onPressed: onImport, + ), + const SizedBox(height: InkSpacing.sm), + InkGhostButton( + label: context.l10n.studioCreateSampleProject, + icon: Icons.auto_awesome_outlined, + onPressed: onCreateSample, + ), +``` + +Check `InkGhostButton`'s `onPressed` parameter type before this edit — it must already accept `VoidCallback?` (nullable), since the FAB's existing `InkGhostButton` for import is already wired to a nullable callback (`onPressed: ... ? null : () => ...`). No change needed there. + +- [ ] **Step 7: Run the test to verify it passes** + +Run: `flutter test test/features/studio/widgets/studio_import_test.dart` +Expected: PASS — all 5 existing tests (now passing `projects: _oneProject`) plus the new empty-state test. + +- [ ] **Step 8: Run the full studio test directory to catch any other test relying on `_StudioEmptyState`'s constructor** + +Run: `flutter test test/features/studio` +Expected: PASS. If `test/features/studio/studio_home_test.dart` or any other file constructs `_StudioEmptyState` directly or asserts on the exact button count/order in the empty state, update it to pass `onImport: () {}` (or the appropriate callback) — check with: + +Run: `grep -rn "_StudioEmptyState(" test/` + +Expected: only the production call site in `lib/features/studio/studio_home_screen.dart` — `_StudioEmptyState` is a private class, so no test can construct it directly; any affected test instead exercises it indirectly through `StudioHomeScreen`, which is why Step 8's full-directory run is the actual verification step, not the grep. + +- [ ] **Step 9: Run static analysis** + +Run: `flutter analyze lib test` +Expected: `No issues found!` + +- [ ] **Step 10: Commit** + +```bash +git add lib/features/studio/project_import_flow.dart lib/features/studio/studio_home_screen.dart test/features/studio/widgets/studio_import_test.dart +git commit -m "fix: make Import project reachable from Studio's zero-project empty state + +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" +``` + +--- + +### Task 5: Add "Import project" to the Studio command-palette context + +**Files:** +- Modify: `lib/features/command_palette/command_actions.dart` +- Test: `test/features/command_palette/command_palette_test.dart` + +**Interfaces:** +- Consumes: `runProjectImportFlow(BuildContext, WidgetRef)` from `lib/features/studio/project_import_flow.dart` (produced by Task 4). +- Produces: nothing new downstream — this is the last consumer in this plan. + +- [ ] **Step 1: Write the failing test** + +In `test/features/command_palette/command_palette_test.dart`, add the import: + +```dart +import 'package:inkframe/core/di/project_archive.dart'; +``` + +Then add this test after the existing `'studio 上下文只有 Open settings;执行后导航到设置页'` test: + +```dart + testWidgets('studio 上下文能直接执行 Import project(回归 2026-08-31 审计 P0)', + (tester) async { + final container = await _pumpShell(tester, overrides: [ + openFilePickerProvider.overrideWithValue(() async => null), + ]); + await _pressCtrlK(tester); + + expect(find.text('Import project…'), findsOneWidget); + await tester.tap(find.text('Import project…')); + await tester.pumpAndSettle(); + + // picker 返回 null(用户取消):面板已经关闭,且没有崩溃/挂起。 + expect(find.byType(CommandPaletteDialog), findsNothing); + expect(container.read(projectImportBusyProvider), isFalse); + }); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `flutter test test/features/command_palette/command_palette_test.dart --plain-name "Import project"` +Expected: FAIL — `find.text('Import project…')` finds nothing, because the Studio context currently only returns `[_openShowcase(l), _openSettings(l)]`. + +- [ ] **Step 3: Implement the fix** + +In `lib/features/command_palette/command_actions.dart`, add the import: + +```dart +import '../studio/project_import_flow.dart'; +``` + +(insert it alphabetically among the existing relative imports, after `import '../gallery/providers/current_gallery_project.dart';`). + +Replace the studio-context branch: + +```dart + // studio:内置示例是全局动作,项目卡菜单在零项目空态下不存在——命令面板 + // 与空态 CTA 一起保证零项目用户也够得到(评审 P1-1)。 + AppScreen.studio => [_openShowcase(l), _openSettings(l)], +``` + +with: + +```dart + // studio:内置示例是全局动作,项目卡菜单在零项目空态下不存在——命令面板 + // 与空态 CTA 一起保证零项目用户也够得到(评审 P1-1)。2026-08-31 审计 P0: + // 导入项目此前在这里完全够不到,见 studio/project_import_flow.dart。 + AppScreen.studio => [ + _importProject(l), + _openShowcase(l), + _openSettings(l), + ], +``` + +Then add the new action builder near the other `_open*`/`_back*` builders at the bottom of the file (after `_openShowcase`, before `_openSettings`): + +```dart +CommandAction _importProject(AppLocalizations l) => CommandAction( + id: 'importProject', + icon: Icons.unarchive_outlined, + label: l.studioImportProject, + run: (context, ref) => runProjectImportFlow(context, ref), + ); +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `flutter test test/features/command_palette/command_palette_test.dart` +Expected: PASS — all existing tests still pass (the "studio 上下文只有 Open settings" test only asserts presence of `'Open settings'` and absence of canvas-specific actions, so adding a third Studio action does not break it), plus the new import-command test. + +- [ ] **Step 5: Run static analysis** + +Run: `flutter analyze lib test` +Expected: `No issues found!` + +- [ ] **Step 6: Run the full local gate** + +Run: `flutter analyze lib test && flutter test --exclude-tags golden` +Expected: `No issues found!` and all tests pass — this is the final confirmation that all 5 tasks compose cleanly together. + +- [ ] **Step 7: Commit** + +```bash +git add lib/features/command_palette/command_actions.dart test/features/command_palette/command_palette_test.dart +git commit -m "fix: add Import project to the Studio command-palette context + +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" +``` + +--- + +## Self-Review Notes + +- **Spec coverage**: all 3 P0 items from `AUDIT-SUMMARY.md` §一 are covered — P0-1 (Task 1), P0-2 (Tasks 2+3, split because the two debounce mechanisms are independent code paths in different files), P0-3 (Tasks 4+5, split because the empty-state CTA and the command-palette action are independently testable surfaces that both depend on the same extracted function). +- **Type consistency checked**: `runProjectImportFlow(BuildContext, WidgetRef)` signature matches `CommandAction.run`'s `Future Function(BuildContext, WidgetRef)` exactly, and matches the `VoidCallback`-wrapping closures used at both FAB and empty-state call sites (`() => runProjectImportFlow(context, ref)`). `_StudioEmptyState.onImport` is `VoidCallback?` (nullable) to support the busy-disable pattern already used by the FAB's equivalent button. +- **No placeholders**: every step has literal, complete code — no "add appropriate handling" language. From fabd37ebfa09799b563ac6543b8660bd70a2df62 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 2 Sep 2026 07:49:49 +0000 Subject: [PATCH 08/14] test(golden): regenerate baselines on ubuntu runner [skip ci] --- test/app/goldens/studio_empty.png | Bin 51420 -> 53373 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/test/app/goldens/studio_empty.png b/test/app/goldens/studio_empty.png index 51c9ffedf1ea48f245bc81677da9bac07c5c2519..b5a0ffdd0ae0df6ba5dab8e56abd9a669142d89a 100644 GIT binary patch delta 34664 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 Date: Wed, 2 Sep 2026 17:52:28 +1000 Subject: [PATCH 09/14] =?UTF-8?q?docs:=202026-08-31=20=E5=85=A8=E9=87=8F?= =?UTF-8?q?=E5=AE=A1=E8=AE=A1=E6=8A=A5=E5=91=8A=E8=90=BD=E6=A1=A3=E2=80=94?= =?UTF-8?q?=E2=80=94P0=20=E4=BF=AE=E5=A4=8D=E7=9A=84=E8=AF=81=E6=8D=AE?= =?UTF-8?q?=E6=BA=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 计划文件与 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 Claude-Session: https://claude.ai/code/session_014c1ft3S1sBYwZH2iBzDXVx --- docs/review/2026-08-31/AUDIT-SUMMARY.md | 111 ++++++++++++++++ docs/review/2026-08-31/W-UX-live.md | 45 +++++++ docs/review/2026-08-31/W10.md | 54 ++++++++ docs/review/2026-08-31/W11.md | 61 +++++++++ docs/review/2026-08-31/W12.md | 96 ++++++++++++++ docs/review/2026-08-31/W13.md | 77 ++++++++++++ docs/review/2026-08-31/W14.md | 98 +++++++++++++++ docs/review/2026-08-31/W15.md | 68 ++++++++++ docs/review/2026-08-31/W16.md | 55 ++++++++ docs/review/2026-08-31/W17.md | 161 ++++++++++++++++++++++++ docs/review/2026-08-31/W1a.md | 102 +++++++++++++++ docs/review/2026-08-31/W1b.md | 75 +++++++++++ docs/review/2026-08-31/W2.md | 67 ++++++++++ docs/review/2026-08-31/W3.md | 75 +++++++++++ docs/review/2026-08-31/W4.md | 124 ++++++++++++++++++ docs/review/2026-08-31/W5.md | 63 ++++++++++ docs/review/2026-08-31/W6.md | 133 ++++++++++++++++++++ docs/review/2026-08-31/W7.md | 84 +++++++++++++ docs/review/2026-08-31/W8.md | 81 ++++++++++++ docs/review/2026-08-31/W9.md | 89 +++++++++++++ 20 files changed, 1719 insertions(+) create mode 100644 docs/review/2026-08-31/AUDIT-SUMMARY.md create mode 100644 docs/review/2026-08-31/W-UX-live.md create mode 100644 docs/review/2026-08-31/W10.md create mode 100644 docs/review/2026-08-31/W11.md create mode 100644 docs/review/2026-08-31/W12.md create mode 100644 docs/review/2026-08-31/W13.md create mode 100644 docs/review/2026-08-31/W14.md create mode 100644 docs/review/2026-08-31/W15.md create mode 100644 docs/review/2026-08-31/W16.md create mode 100644 docs/review/2026-08-31/W17.md create mode 100644 docs/review/2026-08-31/W1a.md create mode 100644 docs/review/2026-08-31/W1b.md create mode 100644 docs/review/2026-08-31/W2.md create mode 100644 docs/review/2026-08-31/W3.md create mode 100644 docs/review/2026-08-31/W4.md create mode 100644 docs/review/2026-08-31/W5.md create mode 100644 docs/review/2026-08-31/W6.md create mode 100644 docs/review/2026-08-31/W7.md create mode 100644 docs/review/2026-08-31/W8.md create mode 100644 docs/review/2026-08-31/W9.md diff --git a/docs/review/2026-08-31/AUDIT-SUMMARY.md b/docs/review/2026-08-31/AUDIT-SUMMARY.md new file mode 100644 index 00000000..3fdc17b4 --- /dev/null +++ b/docs/review/2026-08-31/AUDIT-SUMMARY.md @@ -0,0 +1,111 @@ +# InkFrame 2026-08-31 全量审计 — 汇总报告 + +> 方法:17 个维度窗口(部分因超时拆分为 18 次实际调用)由 Codex(`gpt-5.2-codex`,只读沙箱)并行执行,每个窗口自带"先怀疑、后落地"式自我对抗核验;另有一路独立静态 UX 走查(W17)与一次尝试性实机走查(被 Developer Mode 权限挡住,见 `W-UX-live.md`)。 +> 基线:`main` @ `4e40412`,距上次全量审计(`docs/AUDIT-REPORT.md`,冻结于 `a5ba6a0`,2026-06-27)已有 542 个文件改动、+69040/-3935 行——规模接近翻倍,本次未做 diff 裁剪,按全量重审。 +> 原始明细见同目录下 `W1a.md` ~ `W17.md` 及 `W-UX-live.md`。本文件是去重后的导航,不是最终结论——过 P0/P1 之前请回到对应原始文件核实证据行号。 + +## 总量 + +| 严重度 | 数量 | 说明 | +|---|---|---| +| P0 | 3 | 立即修 | +| P1 | 47 | 本迭代排期 | +| P2 | 89 | 有价值但不阻断 | +| P3 | 55 | 打磨/长尾 | +| **合计** | **194** | 18 次窗口调用汇总,未去重跨窗口重复报告(见下方"多窗口互证") | + +--- + +## 一、P0(3 条,建议本周内处理) + +### 1. `OrphanFileReaper` 存在真实可达的删除路径 +- **位置**:`lib/services/orphan_file_reaper.dart:64`(详见 `W12.md`) +- **问题**:文档承诺"DRY-RUN v1,只记日志不删文件",但公开方法 `reap({bool dryRun = true})` 接受 `dryRun:false` 并真的调用 `File.delete()`。当前 DI 走默认值,但任何未来的接线错误、测试工具或直接调用都能触发真删除,且没有恢复机制。 +- **建议**:把删除实现整体从这个"只读审计"服务里去掉,真删除留给未来单独评审的实现。 + +### 2. Inspector 自动保存会静默丢弃最后一次编辑(三个窗口独立发现,互证) +- **位置**:`lib/features/canvas/providers/inspector_submit_controller.dart:84-121`、`image_config_inspector.dart`、`video_config_inspector.dart`、`shot_config_inspector.dart:74`(W17 定为 P0;W4 独立发现 shot notes 场景为 P1;W3 独立发现"autosave 与 submit 竞态"为 P1) +- **问题**:输入防抖 500ms 后才落库,但切换节点选中会立刻 dispose 掉计时器而不是先 flush;持久化失败也被吞掉,UI 仍显示"已保存"。 +- **影响**:打字后立刻切换节点,编辑内容直接丢失且无任何提示;更严重的是,旧的 autosave 可能在 submit() 之后才落库,把已提交生成任务的最终 prompt 覆盖回旧值。 +- **建议**:选中切换/disposal/submit 前统一 flush 挂起的写入,失败要有可见的重试态。这是本次审计里信号最强的一个 bug——三个完全独立的窗口从三个角度撞见了同一个根因。 + +### 3. Studio 空项目状态下找不到"导入项目"入口 +- **位置**:`lib/features/studio/studio_home_screen.dart:85-150`(W17) +- **问题**:导入按钮只出现在项目列表非空的 FAB 行里;零项目的空态只给"新建/示例/展示"三个选项,命令面板在 Studio 场景下也没有导入动作。 +- **影响**:一个只有归档文件、还没有任何项目的新用户,完全找不到路径导入自己的项目——必须先建一个无关项目才能看到导入入口。 +- **建议**:把"导入项目"加进空态 CTA 和 Studio 命令面板。 + +--- + +## 二、多窗口互证的系统性模式(比单点 bug 更值得优先处理) + +### A. Riverpod `autoDispose` 生命周期 vs 进行中异步操作的竞态——贯穪全仓库的一类问题 +独立出现在 **5 个窗口**: +- W1b:数据库恢复流程等的是原始 pool 而非"迁移完成"barrier,启动阶段可以在 schema 迁移未完成时就触发恢复(P1) +- W3:节点删除不是任何并发操作的屏障——链接模式/生成提交可以在节点被删后继续跑,产生悬空边或"charge 了钱但源节点已被软删"的结果(P1);autosave 与 submit 无序竞态,可能把已提交生成用的最终配置覆盖成旧值(P1,即上面的 P0-2) +- W5:批量大小完全不校验 provider 能力上限(P1);仓储读取失败被吞掉后仍继续提交一个语义完全不同但仍计费的生成任务(P1) +- W6:Studio 的增删改查用的是 `autoDispose` controller,只用 `ref.read`,数据库写成功后 `invalidate()` 可能因为 Ref 已被 dispose 而抛出非 `InkError` 异常,列表停留在旧状态(P1) +- W11:job queue 并发仲裁本身有 4 个独立 P1(pending 取消持久化失败导致 handle 永久卡死、取消可能输掉预提交竞态但仍然把已计费任务提交出去、dispose 与真实终态可能产生两个矛盾结果、poll 超时/取消检查不了正在进行中的单次 poll) + +**建议**:这不是 5 个孤立 bug,是一类系统性设计缺口——"异步写入 + autoDispose/取消" 组合缺乏统一的屏障/版本号机制。值得先做一次专门的架构修复(比如引入统一的"操作生命周期"或乐观锁版本号),而不是逐个打补丁。 + +### B. 键盘可达性/无障碍——贯穪 3 个窗口 +- W2:多数自定义交互组件(`InkAmberButton`/`InkGhostButton`/窗口 chrome 按钮等)只有 `GestureDetector`,没有 Focus,键盘用户无法 Tab 到主要操作(P1) +- W4:节点卡片、边选中、车道色板同样是纯指针交互,无可访问语义(P2) +- W17:Canvas 节点卡片/Gallery 预览/命令面板结果行/主题选择等均为"指针专属"内容,屏幕阅读器拿不到可操作状态(多条 P1/P2) + +**建议**:这也是系统性的——设计系统的基础交互原语(`ink_amber_button.dart` 等)本身缺 Focus 支持,一次性把这几个原语修好,能连带修掉上面一串上层组件的问题。 + +### C. Design Token / i18n 铁律被绕过——贯穪 7 个窗口 +W2、W4、W5、W6、W7、W8、W14 都各自独立报出"某处硬编码颜色/尺寸/间距"或"某处硬编码用户可见字符串"。W14 做了仓库级 grep,发现 48 处裸 `Icon.size`(25 个文件)、10 处裸 letterSpacing/strokeWidth(7 个文件)。 + +**建议**:现有的 `no_inline_styles_test.dart` / `no_magic_strings_test.dart` 门禁检测粒度太窄(后者甚至只认 `${l10n.x}` 语法,纯字面量完全测不到,见 W15)。建议先把这两个质量闸门做严,再批量清理,而不是一个个文件手动改。 + +### D. `InkError` 边界被绕过——贯穪 5 个窗口 +W3、W6、W7、W8、W13 都独立发现某个 provider/controller/service 把 `FileSystemException`、`CharacterAssetError`、`PlatformException` 等具体异常类型直接抛给上层,而不是按项目铁律统一映射成 `InkError`。 + +--- + +## 三、按主题分类的 P1 全量(47 条) + +### 并发/竞态(10 条)—— W11×4、W3×2、W5×1(批量校验)、W1b×1(restore 竞态)、W6×1、W5×1(见 §二 A,不重复列) + +### 安全(6 条)—— 全部见 `W12.md` + `W9.md` +- W12-P1:Restore 接受未认证的 PG dump,以超级用户身份执行(可导致任意 SQL/命令执行) +- W12-P1:DB 交换非崩溃安全,原库在校验通过前就被删除 +- W12-P1:符号链接 zip 炸弹在归档护栏生效前就被解压 +- W12-P1:诊断包会原样打包日志/崩溃文件,"绝不含 API key"的承诺不成立 +- W9-P1:Provider 报错信息未脱敏,API key 可能进入 `InkError.extra` 进而进日志/诊断包 + +### 文档漂移(6 条,全部 P1,见 `W16.md`) +高级图像参数/画布选中重绘/视频成本估算/参考图导入/画廊视频播放/自定义 provider 启动注册——BOARD.md 标"✅ 完成 + 测试"的功能,测试实际没有覆盖到声称的具体保证。 + +### UX 可用性(17 条,全部 P1,见 `W17.md`) +关键几条:script 导入弹窗运行中可被点击关闭、清除 API Key 无二次确认、长耗时操作(备份/恢复/导出/示例创建)大多只是把按钮置灰没有进度反馈、生成校验失败会把内部机器原因字符串直接展示给用户(且英文残留进中文界面)、导出默认会同时勾选新旧两版镜头、多处失败态直接丢弃具体错误原因退化成"失败了"。 + +### Provider 适配器(1 条)—— W9 API key 泄露(已归入安全类) + +### 存储/PG(0 条 P1)—— W10 全部 P2 及以下,基础机制经得住审 + +### 主题/设计系统(3 条)—— 文本缩放只影响自定义 token 不影响 Material 组件、大部分自定义交互控件无键盘焦点(见 §二 B)、WCAG 锁漏了几组真实在用的前景/背景搭配 + +### i18n/CI/依赖(3 条)—— 分支保护未强制五项检查、发布流水线签名密钥暴露给可变未锁版本的工具链、macOS 打包的第三方 PG 依赖库(ICU/OpenSSL 等)许可证未登记 + +--- + +## 四、已知阻塞项 + +**实机可用性走查未完成**:Flutter Windows 插件构建需要 Developer Mode(符号链接权限),此环境未开启且无管理员权限,agent 已如实上报而非编造走查结果(见 `W-UX-live.md`)。等你开启 Developer Mode 后可以重新派发这一路,拿到真实点击体验(视觉层级、卡顿、真实报错文案观感)作为 W17 代码层走查的补充。 + +--- + +## 五、建议的处理顺序 + +1. **本周**:3 个 P0。 +2. **本迭代**:先解决"多窗口互证"的两类系统性问题(§二 A 并发/生命周期、§二 B 键盘可达性)——一次架构级修复覆盖多条 P1,比按窗口逐条修更省成本。 +3. **随后**:安全类 6 条 P1(尤其 restore 任意 SQL 执行和诊断包泄密这两条,影响面最大)。 +4. **随后**:文档漂移 6 条——多数是"补测试"而不是改代码,成本低、能防止未来真的踩上这些坑。 +5. **并行**:UX 的 17 条 P1 可以按功能区拆给不同人平行处理,互不冲突。 +6. **有空再做**:design-token/i18n 系统性清理(先加强门禁测试,再批量清)、P2/P3 长尾。 + +原始 18 份窗口报告见本目录,每条 finding 都带文件路径+行号+复现证据,修复前建议对照原文件核实。 diff --git a/docs/review/2026-08-31/W-UX-live.md b/docs/review/2026-08-31/W-UX-live.md new file mode 100644 index 00000000..7fdd59e2 --- /dev/null +++ b/docs/review/2026-08-31/W-UX-live.md @@ -0,0 +1,45 @@ +# W-UX-live — 真机可用性走查 (Windows desktop, live) + +**CONCLUSION**: 0 findings by severity — the live walkthrough could not be performed. **1 P0 environment blocker** prevented launching the app at all; no in-app screens were reached and no screenshots were captured. + +## Screens covered + +**None reached.** The app never launched. Everything in the requested flow list (onboarding, Studio home, canvas node add/connect/inspector, generation submit, Storyboard import/preview, Gallery, Export, Settings, Command palette, empty/loading/error states) is unaudited from a live-interaction standpoint. + +What I did establish, as a substitute check, is that the current branch's Dart code is not the reason for the failure: `flutter analyze lib` on this branch reports **"No issues found!"** — the blocker below is purely an environment/toolchain issue on this machine, not a compile or lint defect in the diff (`lib/features/canvas/widgets/characters_section.dart`, `inspector_chip.dart`, `name_dialog.dart`, `image_config_inspector.dart`, `video_config_inspector.dart`, `generation_controller.dart`). + +## Findings + +### [P0] Cannot build/launch the Windows desktop app on this machine — missing symlink privilege + +- **Screen/flow**: App launch (blocks all downstream flows) +- **What happened**: Ran `flutter devices` — `windows-x64` desktop device is correctly detected. Ran `flutter run -d windows` from repo root (`D:\Projects\InkFrame`). Dependency resolution succeeded, but the build failed immediately with: + ``` + Error: Building with plugins requires symlink support. + + Please enable Developer Mode in your system settings. Run + start ms-settings:developers + to open settings. + ``` + This comes from Flutter's Windows plugin-linking step (`flutter_plugins.dart`, `handleSymlinkException`, Win32 error 1314 `ERROR_PRIVILEGE_NOT_HELD`): creating the per-plugin symlinks under `windows/flutter/ephemeral/.plugin_symlinks` requires either Windows Developer Mode enabled or an Administrator-elevated terminal — neither is available on this session/machine (verified: no prior `build/windows/...Debug|Release` artifacts exist to run directly; current shell confirmed **not** running elevated via `WindowsPrincipal.IsInRole(Administrator) = False`; checked for a pre-built exe in the four sibling Codex worktrees under `.worktrees/` — none exist there either). + Enabling Windows Developer Mode is a system/security-setting change (`Settings → Privacy & Security → For developers`), which is outside what I'm permitted to change unilaterally, so I did not toggle it and instead stopped to report the blocker, per this task's own escalation instructions. +- **Why it's a problem**: This is an environment issue, not an InkFrame defect — but it's worth flagging in `docs/BOARD.md`/dev-setup docs (if not already) that a fresh Windows dev machine needs Developer Mode ON (or an elevated shell) before `flutter run -d windows` will work at all, given the number of native Windows plugins in this project (window_manager, screen_retriever, media_kit, flutter_secure_storage_windows, file_selector_windows, package_info_plus, etc. — all confirmed present in `pubspec.yaml`). +- **Suggested fix**: Not an app fix. For this audit to proceed, a human (or a session with permission to change Windows settings) needs to either run `start ms-settings:developers` and toggle "Developer Mode" on, or re-run from an Administrator-elevated terminal, then re-invoke this audit. + +## What I tried before stopping (per task's "try ≥2 alternatives" instruction) + +1. `flutter run -d windows` (standard launch) — failed as above. +2. Searched for any already-built executable to run directly without rebuilding (`build/windows/x64/runner/{Debug,Release}/*.exe` in this checkout and in the four `.worktrees/*` sibling checkouts) — none found; this is the first build attempt on this machine. +3. Checked whether the current shell already has the privilege needed to create the plugin symlinks without a settings change (`IsInRole(Administrator)`) — it does not, and I have no way to supply admin credentials/UAC consent non-interactively. +4. Considered falling back to a web/Edge build (also listed as an available device) to at least see *some* UI — rejected: the task explicitly scopes this as a desktop-app audit, and InkFrame's desktop-only plugins (embedded PostgreSQL, `window_manager` frameless chrome, `media_kit` desktop video, Windows Credential Manager secure storage) mean a web build would not compile/behave the same and would produce a misleading, non-representative audit. +5. Ran `flutter analyze lib` as a sanity check that the branch itself isn't broken — clean, confirming the blocker is environmental, not a code regression on `feat/ch-2-video-inspector-characters`. + +No native-desktop screenshot/input tooling was available in this session either (only Chrome-browser MCP tools, explicitly out of scope per the task); a PowerShell screenshot helper was prepared (`GetWindowRect` + `CopyFromScreen`) at `C:\Users\Kerro\AppData\Local\Temp\claude\...\scratchpad\screenshot.ps1` for use once the app can launch, but it was never exercised since no window ever appeared. + +## Screenshots + +None captured — the app window never opened, so there is nothing to screenshot. `flutter devices` output and the exact build error text above are the only artifacts from this session; no image evidence exists. + +## Recommendation + +Re-run this live UX audit after either (a) Developer Mode is enabled on the audit machine, or (b) a pre-built `inkframe.exe` is provided/checked into a reachable location, or (c) the audit is run from an environment where an elevated shell is acceptable. Until then, this report should be treated as "not run" rather than "no issues found" for the live-interaction portion of the larger review. diff --git a/docs/review/2026-08-31/W10.md b/docs/review/2026-08-31/W10.md new file mode 100644 index 00000000..7683bf1a --- /dev/null +++ b/docs/review/2026-08-31/W10.md @@ -0,0 +1,54 @@ +# W10 — storage / embedded PostgreSQL + +**CONCLUSION**: 7 findings — 0 P0, 0 P1, 4 P2, 3 P3. Clean areas: repository values are parameterized; `PostgresUnitOfWork` uses one transaction session and does not permit partial commits when awaited operations fail; migrations v1–v7 are contiguous, atomically advance `schema_version`, and reject downgrade; git history shows no migration edits after ADR-0012; hard-delete cascades match the documented matrix; new-cluster passwords route through `SecureStorageService` without being logged. This was a strict read-only review, so tests were inspected but not executed. + +## Findings + +### [P2] First-run initialization and migration locking are only process-local +- **File**: lib/storage/pg_controller.dart:260; lib/storage/pg_controller.dart:282; lib/storage/migrations/migration_runner.dart:74 +- **Issue**: `_startInflight` prevents concurrent starts only within one `PgController`. There is no inter-process lock around `initdb`, password creation, postmaster startup, or migration version checks. +- **Evidence**: Every process can independently observe no `PG_VERSION`, store a different password, and overwrite the same `config/pg.pwfile.tmp`. Likewise, two processes can both read the same old `schema_version` before independently executing the same DDL. The existing concurrency test invokes `start()` twice on one controller and therefore cannot falsify this cross-process race. +- **Impact**: On Windows, two quickly launched first-run instances can initialize the cluster with password A while password B wins in secure storage, leaving the cluster inaccessible. During an upgrade, one instance can successfully migrate while the other fails on already-created objects and enters the startup-error flow. +- **Suggested fix**: Acquire an OS-backed lock for PGDATA initialization/startup, use an exclusively created random pwfile, and serialize migrations with a PostgreSQL advisory lock held across version read and migration execution. + +### [P2] A reused unrelated PID is accepted as the existing PostgreSQL server +- **File**: lib/storage/pg_controller.dart:402 +- **Issue**: Recovery treats any live PID from `postmaster.pid` as the correct postmaster and trusts the stale file's port without verifying process identity, data directory, start time, or database readiness. +- **Evidence**: `_reuseAliveInstanceOrCleanStale` checks only `_runner.isProcessAlive(pid)` and parses line four. It ignores the data-directory and start-time fields already present in the file. On Unix, the liveness probe even sends `SIGCONT` to that PID. Tests model "PID alive" as sufficient and have no PID-reuse case. +- **Impact**: After a crash and later PID reuse, InkFrame can "reuse" an unrelated process, publish a stale port, and repeatedly fail to connect. Retry/stop may also fail to clear the stale file, requiring manual recovery. +- **Suggested fix**: Validate canonical PGDATA and process start time, use `pg_ctl status -D`, and perform an authenticated readiness probe before accepting reuse. Avoid using `SIGCONT` as a liveness test. + +### [P2] Foreign keys do not enforce project/canvas ownership consistency +- **File**: lib/storage/schema/schema_v1.dart:103; lib/storage/schema/schema_v1.dart:145; lib/storage/schema/schema_v1.dart:170; lib/storage/schema/schema_v1.dart:208; lib/storage/schema/schema_v3.dart:28 +- **Issue**: Related identifiers are individually valid FKs, but their duplicated ownership fields need not agree. Examples include an edge whose `canvas_id` differs from either endpoint's canvas, a node assigned to a lane from another canvas, a job referencing nodes outside its canvas, or a project cover belonging to another project. +- **Evidence**: `edges.canvas_id`, `source_node_id`, and `target_node_id` are independent FKs. The same pattern exists for jobs and batch results. Repository creates accept these IDs without ownership validation. Archive reads select nodes and edges independently by their recorded owners (`postgres_project_archive_reader.dart:51` and `:55`), so malformed cross-project references break snapshot closure. +- **Impact**: A corrupted or hand-edited import can commit cross-project graph data. Deleting a node in project B may cascade-delete an edge displayed as belonging to project A; exporting A can emit an edge or job without its referenced node, causing data to be dropped on re-import. +- **Suggested fix**: Add a new migration with composite ownership constraints, such as `(node_id, canvas_id)` FKs for edges/jobs and `(lane_id, canvas_id)` for node lanes. Validate project covers and batch-result/job-node alignment transactionally. Add mismatch cases to the violation matrix. + +### [P2] Imported JSONB can violate required shapes, and the grid CHECK accepts missing flags +- **File**: lib/storage/schema/schema_v6.dart:18; lib/storage/schema/schema_v1.dart:121; lib/storage/schema/schema_v1.dart:126; lib/storage/repositories/postgres_project_import_writer.dart:45 +- **Issue**: JSONB columns lack top-level shape checks, while the import writer explicitly accepts and encodes either maps or lists. Separately, `chk_grid_consistency` evaluates to SQL `NULL`—which passes a CHECK—when `parent_grid_id` exists but `is_grid_generation` is missing or JSON null. +- **Evidence**: `reference_image_paths` has no `jsonb_typeof(...) = 'array'` constraint. An archive value such as `"reference_image_paths": {}` passes remapping and database insertion, although repository consumers require a list. For grid data, `(type_config->>'is_grid_generation')::bool = false` becomes null when the key is absent, so the implication is not enforced. Existing tests cover true/nonempty violations but not missing/null flags or wrong JSONB container types. +- **Impact**: A malformed project archive can report successful import and then make character loading fail with a decode error. Non-object `type_config` can also be silently treated as empty, losing imported node configuration. +- **Suggested fix**: Add v8 shape constraints for JSONB object/array columns, reject or normalize malformed JSON during remapping, and express the grid predicate with `IS NOT DISTINCT FROM false` or an explicit non-null requirement. + +### [P3] Generic patch keys remain latent SQL-identifier injection sinks +- **File**: lib/storage/base_repository.dart:82; lib/storage/repositories/postgres_batch_result_repository.dart:104; lib/storage/repositories/postgres_edge_repository.dart:99; lib/storage/repositories/postgres_job_repository.dart:111; lib/storage/repositories/postgres_project_import_writer.dart:38 +- **Issue**: Values are correctly bound, but column identifiers are interpolated directly from caller-supplied map keys. This is already noted in BOARD.md as the `buildUpdate` whitelist debt. +- **Evidence**: `setParts.add('$key = @$param')` and equivalent code are used by every generic update implementation. A key such as `name = 'owned' WHERE true --` can alter the SQL structure rather than becoming a bound value. The import writer likewise joins row keys into its column list. I found no currently tainted production caller: normal call sites use column constants, and archive data passes through explicit remapper whitelists. Compile-time status interpolation is also safe. +- **Impact**: Any future pass-through of decoded JSON, plugin data, or another dynamic patch map could turn this latent sink into mass modification or SQL injection. +- **Suggested fix**: Enforce per-table allowed-column sets at the repository boundary, replace arbitrary patch APIs with typed methods where practical, and quote validated identifiers defensively. + +### [P3] Bulk orphan-node soft deletion can leave logically live edges +- **File**: lib/storage/repositories/postgres_node_repository.dart:178; lib/storage/repositories/postgres_edge_repository.dart:53 +- **Issue**: `softDeleteEmptyOrphanResults` marks nodes deleted without soft-deleting their incident edges, while edge reads filter only `edges.deleted_at`. +- **Evidence**: The bulk `UPDATE nodes` has guards for media, slots, and active jobs but no corresponding edge update. `listByCanvas`, `listOutgoing`, and `listIncoming` do not join endpoints to require live nodes. Normal interactive node deletion was checked and is safe because it soft-deletes edges in one unit of work; this separate startup-cleanup path remains exposed. +- **Impact**: An orphan result with a narrative/data edge can be hidden on startup while its edge remains active. The edge can reference an invisible endpoint and continue occupying the partial unique-index slot. +- **Suggested fix**: Soft-delete incident edges and target nodes in one transaction/CTE, or make active-edge queries require both endpoints to be live. Add a storage integration test with an edged orphan result. + +### [P3] Port allocation and failed shutdown are not self-healing +- **File**: lib/storage/pg_controller.dart:359; lib/storage/pg_controller.dart:437; lib/storage/pg_controller.dart:469 +- **Issue**: The selected port is released before PostgreSQL binds it, and `pg_ctl start` is not retried on address conflict. Conversely, any nonzero `pg_ctl stop` result is immediately thrown without a verified fallback. +- **Evidence**: `_defaultPortPicker` binds port zero, records the port, then closes the socket. `_doStart` makes one start attempt. `stop` throws at the first nonzero exit and leaves the postmaster/runtime for callers to handle. Tests cover deterministic start/stop success but no stolen-port or stop-timeout cases. +- **Impact**: Another process claiming the selected port causes an avoidable startup failure. A backend that does not stop within the ten-second fast-shutdown window can survive application exit as an orphan postmaster; the next launch may reuse it, but resources remain active in the interim. +- **Suggested fix**: Retry a bounded number of times with newly selected ports for confirmed bind conflicts. On stop failure, verify the exact postmaster, wait/reprobe, and apply a carefully scoped immediate-stop fallback before giving up. diff --git a/docs/review/2026-08-31/W11.md b/docs/review/2026-08-31/W11.md new file mode 100644 index 00000000..7d6168df --- /dev/null +++ b/docs/review/2026-08-31/W11.md @@ -0,0 +1,61 @@ +# W11 — job queue concurrency + +**CONCLUSION**: 8 findings, split by severity P0=0, P1=4, P2=4, P3=0. The normal submitted/polling cancel-vs-terminal SQL arbitration is sound. App-scoped DI, ordinary batch-slot serialization, async provider `InkError` mapping, and the prior missing-ID false-success bug are clean/fixed. + +## Findings + +### [P1] Pending cancellation persistence failure permanently strands the handle +- **File**: lib/services/job_queue_service.dart:133 +- **Issue**: A pending job is removed from `_pendingIndex` and marked cancelled before cancellation is persisted. If `persistCancel` throws, the handle is never completed and the job cannot be cancelled again. +- **Evidence**: The interleaving is deterministic: `_pendingIndex.remove(jobId)` succeeds → `pending.cancelled = true` → `await _state.persistCancel(...)` throws `LocalIOError`. Control never reaches `_emitFailure`. Dispatch later skips the cancelled queue entry; a retry sees neither `_pendingIndex` nor `_running`; `dispose()` also skips it at line 172 because `p.cancelled` is already true. +- **Impact**: A transient database failure leaves `JobHandle.done` pending forever while the job has disappeared from all cancellable indexes. Generation tracking/UI can remain stuck indefinitely. +- **Suggested fix**: Settle the handle in a `catch/finally`, or roll back/reinsert the pending entry when persistence fails. Add a test where pending `persistCancel` throws. + +### [P1] Cancel can lose the pre-submit race and still launch billed provider work +- **File**: lib/services/job_queue_service.dart:149 +- **Issue**: `_runJob` publishes the job in `_running` before awaiting `pending → submitted`, but running cancellation only targets `submitted/polling`. The initial transition result and `running.cancelled` are then ignored before `provider.submit`. +- **Evidence**: T1 inserts `_running` at lines 243–246 and awaits the initial transition. T2 calls `cancel`: it sets `running.cancelled`, but its guarded update excludes `pending`, so it can affect zero rows; with no remote ID yet it calls `provider.cancel(running.providerJobId ?? jobId)` using the local business ID. T1 then unconditionally calls `provider.submit` at line 257. When the real remote ID arrives, no deferred provider cancellation is issued. +- **Impact**: Cancellation can return while the row subsequently becomes `submitted` and a paid generation still starts. The handle remains active until `submit` returns; synchronous providers can continue through rate limiting and a full generation request. A crash in this window records `cancelled_on_exit`, losing the user-cancel reason. +- **Suggested fix**: Include `pending` in the running cancel guard, inspect the initial transition result, check `running.cancelled` before submission, and defer provider cancellation until the actual provider job ID exists. Provider submission should accept a cancellation signal. + +### [P1] Dispose can produce two contradictory terminal outcomes +- **File**: lib/services/job_queue_service.dart:184; lib/services/job_queue/job_handle_impl.dart:54 +- **Issue**: `dispose()` immediately completes every running handle as cancelled without claiming terminal ownership. An already-started success/failure transition can subsequently win and overwrite the handle's replay cache. +- **Evidence**: T1 passes the cancellation check at lines 391–396 and awaits media or the terminal SQL update. T2 calls `dispose()`, sets `running.cancelled`, and `_emitFailure` completes `done` as cancelled; it does not persist a competing cancellation for running jobs. T1's guarded terminal update can therefore return one row, making `_lostToCancel` false, after which it emits success/original failure. `JobHandleImpl.emit` always assigns `_last`, even when `_done` is already completed and the controller is closed. +- **Impact**: The same handle can report cancellation from `done` but success from a later `status` subscription. The generation tracker may process cancellation and soft-delete a single-result node even though the database ultimately records success. +- **Suggested fix**: Introduce a single compare-and-set terminal owner before terminal persistence. Disposal must respect an already-claimed terminalization, and `JobHandleImpl` must reject all emissions after its first terminal result. + +### [P1] Poll timeout and cancellation do not bound an in-flight poll +- **File**: lib/services/job_queue_service.dart:358 +- **Issue**: The deadline and cancellation flag are checked only before `await provider.poll`. Neither is checked immediately after the await, and `running.wake()` only interrupts backoff sleep. +- **Evidence**: A poll beginning just before the deadline may return success after the deadline and be accepted at lines 391–455. If the poll future never resolves, neither timeout nor cancellation can reach another checkpoint, so the handle and concurrency slot remain occupied indefinitely. +- **Impact**: One or two stuck polls can exhaust the global queue. A configured provider `pollTimeout` is not a real upper bound, and the cancel button cannot release the local job while polling is blocked. +- **Suggested fix**: Race each poll against the remaining deadline and a cancellation future/token. Recheck both conditions after the await before processing the returned status. + +### [P2] A completed job ID can be submitted again and overwrite its artifacts +- **File**: lib/services/job_queue_service.dart:111 +- **Issue**: Duplicate detection covers only current `_pendingIndex` and `_running` entries. `_running` is removed after completion, so the documented duplicate-ID rejection no longer applies. +- **Evidence**: Resubmitting a completed ID passes lines 111–114. Its `pending → submitted` transition affects zero rows because the database row is terminal, but that result is ignored. The provider is called again, `remote_task_id` is unconditionally updated, and media paths derived from the same job ID are rewritten before the final guarded transition also affects zero rows. +- **Impact**: An accidental retry can incur a second provider charge, replace existing files and slot contents, and return success through its new handle while the jobs row retains stale status/timestamps from the first run. +- **Suggested fix**: Retain submitted IDs for the service lifetime and, independently, require the initial transition to affect exactly one row before invoking the provider. + +### [P2] Job and batch-slot terminalization is non-atomic and recovery preserves the mismatch +- **File**: lib/services/job_queue/job_state_persister.dart:142; lib/services/job_queue/job_media_persister.dart:73 +- **Issue**: The terminal jobs-row update commits before slot convergence in a separate operation. Slot convergence errors are swallowed, and startup recovery assigns every remaining generating slot `cancelled_on_exit` without consulting its already-terminal parent job. +- **Evidence**: For failure, `persistTransition(... to: error)` completes at lines 142–151, then slot convergence starts at line 152. A crash or swallowed `LocalIOError` between them leaves an error job with generating slots. On restart, the job is ignored by orphan recovery because it is already terminal, while `finalizeAllPending` changes its slots to cancelled with `cancelled_on_exit`. The same window changes a user-cancelled job's slots from the intended `cancelled_by_user` to `cancelled_on_exit`. +- **Impact**: The jobs table and batch grid permanently disagree about why the generation ended; without a restart, affected slots can continue showing "generating" after the handle and job are terminal. +- **Suggested fix**: Terminalize the job row and its remaining slots in one database transaction, or make recovery derive each generating slot's terminal state/error from its parent job and retry convergence during the current session. + +### [P2] Last-value replay fails when the stream is obtained before the first event +- **File**: lib/services/job_queue/job_handle_impl.dart:25 +- **Issue**: When `_last` is null, `status` returns the raw broadcast stream rather than a replaying wrapper. Replay behavior is therefore determined when the getter is accessed, not when the returned stream is subscribed. +- **Evidence**: `final stream = handle.status; await handle.done; await stream.first` obtains the raw stream at line 27. The terminal event occurs while it has no listener and is discarded by the broadcast controller; subscribing afterward sees only a closed stream. This contradicts the contract that subscription first replays the latest value and that each access returns a new stream. +- **Impact**: Consumers that cache the stream before attaching a listener can miss the only terminal event and receive `Bad state: No element`. +- **Suggested fix**: Always return a per-access wrapper whose `onListen` reads the current `_last` and then bridges to the source. + +### [P2] Race tests serialize the supposed races and leave the dangerous outcomes untested +- **File**: test/services/job_queue_service_test.dart:1103; test/services/job_queue_service_batch_test.dart:689 +- **Issue**: The suite does not exercise competing persistence operations or both arbitration winners. +- **Evidence**: The cancel/success and cancel/failure tests fully `await svc.cancel` before releasing the provider gate, forcing cancel to win sequentially. The fake repository transitions complete without gates. The general running-cancel tests accept either success or cancellation. Batch cancellation awaits the entire cancel call from inside the downloader before slot writeback resumes. The dispose test never releases its poll gate, and the replay test obtains `status` only after `done`. +- **Impact**: Pre-submit cancellation, completion-wins arbitration, persistence failure, dispose-vs-terminal write, in-flight poll timeout, cached-stream replay, and crash-created terminal/slot mismatches can regress without any test failure. +- **Suggested fix**: Add gated repository transitions that independently control commit order, plus deterministic tests for both race winners, cancel persistence failure, cancel during submit/poll/media, late completion after dispose, stream-obtained-before-terminal replay, and terminal-job/generating-slot startup recovery. diff --git a/docs/review/2026-08-31/W12.md b/docs/review/2026-08-31/W12.md new file mode 100644 index 00000000..c52da975 --- /dev/null +++ b/docs/review/2026-08-31/W12.md @@ -0,0 +1,96 @@ +# W12 — data safety & security (backup/restore/import/export/diagnostics) + +**CONCLUSION**: 13 findings — P0: 1, P1: 4, P2: 7, P3: 1 + +## Findings + +### [P0] OrphanFileReaper contains a publicly reachable real-delete path +- **File**: lib/services/orphan_file_reaper.dart:64 +- **Issue**: The supposedly dry-run-only public method accepts `dryRun: false`, which reaches `_reapFile()` and calls `File.delete()`. +- **Evidence**: `reap({bool dryRun = true})` branches at lines 98–100 on `!dryRun`; `_reapFile()` unlinks the candidate at lines 226–228. Current DI uses the default, but any caller can invoke `reap(dryRun: false)`. +- **Impact**: A future wiring mistake, test utility, plugin, or direct service caller can permanently delete media. The deletion logic is already compiled and lacks restore-awareness or recovery. +- **Suggested fix**: Remove the parameter and deletion implementation entirely from the dry-run version. Introduce real deletion only in a separately reviewed implementation/API. + +### [P1] Restore accepts unauthenticated PostgreSQL dumps and executes them as the database superuser +- **File**: lib/services/database_restore_service.dart:147 +- **Issue**: A recognized filename is sufficient when the sidecar is absent. When present, the sidecar contains only an attacker-replaceable SHA-256, not a MAC or signature. The dump is then passed to `pg_restore` using the default embedded-superuser connection. +- **Evidence**: `_verifySidecar()` returns success when metadata is absent at line 276. Lines 179–193 invoke `pg_restore --single-transaction`; that option provides atomicity, not sandboxing. Default wiring supplies `kPgSuperuser`. +- **Impact**: A crafted custom-format dump placed under a valid backup filename can define functions, expression indexes, event triggers, or other objects whose code executes during restore. PostgreSQL restores from untrusted superusers can consequently run arbitrary SQL and potentially OS commands as the InkFrame process account. +- **Suggested fix**: Refuse unauthenticated dumps, MAC app-created backups using a secure-storage secret, and treat legacy restore as an explicit high-risk migration. Restore under a tightly restricted role and validate an allowed schema/TOC before execution. + +### [P1] The database swap is neither crash-atomic nor validated before the original is dropped +- **File**: lib/services/database_restore_service.dart:217 +- **Issue**: Renaming the current database away and renaming scratch into place are two separate statements. The retired database is then dropped before the caller has reopened, migrated, and validated the restored database. +- **Evidence**: Lines 224–229 perform the two renames; lines 251–258 immediately drop the original. Compensation only handles a caught `MaintenanceSqlError`, not process termination. Forward migration occurs later in the restore flow, after this service returns. +- **Impact**: A crash after rename-away leaves the configured database missing and the old data stranded under a timestamped name. A restored database that fails startup migration has already replaced the good database, while the original may already be deleted. Thus an overall failed restore can change or make the workspace unavailable. +- **Suggested fix**: Persist a swap journal, recover stranded retired databases at startup, retain the original until the replacement has been reopened and migrated successfully, and only then garbage-collect it. + +### [P1] Symlink ZIP bombs are inflated before the archive guard runs +- **File**: lib/services/project_import_service.dart:90 +- **Issue**: `ZipDecoder.decodeStream()` runs before `validateArchiveEntries()`. In archive 4.0.9, decoding a Unix symlink eagerly reads and UTF-8-decodes its content to determine the link target. +- **Evidence**: The service decodes at lines 91–95 and only constructs `isSymlink` metadata and rejects it at lines 100–110. Therefore the counting output stream and declared-size gates have not run when symlink content is inflated. If decoding throws, the empty `finally` at lines 93–98 also leaves `input` open because the closing block at lines 209–211 is never reached. +- **Impact**: A small, highly compressed symlink entry can exhaust memory or hang the UI before being rejected. Malformed variants can additionally leak file handles across repeated import attempts. +- **Suggested fix**: Validate raw central-directory size, mode, count, and names before calling `ZipDecoder`; avoid a decoder that materializes symlinks, and wrap the input itself in an unconditional outer `try/finally`. + +### [P1] Diagnostics bundles include raw logs and crash files, so "never API keys" is not guaranteed +- **File**: lib/services/diagnostics_bundle_service.dart:92 +- **Issue**: Configuration allowlist files are passed through `LogSanitizer`, but every regular file beneath `logs/` and `crashes/` is added byte-for-byte. +- **Evidence**: `_addDirectory()` recursively enumerates and calls `encoder.addFile()` at lines 149–160 without inspecting or sanitizing content. This includes legacy logs, `pg.log`, manually added files, and output from components outside `FileLoggerService`. The config masking is also textual and misses names such as `access_key`. +- **Impact**: A legacy/third-party log containing an API key, database password, proxy credential, or bearer token is silently placed in a bundle that the user is likely to upload to support. +- **Suggested fix**: Allowlist known diagnostic filenames, sanitize every text file again during bundling, structurally parse and redact JSON, and add a final whole-bundle secret scan that fails closed. + +### [P2] Archive byte limits do not cap entry count or central-directory complexity +- **File**: lib/services/import/archive_import_guard.dart:53 +- **Issue**: The guard caps decoded bytes but has no maximum number of entries, directory depth, compressed archive size, or metadata budget. +- **Evidence**: `validateArchiveEntries()` loops over the complete list and grows `seen`; zero-byte entries consume none of the 16 GiB byte budget. Before that, `ZipDirectory` and `ZipDecoder` each materialize lists of headers/files, and extraction can create one filesystem object per entry. +- **Impact**: A ZIP containing hundreds of thousands or millions of empty files can exhaust memory, CPU, filesystem metadata/inodes, or path-creation time without triggering any byte limit. +- **Suggested fix**: Add strict entry-count, path-depth, compressed-input-size, and central-directory-size limits before decoding or extraction. + +### [P2] Import's "zero-residue" guarantee does not survive process crashes +- **File**: lib/services/project_import_service.dart:193 +- **Issue**: Files are renamed from staging to the final UUID directory before the database transaction. Cleanup is best-effort `finally` compensation and cannot run after process termination. +- **Evidence**: Lines 193–199 place the directory and then call `writeAll()`. The only sweep at lines 263–268 recognizes `.import-*`, and it is called only when another import begins—not at startup. A final `projects/{uuid}` directory is never swept. +- **Impact**: A crash during extraction leaves staging indefinitely until another import. A crash after rename but before transaction commit leaves a permanent final-looking project directory with no database row. Compensation deletion failures produce the same residue. +- **Suggested fix**: Journal import state, keep the directory under a recognizable staging name until the database commit is durably recorded, and perform startup reconciliation of both staged and unbacked final directories. + +### [P2] UUID remapping omits documented grid relationship IDs inside `type_config` +- **File**: lib/services/import/import_remapper.dart:215 +- **Issue**: The remapper rewrites only `type_config.character_ids`; it leaves `parent_grid_id` and `grid_children` unchanged even though both are documented node UUID relationships and participate in schema consistency rules. +- **Evidence**: Lines 216–233 inspect and replace only `character_ids`. No other JSONB UUID fields are visited. +- **Impact**: Exporting and importing a project into the same workspace leaves grid relationships pointing at the original project's node IDs. Future grid operations could resolve or mutate unrelated original nodes; otherwise the imported project silently loses relationship integrity. +- **Suggested fix**: Define a schema-driven JSONB remapping table and rewrite/drop `parent_grid_id`, every `grid_children` element, and future UUID-bearing keys using `nodeMap`. + +### [P2] Video metadata backfill can overwrite arbitrary files within a canvas +- **File**: lib/services/video_metadata_backfill_service.dart:83 +- **Issue**: Imported/restored `thumbnail_url` is accepted as the screenshot destination without verifying its media subdirectory, extension, uniqueness, or that it differs from `video_url`. +- **Evidence**: Lines 85–94 resolve `c.thumbnailUrl` directly and pass it to `extractFirstFrame()`, whose implementation writes JPEG bytes to that path. Lexical root containment prevents `../` traversal but does not prevent same-root destructive collisions. +- **Impact**: A malformed node can set `thumbnail_url == video_url`, causing startup backfill to truncate the source video on platforms permitting overwrite of an open file. It can similarly overwrite another image or media artifact in that canvas. +- **Suggested fix**: Validate imported media-path fields, require a dedicated thumbnail path, reject source/destination identity, and publish thumbnails via exclusive temporary-file creation plus atomic rename. + +### [P2] Existing exports are deleted before replacement, defeating atomic publication +- **File**: lib/services/project_archive_service.dart:159; lib/services/diagnostics_bundle_service.dart:114 +- **Issue**: Both services delete an existing target and only then rename `.partial` into place. +- **Evidence**: Project export lines 160–162 and diagnostics lines 114–116 implement `deleteSync()` followed by `renameSync()`. If rename fails, catch cleanup deletes the partial too. +- **Impact**: A crash, antivirus race, permission change, or filesystem error in this gap can destroy the previously valid archive and leave no replacement, despite the advertised atomic-write discipline. +- **Suggested fix**: Use a platform-specific atomic replace operation or rename the old target to a rollback name until the new file is durably published. + +### [P2] Backup metadata publication fails open +- **File**: lib/services/database_backup_service.dart:272; lib/services/database_restore_service.dart:273 +- **Issue**: The dump is published before its integrity metadata. Sidecar write failure is logged but the backup is still reported as created, while restore accepts missing sidecars and accepts metadata whose hash/version fields have incorrect types. +- **Evidence**: Lines 282–297 write metadata after rename and swallow failure; `_publish()` still returns `created` at line 305. Restore returns success immediately when the sidecar is absent at line 276 and validates fields only when they happen to be correctly typed. +- **Impact**: A crash or disk error can produce a "successful" but unverified backup. Subsequent corruption or a newer-schema dump can bypass the intended hash/version gate. +- **Suggested fix**: Stream-hash first, atomically publish dump and a strict commit marker together, require all metadata fields and types, and visibly distinguish explicitly approved legacy backups. + +### [P2] Large-file paths buffer whole payloads and permit practical memory exhaustion +- **File**: lib/services/project_archive_service.dart:149; lib/services/diagnostics_bundle_service.dart:156; lib/services/database_backup_service.dart:285; lib/services/project_import_service.dart:244 +- **Issue**: The archive dependency's default DEFLATE path buffers each compressed file in memory; backup/restore hash entire dumps with `readAsBytesSync`; import materializes up to 256 MiB of JSON and then creates additional byte, UTF-8 string, and object-graph copies. +- **Evidence**: Project and diagnostics exports call `ZipFileEncoder.addFile()` without store/streaming mode. Backup and restore call `sha256.convert(file.readAsBytesSync())`. `_readJsonEntry()` uses `OutputMemoryStream`, `getBytes()`, UTF-8 decoding, and `jsonDecode`. +- **Impact**: A large project video, unbounded `pg.log`, database dump, or permitted large `data.json` can freeze or terminate the desktop process through multi-gigabyte peak allocation. +- **Suggested fix**: Store already-compressed media, use a genuinely streaming ZIP implementation, stream SHA-256, cap diagnostic input sizes, and parse import JSON with a lower bound or streaming parser. + +### [P3] Security tests miss the surviving adversarial and crash cases +- **File**: test/services/import/archive_import_guard_test.dart:37; test/services/project_import_service_test.dart:135; test/services/diagnostics_bundle_service_test.dart:86; test/services/orphan_file_reaper_test.dart:127 +- **Issue**: Tests do cover explicit traversal, normal symlink metadata rejection, decoded-byte bombs, corrupt ZIPs, transaction failure, and happy overwrite. They do not exercise an actual compressed Unix-symlink ZIP, huge zero-byte entry counts, normalized-name collisions, process death between rename/commit, grid JSONB UUIDs, raw-log credential canaries, `reap(dryRun:false)`, post-swap migration failure, or source/destination thumbnail collisions. +- **Evidence**: The symlink test invokes only the pure guard; diagnostic canaries exist only in config while seeded logs are harmless; orphan tests invoke the default argument; import compensation uses a normal thrown writer error rather than process-death barriers. +- **Impact**: The suite gives strong assurance for recoverable exceptions but cannot detect the crash, resource-exhaustion, secret-leak, and destructive-call paths above. +- **Suggested fix**: Add crafted binary fixtures and subprocess crash-injection tests with barriers immediately before/after each rename, transaction commit, target deletion, and database swap statement. diff --git a/docs/review/2026-08-31/W13.md b/docs/review/2026-08-31/W13.md new file mode 100644 index 00000000..1a3cb10a --- /dev/null +++ b/docs/review/2026-08-31/W13.md @@ -0,0 +1,77 @@ +# W13 — runtime services (ffmpeg/media/window/process/secure-storage/misc) + +**CONCLUSION**: 10 findings — P0: 0, P1: 0, P2: 4, P3: 6 + +## Findings + +### [P2] A timed-out process spawn can complete later and become an orphan +- **File**: `lib/services/process_watchdog.dart:56` +- **Issue**: `Future.timeout` does not cancel the underlying `starter.start()` future. After lines 59–62 report a spawn timeout, the original future can still complete with a running process, but its handle is discarded. +- **Evidence**: There is no continuation attached to kill or drain a late `RunningProcess`. Existing watchdog tests cover immediate `ProcessException`, execution timeout, and ineffective kill, but not delayed spawn completion. +- **Impact**: A late `pg_dump`/`pg_restore` can continue after the UI reports failure and starts cleanup, leaving an unmanaged child and undrained pipes. A restore may operate against scratch resources whose cleanup has already begun. +- **Suggested fix**: Retain the original start future and attach late-result cleanup that kills the process and drains/observes its streams, or make spawning cancellable. + +### [P2] Failed ffmpeg finalization can delete both the previous export and the new artifact +- **File**: `lib/services/ffmpeg_video_export_service.dart:167` +- **Issue**: Replacement is delete-then-rename, not atomic. Once the old output is deleted, `renameSync` can still fail; the catch then deletes the `.partial` file. +- **Evidence**: Lines 167–168 delete the final file before renaming. Lines 174–175 catch a rename failure and remove the partial. Tests preserve an old export when ffmpeg exits nonzero, but do not exercise failure after successful deletion. +- **Impact**: A target recreation race, antivirus lock, or Windows ACL/handle failure between the two operations loses the user's prior export and the newly completed render. +- **Suggested fix**: Use an atomic replace primitive where available, or rename the old file to a rollback name and restore it if committing the partial fails. Preserve the completed partial on commit failure. + +### [P2] First-launch primary-display selection can choose an adjacent secondary monitor +- **File**: `lib/services/window_state_service.dart:56` +- **Issue**: `_nearestToOrigin` measures distance to closed display rectangles. A monitor immediately left or above the primary touches `(0,0)` and therefore ties—or beats a primary visible frame inset by a menu bar. +- **Evidence**: For a left display `(-1280,0,1280,800)`, lines 60–62 calculate distance zero because `right == 0`. A primary frame `(0,25,1440,875)` has distance `25²`, so the secondary wins. The current multi-monitor test only places the secondary to the right. +- **Impact**: On first launch, an oversized default window can be centered and shrunk on the wrong monitor, or incorrectly judged to fit based on another monitor. +- **Suggested fix**: Query the actual primary display through `screenRetriever.getPrimaryDisplay`, or add an unambiguous primary-display seam instead of inferring it from rectangle distance. + +### [P2] Character asset failures escape the InkError boundary +- **File**: `lib/services/character_asset_service.dart:34` +- **Issue**: Missing sources and invalid paths throw `CharacterAssetError`, while directory creation/copy can expose raw `FileSystemException`. +- **Evidence**: `CharactersController` rethrows both raw types. The two character-section import handlers catch only `InkError`; unlike the gallery handler, they do not catch these exceptions. +- **Impact**: If a selected file disappears, permissions deny copying, or the disk fills, the import escapes to the global uncaught-error hook instead of producing the localized failure message. This violates the documented service-level InkError contract. +- **Suggested fix**: Translate these failures to `LocalIOError` at the service boundary and update the tests to require InkError. + +### [P3] The terminal progress callback can turn a successful export into a raw failure +- **File**: `lib/services/ffmpeg_video_export_service.dart:170` +- **Issue**: The final `onProgress(1.0)` call occurs after the inner callback-error guard and after the partial has been committed. +- **Evidence**: Progress callbacks from stdout are caught at lines 115–139, but the terminal callback at line 171 is protected only by an outer `FileSystemException` catch. +- **Impact**: A disposed or otherwise throwing listener receives a raw exception even though the final MP4 already replaced the target. The UI can report failure and encourage a redundant retry. +- **Suggested fix**: Guard terminal notification consistently; once the artifact is committed, listener failure should be logged without changing export success. + +### [P3] An invalid custom-provider entry can suppress a later valid entry +- **File**: `lib/services/custom_providers_file_service.dart:126` +- **Issue**: The ID is added to `seenIds` before template and base-URL validation. Those rejection paths do not remove it. +- **Evidence**: An invalid-template entry with ID `foo`, followed by a fully valid `foo`, causes the latter to be rejected as `duplicate_id`. Only the reserved-provider rejection explicitly removes the ID. +- **Impact**: Contrary to the "reject only the bad entry" fallback contract, one malformed entry can disable a valid provider later in the file. It remains startup-safe and does not execute configuration content. +- **Suggested fix**: Add the ID only after all entry validation succeeds, or remove it on every later rejection path. + +### [P3] UPD-1 computes the maximum SemVer from only ten releases +- **File**: `lib/services/github_update_check_service.dart:30` +- **Issue**: The service requests one page with `per_page=10` and never follows pagination. +- **Evidence**: GitHub orders releases by creation time, not semantic version. A higher version can fall outside the first page after later-created maintenance or backfilled releases. +- **Impact**: The app can incorrectly report "up to date" and miss an available release, including a security update. +- **Suggested fix**: Paginate through the releases endpoint, or at minimum request 100 and follow the `Link` header until exhausted. + +### [P3] A logger failure prevents the independent crash report from being attempted +- **File**: `lib/services/error_hooks.dart:54` +- **Issue**: Logging, crash reporting, and flush share one `try` block. +- **Evidence**: If `logger.error` throws at line 55—such as from a permissions or logging-directory failure—execution jumps to line 60 before `reporter.report` runs. Tests cover a throwing reporter, not a throwing logger. +- **Impact**: A logging-path failure can eliminate both diagnostic channels even when the separate crashes directory remains writable. +- **Suggested fix**: Guard logger, reporter, and flush independently. + +### [P3] Preferences writes can corrupt the only persisted copy +- **File**: `lib/services/file_preferences_service.dart:60` +- **Issue**: Updates write directly to `preferences.json`; `flush: true` provides durability but not atomicity. +- **Evidence**: `writeAsString` opens/truncates the destination before completing the write. A crash or write failure can leave truncated JSON, which the next load silently replaces with defaults. +- **Impact**: Window state, onboarding status, locale/theme, last-session pointers, and update preferences can all be lost after an interrupted write. +- **Suggested fix**: Write and flush a sibling partial file, then atomically rename it over the destination while preserving the old file on failure. + +### [P3] Native runtime seams and teardown failure continuation lack direct coverage +- **File**: `lib/services/media_kit_video_player_service.dart:7`; `lib/services/media_kit_thumbnail_service.dart:15`; `lib/services/window_manager_adapters.dart:13`; `lib/services/system_process_runner.dart:8`; `lib/services/system_folder_opener.dart:13`; `lib/services/app_teardown.dart:29` +- **Issue**: There are no direct implementation tests for the media-kit services, window adapters, system process runner, or folder opener. Teardown tests cover ordering, in-flight initialization, and idempotence, but never make JobQueue disposal, pool close, or PG stop throw. +- **Evidence**: No matching concrete-service tests exist; the optional ffmpeg integration test only indirectly exercises part of `SystemProcessRunner`. +- **Impact**: Native initialization, stdin closure, stderr draining, platform command selection, plugin field mapping, and teardown failure-continuation regressions can reach Windows/macOS without CI detection. +- **Suggested fix**: Add injectable native factories/adapters and contract tests, plus teardown tests that independently fail every stage and assert later stages still run. + +Clean sub-areas: process invocations use executable-plus-argument APIs rather than shells, so no shell command injection survived for ffmpeg, URL opening, or folder opening. File resolution rejects traversal and control-character list injection. The plaintext secure store's only production construction is correctly guarded by `kDebugMode && Platform.isMacOS`. Custom-provider corruption cannot execute content or crash normal startup. Teardown implementation order is window capture → JobQueue → pool → PostgreSQL, with thrown failures isolated so later steps continue. DI lifetimes and Windows/macOS command guards are otherwise consistent. diff --git a/docs/review/2026-08-31/W14.md b/docs/review/2026-08-31/W14.md new file mode 100644 index 00000000..d8d7f0ec --- /dev/null +++ b/docs/review/2026-08-31/W14.md @@ -0,0 +1,98 @@ +# W14 — i18n hygiene + design-token hygiene + CI/scripts + dependency hygiene + +**CONCLUSION**: 9 findings — P0: 0, P1: 3, P2: 3, P3: 3. ARB parity is currently 423/423 and generated l10n is current; the pre-push commands match CLAUDE.md. No `any`/wildcard pub constraints were found, and the tracked lockfile pins hosted packages with checksums. + +## Findings + +### [P1] CI checks are not required by branch protection +- **File**: docs/BOARD.md:113; docs/CLAUDE.md:364; .github/workflows/ci.yml:23; .github/workflows/secret-scan.yml:10 +- **Issue**: The workflows run blocking jobs, but BOARD—the declared current source of truth—states that branch protection does not require the five checks and a PR can merge while red. This contradicts CLAUDE.md's statement that `main` is protected. +- **Evidence**: BOARD records: `分支保护当前不强制五项检查,红着合得进去`. Nothing in the repository itself can make `analyze`, `test`, `golden`, `release-scripts`, or `gitleaks` required checks. +- **Impact**: Analyze, tests, golden regressions, or secret scanning can fail or be skipped without preventing merge to `main`. +- **Suggested fix**: Configure the GitHub ruleset to require all five named status checks, require the branch to be up to date, and prevent bypass except for an explicit emergency process. + +### [P1] Release credentials are exposed to mutable, unpinned tooling +- **File**: .github/workflows/release.yml:44, .github/workflows/release.yml:76, .github/workflows/release.yml:119, .github/workflows/release.yml:168; scripts/release/package-macos-dmg.sh:17; scripts/pg/fetch-binaries.sh:20 +- **Issue**: Signing and notarization secrets are defined at job scope, so every action and shell command receives them. The same jobs install and execute mutable dependencies not covered by `pubspec.lock`: current Homebrew `postgresql@17`, current `create-dmg`, and an unversioned `flutter_distributor`. +- **Evidence**: + - `brew install "postgresql@$(...)""` is pinned only to major version; macOS validation accepts any `17.x`. + - `brew install create-dmg` + - `dart pub global activate flutter_distributor` + - `WINDOWS_CERT_*`, `MACOS_CERT_*`, and Apple credentials are job-level environment variables. +- **Impact**: A compromised or unexpectedly changed tool/formula can alter release artifacts or exfiltrate signing credentials. Rebuilding the same tag can also produce a different PostgreSQL/crypto-library closure. +- **Suggested fix**: Scope secrets only to signing/notarization steps, use repository-locked tool manifests with exact versions/checksums, and pin external actions/tool sources immutably. + +### [P1] Vendored macOS PostgreSQL dependency licenses are omitted +- **File**: scripts/pg/make-relocatable-macos.sh:6, scripts/pg/make-relocatable-macos.sh:91; lib/core/licenses.dart:45; THIRD-PARTY.md:48 +- **Issue**: The relocator recursively copies external dylibs—including ICU, OpenSSL, Kerberos, LZ4, Zstandard, and gettext/libintl—into the application. LicenseRegistry and THIRD-PARTY.md register only PostgreSQL itself. +- **Evidence**: The script explicitly documents and vendors the dependency closure with `cp -L "$abs" "$VENDOR_DIR/$base"`. The shipped license assets contain only LGPL-2.1, PostgreSQL, and the two font licenses. +- **Impact**: Distributed macOS artifacts can omit copyright notices, license texts, and potentially LGPL source/relinking information required by the bundled libraries. +- **Suggested fix**: Generate an exact native-library/license manifest during packaging, ship every required notice/license, register them with LicenseRegistry, and add a CI assertion that every vendored dylib has an inventory entry. + +### [P2] Golden guard can silently lose an entire test suite +- **File**: test/app/empty_states_golden_test.dart:45; .github/workflows/ci.yml:134; docs/BOARD.md:185 +- **Issue**: All five empty-state goldens use the existence of only `studio_empty.png` as their shared sentinel. CI fails only when the total number of executed golden tests is zero. +- **Evidence**: Removing `studio_empty.png` skips all five app golden tests. The three node-card goldens still run, making `ran > 0`, so the guard passes despite five skipped tests and four remaining app baselines. +- **Impact**: A baseline deletion or accidental sentinel change can remove broad visual-regression coverage while CI remains green. +- **Suggested fix**: Validate each golden test/baseline pair via an explicit manifest, or fail on sentinel-driven skips whenever that suite already has committed baselines. + +### [P2] `custom_lint` remains both disabled and non-blocking +- **File**: .github/workflows/ci.yml:52; analysis_options.yaml:1; pubspec.yaml:73; docs/BOARD.md:161 +- **Issue**: CI uses `continue-on-error: true`, while `analysis_options.yaml` does not enable the `custom_lint` analyzer plugin. Therefore `riverpod_lint` rules are not an enforced gate. +- **Evidence**: + ```yaml + - name: custom_lint + continue-on-error: true + run: dart run custom_lint + ``` + The fallback text-based quality tests cover selected patterns, but not all AST-level Riverpod or feature-boundary rules; BOARD still records 18 cross-feature import violations awaiting this lint gate. +- **Impact**: Custom-lint crashes, disabled rules, and new Riverpod/architecture violations do not fail CI. +- **Suggested fix**: Upgrade to compatible versions, enable the plugin, establish a clean baseline, then remove `continue-on-error`. + +### [P2] Visual size tokens are systematically bypassed +- **File**: lib/features/canvas/widgets/batch_results_grid.dart:163; lib/features/canvas/widgets/canvas_empty_state.dart:131; lib/features/canvas/widgets/canvas_top_chrome.dart:142; lib/features/gallery/widgets/gallery_screen.dart:223; lib/features/canvas/widgets/inspector_status_panel.dart:177; lib/features/studio/studio_home_screen.dart:546; lib/theme/primitives/ink_surface_button.dart:64 +- **Issue**: There are 48 raw numeric `Icon.size` values across 25 files, plus 10 raw `letterSpacing`, progress `strokeWidth`, or micro-spacing values across seven files. +- **Evidence**: + ```dart + Icon(icon, color: colors.fg3, size: 20) + Icon(Icons.arrow_back, size: 14, ...) + Icon(Icons.filter_alt_off_outlined, size: 32, ...) + CircularProgressIndicator(strokeWidth: 2) + letterSpacing: 1.5 + ``` + The icon pattern repeats in 18 more files: `canvas_view.dart`, `characters_section.dart`, `image_config_inspector.dart`, `inspector_chip.dart`, `node_card.dart`, `node_inputs_section.dart`, `video_node_body.dart`, `command_palette_dialog.dart`, `gallery_image_lightbox.dart`, `gallery_tile.dart`, `built_in_showcase_screen.dart`, `library_sidebar.dart`, `project_card.dart`, `studio_top_chrome.dart`, `ink_window_chrome.dart`, `ink_accent_chip.dart`, `ink_amber_button.dart`, and `ink_ghost_button.dart`. +- **Impact**: Icon scale, progress weight, and typography tracking cannot be updated consistently through the token system. The current inline-style test does not cover these properties. +- **Suggested fix**: Add semantic icon/progress-size tokens, use typography styles for tracking, replace the literals, and extend `no_inline_styles_test.dart`. + +### [P3] Native file-dialog labels bypass localization +- **File**: lib/core/di/project_archive.dart:29, lib/core/di/project_archive.dart:48; lib/features/canvas/widgets/characters_section.dart:206 +- **Issue**: Native file-selector type-group names are hardcoded as `zip` and `images`. +- **Evidence**: + ```dart + XTypeGroup(label: 'zip', extensions: ['zip']) + XTypeGroup(label: 'images', extensions: ...) + ``` + These labels identify filters in the platform file chooser; they are not logs, protocol values, error identifiers, or LLM prompts. +- **Impact**: Chinese users can encounter English filter names inside otherwise localized native dialogs. +- **Suggested fix**: Add ARB keys and pass localized filter labels into the picker abstraction. + +### [P3] Six direct dependencies are currently unused +- **File**: pubspec.yaml:18, pubspec.yaml:22, pubspec.yaml:28, pubspec.yaml:35, pubspec.yaml:69, pubspec.yaml:73 +- **Issue**: Repository-wide import/configuration checks found no active use of `riverpod_annotation`, direct `json_annotation`, `logging`, `cupertino_icons`, `riverpod_generator`, or `riverpod_lint`. +- **Evidence**: + - No `package:riverpod_annotation`, `package:json_annotation`, `package:logging`, or `package:cupertino_icons` imports. + - No `@riverpod` declarations. + - `JsonKey` is obtained through `freezed_annotation`'s re-export. + - `riverpod_lint` is not enabled in analyzer configuration. +- **Impact**: These declarations enlarge and obscure the resolved toolchain; the dormant Riverpod generator/lint stack also participates in the documented analyzer/build-runner incompatibility. +- **Suggested fix**: Remove dormant dependencies now and reintroduce compatible versions atomically if the planned Riverpod codegen migration proceeds. + +### [P3] One Chinese translation retains an untranslated product term +- **File**: lib/l10n/app_zh.arb:292; lib/l10n/generated/app_localizations_zh.dart:671 +- **Issue**: `inspectorShotParamHint` leaves "provider" in English, unlike the rest of the Chinese catalog, which consistently uses "服务商". +- **Evidence**: + ```json + "inspectorShotParamHint": "这里记的是意图——真正能不能做到,由生成时选的 provider 决定。" + ``` +- **Impact**: The Chinese Inspector displays mixed-language terminology and regresses the terminology consistency established by the other provider-related strings. +- **Suggested fix**: Change it to `由生成时选择的服务商决定` and rerun `flutter gen-l10n`. diff --git a/docs/review/2026-08-31/W15.md b/docs/review/2026-08-31/W15.md new file mode 100644 index 00000000..fb8b44e7 --- /dev/null +++ b/docs/review/2026-08-31/W15.md @@ -0,0 +1,68 @@ +# W15 — test suite quality & coverage + +**CONCLUSION**: 9 findings: 0 P0–P1 / 5 P2 / 4 P3. The suite is broad and most sampled APIs have meaningful assertions. Golden tagging and `test/quality/` CI execution are correct, and no golden history suggested a baseline was regenerated to conceal a bug. + +## Findings + +### [P2] Several public production paths have no corresponding behavioral test +- **File**: `lib/core/di/video_backfill.dart:21`; `lib/storage/repositories/postgres_video_backfill_repository.dart:16`; `lib/services/system_folder_opener.dart:19`; `lib/services/window_manager_adapters.dart:17` +- **Issue**: The documented "every public method has a test" rule is not met. +- **Evidence**: A systematic 20-API sample covered core, providers, services, storage, and features; 17 had substantive tests. The three sampled gaps were `videoBackfillStartupProvider`, `PostgresVideoBackfillRepository.listMissingDuration`, and `SystemFolderOpener.open`. Repository-wide test searches found no reference to these implementations. The backfill service is tested with a fake repository, but its production SQL—live result nodes, non-empty `video_url`, missing `duration_ms`, soft-delete filtering—is never exercised. `ScreenRetrieverDisplayQuery.visibleFrames` and `WindowManagerWindowController` are additional unsampled gaps; the source explicitly says the adapter has no unit test. +- **Impact**: Backfill could silently select no records or incorrect records, startup could swallow a wiring failure forever, and platform folder/window adapters could pass incorrect commands or display geometry without failing CI. +- **Suggested fix**: Add provider-container tests for all startup branches, a PG integration test for `listMissingDuration`, a capturing `ProcessRunner` test for folder opening, and extract display conversion into a pure tested function. + +### [P2] Real-response coverage for OpenAI and Stability is permanently skipped +- **File**: `test/providers/openai_image_provider_test.dart:52`; `test/providers/openai_image_provider_test.dart:682`; `test/providers/stability_image_core_provider_test.dart:499` +- **Issue**: The exact provider tests most likely to expose API-response drift are disabled; successful responses are instead represented by hand-written mocks. +- **Evidence**: OpenAI's unit path constructs `{created, data: [{b64_json: ...}]}` locally. Its four "fixture-replay E2E" tests are skipped under `BLOCKED-pending-key`, and the referenced success/content-policy/rate-limit/models fixtures do not exist. Stability's corresponding success fixture group is empty and skipped. Only invalid-key fixtures were captured from the real APIs. +- **Impact**: A changed success payload, content-filter response, binary encoding, or model-list schema could break both providers in production while all active tests remain green. +- **Suggested fix**: Capture and sanitize real success/error payloads, commit them as fixtures, remove the skips, and retain hand-written mocks only for narrowly isolated unit branches. + +### [P2] `test/e2e/` does not exercise a cross-feature user journey +- **File**: `test/e2e/generation_pipeline_e2e_test.dart:1`; `test/e2e/generation_pipeline_e2e_test.dart:195`; `test/e2e/generation_render_node_e2e_test.dart:1`; `test/e2e/real_pg_stack_e2e_test.dart:43`; `.github/workflows/ci.yml:83` +- **Issue**: The directory contains integration slices, not an end-to-end workflow such as create project → add nodes → generate → render → export. +- **Evidence**: The generation test manually seeds a result node and pending job, bypasses `GenerationController`, and calls `JobQueueService` directly. The rendering test independently writes a PNG and seeds a `CanvasNode`; it does not consume the output of the pipeline test. The two real-PG files test database bootstrap/backup/restore/upgrade rather than UI or cross-feature behavior. They skip unless `TEST_REAL_PG=1`, and no CI workflow sets it. Likewise the real ffmpeg test skips because CI does not set `TEST_FFMPEG`. +- **Impact**: Broken routing, DI wiring, project/node creation, generation-controller orchestration, gallery refresh, or export handoff can ship even though the "E2E" directory is green. +- **Suggested fix**: Add one full application-level journey with a fake network provider but real controllers/repositories, plus a scheduled or release-gated job for the real binary tests. + +### [P2] Golden coverage is far below the documented critical-widget matrix +- **File**: `docs/TESTING.md:331`; `test/features/canvas/widgets/node_card_golden_test.dart:77`; `test/app/empty_states_golden_test.dart:83` +- **Issue**: Only two golden test files and eight PNG baselines exist. +- **Evidence**: The documented rule requires every node state and every `InkButton`, `InkCard`, and `InkInput` variant. Node goldens cover only idle, selected, and link-source—not generating, error, or success. There are no component-variant goldens. Settings has one full-screen viewport rather than coverage of its individual sections/states. +- **Impact**: Visual regressions in the most important progress, error, success, and design-system states will not be caught by the Ubuntu pixel gate. +- **Suggested fix**: Build parameterized golden matrices for node states and component variants, then add focused settings-section scenes. + +### [P3] A single missing sentinel baseline can silently disable an entire golden file +- **File**: `test/app/empty_states_golden_test.dart:45`; `test/features/canvas/widgets/node_card_golden_test.dart:31`; `.github/workflows/ci.yml:133` +- **Issue**: Each file checks only one sentinel PNG and uses that result to skip every test in the file. CI fails only when the global count of executed golden tests is zero. +- **Evidence**: Deleting `studio_empty.png` skips all five app goldens, but the three node-card tests still make `ran > 0`, so the CI guard passes. Deleting `node_card_idle.png` similarly skips all three node goldens while the app goldens satisfy the guard. +- **Impact**: Accidental deletion of one sentinel can remove three or five visual checks without any red CI signal. +- **Suggested fix**: Validate every expected baseline individually or make CI fail on unexpected skipped golden test names. + +### [P2] Several async tests depend on wall-clock timing and scheduler luck +- **File**: `test/services/job_queue_service_test.dart:649`; `test/services/job_queue_service_logging_test.dart:136`; `test/features/canvas/providers/inspector_submit_controller_test.dart:174`; `test/core/logging/logger_service_test.dart:388`; `test/features/gallery/widgets/gallery_tile_test.dart:142` +- **Issue**: Non-zero `Future.delayed` calls are used to wait for internal state, despite the project's explicit completer/event synchronization rule. +- **Evidence**: The running-job cancellation test waits 30 ms and then expects `provider.cancel` to have been reached; a slow runner may still have the job pending. Other tests wait 10–100 ms for polling, background log reclamation, image errors, or debounce completion. The queue also has three wall-clock performance assertions requiring 10,000 operations in under 500 ms (`test/services/job_queue_service_test.dart:1215`), including an unseeded random order. +- **Impact**: Loaded CI runners can generate sporadic failures unrelated to product behavior; overly fast or slow scheduling can also exercise a different branch than the test name claims. +- **Suggested fix**: Use explicit reached-state completers, `fakeAsync` for debounce/timers, and observable completion futures. Move timing benchmarks out of blocking correctness CI. + +### [P3] One default test explicitly contains no assertions +- **File**: `test/services/job_queue_service_cancel_bench_test.dart:1` +- **Issue**: The diagnostic benchmark is named `_test.dart`, runs in the normal suite, and explicitly promises no assertions. +- **Evidence**: It performs 33,330 submit/cancel cycles across sizes and random patterns, then only prints timing. A no-op `cancel` implementation would still pass provided it did not throw. Separate assertion-bearing performance guards already exist in `job_queue_service_test.dart`. +- **Impact**: It adds runtime and line coverage while providing no behavioral signal, inflating the apparent size and coverage of the trustworthy suite. +- **Suggested fix**: Move it to a non-test benchmark script or add a meaningful invariant and exclude benchmark execution from coverage accounting. + +### [P3] The enforced "no magic strings" quality gate checks only one narrow syntax +- **File**: `test/quality/no_magic_strings_test.dart:1`; `scripts/hooks/pre-commit:28`; `.github/workflows/ci.yml:43` +- **Issue**: `test/quality/` is genuinely enforced in pre-commit and CI, but this particular gate does not enforce the project's zero-hardcoded-UI-string rule. +- **Evidence**: Its only matcher detects a literal prefix immediately followed by `${l.*}`, `${context.l10n.*}`, or `${loc.*}`. The file explicitly declines to detect plain literals. A regression such as `Text('Delete project')`, a literal tooltip, or a hardcoded suffix passes this gate and normal Flutter analysis. +- **Impact**: Untranslated user-visible English can ship while the quality job reports that the magic-string discipline passed. +- **Suggested fix**: Use an AST-aware widget-property check, backed by positive and negative fixture tests for `Text`, tooltips, labels, hints, snackbars, and interpolation variants. + +### [P3] The mandatory shared provider contract suite does not exist +- **File**: `docs/TESTING.md:291`; `test/providers/openai_image_provider_test.dart:3` +- **Issue**: Documentation and test comments claim every concrete provider runs `providerContractSuite(factory)`, but there is no definition or invocation of that symbol anywhere in the repository. +- **Evidence**: All providers have adapter-specific tests—some very thorough—but there is no reusable suite enforcing common invariants across every implementation. +- **Impact**: A new or modified adapter can mismatch capability flags and implemented interfaces, expose an inconsistent provider ID, or violate common submit/poll/cancel/key-validation behavior without a uniform regression test catching it. +- **Suggested fix**: Implement the suite in `test/_harness/` and invoke it for every concrete provider factory. diff --git a/docs/review/2026-08-31/W16.md b/docs/review/2026-08-31/W16.md new file mode 100644 index 00000000..66a972bb --- /dev/null +++ b/docs/review/2026-08-31/W16.md @@ -0,0 +1,55 @@ +# W16 — documentation vs. code reality drift + +**CONCLUSION**: 8 drift findings, split by severity: P0=0, P1=6, P2=2, P3=0. `docs/CLAUDE.md` matched the actual `lib/` structure exactly: 61 documented directories, 61 present, with no additions or missing directories. + +## Claims checked (tally) +- Checked: 34 · Drift found: 8 · Held up: 26 + +## Findings +### [P1] Advanced image parameters exist, but the claimed regression coverage does not exercise their UI-to-generation path +- **Doc claim**: "节点级实时进度 + 高级图像参数(宽高比/负向/种子/批量)… `+ 测试`" — docs/BOARD.md:21 +- **Reality**: The controls and persistence handlers exist in image_config_inspector.dart:306. The widget tests only assert control visibility and hydration of seed/negative prompt at image_config_inspector_test.dart:219. The controller test starts with an already-seeded `type_config` and checks seed/negative/batch propagation at generation_controller_test.dart:846; it does not exercise UI edits, and no test covers `aspect_ratio` propagation. +- **Gap**: Removing an `onChanged` persistence handler—or dropping aspect ratio between the node and `GenerationTask`—would not fail the cited tests. +- **Suggested fix**: Add a widget-to-repository test for ratio, negative prompt, seed, and batch edits, plus a controller assertion for `aspectRatio`. + +### [P1] The selection-rebuild performance guarantee has no behavior-specific test +- **Doc claim**: "画布丝滑(改选中只重建涉及卡片)… `+ 测试`" — docs/BOARD.md:22 +- **Reality**: The implementation uses a per-node Riverpod `.select` in `_NodeCardSlot`, which supports the claim, at canvas_view.dart:1001. The performance suite only measures initial construction of 400 nodes and edge hit-testing at canvas_scale_perf_test.dart:143; it never changes selection or counts rebuilt cards. +- **Gap**: Replacing the per-card selector with a whole-stage selection watch would preserve all current tests while violating the documented guarantee. +- **Suggested fix**: Add an instrumented widget test that changes selection and asserts only the old/new selected card slots rebuild. + +### [P1] Video Inspector cost wiring is implemented but untested +- **Doc claim**: "视频 Inspector 接成本" marked ✅ — docs/BOARD.md:39 +- **Reality**: `VideoConfigInspector` calls `estimateCostUsd` with the selected duration at video_config_inspector.dart:278. Pure estimator tests cover per-second arithmetic at cost_estimator_test.dart:23, but the video widget suite's cases at video_config_inspector_test.dart:113 never assert that an estimate is rendered or updated. +- **Gap**: Deleting the cost block from the Video Inspector would leave the estimator tests and all video widget tests green. +- **Suggested fix**: Add a widget test using a per-second cost model, select a duration, and assert the exact displayed estimate. + +### [P1] File-selector reference-image import lacks coverage of the user-visible picker path +- **Doc claim**: "文件系统导入参考图(file_selector)" marked ✅ — docs/BOARD.md:40 +- **Reality**: `CharactersSection` calls the top-level `openFile` and forwards the selected path to `createFromImage` at characters_section.dart:201. Tests separately cover controller import at characters_controller_test.dart:55 and file copying at character_asset_service_test.dart:21, but no test invokes the picker action or verifies picker result → naming dialog → character attachment. +- **Gap**: The button could become disconnected, reject valid picker results, or stop attaching the created character while all downstream tests remained green. +- **Suggested fix**: Inject the picker callback and add a widget test covering selection, naming, import, and attachment. + +### [P1] Gallery video playback is explicitly missing happy-path regression coverage +- **Doc claim**: "视频缩略图/播放(GA-1/2/7…)…已随 Polish Wave 1 落地" — docs/BOARD.md:49 +- **Reality**: Existing-file playback calls the shared video lightbox at gallery_tile.dart:201. The test file explicitly says the successful playback path is not covered at gallery_tile_test.dart:1, and its only click test uses a missing video and expects no dialog at gallery_tile_test.dart:270. +- **Gap**: A regression preventing valid videos from opening or playing would not be caught. +- **Suggested fix**: Add an injectable lightbox/player seam and test that clicking an existing video opens playback and releases its handle. + +### [P1] Custom-provider startup registration is only tested as two disconnected halves +- **Doc claim**: "`custom_providers.json`…启动期一次性注册(改 json 重启生效)" — docs/BOARD.md:50 +- **Reality**: Production bootstrap loads the file and overrides `customProviderSourceProvider` at main.dart:167. File parsing is tested independently at custom_providers_file_service_test.dart:69, while registry tests inject a pre-populated fake source directly at custom_provider_registration_test.dart:42. +- **Gap**: Removing either `customProviders.load()` or the production container override would disable custom providers in the app while both suites stayed green. +- **Suggested fix**: Add a bootstrap-level test: write a temporary JSON file, build the production-style container, and assert the custom provider ID is registered. + +### [P2] M2's character-consistency scope note is stale and contradicts the current video implementation +- **Doc claim**: "仅 image 节点、maxRefImages>0 且 imageToImage 生效" — docs/BOARD.md:35 +- **Reality**: The shared rule now supports images under that condition but supports video whenever `maxRefImages > 0`, at provider_capabilities.dart:72 and generation_controller.dart:655. Video injection and the zero-reference case are regression-tested at generation_controller_video_test.dart:417. BOARD itself acknowledges video coverage in the later M2 row at docs/BOARD.md:39. +- **Gap**: The same M2 table simultaneously describes character injection as image-only and video-capable. +- **Suggested fix**: Update row 35 to state the current image and video capability rules separately. + +### [P2] MASTERPLAN still describes landed SB-6/EX-1′ work as merely remaining or unlocked +- **Doc claim**: E4 says "剩 EX-1′" at docs/MASTERPLAN.md:87, and the wave header says "EX-1′/SB-6 前置已解锁" at docs/MASTERPLAN.md:93. +- **Reality**: BOARD marks SB-6 landed at docs/BOARD.md:47 and EX-1′ landed at docs/BOARD.md:48. Narrative export ordering is implemented at export_order.dart:38 and tested at export_order_test.dart:53; sequence playback is implemented at sequence_preview_dialog.dart:86 and tested through real-progress/fallback behavior at sequence_preview_dialog_test.dart:244. +- **Gap**: The forward task list contradicts the single-source BOARD and current code by presenting completed work as pending. +- **Suggested fix**: Mark EX-1′ and SB-6 complete in §2 and remove "剩/前置已解锁" wording. diff --git a/docs/review/2026-08-31/W17.md b/docs/review/2026-08-31/W17.md new file mode 100644 index 00000000..b8a38864 --- /dev/null +++ b/docs/review/2026-08-31/W17.md @@ -0,0 +1,161 @@ +# W17 — UI/UX static heuristic walkthrough (code-based) + +**CONCLUSION**: 26 findings: 2 P0, 17 P1, 7 P2, 0 P3. This was a static source review; the app was not run. + +## Findings + +### [P0] Inspector autosave can silently discard the latest edit +- **Screen/flow**: Canvas image/video/shot inspectors — lib/features/canvas/widgets/image_config_inspector.dart:132-158, video_config_inspector.dart:84-117, shot_config_inspector.dart:74-104, lib/features/canvas/providers/inspector_submit_controller.dart:84-121 +- **Heuristic violated**: feedback/safeguards +- **What a user would experience**: Prompt and shot-note changes wait 500 ms before saving. Selecting another node disposes the auto-dispose controller or widget and cancels that timer instead of flushing it. A user who types and immediately changes selection can return to find the last edit gone. Persistence exceptions are also swallowed at inspector_submit_controller.dart:105-113, so a failed save still appears successful. +- **Suggested fix**: Flush pending edits on focus loss, selection change, and disposal; expose saving/saved/error state with retry. + +### [P0] Project import disappears when the Studio contains no projects +- **Screen/flow**: Studio empty state — lib/features/studio/studio_home_screen.dart:85-88, :119-150, :375-444; command palette — lib/features/command_palette/command_actions.dart:95-100 +- **Heuristic violated**: discoverability +- **What a user would experience**: The Import project button is inside a FAB row rendered only when the project list is nonempty. The zero-project state offers New project, Sample project, and Showcase, while the Studio command palette offers only Showcase and Settings. Someone whose only InkFrame content is an archive cannot import it without first creating an unrelated dummy project. +- **Suggested fix**: Add Import project to the empty-state CTAs and Studio command palette, or keep the import control visible regardless of project count. + +### [P1] Script import remains dismissible while the import is running +- **Screen/flow**: Storyboard script import — lib/features/storyboard/widgets/script_import_dialog.dart:19-27, :60-82, :105-110, :139-147 +- **Heuristic violated**: feedback/safeguards +- **What a user would experience**: `_busy` disables the two dialog buttons, but the dialog retains the default dismissible barrier and has no `PopScope`. Clicking outside or pressing Escape closes it while the import continues. Successful completion then calls the previously captured `navigator.pop()` again, which can pop the route currently on top. The text field also remains editable and no spinner or "Importing…" state is shown. +- **Suggested fix**: Make the dialog non-dismissible while busy, disable all inputs, identify the dialog route before popping it, and show progress. + +### [P1] Major custom buttons are not keyboard focusable +- **Screen/flow**: Studio, Gallery, app chrome — lib/theme/primitives/ink_amber_button.dart:58-70, ink_ghost_button.dart:32-68, lib/features/studio/studio_home_screen.dart:143-155, :427-443, lib/theme/components/ink_window_chrome.dart:118-138, lib/features/studio/widgets/studio_top_chrome.dart:143-179 +- **Heuristic violated**: accessibility +- **What a user would experience**: These controls use `GestureDetector` inside `Semantics` but no focusable action widget. Tab navigation cannot reach primary actions such as New project, Import, Sample project, Settings, or the custom window controls. A semantic label alone does not supply desktop keyboard focus or Enter/Space activation. +- **Suggested fix**: Use Material buttons/`InkWell`, or add `FocusableActionDetector`, `ActivateIntent`, focus visuals, and deliberate traversal order. + +### [P1] Canvas nodes and Gallery previews are pointer-only content +- **Screen/flow**: Canvas node cards — lib/features/canvas/widgets/node_card.dart:139-158, :198-213; Gallery tiles — lib/features/gallery/widgets/gallery_tile.dart:137-198 +- **Heuristic violated**: accessibility +- **What a user would experience**: Node selection and media opening are implemented with bare `GestureDetector`s without focus or interactive semantics. Keyboard users cannot select a node, expose its link/delete anchors, open an image, or play a Gallery video. Screen readers also are not told that these previews are actionable. +- **Suggested fix**: Give cards/previews semantic labels and button state, make them focusable, and support Enter/Space. Provide keyboard alternatives for drag/link operations. + +### [P1] Select-all and viewport controls are shortcut-only +- **Screen/flow**: Canvas shortcuts and visible overlays — lib/features/canvas/widgets/canvas_shortcuts.dart:83-127, lib/features/canvas/widgets/canvas_view.dart:334-363, lib/features/canvas/widgets/canvas_screen.dart:31-55 +- **Heuristic violated**: shortcuts/discoverability +- **What a user would experience**: Ctrl/Cmd+A, zoom in/out, and zoom reset are registered, but the visible Canvas UI contains only error/link/selection/lane overlays—no zoom controls, reset action, shortcut HUD, or command-palette equivalents. Unlike Ctrl/Cmd+K, these shortcuts are never surfaced. +- **Suggested fix**: Restore the mockup's compact viewport/shortcut HUD or add visible zoom/reset controls and matching command-palette actions. + +### [P1] Canvas load failure has no recovery action +- **Screen/flow**: Canvas loading error — lib/features/canvas/widgets/canvas_view.dart:65-74, :219-248 +- **Heuristic violated**: feedback/error-clarity +- **What a user would experience**: A load failure replaces the canvas with a title and localized error message, but there is no Retry button. A transient repository failure leaves the user unable to recover in place; they must navigate away and reopen the canvas. +- **Suggested fix**: Add Retry that invalidates the nodes provider, following the Gallery and Studio error-state pattern. + +### [P1] Inspector unlink bypasses the PL-4a edge undo guard and hides failures +- **Screen/flow**: Canvas node inputs — lib/features/canvas/widgets/node_inputs_section.dart:201-220; direct Canvas edge deletion — lib/features/canvas/widgets/canvas_view.dart:390-409 +- **Heuristic violated**: safeguards/feedback/consistency +- **What a user would experience**: Removing an input immediately deletes the edge with no confirmation or Undo, and any failure is swallowed. Edge-role update failures are also silent. By contrast, deleting the same edge directly on the canvas shows "Deleted · Undo." Users can unknowingly remove a connection through one surface and receive no explanation when another edit fails. +- **Suggested fix**: Route inspector unlink through the shared edge-delete-with-undo path and surface role/update failures. + +### [P1] Video lightbox failures look like permanent loading +- **Screen/flow**: Canvas/Gallery video lightbox — lib/features/canvas/widgets/video_lightbox.dart:45-59, :68-84 +- **Heuristic violated**: feedback/error-clarity +- **What a user would experience**: `open()` has no error handler, and the only non-ready UI is a spinner. A missing, corrupt, or unsupported video leaves the lightbox spinning forever. The same happens when the player's raw object is not a `Player`, even if `open()` completes. +- **Suggested fix**: Track loading, ready, and failed states; show a localized failure with Retry and Close. + +### [P1] Render-queue cancellation has no pending or failure state +- **Screen/flow**: Canvas render queue — lib/features/canvas/widgets/canvas_render_queue.dart:246-279 +- **Heuristic violated**: feedback +- **What a user would experience**: Clicking Cancel starts two awaits, but the row and cancel button remain unchanged. There is no duplicate-click guard, "Cancelling…" state, exception handling, or failure notification. A slow or failed cancellation therefore appears as an ignored click. +- **Suggested fix**: Track cancellation per job, disable or replace the button with progress, and show a retryable error if cancellation fails. + +### [P1] Long archive operations often show only a disabled button +- **Screen/flow**: Settings backup/diagnostics and Studio project export — lib/features/settings/widgets/backup_section.dart:64-86, :154-194; diagnostics_section.dart:61-100; lib/features/studio/studio_home_screen.dart:672-715 +- **Heuristic violated**: feedback/consistency +- **What a user would experience**: Backup and diagnostics export merely disable their buttons without changing the label or showing progress. Project export sets a global busy flag that is not rendered beside the project card; repeated triggers silently return. This differs from project import and database restore, which show blocking progress dialogs. +- **Suggested fix**: Use a shared long-operation status pattern with a spinner/status label, disabled triggers everywhere, and cancellation where supported. + +### [P1] Startup recovery leaves stale error UI during retry or restore +- **Screen/flow**: Startup error recovery — lib/features/startup/widgets/startup_error_view.dart:73-136, :148-169, :204-227 +- **Heuristic violated**: feedback/error-clarity +- **What a user would experience**: Retry or restore only greys out buttons while the original error remains unchanged. A restore can run for a long time without "Restoring…" feedback. The Open log directory action also explicitly ignores opener failures, so the main diagnostic escape route can appear to do nothing. +- **Suggested fix**: Replace the stale error region with explicit retry/restore progress and surface folder-opening failures while retaining the selectable path. + +### [P1] Onboarding sample creation permits repeated submission +- **Screen/flow**: Studio onboarding — lib/features/studio/widgets/onboarding_dialog.dart:44-80, :156-168 +- **Heuristic violated**: feedback/safeguards +- **What a user would experience**: Sample creation has no busy flag, disabled state, or spinner. A double-click starts multiple asynchronous `createSample` calls, potentially creating duplicate sample projects while the dialog still looks idle. +- **Suggested fix**: Guard re-entry, disable both completion choices, and show "Creating sample…" until completion. + +### [P1] "Save as character" is buried and gives no in-flight feedback +- **Screen/flow**: Gallery image tile — lib/features/gallery/widgets/gallery_tile.dart:90-114, :255-318 +- **Heuristic violated**: discoverability/feedback/consistency +- **What a user would experience**: The only affordance is a generic three-dot menu; the character action is not visible until that menu opens. After entering a name, `_savingCharacter` changes without `setState`, so the tile and menu remain visually idle while work proceeds. Further attempts are silently ignored. +- **Suggested fix**: Expose a labeled character action on image tiles and render a disabled/spinner state during saving. + +### [P1] Clearing an API key has no confirmation or undo +- **Screen/flow**: Settings API keys — lib/features/settings/widgets/api_keys_section.dart:107-117, :185-194 +- **Heuristic violated**: safeguards/consistency +- **What a user would experience**: Clear immediately deletes the stored secret. It is styled as a secondary button rather than a destructive action and has no confirmation or recovery. Project, lane, and custom-provider deletion use confirmations or trash, making this irreversible deletion inconsistent. +- **Suggested fix**: Use danger styling and require confirmation identifying the provider whose key will be removed. + +### [P1] Video export selects old and new takes by default +- **Screen/flow**: Export video dialog — lib/features/export/widgets/export_video_dialog.dart:58-70, :214-255 +- **Heuristic violated**: safeguards/discoverability +- **What a user would experience**: Every result node is initially selected. If a shot has been regenerated, both its latest and earlier takes enter the export, producing repeated shots unless the user manually identifies and unchecks old files. Rows show only node label and filename, without source-shot grouping or latest/old-take status. +- **Suggested fix**: Select only the latest take per source node by default and group or badge alternative takes. + +### [P1] Several failure screens discard useful error detail +- **Screen/flow**: Studio, Gallery, script import, and project export — lib/features/studio/studio_home_screen.dart:113-118, :337-367, :705-713; lib/features/gallery/widgets/gallery_screen.dart:43-48, :319-346; lib/features/storyboard/widgets/script_import_dialog.dart:65-82; lib/l10n/app_en.arb:371-394, :548, :649 +- **Heuristic violated**: error-clarity/consistency +- **What a user would experience**: Studio and Gallery drop the received exception entirely. Studio then displays the empty-state instruction "Create your first project…" beneath a load error. Script and project-export failures reduce all causes to "Couldn't import…" or "Failed to export project," with no corrective action. Canvas and Startup already use `l10nAsyncError`, so equivalent failures provide different diagnostic value. +- **Suggested fix**: Preserve mapped error categories, use a shared error-state component, and provide targeted retry, permission, format, or log guidance. + +### [P1] Generation validation exposes internal machine reasons +- **Screen/flow**: Generation status panel — lib/features/canvas/widgets/inspector_status_panel.dart:38-46, :126-133; lib/features/canvas/providers/inspector_submit_controller.dart:141-148; lib/features/generation/generation_controller.dart:165-178, :267-270; lib/l10n/app_en.arb:18-24, :220-227 +- **Heuristic violated**: error-clarity +- **What a user would experience**: `InspectorInvalidConfig.reason` is inserted directly into localized copy. Actual values include `config node not found`, `first_frame_unsupported`, and `last_frame_unsupported`, producing technical messages such as "Invalid configuration: first_frame_unsupported"; these English tokens also leak into Chinese UI. Invalid-response, polling-timeout, and unknown-error strings provide no diagnosis beyond the adjacent generic Retry. +- **Suggested fix**: Convert validation reasons to typed, localized, actionable cases and add provider/log guidance where retry alone is insufficient. + +### [P1] Custom-provider Save closes the editor before persistence succeeds +- **Screen/flow**: Settings custom-provider editor — lib/features/settings/widgets/custom_providers_section.dart:212-257 +- **Heuristic violated**: feedback/safeguards/error-clarity +- **What a user would experience**: Pressing Save pops the editor and returns a config; only afterward does `store.upsert()` run. If the write fails, the user receives a generic snackbar but all five entered fields are gone and must be retyped. Failure while initially reading providers also incorrectly uses the save-failure message. +- **Suggested fix**: Keep the editor open through persistence or reopen it with the attempted values, and distinguish load from save errors. + +### [P2] Backup and restore timeouts are indistinguishable from ordinary failures +- **Screen/flow**: Settings/startup backup recovery — lib/features/settings/widgets/backup_section.dart:172-190, lib/l10n/l10n_x.dart:21-29, lib/services/database_backup_service.dart:79, database_restore_service.dart:109 +- **Heuristic violated**: error-clarity +- **What a user would experience**: Ten-minute backup and thirty-minute restore watchdog expirations collapse into the same `failed` outcomes and generic messages as immediate errors. After waiting that long, the UI does not say the operation timed out or whether retrying is appropriate. +- **Suggested fix**: Add explicit timed-out outcomes and messages explaining the timeout, retry, and log-location options. + +### [P2] Sequence preview hides playback failures behind loading and auto-skip +- **Screen/flow**: Storyboard sequence preview — lib/features/storyboard/widgets/sequence_preview_dialog.dart:164-191, :231-255, :265-271 +- **Heuristic violated**: feedback/discoverability +- **What a user would experience**: Video opening runs in an unawaited async closure without a catch. A broken clip remains a "loading" notes placeholder until the fallback timer advances, so it silently looks slow and then gets skipped. When the sequence is empty, the controls—including Close—are omitted entirely, leaving only outside-click/Escape dismissal. +- **Suggested fix**: Render a playback-error state with Retry/Skip and keep an explicit Close control in every state. + +### [P2] Color swatches lack usable accessible names and focus behavior +- **Screen/flow**: Settings Canvas appearance and lane editor — lib/features/settings/widgets/canvas_appearance_section.dart:97-130; lib/features/canvas/widgets/lane_edit_dialog.dart:188-215 +- **Heuristic violated**: accessibility +- **What a user would experience**: Canvas appearance swatches expose button/selected state but no color label, leaving multiple indistinguishable "button" announcements. Lane tint chips are bare `GestureDetector`s with neither semantic names nor keyboard focus. +- **Suggested fix**: Supply localized color names and selected state, and use focusable Material choice controls. + +### [P2] Shared text inputs cannot expose their visible field label +- **Screen/flow**: Shared input and form dialogs — lib/theme/components/ink_input.dart:7-29, :45-58; lib/features/export/widgets/export_video_dialog.dart:144-154; lib/features/settings/widgets/custom_providers_section.dart:357-370 +- **Heuristic violated**: accessibility +- **What a user would experience**: `InkInput` accepts only hint text; it has no `labelText` or semantic-label parameter. Forms place labels in adjacent `Text` widgets with no programmatic association. Screen readers may announce only the hint or current value rather than "Output file name," "Base URL," or "Model ID," particularly after the hint disappears. +- **Suggested fix**: Add label and semantic-label support to `InkInput` and pass the visible label through every form. + +### [P2] Current theme and language are conveyed only by color +- **Screen/flow**: Settings theme/language choices — lib/features/settings/widgets/theme_section.dart:100-118, language_section.dart:55-73; shared button semantics — lib/theme/components/ink_button.dart:44-61 +- **Heuristic violated**: accessibility +- **What a user would experience**: Selection changes only the `InkButton` visual variant. Its semantics expose a button and label but no selected/toggled or mutually-exclusive-group state, so a screen-reader user cannot determine the current theme or language. +- **Suggested fix**: Expose `selected` and group semantics, or use radio/segmented controls with native selection announcements. + +### [P2] Command-palette result rows are not announced as actions +- **Screen/flow**: Command palette — lib/features/command_palette/widgets/command_palette_dialog.dart:89-128, :138-193 +- **Heuristic violated**: accessibility +- **What a user would experience**: Arrow keys and Enter work through dialog-level shortcuts, but each result is a `MouseRegion` plus `GestureDetector` without button or selected semantics. Assistive technology receives text/icon content without knowing which result is selected or that it can be invoked. +- **Suggested fix**: Add button/selected semantics and announce selection changes; optionally give rows managed focus while retaining arrow navigation. + +### [P2] The selected-edge delete control is unlabeled and undersized +- **Screen/flow**: Canvas selected-edge control — lib/features/canvas/widgets/canvas_view.dart:79-99 +- **Heuristic violated**: accessibility +- **What a user would experience**: The destructive 28×28 close icon has no tooltip or semantic label identifying it as "Delete connection." Screen-reader users may hear only an unlabeled icon, and its pointer target is smaller than the usual desktop accessibility target. +- **Suggested fix**: Add localized Tooltip/Semantics text and provide at least a 44–48 px hit region without enlarging the visual icon. diff --git a/docs/review/2026-08-31/W1a.md b/docs/review/2026-08-31/W1a.md new file mode 100644 index 00000000..fa84a9f6 --- /dev/null +++ b/docs/review/2026-08-31/W1a.md @@ -0,0 +1,102 @@ +# W1a — core errors/interfaces/models + +**CONCLUSION**: 8 findings — P0: 0, P1: 0, P2: 4, P3: 4. The 15 InkError codes, wire values, retryability set, sealed subclasses, message-key mappings, and localization routing are exhaustive and well tested. CustomProviderConfig JSON round-tripping and generated Freezed copyWith/outer collection wrappers are also clean. The board's tracked JobRepository fat-interface debt is not repeated below. + +## Findings + +### [P2] JobStatus permits states that violate persistence and success invariants +- **File**: lib/core/models/job_status.dart:17 +- **Issue**: `inProgress` documents `progress ∈ [0,1]`, while `success` means an asset was produced, but neither invariant is enforced. +- **Evidence**: + ```dart + const factory JobStatus.inProgress({ + @Default(0.0) double progress, + }) = JobInProgress; + + const factory JobStatus.success({ + required List remoteUrls, + List? inlineBytes, + }) = JobSuccess; + ``` + `JobStatus.inProgress(progress: 1.1)` and `JobStatus.success(remoteUrls: [])` are valid. JobQueue persists progress into a database column constrained to `0.0..1.0`. Its zero-output guard applies only when `batchSize > 1`; a single-item empty success is transitioned to success without media. Scoped tests cover only valid progress and do not reject empty success. +- **Impact**: A provider can cause a database constraint failure merely by reporting out-of-range progress, or produce a successful job/result node with no downloadable or inline artifact. +- **Suggested fix**: Validate provider results at the JobQueue boundary using release-mode checks. Represent success as explicit non-empty remote/inline variants, or reject a success for which both channels are empty. + +### [P2] Numeric but invalid text scales survive tolerant preference parsing +- **File**: lib/core/models/app_preferences.dart:129 +- **Issue**: `fromMap` promises that illegal values fall back to defaults, but any numeric `text_scale` is accepted without range or finiteness validation. +- **Evidence**: + ```dart + textScale: ts is num ? ts.toDouble() : 1.0, + ``` + The settings slider accepts only `0.85..1.40`, and typography multiplies every font size by this value. Existing tests cover null/type errors, not numeric values outside the supported range. +- **Impact**: A valid but manually edited or stale preferences file containing `"text_scale": 2`, `0`, or a negative value seeds invalid UI state. Opening Settings can violate the Slider's range assertion, while zero/negative values create invalid typography. +- **Suggested fix**: Accept only finite values within `0.85..1.40`; otherwise use `1.0` or clamp consistently. Add range, NaN, and infinity tests. + +### [P2] ProviderRegistry erases mandatory provider facets +- **File**: lib/core/interfaces/provider_registry.dart:9; lib/core/interfaces/generation_provider.dart:18 +- **Issue**: The registry factory and lookup return only `Submittable`, although consumers assume every registered provider is also `Pollable` and `KeyValidatable`. +- **Evidence**: + ```dart + typedef ProviderFactory = Submittable Function(); + Submittable get(String providerId); + ``` + `generation_provider.dart` says all providers must support key validation. Nevertheless, JobQueue rejects a registered non-`Pollable` only after submission, while API-key settings treat a non-`KeyValidatable` provider as successfully validated. The repository's `FakeSubmittable` proves such an implementation is accepted statically. Current production providers happen to implement all three facets, but the contract does not enforce that. +- **Impact**: A replacement provider valid under `ProviderRegistry` can fail every generation after submission, or have an invalid key saved and reported as verified without validation. +- **Suggested fix**: Define a composed mandatory provider type implementing `Submittable`, `Pollable`, and `KeyValidatable`, and return that from the registry. Keep genuinely optional facets such as `Cancellable` separate. + +### [P2] VideoPlayerHandle exposes an Object with a hidden media_kit requirement +- **File**: lib/core/interfaces/video_player_service.dart:25 +- **Issue**: `rawPlayer` claims to avoid leaking media_kit, but consumers require the object to be a media_kit `Player`. +- **Evidence**: + ```dart + Object get rawPlayer; + ``` + Both video UIs perform `if (raw is Player) VideoController(raw)`. Any otherwise valid `VideoPlayerHandle` returning another backend object cannot render. The sequence-preview fake returns `Object()` and therefore cannot exercise the rendered-video path. +- **Impact**: Alternative backends and complete test doubles satisfy the interface but yield an endless loading/placeholder surface. The implementation is not substitutable despite the abstraction. +- **Suggested fix**: Either make the concrete dependency explicit and typed, or abstract controller/surface creation so UI never inspects a backend object. + +### [P3] SemVer.tryParse can throw and accepts versions forbidden by SemVer 2.0 +- **File**: lib/core/models/semver.dart:25 +- **Issue**: The parser promises invalid input returns null, but its regex accepts arbitrary-length numeric fields and leading-zero numeric identifiers. +- **Evidence**: + ```dart + r'^v?(\d+)\.(\d+)\.(\d+)...' + ... + major: int.parse(m.group(1)!), + ``` + An oversized numeric component can make `int.parse` throw `FormatException`. Values such as `01.2.3` and `1.2.3-alpha.01` are accepted even though SemVer forbids leading zeroes. Tests omit both cases. +- **Impact**: One malformed GitHub release tag can abort the entire update check instead of being skipped, leaking a raw non-InkError despite the service contract. +- **Suggested fix**: Use `int.tryParse`, reject oversized components, and tighten core/prerelease numeric patterns to disallow leading zeroes. + +### [P3] Core "immutable" values expose writable payloads +- **File**: lib/core/errors/ink_error.dart:74; lib/core/models/import_plan_data.dart:26; lib/core/models/job_status.dart:30 +- **Issue**: Several values retain and publicly expose mutable collections. +- **Evidence**: + ```dart + final Map extra; + final Map canvasIdMap; + final List> nodes; + List? inlineBytes; + ``` + `ImportPlanData` has no defensive wrappers at any level. Freezed protects the outer `inlineBytes` list, but each `Uint8List` remains writable. `InkError.extra` is directly mutable despite `InkError` being annotated immutable. No scoped test attempts mutation or retained-input aliasing. +- **Impact**: Import rows can change after remapping but before transactional insertion; provider byte buffers can change between status emission and persistence; error reasons/log data can change after construction. +- **Suggested fix**: Defensively copy and expose unmodifiable collections, establish immutable byte ownership or copy-on-read semantics, and add mutation/aliasing tests. + +### [P3] Tagged result classes admit contradictory success states +- **File**: lib/core/interfaces/database_backup_service.dart:61; lib/core/interfaces/project_import_service.dart:27 +- **Issue**: Nullable payloads encode invariants only in comments: + ```dart + const BackupNowResult({required this.outcome, this.fileName}); + const ImportResult({required this.outcome, this.newProjectId, this.reason}); + ``` + Thus `BackupNowResult(outcome: created)` and `ImportResult(outcome: imported)` are valid despite lacking their required success payloads. Current concrete implementations honor the convention, but alternative implementations and fakes are not forced to. +- **Impact**: Import UI can show success while selecting a null project ID. Restore flow can believe its safety backup succeeded while losing the filename needed for diagnosis/recovery. +- **Suggested fix**: Use Freezed sealed variants such as `created(fileName)`/`failed(outcome)` and `imported(projectId)`/`failed(outcome, reason)`. + +### [P3] Transaction and repository interfaces remain client-fat +- **File**: lib/core/interfaces/unit_of_work.dart:19; lib/core/interfaces/node_repository.dart:2 +- **Issue**: `RepositoryScope` requires nine unrelated repositories, while `NodeRepository` combines ordinary CRUD, type-config mutation, startup cleanup, and orphan-media scanning. +- **Evidence**: The shared `FakeRepositoryScope` must carry nine nullable repositories and implement nine getters that throw `StateError` when omitted. Numerous narrow NodeRepository fakes implement unused methods with `UnimplementedError` or `noSuchMethod`. This is separate from the JobRepository debt already tracked on the board. +- **Impact**: Adding one transactional repository forces every scope implementation and harness to change. Narrow tests compile with runtime holes, so an accidental new call fails at runtime rather than being excluded by the dependency type. +- **Suggested fix**: Compose narrow transaction-scope interfaces per use case, and split Node CRUD/type-config operations from startup and media-GC queries. diff --git a/docs/review/2026-08-31/W1b.md b/docs/review/2026-08-31/W1b.md new file mode 100644 index 00000000..1993e8c0 --- /dev/null +++ b/docs/review/2026-08-31/W1b.md @@ -0,0 +1,75 @@ +# W1b — core DI wiring + app entry point + +**CONCLUSION**: 10 findings — P0: 0, P1: 1, P2: 5, P3: 4. Clean after falsification: constants and licenses, routing precedence/onboarding decisions, repository/HTTP-client/JobQueue app lifecycles, proxy credential/NO_PROXY parsing, and the prohibition on static/global mutable state in `di/**`. + +## Findings + +### [P1] Restore can race the schema bootstrap during normal startup +- **File**: lib/core/di/database_restore.dart:100; lib/app.dart:112 +- **Issue**: The restore flow waits for the raw pool, not the migrated-pool barrier, while the startup gate exposes the interactive shell during migration. +- **Evidence**: `_StartupGate` sends both `AsyncLoading` and `AsyncData` to `_UnlockedShell`. `DatabaseRestoreFlow` then awaits `pgPoolProvider.future`; that future resolves before `DatabaseBootstrap.run()` completes in `pgMigratedPoolProvider`. The restore-flow tests delay only `pgPoolProvider`, and the routing tests deliberately keep `pgMigratedPoolProvider` loading without exercising restore. +- **Impact**: During a slow first launch, a user can reach Settings and start restore while schema DDL is still running. The flow may run `pg_dump`, close the pool, or swap databases underneath the migration, producing a partial safety backup, failed migration, or inconsistent restored state. +- **Suggested fix**: While migrated state is loading, await `pgMigratedPoolProvider.future` before backup/restore. Retain raw-pool recovery only for an established `AsyncError`, or disable DB maintenance controls until readiness is settled. Add a combined loading-gate/restore test. + +### [P2] An empty HTTPS proxy variable disables a valid HTTP proxy globally +- **File**: lib/core/net/proxy_env.dart:50 +- **Issue**: `applyEnvProxy` uses the precedence-aware `_firstSet` to decide whether to install `findProxy`. +- **Evidence**: For `HTTPS_PROXY=''` and `HTTP_PROXY='http://proxy:8080'`, `_firstSet` stops at the empty HTTPS value and returns `null`, so the adapter is untouched. However, `proxyRuleFor` correctly selects `HTTP_PROXY` for an HTTP URL. Thus the pure decision function and actual Dio wiring disagree. +- **Impact**: HTTP requests silently bypass the configured proxy in mixed explicit-disable configurations. +- **Suggested fix**: Determine adapter activation by checking whether any proxy variable independently contains a non-empty value; keep `_firstSet` only for per-request precedence. Add an `applyEnvProxy` test covering empty HTTPS plus non-empty HTTP/ALL proxy. + +### [P2] Blank or relative environment roots can move data into the process working directory +- **File**: lib/core/paths/app_paths.dart:62 +- **Issue**: Platform roots are accepted whenever non-null; blank and relative values are not rejected. Blank `HOME` also prevents fallback to `USERPROFILE`. +- **Evidence**: `p.join('', 'InkFrame')` produces a relative `InkFrame` path. `main.dart` treats any non-null conventional path as migration-safe, so a real legacy root can be renamed into that relative location. +- **Impact**: A stripped or malformed launch environment can place—and during DIR-1, move—the workspace under the current working directory, potentially an installation directory or an attacker-influenced relative path. +- **Suggested fix**: Trim values, treat empty values as missing, require an absolute root, and otherwise use `path_provider`. Let `legacyRootPath` skip blank `HOME` and try `USERPROFILE`. Test blank and relative values. + +### [P2] Legacy-root inspection errors escape the migration fallback +- **File**: lib/core/paths/legacy_root_migrator.dart:65 +- **Issue**: `_legacyHasRealData()` performs `Directory.list()` outside the migration's `FileSystemException` guard. +- **Evidence**: Both the healthy-target branch at line 69 and pre-rename branch at line 74 call the scanner before the `try` beginning at line 80. Permission errors or an unreadable/stale legacy link therefore escape `migrate()`. +- **Impact**: Even when the conventional target is healthy, an inaccessible leftover `~/InkFrame` can abort startup before `runApp` and before the file logger is ready. +- **Suggested fix**: Guard legacy inspection separately. When the target exists, inspection failure should not prevent using it; record a diagnostic outcome for later logging. Add an injected/scanner failure test. + +### [P2] PNG probing accepts truncated headers and dimensions outside the PNG limit +- **File**: lib/core/media/png_dimensions.dart:12 +- **Issue**: The parser accepts only 24 bytes, ignores the rest of IHDR and its CRC, and accepts unsigned dimensions above `2^31-1`. +- **Evidence**: A signature plus length/type/width/height returns a size even when bytes 24–32 are absent. `_readUint32` can also return `0xffffffff`, while validation rejects only zero. Existing tests use a zero CRC and test truncation only at 23 bytes. +- **Impact**: Corrupt output can receive trusted metadata. Dimensions above `2147483647` can then be written to PostgreSQL `INTEGER` width/height columns, turning otherwise persisted output into a database failure. +- **Suggested fix**: Require the complete 33-byte IHDR, enforce `1..0x7fffffff`, validate IHDR fields, and preferably verify CRC. Add 24–32-byte truncation, bad CRC, and high-bit dimension tests. + +### [P2] File-logger failures can defeat the pre-reporter startup fallback +- **File**: lib/core/logging/logger_service.dart:137; lib/main.dart:268 +- **Issue**: Synchronous directory/stat/write/rotation operations can throw. Before `CrashReporter` exists, the zone handler retries the same logger before writing to stderr. +- **Evidence**: `_writeLine` has no I/O guard. The first lifecycle log occurs before reporter construction; if it fails due to disk-full, permissions, or a locked path, the zone handler calls `l?.error(...)`, which can throw again and prevent lines 270–271 from executing. +- **Impact**: Startup can terminate with neither the application nor the promised stderr diagnostic, specifically when logging storage is unavailable. +- **Suggested fix**: Make logging I/O best-effort or surface a safe fallback sink. Independently wrap the pre-reporter `l.error` call so stderr is always reached. Add failing-filesystem logger tests. + +### [P3] Malformed JSON escapes the typed database decoding boundary +- **File**: lib/core/db/row_reader.dart:94 +- **Issue**: `stringList` promises `LocalIOError` for invalid input but lets `jsonDecode` throw `FormatException`. +- **Evidence**: Only successfully decoded non-list values reach `_decodeError`; malformed JSON bypasses it. `row_reader_test.dart` does not exercise `stringList`. +- **Impact**: Snapshot, fake, or import-backed rows can emit an unexpected raw exception instead of the repository's diagnosable decode error. +- **Suggested fix**: Catch `FormatException` and throw `_decodeError`; add decoded-list, malformed-JSON, and decoded-non-list tests. + +### [P3] DI exposes concrete lifecycle coordinators instead of abstract contracts +- **File**: lib/core/di/database.dart:31; lib/core/di/database_restore.dart:45 +- **Issue**: `pgControllerProvider` publishes concrete `PgController`, and `databaseRestoreFlowProvider` publishes concrete `DatabaseRestoreFlow`, which also retains a raw Riverpod `Ref`. +- **Evidence**: UI, teardown, backup, and restore code directly consume these concrete types. Tests must `implements PgController`, coupling fakes to every member of a production class. +- **Impact**: Lifecycle implementation changes propagate across UI and tests, violating the documented "every injectable has an abstract interface" rule. +- **Suggested fix**: Introduce narrow lifecycle and restore-coordinator interfaces, bind implementations only in `di/**`, and keep raw pool/controller mechanics behind them. + +### [P3] CanvasStyleController is retained for the entire application lifetime +- **File**: lib/core/di/canvas_style.dart:59 +- **Issue**: A screen-facing controller is declared with plain `NotifierProvider`, making it keep-alive rather than screen-scoped `autoDispose`. +- **Evidence**: Unlike theme and locale, it is not watched by the app root; it is used only by Canvas and Settings. Its durable source is already `PreferencesService`, so reconstruction is safe. +- **Impact**: After either screen first opens, the notifier remains resident and may preserve stale state if preferences change through another path, contrary to the lifecycle table. +- **Suggested fix**: Use `NotifierProvider.autoDispose` and test that leaving the last consumer disposes it and rebuilding re-seeds from preferences. + +### [P3] Package metadata is fetched twice +- **File**: lib/main.dart:141; lib/core/di/package_info.dart:5 +- **Issue**: `main` eagerly obtains `PackageInfo` for crash reporting but does not inject that value into `packageInfoProvider`. +- **Evidence**: The `ProviderContainer` overrides at lines 185–192 omit `packageInfoProvider`, whose default calls `PackageInfo.fromPlatform()` again when update checking, About, diagnostics, or project export first reads it. +- **Impact**: An unnecessary second platform-channel call and duplicate `PackageInfo` instance occur during normal use/startup update checks. +- **Suggested fix**: Override `packageInfoProvider` with the already-loaded `pkg` value, or centralize the initial read through one shared bootstrap future. diff --git a/docs/review/2026-08-31/W2.md b/docs/review/2026-08-31/W2.md new file mode 100644 index 00000000..5315a4c4 --- /dev/null +++ b/docs/review/2026-08-31/W2.md @@ -0,0 +1,67 @@ +# W2 — theme/ design token system + +**CONCLUSION**: 8 findings — P0: 0, P1: 3, P2: 2, P3: 3. The opaque WCAG formula, on-color derivation, high-contrast foreground/surface matrix (minimum 7.36:1), typography definitions, motion conversion, and component SOLID boundaries are otherwise clean. BOARD-tracked component-family, chrome-dependency, and icon/control-size debt is omitted. + +## Findings + +### [P1] App text-size setting only scales custom token text +- **File**: lib/theme/app_theme.dart:58 +- **Issue**: `textScale` is applied only to `AppThemeExtension.typography`; `ThemeData.textTheme` and Material component themes remain at Flutter defaults. +- **Evidence**: `InkTypography.defaults(scale: textScale)` is placed solely in `extensions`. `ThemeData` defines no `textTheme`, `textButtonTheme`, `filledButtonTheme`, or `iconButtonTheme`, and the app adds no MediaQuery-level scaling. A brief call-site scan found 27 raw `TextButton`, 6 `FilledButton`, and 30 `IconButton` usages outside `theme/`, plus unstyled dialog, dropdown, snackbar, and menu `Text` widgets. Existing tests verify only `context.inkTypography`, never an inherited Material label at a non-1.0 scale. +- **Impact**: Moving "Text size" from 1.0 to 1.4 enlarges token-styled copy while standard buttons, dialogs, dropdown entries, snackbars, and other inherited Material text remain unchanged. The accessibility preference therefore produces a mixed-scale UI and does not help users on substantial portions of the app. +- **Suggested fix**: Map `InkTypography` into `ThemeData.textTheme` and relevant component themes, or ensure all text-bearing controls use scalable wrappers. Add an integration test comparing token-styled and inherited Material text at 1.4. + +### [P1] Most custom interactive primitives are pointer-only +- **File**: lib/theme/primitives/ink_amber_button.dart:58 +- **Issue**: The studio primitive family uses `MouseRegion` plus `GestureDetector`, without a `Focus`, `FocusableActionDetector`, keyboard actions, or visible focus state. +- **Evidence**: The same pattern appears in `InkAmberButton`, `InkGhostButton`, `InkNoirCard`, `InkDashedSlot`, `InkAccentChip`, `InkSurfaceButton`, and the Windows chrome buttons. `InkColors.focusRing` has zero consumers in `lib/`. Some widgets add `Semantics`, but that does not place them in desktop Tab traversal or provide Enter/Space activation. These primitives are used by New Project, onboarding navigation, project cards, and other primary workflows. Tests exercise pointer taps only. +- **Impact**: Keyboard-only users cannot focus or activate major actions, and there is no focus indicator for the custom control family. This is especially material for a desktop application. +- **Suggested fix**: Build these controls on `InkWell`/Material buttons or `FocusableActionDetector`, wire Activate/Enter/Space actions, and render `focusRing`. Add traversal and keyboard-activation tests. + +### [P1] The WCAG lock omits live foreground/surface pairs that already fail +- **File**: lib/theme/tokens.dart:119 +- **Issue**: Several semantic colors are used both as fills and as small-text/non-text foregrounds, but only solid-fill `onAccent`/`onDanger` pairs are contrast-tested. +- **Evidence**: Using the repository's WCAG formula: + - Light `fg3` on `surface2`, used by `InkInput` hint text, is **3.81:1**, below the 4.5:1 normal-text threshold. + - Dark `danger` on `surface2`, used by `InkErrorBanner`, is **4.22:1**. + - Light `cta` against `surface3`, used by `InkProgressBar`, is **2.39:1**, below the 3:1 non-text threshold. + - A scan found 73 outside-theme `copyWith(color: colors.fg3)` text sites. + + `test/theme/tokens_test.dart:82` and `ink_button_test.dart` lock only colored fills with their on-colors; progress is contrast-tested in no variant, while input/error-banner surface pairs are not tested. +- **Impact**: Light-theme hints and secondary copy are systematically below AA, dark error text misses AA, and light progress can be hard to distinguish from its track. +- **Suggested fix**: Introduce role-specific foreground tokens such as `textMuted`, `dangerText`, and `progressFill`, then lock every component's rendered foreground/background pair across all variants. + +### [P2] Canvas customization palettes are incompatible with the light theme +- **File**: lib/theme/tokens.dart:23 +- **Issue**: Edge and card color choices are fixed dark-theme-oriented values, but the selected preference persists unchanged across theme variants. +- **Evidence**: All six edge choices against light `surface1` produce only **1.36–2.48:1**, below the 3:1 graphical-object threshold. All six dark card choices against light `fg1` produce only **1.00–1.23:1**. `node_card.dart` substitutes the persisted card color for `surface2` without selecting a companion foreground, while `canvas_view.dart` similarly uses the persisted edge color directly. No theme test covers customization colors. +- **Impact**: A user who selects a customization in dark mode and switches to light mode gets nearly invisible node text and faint connection lines; the preference survives restart. +- **Suggested fix**: Provide per-variant choices, derive a contrast-safe companion foreground, or reject/remap incompatible persisted colors when the variant changes. + +### [P2] Hovering the Windows close button destroys icon contrast +- **File**: lib/theme/components/ink_window_chrome.dart:115 +- **Issue**: The close button changes its hover background to `danger`, but its icon remains `fg2` instead of switching to `onDanger`. +- **Evidence**: `bg` becomes `colors.danger` at lines 115–117, while line 134 always renders the icon with `colors.fg2`. The resulting icon/background ratios are **1.91:1 dark**, **1.35:1 light**, and **2.33:1 high contrast**—all below the 3:1 non-text threshold. The correct `onDanger` token already exists. Chrome tests do not simulate hover or inspect contrast. +- **Impact**: The close glyph becomes less distinguishable exactly when the pointer targets it, including in high-contrast mode. +- **Suggested fix**: Use `colors.onDanger` for the danger-hover icon and add a three-variant hover contrast test. + +### [P3] Theme animation mixes interpolated Material colors with snapping Ink tokens +- **File**: lib/theme/app_theme.dart:33 +- **Issue**: `AppThemeExtension.lerp` switches wholesale at `t == 0.5`, while Flutter interpolates the surrounding `ThemeData`. +- **Evidence**: The extension returns `this` below 0.5 and `other` above it. `MaterialApp` uses a 200 ms animated theme by default, and the app does not disable that animation. Consequently Scaffold/ColorScheme values interpolate continuously while token-styled foregrounds and surfaces jump halfway through. The existing test locks the snap behavior but does not exercise an integrated theme transition. +- **Impact**: Light/dark switching briefly renders a hybrid palette and visibly flashes token-styled text or controls against intermediate Material surfaces. +- **Suggested fix**: Lerp every `InkColors`/typography slot, or explicitly disable Material theme animation if switching is intentionally discrete. + +### [P3] Raw component constants bypass the declared token-only source of truth +- **File**: lib/theme/primitives/ink_accent_chip.dart:33 +- **Issue**: Components contain un-tokenized opacity, stroke, dash, and geometry constants despite `tokens.dart` declaring itself the only allowed raw-value file. +- **Evidence**: Examples include accent-chip alpha `0.10`/`0.50` and border `0.8`, dashed-slot defaults `6/4/1`, and the private 3 px progress-bar height. These are beyond BOARD's already-tracked icon-size/control-height debt. The style hygiene test exempts component directories from several rules and does not inspect alpha, stroke, dash, or general geometry; seven additional literal alpha values already exist outside `theme/`. +- **Impact**: Visual recipes cannot be changed or contrast-tested centrally, and callers reproduce missing opacity/stroke semantics outside the subsystem. +- **Suggested fix**: Add component/opacity/stroke tokens and extend the hygiene test to cover these properties. + +### [P3] Public components and token presets are test-maintained dead code +- **File**: lib/theme/primitives/ink_accent_chip.dart:10 +- **Issue**: Several public design-system APIs have no production consumer. +- **Evidence**: `InkAccentChip` and `InkSurfaceButton` have zero references outside `lib/theme`; nevertheless both have dedicated tests. `InkMotionSpringConfig.defaultSpring`, `panelIn`, `popoverIn`, `popoverOut`, `InkMotion.normal`, `InkMotion.slow`, `InkRadius.bento`, `InkRadius.bentoBtn`, `InkShadow.overlay`, and `InkSpacing.s3` also have no production references. Meanwhile the application uses 30 raw Material `IconButton`s rather than the unused surface-button abstraction. +- **Impact**: The advertised design-system surface is misleading, carries maintenance/tests for behavior the app does not use, and encourages parallel implementations. +- **Suggested fix**: Remove these APIs until needed or adopt them at real call sites and test those integrations. diff --git a/docs/review/2026-08-31/W3.md b/docs/review/2026-08-31/W3.md new file mode 100644 index 00000000..eac3e836 --- /dev/null +++ b/docs/review/2026-08-31/W3.md @@ -0,0 +1,75 @@ +# W3 — canvas models/providers/util + +**CONCLUSION**: 10 findings — P0: 0, P1: 2, P2: 5, P3: 3. No surviving issues in canvas geometry/hit-testing, extent/zoom/positioning, lane geometry/tint, camera labels/base-style presets, bootstrap/current-name/playable-video providers, serial FIFO behavior, or documented terminal batch-slot convergence. + +## Findings + +### [P1] Node deletion can race with new edges and billable generation jobs +- **File**: lib/features/canvas/providers/canvas_nodes_controller.dart:149; lib/features/canvas/providers/link_action_controller.dart:48; lib/features/canvas/providers/inspector_submit_controller.dart:135 +- **Issue**: Deletion is not a barrier against concurrent link creation or job submission. +- **Evidence**: `removeNode` takes a one-time snapshot of incoming/outgoing edges, soft-deletes them, then soft-deletes the node. `LinkActionController` subsequently calls `addEdge` without verifying that both endpoints remain live—even its "nodes not ready" path deliberately falls back to `reference` and proceeds. Likewise, generation submission has no synchronization with deletion. Database foreign keys accept references to soft-deleted rows, so they do not close either race. Existing tests cover static cascade deletion and serialization within one controller, not cross-controller mutation. +- **Impact**: Starting link mode from A, deleting A, then clicking B creates a persistent live edge whose source is absent from the canvas. A generate/delete TOCTOU can also submit and charge for a job after the source node was deleted, leaving a live result tied to a soft-deleted config. +- **Suggested fix**: Add a transactional endpoint/source liveness check with row locking immediately before edge/job creation. Mark nodes as deleting, cancel link mode, and atomically cancel or explicitly detach active submissions. + +### [P1] An already-running autosave can overwrite the final config submitted for generation +- **File**: lib/features/canvas/providers/inspector_submit_controller.dart:107 +- **Issue**: Debounced saves and `submit()` write `type_config` concurrently without ordering or revision checks. +- **Evidence**: The timer launches `unawaited(saveConfig(...))`. `submit()` only cancels the timer; cancellation cannot stop a `saveConfig` future that has already started. Both paths independently call `patchTypeConfig`. +- **Impact**: An old prompt save can start, the user can submit a newer prompt, and the old save can finish last. Generation may reload and send the stale prompt, potentially spending money on the wrong output; even if generation reads first, the persisted editor value regresses afterward. Tests cover debounce-before-fire and duplicate submits, but not an in-flight save. +- **Suggested fix**: Serialize all config writes per node and await the current write tail before submission, or use monotonically increasing revisions/CAS updates. + +### [P2] Provider invalidation can hide a successfully committed mutation indefinitely +- **File**: lib/features/canvas/providers/canvas_nodes_controller.dart:115; lib/features/canvas/providers/canvas_edges_controller.dart:74; lib/features/canvas/providers/canvas_lanes_controller.dart:70 +- **Issue**: A mutation may commit through an old notifier after invalidation while the replacement notifier has already loaded a pre-commit snapshot. +- **Evidence**: Each controller sets `_alive=false` on disposal and simply skips its state write after the repository call. There is no final invalidation or event sent to the replacement provider. For example, node deletion invalidates the edge controller; an in-flight `addEdge` can then commit after the replacement edge list loaded. The disposal tests assert only that no `StateError` is thrown. +- **Impact**: The database contains a successfully added node/edge/lane, the action future reports success, but the live canvas omits it until another unrelated reload or reopening. +- **Suggested fix**: Coordinate builds and mutations through a canvas-scoped mutation service/version, or publish commit events that force the currently live provider to reload. + +### [P2] Narrative merge ordering violates its own predecessor edges +- **File**: lib/features/canvas/util/narrative_order.dart:71 +- **Issue**: DFS preorder does not produce a topological order when narrative branches merge. +- **Evidence**: For `A→B`, `A→C`, `B→D`, `C→D`, the implementation emits `A,B,D,C`; this puts D before its declared predecessor C. The existing merge test explicitly locks in this output, checking only that D is not duplicated. +- **Impact**: Sequence preview and narrative export play/render a merged shot before one of the shots that is supposed to precede it. +- **Suggested fix**: Use stable Kahn topological ordering for DAG components, with the existing position/sort-order tie-breakers. Collapse SCCs or retain the deterministic fallback only for actual cycles. + +### [P2] Canvas domain models are neither Freezed nor reliably immutable/value-correct +- **File**: lib/features/canvas/models/canvas_node.dart:23; lib/features/canvas/models/character.dart:12; lib/features/canvas/models/batch_result.dart:49 +- **Issue**: All six scoped domain models are handwritten despite the project and canvas README requiring Freezed models. Collection-bearing models expose mutable state, and `BatchResult` has incomplete equality. +- **Evidence**: `CanvasNode` stores a caller-owned `typeConfig` map and reuses it in `copyWith`; `Character` similarly stores and reuses a mutable list. `fromRow` only freezes the outer node map, not nested lists/maps. `BatchResult.==` and `hashCode` omit `width`, `height`, `seed`, `errorCode`, and `errorMessage`. +- **Impact**: A caller can mutate provider state without `copyWith` or notification, change an object's hash after insertion into a set/map, and make visually different batch slots compare equal. +- **Suggested fix**: Convert these domain models to Freezed with unmodifiable collection views and complete generated equality/copy semantics. + +### [P2] Character and preset creation can commit successfully and then report failure +- **File**: lib/features/canvas/providers/characters_controller.dart:95; lib/features/canvas/providers/prompt_presets_controller.dart:59 +- **Issue**: Both creation methods perform a committed write and then await a full-list reload as part of the same reported operation. +- **Evidence**: Character creation has already created the row, copied the asset, and updated its paths before `_reload`. Preset creation similarly commits `repo.create` before `_reload`. A `listByProject` failure escapes to the caller, with no rollback and no distinction between write and refresh failure. +- **Impact**: UI reports "creation failed"; retrying creates duplicate presets or duplicate character records/assets even though the first attempt succeeded. +- **Suggested fix**: Append the known created model optimistically or invalidate separately. Never translate a post-commit refresh failure into a write failure. + +### [P2] Several provider operations violate structured InkError propagation +- **File**: lib/features/canvas/providers/characters_controller.dart:85; lib/features/canvas/providers/link_action_controller.dart:54; lib/features/canvas/util/canvas_node_delete.dart:37 +- **Issue**: Errors are either leaked outside the `InkError` hierarchy or stripped of their structured cause. +- **Evidence**: `CharactersController` rethrows raw `CharacterAssetError` and `FileSystemException`. `LinkActionController` reduces every non-23505 `InkError` to `LinkActionResult.failed`. Delete helpers discard the error and show one generic failure string. These local choices conflict with the project rule that providers propagate `InkError` for localized `messageKey` rendering. +- **Impact**: File import failures may bypass localized error handling, while database/network failures lose actionable diagnosis and retry guidance. +- **Suggested fix**: Map asset/file errors to `LocalIOError`, retain the original `InkError` in action state/events, and localize through the central error mapping. + +### [P3] Failed deletion rolls back the node but not its selection state +- **File**: lib/features/canvas/util/canvas_node_delete.dart:23 +- **Issue**: Selection is removed before deletion, but the failure path never restores it. +- **Evidence**: `removed(nodeId)` runs before `removeNode`; the node controller correctly restores its list on `InkError`, while the catch block only displays a snackbar. The batch path has the same ordering. +- **Impact**: A failed delete leaves the node visible but unexpectedly closes/deselects its inspector. In a batch, the failed node and already-processed selection state diverge. +- **Suggested fix**: Clear selection only after successful deletion, or snapshot and restore selection on failure. No scoped test covers this rollback. + +### [P3] Artifact lookup becomes quadratic for sequence/export construction +- **File**: lib/features/canvas/util/node_artifacts.dart:21 +- **Issue**: `resultsFor` scans the complete node list and sorts candidates for one source node on every call. +- **Evidence**: Narrative consumers call `resultsFor`/`latestResultFor` once or more for each chain node. With N sources and N results this performs O(N²) filtering plus repeated allocations/sorts. Existing tests cover correctness only. +- **Impact**: Opening sequence preview or export increasingly stalls as a canvas accumulates shots and multiple takes. +- **Suggested fix**: Build one `Map>`, sort each group once, and reuse it for the whole operation. + +### [P3] Every job progress tick performs a nodes-by-jobs scan +- **File**: lib/features/canvas/providers/node_active_job.dart:12 +- **Issue**: Every mounted node-family provider watches the entire jobs registry and linearly scans it after any job change. +- **Evidence**: With M mounted nodes and J retained jobs, one progress update performs O(M×J) checks. At the documented caps, 400 nodes and 250 jobs cause roughly 100,000 checks per tick before Riverpod suppresses unchanged widget rebuilds. The logic also duplicates `JobsRegistry.activeForSourceNode`; there is no scoped performance test. +- **Impact**: Simultaneous generation progress can consume substantial UI-thread time on the already-known large-canvas path. +- **Suggested fix**: Maintain an indexed `sourceNodeId → active JobState` projection and notify only the affected node family. diff --git a/docs/review/2026-08-31/W4.md b/docs/review/2026-08-31/W4.md new file mode 100644 index 00000000..a519b949 --- /dev/null +++ b/docs/review/2026-08-31/W4.md @@ -0,0 +1,124 @@ +# W4 — canvas widgets + +**CONCLUSION**: 17 findings (P0: 0, P1: 2, P2: 10, P3: 5). Clean after falsification: node-inspector state keying, video capability filtering, batch-grid indexing, render-queue filtering, shortcut text-entry arbitration, and lane/background geometry. Known BOARD items—including lane hit-testing and concurrent error dismissal—were excluded. + +## Findings + +### [P1] Image inspector exposes video-only providers +- **File**: lib/features/canvas/widgets/image_config_inspector.dart:87 +- **Issue**: The provider dropdown uses the complete capability list without filtering for image-generation modes. +- **Evidence**: `ref.read(providerCapabilitiesListProvider)` is assigned directly to `caps`, and line 242 builds every capability into the dropdown. The video inspector correctly performs mode filtering. +- **Impact**: An image node can be submitted through a video-only adapter. The returned video is subsequently handled through the image task's persistence/display path, producing a charged but unusable result. +- **Suggested fix**: Filter capabilities to image modes before selecting defaults or building dropdown items, and add a mixed image/video capability test. + +### [P1] Debounced shot notes are lost when selection changes +- **File**: lib/features/canvas/widgets/shot_config_inspector.dart:74 +- **Issue**: `dispose()` cancels the pending notes timer without flushing its value. +- **Evidence**: Notes are saved only after the 500 ms timer created at line 97. Canvas selection keys the inspector by target ID at `canvas_view.dart:1192`, so changing selection disposes it immediately. +- **Impact**: Typing notes and selecting another node within 500 ms silently discards the edit. +- **Suggested fix**: Flush the pending value during disposal/selection change, or move debouncing into durable controller state. Test typing followed immediately by node switching. + +### [P2] Persisted provider and resolution values are not validated +- **File**: lib/features/canvas/widgets/image_config_inspector.dart:90; lib/features/canvas/widgets/video_config_inspector.dart:54 +- **Issue**: Saved provider IDs are accepted without checking current dropdown options; image resolutions are likewise not clamped to the selected provider. +- **Evidence**: `_selectedCaps` falls back to the first capability but leaves `_providerId` unchanged. The dropdown then receives a `value` absent from its items. Image `_resolution` can also remain outside `supportedResolutions`. +- **Impact**: Deleting a custom provider or changing provider capabilities can trigger debug assertions. In release builds, displayed parameters can belong to one provider while submission uses a stale provider ID. +- **Suggested fix**: Normalize all persisted selections against current capabilities before first build and whenever capabilities change. + +### [P2] Selected characters can be silently excluded by reference limits +- **File**: lib/features/canvas/widgets/characters_section.dart:169; lib/features/canvas/widgets/node_inputs_section.dart:70 +- **Issue**: Every character remains selectable even when linked reference images have already consumed the provider's reference-image capacity. +- **Evidence**: Character chips enforce only boolean support, while the inputs section counts reference edges independently. No remaining-capacity calculation or truncation warning is rendered. +- **Impact**: With a one-reference provider and one linked reference image, an attached character appears selected and persists in configuration but never reaches generation. +- **Suggested fix**: Calculate remaining capacity from both sources, disable excess selections or show exactly which references will be omitted, and test combined edge-plus-character limits. + +### [P2] Character import mixes platform I/O, mutation, and rendering +- **File**: lib/features/canvas/widgets/characters_section.dart:202 +- **Issue**: The widget directly opens a platform file picker, solicits a name, and performs controller mutation. +- **Evidence**: `openFile()` at line 209 occurs outside the surrounding error handler. The picker's `XTypeGroup(label: 'images')` is also a hardcoded platform-visible string. +- **Impact**: A picker/platform failure escapes as an unhandled future error, the flow is difficult to substitute in tests, and some platforms can expose an untranslated label. +- **Suggested fix**: Delegate importing to an injected controller/service, catch picker failures, map them to localized feedback, and localize the type-group label. + +### [P2] API-key loading and failures are reported as "no key" +- **File**: lib/features/canvas/widgets/inspector_status_panel.dart:80 +- **Issue**: The secure-storage query's loading and error states collapse to `false`. +- **Evidence**: `(ref.watch(inspectorHasApiKeyProvider(pid)).valueOrNull ?? false)` cannot distinguish an initial load/error from a confirmed missing key. +- **Impact**: Users briefly—or permanently after a storage error—see an incorrect missing-key message and disabled submission, with no retry or accurate diagnosis. +- **Suggested fix**: Render explicit loading and error states and add delayed/error provider tests for `InspectorStatusBinding`. + +### [P2] Video-open failures leave the lightbox spinning forever +- **File**: lib/features/canvas/widgets/video_lightbox.dart:50 +- **Issue**: `_handle.open(path).then(...)` has no error path. +- **Evidence**: `_opened` becomes true only on success; build renders a spinner while false. The rejected future is otherwise unobserved. +- **Impact**: Missing, corrupt, or decoder-incompatible media causes an unhandled asynchronous error and an endless loading indicator. +- **Suggested fix**: Track loading/error/success explicitly, provide localized failure UI, and test open failure and disposal with a fake handle. + +### [P2] Canvas load errors have no retry path +- **File**: lib/features/canvas/widgets/canvas_view.dart:219 +- **Issue**: `_LoadError` only displays title and message. +- **Evidence**: It has no callback, provider invalidation, or retry control. +- **Impact**: A transient database/read failure leaves the canvas trapped in its error state until some unrelated invalidation or navigation occurs. +- **Suggested fix**: Add a localized retry action that invalidates/reloads the nodes provider and test recovery after an initial failure. + +### [P2] Connection and lane mutation failures are hidden or unhandled +- **File**: lib/features/canvas/widgets/node_inputs_section.dart:208; lib/features/canvas/widgets/canvas_view.dart:390; lib/features/canvas/widgets/canvas_view.dart:805 +- **Issue**: Role/removal failures are swallowed, edge-delete errors are caught without feedback, and lane reorder is fired without handling its future. +- **Evidence**: The relevant paths use empty `catchError`/catch bodies or do not await the returned future. +- **Impact**: Failed changes silently snap back, edge deletion appears to do nothing after selection is cleared, and lane persistence failures can surface as unhandled asynchronous errors. +- **Suggested fix**: Route mutation failures through one localized canvas error surface and retain/recover the affected selection. + +### [P2] Core custom interactions are not keyboard or screen-reader complete +- **File**: lib/features/canvas/widgets/node_card.dart:139; lib/features/canvas/widgets/canvas_view.dart:79; lib/features/canvas/widgets/lane_edit_dialog.dart:193; lib/features/canvas/widgets/inspector_chip.dart:38 +- **Issue**: Node and edge selection rely on pointer gestures; lane swatches lack keyboard activation and accessible color labels; chips do not expose their selected state. +- **Evidence**: These surfaces use `GestureDetector` or visual-only state without corresponding `Focus`, actions, or stateful `Semantics`. Edge deletion also has no accessible label. +- **Impact**: Keyboard-only users cannot select individual nodes/edges or choose a lane tint reliably, while screen readers cannot identify colors or whether a character chip is attached. +- **Suggested fix**: Add focusable actions, localized semantic labels, `Semantics(selected: ...)`, and keyboard traversal tests. + +### [P2] Video node rendering performs synchronous filesystem I/O +- **File**: lib/features/canvas/widgets/video_node_body.dart:114 +- **Issue**: `_ThumbnailOrBroken` calls `File.existsSync()` from widget lifecycle code. +- **Evidence**: Media-path resolution and existence checking happen inside the rendering component rather than a provider/controller. +- **Impact**: A slow or network-backed application-data directory can block the UI isolate while cards mount, particularly on canvases containing many videos. +- **Suggested fix**: Remove the pre-stat and rely on `Image.file` error handling, or resolve/cache availability asynchronously outside the widget. + +### [P2] Hardcoded visual values bypass the design-token policy +- **File**: lib/features/canvas/widgets/lane_edit_dialog.dart:25; lib/features/canvas/widgets/image_config_inspector.dart:209; lib/features/canvas/widgets/inspector_status_panel.dart:256 +- **Issue**: Canvas widgets still contain hardcoded palette colors, inspector width `320`, and spacing `2`. +- **Evidence**: The lane palette is independently duplicated as raw hex values; the inspector width is repeated across four files; the status panel uses `SizedBox(height: 2)`. +- **Impact**: Theme/layout changes can drift across inspectors, and the lane palette can diverge from tint interpretation elsewhere. +- **Suggested fix**: Introduce semantic palette, inspector-width, and spacing tokens and consume them consistently. + +### [P3] Initial asynchronous loads masquerade as empty content +- **File**: lib/features/canvas/widgets/characters_section.dart:131; lib/features/canvas/widgets/node_inputs_section.dart:61; lib/features/canvas/widgets/image_config_inspector.dart:497; lib/features/canvas/widgets/batch_results_grid.dart:28 +- **Issue**: Initial loading is commonly reduced to `valueOrNull ?? []` or `SizedBox.shrink()`. +- **Evidence**: Characters, presets, and inputs render their empty states while loading; batch results render nothing. +- **Impact**: Users can see misleading "empty" calls to action or a disappearing result inspector while data is still being read. +- **Suggested fix**: Use explicit `AsyncValue` loading/error/data branches and preserve stale data during refresh. + +### [P3] Empty names close the dialog without validation feedback +- **File**: lib/features/canvas/widgets/name_dialog.dart:49 +- **Issue**: Confirm always returns the raw text, including empty or whitespace-only input. +- **Evidence**: Callers such as `characters_section.dart:211` trim the result and silently return after the dialog has already closed. +- **Impact**: Clicking Save with an empty name appears to succeed but creates nothing and explains nothing. +- **Suggested fix**: Trim and validate inside the dialog, disable confirmation while invalid, and keep the dialog open with localized feedback. + +### [P3] Long inspector-chip labels can overflow +- **File**: lib/features/canvas/widgets/inspector_chip.dart:76 +- **Issue**: The label `Text` is a non-flex child in a `Row` with no line or overflow constraint. +- **Evidence**: User-created character/preset names have no equivalent display-length restriction, while tests exercise only short labels. +- **Impact**: Long names produce RenderFlex overflow stripes or collide with the selected indicator in the narrow inspector. +- **Suggested fix**: Wrap the text in `Flexible` and apply `maxLines: 1` plus ellipsis; add a constrained long-label test. + +### [P3] Inspector panel scaffolding is duplicated four times +- **File**: lib/features/canvas/widgets/image_config_inspector.dart:208; lib/features/canvas/widgets/video_config_inspector.dart:144; lib/features/canvas/widgets/shot_config_inspector.dart:191; lib/features/canvas/widgets/image_result_inspector.dart:28 +- **Issue**: Width, padding, surface, border, scrolling, and column scaffolding are nearly identical. +- **Evidence**: Each implementation independently declares the same `Container`/`SingleChildScrollView` structure. +- **Impact**: Accessibility, sizing, or visual changes can be applied to some inspector variants but missed in others. +- **Suggested fix**: Extract a shared `InspectorPanelScaffold`. + +### [P3] Connection painter behavior has no direct rendering coverage +- **File**: lib/features/canvas/widgets/edge_painter.dart:44 +- **Issue**: No scoped test directly exercises or golden-tests `EdgePainter`. +- **Evidence**: Existing geometry and canvas tests do not verify actual painted data/narrative styles, vertical arrows, selected styling, ports, or `shouldRepaint`. +- **Impact**: Connection-style and repaint regressions can pass the suite while materially breaking canvas readability. +- **Suggested fix**: Add painter/golden cases for both canvas directions, both edge types, selection, port markers, and repaint transitions. diff --git a/docs/review/2026-08-31/W5.md b/docs/review/2026-08-31/W5.md new file mode 100644 index 00000000..4c6363f2 --- /dev/null +++ b/docs/review/2026-08-31/W5.md @@ -0,0 +1,63 @@ +# W5 — generation + export + +**CONCLUSION**: 8 findings — P0: 0, P1: 2, P2: 3, P3: 3 + +## Findings + +### [P1] Unbounded batch size bypasses provider capabilities +- **File**: lib/features/generation/generation_controller.dart:195 +- **Issue**: Any positive image `batch_size` is accepted without checking `supportsBatch` or `maxBatchSize`. +- **Evidence**: `batchSize` is copied directly from `type_config`, then used to create that many slot rows and passed to the provider. Existing tests even submit batch 3 using fake capabilities declaring `supportsBatch: false` and `maxBatchSize: 1`, masking the missing validation. +- **Impact**: A crafted or stale node can create an arbitrarily large transaction/slot loop, potentially freezing the app or database. Smaller unsupported values produce rejected or inconsistent provider jobs. +- **Suggested fix**: Validate against provider capabilities before starting the transaction and return an `InkError` for unsupported or out-of-range values. + +### [P1] Repository errors are swallowed before submitting a materially different, billable task +- **File**: lib/features/generation/generation_controller.dart:552 +- **Issue**: Failures loading incoming edges, reference nodes, paths, canvas style, lane style, characters, or associated text are logged and converted to empty inputs. +- **Evidence**: `_incomingEdges` returns `[]` on `InkError`; similar swallowing occurs at lines 589, 612, 686, 746, 758, and 794. Submission then continues with missing references, text, or styling. +- **Impact**: A transient database/path failure can silently turn image-to-image into text-to-image or omit prompt context while still charging for generation. The `InkError` never reaches the UI. +- **Suggested fix**: Propagate authoritative read failures. If degradation is an intentional product behavior, expose a localized warning and require explicit confirmation before submission. + +### [P2] Direct reference edges bypass provider capability checks +- **File**: lib/features/generation/generation_controller.dart:222 +- **Issue**: Connected reference images are always resolved and any non-empty list forces `GenerationMode.imageToImage`; only character references use the recently added capability gate. +- **Evidence**: Lines 256–258 infer image-to-image purely from `refs.refImagePaths`. The only subsequent capability checks cover first/last frames. The direct-reference test uses a fake provider with `maxRefImages: 0` and only `textToImage`, yet expects an image-to-image task. +- **Impact**: Connecting a reference—or switching an existing referenced node to Gemini, OpenAI Image, or Stability—submits an unsupported mode and guarantees provider failure. +- **Suggested fix**: Validate direct references against the same image/video capability policy before creating result/job rows, with an adversarial test for a text-only provider. + +### [P2] Cleanup failure can prevent every terminal generation state +- **File**: lib/features/generation/generation_controller.dart:503 +- **Issue**: If terminal processing throws—commonly because `nodes.softDelete` fails—the catch block immediately calls the same `softDelete` again before recording `JobFailed`. +- **Evidence**: The original cleanup at lines 468/480 can enter the catch; line 511 retries it without protection, and only line 512 updates the registry. `_track` itself is launched unawaited. +- **Impact**: During a repository outage, the second cleanup throws too, producing an unhandled asynchronous error while the job remains indefinitely queued/running in the UI. +- **Suggested fix**: Make cleanup independently best-effort and guarantee the terminal registry transition in a `finally`-style path. Add tests where `softDelete` throws. + +### [P2] Export's no-narrative position fallback is not actually applied +- **File**: lib/features/export/util/export_order.dart:45 +- **Issue**: `orderByNarrativeChain` returns a total order including isolated source nodes. Their artifacts are therefore marked `taken` during the chain pass and never reach the result-node `position.x` fallback. +- **Evidence**: With no edges, source `s1(x=500) → v1(x=0)` and source `s2(x=0) → v2(x=500)` exports as `v2, v1`, contrary to the documented `v1, v2` fallback. The existing fallback test has both results under one source, so it passes accidentally. +- **Impact**: Projects without narrative edges—or with isolated sources alongside a partial chain—can export shots in the wrong order. +- **Suggested fix**: Apply the chain pass only to nodes participating in live narrative edges; sort all remaining artifacts by their own x-position. Add inverse source/result-position and mixed-chain tests. + +### [P3] Generation validation errors bypass the InkError taxonomy +- **File**: lib/features/generation/generation_controller.dart:98 +- **Issue**: `GenerationError implements Exception` introduces a parallel error hierarchy for missing keys, invalid configuration, and unregistered providers. +- **Evidence**: These errors require bespoke UI catches instead of the standard `InkError`/`AsyncValue` localization path. `InvalidGenerationConfigError` also carries internal English reason strings. +- **Impact**: Every consumer must duplicate error mapping; a new subtype or consumer can fall through to an unknown error or expose an untranslated diagnostic. +- **Suggested fix**: Represent validation failures with suitable `InkError` codes and structured `extra` values. + +### [P3] Export widget contains hardcoded visual metrics +- **File**: lib/features/export/widgets/export_video_dialog.dart:24 +- **Issue**: The widget bypasses design tokens for thumbnail dimensions, dialog limits, button constraints, and splash radii. +- **Evidence**: Hardcoded values include `64 × 36`, `520 × 560` at line 117, `28 × 28` at lines 267/279, and radius `16` at lines 268/280—even though token values such as `InkSpacing.s28` exist. +- **Impact**: The dialog can drift from density/accessibility changes and violates the repository's token-only widget styling rule. +- **Suggested fix**: Move component dimensions into shared layout/component tokens and use existing spacing tokens where applicable. + +### [P3] JobSubmitting is a dead state +- **File**: lib/features/generation/models/job_state.dart:36 +- **Issue**: The documented `queued → submitting → running` transition is impossible in production. +- **Evidence**: No production code constructs `JobState.submitting`; the controller emits `queued`, then maps the first `JobInProgress` directly to `running`. References outside generated code are only pattern matches and unit construction tests. +- **Impact**: It adds generated code and UI branches that cannot execute, while misleading future state-machine work. +- **Suggested fix**: Remove the state or add a real queue event and transition representing provider submission. + +**Clean sub-areas**: Output filename validation matches the service-side plain-filename rules, and no string-based output path escape survived testing. Export cancellation, overwrite handling, progress, and partial-file semantics are coherent. DI wiring is interface/provider-based. The recent `supportsCharacterRefsFor(nodeType)` modification matches the BOARD's deliberate image/video split. No hardcoded user-facing widget strings, colors, or typography survived review. diff --git a/docs/review/2026-08-31/W6.md b/docs/review/2026-08-31/W6.md new file mode 100644 index 00000000..1d2feb28 --- /dev/null +++ b/docs/review/2026-08-31/W6.md @@ -0,0 +1,133 @@ +# W6 — studio + command_palette + startup + +**CONCLUSION**: 10 findings (P0: 0, P1: 1, P2: 4, P3: 5). Soft deletion/recovery does not invoke hard deletion and preserves child data; the BOARD-tracked restore-name collision remains but is not double-counted. Initial PG bootstrap/migration failures reach `StartupErrorView` correctly. Command counts and routing are otherwise context-aware and remain ≤6. No additional security, performance, or dead-code issue survived falsification. + +## Findings + +### [P1] CRUD operations can commit and then fail through a disposed controller Ref +- **File**: lib/features/studio/controllers/studio_projects_controller.dart:11 +- **Issue**: `studioProjectsControllerProvider` is `autoDispose`, every caller only uses `ref.read`, and every successful mutation calls `_ref.invalidate(...)` after one or more database awaits. +- **Evidence**: + ```dart + final studioProjectsControllerProvider = + Provider.autoDispose(...); + + await repo.softDelete(id); + _ref.invalidate(workspaceProjectsProvider); + ``` + There is no `watch` or `listen` call keeping the controller alive. Once a real PG operation spans the auto-dispose grace period, its `Ref` is disposed before the invalidate at lines 32, 42, 49, 59, 66, 73, or 80. Existing tests use immediately completing in-memory repositories, so they do not cross this lifecycle boundary. +- **Impact**: Create, rename, delete, or restore can succeed in PostgreSQL and then throw a non-`InkError` `StateError`. Callers only catch `InkError`, so the list remains stale and the user may retry an operation that already committed. +- **Suggested fix**: Keep the provider alive for each in-flight mutation, or implement this as a watched `AsyncNotifier`/controller with an explicit operation lifecycle. Add delayed-repository tests that pump past one frame. + +### [P2] Opening a project with no canvases uses a WidgetRef after an await +- **File**: lib/features/studio/open_canvas.dart:22 +- **Issue**: `openProjectCanvas` awaits canvas creation and only afterwards calls the injected `read`; production passes `_ProjectGrid`'s `WidgetRef.read`. +- **Evidence**: + ```dart + canvasId = await createCanvas(project.id); + read(currentCanvasIdProvider.notifier).state = canvasId; + ``` + The project card remains enabled, and Studio's Settings navigation remains available while creation is pending. +- **Impact**: If the user leaves Studio during a slow create, the canvas is committed but the disposed `WidgetRef` throws before it can be opened or recorded in preferences. The caller catches only `InkError`, so this becomes an unhandled error with a newly created but apparently unexplained canvas. +- **Suggested fix**: Capture the canvas notifier and preferences service before awaiting, or move the complete open/create operation into a lifecycle-managed controller. Add an unmount-during-create test. + +### [P2] Sample-project actions are re-entrant and can create duplicate projects +- **File**: lib/features/studio/widgets/onboarding_dialog.dart:51; lib/features/studio/studio_home_screen.dart:268 +- **Issue**: Neither sample-project entry has an in-flight guard, and both buttons remain enabled while `createSample` performs a four-write transaction. +- **Evidence**: + ```dart + onPressed: _createSample + ``` + and: + ```dart + onPressed: onCreateSample + ``` + The button primitives do not internally suppress repeated taps. `CanvasBootstrapController.createSample` creates fresh UUIDs on every call; its own test confirms repeated calls accumulate projects. +- **Impact**: A normal desktop double-click can launch two transactions, creating two "Sample Project" trees. Whichever finishes last becomes the active canvas, leaving the other duplicate in the library. +- **Suggested fix**: Add a shared `_working`/single-flight guard, disable all completion buttons while active, and test rapid repeated taps. + +### [P2] A non-PgLifecycle stop failure permanently disables startup retry +- **File**: lib/features/startup/widgets/startup_error_view.dart:151 +- **Issue**: `_reboot` resets `_working` only on its normal path and catches only `PgLifecycleError`. +- **Evidence**: + ```dart + setState(() => _working = true); + try { + await ref.read(pgControllerProvider).stop(); + } on PgLifecycleError { + // continue + } + ... + if (mounted) setState(() => _working = false); + ``` + `PgController.stop()` can also throw `PgBinaryNotFoundError` from binary lookup or `ProcessException` while spawning `pg_ctl`; neither reaches the reset. +- **Impact**: With a damaged/quarantined PG binary or stop-spawn failure, Retry throws asynchronously, never rebuilds the provider chain, and remains disabled for the rest of the session. Restarting the application is the only escape. +- **Suggested fix**: Normalize all lifecycle failures at the controller boundary and use `finally` to restore UI state. Do not start a replacement controller until stop failure has been resolved safely. + +### [P2] Project export does not surface file-picker failures +- **File**: lib/features/studio/studio_home_screen.dart:700 +- **Issue**: The save picker is inside a block that catches only `InkError`, but the production `file_selector` adapter does not translate method-channel/platform failures into `InkError`. +- **Evidence**: + ```dart + final path = await picker(suggestedArchiveName(p.name)); + ... + } on InkError catch (e, st) { + toast.show(failedMsg, kind: ToastKind.error); + } + ``` + The import path explicitly catches and logs picker exceptions; export lacks that protection. Tests cover cancellation and service `InkError`, but not picker failure. +- **Impact**: An OS dialog/plugin failure produces an uncaught asynchronous error and no user-facing export failure message. The busy flag resets, but the user receives no explanation or recovery direction. +- **Suggested fix**: Translate picker exceptions to `LocalIOError` in the DI adapter, or catch the specific platform exception, log it, and display the localized failure toast. + +### [P3] Studio's project-list error state discards the InkError +- **File**: lib/features/studio/studio_home_screen.dart:115 +- **Issue**: The `AsyncError` value is ignored, and the error state renders a generic title followed by the empty-library subtitle. +- **Evidence**: + ```dart + error: (e, _) => _StudioErrorState(...), + + InkErrorBanner(message: context.l10n.studioErrorTitle), + Text(context.l10n.studioEmptySubtitle), + ``` + `l10nAsyncError` is already used elsewhere in the same file but not here. +- **Impact**: A repository `LocalIOError` or other actionable `InkError` is reduced to "Failed to load projects / Create your first project…", incorrectly implying an empty workspace and hiding the actual failure category. +- **Suggested fix**: Pass the error into `_StudioErrorState`, render `l10nAsyncError(context, error)`, and use load-failure-specific supporting copy. + +### [P3] Startup UI directly depends on the concrete PostgreSQL controller +- **File**: lib/features/startup/widgets/startup_error_view.dart:22 +- **Issue**: The widget imports `storage/pg_controller.dart`, reads `pgControllerProvider` as a concrete `PgController`, and catches its concrete exception. +- **Evidence**: + ```dart + await ref.read(pgControllerProvider).stop(); + } on PgLifecycleError { + ``` + This violates the project rules that every injectable have an abstract interface and widgets depend only on abstractions. Tests must subclass and construct `PgController` rather than provide a small lifecycle fake. +- **Impact**: PG process semantics leak into the presentation layer, making lifecycle changes and exhaustive failure testing harder and contributing directly to the missed exception cases above. +- **Suggested fix**: Introduce an abstract database lifecycle/restart interface and inject it through an interface-typed provider. + +### [P3] Studio view models violate the Freezed/immutability rule +- **File**: lib/features/studio/models/project_with_canvases.dart:6; lib/features/studio/providers/trashed_items_providers.dart:11 +- **Issue**: `ProjectWithCanvases`, `CanvasRef`, and `TrashedItem` are handwritten classes rather than Freezed models. `ProjectWithCanvases.canvases` exposes a mutable `List`. +- **Evidence**: + ```dart + class ProjectWithCanvases { + final List canvases; + } + ``` + ADR-0003 explicitly requires UI/ViewModel/Service models to be Freezed. +- **Impact**: Provider snapshots can be mutated in place without Riverpod notification, and the models lack generated value equality, defensive list wrapping, and consistent serialization/copy semantics. +- **Suggested fix**: Convert these types to Freezed models with immutable collection handling. + +### [P3] Scoped widgets contain widespread hardcoded visual values +- **File**: lib/features/studio/widgets/library_sidebar.dart:29; lib/features/studio/widgets/onboarding_dialog.dart:129; lib/features/studio/studio_home_screen.dart:530; lib/features/command_palette/widgets/command_palette_dialog.dart:88; lib/features/startup/widgets/startup_error_view.dart:80 +- **Issue**: Layout widths, heights, icon sizes, aspect ratios, and letter spacing are embedded directly in widgets instead of theme/layout tokens. +- **Evidence**: Examples include `width: 280`, `BoxConstraints(maxWidth: 560, maxHeight: 640)`, `maxWidth: 440/480/520`, `size: 16/18/32`, and `letterSpacing: 1.5`. This extends beyond the BOARD's already-tracked icon/control-token debt. +- **Impact**: Density, accessibility, and future theme changes cannot adjust these surfaces centrally; fixed dimensions can drift or overflow under increased text scale. +- **Suggested fix**: Add layout, icon-size, control-height, and tracking tokens, then extend the style regression test to cover these literal categories. + +### [P3] Command-palette tests do not cover the full context/action matrix +- **File**: test/features/command_palette/command_palette_test.dart:117 +- **Issue**: The Studio test is stale, gallery/settings contexts are absent, and the export action is checked only for visibility. +- **Evidence**: The test is named "studio context only Open settings", while production now returns both `openShowcase` and `openSettings`. No test configures `currentGalleryProjectProvider` or `AppScreen.settings`, and the export test never executes the action or verifies that the export dialog receives the correct project and ordered nodes. +- **Impact**: A missing, extra, or nonfunctional context action can pass the suite—the precise regression class PL-1/D-8 is intended to prevent. +- **Suggested fix**: Add a table-driven test asserting exact action IDs and `length <= 6` for every context, then execute every action with provider/dialog seams overridden. diff --git a/docs/review/2026-08-31/W7.md b/docs/review/2026-08-31/W7.md new file mode 100644 index 00000000..7f2e35a9 --- /dev/null +++ b/docs/review/2026-08-31/W7.md @@ -0,0 +1,84 @@ +# W7 — settings + +**CONCLUSION**: 11 findings: P0 0, P1 0, P2 7, P3 4 + +## Findings + +### [P2] API keys are displayed as ordinary cleartext +- **File**: lib/features/settings/widgets/api_keys_section.dart:179 +- **Issue**: The API-key field uses `InkInput` without an obscured/password mode. +- **Evidence**: `InkInput(controller: _ctrl, ...)` ultimately creates a `TextField` with no `obscureText`; the widget tests enter secrets but never assert masking. +- **Impact**: Provider keys are visible to shoulder surfing, screenshots, screen sharing, and recordings, including rejected keys deliberately retained for editing. +- **Suggested fix**: Add an `obscureText` option to `InkInput`, enable it here, disable suggestions/personalized learning, and provide an explicit reveal toggle. + +### [P2] API-key load failures are misreported as "Not set" +- **File**: lib/features/settings/widgets/api_keys_section.dart:134 +- **Issue**: The `AsyncError` state from `SecureStorageService.exists()` is discarded. +- **Evidence**: `final isSet = keyState.valueOrNull ?? false;` converts an error into `false`; `loading` is also false, so the row displays "Not set," enables Save, and disables Clear without showing `l10nError`. +- **Impact**: If Credential Manager/Keychain access fails, users may believe no key exists and cannot clear an existing credential through the UI. +- **Suggested fix**: Render the `AsyncError` explicitly, disable mutations until state is known, and offer retry. Add an `exists()`-failure widget test. + +### [P2] Save and Clear operations are not single-flight +- **File**: lib/features/settings/widgets/api_keys_section.dart:187 +- **Issue**: Buttons are disabled only while the controller's initial `build()` is loading; `save()` and `clear()` never put the state into a busy state. +- **Evidence**: `ApiKeyScopeController.save()` awaits remote validation while leaving `state` as `AsyncData`; therefore `keyState.isLoading` stays false and both buttons remain actionable. +- **Impact**: Two saves can complete out of order, or Clear can report success before an older in-flight Save writes the key back. +- **Suggested fix**: Serialize mutations and expose a mutation-busy state used to disable both buttons. Test with `Completer`-controlled validation/storage calls. + +### [P2] Debug macOS storage errors can expose the plaintext secrets file +- **File**: lib/features/settings/widgets/about_section.dart:65 +- **Issue**: The secure-storage probe catches every exception and displays `e.toString()` verbatim. +- **Evidence**: The Debug+macOS file backend decodes the one-line `secrets.dev.json` with `jsonDecode`. A `FormatException.toString()` can include its source line—which can contain every stored API key—and lines 138–150 render the result as selectable text. The test at `about_section_test.dart:114` explicitly requires raw exception text and has no redaction assertion. +- **Impact**: A partially corrupted development secrets file can put provider keys directly on the Settings screen during screen sharing or diagnostics. +- **Suggested fix**: Translate file-backend failures to `LocalIOError` and display only a redacted error code/reason. Never surface raw parser source. + +### [P3] Debug macOS plaintext storage is falsely labeled as Keychain +- **File**: lib/features/settings/widgets/about_section.dart:78 +- **Issue**: Backend labeling is based only on `defaultTargetPlatform`. +- **Evidence**: `_backendLabel()` always returns `Keychain` for macOS, while production DI selects `FileSecureStorageService` when `kDebugMode && Platform.isMacOS`. +- **Impact**: Developers see "Available (Keychain)" even though their API keys are stored in plaintext `secrets.dev.json`. +- **Suggested fix**: Expose backend metadata through DI and label the development-file backend explicitly, preferably with warning styling. + +### [P2] Preference save and load failures are intentionally silent +- **File**: lib/features/settings/widgets/theme_section.dart:25 +- **Issue**: Theme, contrast, text-scale, language, canvas-color, and update-check controls expose synchronous/void setters with no persistence outcome. +- **Evidence**: The downstream controllers fire `PreferencesService.update()` with `unawaited`; the real `FilePreferencesService` catches `FileSystemException` on both load and save and neither throws `InkError` nor logs or informs the UI. The in-scope update-check setter repeats this at `update_check_controller.dart:87–93`. +- **Impact**: On a read-only/full/temporarily unavailable config directory, controls appear saved but revert after restart. A failed startup read can also seed defaults without telling the user. +- **Suggested fix**: Return/propagate `LocalIOError`, expose persistence as `AsyncValue`, and either roll back optimistic state or show a persistent "not saved" error. + +### [P2] Persisted text scale is not constrained to the Slider contract +- **File**: lib/features/settings/widgets/theme_section.dart:77 +- **Issue**: `state.textScale` is passed directly to a Slider limited to `0.85…1.40`. +- **Evidence**: `AppPreferences.fromMap` accepts any numeric `text_scale`; the repository's file-persistence test explicitly round-trips `1.5`. Opening this section with that valid persisted value violates Flutter Slider's `min <= value <= max` assertion. +- **Impact**: Debug desktop builds can crash when Settings opens; release behavior is outside the control's supported range. +- **Suggested fix**: Centralize scale bounds and clamp/validate both deserialization and controller setters. Add a persisted-out-of-range widget test. + +### [P2] Backup UI directly depends on the concrete PostgreSQL lifecycle controller +- **File**: lib/features/settings/widgets/backup_section.dart:13 +- **Issue**: A widget imports `pg_controller.dart`, starts PostgreSQL, inspects `PgRuntime`, and handles the database password itself. +- **Evidence**: Lines 157–179 read `pgControllerProvider`, call `controller.start()`, and pass `runtime.password`. The DI module itself states that widget/viewmodel imports are an anti-pattern, and the test must fake the concrete `PgController` using `implements` plus `noSuchMethod`. +- **Impact**: This violates the required abstract-interface DI boundary and places sensitive lifecycle/connection assembly in presentation code. +- **Suggested fix**: Introduce an abstract manual-backup flow/controller that owns PostgreSQL startup and `BackupConnection` construction; the widget should consume only its outcome state. + +### [P3] User-visible strings bypass ARB localization +- **File**: lib/features/settings/widgets/custom_providers_section.dart:399 +- **Issue**: Visible field hints are hardcoded (`'my-openrouter'` and `'https://openrouter.ai/api/v1'`). +- **Evidence**: These literals flow directly into `InkInput.hintText`. `about_section.dart:78–92` likewise hardcodes backend names rather than sourcing them through ARB. +- **Impact**: Settings copy bypasses en/zh key parity and cannot be localized or revised centrally. +- **Suggested fix**: Move visible examples/backend labels to ARB keys, even where translated values intentionally remain identical. + +### [P3] Settings contains numerous untokenized visual dimensions +- **File**: lib/features/settings/settings_screen.dart:42 +- **Issue**: Layout widths, indicator sizes, stroke widths, and border widths are embedded as numeric literals. +- **Evidence**: Examples include `maxWidth: 720`; About label width `160`; custom-provider dialog width `420`; API-key indicator `12×12`/stroke `2`; backup indicator `24×24`/stroke `2`; theme value width `56`; and swatch borders `2.5/1.0`. +- **Impact**: The feature violates the zero-hardcoded-style rule and bypasses centralized layout/accessibility tuning. +- **Suggested fix**: Add semantic layout/control/stroke tokens and extend the style hygiene test to cover width, height, and stroke literals. + +### [P3] Clipboard failures escape as raw platform exceptions +- **File**: lib/features/settings/widgets/storage_path_section.dart:63 +- **Issue**: The widget directly awaits static `Clipboard.setData` without an error boundary. +- **Evidence**: A thrown `PlatformException` bypasses `InkError` and produces no failure feedback. `test/features/settings/storage_path_section_test.dart` only reads the paths provider and checks the widget type; it never exercises rendering, copying, or failure. +- **Impact**: Clipboard restrictions or platform-channel failures produce an unhandled async error when the user clicks Copy. +- **Suggested fix**: Inject a clipboard abstraction that maps failures to `LocalIOError`, show localized failure feedback, and add success/failure interaction tests. + +**Clean sub-areas**: the plaintext fallback gate itself is correctly restricted to the exact conjunction `kDebugMode && Platform.isMacOS`; Windows Debug and all Release builds select `PlatformSecureStorageService`, and no settings code directly instantiates the file backend. Apart from the findings above, provider scope folding, per-provider validation outcomes, secure-storage-only key persistence, and absence of key logging/clipboard export are clean. Normal-range theme/language/contrast/scale round-tripping is correctly wired when I/O succeeds. No additional untracked dead-code or duplication findings survived; the existing GAP-1 and ON-3 debts already recorded in `docs/BOARD.md` were not recounted. diff --git a/docs/review/2026-08-31/W8.md b/docs/review/2026-08-31/W8.md new file mode 100644 index 00000000..ef442d68 --- /dev/null +++ b/docs/review/2026-08-31/W8.md @@ -0,0 +1,81 @@ +# W8 — storyboard + gallery + showcase + +**CONCLUSION**: 10 findings — 1 P1, 6 P2, 3 P3; no P0. + +## Findings + +### [P1] Result-less generated configs are not folded into their parent shot +- **File**: lib/features/storyboard/util/sequence_builder.dart:67 +- **Issue**: A successor config is marked consumed only when it already has a result artifact. Pending or failed configs remain separate playlist entries. Only the first artifact-bearing child is consumed, so additional generated configs also become extra shots. +- **Evidence**: `consumed.add(succ.id)` occurs only after `borrowed != null`, followed by `break`. Later, `_notesOf` treats an unconsumed config's `prompt` as displayable notes. +- **Impact**: Immediately after "generate image/video from notes" creates `shot → config`, but before generation succeeds, one storyboard shot previews twice: once as the shot placeholder and again as the config prompt. Failed generations make the duplication permanent. Creating both image and video configs can add still more entries. +- **Suggested fix**: Fold direct generated-config children regardless of result state, select one artifact using an explicit rule, and consume all children representing alternatives for that shot. Add result-less and multiple-child tests. + +### [P2] Index stripping corrupts valid script content +- **File**: lib/features/storyboard/util/script_splitter.dart:103 +- **Issue**: The prefix regex is too permissive, while label truncation operates on UTF-16 code units. +- **Evidence**: + - Bare-number separators need no following whitespace, so `16:9 establishing shot` becomes `9 establishing shot` and `3-point lighting` becomes `point lighting`. + - `镜` is optional after `第N`, so `第3季 夜景` becomes `季 夜景`. + - `shot N` needs no boundary, so `Shot 35mm close-up` becomes `mm close-up`. + - Lines 131–133 use `substring(0, 60)`, which can split an emoji surrogate pair or grapheme cluster. +- **Impact**: Valid visual instructions are silently changed before becoming `shot_notes` and generation prompts; boundary Unicode can also produce a broken node label. +- **Suggested fix**: Require a separator boundary suitable for numbering, make `第N镜` explicit, and truncate with grapheme clusters. Add aspect-ratio, ordinal-content, lens-unit, and emoji-boundary tests. + +### [P2] Dismissing an in-flight import can pop the underlying app route +- **File**: lib/features/storyboard/widgets/script_import_dialog.dart:24 +- **Issue**: `showDialog` retains its default `barrierDismissible: true`. Although buttons are disabled while busy, the user can dismiss the dialog via its barrier; after the transaction finishes, line 76 calls the previously captured `navigator.pop()` again. +- **Evidence**: There is no `PopScope`, fixed non-dismissible barrier, or `mounted` guard around the success pop. +- **Impact**: With a large/slow import, clicking outside the dialog can dismiss it; successful completion then pops the canvas/initial route and may navigate away or close the window after data has committed. +- **Suggested fix**: Make the import dialog non-dismissible during the operation and guard the success pop with `mounted`. Add a delayed-UoW barrier-dismissal test. + +### [P2] Leaving a video shot does not stop its player +- **File**: lib/features/storyboard/widgets/sequence_preview_dialog.dart:123 +- **Issue**: `_enterShot` cancels the timer and position subscription but never pauses the handle when moving from video to image/placeholder or when reaching the end. `pause()` is only called by the explicit play/pause control. +- **Evidence**: `_step` directly invokes `_enterShot`; the end branch sets `_playing = false` without touching `_handle`. +- **Impact**: Manual Next, or a fallback timer firing before the real video ends, hides the video while its audio continues behind later shots. At sequence end the UI can show a paused state while playback continues. +- **Suggested fix**: Centralize shot-exit cleanup and pause/stop the current video before switching to a non-video shot or finishing. Assert `pauseCount` in video-to-image and fallback tests. + +### [P2] Player failures escape from discarded futures +- **File**: lib/features/storyboard/widgets/sequence_preview_dialog.dart:176 +- **Issue**: The unawaited async operation calls `handle.open`, `play`, and `pause` without an error handler. +- **Evidence**: The fallback timer prevents a sequencing hang, but it does not consume errors from the discarded Future. +- **Impact**: Missing/corrupt media or a player backend failure can become an uncaught zone error and crash report instead of a localized missing-media state; the preview later advances only because of the timer. +- **Suggested fix**: Normalize backend failures to `InkError` in the video service, handle them in the widget, and add a throwing fake-handle test. + +### [P2] Sequence construction is quadratic in canvas node count +- **File**: lib/features/storyboard/util/sequence_builder.dart:66 +- **Issue**: `latestResultFor` scans and allocates over the entire node list for every playlist candidate, and potentially again for each successor. +- **Evidence**: A chain of `V` nodes with no artifacts performs approximately `V × V` node inspections. `buildSequence` is invoked synchronously from the toolbar click before the dialog opens. +- **Impact**: Large storyboards or result-heavy canvases can freeze the desktop UI when Sequence Preview is opened. +- **Suggested fix**: Pre-index result nodes by `sourceNodeId` once, select each latest result once, and make construction approximately `O(V + E)`. + +### [P2] Gallery loading performs an unbounded cross-canvas N+1 query +- **File**: lib/features/gallery/providers/gallery_controller.dart:31 +- **Issue**: The controller lists canvases, then launches one `listByCanvas` query per canvas with unbounded `Future.wait`. Each query loads and decodes every node role, although the gallery needs only result media rows. The batch query is then executed separately. +- **Evidence**: Lines 41–43 create `C` repository queries for `C` canvases; `NodeRepository.listByCanvas` selects all live nodes. +- **Impact**: Projects with many canvases generate hundreds of queued database operations and transfer all config/text/shot nodes, causing slow gallery opens and avoidable pool/memory pressure. +- **Suggested fix**: Add an interface-backed project-level result projection using a single `nodes JOIN canvases` query, ideally returning only gallery columns. Add query-count/large-project coverage. + +### [P3] Script import controller families remain cached for the app lifetime +- **File**: lib/features/storyboard/providers/script_import_controller.dart:26 +- **Issue**: The controller intentionally uses an always-alive `Provider.family`, contrary to the screen-scoped controller lifecycle rule. +- **Evidence**: Every canvas ID read initializes another family element containing a `Ref`; without `autoDispose`, it remains until the root container is destroyed. +- **Impact**: Long-running sessions accumulate one controller/provider element per imported canvas, and the implementation institutionalizes a lifecycle exception. +- **Suggested fix**: Use `autoDispose.family` and keep the provider alive only for the operation with a manual subscription or scoped keep-alive. + +### [P3] Gallery bypasses the `InkError` boundary +- **File**: lib/features/gallery/providers/gallery_controller.dart:48; lib/features/gallery/widgets/gallery_tile.dart:300 +- **Issue**: Row parsing may propagate raw `FormatException`, while the character-save widget imports and catches `CharacterAssetError` and `FileSystemException` directly. +- **Evidence**: The controller explicitly documents raw parse failures; the widget maintains a three-type exception matrix instead of consuming only `InkError`. +- **Impact**: Widgets are coupled to storage/OS failure types, retry and diagnostic semantics are inconsistent, and any newly introduced concrete exception can escape as an uncaught error. +- **Suggested fix**: Translate parse and asset/filesystem failures to `LocalIOError` or `UnknownError` at provider/controller/service boundaries, leaving the widget with one `InkError` path. + +### [P3] User-visible formatting and visual values bypass i18n/design tokens +- **File**: lib/features/storyboard/widgets/script_import_dialog.dart:99; lib/features/gallery/widgets/gallery_tile.dart:216; lib/features/showcase/widgets/built_in_showcase_screen.dart:34 +- **Issue**: In-scope widgets retain literal dimensions/icon sizes and an inline alpha-derived color; script preview rows also hardcode the user-visible `'${i + 1}. …'` format at line 204. +- **Evidence**: Examples include dialog width `520`, preview height `148`, multiple raw icon sizes, `surface1.withValues(alpha: 0.8)`, and the non-ARB list-item pattern. +- **Impact**: Presentation cannot be centrally adjusted across themes/layouts, the alpha variant can drift in high-contrast mode, and list punctuation/order cannot be localized. +- **Suggested fix**: Move semantic layout/icon/color values into theme tokens and add an ARB formatter for preview rows; expand quality guards to cover these currently missed forms. + +**Explicitly clean sub-areas**: Script import's database writes genuinely share one PostgreSQL `runTx` scope, so node or edge failure rolls the entire import back with zero residue. Gallery dedup by `(canvasId, relativePath)` correctly collapses node-main/batch-slot duplicates without conflating same-named files on different canvases. Showcase asset packaging, responsive branching, routing, and error fallback are otherwise clean. No material dead code or duplicated domain logic survived review. diff --git a/docs/review/2026-08-31/W9.md b/docs/review/2026-08-31/W9.md new file mode 100644 index 00000000..a28c0875 --- /dev/null +++ b/docs/review/2026-08-31/W9.md @@ -0,0 +1,89 @@ +# W9 — AI provider adapters + +**CONCLUSION**: 12 findings (P0: 0, P1: 1, P2: 7, P3: 4). Token-bucket accounting/FIFO/disposal and per-provider DI isolation are sound. Dio exception-type coverage is exhaustive, ISP correctly avoids no-op cancellation implementations, and the wanx-i2v `input.media` contract is correct. + +## Findings + +### [P1] Free-form provider error messages can leak API keys into diagnostic context +- **File**: lib/providers/dio_error_mapper.dart:75; lib/providers/dashscope_async_provider_base.dart:178 +- **Issue**: Error messages copied into `InkError.extra` are truncated but never redacted. +- **Evidence**: `_bodyBrief` copies `message`/`msg` or a non-Map body verbatim for up to 300 characters. DashScope similarly stores raw `output.message` as `aliyun_message`. `InkError.toLogJson()` serializes `extra`. The mapper test only hides an unselected sibling field; it does not test a secret embedded in the selected message. +- **Impact**: An OpenAI-compatible endpoint, proxy, or provider returning `"Incorrect API key provided: sk-..."` in a 400/422 or FAILED response can place the credential in logs, diagnostics, or crash context. +- **Suggested fix**: Do not retain free-form provider messages, or centrally redact bearer tokens, `sk-*` values, and known key formats before storing them. Add a test with the secret inside `error.message`. + +### [P2] Gemini 200-level safety blocks are reported as retryable server failures +- **File**: lib/providers/gemini_image_provider.dart:135 +- **Issue**: Content-policy detection only examines HTTP 400 errors. +- **Evidence**: Gemini can return HTTP 200 with `promptFeedback.blockReason == "SAFETY"` and no candidates, or a candidate with a safety finish reason and no image. `_decodeInlineImage` maps missing candidates/inline image to retryable `providerServer` at lines 155–163 and 211–217. +- **Impact**: A policy-rejected prompt is presented as a transient provider outage, encouraging futile retries instead of an actionable content-policy error. +- **Suggested fix**: Inspect `promptFeedback.blockReason`, candidate `finishReason`, and safety ratings before normal image extraction and return `contentPolicy`. + +### [P2] DashScope's real balance and allocation-limit codes are not mapped +- **File**: lib/providers/dashscope_async_provider_base.dart:347 +- **Issue**: The business-code map recognizes `InsufficientBalance` but omits DashScope's `Arrearage`, and omits allocation-quota throttling variants such as `Throttling.AllocationQuota`. +- **Evidence**: All unrecognized codes fall through to retryable `providerServer`. +- **Impact**: An account in arrears is reported as a server outage, while an allocation-limit response is not classified as `providerBusy`; users receive the wrong remediation and may retry needlessly. +- **Suggested fix**: Map the complete DashScope code set, including `Arrearage` and all documented `Throttling.*` variants, with table-driven tests. + +### [P2] Malformed 2xx responses violate the `provider_invalid_response` contract +- **File**: lib/providers/dashscope_async_provider_base.dart:127; lib/providers/dashscope_async_provider_base.dart:158; lib/providers/openai_image_provider.dart:137; lib/providers/gemini_image_provider.dart:149 +- **Issue**: Missing required fields are frequently classified as retryable `providerServer`, while wrong field types can escape as raw `_TypeError`. +- **Evidence**: Missing DashScope `task_id`/`output`, empty Gemini candidates, and missing OpenAI `data`/`b64_json` use `providerServer`, contrary to PROVIDER-API §6. Unsafe casts include `task_status as String?`, `task_id as String?`, `video_url as String?`, and OpenAI `b64_json as String?`. +- **Impact**: A malformed poll response can be retried repeatedly until the 30-minute poll timeout; wrong types fall through JobQueue's `UnknownError` boundary instead of producing the required deterministic error. +- **Suggested fix**: Use shared type-safe extractors and classify every malformed 2xx response as non-retryable `providerInvalidResponse`. + +### [P2] Custom OpenAI-compatible providers lose content-policy classification +- **File**: lib/providers/openai_compatible_provider.dart:29; lib/providers/sync_provider_base.dart:72 +- **Issue**: The custom adapter does not override `contentPolicyFromDioError`. +- **Evidence**: A compatible endpoint returning OpenAI's HTTP 400 `content_policy_violation` therefore reaches `mapDioError` and becomes `invalidParameter`. The built-in OpenAI adapter explicitly handles this code, despite PROVIDER-API §13 requiring identical error mapping. +- **Impact**: Custom-provider users are told that their request parameters are invalid when the prompt was actually rejected by policy. +- **Suggested fix**: Share the OpenAI error-code parser between the built-in and compatible adapters. + +### [P2] Async adapters do not enforce their declared capabilities +- **File**: lib/providers/dashscope_async_provider_base.dart:106; lib/providers/wanx_t2v_provider.dart:79; lib/providers/wanx_image_provider.dart:88 +- **Issue**: The async base performs no common prompt, mode, resolution, duration, or batch validation. +- **Evidence**: T2V silently ignores an image-to-video mode and frame inputs; video adapters pass arbitrary positive durations despite declaring only 5/10 seconds; Wanx Image forwards any `batchSize` as `n`; unsupported size combinations silently become 720p 16:9. This contrasts with the sync base's local mode/prompt checks. +- **Impact**: Stale, corrupted, or non-UI callers can consume quota on a request that silently changes meaning or is guaranteed to fail at the vendor. +- **Suggested fix**: Add a shared preflight validator against `capabilities` before image inlining, limiter acquisition, or key retrieval. + +### [P2] Cancelled synchronous submissions retain generated images for the app lifetime +- **File**: lib/providers/sync_provider_base.dart:42 +- **Issue**: Inline bytes are inserted after generation and removed only by `poll`. +- **Evidence**: `_inlineCache[jobId] = bytes` occurs at lines 99–101; the only removal is `_inlineCache.remove(id)` at line 114. If cancellation occurs while synchronous generation is in flight, JobQueue sees the cancellation before polling and never drains the cache. +- **Impact**: Every such cancellation permanently retains a full generated image in the app-scoped provider instance. Repeated cancellations can cause substantial memory growth. +- **Suggested fix**: Add an explicit discard lifecycle or cancellation token, or guarantee bounded/TTL eviction without introducing a no-op `Cancellable`. + +### [P2] R2V/Omni capabilities require caller-side mode special-casing +- **File**: lib/providers/wanx_r2v_provider.dart:32; lib/providers/kling_v3_omni_provider.dart:32 +- **Issue**: Both adapters declare only `textToVideo` while also declaring and consuming reference images. +- **Evidence**: The generation caller must deliberately ignore `modes`, allow references based only on `maxRefImages`, and then creates an `imageToVideo` task that these adapters accept only because the async base performs no mode validation. +- **Impact**: Generic capability validation would reject valid R2V/Omni work, while adding correct validation to the base would break the current workflow. These providers are not substitutable without undocumented semantic exceptions. +- **Suggested fix**: Represent reference-to-video explicitly in `GenerationMode` or in a typed input-capability model, then keep the emitted task mode consistent with the declaration. + +### [P3] Provider tests do not meet the documented contract/fixture matrix +- **File**: test/providers/openai_image_provider_test.dart:682; test/providers/kling_v3_provider_test.dart:140 +- **Issue**: Several adapters lack real success/failure fixture coverage and named contract suites. +- **Evidence**: OpenAI success, content-policy, rate-limit, and models fixtures are in an explicitly skipped group. Stability lacks a success fixture. Kling v3, Kling Omni, Wanx T2V/I2V/R2V have only success fixtures. Only OpenAI and the custom adapter contain named `ProviderContractSuite` groups, although PROVIDER-API §12 requires one per adapter. +- **Impact**: Vendor-schema drift, timeout handling, malformed responses, and provider-specific error mapping can regress while all tests remain green. +- **Suggested fix**: Parameterize a shared contract suite and add at least one real failure fixture per provider, plus submit/poll timeout and malformed-response cases. + +### [P3] Remaining duplicated parsers and video parameter builders are already drifting +- **File**: lib/providers/openai_image_provider.dart:135; lib/providers/openai_compatible_provider.dart:85; lib/providers/wanx_t2v_provider.dart:79; lib/providers/wanx_r2v_provider.dart:82 +- **Issue**: OpenAI base64 extraction is duplicated, as are the DashScope video size/duration/negative-prompt maps. +- **Evidence**: The compatible OpenAI parser safely type-checks non-string `b64_json`; the built-in parser uses an unsafe cast. Five video adapters repeat nearly identical size fallback and duration construction. +- **Impact**: Protocol fixes can land in only one copy, producing provider-specific behavior and error drift. +- **Suggested fix**: Move OpenAI response decoding into a shared protected helper and centralize DashScope video parameter construction with explicit feature flags. + +### [P3] `ProviderRegistry.listCapabilities` is dead and duplicates the const capability source +- **File**: lib/providers/provider_registry.dart:58 +- **Issue**: No production caller uses this method; the UI correctly reads `providerCapabilitiesListProvider`. +- **Evidence**: Its only consumers are tests. Calling it instantiates every provider through `get`, contrary to the documented const capability path designed to avoid constructing Dio clients, limiters, and inline caches. +- **Impact**: The duplicate capability path can drift and gives future callers an unexpectedly expensive API. +- **Suggested fix**: Remove it from the registry interface and implementation, retaining the dedicated synchronous capability provider as the sole source. + +### [P3] Mandatory rate-limit wait telemetry is not implemented +- **File**: lib/providers/rate_limiter.dart:15; lib/providers/sync_provider_base.dart:95 +- **Issue**: PROVIDER-API §7 requires a DEBUG record containing provider and wait duration when acquisition blocks. +- **Evidence**: The limiter delegates logging to callers, but both bases simply `await acquire()` without measuring or logging; no `"rate limit wait"` call exists. +- **Impact**: Token-bucket contention is operationally indistinguishable from a hung submission. +- **Suggested fix**: Return/report wait duration through a callback or inject a logger into the limiter while keeping token accounting unchanged. From 423f1413d9cd1e715ccc996de143d401906d4f43 Mon Sep 17 00:00:00 2001 From: Kiro Date: Wed, 2 Sep 2026 17:54:36 +1000 Subject: [PATCH 10/14] =?UTF-8?q?docs:=20=E5=AE=A1=E8=AE=A1=20P0=20?= =?UTF-8?q?=E6=94=B6=E5=8F=A3=E5=90=8C=E6=AD=A5=20BOARD=20=E7=8A=B6?= =?UTF-8?q?=E6=80=81/=E5=80=BA=E8=A1=A8=20+=20CLAUDE.md=20=E7=BB=93?= =?UTF-8?q?=E6=9E=84=E5=BF=AB=E7=85=A7=20+=20studio=20README?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 Claude-Session: https://claude.ai/code/session_014c1ft3S1sBYwZH2iBzDXVx --- docs/BOARD.md | 4 ++++ docs/CLAUDE.md | 3 ++- lib/features/studio/README.md | 2 ++ 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/docs/BOARD.md b/docs/BOARD.md index c5e40fc5..f9692687 100644 --- a/docs/BOARD.md +++ b/docs/BOARD.md @@ -122,6 +122,7 @@ | LB-23 内存基线:cacheWidth 缩略解码收口 ×4 文件 5 站点(gallery tile 图片+视频缩略图/batch grid/两微缩略图;lightbox InteractiveViewer 有意豁免)+ ImageCache 上限 100→256MB(`kImageCacheMaxBytes`,main bootstrap 设定)+ perf-baseline 内存水位节(方法论/阈值/mac 空载实测 140MB,画廊与生成场景按 SOP 待填,Windows 列待群内机器)+ keepAlive 盘点 8 文件无未管控大对象 | #227 | | CH-2 视频 Inspector 角色区:角色区/命名框/角色 chip 抽共享 `characters_section.dart`(image inspector 1033→672 行,行为零变化以既有角色区测试一行未改全绿为证)+ video inspector 门控挂载(`maxRefImages>0` 对齐 CH-1 注入门,不检查 imageToImage——r2v/omni 语义;与 image 侧常挂+警示有意不同,视频多数 provider 无 ref 能力常挂=一屏死区)——CH-1 视频角色注入自此有用户可见入口 | #230 | | **D-M4-2~8 七决策批量拍板落档(2026-08-21,全取推荐 A 档)**:画廊→画布=菜单动作 / 画廊删除=永久删行+文件 / 聚合器=编辑+重启 / 第二模板=openai-chat-image / 转码=单开关 1080p30 H.264 丢音频 / 项目复制=不带 jobs/batch/exports / 角色库=独立整屏仿 Gallery。**M4 Wave 3 全解锁**(CH-3→GA-5/6→AG-4/5→EX-2);详见 MASTERPLAN §9。顺带清理 4 个已合入 worktree + 8 条残留本地分支 | #231 | +| **审计 P0 ×3 收口(2026-08-31 全量审计,证据见 `docs/review/2026-08-31/`)**:①`OrphanFileReaper` 号称只读却留着 `reap(dryRun:false)` 真删分支——接口收成无参 `reap()`,删除实现整段拿掉(不是"默认关"而是这个类里根本没有删除代码;真删另立独立评审的实现);②Inspector 防抖自动保存在切换选中时被 dispose 直接丢弃最后一次编辑——`InspectorSubmitController.saveDebounced(patch)` 挂起期间 `ref.keepAlive()` 撑住 autoDispose 直到落盘,prompt 与 shot_notes 共用这一份(**卡面原定"widget dispose 里 flush"走不通**:ConsumerStatefulElement 在自身 unmount 时拒绝该 widget 的任何 ref 访问,故防抖必须整个挂在 controller 上;单 node 单挂起槽位,两防抖字段同 node 时会互相顶掉——当前两字段分属不相交节点类型,记债);③零项目空态找不到「导入项目」——私有 `_importProject` 抽成 `studio/project_import_flow.dart` 顶层 `runProjectImportFlow`,FAB / 空态 CTA / ⌘K 三入口同一条路径,busy 互斥门与 FAB 同源。golden `studio_empty` 重铸。**刻意不做**(记债):已发射在途的自动保存仍可能晚于 submit() 落盘覆盖终稿(需写序号/版本机制,属审计 §二A 系统性模式);自动保存失败仍静默无重试面。审计报告 20 份随本 PR 入库。+5 例 | #232 | ## M1 补遗(审计发现的悬空项) @@ -189,3 +190,6 @@ | PKG-2A 评审 P3 残留:fetch-binaries macOS upstream 无架构核对——arm64 机上 INKFRAME_PG_PLATFORM=macos-x64 会把 arm64 二进制落进 x64 目录且本机 verify 照过(操作员失误场景;release.yml 现只建 arm64;make-relocatable 旧脚本同病) | 🅿️ | 随 macos-13 x64 matrix(PKG-7 真做)同窗加 uname 对 PLATFORM 的核对 | | 导出默认全选把同一镜的多个 take 一起选上(EX-1′ 本卡范围裁定:只改序不改候选集):重跑过的镜在默认导出里会重复出现,用户须手动取消勾选旧 take | 🅿️ | 本卡之前即如此,非新引入。修法=默认只勾每个 source 节点的最新 take(`resultsFor` 头一个),旧 take 仍列出可手动补选;属产品行为变更,随下一个动导出对话框的窗口拍板后做 | | ON-2b 评审 P3 三条(#195):①真 PG 回滚测只走 projects+canvas 两仓储,建议扩成与 createSample 同构四步;②泳道带厚 400 魔数散落三处(接口默认/注释/测试),建议提 kDefaultLaneSize;③示例 laneStylePrompt 走 zh 本地化与 base_style_presets「模型合约保英文」惯例有张力(用户可见可编辑,判定可接受)——产品可拍板改为仅本地化 label | 🅿️ | ①②低成本顺窗;③产品取舍,英文语系 provider 出图质量考量 | +| 防抖自动保存「在途写入」与 submit() 竞序(2026-08-31 审计 W3 P1;#232 明确不做):计时器已触发、`saveConfig` 正 await 仓储时 submit() 落完整 finalConfig,旧 patch 可能晚到覆盖终稿 prompt;#232 只修「挂起未发射」被 dispose 丢弃这一种 | 🅿️ | 需写序号/版本号或串行写链,属审计 §二A「autoDispose/取消 vs 异步写」系统性模式(5 窗口互证),应一次架构修复而非逐点补丁 | +| 自动保存失败静默(2026-08-31 审计 W17 P1;#232 明确不做):`saveConfig` 吞 InkError,UI 无失败态/无重试;下一次输入覆盖即"自愈",但最后一次编辑若正好失败即静默丢 | 🅿️ | UX 面而非数据丢失 bug;随 W17 UX P1 批次(长耗时操作进度/失败原因上屏)同窗 | +| InspectorSubmitController 单 node 单防抖槽位(#232 设计裁定):同一 node 上第二个 `saveDebounced` 会把第一个未落盘的 patch 整个顶掉;当前 prompt / shot_notes 分属 image·video / shot 节点,不相交,非活 bug | 🅿️ | 若将来某节点类型要同时防抖两个字段,改为按 key 分槽或合并 patch | diff --git a/docs/CLAUDE.md b/docs/CLAUDE.md index bbbe4711..54cac769 100644 --- a/docs/CLAUDE.md +++ b/docs/CLAUDE.md @@ -269,6 +269,7 @@ lib/ │ └── studio/ # Project / workspace shell (home + open-canvas + first-run onboarding dialog; ON-1/ON-2) │ ├── studio_home_screen.dart │ ├── open_canvas.dart # Open/create a canvas from Studio +│ ├── project_import_flow.dart # runProjectImportFlow — LB-12 archive import, one path shared by FAB / zero-project empty state / ⌘K (audit 2026-08-31 P0-3) │ ├── controllers/ │ ├── models/ │ ├── providers/ @@ -309,7 +310,7 @@ lib/ ├── file_preferences_service.dart # config/preferences.json load/save ├── custom_providers_file_service.dart # config/custom_providers.json parse + fallback ├── character_asset_service.dart - ├── orphan_file_reaper.dart # DiskOrphanFileReaper (disk orphan media GC; DRY-RUN v1 — logs only, never deletes; LB-13) + ├── orphan_file_reaper.dart # DiskOrphanFileReaper (disk orphan media GC; read-only scan — logs candidates, contains NO delete code at all; LB-13, audit 2026-08-31 P0-1) ├── database_backup_service.dart # PgDumpBackupService (daily/manual/prerestore pg_dump -Fc, per-family retention 7/3/3, meta.json sidecar; LB-10/LB-22) ├── database_restore_service.dart # PgSwapRestoreService (restore into scratch DB then rename-swap — failed restore leaves data untouched; LB-22) ├── diagnostics_bundle_service.dart # ZipDiagnosticsBundleService (support bundle: info+logs+crashes+config allowlist, never api keys; LB-18) diff --git a/lib/features/studio/README.md b/lib/features/studio/README.md index 4072a499..c7059db4 100644 --- a/lib/features/studio/README.md +++ b/lib/features/studio/README.md @@ -9,6 +9,7 @@ ``` studio_home_screen.dart 首页整屏(顶栏 + 侧栏 + 主区项目网格;含画布管理对话框 _ManageCanvasesDialog) open_canvas.dart 从 studio 打开/创建画布(同时写偏好 lastCanvasId/lastProjectId 供重启恢复) +project_import_flow.dart 项目包导入流程 runProjectImportFlow(picker → barrier 模态 → service → 选中新项目;FAB / 零项目空态 CTA / ⌘K 三入口共用一条路径;与还原/导出互斥) controllers/studio_projects_controller 项目列表加载/新建/重命名/删除/归档 controllers/studio_state studio 视图状态 models/project_with_canvases 项目 + 其画布聚合视图 @@ -23,6 +24,7 @@ widgets/studio_provider_banner "未配置 API Key" 提示条 ## 数据流 - `workspace_projects_provider` / `studio_projects_controller` 从仓库(Map,ADR-0003)拉项目 → `project_with_canvases` 聚合 → `studio_home_screen` 渲染网格 - 打开画布 → `open_canvas` 设置 `currentCanvasIdProvider`(见 [features/canvas](../canvas/README.md)),app 切到画布屏 +- 导入项目 → `project_import_flow.runProjectImportFlow`(FAB、零项目空态 CTA、命令面板三处同一条路径;2026-08-31 审计 P0-3 之前只有 FAB 一处,零项目用户根本够不到) - 项目卡菜单「Gallery」→ 写 `currentGalleryProjectProvider`,`app.dart` 切到 `GalleryScreen`(见 [features/gallery](../gallery/README.md)) - 项目卡菜单「管理画布」→ `_ManageCanvasesDialog`(画布级重命名/软删,controller 走 canvasRepo update/softDelete) - 空态 / 错误态 / "无 Key" 提示条均走 l10n(`studioEmpty*` / `studioError*` / `studioNoKeyBanner*`) From e763169e696639c170560a1a32f4b8800587c593 Mon Sep 17 00:00:00 2001 From: Kiro Date: Thu, 3 Sep 2026 16:10:34 +1000 Subject: [PATCH 11/14] =?UTF-8?q?refactor(reaper):=20=E5=8E=BB=E6=8E=89=20?= =?UTF-8?q?OrphanCandidate=20=E6=AD=BB=E5=AD=97=E6=AE=B5=20file=20+=20?= =?UTF-8?q?=E6=94=B6=E5=8F=A3"=E5=8F=AA=E8=AF=BB"=E6=8E=AA=E8=BE=9E?= =?UTF-8?q?=EF=BC=88=E5=AF=B9=E6=8A=97=E8=AF=84=E5=AE=A1=20P3=20=C3=973?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `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 --- docs/BOARD.md | 4 ++-- docs/MASTERPLAN.md | 2 +- lib/core/di/orphan_reaper.dart | 2 +- lib/core/interfaces/orphan_file_reaper.dart | 2 +- lib/services/orphan_file_reaper.dart | 4 ---- test/services/orphan_file_reaper_test.dart | 2 +- 6 files changed, 6 insertions(+), 10 deletions(-) diff --git a/docs/BOARD.md b/docs/BOARD.md index f9692687..d06491e3 100644 --- a/docs/BOARD.md +++ b/docs/BOARD.md @@ -174,14 +174,14 @@ | 竖向末道 <200px 时标题栏按钮点不动(#186 评审 P2-3:皮 Positioned 盒宽=lanesTotal,溢出部分可见不可命中;标签区可拖是唯一逃生口) | 🅿️ | 同上根因族;修法=标题栏盒宽脱离道宽或按钮区折叠 | | 建点位置固定世界 (200..600),全向漫游后建点必在屏幕外(#186 评审 P3-3:旧模型只右下漫游概率低,全向后被放大;命令面板/FAB/空态三入口同病) | ✅ | Polish Wave 1 PR-8(本 PR):`pickViewportCenteredNodePosition`——视口中心经逆变换入世界坐标+±60 散布防叠点,三入口接线;视口未上报回退旧固定区(既有测试语义不变);矩阵换算单测钉死 | | 项目导出大文件路径(#188 评审 P2-4):archive 包 deflate 把单文件压缩输出整段驻内存(GB 视频=GB 峰值)且同步压缩冻结 UI;附带 addFile 异常路径泄漏源文件句柄(Windows 进程退出才释放) | 🅿️ | 媒体改 store 不压缩 + Isolate.run 整体导出;与 LB-12 进度组件同窗做,v1 有 busy 防重入垫底 | -| OrphanFileReaper 转真删前必须 restore-aware(LB-22 评审 P3-1):还原旧备份后新生成文件成 DB 孤儿——reaper 真删会吃掉「还原更新备份时还需要的文件」;当前 DRY-RUN 无害 | 🅿️ | LB-13b 真删灰度的前置不变量;修法=还原动作后重置 mtime 护栏或记还原水位 | +| OrphanFileReaper 转真删前必须 restore-aware(LB-22 评审 P3-1):还原旧备份后新生成文件成 DB 孤儿——reaper 真删会吃掉「还原更新备份时还需要的文件」;当前 reaper 无删除代码(#232 P0-1),无害 | 🅿️ | 将来若另立真删实现,此为前置不变量;修法=还原动作后重置 mtime 护栏或记还原水位 | | pg_dump/pg_restore 无超时(LB-22 评审 P3-2):挂死子进程让备份/还原 busy 永久锁 UI;与 EX-3 ffmpeg 同根因(ProcessRunner 无 kill/timeout 通道) | ✅ | Polish Wave 1 PR-2(本 PR):`runWithWatchdog`(ProcessStarter 流式+定时 kill+**硬截止** timeout+killGrace——kill 无效也不永挂);备份 10min/还原 30min,超时=kill+带 stderr/exit_code 归因 warn;exit 0 优先于超时判定(已成功不误删,同 EX-3 不变量);还原 DROP tmp 加 `WITH (FORCE)`(超时 kill 后 backend 可能仍占库)+失败留证;**顺带评审 P1-1**:Process.start 补关子进程 stdin(对齐 Process.run,pg 密码提示从 10min 冻结回毫秒级 EOF 失败,EX-3 ffmpeg 同受益);进程 fake 迁 _harness(backup/restore/watchdog 共用+契约自测;ffmpeg fake 因进度流语义专用留原地) | | 还原对换的 retired 库残留(DROP 失败仅 warn)与 swap_stranded 极端夹缝无启动期清扫/救援 | 🅿️ | 空间代价可接受;随 LB-12 同窗盘点:启动 housekeeping 扫 inkframe_retired_*/inkframe_restore_tmp 报告或回收 | | 回收站恢复绕过名字唯一性(#190 评审 P3-2):建 Alpha→删→再建 Alpha→恢复旧 Alpha=工作库两个 Alpha;schema 无唯一约束,create/rename 的 UI 校验管不到 restore | 🅿️ | 不炸纯 UX 漂移;修法=restore 前查同名给改名/后缀,或列表 UI 容忍同名靠时间区分 | | zip `.partial` 落盘骨架三份逐字复制(LB-10/11/18;#191 评审 P3-3 复发实证:自吞守卫没跟着骨架走) | 🅿️ | 抽 `atomicZipWrite(target, build)` 共享件并内置 #188 P2-5 自吞排除;**PR-8 范围裁定拆独立卡**(M 级:三服务 fake 契约面,与本簇 S 件不同窗;修法不变) | | pg.log 无轮转(pg_ctl -l 追加写;logger 的 10MB 预算只认 inkframe.* 前缀)——诊断包/磁盘体积长期无界(#191 评审 P3-5) | 🅿️ | pg.log 轮转(启动期截断/按大小滚动)或诊断包按 mtime 截取最近 N 份 | | 三大重操作互斥只在导入侧单向查(LB-12 拍板 9):还原/导出入口不查 projectImportBusyProvider——导入进行中仍可点还原 | ✅ | Polish Wave 1 PR-8(本 PR):反向补查——项目导出入口查 import+restore busy,备份/还原区查 import+export busy;import 侧原有三方检查不变 | -| 导入补偿删除失败→projects/{uuid} 孤儿目录无回收路径(#192 评审 P3-2:无 .import- 前缀 sweep 不认,reaper 又 DRY-RUN);另记拍板 4 三处字面偏差(U+FFFD 奇名可过/最终路径长未预检/isWithin 代 resolveInProject)均安全失败 | 🅿️ | 随 LB-13 reaper 转真删同窗:无行背书目录纳入回收;字面偏差随安全面复审顺修 | +| 导入补偿删除失败→projects/{uuid} 孤儿目录无回收路径(#192 评审 P3-2:无 .import- 前缀 sweep 不认,reaper 又无删除实现);另记拍板 4 三处字面偏差(U+FFFD 奇名可过/最终路径长未预检/isWithin 代 resolveInProject)均安全失败 | 🅿️ | 随将来独立评审的真删实现同窗:无行背书目录纳入回收;字面偏差随安全面复审顺修 | | **迁移纪律备忘(#192 评审 P3-6)**:导入的列白名单过滤依赖「迁移只加可空/有默认列」——将来任何「新增 NOT NULL 无默认」迁移会让旧项目包导入必炸 | 🅿️ | ADR-0012 补一句:新增列必须可空或带默认,否则同时给导入侧加填充逻辑 | | GAP-1 评审 P3 残留(#200):①unknownTemplate 在 UI 错误文案映射到 InvalidId 键(当前不可达——模板恒下拉;改自由输入即活雷,补专用键或注释);②_openEditor 读失败报「保存失败」文案微错位;③写无顺序化(模态门控下重合概率趋零,硬化=_queue.then 串行链);④_parseEntry seenIds 在 template/url 校验前占坑,被拒条目致后续同 id 合法条目误判 duplicate(既有债非本卡引入);⑤section 内 provider 定义应迁 features/settings/providers/(风格) | 🅿️ | 均低害;①随模板扩展窗强制处理 | | GAP-3 评审 P3 残留(#199):①方向读错期 lane_toolbar 置灰未做(二元域无损毁,但 toggle 到不了 horizontal 的怪异 UX);②横幅三源 `??` 链+单 `_dismissed` 遮蔽——关掉 edges 错后并发 lanes/direction 错不上屏(改集合);③非 InkError→errorUnknown 后无任何日志线索(此前 raw toString 至少可报障),建议 error 分支补 log 或 ProviderObserver.providerDidFail | 🅿️ | ①②低害 UX;③可观测性,随日志面收口 | diff --git a/docs/MASTERPLAN.md b/docs/MASTERPLAN.md index 1fd6e5ec..aebd4993 100644 --- a/docs/MASTERPLAN.md +++ b/docs/MASTERPLAN.md @@ -131,7 +131,7 @@ M6 「公开上线」……… 官网 + 示例项目 + 冷启动执行(HN/Reddit Windows CI 排 pg 标签+控制器测试全 fake 故此前不可见);Windows 分支改 inheritStdio; 随附 realpg 门控真栈 E2E(真 initdb SCRAM→pg_ctl→迁移→pg_dump→pg_restore 对换→teardown) - **LB-13 purge 语义修正+孤儿文件回收**:✅ 切片 A(#163,purge 加 success-slot 守卫保画廊); - ✅ 切片 B = **LB-13b**(#165,OrphanFileReaper DRY-RUN v1 只记不删;真删除待 dry-run 灰度后) + ✅ 切片 B = **LB-13b**(#165,OrphanFileReaper DRY-RUN v1 只记不删;真删除待 dry-run 灰度后;**2026-08-31 审计 P0-1/#232**:删除分支已整体移除,真删须另立独立评审的实现) - **LB-14 崩溃遗留空 result 节点收敛**:✅ #162(启动 softDeleteEmptyOrphanResults); **LB-09 启动失败 surface**:✅ #169(PG 引导失败全屏错误替代白屏); **LB-17 全局错误钩子**:✅ #160(runZonedGuarded + crash 落盘)+ **LB-18 诊断包**:✅ #191 diff --git a/lib/core/di/orphan_reaper.dart b/lib/core/di/orphan_reaper.dart index 8e102c63..1efe36b7 100644 --- a/lib/core/di/orphan_reaper.dart +++ b/lib/core/di/orphan_reaper.dart @@ -1,4 +1,4 @@ -// OrphanFileReaper DI + 启动触发(LB-13 slice B,DRY-RUN v1)。 +// OrphanFileReaper DI + 启动触发(LB-13 slice B,只读扫描——无删除代码)。 import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../interfaces/orphan_file_reaper.dart'; diff --git a/lib/core/interfaces/orphan_file_reaper.dart b/lib/core/interfaces/orphan_file_reaper.dart index 625338ca..b28b46d9 100644 --- a/lib/core/interfaces/orphan_file_reaper.dart +++ b/lib/core/interfaces/orphan_file_reaper.dart @@ -5,7 +5,7 @@ // **没有任何删除实现**——不是"默认关闭的开关",是这个类里根本不存在删除代码。 // 真正的删除需要独立实现、独立评审,不在本契约里。 abstract class OrphanFileReaper { - /// 扫描并识别孤儿文件,只记 orphan.reap.dryrun 日志、不删除、不改动磁盘。 + /// 扫描并识别孤儿文件,只记 orphan.reap.dryrun 日志、不删除、不碰任何媒体文件(唯一落盘 = config/ 下的节流标记)。 /// 节流:距上次成功回收不足阈值则直接跳过(返回 [OrphanReapReport.skipped])。 /// 引用集构建失败(InkError)向上抛——由启动兜底 swallow 成 warn,绝不阻断。 Future reap(); diff --git a/lib/services/orphan_file_reaper.dart b/lib/services/orphan_file_reaper.dart index ead7e6f6..6b108f3b 100644 --- a/lib/services/orphan_file_reaper.dart +++ b/lib/services/orphan_file_reaper.dart @@ -147,7 +147,6 @@ class DiskOrphanFileReaper implements OrphanFileReaper { if (age <= kOrphanMinAge) continue; out.add( OrphanCandidate( - file: entity, relativePath: rel, sizeBytes: stat.size, ageDays: age.inDays, @@ -217,14 +216,11 @@ class DiskOrphanFileReaper implements OrphanFileReaper { /// 本卡只用于日志 / 统计,从不据此删除。 class OrphanCandidate { const OrphanCandidate({ - required this.file, required this.relativePath, required this.sizeBytes, required this.ageDays, }); - final File file; - /// 画布相对路径(`images/` 或 `videos/`)。 final String relativePath; final int sizeBytes; diff --git a/test/services/orphan_file_reaper_test.dart b/test/services/orphan_file_reaper_test.dart index efa38971..08c54d0b 100644 --- a/test/services/orphan_file_reaper_test.dart +++ b/test/services/orphan_file_reaper_test.dart @@ -1,4 +1,4 @@ -// DiskOrphanFileReaper 单测(LB-13 slice B,DRY-RUN v1)。 +// DiskOrphanFileReaper 单测(LB-13 slice B,只读扫描——无删除代码)。 // // 覆盖:识别逻辑(恰好命中未引用 AND >7d)、mtime 守卫、目录白名单安全、 // 引用集构建(含软删节点)、节流、以及 dry-run「绝不删除 + 记 orphan.reap.dryrun」。 From 35c780c60940fc0d35c6229f0c751434b07d2ead Mon Sep 17 00:00:00 2001 From: Kiro Date: Thu, 3 Sep 2026 16:13:21 +1000 Subject: [PATCH 12/14] =?UTF-8?q?docs(board):=20=E8=A7=92=E8=89=B2?= =?UTF-8?q?=E4=B8=80=E8=87=B4=E6=80=A7=E7=8A=B6=E6=80=81=E8=A1=8C=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=E5=8F=8C=E5=88=86=E6=94=AF=E8=A7=84=E5=88=99=20+=20?= =?UTF-8?q?=E8=AE=B0=20CH-2=20=E5=90=88=E5=B9=B6=E5=90=8E=E8=AF=84?= =?UTF-8?q?=E5=AE=A1=E6=AE=8B=E7=95=99=E5=9B=9B=E6=9D=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 状态表「角色一致性」一行自 #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 --- docs/BOARD.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/BOARD.md b/docs/BOARD.md index d06491e3..44e2cb53 100644 --- a/docs/BOARD.md +++ b/docs/BOARD.md @@ -32,7 +32,7 @@ | 功能 | 状态 | 优先级 | 备注 | |---|---|---|---| | 参考图 / 首尾帧 UI(Provider 已支持,缺界面) | ✅ | P0 | 共享 `NodeInputsSection`(image+video 均挂载,缩略图/role 按 `supportsFirstFrame/LastFrame` 门控/n-max 计数);连线经画布 link 模式,image→video 智能默认 first_frame(PR #138 B1/B3) | -| 角色一致性(项目级角色参考,自动带入生成) | ✅ | P0 | characters 表/仓储/资产服务 + 生成按能力注入 + Inspector 角色区/存为角色(`528a3e9`/`eda2d7c`)。仅 image 节点、maxRefImages>0 且 imageToImage 生效 | +| 角色一致性(项目级角色参考,自动带入生成) | ✅ | P0 | characters 表/仓储/资产服务 + 生成按能力注入 + Inspector 角色区/存为角色(`528a3e9`/`eda2d7c`)。image 节点:maxRefImages>0 且 imageToImage;video 节点:仅 maxRefImages>0、不查 modes(CH-1 #209 注入链 + CH-2 #230 Inspector 入口) | | 批量 / 变体(生产侧 + 消费侧全链路) | ✅ | P1 | 消费侧骨架(`854124b`)+ 生产侧:提交事务预建 slot 占位、JobQueue 逐 slot 落库、结果节点 Inspector 挂 `BatchResultsGrid`、取消/失败/孤儿收敛。拍板语义:≥1 成即 job success、取消保留已成 slot。经 3 路对抗评审,2×P1+4×P2 全修 | | 提示词模板 / 预设库 | ✅ | P1 | 项目级 schema_v7 预设库 + Inspector 点选应用/存为预设(`8a28777`) | | 成本估算 UI(CostModel 已定义,缺消费端) | ✅ | P2 | `estimateCostUsd` + 图像/视频 Inspector 实时预估(`1273522`/`d1dfc46`) | @@ -193,3 +193,4 @@ | 防抖自动保存「在途写入」与 submit() 竞序(2026-08-31 审计 W3 P1;#232 明确不做):计时器已触发、`saveConfig` 正 await 仓储时 submit() 落完整 finalConfig,旧 patch 可能晚到覆盖终稿 prompt;#232 只修「挂起未发射」被 dispose 丢弃这一种 | 🅿️ | 需写序号/版本号或串行写链,属审计 §二A「autoDispose/取消 vs 异步写」系统性模式(5 窗口互证),应一次架构修复而非逐点补丁 | | 自动保存失败静默(2026-08-31 审计 W17 P1;#232 明确不做):`saveConfig` 吞 InkError,UI 无失败态/无重试;下一次输入覆盖即"自愈",但最后一次编辑若正好失败即静默丢 | 🅿️ | UX 面而非数据丢失 bug;随 W17 UX P1 批次(长耗时操作进度/失败原因上屏)同窗 | | InspectorSubmitController 单 node 单防抖槽位(#232 设计裁定):同一 node 上第二个 `saveDebounced` 会把第一个未落盘的 patch 整个顶掉;当前 prompt / shot_notes 分属 image·video / shot 节点,不相交,非活 bug | 🅿️ | 若将来某节点类型要同时防抖两个字段,改为按 key 分槽或合并 patch | +| CH-2 合并后评审残留(#230;2026-09-02 对抗评审,三反驳者多数通过):①video inspector「存为角色」只认 reference 角色的入边,而 image→video 连线默认 first_frame,常态下按钮恒灰(用户须先把边切成 reference 才能存);②挂载角色数 + 首尾帧/参考图合计超过 maxRefImages 时 UI 无任何提示,而 take() 截掉的恰是角色图;③`_createFromReference`/`_importFromFile` 在 `await _promptName` 之后直接 ref.read 无 mounted 守卫(抽出前即如此);④gallery_tile 仍留一份私有 `_CharacterNameDialog`,与已公开的 InspectorNameDialog 成三份近似件 | 🅿️ | ①②UX 面,随 E5 CH-3 角色库页同窗定夺;③④低成本顺手清 | From 9ba0737ce40cecaec48011ee513c050b03a7e700 Mon Sep 17 00:00:00 2001 From: Kiro Date: Thu, 3 Sep 2026 17:47:15 +1000 Subject: [PATCH 13/14] =?UTF-8?q?fix:=20=E5=AF=B9=E6=8A=97=E8=AF=84?= =?UTF-8?q?=E5=AE=A1=E4=B8=83=E6=9D=A1=E6=94=B6=E5=8F=A3=E2=80=94=E2=80=94?= =?UTF-8?q?=E8=A1=A5=E7=A9=BA=E6=80=81=20busy=20=E9=97=A8=E6=8E=A7?= =?UTF-8?q?=E5=9B=9E=E5=BD=92=E6=B5=8B=E8=AF=95=20+=20=E8=AE=A1=E5=88=92?= =?UTF-8?q?=E5=81=8F=E7=A6=BBnote=20+=20=E4=BA=94=E5=A4=84=E6=8E=AA?= =?UTF-8?q?=E8=BE=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 对抗评审(六视角 + 每条三反驳者)对本 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) --- docs/BOARD.md | 2 +- docs/MASTERPLAN.md | 2 +- docs/review/2026-08-31/W-UX-live.md | 2 +- .../plans/2026-08-31-audit-p0-fixes.md | 9 +++++++++ lib/app.dart | 2 +- .../command_palette/command_actions.dart | 3 ++- .../studio/widgets/studio_import_test.dart | 19 +++++++++++++++++++ 7 files changed, 34 insertions(+), 5 deletions(-) diff --git a/docs/BOARD.md b/docs/BOARD.md index 44e2cb53..c6e02598 100644 --- a/docs/BOARD.md +++ b/docs/BOARD.md @@ -122,7 +122,7 @@ | LB-23 内存基线:cacheWidth 缩略解码收口 ×4 文件 5 站点(gallery tile 图片+视频缩略图/batch grid/两微缩略图;lightbox InteractiveViewer 有意豁免)+ ImageCache 上限 100→256MB(`kImageCacheMaxBytes`,main bootstrap 设定)+ perf-baseline 内存水位节(方法论/阈值/mac 空载实测 140MB,画廊与生成场景按 SOP 待填,Windows 列待群内机器)+ keepAlive 盘点 8 文件无未管控大对象 | #227 | | CH-2 视频 Inspector 角色区:角色区/命名框/角色 chip 抽共享 `characters_section.dart`(image inspector 1033→672 行,行为零变化以既有角色区测试一行未改全绿为证)+ video inspector 门控挂载(`maxRefImages>0` 对齐 CH-1 注入门,不检查 imageToImage——r2v/omni 语义;与 image 侧常挂+警示有意不同,视频多数 provider 无 ref 能力常挂=一屏死区)——CH-1 视频角色注入自此有用户可见入口 | #230 | | **D-M4-2~8 七决策批量拍板落档(2026-08-21,全取推荐 A 档)**:画廊→画布=菜单动作 / 画廊删除=永久删行+文件 / 聚合器=编辑+重启 / 第二模板=openai-chat-image / 转码=单开关 1080p30 H.264 丢音频 / 项目复制=不带 jobs/batch/exports / 角色库=独立整屏仿 Gallery。**M4 Wave 3 全解锁**(CH-3→GA-5/6→AG-4/5→EX-2);详见 MASTERPLAN §9。顺带清理 4 个已合入 worktree + 8 条残留本地分支 | #231 | -| **审计 P0 ×3 收口(2026-08-31 全量审计,证据见 `docs/review/2026-08-31/`)**:①`OrphanFileReaper` 号称只读却留着 `reap(dryRun:false)` 真删分支——接口收成无参 `reap()`,删除实现整段拿掉(不是"默认关"而是这个类里根本没有删除代码;真删另立独立评审的实现);②Inspector 防抖自动保存在切换选中时被 dispose 直接丢弃最后一次编辑——`InspectorSubmitController.saveDebounced(patch)` 挂起期间 `ref.keepAlive()` 撑住 autoDispose 直到落盘,prompt 与 shot_notes 共用这一份(**卡面原定"widget dispose 里 flush"走不通**:ConsumerStatefulElement 在自身 unmount 时拒绝该 widget 的任何 ref 访问,故防抖必须整个挂在 controller 上;单 node 单挂起槽位,两防抖字段同 node 时会互相顶掉——当前两字段分属不相交节点类型,记债);③零项目空态找不到「导入项目」——私有 `_importProject` 抽成 `studio/project_import_flow.dart` 顶层 `runProjectImportFlow`,FAB / 空态 CTA / ⌘K 三入口同一条路径,busy 互斥门与 FAB 同源。golden `studio_empty` 重铸。**刻意不做**(记债):已发射在途的自动保存仍可能晚于 submit() 落盘覆盖终稿(需写序号/版本机制,属审计 §二A 系统性模式);自动保存失败仍静默无重试面。审计报告 20 份随本 PR 入库。+5 例 | #232 | +| **审计 P0 ×3 收口(2026-08-31 全量审计,证据见 `docs/review/2026-08-31/`)**:①`OrphanFileReaper` 号称只读却留着 `reap(dryRun:false)` 真删分支——接口收成无参 `reap()`,删除实现整段拿掉(不是"默认关"而是这个类里根本没有删除代码;真删另立独立评审的实现);②Inspector 防抖自动保存在切换选中时被 dispose 直接丢弃最后一次编辑——`InspectorSubmitController.saveDebounced(patch)` 挂起期间 `ref.keepAlive()` 撑住 autoDispose 直到落盘,prompt 与 shot_notes 共用这一份(**卡面原定"widget dispose 里 flush"走不通**:ConsumerStatefulElement 在自身 unmount 时拒绝该 widget 的任何 ref 访问,故防抖必须整个挂在 controller 上;单 node 单挂起槽位,两防抖字段同 node 时会互相顶掉——当前两字段分属不相交节点类型,记债);③零项目空态找不到「导入项目」——私有 `_importProject` 抽成 `studio/project_import_flow.dart` 顶层 `runProjectImportFlow`,FAB / 空态 CTA / ⌘K 三入口同一条路径,busy 互斥门与 FAB 同源。golden `studio_empty` 重铸。**刻意不做**(记债):已发射在途的自动保存仍可能晚于 submit() 落盘覆盖终稿(需写序号/版本机制,属审计 §二A 系统性模式);自动保存失败仍静默无重试面。审计报告 20 份随本 PR 入库。+5 例(计划 4 + 评审补 1) | #232 | ## M1 补遗(审计发现的悬空项) diff --git a/docs/MASTERPLAN.md b/docs/MASTERPLAN.md index aebd4993..6432406a 100644 --- a/docs/MASTERPLAN.md +++ b/docs/MASTERPLAN.md @@ -131,7 +131,7 @@ M6 「公开上线」……… 官网 + 示例项目 + 冷启动执行(HN/Reddit Windows CI 排 pg 标签+控制器测试全 fake 故此前不可见);Windows 分支改 inheritStdio; 随附 realpg 门控真栈 E2E(真 initdb SCRAM→pg_ctl→迁移→pg_dump→pg_restore 对换→teardown) - **LB-13 purge 语义修正+孤儿文件回收**:✅ 切片 A(#163,purge 加 success-slot 守卫保画廊); - ✅ 切片 B = **LB-13b**(#165,OrphanFileReaper DRY-RUN v1 只记不删;真删除待 dry-run 灰度后;**2026-08-31 审计 P0-1/#232**:删除分支已整体移除,真删须另立独立评审的实现) + ✅ 切片 B = **LB-13b**(#165,OrphanFileReaper DRY-RUN v1 只记不删;**2026-08-31 审计 P0-1/#232**:删除分支已整体移除,真删须另立独立评审的实现) - **LB-14 崩溃遗留空 result 节点收敛**:✅ #162(启动 softDeleteEmptyOrphanResults); **LB-09 启动失败 surface**:✅ #169(PG 引导失败全屏错误替代白屏); **LB-17 全局错误钩子**:✅ #160(runZonedGuarded + crash 落盘)+ **LB-18 诊断包**:✅ #191 diff --git a/docs/review/2026-08-31/W-UX-live.md b/docs/review/2026-08-31/W-UX-live.md index 7fdd59e2..feb58ff7 100644 --- a/docs/review/2026-08-31/W-UX-live.md +++ b/docs/review/2026-08-31/W-UX-live.md @@ -34,7 +34,7 @@ What I did establish, as a substitute check, is that the current branch's Dart c 4. Considered falling back to a web/Edge build (also listed as an available device) to at least see *some* UI — rejected: the task explicitly scopes this as a desktop-app audit, and InkFrame's desktop-only plugins (embedded PostgreSQL, `window_manager` frameless chrome, `media_kit` desktop video, Windows Credential Manager secure storage) mean a web build would not compile/behave the same and would produce a misleading, non-representative audit. 5. Ran `flutter analyze lib` as a sanity check that the branch itself isn't broken — clean, confirming the blocker is environmental, not a code regression on `feat/ch-2-video-inspector-characters`. -No native-desktop screenshot/input tooling was available in this session either (only Chrome-browser MCP tools, explicitly out of scope per the task); a PowerShell screenshot helper was prepared (`GetWindowRect` + `CopyFromScreen`) at `C:\Users\Kerro\AppData\Local\Temp\claude\...\scratchpad\screenshot.ps1` for use once the app can launch, but it was never exercised since no window ever appeared. +No native-desktop screenshot/input tooling was available in this session either (only Chrome-browser MCP tools, explicitly out of scope per the task); a PowerShell screenshot helper was prepared (`GetWindowRect` + `CopyFromScreen`) at 本会话临时目录下的 `screenshot.ps1` for use once the app can launch, but it was never exercised since no window ever appeared. ## Screenshots diff --git a/docs/superpowers/plans/2026-08-31-audit-p0-fixes.md b/docs/superpowers/plans/2026-08-31-audit-p0-fixes.md index b88748f6..a2a627c4 100644 --- a/docs/superpowers/plans/2026-08-31-audit-p0-fixes.md +++ b/docs/superpowers/plans/2026-08-31-audit-p0-fixes.md @@ -428,6 +428,15 @@ W4.md P1 (same root cause, found independently by three windows)." ### Task 3: Stop `ShotConfigInspector`'s local notes debounce from losing edits on dispose +> **⚠️ 本节以下步骤是计划稿,与最终落地不一致(as-built deviation,2026-09-02 补记)。** +> 计划原方案是「在 `_ShotConfigInspectorState.dispose()` 里 flush 本地 `Timer`」。**该方案行不通**: +> Riverpod 的 `ConsumerStatefulElement` 在 widget 自身 unmount 时就会拒绝该 widget 发起的任何 +> `ref` 访问(与 provider 容器是否还活着无关),因此 `dispose()` 里 `ref.read(...).saveConfig(...)` +> 必然抛出。实际落地改为:**删掉 widget 本地 Timer**,把 Task 2 的机制泛化成 +> `InspectorSubmitController.saveDebounced(patch)`,prompt 与 shot_notes 共用同一份防抖 + keepAlive +> 实现(见 commit `28bd0ec`)。以下 Step 1~6 的代码块保留为历史记录,**不要照着实施**; +> 单 node 单挂起槽位的限制已记入 `docs/BOARD.md` 债表。 + **Files:** - Modify: `lib/features/canvas/widgets/shot_config_inspector.dart` - Test: `test/features/canvas/widgets/shot_config_inspector_test.dart` diff --git a/lib/app.dart b/lib/app.dart index 8f30cfd1..e96945ab 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -58,7 +58,7 @@ class _InkFrameAppState extends ConsumerState // 的 context 才能 showDialog。 WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; - // LB-13:首帧后触发磁盘孤儿文件回收(DRY-RUN + ≥7d 节流)。fire-and-forget, + // LB-13:首帧后触发磁盘孤儿文件回收(只读扫描 + ≥7d 节流;该服务无删除实现)。fire-and-forget, // housekeeping,内部吞错只 warn,绝不阻断启动或抢占其它流程。 ref.read(orphanReapStartupProvider); // XM-1b:存量视频元数据回填(同级 housekeeping,稳态只花一条 SQL)。 diff --git a/lib/features/command_palette/command_actions.dart b/lib/features/command_palette/command_actions.dart index a2650013..de3ee61d 100644 --- a/lib/features/command_palette/command_actions.dart +++ b/lib/features/command_palette/command_actions.dart @@ -48,7 +48,8 @@ class CommandAction { /// - gallery:返回 Studio + 设置 /// - settings:返回 Studio /// - showcase:返回 Studio + 设置 -/// - studio 首页:设置 +/// - studio 首页:导入项目 + 内置示例 + 设置(零项目空态下项目卡菜单不存在, +/// 导入必须能从这里够到——2026-08-31 审计 P0-3) List buildCommandActions(BuildContext context, WidgetRef ref) { final l = context.l10n; final canvasId = ref.read(currentCanvasIdProvider); diff --git a/test/features/studio/widgets/studio_import_test.dart b/test/features/studio/widgets/studio_import_test.dart index 76abe5f0..2b6c6877 100644 --- a/test/features/studio/widgets/studio_import_test.dart +++ b/test/features/studio/widgets/studio_import_test.dart @@ -15,6 +15,7 @@ import 'package:inkframe/features/studio/providers/workspace_projects_provider.d import 'package:inkframe/features/studio/studio_home_screen.dart'; import 'package:inkframe/l10n/generated/app_localizations.dart'; import 'package:inkframe/theme/app_theme.dart'; +import 'package:inkframe/theme/primitives/ink_ghost_button.dart'; import '../../../helpers/recording_logger.dart'; @@ -157,4 +158,22 @@ void main() { expect(toast.shown.single.message, 'Project imported'); expect(container.read(selectedProjectIdProvider), 'new-proj'); }); + + testWidgets('零项目空态:还原 busy 时导入按钮同样禁用(与 FAB 同一把互斥锁)', + (tester) async { + // 空态下 FAB 整行不渲染,故上面那条 busy 用例够不到这个按钮——两处各有一份 + // busy 表达式,只测 FAB 那份等于没测空态那份(PLAYBOOK §5.3 规则漂移)。 + final container = await pump(tester, pickedPath: 'C:/tmp/p.zip'); + container.read(databaseRestoreBusyProvider.notifier).state = true; + await tester.pumpAndSettle(); + + final btn = tester.widget( + find.widgetWithText(InkGhostButton, 'Import project…'), + ); + expect(btn.onPressed, isNull, reason: '还原在途时空态导入按钮必须禁用'); + + await tester.tap(find.text('Import project…'), warnIfMissed: false); + await tester.pumpAndSettle(); + expect(service.paths, isEmpty); + }); } From 9cd7b5dc278c21e6e54f229a70c98bb3ac404b4c Mon Sep 17 00:00:00 2001 From: Kiro Date: Thu, 3 Sep 2026 17:48:23 +1000 Subject: [PATCH 14/14] =?UTF-8?q?docs(board):=20=E8=AE=B0=E4=B8=80?= =?UTF-8?q?=E6=9D=A1=E9=80=80=E5=87=BA=E5=BA=94=E7=94=A8=E4=B8=A2=E6=8C=82?= =?UTF-8?q?=E8=B5=B7=E9=98=B2=E6=8A=96=E5=86=99=E5=85=A5=E7=9A=84=E6=AE=8B?= =?UTF-8?q?=E7=95=99=E7=BC=BA=E5=8F=A3=EF=BC=88#232=20=E8=AF=84=E5=AE=A1?= =?UTF-8?q?=20P2=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit keepAlive 只挡 autoDispose,挡不住 AppTeardown 的 container.dispose;而且退出序列 在 dispose 之前就 Pool.close 了,补落盘也写不进去。窗口≤500ms,比切换选中窄, 但根因同属 P0-2 那类"挂起写入被丢弃",不记账将来会被当成已修。 Co-Authored-By: Claude Opus 5 (1M context) --- docs/BOARD.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/BOARD.md b/docs/BOARD.md index c6e02598..052a78f2 100644 --- a/docs/BOARD.md +++ b/docs/BOARD.md @@ -194,3 +194,4 @@ | 自动保存失败静默(2026-08-31 审计 W17 P1;#232 明确不做):`saveConfig` 吞 InkError,UI 无失败态/无重试;下一次输入覆盖即"自愈",但最后一次编辑若正好失败即静默丢 | 🅿️ | UX 面而非数据丢失 bug;随 W17 UX P1 批次(长耗时操作进度/失败原因上屏)同窗 | | InspectorSubmitController 单 node 单防抖槽位(#232 设计裁定):同一 node 上第二个 `saveDebounced` 会把第一个未落盘的 patch 整个顶掉;当前 prompt / shot_notes 分属 image·video / shot 节点,不相交,非活 bug | 🅿️ | 若将来某节点类型要同时防抖两个字段,改为按 key 分槽或合并 patch | | CH-2 合并后评审残留(#230;2026-09-02 对抗评审,三反驳者多数通过):①video inspector「存为角色」只认 reference 角色的入边,而 image→video 连线默认 first_frame,常态下按钮恒灰(用户须先把边切成 reference 才能存);②挂载角色数 + 首尾帧/参考图合计超过 maxRefImages 时 UI 无任何提示,而 take() 截掉的恰是角色图;③`_createFromReference`/`_importFromFile` 在 `await _promptName` 之后直接 ref.read 无 mounted 守卫(抽出前即如此);④gallery_tile 仍留一份私有 `_CharacterNameDialog`,与已公开的 InspectorNameDialog 成三份近似件 | 🅿️ | ①②UX 面,随 E5 CH-3 角色库页同窗定夺;③④低成本顺手清 | +| 退出应用会丢挂起的防抖自动保存(#232 对抗评审 P2,反驳者 2/3 判超范围,此处仅记账):`AppTeardown._run` 的顺序是「JobQueue → **Pool.close** → PgController.stop → container.dispose」,防抖计时器的 keepAlive 只挡得住 autoDispose、挡不住整个容器被 dispose;且即便此时补落盘,连接池已关也写不进去。窗口≤500ms,比"切换选中"窄得多,但同属 P0-2 那类"挂起写入被丢弃" | 🅿️ | 正解=退出序列在关 Pool **之前**加一步 flush 全部挂起防抖(需 controller 侧暴露 flushAll 或由 teardown 遍历已挂载的 InspectorSubmitController);与上面「在途写入 vs submit 竞序」同属一次写序列化改造 |