Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude/agents/trellis-check.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ After finding issues:
FMP is a Flutter app: "lint and typecheck" is `flutter analyze`. Read the task's `package` from `task.json` first: `legacy` verifies against `lib/AGENTS.md`, `app` against `app/AGENTS.md`. Run, in order:

1. Codegen when a model or `*.i18n.json` changed, or `*.g.dart` is missing: `dart run build_runner build`, `dart run slang`. Stale codegen fails as a missing getter that looks like a source bug.
2. `dart format lib test tool`, then `flutter analyze`.
2. Format, then analyze. `legacy`: `dart format lib test tool`, then `flutter analyze`, at the repo root. `app`: inside `app/`, `dart format --output=none --set-exit-if-changed .`, then `flutter analyze`.
3. The tests for every changed area: the matching rows of the package's `AGENTS.md` § Verification / § 驗證, plus the Quality Check section of each touched `.trellis/spec/<package>/<layer>/index.md`.

If anything fails, fix it and re-run.
Expand Down
2 changes: 1 addition & 1 deletion .claude/agents/trellis-implement.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ Read the task's prd.md, design.md if present, and implement.md if present:
FMP is a Flutter app: "lint and typecheck" is `flutter analyze`. Read the task's `package` from `task.json` first: `legacy` verifies against `lib/AGENTS.md` § Verification, `app` against `app/AGENTS.md` § 驗證.

1. Codegen when a model or `*.i18n.json` changed, or `*.g.dart` is missing: `dart run build_runner build`, `dart run slang`.
2. `dart format lib test tool`, then `flutter analyze`.
2. Format, then analyze. `legacy`: `dart format lib test tool`, then `flutter analyze`, at the repo root. `app`: inside `app/`, `dart format --output=none --set-exit-if-changed .`, then `flutter analyze`.
3. The tests named by the matching rows of the package's § Verification / § 驗證, plus the tests you wrote.

If the change is user-visible, say so in the report: on-device verification is the main session's job (`verify-legacy-on-device` skill for `legacy`, `verify-on-device` for `app`).
Expand Down
112 changes: 107 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
name: CI

# No path filter. Markdown carries rules that tests enforce -- the agent
# instruction files most of all -- so a prose-only commit is exactly the one
# that must not skip `validate`. `paths-ignore` is trigger-level and cannot
# skip only the build jobs; measured run time is 23 minutes total (validate 7,
# Android 6, Windows 10), which is not worth a changed-files job to avoid.
# repo 裡有兩個專案:根目錄的舊專案(凍結,只收緊急修正)與 app/ 的新專案
# (ADR 0008)。`changes` job 依變動路徑分流(ADR 0015 §決定 9),兩邊的 CI
# 失敗不會互相擋 PR:
# - app/** 或 .github/** 有變動:跑 `app`。
# - app/ 以外有任何變動:跑舊專案的三個 job。文件變更也算,因為 Markdown
# 承載由測試守著的規則(尤其是 agent 指令檔),只改文字的 commit 正是最
# 不能跳過 `validate` 的那種。所以舊專案的篩選條件是「app/ 以外」,不是
# 「是不是程式碼」。
# - 手動觸發一律全跑。
# 分流在 job 層級做,不用觸發層級的 `paths`/`paths-ignore`:必要檢查只有最後
# 的 `CI Result`,它每次都要回報,被跳過的 job 算通過、失敗或取消的算失敗。
on:
pull_request:
branches:
Expand All @@ -26,12 +32,50 @@ concurrency:

env:
FLUTTER_VERSION: '3.47.1'
# app/ 跟隨當時的 stable(ADR 0015;M1 design §3);舊專案維持上面那版。
APP_FLUTTER_VERSION: '3.47.5'
JAVA_VERSION: '17'
JAVA_DISTRIBUTION: 'temurin'

