diff --git a/.claude/skills/api-contract/SKILL.md b/.claude/skills/api-contract/SKILL.md index 959fc43f..08846da8 100644 --- a/.claude/skills/api-contract/SKILL.md +++ b/.claude/skills/api-contract/SKILL.md @@ -80,7 +80,27 @@ stabilises. `buf`, `watchexec`, and `github:googleapis/api-linter` are all installed via GitHub release downloads (aqua backend). In this sandbox the outbound proxy -returned `403 Forbidden` for all three when tested live: +returned `403 Forbidden` for all three when tested live. + +> **Since #510**, `buf` (pinned `1.70.0`) and `api-linter` (pinned `2.3.1`) are +> declared as *task-scoped* tools on the `proto:*` tasks rather than in +> `mise.dev.toml`'s `[tools]`. Non-proto tasks (`check`, `test`, `analyze`) no +> longer resolve them at all, so the 403 can't block a Dart test run any more. +> For the `proto:*` tasks themselves the 403 still applies — but as a `WARN` +> rather than a fatal install error. +> +> A lockfile does **not** rescue this, despite being the right mechanism in +> general. Both tools were already pinned and locked in `mise.dev.lock` (mise +> keeps one lockfile per config file — don't check only `mise.lock`), and the +> 403 happened regardless. Verified on mise 2026.5.8: the failing call is SLSA +> provenance verification, download and checksum both succeed, a lock entry +> does not suppress it, and `MISE_SLSA_VERIFY=0` does not disable it. +> +> So proto tooling in this sandbox remains best-effort — push and let +> `ci.yml`'s `proto` job (which installs buf via `buf-action`, a different code +> path) be the first real lint. + +The original failure mode: ``` mise ERROR Failed to install tools: aqua:bufbuild/buf@latest, aqua:watchexec/watchexec@2.5.1, github:googleapis/api-linter@latest diff --git a/.claude/skills/build-and-env/SKILL.md b/.claude/skills/build-and-env/SKILL.md index 1a0e56c4..6f098378 100644 --- a/.claude/skills/build-and-env/SKILL.md +++ b/.claude/skills/build-and-env/SKILL.md @@ -60,7 +60,7 @@ uses four, plus a Tera-templated auto-selector: | File | Selected by | Tools / tasks it adds | Audience | |---|---|---|---| | `mise.toml` | always (base) | `flutter=3.44.0`, `node=22`, `shellcheck=0.9.0`, `shfmt=3.8.0`; tasks: `generate`, `dart:format*`, `prettier:*`, `fmt:check`, `mise:format`, `format`, `check`, `goldens:update`, `validate:festivals`, `test:worker`, `analyze`, `test`, `coverage` | CI and everyone | -| `mise.dev.toml` | `MISE_ENV=dev` | `watchexec=2.5.1`, `buf=latest`, `github:googleapis/api-linter=latest`; tasks: all `proto:*`, plus file-tasks in `mise-tasks/` — `dev`, `dev:tunnel`, `build:web`, `build:web:prod`, `serve:release`, `test:e2e*`, `setup:playwright`, `setup:tunnel`, `screenshots:batch`, `test:check-page` | Building/running/proto work | +| `mise.dev.toml` | `MISE_ENV=dev` | env-level: `watchexec=2.5.1` only. `buf=1.70.0` and `github:googleapis/api-linter=2.3.1` are **task-scoped** on the `proto:*` tasks that use them (#510), so they can't block non-proto tasks; tasks: all `proto:*`, plus file-tasks in `mise-tasks/` — `dev`, `dev:tunnel`, `build:web`, `build:web:prod`, `serve:release`, `test:e2e*`, `setup:playwright`, `setup:tunnel`, `screenshots:batch`, `test:check-page` | Building/running/proto work | | `mise.human.toml` | `MISE_ENV=dev,human` | `claude`, `cloudflared`, `gh`, `npm:firebase-tools` | Human machines only — never load on an agent | | `mise.claude-code-web.toml` | `.miserc.toml` auto-select, or explicit `MISE_ENV=claude-code-web` | `[settings] libgit2=false, gix=false` (git-transport fix, §3b); `node=path:/opt/node22`, `python=path:/usr`, `jq=path:/usr` (reuse sandbox-baked binaries instead of downloading) | Claude Code Web sandbox only | @@ -93,12 +93,40 @@ out `MISE_ENV=claude-code-web,dev`. The agent proxy in this sandbox returns 403 for direct GitHub release-asset downloads. `buf`, `watchexec`, and `github:googleapis/api-linter` are all -installed via aqua/GitHub-release backends (`mise.dev.toml:18-26`), so the -**first** task that needs any of them fails mid-install — and because -`.miserc.toml` auto-selects `dev` on Claude Code Web, this means **any** -`./bin/mise run ` can trip it, even for a task that itself doesn't need -those tools, if mise decides to resolve/install the full active toolset. -Reproduced live in this session: +installed via aqua/GitHub-release backends, so the **first** task that needs +any of them fails mid-install. + +> **Addressed by #510 — and read this before theorising about lockfiles.** +> +> mise keeps **one lockfile per config file**: `mise.toml` → `mise.lock`, +> `mise.dev.toml` → `mise.dev.lock`. Both are committed. Checking only +> `mise.lock` and concluding the dev tools are unlocked is a trap — they live +> in `mise.dev.lock`. +> +> `buf` (1.70.0) and `api-linter` (2.3.1) were **already pinned and locked**, +> with checksums and asset URLs, and the 403 happened anyway. **Verified on +> mise 2026.5.8: a lockfile entry does not suppress api-linter's SLSA +> provenance call.** Download and checksum both succeed; only the provenance +> lookup against `api.github.com` 403s, and `MISE_SLSA_VERIFY=0` does not +> disable it. mise had even recorded `github_attestations = "unavailable"` in +> the lock and still attempted verification — likely a mise bug. +> +> So #510 attacks blast radius, not the fetch: `buf` and `api-linter` are now +> *task-scoped* to the `proto:*` tasks, so a non-proto task (`check`, `test`, +> `analyze`) never resolves them, and a scoped tool that fails to fetch emits a +> `WARN` and still runs the task body. `lockfile = true` is set so the locks +> stay maintained. Only `watchexec` remains env-level. +> +> **Trade-off to know:** task-scoped tools aren't in a config's `[tools]` set, +> so `mise lock` drops them from `mise.dev.lock` — those two lose lock coverage +> and re-resolve on a fresh worktree. A `MISE_ENV=dev,proto` env file would +> keep both properties at the cost of changing the `proto:*` invocation. +> +> The history below is retained because it explains the trap. + +Historically, because `.miserc.toml` auto-selects `dev` on Claude Code Web, +**any** `./bin/mise run ` could trip this, even a task that itself +didn't need those tools. Reproduced live: ``` mise buf@1.70.0 [1/3] download buf-Linux-x86_64.tar.gz diff --git a/docs/code/domain-architecture.md b/docs/code/domain-architecture.md index fcddfb65..98b6461b 100644 --- a/docs/code/domain-architecture.md +++ b/docs/code/domain-architecture.md @@ -17,20 +17,32 @@ The app uses a **layered architecture** with a dedicated domain layer containing ↓ ┌─────────────────────────────────────────────┐ │ State Management (BeerProvider) │ -│ • Orchestrates domain services │ +│ • Composes domain controllers │ │ • Manages UI state (loading, errors) │ +│ • Handles persistence, analytics, notify │ │ • Uses repositories for data access │ └───────────┬────────────────┬────────────────┘ - │ delegates to │ uses + │ composes │ uses ↓ ↓ -┌───────────────────────┐ ┌──────────────────────────────┐ -│ Domain Layer │ │ Domain Repositories │ -│ • DrinkFilterService │ │ • DrinkRepository (interface)│ -│ • DrinkSortService │ │ • FestivalRepository (iface) │ -│ Pure business logic │ │ Data access abstractions │ -└───────────────────────┘ └────────────┬─────────────────┘ - │ operates on │ implemented by - ↓ ↓ +┌───────────────────────────┐ ┌──────────────────────────────┐ +│ Domain Controllers │ │ Domain Repositories │ +│ • DrinkFilterController │ │ • DrinkRepository (interface)│ +│ • FestivalController │ │ • FestivalRepository (iface) │ +│ • UserDrinkStateController│ │ Data access abstractions │ +│ • UserPreferencesController│ └───────────┬─────────────────┘ +│ Owns filter/sort state │ │ +└───────────┬───────────────┘ │ + │ delegates to │ + ↓ │ +┌───────────────────────┐ │ +│ Domain Services │ │ +│ • DrinkFilterService │ │ +│ • DrinkSortService │ │ +│ • SearchMatchService │ │ +│ Pure, stateless │ │ +└───────────┬───────────┘ │ + │ operates on implemented by + ↓ ↓ ┌─────────────────────────────────────────────┐ │ Data Layer (Models) │ │ • Drink, Product, Producer, Festival │ @@ -47,9 +59,10 @@ The app uses a **layered architecture** with a dedicated domain layer containing ┌─────────────────────────────────────────────┐ │ Infrastructure (Services) │ │ • BeerApiService - HTTP calls │ -│ • FavoritesService - Storage │ +│ • UserDataStore - All per-user data │ +│ • DrinkCacheService - Offline catalogue │ │ • FestivalService - Festival API │ -│ • FestivalStorageService - Storage │ +│ • FestivalStorageService - Festival choice │ │ • AnalyticsService - Tracking │ └─────────────────────────────────────────────┘ ``` @@ -63,28 +76,40 @@ The app uses a **layered architecture** with a dedicated domain layer containing **Purpose:** Contains all filtering logic for drinks. **Methods:** -- `filterByCategory(drinks, category)` - Filter by category (beer, cider, etc.) +- `filterByCategories(drinks, categories)` - Filter by categories (multi-select, OR logic) - `filterByStyles(drinks, styles)` - Filter by multiple styles (OR logic) -- `filterByFavorites(drinks, favoritesOnly)` - Show only favorites -- `filterByAvailability(drinks, hideUnavailable)` - Hide out-of-stock drinks -- `filterBySearch(drinks, query)` - Search across name, brewery, style, notes -- `applyAllFilters(drinks, {...})` - Convenience method for all filters +- `filterByFavorites(drinks, favoritesOnly:)` - Show only favorites +- `filterByAvailability(drinks, hideUnavailable:)` - Hide sold-out drinks +- `filterByNotTasted(drinks, notTastedOnly:)` - Hide drinks already tasted +- `filterByVegan(drinks, veganOnly:)` - Only drinks explicitly flagged vegan +- `filterByExcludedAllergens(drinks, excludedAllergens)` - Exclude drinks carrying any listed allergen +- `filterBySearch(drinks, query)` - Search across name, brewery, style, description, and the user's own note +- `filterDrinks(drinks, {...})` - Composes all of the above in sequence **Design:** - Pure functions - no side effects - Stateless - no instance variables -- No dependencies - operates only on data passed as parameters +- Single-purpose filters return a lazy `Iterable`; `filterDrinks` materialises once at the end +- `filterDrinks` is *composed from* the single-purpose filters rather than + re-testing each predicate inline, so there is exactly one copy of every rule — + a fix to `filterByAvailability` cannot pass its own unit test while leaving the + rendered list unchanged - Returns new lists - doesn't mutate input +- Free-text matching is delegated to `SearchMatchService`, the shared source of + truth for which fields search covers, so the UI's excerpt/highlighting stays in + lock-step with what actually matched **Example:** ```dart final service = DrinkFilterService(); -final filtered = service.applyAllFilters( +final filtered = service.filterDrinks( allDrinks, - category: 'beer', + categories: {'beer'}, styles: {'IPA', 'Bitter'}, favoritesOnly: true, + visibilityFilters: {DrinkVisibilityFilter.availableOnly}, + excludedAllergens: {'gluten'}, searchQuery: 'hoppy', ); ``` @@ -143,24 +168,28 @@ final sorted = service.sortDrinks(drinks, DrinkSort.abvHigh); - `removeRating(festivalId, drinkId)` - Remove drink rating **Implementation:** `ApiDrinkRepository` -- Wraps `BeerApiService`, `FavoritesService`, `RatingsService` -- Fetches drinks and populates favorite/rating status in a single operation +- Delegates catalogue loading to `BeerApiService` / `DrinkCacheService`, and all + personal state to a `UserDataStore` +- Fetches drinks and populates favorite/rating/tasted status in a single operation **Design:** - Interface in domain layer - abstracts data access - Implementation uses infrastructure services - BeerProvider depends on repository interface, not concrete services +- Depending on the `UserDataStore` *interface* (not the concrete + SharedPreferences store) keeps a synced backend a constructor swap **Example:** ```dart final repository = ApiDrinkRepository( apiService: BeerApiService(), - favoritesService: FavoritesService(prefs), - ratingsService: RatingsService(prefs), + userDataStore: userDataStore, + cacheService: DrinkCacheService(), + analyticsService: AnalyticsService(), ); final drinks = await repository.getDrinks(festival); -// Drinks already have isFavorite and rating populated +// Drinks already have isFavorite, rating, and tasted status populated ``` ### FestivalRepository @@ -178,33 +207,106 @@ final drinks = await repository.getDrinks(festival); - Wraps `FestivalService`, `FestivalStorageService` - Separates festival data fetching from local preference storage -## BeerProvider Orchestration +## Domain Controllers -`BeerProvider` delegates business logic to domain services: +Controllers sit between `BeerProvider` and the pure services. They own +*application state* (the user's current filter selections) while the services +stay stateless. Like services they are pure application logic — no Flutter, +persistence, async, or analytics — so they unit-test in isolation. -```dart -void _applyFiltersAndSort() { - var drinks = List.from(_allDrinks); +### DrinkFilterController + +**Location:** `lib/domain/controllers/drink_filter_controller.dart` + +**Purpose:** Owns filtering, sorting, and search state, and derives the views the +UI needs — the filtered list plus the category/style/allergen facets. + +`BeerProvider` composes this controller, feeds it the loaded drinks via +`setSource()`, and handles the cross-cutting concerns (persistence, analytics, +change notification) around it. All mutators are synchronous and side-effect +free; callers persist and broadcast. - // Delegate filtering to domain service - drinks = _filterService.applyAllFilters( - drinks, - category: _selectedCategory, +**The single recompute path** is `recompute()` — every mutator ends by calling +it, and it is the only place the filter/sort pipeline runs: + +```dart +void recompute() { + final filtered = _filterService.filterDrinks( + _source, + categories: _selectedCategories, styles: _selectedStyles, favoritesOnly: _showFavoritesOnly, - hideUnavailable: _hideUnavailable, + visibilityFilters: _visibilityFilters, + excludedAllergens: _excludedAllergens, searchQuery: _searchQuery, ); - - // Delegate sorting to domain service - drinks = _sortService.sortDrinks(drinks, _currentSort); - - _filteredDrinks = drinks; + _filtered = _sortService.sortDrinks(filtered, _currentSort); } ``` -**Before refactoring:** 63 lines of filtering/sorting logic in `_applyFiltersAndSort()` -**After refactoring:** 17 lines delegating to domain services +Call `recompute()` directly after the source drinks mutate in place (e.g. a +favourite or tasted toggle); use `setSource()` when the list itself is replaced. + +#### The facet-scoping rule + +`availableCategories`, `categoryCountsMap`, `availableStyles`, `styleCountsMap`, +`stylesByCategory`, and `availableAllergens` are all derived by one rule: + +> **A facet is computed from the source with every *other* structural filter +> applied — but never its own.** + +Structural filters are category, styles, favourites-only, visibility filters, and +excluded allergens. A facet must not narrow itself, or selecting one of its own +options would hide its siblings and the list would collapse under the user's +finger — picking one style must not make every other style vanish from the style +picker. `_scopeFor(_Facet)` is the single helper all the getters share; it blanks +out exactly the facet's own criterion. + +Two invariants hold for every facet: + +1. **An active filter is never hidden.** A currently-selected option is always + listed, even when its scoped count is 0. This matters most for allergen + exclusions, which are a *safety* filter — a ticked allergen must never vanish + from the UI the user would use to untick it. +2. **Allergens must be actually present.** `availableAllergens` lists only + allergens with a non-zero value on some drink in scope, not merely mentioned + with a value of 0 — matching `DrinkFilterService.filterByExcludedAllergens`'s + own definition of "absent". + +**Free-text search is deliberately excluded from facet scoping.** +`drinks_screen.dart` derives `hasStyleFilter` from +`provider.availableStyles.isNotEmpty` to decide whether to show the Style button +in the filter bar. Scoping facets by the search query would make that button +appear and disappear as the user types. + +A related consequence: `toggleCategory` *prunes* the style selection to the new +scope rather than clearing it. Under the old single-select category this was an +unconditional clear, which is too destructive for multi-select — adding "perry" +to an existing "cider" selection would otherwise wipe a cider style the user just +picked. + +## BeerProvider Orchestration + +`BeerProvider` composes the controllers and exposes their derived views: + +```dart +BeerProvider({ + AnalyticsService? analyticsService, + DrinkFilterService? filterService, + DrinkSortService? sortService, + DrinkRepository? drinkRepository, + FestivalRepository? festivalRepository, +}) : _analyticsService = analyticsService ?? AnalyticsService(), + _filter = DrinkFilterController( + filterService: filterService, + sortService: sortService, + ), + _drinkRepository = drinkRepository, + _festivalRepository = festivalRepository; + +// Derived views delegate straight through +List get drinks => _filter.filteredDrinks; +``` ## Design Principles @@ -286,9 +388,9 @@ test('filters by category', () { **After:** ```dart // Simple, focused unit test -test('filters by category', () { +test('filters by categories', () { final service = DrinkFilterService(); - final result = service.filterByCategory(testDrinks, 'beer'); + final result = service.filterByCategories(testDrinks, {'beer'}); expect(result, hasLength(2)); }); ``` @@ -296,8 +398,9 @@ test('filters by category', () { ### 2. Better Maintainability Changes to filtering logic: -- **Before:** Modify `BeerProvider._applyFiltersAndSort()` (63 lines) -- **After:** Modify `DrinkFilterService` (focused, single responsibility) +- **Before:** Modify a monolithic `_applyFiltersAndSort()` inside `BeerProvider` +- **After:** Modify `DrinkFilterService` (a single rule) or + `DrinkFilterController` (how the rules compose and what state drives them) ### 3. Code Reuse @@ -382,12 +485,12 @@ Potential extensions to the domain layer: Data access is now abstracted behind repository interfaces: **Repository Interfaces:** -- `DrinkRepository` - Abstracts drink data access, favorites, and ratings +- `DrinkRepository` - Abstracts drink data access, favorites, ratings, and tastings - `FestivalRepository` - Abstracts festival data access and user preferences **Implementations:** -- `ApiDrinkRepository` - Wraps BeerApiService, FavoritesService, RatingsService -- `ApiFestivalRepository` - Wraps FestivalService, FestivalStorageService +- `ApiDrinkRepository` - Wraps BeerApiService, UserDataStore, DrinkCacheService, AnalyticsService +- `ApiFestivalRepository` - Wraps FestivalService, FestivalStorageService, FestivalCacheService, AnalyticsService **Location:** `lib/domain/repositories/` @@ -395,33 +498,22 @@ Data access is now abstracted behind repository interfaces: ```dart abstract class DrinkRepository { Future> getDrinks(Festival festival); + Future?> getCachedDrinks(Festival festival); Future> getFavorites(String festivalId); - Future toggleFavorite(String festivalId, String drinkId); + Future toggleFavorite(String festivalId, String drinkId); Future getRating(String festivalId, String drinkId); - Future setRating(String festivalId, String drinkId, int rating); - Future removeRating(String festivalId, String drinkId); -} - -class ApiDrinkRepository implements DrinkRepository { - final BeerApiService _apiService; - final FavoritesService _favoritesService; - final RatingsService _ratingsService; - - @override - Future> getDrinks(Festival festival) async { - final drinks = await _apiService.fetchAllDrinks(festival); - // Populate favorites and ratings - final favorites = _favoritesService.getFavorites(festival.id); - for (final drink in drinks) { - drink.isFavorite = favorites.contains(drink.id); - drink.rating = _ratingsService.getRating(festival.id, drink.id); - } - return drinks; - } - // ... other methods + Future setRating(String festivalId, String drinkId, int rating); + Future removeRating(String festivalId, String drinkId); + Future hasTasted(String festivalId, String drinkId); + Future toggleTasted(String festivalId, String drinkId); + // ... tasting-log methods } ``` +Mutating methods return the persisted `UserDrinkState`, or `null` when the +record was pruned to empty — the caller updates from what was actually written +rather than assuming the write succeeded. + **Benefits:** - **Testability:** BeerProvider can be tested with mock repositories - **Decoupling:** Provider doesn't depend on concrete services @@ -438,7 +530,7 @@ class LoadFestivalDrinksUseCase { Future> execute(Festival festival, FilterCriteria criteria) { final drinks = await _repository.getDrinks(festival); - return _filterService.applyAllFilters(drinks, ...); + return _filterService.filterDrinks(drinks, ...); } } ``` @@ -451,19 +543,21 @@ Encapsulate validation and behavior: ```dart class FilterCriteria { - final String? category; + final Set categories; final Set styles; final bool favoritesOnly; - final bool hideUnavailable; + final Set visibilityFilters; + final Set excludedAllergens; final String searchQuery; FilterCriteria({...}); bool get hasActiveFilters => - category != null || + categories.isNotEmpty || styles.isNotEmpty || favoritesOnly || - hideUnavailable || + visibilityFilters.isNotEmpty || + excludedAllergens.isNotEmpty || searchQuery.isNotEmpty; } ``` diff --git a/mise-tasks/shell/check.sh b/mise-tasks/shell/check.sh index 240df7c4..d0ea0da6 100755 --- a/mise-tasks/shell/check.sh +++ b/mise-tasks/shell/check.sh @@ -3,7 +3,16 @@ set -euo pipefail -mapfile -t ALL_FILES < <(find . -name "*.sh" -not -path "./.git/*" -not -path "./.mise/*" -not -path "*/node_modules/*" | sort) +# Exclusions are unanchored (`*/.mise/*`, not `./.mise/*`) so they also match +# nested copies inside agent worktrees, which carry their own extracted Flutter +# SDK — thousands of vendored .sh files, some of which shfmt/shellcheck reject +# (#509). Worktrees are skipped outright as well: their scripts are already +# covered by this same task in the main tree. +mapfile -t ALL_FILES < <(find . -name "*.sh" \ + -not -path "./.git/*" \ + -not -path "*/.mise/*" \ + -not -path "*/.claude/worktrees/*" \ + -not -path "*/node_modules/*" | sort) if [ "${#ALL_FILES[@]}" -eq 0 ]; then echo "No shell scripts found" diff --git a/mise-tasks/shell/format.sh b/mise-tasks/shell/format.sh index 585f3da4..a036ce89 100755 --- a/mise-tasks/shell/format.sh +++ b/mise-tasks/shell/format.sh @@ -3,7 +3,16 @@ set -euo pipefail -mapfile -t ALL_FILES < <(find . -name "*.sh" -not -path "./.git/*" -not -path "./.mise/*" -not -path "*/node_modules/*" | sort) +# Exclusions are unanchored (`*/.mise/*`, not `./.mise/*`) so they also match +# nested copies inside agent worktrees, which carry their own extracted Flutter +# SDK — thousands of vendored .sh files, some of which shfmt/shellcheck reject +# (#509). Worktrees are skipped outright as well: their scripts are already +# covered by this same task in the main tree. +mapfile -t ALL_FILES < <(find . -name "*.sh" \ + -not -path "./.git/*" \ + -not -path "*/.mise/*" \ + -not -path "*/.claude/worktrees/*" \ + -not -path "*/node_modules/*" | sort) if [ "${#ALL_FILES[@]}" -eq 0 ]; then echo "No shell scripts found" diff --git a/mise.dev.toml b/mise.dev.toml index fe3ab1eb..f01a0e42 100644 --- a/mise.dev.toml +++ b/mise.dev.toml @@ -16,33 +16,64 @@ [tools] watchexec = "2.5.1" -buf = "latest" # --- Protobuf / OpenAPI (API contract is proto-first; see proto/README.md) --- -# buf and api-linter live here (dev-only) while the API design is still in flux. +# buf and api-linter are declared as *task-scoped* tools on the proto:* tasks +# that actually invoke them, not in [tools] above (#510). +# +# Why scoping rather than pinning: both were ALREADY pinned and locked. +# mise.dev.lock carried buf 1.70.0 and api-linter 2.3.1 with checksums and +# asset URLs before this change, and the failure happened anyway. Verified on +# mise 2026.5.8: a lockfile entry does NOT suppress api-linter's SLSA +# provenance call. The download and checksum steps both succeed; only the +# provenance lookup against api.github.com 403s, and MISE_SLSA_VERIFY=0 does +# not disable it. mise had even recorded github_attestations = "unavailable" +# in the lock and still attempted verification. +# +# So what scoping buys is blast radius, and that is the actual bug in #510: +# 1. A proto-only linter should not sit in the critical path of a Dart test +# run. As env-level tools they were provisioned for every `dev`-env task, +# so a failure to fetch them aborted `format`, `analyze`, `test` and +# `check`. On Claude Code Web `.miserc.toml` auto-selects the `dev` env, +# so this hit plain `./bin/mise run check`. +# 2. Task-scoped tools degrade to a warning when provisioning fails, where an +# [tools] entry is a hard error that aborts before any task runs. The +# failure is contained, NOT resolved: the proto:* task itself still runs +# its body and will then fail on `buf: command not found` unless the +# binary is already on PATH. The point is that `check` and `test` are no +# longer collateral damage — not that proto linting works without buf. +# +# KNOWN TRADE-OFF: task-scoped tools are not part of a config's [tools] set, +# so `mise lock` drops them from mise.dev.lock. These two therefore lose +# lockfile coverage and re-resolve on a fresh worktree. Moving them to their +# own env file (MISE_ENV=dev,proto) would keep both properties — off the dev +# critical path AND locked — at the cost of changing the proto:* invocation. +# Not done here; see #510 discussion. +# # Move both to base mise.toml when the API stabilises and proto tasks enter CI. -# Move it to base mise.toml once the resource shapes and method signatures -# have stabilised and the linter output is expected to stay clean in CI. -"github:googleapis/api-linter" = "latest" [tasks."proto:lint"] description = "Lint the protobuf API contract (buf STANDARD ruleset)" dir = "proto" +tools = { buf = "1.70.0" } run = "buf lint" [tasks."proto:format"] description = "Format protobuf files in place" dir = "proto" +tools = { buf = "1.70.0" } run = "buf format -w" [tasks."proto:dep-update"] description = "Refresh buf.lock from BSR dependencies (googleapis)" dir = "proto" +tools = { buf = "1.70.0" } run = "buf dep update" [tasks."proto:generate"] description = "Generate OpenAPI spec from the proto contract via BSR remote plugin" dir = "proto" +tools = { buf = "1.70.0" } run = "buf generate" [tasks."proto:clients"] @@ -81,6 +112,7 @@ dart run build_runner build [tasks."proto:api-lint"] description = "Lint proto files against Google AIP design guidelines (googleapis/api-linter)" dir = "proto" +tools = { buf = "1.70.0", "github:googleapis/api-linter" = "2.3.1" } run = """ buf build -o /tmp/cambeerfestival.pb api-linter \ diff --git a/mise.toml b/mise.toml index fcffefea..f96e8b69 100644 --- a/mise.toml +++ b/mise.toml @@ -12,6 +12,20 @@ [settings] experimental = true +# Maintain the lockfiles on install. They record resolved versions, checksums +# and asset URLs, so a provisioned tool does not re-resolve against the +# upstream release API on every install — which is what keeps fresh clones and +# worktrees off the GitHub API and out of its rate limits. +# +# mise keeps one lockfile per config file: mise.toml -> mise.lock, +# mise.dev.toml -> mise.dev.lock. Both are committed. +# +# This setting was previously unset, so mise never updated them automatically +# and they only changed when regenerated by hand (#454). Refresh with: +# ./bin/mise lock # base tools +# MISE_ENV=dev ./bin/mise lock # dev tools too +lockfile = true + [env] _.path = ["./bin"]