diff --git a/app/test/patch_flutter_sdk_test.dart b/app/test/patch_flutter_sdk_test.dart new file mode 100644 index 00000000..8eeed110 --- /dev/null +++ b/app/test/patch_flutter_sdk_test.dart @@ -0,0 +1,274 @@ +// Tests for tool/patch_flutter_sdk.sh — the in-place Flutter SDK patcher +// (FLUTTER-BUMP-HANDOUT.md §5). Runs the real script against fixture SDK trees. +// +// Why this exists: the script's job is to survive an SDK bump, and its most +// dangerous failure mode is not "crashed" but "reported success while doing +// nothing". 3.47.0 moved the #182400 call site one nesting level deeper, which +// made the literal anchor miss — and the script printed "already patched". +// The unpatched bug then only shows up as hundreds of lines of SkSL noise on +// someone's next macOS build. Every case below pins a *distinguishable* report. +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +/// The #182400 call site as it appears in Flutter 3.44.9 — the `else` block is +/// 8 spaces deep. +const _shader344 = r''' +class ShaderCompiler { + Future compileShader() async { + if (!retryResult.succeeded) { + _logger.printError('impellerc failure: ${retryResult.stderr}'); + return false; + } else { + _logger.printError( + 'warning: Shader `${input.path}` is incompatible with SkSL. This ' + 'shader will not load when running with the Skia backend.', + ); + _logger.printError('impellerc failure: ${result.stderr}'); + } + return true; + } +} +'''; + +/// The same call site in Flutter 3.47.0 — one nesting level deeper (10 spaces). +/// Byte-identical logic, different indentation: the case that silently no-opped. +const _shader347 = r''' +class ShaderCompiler { + Future compileShader() async { + if (needsRetry) { + if (!retryResult.succeeded) { + _logger.printError('impellerc failure: ${retryResult.stderr}'); + return false; + } else { + _logger.printError( + 'warning: Shader `${input.path}` is incompatible with SkSL. This ' + 'shader will not load when running with the Skia backend.', + ); + _logger.printError('impellerc failure: ${result.stderr}'); + } + } + return true; + } +} +'''; + +/// A plausible future refactor: the warning is gone entirely (upstream fixed it +/// their own way, or moved it elsewhere). The patch has nothing to attach to and +/// must say so instead of claiming success. +const _shaderRefactored = r''' +class ShaderCompiler { + Future compileShader() async { + if (!result.succeeded) { + _logger.printError('impellerc failure: ${result.stderr}'); + return false; + } + return true; + } +} +'''; + +const _windowClasses = [ + '_WindowCreationRequest', + '_Size', + '_Offset', + '_Rect', + '_Constraints', +]; + +String _windowFile({List classes = _windowClasses}) { + final buf = StringBuffer("import 'dart:ffi';\n\n"); + for (final name in classes) { + buf.writeln('final class $name extends Struct {'); + buf.writeln(' @Int64()'); + buf.writeln(' external int value;'); + buf.writeln('}'); + buf.writeln(); + } + return buf.toString(); +} + +void main() { + // Tests run with CWD = app/, and the script is bash-only, so POSIX + // separators are correct here. + final script = '${Directory.current.path}/tool/patch_flutter_sdk.sh'; + + late Directory sdk; + late File windowFile; + late File shaderFile; + + setUp(() { + sdk = Directory.systemTemp.createTempSync('fake_flutter_sdk'); + windowFile = File( + '${sdk.path}/packages/flutter/lib/src/widgets/_window_macos.dart', + )..createSync(recursive: true); + shaderFile = File( + '${sdk.path}/packages/flutter_tools/lib/src/build_system/tools/' + 'shader_compiler.dart', + )..createSync(recursive: true); + windowFile.writeAsStringSync(_windowFile()); + shaderFile.writeAsStringSync(_shader344); + }); + + tearDown(() => sdk.deleteSync(recursive: true)); + + ProcessResult run() => Process.runSync( + 'bash', + [script], + environment: {'FLUTTER_ROOT': sdk.path}, + ); + + group('#182400 — SkSL stderr dump', () { + test('patches the 3.44.9 call site', () { + final r = run(); + expect(r.exitCode, 0, reason: '${r.stdout}${r.stderr}'); + expect( + shaderFile.readAsStringSync(), + contains(r"_logger.printTrace('impellerc failure: ${result.stderr}');"), + ); + }); + + test('patches the 3.47.0 call site, which is nested one level deeper', () { + shaderFile.writeAsStringSync(_shader347); + final r = run(); + expect(r.exitCode, 0, reason: '${r.stdout}${r.stderr}'); + final out = shaderFile.readAsStringSync(); + expect( + out, + contains(r"_logger.printTrace('impellerc failure: ${result.stderr}');"), + reason: 'indentation must not decide whether the patch applies', + ); + // The other two printError call sites are unrelated and must survive. + expect( + out, + contains( + r"_logger.printError('impellerc failure: ${retryResult.stderr}');", + ), + reason: 'only the call site after the Skia warning may be downgraded', + ); + }); + + test('keeps the concise one-line warning', () { + final r = run(); + // Both guards matter: without them a script that died before touching the + // fixture leaves the warning in place, and this test passes for the one + // reason it is meant to rule out. + expect(r.exitCode, 0, reason: '${r.stdout}${r.stderr}'); + expect( + r.stdout, + contains('[182400] silenced'), + reason: 'the warning only survived something if the patch ran', + ); + expect( + shaderFile.readAsStringSync(), + contains('is incompatible with SkSL'), + ); + }); + + test('is idempotent, and says so', () { + run(); + final afterFirst = shaderFile.readAsStringSync(); + final r = run(); + expect(r.exitCode, 0); + expect(r.stdout, contains('[182400] already patched')); + expect(shaderFile.readAsStringSync(), afterFirst); + }); + + test( + 'fails loudly when the anchor is gone, rather than claiming success', + () { + shaderFile.writeAsStringSync(_shaderRefactored); + final r = run(); + expect( + r.exitCode, + isNot(0), + reason: + 'an unapplied patch must not exit 0 — §5 says stop and ' + 're-derive, which nobody does if the script prints OK', + ); + expect( + '${r.stdout}${r.stderr}', + contains('182400'), + reason: 'the report must name which fix stopped applying', + ); + expect( + r.stdout, + isNot(contains('[182400] already patched')), + reason: '"already patched" on an unpatched file is the actual bug', + ); + }, + ); + }); + + group('#188060 — AOT windowing structs', () { + test('patches all five structs', () { + final r = run(); + expect(r.exitCode, 0, reason: '${r.stdout}${r.stderr}'); + expect(r.stdout, contains('patched 5 class(es)')); + final out = windowFile.readAsStringSync(); + for (final name in _windowClasses) { + expect( + out, + contains("@pragma('vm:entry-point')\nfinal class $name"), + reason: '$name must be force-retained against the tree-shaker', + ); + } + }); + + test('is idempotent', () { + run(); + final afterFirst = windowFile.readAsStringSync(); + final r = run(); + expect(r.exitCode, 0); + expect(r.stdout, contains('patched 0 class(es)')); + expect(windowFile.readAsStringSync(), afterFirst); + }); + + test('distinguishes a renamed struct from an already-patched one', () { + // Upstream renames _Rect. "patched 4; 1 already patched" would be a lie + // that reads exactly like a clean re-run. + windowFile.writeAsStringSync( + _windowFile( + classes: const [ + '_WindowCreationRequest', + '_Size', + '_Offset', + '_Constraints', + ], + ), + ); + final r = run(); + expect( + r.exitCode, + isNot(0), + reason: 'a struct that no longer exists means the AOT crash is back', + ); + expect('${r.stdout}${r.stderr}', contains('_Rect')); + }); + }); + + group('SDK layout', () { + // Naming the absent file is the whole value of the failure: "exited 1" is + // also what a typo in the script looks like. Each patch target is checked + // separately so neither branch can rot behind the other. + for (final (label, victim) in [ + ('shader_compiler.dart', () => shaderFile), + ('_window_macos.dart', () => windowFile), + ]) { + test('fails, naming the file, when $label is missing', () { + victim().deleteSync(); + final r = run(); + expect( + r.exitCode, + isNot(0), + reason: 'a missing file means the patch did not apply', + ); + expect( + '${r.stdout}${r.stderr}', + contains('$label not found'), + reason: 'the report must name the file that moved', + ); + }); + } + }); +} diff --git a/app/tool/patch_flutter_sdk.sh b/app/tool/patch_flutter_sdk.sh index 12fa8e7d..79c6856c 100755 --- a/app/tool/patch_flutter_sdk.sh +++ b/app/tool/patch_flutter_sdk.sh @@ -20,6 +20,21 @@ # compiler stderr (hundreds of lines) on every build. Fix: downgrade that # dump from printError to printTrace so it only shows with `-v`; the concise # one-line "incompatible with SkSL" warning is kept. +# +# CONTRACT — this script exits non-zero if a patch does not apply. +# A patch that no longer matches means the bug is *back*, not fixed: the SkSL +# noise returns, or macOS `--release` crashes at launch. Both are far cheaper to +# learn about here than from a build. Anything other than "patched" or "already +# patched" is a hard failure naming the site that moved, so you can re-derive it +# (FLUTTER-BUMP-HANDOUT.md §5). +# +# Learned the hard way on Flutter 3.47.0: it moved the #182400 call site one +# nesting level deeper. The old literal-with-indentation anchor missed, and the +# script reported "already patched" on a completely unpatched file. Both fixes +# now match indentation-insensitively, and "anchor absent" is a distinct, +# loud state rather than being folded into "already patched". +# +# Covered by app/test/patch_flutter_sdk_test.dart. set -euo pipefail # Locate the Flutter SDK. Xcode build phases export FLUTTER_ROOT but have no @@ -36,69 +51,127 @@ else sdk_root="$(cd "$(dirname "$(readlink -f "$flutter_bin" 2>/dev/null || echo "$flutter_bin")")/.." && pwd)" fi -window_file="$sdk_root/packages/flutter/lib/src/widgets/_window_macos.dart" -shader_file="$sdk_root/packages/flutter_tools/lib/src/build_system/tools/shader_compiler.dart" +# Both fixes are applied by one python pass: it owns locating the call sites, +# rewriting them, invalidating the flutter_tools snapshot, and deciding the exit +# code. Bash's only job is to resolve the SDK root above. +python3 - "$sdk_root" <<'PY' +import re +import sys +from pathlib import Path + +sdk_root = Path(sys.argv[1]) +window_file = sdk_root / "packages/flutter/lib/src/widgets/_window_macos.dart" +shader_file = ( + sdk_root + / "packages/flutter_tools/lib/src/build_system/tools/shader_compiler.dart" +) + +failures = [] + + +def read(path): + """Return the file's text, or None (recording a failure) if it is absent. + + A missing file is a hard failure: the patch cannot have been applied, and + the SDK layout moving is exactly the case that needs a human. + """ + if not path.is_file(): + failures.append( + f"{path} not found — Flutter's layout changed; re-derive the patch" + ) + return None + return path.read_text() + # --- Fix 1: AOT windowing structs (#188060) --------------------------------- -if [[ -f "$window_file" ]]; then - python3 - "$window_file" <<'PY' -import re, sys -path = sys.argv[1] -src = open(path).read() -classes = ["_WindowCreationRequest", "_Size", "_Offset", "_Rect", "_Constraints"] -pragma = "@pragma('vm:entry-point')" -changed = 0 -for name in classes: - decl = f"final class {name} extends Struct {{" - if decl not in src: - continue - if re.search(re.escape(pragma) + r"\n" + re.escape(decl), src): - continue - src = src.replace(decl, f"{pragma}\n{decl}", 1) - changed += 1 -open(path, "w").write(src) -print(f"[188060] patched {changed} class(es); {len(classes) - changed} already patched") -PY -else - echo "warn: $window_file not found (Flutter layout changed?)" >&2 -fi +# Matched on the declaration alone, so reformatting of the struct body cannot +# break it. A class that has vanished is reported by name, never as "already +# patched" — a rename means the tree-shaker drops it again and macOS --release +# crashes at launch. +CLASSES = ["_WindowCreationRequest", "_Size", "_Offset", "_Rect", "_Constraints"] +PRAGMA = "@pragma('vm:entry-point')" + +src = read(window_file) +if src is not None: + patched, already, missing = [], [], [] + for name in CLASSES: + decl = re.compile( + r"^(?P[ \t]*)final class " + re.escape(name) + r"\b[^\n{]*\{", + re.MULTILINE, + ) + m = decl.search(src) + if m is None: + missing.append(name) + continue + preceding = src[: m.start()].rstrip() + if preceding.endswith(PRAGMA): + already.append(name) + continue + src = ( + src[: m.start()] + + f"{m.group('indent')}{PRAGMA}\n" + + src[m.start() :] + ) + patched.append(name) + + if patched: + window_file.write_text(src) + print( + f"[188060] patched {len(patched)} class(es); {len(already)} already patched" + ) + if missing: + failures.append( + "[188060] struct(s) not found: " + + ", ".join(missing) + + " — upstream renamed or removed them. Re-derive the patch; " + "without it macOS --release crashes with 'illegal cid, full-aot'." + ) # --- Fix 2: silence irrelevant SkSL shader dump (#182400) ------------------- -shader_changed=0 -if [[ -f "$shader_file" ]]; then - shader_changed=$(python3 - "$shader_file" <<'PY' -import sys -path = sys.argv[1] -src = open(path).read() -old = ( - " 'shader will not load when running with the Skia backend.',\n" - " );\n" - " _logger.printError('impellerc failure: ${result.stderr}');" -) -new = ( - " 'shader will not load when running with the Skia backend.',\n" - " );\n" - " _logger.printTrace('impellerc failure: ${result.stderr}');" -) -if old in src: - open(path, "w").write(src.replace(old, new, 1)) - print(1) -else: - print(0) -PY +# Anchored on the user-visible warning text and tolerant of leading whitespace, +# because the call site's nesting depth is not stable across releases (3.47.0 +# moved it one level deeper). Only the dump that follows the Skia-backend +# warning is downgraded — the other `impellerc failure:` call sites are real +# errors and must keep shouting. +ANCHOR = re.compile( + r"shader will not load when running with the Skia backend\.',\n" + r"\s*\);\n" + r"\s*_logger\.print(?PError|Trace)" + r"\('impellerc failure: \$\{result\.stderr\}'\);" ) - if [[ "$shader_changed" == "1" ]]; then - echo "[182400] silenced SkSL stderr dump (kept one-line warning)" - # The flutter tool runs from a cached snapshot that is NOT invalidated by a - # source edit (only by git-revision/pubspec changes). Delete it so the next - # `flutter` invocation recompiles the tool from the patched source. - rm -f "$sdk_root/bin/cache/flutter_tools.snapshot" \ - "$sdk_root/bin/cache/flutter_tools.stamp" - else - echo "[182400] already patched" - fi -else - echo "warn: $shader_file not found (Flutter layout changed?)" >&2 -fi -echo "OK: patched Flutter SDK at $sdk_root" +src = read(shader_file) +if src is not None: + m = ANCHOR.search(src) + if m is None: + failures.append( + "[182400] the SkSL dump call site was not found — upstream moved or " + "fixed it. Verify which, then re-derive or delete this fix. Left " + "unpatched, every macOS/iOS build dumps hundreds of SkSL lines." + ) + elif m.group("level") == "Trace": + print("[182400] already patched") + else: + start, end = m.span("level") + shader_file.write_text(src[:start] + "Trace" + src[end:]) + print("[182400] silenced SkSL stderr dump (kept one-line warning)") + # The flutter tool runs from a cached snapshot that is NOT invalidated + # by a source edit (only by git-revision/pubspec changes). Delete it so + # the next `flutter` invocation recompiles the tool from the patched + # source. + for stale in ("flutter_tools.snapshot", "flutter_tools.stamp"): + (sdk_root / "bin" / "cache" / stale).unlink(missing_ok=True) + +if failures: + print("", file=sys.stderr) + for f in failures: + print(f"error: {f}", file=sys.stderr) + print( + "\nSee FLUTTER-BUMP-HANDOUT.md §5. Do not assume the bug is fixed " + "because the patch stopped applying — check upstream first.", + file=sys.stderr, + ) + sys.exit(1) + +print(f"OK: patched Flutter SDK at {sdk_root}") +PY diff --git a/docs/FLUTTER-BUMP-HANDOUT.md b/docs/FLUTTER-BUMP-HANDOUT.md index fd9c02ed..56046363 100644 --- a/docs/FLUTTER-BUMP-HANDOUT.md +++ b/docs/FLUTTER-BUMP-HANDOUT.md @@ -20,10 +20,18 @@ and the other four worktrees did not move. Using it needs a per-shell override: export PATH="/Users/le/Work/Vibe/flutter-3.44.9/bin:$PATH" ``` -**At merge:** move the shared checkout to `3.44.9` (§7 step 1), re-run -`patch_flutter_sdk.sh` against it, then delete `flutter-3.44.9` (4.0 GB). +**Post-merge cleanup — done 2026-08-12.** The shared checkout +`/Users/le/Work/Vibe/flutter` is now `3.44.9` (rev `6b182d2c75`), +`patch_flutter_sdk.sh` re-applied against it (`patched 5 class(es)`), and the +temporary `flutter-3.44.9` directory deleted (3.8 GB reclaimed). `docs/DEVELOPMENT.md` deliberately documents the canonical shared path, not the -temporary one. +temporary one — as of this cleanup that row is finally accurate rather than +aspirational. + +**This cleanup sat undone for four days**, during which local builds ran +`3.44.4` while all 8 CI pins said `3.44.9`. If you land a pin bump the +separate-directory way (§6), do the §7-step-1 move in the *same* sitting or the +drift is invisible until something breaks only on one side. Every claim below was re-checked against 3.44.9 rather than assumed: §3 byte-identical pubspecs ✓ · zero-line `pubspec.lock` diff ✓ · `pub get` still @@ -166,20 +174,22 @@ rather than assuming the bug is fixed. --- -## 6. Trap 2 — one SDK, five worktrees +## 6. Trap 2 — one SDK, every worktree -`/Users/le/Work/Vibe/flutter` is shared by every worktree: +`/Users/le/Work/Vibe/flutter` is shared by every worktree. Run +`git worktree list` for the live set rather than trusting a list here — it goes +stale fast (as of 2026-08-12 it is 4, and 3 of the 5 originally listed are +gone). -```text -/Users/le/Work/Vibe/makit [main] -/Users/le/.worktrees/makit/chore-bump-flutter [chore/bump-flutter] -/Users/le/.worktrees/makit/chore-update-packages [chore/update-packages] -/Users/le/.worktrees/makit/feat-cli-client [feat/cli-client] -/Users/le/.worktrees/makit/feat-todo-lists [feat/todo-lists] -``` +`flutter upgrade` moves **all of them** at once, before this branch has merged +or even passed CI. -`flutter upgrade` moves **all five** at once, before this branch has merged or -even passed CI. Every one of them then needs the §5 patch re-applied. +The §5 patch, however, is applied to the **SDK**, not to a worktree — so one run +of `patch_flutter_sdk.sh` against the shared checkout covers every worktree, and +re-running it from another worktree is a verified no-op +(`patched 0 class(es); 5 already patched`). Earlier wording here and in §7 step 0 +implied per-worktree re-application; that is wrong, and only misleads you into +thinking you have more work than you do. If that is unacceptable, install the new SDK into a separate directory and point only this worktree's `PATH` at it, leaving the shared checkout on 3.44.4 until @@ -293,14 +303,35 @@ update the VM image. means the SDK pins moved after all — stop and re-read §3, because the blast radius is then much larger than this branch assumes - [x] `flutter analyze` — clean -- [x] `flutter test` — no *new* failures. Known pre-existing noise: 5–17 test - files fail at the *loading* stage on any whole-suite run, a different set - each time, and each one passes when run alone. **Not** a parallelism - artifact as earlier drafts of this file claimed — `--concurrency 1` still - fails 5–7. Baseline on `main` @ 3.44.4: 8 failures parallel / 7 serial, - and `0` non-loading either way. Check that - `grep -v ': loading '` over the failure list is empty and that the count - is in family with a baseline run before blaming the bump +- [x] `flutter test` — no *new* failures. Known pre-existing noise: test files + fail at the *loading* stage on any whole-suite run with + `Unable to connect to flutter_tester process: WebSocketException: Invalid + WebSocket upgrade request`, a different set each time, and each one passes + when run alone. **Not** a parallelism artifact as earlier drafts of this + file claimed — `--concurrency 1` still fails. + Baselines, all with `0` non-loading failures: + | SDK | parallel | serial | + |---|---|---| + | 3.44.4 (`main`, 2026-08-08) | 8 | 7 | + | 3.44.9 (2026-08-12) | 19, then 28 | 9, then 2 | + | 3.47.0 (2026-08-13, §11) | — | 3 | + **Neither count is reproducible run-to-run — not even the serial one**, so + do not gate on a number at all. Measured on one machine within one hour, + serial ranged 2–9 and parallel 19–28 on the *same* commit and SDK; a + failed load also swallows that file's tests, so the pass total moves too + (2861–3090). The only stable signal is the **non-loading count, which must + be 0**, and each named file passing when run alone. +- [x] Two failures that *look* like real regressions but are **intentional** — + do not chase them. Both print a full `EXCEPTION CAUGHT` banner with a + stack trace into production code, without incrementing the failure count: + - `test/desktop/chat/workspace_controller_test.dart` — "a throwing sink + cannot take the mutation (or the app) down" throws + `StateError('disk full')` on purpose; the trace points at + `WorkspaceController._commit`/`divideActive`. File alone: `+48` green. + - `test/diagnostics/error_capture_test.dart` — "installErrorCapture + funnels a framework error into the log" throws `Exception: boom in build`. + Read the *counter* (`-N`), not the banner: if `-N` did not increment on + that line, nothing failed. - [x] `flutter build macos --release` succeeds and **launches** — this is the #188060 canary; an `illegal cid, full-aot` crash means step 2 was skipped - [x] `cd server && pnpm typecheck && pnpm test` — untouched, but cheap to confirm @@ -314,7 +345,145 @@ update the VM image. - Do not merge package-dependency changes into this branch. `chore/update-packages` owns those; keeping them apart is what makes both diffs reviewable. -- Do not switch channels to get Dart 3.13 without a separate decision (§3). +- Do not switch **channels** to get a newer Dart. (Dart 3.13 no longer needs a + channel switch — it is on stable in 3.47.0, §11 — but the rule stands for + whatever is next.) - Do not edit `app/pubspec.lock` by hand — see the header comment in `app/pubspec.yaml` and `SECURITY.md`. - Do not skip `app/tool/patch_flutter_sdk.sh` (§5). +- Do not trust the patch script's output without reading it. "Already patched" + used to be printed for an unpatched file (§11); it is a hard failure now, but + the general lesson holds — a patch that reports success is not the same as a + patch that applied. + +--- + +## 11. Next bump — 3.47.0, evaluated 2026-08-13 (not landed) + +Measured against this branch on the real SDK, installed as a **git worktree of +the existing SDK clone** rather than a second full clone: + +```sh +git -C /Users/le/Work/Vibe/flutter worktree add /Users/le/Work/Vibe/flutter-3.47.0 3.47.0 +``` + +That is a strictly better version of §6's "separate directory": it shares the +object store, so the checkout costs ~1.5 GB instead of ~4 GB, and +`flutter --version` still reports `3.47.0` correctly. Remove it with +`git -C .../flutter worktree remove ../flutter-3.47.0`, not `rm -rf`. + +| | version | Dart | published | §3 lockfile moves? | +|---|---|---|---|---| +| pinned | `3.44.9` | 3.12.2 | 2026-08-06 | — | +| candidate | `3.47.0` | **3.13.0** | 2026-08-12 18:44 UTC | **yes — 6 packages** | + +**Cooldown.** The repo's window is **3 days** (`cooldownWindow` in +`tool/pub_cooldown.dart`, SECURITY.md §8 — *not* 7; §8 records why 7 was +rejected). 3.47.0 clears it at **2026-08-15 18:44 UTC**. Note the gate covers +`pubspec.lock` packages, **not** the SDK itself, so holding the SDK to that +window is a judgement call by analogy, not something CI enforces. The 6 packages +it *does* force are all ≥3 days old — `pub_cooldown.dart` passes on the 3.47.0 +lockfile today. + +### What it costs — four things, none of them optional + +1. **macOS deployment target 10.15 → 12.0 — accepted 2026-08-13 (KC).** + `flutter build macos` silently rewrites `macos/Podfile`, + `macos/Podfile.lock` and `macos/Runner.xcodeproj/project.pbxproj` through the + tool's own `macos_deployment_target_migration.dart`. That is **dropping macOS + 11 and earlier** — a product decision wearing the costume of a build + artifact, so it is recorded here rather than discovered in a diff. + **Decision: acceptable.** macOS 11 (2020) is four majors behind; current is + 26.5.2. Nothing user-facing promised 10.15 — in fact the app's minimum OS is + documented *nowhere*, only implied by `project.pbxproj`. Land the three + migrated files deliberately in the bump commit, and close that documentation + gap at the same time (see the landing checklist). +2. **6 SDK-forced package bumps** — `matcher` 0.12.19→0.12.20, `meta` + 1.18.0→1.19.0, `test` 1.31.0→1.31.1, `test_api` 0.7.11→0.7.12, + `test_core` 0.6.17→0.6.18, `vector_math` **2.2.0→2.4.2**. So + `--enforce-lockfile` fails until the lockfile is regenerated — unlike the + 3.44.x bumps, where §3's "zero-line lockfile diff" held. The + "N packages incompatible" warning drops 27 → 24. +3. **5 golden images must be regenerated.** All five are **rounded-corner + anti-aliasing only** — verified by eye on the `*_isolatedDiff.png` files, + which show the four corner arcs and nothing else: no text or layout + movement. 0.02% / ~75px on `desktop/chat/open_in_ide_golden_test.dart` + (`split button — light|dark`); 0.08% / ~160px on + `ui/composer/context_usage_golden_test.dart` (`details panel — + codex|pi|tightening`). Do **not** regenerate them before the bump lands — + they would then fail on the pinned 3.44.9. +4. **`pub get` rewrites `analysis_options.yaml`**, appending + `android|ios|web|windows|macos|linux/**` to `analyzer.exclude`. Tracked file, + silent edit, so every dev and CI run shows a dirty tree until it is + committed. + +### What it does not cost + +- `flutter analyze` — clean. +- `flutter test --concurrency 1` — 3035 pass; 3 loading-stage flakes (§9) plus + the 5 goldens above, and nothing else. Count failures from + `--reporter=json` (`testDone` events whose `result != success`, ignoring + `hidden`) rather than reading the human reporter — that is the only way to + separate a real failure from the intentional-exception banners §9 lists. +- `flutter build macos --release` — builds **and launches**; the #188060 canary + is clean. +- The §5 patch — still needed, and still applies. See below. + +### The §5 patch nearly broke silently here + +Issue `#188060` is **still open** in 3.47.0 (no `vm:entry-point` upstream, all +five structs still present) and that half of the patch applies unchanged. But +3.47.0 moved the `#182400` call site one nesting level deeper, so the old +literal-with-indentation anchor missed — and the script printed +**`[182400] already patched` against a completely unpatched file**. That is +exactly the "do not assume the bug is fixed" failure §5 warns about, except the +script itself was doing the assuming. + +Fixed on this branch: both fixes now match indentation-insensitively, "anchor +absent" is a distinct hard failure (non-zero exit naming the site that moved), +and a *renamed* struct no longer reports as "already patched". Covered by +`app/test/patch_flutter_sdk_test.dart`, which pins the 3.44.9 **and** 3.47.0 call +sites as fixtures, so the next bump fails a test instead of a build. + +### Landing it, once the window clears + +Follow §7 with `3.47.0`, plus these extra steps: + +```sh +cd app +flutter pub get # NOT --enforce-lockfile; the lockfile must move +dart run tool/pub_cooldown.dart # must pass; gates the 6 forced packages +flutter test --update-goldens test/desktop/chat/open_in_ide_golden_test.dart \ + test/ui/composer/context_usage_golden_test.dart + +git add macos/Podfile macos/Podfile.lock macos/Runner.xcodeproj/project.pbxproj \ + analysis_options.yaml pubspec.lock \ + test/desktop/chat/goldens/ide_launcher_light.png \ + test/desktop/chat/goldens/ide_launcher_dark.png \ + test/ui/composer/goldens/spec37_panel_codex.png \ + test/ui/composer/goldens/spec37_panel_pi.png \ + test/ui/composer/goldens/spec37_panel_tightening.png + +# Those are the only five goldens expected to move. Confirm no sixth did: +git status --short -- test/ # must list exactly the five above +``` + +**Why the explicit list rather than `git add test/`:** the two commands above +regenerate seven goldens, and `ide_launcher_menu.png` / +`spec37_ring_ladder.png` are expected to come back byte-identical. A sixth file +appearing means something other than corner anti-aliasing changed — layout or +text moved — which invalidates the "cosmetic only" finding above. Staging the +directory wholesale is exactly what would hide it. + +And two docs that §8's pin list does not cover. **Both are repo-root files while +the block above runs from `app/`**, so stage them with a root-anchored pathspec +(`:/`) or from the repo root — plain `git add AGENTS.md` inside `app/` fails: + +```sh +git add :/AGENTS.md :/BUILD_AND_DEPLOY.md +``` + +- `AGENTS.md` — documents the Cursor Cloud VM's Flutter version. +- `BUILD_AND_DEPLOY.md` — state the supported minimum, **macOS 12.0+**, which + the bump makes true and which no doc currently says at all. A support floor + that exists only inside `project.pbxproj` is one nobody can check against.