jobs:
changes:
name: Detect Changes
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
# pull_request 事件以 REST API 取變動檔案清單。
pull-requests: read
outputs:
app: ${{ steps.filter.outputs.app }}
legacy: ${{ steps.filter.outputs.legacy }}

steps:
# push 事件以 git 比對變動,需要 checkout。
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Filter changed paths
id: filter
# 手動觸發時下游 job 本來就全跑;跳過比對,免得找不到比對基準時整條 CI 被擋。
if: github.event_name != 'workflow_dispatch'
uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3
with:
# `legacy` 要「符合 ** 且不符合 !app/**」;預設的 `some` 只要符合
# 任一條就算,app/ 的檔案也會觸發舊專案。
predicate-quantifier: some-with-excludes
filters: |
app:
- 'app/**'
- '.github/**'
legacy:
- '**'
- '!app/**'

validate:
name: Analyze and Test
needs: changes
if: needs.changes.outputs.legacy == 'true' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
timeout-minutes: 15

Expand Down Expand Up @@ -153,3 +197,61 @@ jobs:

- name: Build Windows release
run: flutter build windows --release

app:
name: App Analyze and Test
needs: changes
if: needs.changes.outputs.app == 'true' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
timeout-minutes: 15
defaults:
run:
working-directory: app

steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Setup Flutter
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2.23.0
with:
flutter-version: ${{ env.APP_FLUTTER_VERSION }}
cache: true
cache-key: flutter-${{ env.APP_FLUTTER_VERSION }}-${{ runner.os }}
pub-cache: true

- name: Flutter dependencies
run: flutter pub get

- name: Check formatting
run: dart format --output=none --set-exit-if-changed .

- name: Static analysis
run: flutter analyze

# 不加參數:dart_test.yaml 讓 live 預設跳過(ADR 0015 §決定 3)。
# 身分測試要 cmake,ubuntu runner 內建。
- name: Unit and widget tests
run: flutter test

ci-result:
name: CI Result
if: always()
needs: [changes, validate, build-android, build-windows, app]
runs-on: ubuntu-latest
timeout-minutes: 5

steps:
# 唯一的必要檢查。被路徑篩掉而跳過的 job 算通過;失敗、取消或
# `changes` 本身失敗(下游全被跳過)都算失敗。
- name: Check job results
env:
RESULTS: ${{ join(needs.*.result, ' ') }}
run: |
echo "Job results: $RESULTS"
for result in $RESULTS; do
case "$result" in
success|skipped) ;;
*) exit 1 ;;
esac
done
55 changes: 55 additions & 0 deletions .trellis/spec/app/testing/index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# 測試(`app/test/`)

`app/` 的每個測試都適用。哪種改動跑哪些指令見 `app/AGENTS.md` § 驗證;零聯網與身分
的規則本身也寫在那裡,這裡只寫怎麼寫測試。

## 分層(ADR 0015 §決定 1)

測試守契約與使用者看得到的行為,不守實作細節;行為變更同一個 PR 補測試,用能證明它
的最低層。

| 層 | 用在 | 目前的例子 |
|---|---|---|
| 單元 | 純邏輯、平台層實作(注入路徑與 callback,在暫存目錄上跑) | `test/platform/app_data_directory_test.dart` |
| widget | 畫面;依賴以建構子或 provider override 注入 | `test/app/fmp_app_test.dart` |
| 建置設定 | 原生身分:能執行就執行(`cmake -P`),不能就解析設定檔並附變異案例 | `test/identity/` |
| 插件契約、整合、golden | M1 PR 9、PR 13 與設計系統元件加入時再寫 | — |

- 正式程式碼不留測試掛鉤(`*ForTesting`、`@visibleForTesting` 的後門);要替換的東西
經建構子或 provider 注入。
- 不設覆蓋率門檻。

## 零聯網

`test/flutter_test_config.dart` 讓建立真實 `HttpClient` 直接拋 `StateError`;
`dart_test.yaml` 讓 `live` tag 預設跳過。

- 需要 HTTP 的程式碼注入假的 client 或 adapter,不去碰網路。
- 真的要打真實 API 的測試:標 `tags: 'live'`,並在測試裡自己放行:

```dart
final class _AllowNetwork extends HttpOverrides {}

test('...', () async {
await HttpOverrides.runWithHttpOverrides(() async {
// 這個 zone 內建立的 HttpClient 是真的
}, _AllowNetwork());
}, tags: 'live');
```

執行:`flutter test --run-skipped --tags live`。
- `flutter_test_config.dart` 先初始化測試 binding 再設 `HttpOverrides.global`:binding
初始化時會換上 flutter_test 自己的假 client(回 400、不報錯),順序反過來這道防線
就失效。

## 讀原始碼或設定檔的測試

優先寫行為測試。非讀原始碼不可時(例如 Gradle 設定在 CI 上跑太慢),同一個測試檔要有
兩個變異案例:造一個違規證明解析抓得到,改一次無關的格式或命名證明結果不變。例子:
`test/identity/android_identity_test.dart` 的 `parser mutations`。

## Quality Check

- `dart format --output=none --set-exit-if-changed .`、`flutter analyze` 乾淨。
- 新的讀原始碼測試有兩個變異案例。
- 沒有新的測試直接連網;`live` 測試在自己的 zone 放行。
1 change: 1 addition & 0 deletions .trellis/tasks/09-28-m1-skeleton-tracer/implement.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@
- [ ] CI 加入:
- Linux、macOS、iOS(不簽名)建置;
- Linux 與 Windows 的整合測試:搜尋→播放、從檔案安裝插件,用測試插件。
- [ ] `default-flavor: dev` 讓 iOS、macOS 建置需要 `dev`/`prod` 兩個 Xcode scheme,這個 PR 補上(PR 2 發現)。
- [ ] `app-release.yml`(只有 `workflow_dispatch`)與 release-please 設定:
- `app/CHANGELOG.md`;
- manifest;
Expand Down
3 changes: 2 additions & 1 deletion .trellis/tasks/09-28-m1-skeleton-tracer/task.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@
"pr_url": null,
"subtasks": [],
"children": [
"09-29-split-agent-instructions"
"09-29-split-agent-instructions",
"09-29-app-skeleton"
],
"parent": "09-26-fmp-rewrite",
"relatedFiles": [],
Expand Down
5 changes: 5 additions & 0 deletions .trellis/tasks/archive/2026-09/09-29-app-skeleton/check.jsonl
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{"file": ".trellis/tasks/09-28-m1-skeleton-tracer/design.md", "reason": "M1 layout, technical choices and CI plan"}
{"file": ".trellis/tasks/09-28-m1-skeleton-tracer/research/m1-tooling-facts.md", "reason": "Package versions and CI action versions"}
{"file": "docs/adr/0015-testing-gates-and-dev-environment.md", "reason": "Zero-network, flavors, CI split"}
{"file": "docs/adr/0008-rewrite-as-new-app-in-same-repo.md", "reason": "App identity"}
{"file": "docs/adr/0009-platform-layer-with-declared-capabilities.md", "reason": "Directory rules"}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{"file": ".trellis/tasks/09-28-m1-skeleton-tracer/design.md", "reason": "M1 layout, technical choices and CI plan"}
{"file": ".trellis/tasks/09-28-m1-skeleton-tracer/research/m1-tooling-facts.md", "reason": "Package versions and CI action versions"}
{"file": "docs/adr/0015-testing-gates-and-dev-environment.md", "reason": "Zero-network, flavors, CI split"}
{"file": "docs/adr/0008-rewrite-as-new-app-in-same-repo.md", "reason": "App identity"}
{"file": "docs/adr/0009-platform-layer-with-declared-capabilities.md", "reason": "Directory rules"}
86 changes: 86 additions & 0 deletions .trellis/tasks/archive/2026-09/09-29-app-skeleton/prd.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# `app/` 骨架與 CI 分流(M1 PR 2)

父任務:`../09-28-m1-skeleton-tracer`(design §2、§3、§5;implement「2.」)。

依據:
- ADR 0008 §決定 2–3:App 身分;
- ADR 0009 §決定 7:目錄規則;
- ADR 0015 §決定 3:零聯網;§決定 8:開發版;§決定 9:CI 切分。

## 做什麼

1. **Flutter 專案**:`app/`,Flutter 3.47.5(Dart 3.13.4)。
- 以 `flutter create --org com.personal --project-name fmp --platforms android,windows,linux,macos,ios app` 為起點。
- `pubspec.yaml`:
- 是 pub workspace 根(`workspace:` 先空,PR 3 加 `packages/fmp_lints`);
- `flutter: default-flavor: dev`;
- 依賴只加本 PR 用得到的。
- 範本 counter app 換成最小的 `MaterialApp`,只顯示 App 名稱與 flavor。UI 在 PR 12 做。
2. **flavor 與 App 身分**:

| 項目 | prod(與舊版相同,ADR 0008) | dev(ADR 0015 §決定 8) |
|---|---|---|
| Android `applicationId` | `com.personal.fmp` | `com.personal.fmp.dev`(`applicationIdSuffix ".dev"`) |
| Android App 名稱 | `FMP` | `FMP Dev` |
| Windows AppUserModelID | `com.personal.fmp` | `com.personal.fmp.dev` |
| Windows 單一實例 mutex | `Local\FMP_MainInstance` | `Local\FMP_MainInstance-dev` |
| Windows 執行檔名 | `fmp.exe` | `fmp.exe`(flavor 只改身分,不改檔名) |
| 資料目錄 | ADR 0009 §決定 7 | 同一規則加 `-dev` |

- Android namespace 維持 `com.personal.fmp`。
- Android 簽名:本 PR 只用 debug 簽名;release 簽名在 PR 13 接 `key.properties`,照舊版的做法。
- Windows 怎麼依 flavor 設 AUMID 與 mutex,用 Flutter 3.47.5 官方支援的桌面 flavor 機制。先以 context7 或官方文件查證 `--flavor` 在 Windows 的支援方式,並把查證來源寫進 PR 描述。官方不支援時,改用 CMake 的 `FLUTTER_FLAVOR` 或 `--dart-define`,但 C++ 端要能在建立視窗前拿到值。
- 單一實例:第二個實例把既有視窗帶到前景後結束,行為照舊版 `windows/runner/main.cpp:12-66`。
- dev 的標記圖示:本 PR 只改名稱,圖示留到 PR 12。
3. **資料目錄**(`lib/platform/` 最小實作;完整的平台層在 PR 4):
- 擁有者 2026-09-29 決定:Windows Portable 版用程式旁的 `userdata/`(dev 為 `userdata-dev/`),不用 ADR 0009 原寫的 `data/`,因為它和 Flutter 的 `data/` 程式資源資料夾同名。
- 只做「App 資料目錄」這一項能力:Android 私有目錄;Windows 安裝版 `%APPDATA%`、Portable 版程式旁 `data/`;dev 加 `-dev`。
- **開發版拒絕舊版正式資料位置**:解析出的路徑若等於舊版資料位置或在它底下,直接拋錯。舊版位置要到舊專案 `lib/` 裡查(例如 `Documents\FMP`、舊 Android 私有目錄),寫進 dartdoc 並附檔名與行號。
4. **零聯網兩道防線**(ADR 0015 §決定 3):
- `app/dart_test.yaml`:`tags: live: skip:`,並用 preset 解除;
- `app/test/flutter_test_config.dart`:以 `HttpOverrides.global` 讓建立真實 `HttpClient` 直接拋錯;
- 測試:一個標 `live` 的測試在裸 `flutter test` 被跳過;以 preset 解除後,它被 `HttpOverrides` 擋下(斷言錯誤訊息)。
5. **測試**:
- prod 身分的每一項等於上表,dev 的每一項都不同;
- Android 讀 `build.gradle.kts` 產出的值,或以 Gradle task 檢查;Windows 讀 C++/CMake 設定的常數,方式不拘,但要能在 CI 的 Linux 跑;
- 開發版拒絕舊版資料位置;
- 零聯網兩道防線。
6. **`material_ui`**:查 3.47.5 的官方文件,決定 Material 元件的 import 路徑,寫進 `app/AGENTS.md`。
7. **`app/AGENTS.md`**(繁中):
- 只寫查不到的契約與有閘門的規則;沒有規則守的不寫;
- 包含:
- 驗證段:本 PR 起的指令;ADR 0027 的實機驗證三句(預設重播或測試插件、真實連線的條件、Android 與 Windows 每個使用者可見的 PR),skill 在 PR 11 前尚未提供,照實寫;
- 身分表;
- 零聯網;
- `material_ui`。
8. **spec**:建 `.trellis/spec/app/testing/index.md`(繁中),寫零聯網與測試分層;不建 `.trellis/spec/app/index.md`(父任務 design §1)。
9. **CI**(`.github/workflows/ci.yml`):
- `dorny/paths-filter@v4`(釘 commit SHA,照現有 action 的寫法);
- 觸發:`app/**` 與 `.github/**` 跑 `app` 的 job,`app/` 以外有變動就跑舊專案的三個 job;
- 舊 job 內容不動;
- `app` job:`dart format --output=none --set-exit-if-changed .`、`flutter analyze`、`flutter test`,Flutter 3.47.5;
- 最後一個 `always()` 彙總 job:needs 全部 job,被跳過的算通過,失敗或取消的算失敗;
- 檔頭註解改寫:說明為什麼現在有路徑過濾,以及為什麼文件變更仍會跑舊 job。
10. **`orca.yaml`**:setup 加上 `app/` 的 `flutter pub get`(本 PR 還沒有 slang)。每行都要是 cmd 與 bash 共通的單一指令(照現有註解)。
11. **Trellis 本機 agent 檔**:`trellis-check.md`、`trellis-implement.md` 第 2 步的 format/analyze 指令依 package 分流:
- `legacy` 維持現狀;
- `app` 在 `app/` 內跑 `dart format --output=none --set-exit-if-changed .` 與 `flutter analyze`。
12. **根目錄 `AGENTS.md`**:地圖補上兩列:
- `app/` 一列(規則讀 `app/AGENTS.md`);
- `.github/workflows/` 改寫成「`ci.yml` 依路徑分流兩個專案;`release.yml` 發舊專案」。

只寫合併後為真的事。

## 驗收

- [ ] 在 `app/` 內 `flutter analyze` 零問題、`flutter test` 全綠;零聯網兩道防線的測試存在並通過。
- [ ] 舊專案 `flutter test --exclude-tags live` 在 Flutter 3.47.5 下仍全綠(本機)。
- [ ] Windows:
- `flutter build windows --flavor dev` 與 `--flavor prod` 都能建置;
- 同時開啟時兩個都在,各自的 AUMID 不同(主對話驗證);
- 同一 flavor 開第二次,會把第一個帶到前景。
- [ ] Android:`flutter build apk --flavor dev --debug` 與 `--flavor prod --debug`;兩個 App 能同時安裝在模擬器(主對話驗證)。
- [ ] PR 的 CI:
- `app` job 與舊 job 都有跑(這個 PR 同時改到 `app/` 和根目錄);
- 彙總 job 綠;
- 再以一個只改 `app/` 的 commit 驗證舊 job 被跳過、彙總仍綠(可在 PR 內做)。
Loading
Loading