diff --git a/.agents/external-skills.json b/.agents/external-skills.json index da295405..b3f357de 100644 --- a/.agents/external-skills.json +++ b/.agents/external-skills.json @@ -18,5 +18,10 @@ "repo": "twostraws/SwiftData-Agent-Skill", "path": "swiftdata-pro", "ref": "922d989473a9914210b41529a1ac5636aff4b8c1" + }, + "simple-english": { + "repo": "AminBlg/SimpleEnglish", + "path": "skills/simple-english", + "ref": "379728b51981b6d2ee1de0f201164483a9648972" } } diff --git a/.agents/skills/.gitignore b/.agents/skills/.gitignore index acd934d2..5d74462c 100644 --- a/.agents/skills/.gitignore +++ b/.agents/skills/.gitignore @@ -1,4 +1,5 @@ # External skills — fetched via ./sync-agents --install +/simple-english/ /swift-concurrency-pro/ /swift-testing-pro/ /swiftdata-pro/ diff --git a/.agents/skills/github-workflow/SKILL.md b/.agents/skills/github-workflow/SKILL.md index 97cf8144..f37f4bca 100644 --- a/.agents/skills/github-workflow/SKILL.md +++ b/.agents/skills/github-workflow/SKILL.md @@ -1,10 +1,10 @@ --- name: github-workflow -description: Opens and maintains pull requests, handles review feedback, checks CI, and posts as the user via gh. Use when committing for push, opening or updating a PR, responding to review comments, or diagnosing CI failures. +description: Open and maintain pull requests. Handle review feedback. Check CI. Post as the user via gh. Use when you commit for push, open or update a PR, respond to review comments, or diagnose CI failures. --- -GitHub workflow for this repo. Read root [`AGENTS.md`](../../../AGENTS.md) first for -always-on commit and test invariants — this skill assumes those. +GitHub workflow for this repo. Read root [`AGENTS.md`](../../../AGENTS.md) first +for always-on commit and test invariants. This skill assumes those rules. ## Prerequisites @@ -12,75 +12,75 @@ always-on commit and test invariants — this skill assumes those. review comments. - **`./swiftformat --lint` and `./test` are part of "done".** Never push a red tree. -- **Never commit on `main`.** Branch first and keep every commit for one piece - of work on that one branch. +- **Never commit on `main`.** Branch first. Keep every commit for one piece of + work on that one branch. ## Branch and push -- **Multi-step work lands one commit per step**, so history stays bisectable and - can land piecewise — including pure-groundwork steps, which say so in the body. -- **Commit when asked, or when working through a plan.** If it's unclear whether - a commit is wanted, make the change and ask rather than committing silently. +- **Multi-step work lands one commit per step.** History stays bisectable and + can land piecewise. Pure-groundwork steps say so in the body. +- **Commit when asked, or when working through a plan.** If it is unclear + whether a commit is wanted, make the change and ask. Do not commit silently. - Push each commit as it lands once a PR is open. -- **When working through a plan, open a PR once the plan is complete** — push - the branch and open it ready-for-review rather than leaving finished work +- **When working through a plan, open a PR once the plan is complete.** Push + the branch and open it ready-for-review. Do not leave finished work local-only. ## Opening a PR - **Open PRs ready-for-review, not draft.** -- Check for a PR template (`.github/PULL_REQUEST_TEMPLATE.md` or similar) and - use it for the body. +- Check for a PR template (`.github/PULL_REQUEST_TEMPLATE.md` or similar). Use + it for the body. - Describe the **end state**, not a changelog of the conversation. -- **Explain what the diff doesn't show** — motivation, trade-offs, or follow-ups - that aren't obvious from the code alone. -- **Flag lines that warrant extra scrutiny** — leave a PR review comment on - anything a reviewer should look at closely (subtle behavior changes, +- **Explain what the diff does not show** — motivation, trade-offs, or + follow-ups that are not obvious from the code alone. +- **Flag lines that warrant extra scrutiny.** Leave a PR review comment on + anything a reviewer must look at closely (subtle behavior changes, incomplete migrations, assumptions about `main`). ## Keeping a PR current - Push each commit as it lands. -- Refresh the title/body once the branch outgrows them — fold into any human - edits rather than overwriting them. +- Refresh the title/body once the branch outgrows them. Fold into any human + edits. Do not overwrite them. ## Merging main and other branches -When bringing `main` or another branch into yours — because CI failed, before +When you bring `main` or another branch into yours — because CI failed, before a long review, or to pick up a dependency: -- **Resolve git-reported conflicts** — the `<<<<` / `>>>>` markers; don't leave +- **Resolve git-reported conflicts** — the `<<<<` / `>>>>` markers. Do not leave conflict markers or half-resolved hunks. -- **Check for logical conflicts too** — changes on both sides can compose cleanly +- **Check for logical conflicts too.** Changes on both sides can compose cleanly in git but still clash in behavior: a renamed symbol your branch still - references, a relocated test helper, an updated signature your call sites don't - match, a new invariant your code violates, duplicate registrations. Re-read - the merged result and run `./test` (at least the affected tier) after merging - — a clean merge is not proof the branch still makes sense. -- **CI merges `main` into the branch before it runs**, so green-locally / - red-on-CI usually means `main` moved rather than that you broke something. - Merge the latest `main` in locally and rebuild before digging further. + references, a relocated test helper, an updated signature your call sites do + not match, a new invariant your code violates, duplicate registrations. + Re-read the merged result. Run `./test` (at least the affected tier) after + merging. A clean merge is not proof the branch still makes sense. +- **CI merges `main` into the branch before it runs.** Green locally and red on + CI usually means `main` moved. It does not mean you broke something. Merge + the latest `main` in locally and rebuild before you dig further. ## Review comments -- **Don't act on review comments the user hasn't pointed you at.** Summarize - what's there and ask which to take on; reading them to write that summary is +- **Do not act on review comments the user has not pointed you at.** Summarize + what is there and ask which to take on. Reading them to write that summary is expected. -- **One commit per review issue** — each distinct piece of feedback gets its - own commit, unless several items fit together logically or address similar - issues (then one commit for the group is fine). Either way, fixes stay - bisectable and the reply can name the commit that resolved it. -- When a commit resolves one, reply to it naming the commit. -- Anything deliberately not addressed gets filed in the area's - [`TODOs.md`](../../../TODOs.md) — never dropped. +- **One commit per review issue.** Each distinct piece of feedback gets its own + commit, unless several items fit together logically or address similar issues + (then one commit for the group is fine). Either way, fixes stay bisectable + and the reply can name the commit that resolved it. +- When a commit resolves one, reply to it and name the commit. +- If you deliberately do not address something, file it in the area's + [`TODOs.md`](../../../TODOs.md). Never drop it. ## CI -- **Don't block the conversation polling CI.** Report what's running and hand - the turn back; delegate a genuine watch to a background subagent. +- **Do not block the conversation polling CI.** Report what is running and hand + the turn back. Delegate a genuine watch to a background subagent. ## Posting under the user's identity Anything posted as the user — PR replies, issue comments, review responses — opens with a line marking it AI-generated, e.g. `> _Posted by an AI agent on -$USER's behalf._`. No exception for short or purely factual comments. +$USER's behalf._` No exception for short or purely factual comments. diff --git a/.agents/skills/running-tests/SKILL.md b/.agents/skills/running-tests/SKILL.md index 3d36366e..a91a1384 100644 --- a/.agents/skills/running-tests/SKILL.md +++ b/.agents/skills/running-tests/SKILL.md @@ -1,25 +1,32 @@ --- name: running-tests -description: Runs the test suite via ./test, picks the right tier, and manages the per-checkout simulator. Use when running tests, choosing a test scope, debugging simulator launch failures, or reviewing snapshot diffs. +description: Run tests with ./test. Pick a tier. Manage the per-checkout simulator. Use when you run tests, pick scope, debug simulator failures, or review snapshot diffs. --- How to run tests in this repo. Read root [`AGENTS.md`](../../../AGENTS.md) for -always-on rules: **use [`./test`](../../../test)** — never hand-roll `tuist test` -or `xcodebuild`; **`./swiftformat --lint` and `./test` are part of "done".** -Canonical flag list: `./test --help`. Rationale for `./test` over alternatives: -header comment in [`test`](../../../test). +always-on rules. + +**Use [`./test`](../../../test).** Do not hand-roll `tuist test` or +`xcodebuild`. + +**`./swiftformat --lint` and `./test` are part of "done".** + +Read `./test --help` for the canonical flag list. + +Read the header comment in [`test`](../../../test) for why `./test` replaces +alternatives. ## Pick a tier -Pick the **narrowest tier that covers the change**: +Pick the narrowest tier that covers the change. | Tier | Command | When | |------|---------|------| | Affected | `./test` | Default — bundles touched by your diff against `origin/main` | | One bundle | `./test WhereCoreTests` | You know exactly what you touched | -| Unit suite | `./test --all` | Change spans modules; before a wide commit | +| Unit suite | `./test --all` | Change spans modules. Run before a wide commit. | | Image suite | `./test --snapshots` | Triggers below | -| Everything | `./test --everything` | Full revalidation; what CI runs | +| Everything | `./test --everything` | Full revalidation. This is what CI runs. | Examples: @@ -27,7 +34,7 @@ Examples: - Edited `WhereCore` + `WhereUI` → `./test` or `./test --all` before committing - Changed a stylesheet token that renders → `./test --snapshots` (or `./test` if the graph already pulls snapshots in) -Compare against a ref other than `origin/main`: `./test --base REF`. +If you compare against a ref other than `origin/main`, run `./test --base REF`. ## Snapshots @@ -35,16 +42,16 @@ Compare against a ref other than `origin/main`: `./test --base REF`. change touches a **view or its appearance**, a **stylesheet token**, a **string that renders**, **`SnapshotKit` / `SnapshotKitTesting`**, or a **reference image**. `./test` with no arguments already includes image bundles when the -dependency graph says they're affected. +dependency graph says they are affected. - **`--review`** — how each differing reference differs (pixel count, max delta, - changed region); use to tell a broken render from antialiasing drift + changed region). Use this to tell a broken render from antialiasing drift. - **`--timings`** — where capture time went per phase - **`--record MODE`** — re-record references: `all`, `failed`, `missing`, or - `never` (default). Fix the view first; re-record only when the render is - correct + `never` (default). Fix the view first. Re-record only when the render is + correct. -Don't parallelize the image suite — see +Do not parallelize the image suite. See [`Shared/SnapshotKitTesting/AGENTS.md`](../../../Shared/SnapshotKitTesting/AGENTS.md). ## Iterate faster @@ -57,30 +64,33 @@ After a green build: ``` `--only` takes a full xcodebuild test identifier — bundle, suite, or -`Bundle/Suite/testName()`. Repeatable for several tests. +`Bundle/Suite/testName()`. Repeat this for several tests. ## When tests fail -- Swift Testing's headline is often contentless ("Issue recorded"); read the +- Swift Testing's headline is often contentless ("Issue recorded"). Read the **`↳` block** below it for the real reason, path, and snapshot paths. -- Snapshot mismatch → `./test --snapshots --review` on the failing reference. -- Green locally / red on CI → merge latest `main` and re-run before debugging - (see [`github-workflow`](../github-workflow/SKILL.md)). +- If a snapshot mismatches, run `./test --snapshots --review` on the failing + reference. +- If tests are green locally and red on CI, merge latest `main` and re-run + before you debug (see [`github-workflow`](../github-workflow/SKILL.md)). ## Simulator -`./test` resolves a UDID via [`./simulator`](../../../simulator) — don't pass a -device *name* to `simctl` or hand-roll a `-destination`. +`./test` resolves a UDID via [`./simulator`](../../../simulator). Do not pass a +device *name* to `simctl`. Do not hand-roll a `-destination`. -- **First `./simulator` run in a checkout** creates and boots a device — budget - a couple of minutes for the first boot. +- **The first `./simulator` run in a checkout** creates and boots a device. + Budget a couple of minutes for the first boot. - **Launch failures that look like test failures** (suites that do run are green): - `Application failed preflight checks (Busy)` - `Mach error -308 — server died` / `crashed with signal kill before establishing connection` - → wedged or contended device → `./simulator --recreate`, then re-run `./test`. -- Deeper ops (`--list`, `--prune`, `--device` / `--os`): `./simulator --help`. + → If the device is wedged or contended, run `./simulator --recreate`. Then + re-run `./test`. +- For deeper ops (`--list`, `--prune`, `--device` / `--os`), read + `./simulator --help`. Raw one-off `xcodebuild` (rare): @@ -91,8 +101,8 @@ Raw one-off `xcodebuild` (rare): ## Environment - **macOS + Xcode required** for `./test`. -- **Linux cloud agents** — `./swiftformat --lint` and `./sync-agents` only; no - simulator or test runs. Full validation matches CI on macOS. +- **Linux cloud agents** — run `./swiftformat --lint` and `./sync-agents` only. + You cannot run the simulator or tests. Full validation matches CI on macOS. ## Full macOS validation (matches CI) diff --git a/.agents/skills/todo-triage/SKILL.md b/.agents/skills/todo-triage/SKILL.md index 4ae44e12..e9bbbe1c 100644 --- a/.agents/skills/todo-triage/SKILL.md +++ b/.agents/skills/todo-triage/SKILL.md @@ -1,28 +1,28 @@ --- name: todo-triage -description: Runs this repo's backlog pipeline — drain INBOX.md into the right TODOs.md, verify and expand entries against current source, archive completed items, and refresh MODULE_AUDIT.md as a derived report. Use when triaging the inbox, filing findings, running the weekly module audit, or asked to tidy the TODOs. +description: Run this repo's backlog pipeline. Drain INBOX.md into the right TODOs.md. Verify and expand entries against current source. Archive completed items. Refresh MODULE_AUDIT.md as a derived report. Use when you triage the inbox, file findings, run the weekly module audit, or tidy the TODOs. --- The backlog lives in `TODOs.md` files, one per area. This skill is the procedure for putting things into them and keeping them honest. -Read the root [`TODOs.md`](../../../TODOs.md) first — it owns the item format and -the placement rule, and this skill assumes both. It is the contract; this file is -only the process. +Read the root [`TODOs.md`](../../../TODOs.md) first. It owns the item format +and the placement rule. This skill assumes both. It is the contract. This file +is only the process. **Never invent structure.** If a job here seems to need a new field, section, or -file, change the root `TODOs.md` and say so — don't improvise it into one area's -file. +file, change the root `TODOs.md` and say so. Do not improvise it into one +area's file. ## The weekly pass -The weekly automation runs the whole job in one go. The order matters: the -backlog is the source of truth, and the report is written from it, so the report -comes last. +The weekly automation runs the whole job in one go. The order matters. The +backlog is the source of truth. The report is written from it. The report comes +last. 1. **Drain `INBOX.md`** — see below. -2. **Re-verify the open backlog**, area file by area file. Close what shipped, - correct line numbers that have moved, drop claims that are no longer true. +2. **Re-verify the open backlog**, area file by area file. Close what shipped. + Correct line numbers that have moved. Drop claims that are no longer true. This is the bulk of the work. 3. **Review the week's new surface** — the commits and merged PRs landed since the last audit's header date — and file what you find, tagged @@ -30,10 +30,11 @@ comes last. 4. **Rewrite `MODULE_AUDIT.md`** from what the backlog now says — see below. 5. **Update the docs the week invalidated**: a module's `README.md` / `AGENTS.md` when its architecture, public API, or a documented behavior - changed; the root `AGENTS.md` when a global rule, a target, or the build/test - flow did. Run `./sync-agents` afterwards if any `AGENTS.md` changed. -6. **Open a PR** ready-for-review — follow the - [`github-workflow`](../github-workflow/SKILL.md) skill, describing the end + changed. Update the root `AGENTS.md` when a global rule, a target, or the + build/test flow did. If any `AGENTS.md` changed, run `./sync-agents` + afterwards. +6. **Open a PR** ready-for-review. Follow the + [`github-workflow`](../github-workflow/SKILL.md) skill. Describe the end state: what moved in the backlog, what the audit now says, and what you verified rather than assumed. @@ -45,69 +46,69 @@ just the relevant section below, not the whole pass. `INBOX.md` holds raw human notes: terse, uncited, unverified, sometimes already fixed. Take each entry under `# Open` in turn. -1. **Understand what's being claimed.** An entry like "Raw data browser (similar - to SD browser)" is a feature ask; "why do we delete all the DB entries on +1. **Understand what is being claimed.** An entry like "Raw data browser (similar + to SD browser)" is a feature ask. "why do we delete all the DB entries on logout?" is a question that may or may not hide a bug. Resolve which before - going further. + you go further. 2. **Verify it against current source.** Find the code. Confirm the behavior is - really what the note says, and that it hasn't already been fixed or already - been filed. This is the step that earns the promotion — an entry that reaches - a `TODOs.md` unverified is worse than one still sitting in the inbox, because - it now reads as established. + really what the note says. Confirm it has not already been fixed or already + been filed. This is the step that earns the promotion. An entry that reaches + a `TODOs.md` unverified is worse than one still sitting in the inbox. It now + reads as established. 3. **Expand it.** Write the body the root format asks for: the `File.swift:123` sites, why it matters (user-visible consequence, not just tidiness), and a - concrete suggested fix. Preserve the human's intent — if the note asks a - question you now know the answer to, answer it in the body rather than - restating the question. + concrete suggested fix. Preserve the human's intent. If the note asks a + question you now know the answer to, answer it in the body. Do not restate + the question. 4. **Route it** by the placement rule: the lowest `TODOs.md` spanning every area - it touches. Create that area's file if it doesn't exist yet (copy the header - shape from a sibling; link to the root format, don't restate it). + it touches. If that area's file does not exist yet, create it. Copy the + header shape from a sibling. Link to the root format. Do not restate it. 5. **Bucket and tag it.** `PX`/`P0`/`P1`/`P2`, plus `quick-win` or - `needs-design`. Tag the origin `(human )` — keep - the human's date, not today's; the point is to show where the item came from. + `needs-design`. Tag the origin `(human )`. Keep + the human's date, not today's. The point is to show where the item came from. A **new** item takes the bucket its severity implies (high → `P0`, medium → - `P1`, low → `P2`); an item **already in the file keeps the bucket it has**. - Priority is a decision someone made, and a severity opinion from a later pass - doesn't get to silently overrule it — argue for the move in the body instead. -6. **Remove it from `INBOX.md`.** The origin tag is the trail; don't leave a + `P1`, low → `P2`). An item **already in the file keeps the bucket it has**. + Priority is a decision someone made. A severity opinion from a later pass + does not get to silently overrule it. Argue for the move in the body instead. +6. **Remove it from `INBOX.md`.** The origin tag is the trail. Do not leave a copy behind. -An entry you don't file goes under `# Triaged` with a one-line verdict — +If you do not file an entry, put it under `# Triaged` with a one-line verdict — "already fixed by `abc1234`", "already filed as the `ReportLoadGate` P1 in `Where/TODOs.md`", "declined: the store is intentionally reset on logout". Never delete one silently. If a note is too vague to verify, leave it in `# Open` and -say what you'd need to know; guessing at intent is worse than waiting. +say what you would need to know. Guessing at intent is worse than waiting. Agents **never add** to `INBOX.md`. Work you find yourself goes straight into the right `TODOs.md`, fully formed. ## Filing a finding you found yourself -Same expansion and routing, tagged with where it came from — `(audit +Use the same expansion and routing. Tag with where it came from — `(audit 2026-07-26)`, `(pr#107 review)`. Before filing, search every `TODOs.md` for the -symbol or file involved: the most common defect in this backlog is the same issue +symbol or file involved. The most common defect in this backlog is the same issue filed twice in two files with different wording. If it exists, sharpen the existing entry instead of adding a second one. -Deferred PR feedback is filed the same way, and the reply to that comment links -to where it landed. +File deferred PR feedback the same way. The reply to that comment links to where +it landed. ## Closing an item Move it to `# Completed issues` in the same file with a note on how it closed — the PR or commit, and what actually shipped if it differs from what the item -proposed. Never delete it. A completed item whose fix was partial stays open with -the remainder described, rather than being closed optimistically. +proposed. Never delete it. If a completed item's fix was partial, keep it open +with the remainder described. Do not close it optimistically. ## Refreshing MODULE_AUDIT.md `MODULE_AUDIT.md` is **derived and carries no actionable items**. Every finding -belongs in a `TODOs.md`; the audit reports on shape and drift. Regenerating it: +belongs in a `TODOs.md`. The audit reports on shape and drift. To regenerate it: 1. **Re-verify the open backlog** against current source, area file by area file. - Close what has shipped, correct line numbers that have moved, and delete - claims that are no longer true. This is the bulk of the work, and it happens - in the `TODOs.md` files, not in the audit. + Close what has shipped. Correct line numbers that have moved. Delete claims + that are no longer true. This is the bulk of the work. It happens in the + `TODOs.md` files, not in the audit. 2. **File the new findings** from this pass, per the section above. 3. **Then write the report**, from what the backlog now says: - the source/test file inventory per module, and whether each has its @@ -126,7 +127,7 @@ content belongs in a `TODOs.md`. ## Environment -The weekly automation runs on Linux, where Tuist and the simulator are -unavailable, so the audit pass is static analysis only — say so in its -Limitations section rather than implying the suite was run. `./swiftformat ---lint` and `./sync-agents` do work there. +The weekly automation runs on Linux. Tuist and the simulator are unavailable. +The audit pass is static analysis only. Say so in its Limitations section. Do +not imply the suite was run. `./swiftformat --lint` and `./sync-agents` do work +there. diff --git a/AGENTS.md b/AGENTS.md index fa5c4f8e..bf3b05fc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,10 +1,6 @@ # Stuff – Repository Shape -This file is the repo-wide contract: the build system, the conventions all -Swift here follows, and how to work (branches, commits, PRs, CI). **Every -module also carries its own `AGENTS.md`** covering its scope, layering, and -invariants. Read this file first, then the module's — they deliberately don't -repeat each other, so neither is sufficient alone. +This file is the repo-wide contract. It covers the build system, Swift conventions, and how to work with branches, commits, PRs, and CI. **Every module also carries its own `AGENTS.md`**. That file covers scope, layering, and invariants. Read this file first. Then read the module's file. The two files do not repeat each other. Neither file is sufficient alone. Roughly, this file covers: @@ -32,36 +28,32 @@ Roughly, this file covers: | Bumper Bowling | `Package.swift` / `Package.resolved` | -Read the exact pinned versions out of those files rather than trusting a copy -in prose — a version transcribed into a doc goes stale silently. +Read the exact pinned versions from those files. Do not trust a copy in prose. A version in a doc goes stale without notice. Library targets live in the root [`Package.swift`](Package.swift) (one local -package); apps, app extensions, and test bundles are Tuist targets in -[`Project.swift`](Project.swift) (plus [`Tuist.swift`](Tuist.swift)), which +package). Apps, app extensions, and test bundles are Tuist targets in +[`Project.swift`](Project.swift) (plus [`Tuist.swift`](Tuist.swift)). That file references the package via `Package.local(path: .relativeToRoot("."))`. The -two manifests are the authoritative target catalog — it is deliberately not -duplicated here. +two manifests are the authoritative target catalog. This file does not duplicate that catalog. -`./ide` regenerates the Xcode project *and* does the surrounding setup — -external agent skills, `core.hooksPath` — so it's the way to regenerate, not -`tuist generate` alone. Agents must always pass `--no-open` (see [Generating the -Xcode project](#generating-the-xcode-project)). A fresh machine needs `./ide ---bootstrap` first, which installs `mise` and the pinned tools before -generating; plain `./ide` fails fast pointing at it. +`./ide` regenerates the Xcode project and does the surrounding setup. That setup includes external agent skills and `core.hooksPath`. Use `./ide` to regenerate. Do not use `tuist generate` alone. Agents must always pass `--no-open` (see [Generating the +Xcode project](#generating-the-xcode-project)). On a fresh machine, run `./ide +--bootstrap` first. That command installs `mise` and the pinned tools before +generating. Plain `./ide` fails fast and points at bootstrap. -The executables in the repo root are the dev scripts — `ide`, `test`, +The executables in the repo root are the dev scripts. They are `ide`, `test`, `swiftformat`, `sync-agents`, `profile`, `icons`, `flaky`, `simulator`, -`xcstrings`, `attribution` — and each takes `--help`. Reach for one rather than -hand-rolling its job: `test` is the only way tests should be run (see [Running -tests](#running-tests)), and `icons`, `attribution`, and `simulator` in particular own state that is -easy to corrupt by hand — `./simulator` owns a per-checkout device (see the +`xcstrings`, and `attribution`. Each takes `--help`. Use one of these scripts instead of +hand-rolling its job. `test` is the only way to run tests (see [Running +tests](#running-tests)). `icons`, `attribution`, and `simulator` own state that is +easy to corrupt by hand. `./simulator` owns a per-checkout device (see the [`running-tests`](../.agents/skills/running-tests/SKILL.md) skill). ### Managing app icons `./icons` is the single command for the Where app's alternate icons (see `./icons --help`). It keeps both asset catalogs and the picker's -`AppIcons.json` manifest in sync — never hand-edit those or add icon Swift. +`AppIcons.json` manifest in sync. Never hand-edit those files. Never add icon Swift. Run `./ide --no-open` after adding one. ### Version and build metadata @@ -69,49 +61,47 @@ Run `./ide --no-open` after adding one. Bump the Where app's `CFBundleShortVersionString` / `CFBundleVersion` explicitly in [`Project.swift`](Project.swift) (Settings > About shows them). How the app was built is stamped by a post-build script -([`Where/Where/Scripts/stamp-build-info.sh`](Where/Where/Scripts/stamp-build-info.sh)): -the commit into `WhereGitSHA` / `WhereGitStatus`, and how the Swift compiler +([`Where/Where/Scripts/stamp-build-info.sh`](Where/Where/Scripts/stamp-build-info.sh)). +The script writes the commit into `WhereGitSHA` / `WhereGitStatus`. It writes how the Swift compiler was invoked into `WhereConfiguration` / `WhereSwiftOptimizationLevel` / -`WhereSwiftCompilationMode`. All of it is read back by `WhereCore.BuildInfo`, -for Settings > About and for the attributes on every Periscope logging session -(the optimization level is what says whether a recorded span duration means -anything). Only the app is stamped. Tripwires: it must stay a **post** script -(before signing seals the bundle), keep `basedOnDependencyAnalysis: false` (or -an unchanged tree ships the previous commit's SHA), needs -`ENABLE_USER_SCRIPT_SANDBOXING` unset (it reads `.git`), and every key it -writes must fall back to `unknown` rather than let `set -u` abort the build -over a build setting Xcode didn't export. +`WhereSwiftCompilationMode`. All of it is read back by `WhereCore.BuildInfo`. +Settings > About uses it. Every Periscope logging session uses it for attributes. +The optimization level tells you if a recorded span duration means +anything. Only the app is stamped. Tripwires: it must stay a **post** script +(before signing seals the bundle). Keep `basedOnDependencyAnalysis: false`. If you do not, an unchanged tree ships the previous commit's SHA. Set +`ENABLE_USER_SCRIPT_SANDBOXING` unset (it reads `.git`). Every key it +writes must fall back to `unknown`. Do not let `set -u` abort the build +over a build setting Xcode did not export. ## Formatting - **SwiftFormat** uses [`.swiftformat`](.swiftformat). Run `./swiftformat` to - format the tree, or `./swiftformat --lint` to check only (as in CI). + format the tree. Run `./swiftformat --lint` to make sure that formatting is correct (as in CI). - The pre-commit hook (enabled by `./ide` via `core.hooksPath`) formats staged `*.swift` files in place and re-stages them. -- **String Catalogs are stored exactly as Xcode serializes them**, and - `./xcstrings` (`--lint` in CI) enforces it. A catalog written by anything - else parses fine but turns the next IDE build into thousands of lines of - whitespace churn — write catalogs through Xcode or normalize with the +- **String Catalogs are stored exactly as Xcode serializes them**. `./xcstrings` (`--lint` in CI) enforces this. A catalog written by anything + else parses fine. The next IDE build then produces thousands of lines of + whitespace churn. Write catalogs through Xcode. Or normalize with the script afterwards (it touches formatting only, never content). ## Attribution -An app ships an **attribution report** — every third-party work it is built -with, license notices inline. **Re-run `./attribution` and commit the result -whenever you add or bump a package or an agent skill**; `./attribution ---check` fails CI if you forget (offline, sub-second — an app's own tests -can't do this job, since a test bundle can't read `Package.swift`). +An app ships an **attribution report**. It lists every third-party work it is built +with, with license notices inline. **Re-run `./attribution` and commit the result +whenever you add or bump a package or an agent skill**. `./attribution +--check` fails CI if you forget (offline, sub-second). An app's own tests +cannot do this job. A test bundle cannot read `Package.swift`. - [`Shared/CreditKit`](Shared/CreditKit/AGENTS.md) owns the types and the - reporting tool and holds **no credits of its own**; each app declares its + reporting tool and holds **no credits of its own**. Each app declares its sources in an `attribution-sources.json` and ships the report in its own resources (for Where, `Where/Where/Resources/attribution.json`). - The report derives from `.product(name:package:)` links (pinned by - `Package.resolved`) and `.agents/external-skills.json`, notices read at the - pinned revision — so tooling-only packages correctly aren't credited. -- **Kind is derived, not declared**: anything reachable from `shippedFrom`'s - target closure is a library, any other linked package a development tool — - linking is not shipping, and a UI must keep the two apart. + `Package.resolved`) and `.agents/external-skills.json`. Notices are read at the + pinned revision. Tooling-only packages are not credited. +- **Kind is derived, not declared**. Anything reachable from `shippedFrom`'s + target closure is a library. Any other linked package is a development tool. + Linking is not shipping. A UI must keep the two apart. - Data-source provenance for bundled geometry stays with its data, in [`RegionKit`](Where/RegionKit/AGENTS.md). @@ -119,9 +109,8 @@ can't do this job, since a test bundle can't read `Package.swift`). Bumper Bowling enforces the production Where module graph and selected source-level invariants. The entry point is -[`BumperBowling.swift`](BumperBowling.swift), repository-owned shapes and rules -live in [`.bumper/Sources`](.bumper/Sources), and -[`.bumper/RULES.md`](.bumper/RULES.md) is the rule catalog. +[`BumperBowling.swift`](BumperBowling.swift). Repository-owned shapes and rules +live in [`.bumper/Sources`](.bumper/Sources). [`.bumper/RULES.md`](.bumper/RULES.md) is the rule catalog. Run `swift run bumper config .`, `swift run bumper test .`, and `swift run bumper lint . --timings` after changing a Where dependency, @@ -132,33 +121,33 @@ the same change. ## Agent instructions sync `AGENTS.md` is the source of truth for AI agent instructions. Cursor reads -`AGENTS.md` natively; Claude Code uses `CLAUDE.md` and `.claude/skills/`. +`AGENTS.md` natively. Claude Code uses `CLAUDE.md` and `.claude/skills/`. Generated files (`CLAUDE.md`, `.claude/skills/`) are gitignored and produced by `./sync-agents`. - `./sync-agents` — generate `CLAUDE.md` next to each `AGENTS.md` and mirror `.agents/skills/` into `.claude/skills/`. - `./sync-agents --install` — fetch external skills listed in - `.agents/external-skills.json`. Rarely run by hand: `mise install` calls it - from a `postinstall` hook, so installing tools also installs skills, on a dev - machine and a cloud agent alike. + `.agents/external-skills.json`. Rarely run by hand. `mise install` calls it + from a `postinstall` hook. Installing tools also installs skills on a dev + machine and a cloud agent. - `./sync-agents --add [name]` — add an external skill from GitHub. - `./sync-agents --update` — re-fetch all external skills to the latest commit. -`.agents/external-skills.json` pins the **external** skills to a commit; -`.agents/skills/.gitignore` excludes those fetched copies, so anything else +`.agents/external-skills.json` pins the **external** skills to a commit. +`.agents/skills/.gitignore` excludes those fetched copies. Anything else under `.agents/skills/` is **repo-owned** and committed. External skills are -also an **attribution** input — after adding or updating one, re-run +also an **attribution** input. After adding or updating one, re-run `./attribution` (see [Attribution](#attribution)). -**`.agents/skills/` is the real home; edit the source, never the -`.claude/skills/` mirror**, and run `./sync-agents` after adding or editing a -skill (Cursor loads both directories, and the winning copy is undocumented — -don't let them drift). A fresh clone carries only the repo-owned skills; the +**`.agents/skills/` is the real home**. Edit the source. Never edit the +`.claude/skills/` mirror. Run `./sync-agents` after adding or editing a +skill. Cursor loads both directories. The winning copy is undocumented. +Do not let them drift. A fresh clone carries only the repo-owned skills. The external ones arrive with the first `mise install`. -A skill carries **procedure** — the steps of an occasional job, **including -rules that apply only while that job runs** (GitHub, running tests, backlog +A skill carries **procedure**. That is the steps of an occasional job. It includes +rules that apply only while that job runs (GitHub, running tests, backlog triage). **Always-on** rules every edit must honor stay in `AGENTS.md` or `TODOs.md`. @@ -166,91 +155,90 @@ triage). **Always-on** rules every edit must honor stay in `AGENTS.md` or - For the current list of library products, apps, extensions, and test bundles, read [`Package.swift`](Package.swift) and - [`Project.swift`](Project.swift); each module's own `README.md` / - `AGENTS.md` says what it is and how it may be used. + [`Project.swift`](Project.swift). Each module's own `README.md` / + `AGENTS.md` says what it is and how it can be used. - Add SPM library targets in `Package.swift` and wire apps/tests in `Project.swift` (see existing `unitTests` helper). A new module also ships a root `README.md` and `AGENTS.md` — see [Per-module docs](#per-module-docs). -- **CI scheme**: CI runs the explicit shared **Stuff-iOS-Tests** scheme (all test bundles) rather than the autogenerated `Stuff-Workspace` scheme. New test bundles must be added to the `Stuff-iOS-Tests` scheme in `Project.swift` or CI won't run them. -- **Image snapshots are the exception: one bundle per module, one shared scheme.** Each module owning image references has its own `*SnapshotTests` target over its `SnapshotTests/` folder, all listed in the single shared **StuffSnapshotTests** scheme and its dedicated CI `snapshot` job — slow and LFS-backed, so deliberately **out of** `Stuff-iOS-Tests`. References under any `__Snapshots__/` directory are Git LFS (`.gitattributes`; the CI job checks out with `lfs: true`). Framework halves: `Shared/SnapshotKit` (shippable matrix + previews) and `Shared/SnapshotKitTesting` (test-only pipeline, whose own regression bundle **SnapshotKitTestingTests** pixel-probes without LFS and runs in `Stuff-iOS-Tests`). -- **A new image suite gets a target, not a scheme.** Add the `*SnapshotTests` target, list only `SnapshotKitTesting` in `extraPackageProducts`, and add it to the `StuffSnapshotTests` scheme's build and test lists — never a scheme or CI job of its own. An image bundle links only what its module needs (the Periscope and Inspector suites don't build against WhereUI at all); references follow the sources automatically via `#filePath`. -- **Separate snapshot bundles are safe because each `.xctest` gets its own `StuffTestHost` process** (measured on Xcode 27 — `ProcessInfo.processIdentifier` probes; details in the snapshot-bundle comment in [`Project.swift`](Project.swift)). Each bundle statically embeds its own copy of `SnapshotKitTesting`'s capture state, and two copies in one process would corrupt each other. Tripwire: if a toolchain ever shares one host process across bundles, re-measure before adding another image bundle. +- **CI scheme**: CI runs the explicit shared **Stuff-iOS-Tests** scheme (all test bundles). It does not run the autogenerated `Stuff-Workspace` scheme. Add new test bundles to the `Stuff-iOS-Tests` scheme in `Project.swift`. If you do not, CI will not run them. +- **Image snapshots are the exception: one bundle per module, one shared scheme.** Each module owning image references has its own `*SnapshotTests` target over its `SnapshotTests/` folder. All are listed in the single shared **StuffSnapshotTests** scheme and its dedicated CI `snapshot` job. Snapshots are slow and LFS-backed. They are **out of** `Stuff-iOS-Tests`. References under any `__Snapshots__/` directory are Git LFS (`.gitattributes`. The CI job checks out with `lfs: true`). Framework halves: `Shared/SnapshotKit` (shippable matrix + previews) and `Shared/SnapshotKitTesting` (test-only pipeline, whose own regression bundle **SnapshotKitTestingTests** pixel-probes without LFS and runs in `Stuff-iOS-Tests`). +- **A new image suite gets a target, not a scheme.** Add the `*SnapshotTests` target. List only `SnapshotKitTesting` in `extraPackageProducts`. Add it to the `StuffSnapshotTests` scheme's build and test lists. Never add a scheme or CI job of its own. An image bundle links only what its module needs (the Periscope and Inspector suites don't build against WhereUI at all). References follow the sources automatically via `#filePath`. +- **Separate snapshot bundles are safe because each `.xctest` gets its own `StuffTestHost` process** (measured on Xcode 27 — `ProcessInfo.processIdentifier` probes. Details are in the snapshot-bundle comment in [`Project.swift`](Project.swift)). Each bundle statically embeds its own copy of `SnapshotKitTesting`'s capture state. Two copies in one process corrupt each other. Tripwire: if a toolchain ever shares one host process across bundles, re-measure before adding another image bundle. ### Never double-link a product WhereUI already carries A target that depends on **WhereUI** must not also list any of WhereUI's own dependencies (WhereCore, Broadway, LifecycleKit/LifecycleKitUI, Periscope, -SnapshotKit, Inspector, …) in `extraPackageProducts` — reach them +SnapshotKit, Inspector, …) in `extraPackageProducts`. Reach them transitively. A second copy splits the module's type metadata across the WhereUI -boundary and every type-keyed lookup (SwiftUI `EnvironmentKey`s, +boundary. Every type-keyed lookup (SwiftUI `EnvironmentKey`s, `UITraitBridgedEnvironmentKey` bridging such as SnapshotKit's `\.isCapturingSnapshot`, Broadway's `BTraits`/`BThemes`/`BStylesheets`) silently resolves against the wrong one. -It reproduces only in the full multi-bundle scheme (`./test --all`), never in an +It reproduces only in the full multi-bundle scheme (`./test --all`). It does not reproduce in an isolated `./test WhereUITests` run. Guard: `WhereStylesheetTests.resolvesTraitAwareTokensFromTheBroadwayRoot` fails if a duplicate copy answers. -**Nothing in this project is a dynamic framework** — the local package is -handed to Xcode's own SPM integration, which links every product statically -into each consumer, so "WhereUI carries its dependencies" means *statically -embeds them into whatever links WhereUI*, and a double-link lands two copies +**Nothing in this project is a dynamic framework**. The local package is +handed to Xcode's own SPM integration. That integration links every product statically +into each consumer. "WhereUI carries its dependencies" means it statically +embeds them into whatever links WhereUI. A double-link lands two copies in one image. The guard test is the authority on whether a given duplication -is harmful — measured symbol-coalescing detail and the correction history: +is harmful. See measured symbol-coalescing detail and the correction history in PR #145. ## Deployment Platforms and minimum OS live in [`Project.swift`](Project.swift). To get the app onto a connected iPhone without the Xcode UI, use -[`./Where/install`](Where/install) — macOS-only, and it needs a signing team +[`./Where/install`](Where/install). That command is macOS-only. It needs a signing team configured once via `./ide --team-id` (see [`Where/AGENTS.md`](Where/AGENTS.md#installing-to-a-device)). ## Per-module docs -Shared modules live under `Shared/`, feature modules under a top-level folder +Shared modules live under `Shared/`. Feature modules live under a top-level folder per feature (`Where/`). **Every module is a folder containing `Sources/`, -`Tests/`, `README.md`, and `AGENTS.md`** (apps additionally carry `Resources/`), -and a new module must add both docs: +`Tests/`, `README.md`, and `AGENTS.md`** (apps additionally carry `Resources/`). +A new module must add both docs: - `README.md` — the human-facing overview: what the module is, install, a quick start, the public API, how it works, and any contracts/limitations. -- `AGENTS.md` — the agent-facing module shape, kept **deliberately short**: one +- `AGENTS.md` — the agent-facing module shape, kept **short**: one paragraph on what the module is (pointing at the `README.md`), scope & - dependency rules (what it may/may not import, where it's wired), the - architecture/layering rules, any invariants an agent could not re-derive from + dependency rules (what it can and cannot import, where it's wired), the + architecture/layering rules, any invariants an agent cannot re-derive from the code (a line or two each), and a brief testing pointer. It complements - this root file (which owns build/format/global rules) and should link back to - it; it does **not** repeat global rules, catalog the module's types, or - restate behavior the source already documents — agents read code for that. + this root file (which owns build/format/global rules). It must link back to + it. It does **not** repeat global rules. It does not catalog the module's types. It does not + restate behavior the source already documents. Agents read code for that. A module group that spans several targets (`Shared/Broadway/`, -`Shared/Periscope/`) carries the same pair one level up, covering only what the -group shares — the dependency graph between its modules and the invariants no +`Shared/Periscope/`) carries the same pair one level up. It covers only what the +group shares. That is the dependency graph between its modules and the invariants no single module owns. -Keep both **current as the code changes** — treat stale docs as a bug. When you +Keep both **current as the code changes**. Treat stale docs as a bug. When you change a module's architecture, public API, conventions, or a documented -behavior, update that module's `README.md` and `AGENTS.md` in the *same* change; -if you change a global rule, a target, or the build/test flow, update this root -`AGENTS.md` too. After adding or renaming an `AGENTS.md`, run `./sync-agents` so -the generated (gitignored) `CLAUDE.md` is produced next to it. +behavior, update that module's `README.md` and `AGENTS.md` in the *same* change. +If you change a global rule, a target, or the build/test flow, update this root +`AGENTS.md` too. After adding or renaming an `AGENTS.md`, run `./sync-agents`. That produces the generated (gitignored) `CLAUDE.md` next to it. **Point at the source instead of copying it.** The lists that rot fastest are -the ones the code already owns — every style group on a stylesheet, every -collaborator on a service, a pinned tool version. Name the one or two worth -learning from and say where the live list is. An exhaustive copy reads -authoritative long after it stops being true, which is worse than no list. - -**Rules state what, not why.** A rule is an imperative sentence, at most one -clause of consequence (only when the rule would otherwise look wrong enough to -"fix"), and a pointer to the proof — the guard test, the PR number or commit +the ones the code already owns. That includes every style group on a stylesheet, every +collaborator on a service, and every pinned tool version. Name the one or two worth +learning from. Say where the live list is. An exhaustive copy reads +authoritative long after it stops being true. That is worse than no list. + +**Rules state what, not why.** A rule is an imperative sentence. Add at most one +clause of consequence. Do that only when the rule would otherwise look wrong enough to +"fix". Add a pointer to the proof. That is the guard test, the PR number or commit SHA (squash merges keep PR bodies reachable via `git log`), or a `TODOs.md` -entry. Keep, at one line each: **tripwires** (conditions that invalidate a +entry. Keep at one line each: **tripwires** (conditions that invalidate a rule — "re-measure if X"), **diagnostic signatures** (the literal error text of a failure mode), and **decision rules**. History narration, mechanism -walkthroughs, and persuasion belong in the PR that proved them — point, don't -restate. +walkthroughs, and persuasion belong in the PR that proved them. Point to them. Do not +restate them. ## Repo-level docs @@ -259,25 +247,25 @@ A few files outside the module pair carry *state* rather than rules: - **`TODOs.md`** — the durable backlog, and the **only** place an actionable item lives. One per area, at that area's root, plus the root [`TODOs.md`](TODOs.md), which additionally owns the **item format** and the - **placement rule**: an item goes in the *lowest* `TODOs.md` spanning every area - it touches, up to root. Read that file before adding an item, and have a new - area's file link to it rather than copying the header. Anything deliberately - deferred is filed rather than dropped (see the - [`github-workflow`](../.agents/skills/github-workflow/SKILL.md) skill), and a completed - item moves to "Completed issues" — never deleted. + **placement rule**. An item goes in the *lowest* `TODOs.md` spanning every area + it touches, up to root. Read that file before adding an item. Have a new + area's file link to it rather than copying the header. File anything + deferred rather than dropping it (see the + [`github-workflow`](../.agents/skills/github-workflow/SKILL.md) skill). A completed + item moves to "Completed issues". Never delete a completed item. - **`INBOX.md`** — the root drop-box for raw, unverified human notes. Agents - **read from it and promote out of it**; they never file new items there + **read from it and promote out of it**. They never file new items there (agent-found work goes straight to the right `TODOs.md`). The `todo-triage` - skill drains it, recording a verdict for anything it declines. -- **`FLAKY_TESTS.md`** — generated by `./flaky`. Never hand-edit it; re-run the + skill drains it. It records a verdict for anything it declines. +- **`FLAKY_TESTS.md`** — generated by `./flaky`. Never hand-edit it. Re-run the script. -- **`MODULE_AUDIT.md`** — a dated, **derived** snapshot across every module: - the source/test inventory, what each module verified clean, and the +- **`MODULE_AUDIT.md`** — a dated, **derived** snapshot across every module. + It lists the source/test inventory, what each module verified clean, and the cross-cutting themes behind the current backlog. It carries **no actionable - items** — those are in the `TODOs.md` files — so read it to understand shape + items**. Those are in the `TODOs.md` files. Read it to understand shape and drift, not as a work list. A weekly automation refreshes it and the - `TODOs.md` files together through the `todo-triage` skill, so it is current to - its **header date**, not to `HEAD`: anything that landed since is invisible to + `TODOs.md` files together through the `todo-triage` skill. It is current to + its **header date**, not to `HEAD`. Anything that landed since is invisible to it. Verify against current source before acting on what it says. ## Conventions @@ -289,14 +277,14 @@ scope and invariants on top rather than restating these. - **Swift Testing** (`import Testing`) for all unit tests – do not use XCTest. - **Test files are 1:1 with implementation files.** A type in `Foo.swift` is - tested in `FooTests.swift`; when a source file is split (e.g. one detector per - file), split its tests to match rather than keeping one omnibus file. Shared + tested in `FooTests.swift`. When a source file is split (e.g. one detector per + file), split its tests to match. Do not keep one omnibus file. Shared fixtures/helpers live in their own support file (e.g. - `WhereCoreTestSupport.swift`, `DataIssueDetectorTestSupport.swift`), not bundled - into a test file — so a single test clock or input builder isn't copy-pasted + `WhereCoreTestSupport.swift`, `DataIssueDetectorTestSupport.swift`). Do not bundle them + into a test file. That way a single test clock or input builder is not copy-pasted across suites. - **Wait for conditions, not timing.** Prefer polling a predicate (`waitUntil`, - `waitFor`, `waitForResolution`) over fixed run-loop counts or `sleep` — fixed + `waitFor`, `waitForResolution`) over fixed run-loop counts or `sleep`. Fixed delays flake under load. - **Test-only API is `@_spi(Testing)`, not a production parameter.** Hooks that exist for tests or previews — direct store mutation, failure injection, queue @@ -306,7 +294,7 @@ scope and invariants on top rather than restating these. size of 20) rather than hardcoding the production limit. - **Test doubles conform to the production protocol.** Model a seam as a protocol the real and fake both conform to (`LocationSource` / - `ScriptedLocationSource`) — never an enum switch inside a production type + `ScriptedLocationSource`). Never use an enum switch inside a production type that branches to fake behavior. - State machines with many branches (launch runners, lifecycle drives) benefit from **seeded fuzz/adversarial tests** that replay failures exactly. @@ -314,93 +302,93 @@ scope and invariants on top rather than restating these. ### Types, state, and API design - Prefer small named structs over tuples for any value with more than - one field or that escapes a single function — tuples are fine as - ad-hoc inline returns but should not appear in property types, + one field or that escapes a single function. Tuples are fine as + ad-hoc inline returns. They must not appear in property types, collection element types, or public API. - **Group large flat types into sub-structs and child types.** When a type grows a long flat property list (e.g. a config with a cluster of watchdog knobs) or a file accretes several behavioral areas, group related properties - into nested structs and split responsibilities into focused child types — - don't let one god-type keep growing. -- Identifiers/keys are `Hashable` — a typed enum, or a dedicated struct when - the identity has structure (Where's `StoreURL` composite keys) — or - `AnyHashable`, never raw `String`s: a typed token can't silently typo into + into nested structs and split responsibilities into focused child types. + Do not let one god-type keep growing. +- Identifiers/keys are `Hashable`. Use a typed enum, or a dedicated struct when + the identity has structure (Where's `StoreURL` composite keys), or + `AnyHashable`. Never use raw `String`s. A typed token cannot silently typo into a new, untracked id. Prefer carrying the *concrete* type where a generic - can (`LaunchPlan` is generic over its step `ID`); reach for `AnyHashable` - only where a generic can't reach (a non-generic environment value, a + can (`LaunchPlan` is generic over its step `ID`). Reach for `AnyHashable` + only where a generic cannot reach (a non-generic environment value, a heterogeneous container). Examples: `LaunchStepID`, `WherePreferences.Keys`, `StoreURL`. - **Avoid parameter defaults on Core/store APIs.** Prefer explicit call-site - arguments so new behavior isn't silently opted into. Reserve defaults for + arguments so new behavior is not silently opted into. Reserve defaults for SwiftUI convenience inits and obvious zero values (`[]`, `.zero`) where - omission can't change semantics. Test overrides use `@_spi(Testing)` hooks or - dedicated test factories — not production parameter defaults. + omission cannot change semantics. Test overrides use `@_spi(Testing)` hooks or + dedicated test factories. Do not use production parameter defaults. - **`didSet` must skip work when the value is unchanged.** When the stored type is `Equatable`, guard `oldValue != newValue` before invalidation, - logging, or other side effects — reassigning the same value should be a no-op. -- Don't use a bare `default:` in a `switch` over an enum — enumerate every case + logging, or other side effects. Reassigning the same value must be a no-op. +- Don't use a bare `default:` in a `switch` over an enum. Enumerate every case so adding one is a compile error, not a silent fall-through. For non-frozen enums from other modules (e.g. `UNAuthorizationStatus`), handle known cases explicitly plus `@unknown default:`, which still flags newly added cases. -- **Non-obvious types get a brief doc comment** on the type — detectors, +- **Non-obvious types get a brief doc comment** on the type. Detectors, geometry/algorithm helpers, and the like state what they do and their key invariants. ### Errors and failure - **Never silently swallow errors.** Core APIs surface failure by `throw`ing - (or returning a `Result`/typed error) — never absorb it into a benign-looking + (or returning a `Result`/typed error). Never absorb it into a benign-looking default like `[]`, `nil`, or `false`. Don't discard errors with `try?` or an - empty `catch {}` that hides the failure: at minimum a `catch` must log + empty `catch {}` that hides the failure. At minimum a `catch` must log (a `warning`/`error` on the relevant `WhereLog` scope, ideally a typed - `LogEvent` carrying a `LogAttachment.error`) *and* leave observable state honest (preserve the + `LogEvent` carrying a `LogAttachment.error`). It must also leave observable state honest (preserve the last good value or move to a `failed` state — not a default that reads as success, e.g. an empty list rendering as "all clear"). Callers decide *how* to - react (rethrow, log + keep state, set a `failed` case), but the failure must + react (rethrow, log + keep state, set a `failed` case). The failure must always be observable — in logs, in state, or both. - **Distinguish user failures from programmer errors.** User/recoverable failures must throw (or surface honest UI state) and log. Impossible/misconfigured states — corrupt bundled resources, duplicate step IDs, invalid invariants — use `precondition` / `assertionFailure` in debug with a minimal safe fallback - in release; don't paper over them with silent `??` defaults that read as + in release. Do not paper over them with silent `??` defaults that read as success. "Degraded but handled" recovery belongs at `warning`, not hidden. ### Persistence and wire formats - **Prefer compiler-synthesized `Codable`.** A hand-written conformance needs a load-bearing reason, documented on the conformance itself (see - `LogJournalEntry`); a simple struct of primitives just uses the synthesized + `LogJournalEntry`). A simple struct of primitives just uses the synthesized one (see `CalendarDay`). Two reasons qualify: **(a) a single-value wire shape** — a bare id string or UUID rather than a wrapped object (`Region` - encodes as `"us-CA"`, not `{"rawValue":…}`); and **(b) a composite identity - key**, which should be a `store://` URL via Where's `WhereStoreURLCodable` - (parsed/built with `StoreURL`), never an ad-hoc joined `type:value` string. + encodes as `"us-CA"`, not `{"rawValue":…}`). And **(b) a composite identity + key**, which must be a `store://` URL via Where's `WhereStoreURLCodable` + (parsed/built with `StoreURL`). Never use an ad-hoc joined `type:value` string. - **Keep persisted formats rename-safe.** Anything persisted (journals, - backups, stored preferences) must survive Swift-side renames — synthesized + backups, stored preferences) must survive Swift-side renames. Synthesized coding of an enum with associated values freezes the *case names* into the - wire format, so renaming a case silently breaks old data. And don't hand-roll - a keyed `Codable` to paper over missing fields from an older shape; reshape + wire format. Renaming a case silently breaks old data. Do not hand-roll + a keyed `Codable` to paper over missing fields from an older shape. Reshape the data instead (see the no-in-app-migration rule in [`Where/WhereCore/AGENTS.md`](Where/WhereCore/AGENTS.md)). ### SwiftUI, UIKit, and lifetime -- Don't build closure-based `Binding(get:set:)` values in SwiftUI views; bind +- Don't build closure-based `Binding(get:set:)` values in SwiftUI views. Bind directly to observable state (`$model.foo`). For a derived binding (e.g. mapping an optional error to the `Bool` an `.alert` wants), expose a computed - `get`/`set` on the `@Observable` model and bind to that, keeping the + `get`/`set` on the `@Observable` model and bind to that. Keep the underlying value the single source of truth. - **Host child view controllers with direct frame math, not Auto Layout.** When a `UIViewController` embeds a child (e.g. a `UIHostingController` bridging SwiftUI in an app extension), add it and set `child.view.frame = view.bounds` in `viewWillLayoutSubviews()` rather than pinning four edge constraints. For a - full-bleed single child it's simpler, cheaper, and keeps the layout in one + full-bleed single child this is simpler and cheaper. It keeps the layout in one obvious place (see `WhereShareExtension`'s `ShareViewController`). -- **Observe with a target/selector, not a retained token; every `start` has +- **Observe with a target/selector, not a retained token. Every `start` has a `stop`.** Register via `addObserver(_:selector:name:object:)` with `self` - so teardown is one `removeObserver(self)`, and a restart - removes-before-re-adding (see `NotificationAmbientSource`). Never - block-based `addObserver(forName:)` — dropping its token makes the + so teardown is one `removeObserver(self)`. On restart, + remove before re-adding (see `NotificationAmbientSource`). Never use + block-based `addObserver(forName:)`. Dropping its token makes the observation unremovable and immortalizes everything the block captured. Any `start…`-style observation API gets a paired `stop()`. @@ -411,32 +399,32 @@ scope and invariants on top rather than restating these. core module (for Where: `WhereCore` collaborators on `WhereServices`). UI modules hold view models that *orchestrate* those services for SwiftUI (`WhereSession` mirrors output and exposes intent methods) and views that - *render* and *route* — not reimplement rules, cache policy, or store I/O. + *render* and *route*. They do not reimplement rules, cache policy, or store I/O. When adding behavior, default to Core (+ view-model glue if the UI needs a - trigger or observable mirror); push logic into a `View` only for presentation. + trigger or observable mirror). Push logic into a `View` only for presentation. See [`Where/AGENTS.md`](Where/AGENTS.md#layering). - **Reuse before you duplicate.** Before adding a new view / form / component (or any type), look for an existing one covering the same concept and *extend* - it — a new mode or parameter, or a shared subview — rather than forking a + it. Use a new mode or parameter, or a shared subview. Do not fork a near-copy. Two screens that differ only in a few sections (e.g. *add* vs. - *edit* of the same thing) should be **one view with a mode**, not parallel - files; shared chrome (a save-error alert, a region-toggle section, an audit + *edit* of the same thing) must be **one view with a mode**, not parallel + files. Shared chrome (a save-error alert, a region-toggle section, an audit block) becomes a shared subview, not copy-paste. If a planned addition would - substantially overlap existing UI and consolidating vs. forking isn't clearly + substantially overlap existing UI and consolidating vs. forking is not clearly right, **flag it and align before building** rather than shipping the duplicate. (This is the reflex behind `ManualDayView`'s add/edit modes and the shared `ManualEntryAuditSection`.) ### Repo hygiene -- Generated `.xcodeproj` and `Derived/` are git-ignored; never commit them. +- Generated `.xcodeproj` and `Derived/` are git-ignored. Never commit them. - Bundle IDs follow `com.stuff.`. ### Modeling state **Make invalid states unrepresentable.** When a set of values is only -meaningful in certain combinations, model it as a *single* type — usually an -`enum` with associated values — instead of parallel properties that can drift +meaningful in certain combinations, model it as a *single* type. Usually that is an +`enum` with associated values. Do not use parallel properties that can drift into nonsensical combinations. Separate stored properties are the exception to justify, not the reflex. @@ -446,15 +434,15 @@ Worked examples, smallest to largest: `isLoading` + `error` + `data`, and `CalendarContentView`'s single `Result<[CalendarMonth], Error>?` — success and failure can't both be set, and "not loaded yet" is the `nil`. -- **LifecycleKit's typed `LaunchPlan`** applies it to *wiring*: steps are - types whose `Input`/`Output` must chain through the plan's combinators, so - a mis-ordered launch or a consumer without its producer is a compile error - — and value-producing steps can't be skipped, so a hole in the data flow - can't be spelled either (PR #116). -- **`WhereScope`** applies it to *ownership*: the logged-in world is one - value — the open store's services, the preferences driving it, and the log - store they record into, created whole and never reconfigured — so a - logged-in surface can't read one world's store against another world's +- **LifecycleKit's typed `LaunchPlan`** applies it to *wiring*. Steps are + types whose `Input`/`Output` must chain through the plan's combinators. A + mis-ordered launch or a consumer without its producer is a compile error. + Value-producing steps cannot be skipped. A hole in the data flow + cannot be spelled either (PR #116). +- **`WhereScope`** applies it to *ownership*. The logged-in world is one + value. That is the open store's services, the preferences driving it, and the log + store they record into, created whole and never reconfigured. A + logged-in surface cannot read one world's store against another world's preferences (PR #150). Smells that signal a missing type: @@ -473,55 +461,55 @@ Smells that signal a missing type: ### Composition: create once, inject down -**A shared resource is created exactly once, at the composition root, and -reaches every consumer by injection** — init parameters, explicit arguments, -or a composition hook — never by re-resolving a global. Template: the Where +**A shared resource is created exactly once, at the composition root.** It +reaches every consumer by injection. Use init parameters, explicit arguments, +or a composition hook. Never re-resolve a global. Template: the Where app's SwiftData store (the launch's `resolve-scope` step is the process's only -open; the resulting `WhereScope` carries it; the App Intents stack derives from +open. The resulting `WhereScope` carries it. The App Intents stack derives from it via the `onServicesReady` hook). Two subsystems independently "opening the same store" once raced a fresh install into a launch failure. **Create it when it's needed, not before.** That step runs *behind* the -onboarding gate, so an install whose user never onboards opens nothing, and a +onboarding gate. An install whose user never onboards opens nothing. A second world (demo mode) is another scope rather than a flag threaded through the first. See [`Where/AGENTS.md`](Where/AGENTS.md#scopes-and-the-launch). - **An alternate boot stack is a runtime implementation, not a mode switch.** Select one class-bound application runtime at process initialization and - forward lifecycle/root calls through it; never thread a launch-mode enum or + forward lifecycle/root calls through it. Never thread a launch-mode enum or repeated `if` checks through app code. Where's DEBUG Inspector runtime is the reference. - **No singletons or static get-or-create registries** for anything that can - be injected — a global invites the double-create race and forces tests to + be injected. A global invites the double-create race and forces tests to share process-wide state. Needing `@Suite(.serialized)` plus a reset hook - is the smell; injected dependencies get hermetic per-test instances. + is the smell. Injected dependencies get hermetic per-test instances. - **When the platform instantiates the consumer** (App Intents, extension - principal classes), use the platform's DI seam, and keep it a **handoff, - not a factory**: the root installs what it created - (`IntentServices.install(_:)`), early callers await installation - (`current()` parks, cancellation-aware), and the seam never creates the - resource itself — a "create it myself" fallback quietly reintroduces the + principal classes), use the platform's DI seam. Keep it a **handoff, + not a factory**. The root installs what it created + (`IntentServices.install(_:)`). Early callers await installation + (`current()` parks, cancellation-aware). The seam never creates the + resource itself. A "create it myself" fallback quietly reintroduces the duplicate the design exists to prevent. - **Derive, don't re-derive.** A stack built from an existing layer reuses - what that layer computed (the store, the live attributor, the clock) — - derivation stays synchronous and non-throwing, and can't drift from its + what that layer computed (the store, the live attributor, the clock). + Derivation stays synchronous and non-throwing. It cannot drift from its base. - **Re-fire composition hooks wherever the lifecycle re-creates the thing.** - `onServicesReady` fires on every session (re)start, so consumers always + `onServicesReady` fires on every session (re)start. Consumers always hold the current instance, never the first one. -This is [Modeling state](#modeling-state) applied to ownership and lifetime: -one owner, created in one place, the illegal wirings unrepresentable. +This is [Modeling state](#modeling-state) applied to ownership and lifetime. +One owner, created in one place, the illegal wirings unrepresentable. ## Generating the Xcode project -Agents must never open Xcode on the user's machine — it steals focus and +Agents must never open Xcode on the user's machine. It steals focus and disrupts the user's session. Always pass `--no-open` when regenerating: - `./ide --no-open` instead of `./ide` - `mise exec -- tuist generate --no-open` instead of `tuist generate` -`tuist test` / `tuist build` are CLI-only and do not open Xcode, so no +`tuist test` / `tuist build` are CLI-only and do not open Xcode. No flag is needed there. ## Running tests @@ -542,45 +530,45 @@ management (`./simulator` resolves a UDID — never pass a device name to - **`./swiftformat --lint` and `./test` are part of "done".** Never commit a red tree. Load the [`running-tests`](../.agents/skills/running-tests/SKILL.md) skill for which tier to run. -- **Multi-step work lands one commit per step**, so history stays bisectable and - can land piecewise — including pure-groundwork steps, which say so in the body. -- **Commit when asked, or when working through a plan.** If it's unclear whether - a commit is wanted, make the change and ask rather than committing silently. +- **Multi-step work lands one commit per step**. History stays bisectable and + can land piecewise. Pure-groundwork steps say so in the body. +- **Commit when asked, or when working through a plan.** If it is unclear whether + a commit is wanted, make the change and ask. Do not commit silently. ### GitHub Load the [`github-workflow`](../.agents/skills/github-workflow/SKILL.md) skill for PRs, pushes, review feedback, CI, and posting as the user. Always-on: use -`gh`; open PRs ready-for-review; mark AI-posted comments. +`gh`. Open PRs ready-for-review. Mark AI-posted comments. ## Cursor Cloud specific instructions Cloud agent VMs run **Linux**, not macOS. This repo targets **iOS 26** with **Xcode 27+** and **Tuist** (macOS-only). Treat Linux as a partial dev -environment: formatting and agent sync work; builds, tests, and running the +environment. Formatting and agent sync work on Linux. Builds, tests, and running the **Where** app require macOS (as in CI on the `xcode-27` runner image). ### Setup is committed, not configured in a dashboard [`.cursor/environment.json`](.cursor/environment.json) runs -[`.cursor/install.sh`](.cursor/install.sh) after checkout: it installs `mise`, +[`.cursor/install.sh`](.cursor/install.sh) after checkout. It installs `mise`, trusts the config, runs `mise install`, installs `git-lfs`, and points Git at `.githooks/`. Nothing about a cloud agent's setup lives in a dashboard. -`git-lfs` is not optional on either platform — the `.githooks/` LFS hooks -exit non-zero when the binary is missing, breaking checkout/merge/push even +`git-lfs` is not optional on either platform. The `.githooks/` LFS hooks +exit non-zero when the binary is missing. That breaks checkout/merge/push even for work that never touches snapshots. Both bootstraps install it before -setting `core.hooksPath`. The repo-defined environment follows branches, -**takes precedence over any dashboard-managed environment**, and must stay -idempotent (Cursor may re-run it against cached state). +setting `core.hooksPath`. The repo-defined environment follows branches. +It **takes precedence over any dashboard-managed environment**. It must stay +idempotent (Cursor can re-run it against cached state). ### What works on Linux -**Tuist is scoped to `os = ["macos"]`** in `.mise.toml`, and mise skips an -OS-restricted tool entirely rather than failing on it — so `mise install` and +**Tuist is scoped to `os = ["macos"]`** in `.mise.toml`. Mise skips an +OS-restricted tool entirely rather than failing on it. `mise install` and every `mise exec --` now succeed here instead of dying on `unsupported env: linux/amd64`. `mise install` also fires the `postinstall` hook that fetches the -external agent skills, which are gitignored and so absent from a bare checkout. +external agent skills. Those skills are gitignored and absent from a bare checkout. | Check | Command | |-------|---------| @@ -597,8 +585,8 @@ external agent skills, which are gitignored and so absent from a bare checkout. - iOS Simulator, and running the **Where** app - Anything else needing Xcode -These are limits of the **VM**, not of cloud agents generally: a remote-control -session runs iOS, so anything that needs the app actually running — reproducing +These are limits of the **VM**, not of cloud agents generally. A remote-control +session runs iOS. Anything that needs the app actually running — reproducing a bug, checking a screen, exercising a flow by hand — goes there rather than being written off as untestable from a cloud agent. diff --git a/README.md b/README.md index 5f1e8f3e..15fb30d9 100644 --- a/README.md +++ b/README.md @@ -11,10 +11,10 @@ Random apps and stuff. ## Getting started -On a fresh machine, run the one-shot bootstrap. It checks that Xcode is -installed and selected, installs `mise` if missing (via its official -installer — no Homebrew required), installs the pinned tools (Tuist, -SwiftFormat, Ruby), then sets Git hooks, runs `sync-agents --install`, and +On a fresh machine, run the one-shot bootstrap. It makes sure that Xcode is +installed and selected. It installs `mise` if missing (via its official +installer — no Homebrew required). It installs the pinned tools (Tuist, +SwiftFormat, Ruby). Then it sets Git hooks, runs `sync-agents --install`, and generates the Xcode project: ```bash @@ -28,7 +28,7 @@ When bootstrap installs `mise`, it also adds `mise activate` to your shell rc restart your shell (or `source ~/.zshrc`) afterwards. On other shells, add activation manually per the [mise docs](https://mise.jdx.dev/getting-started.html). -On subsequent runs (mise already installed), just regenerate: +On subsequent runs (mise already installed), regenerate the project: ```bash # Generate the Xcode project (also sets Git hooks and runs sync-agents --install) @@ -38,10 +38,10 @@ On subsequent runs (mise already installed), just regenerate: ./ide -i ``` -`./ide` without `--bootstrap` fails fast if `mise` isn't found, pointing you -back at `./ide --bootstrap`. If you'd rather manage `mise` yourself, +If `mise` is not found, `./ide` without `--bootstrap` fails fast and points +you at `./ide --bootstrap`. If you manage `mise` yourself, `brew install mise` (or the [official installer](https://mise.jdx.dev)) -followed by `mise install` works too. +followed by `mise install` also works. Run tests with `./test` (or open the generated workspace in Xcode). With no arguments it runs only the bundles your changes affect, against the simulator @@ -78,9 +78,9 @@ The executable configuration is in [`BumperBowling.swift`](BumperBowling.swift); the enforced invariants and repair guidance are cataloged in [`.bumper/RULES.md`](.bumper/RULES.md). -To see where build and test time goes, run `./profile` — it prints the slowest build phases, the slowest tests (per bundle), and any slow type-check sites. It only reports, it never fails; see `./profile --help` for flags (`--build-only`/`--tests-only`, `--no-snapshots`, `--device`/`--os`, `--top`, thresholds). +To see where build and test time goes, run `./profile`. It prints the slowest build phases, the slowest tests (per bundle), and any slow type-check sites. It only reports, it never fails; see `./profile --help` for flags (`--build-only`/`--tests-only`, `--no-snapshots`, `--device`/`--os`, `--top`, thresholds). -To hunt down flaky tests, run `./flaky` — it runs the whole suite several times, then tight-loops (in isolation) any test that ever failed, and records the tests that both pass and fail (with flake counts) in [`FLAKY_TESTS.md`](FLAKY_TESTS.md). Like `./profile` it's report-only; see `./flaky --help` for flags (`--suite-runs`, `--iterations`, `--device`/`--os`, `--no-update`, `--top`). +To hunt down flaky tests, run `./flaky`. It runs the whole suite several times, then tight-loops (in isolation) any test that ever failed, and records the tests that both pass and fail (with flake counts) in [`FLAKY_TESTS.md`](FLAKY_TESTS.md). Like `./profile` it's report-only; see `./flaky --help` for flags (`--suite-runs`, `--iterations`, `--device`/`--os`, `--no-update`, `--top`). The `./ide` script sets `core.hooksPath` to `.githooks`. The pre-commit hook formats staged Swift with SwiftFormat and runs `./sync-agents --git-add` so diff --git a/Shared/Broadway/AGENTS.md b/Shared/Broadway/AGENTS.md index bcc1bd6d..52cb360b 100644 --- a/Shared/Broadway/AGENTS.md +++ b/Shared/Broadway/AGENTS.md @@ -1,43 +1,25 @@ # Broadway – Module Group Shape -Broadway is a design-system stack centered on `BContext` — a type-keyed -environment (traits, themes, lazily-cached stylesheets) that flows through a -UIKit + SwiftUI view hierarchy. See [`README.md`](README.md). +Broadway is a design-system stack centered on `BContext`. It is a type-keyed environment (traits, themes, lazily-cached stylesheets) that flows through a UIKit and SwiftUI view hierarchy. See [`README.md`](README.md). -This file complements the root [`AGENTS.md`](../../AGENTS.md), which owns build, -formatting, and global conventions. Read that first. +Read the root [`AGENTS.md`](../../AGENTS.md) first. That file owns build, formatting, and global conventions. ## Modules & dependencies -- **BroadwayCore** — foundation types (Foundation + UIKit). No sibling deps. -- **BroadwayUI** — components (SwiftUI + UIKit). Depends on BroadwayCore. +- **BroadwayCore** — foundation types (Foundation and UIKit). No sibling deps. +- **BroadwayUI** — components (SwiftUI and UIKit). Depends on BroadwayCore. - **BroadwayCatalog** — showcase app. Depends on BroadwayUI. -UIKit hosting helpers for Broadway's hosted test bundles live in the shared -[`TestHostSupport`](../TestHostSupport) module (not a Broadway module). +UIKit hosting helpers for Broadway's hosted test bundles live in the shared [`TestHostSupport`](../TestHostSupport) module, not in a Broadway module. -Libraries live in [`Package.swift`](../../Package.swift); the app + hosted test -bundles in [`Project.swift`](../../Project.swift) (the shared `unitTests` helper, -`com.stuff.broadway.*` bundle IDs). +Libraries live in [`Package.swift`](../../Package.swift). The app and hosted test bundles live in [`Project.swift`](../../Project.swift) (the shared `unitTests` helper, `com.stuff.broadway.*` bundle IDs). ## Invariants an agent can't re-derive -- **`BContext` owns a cached `BStylesheets`.** Mutating `baseTraits`, - `traitOverrides`, or `themes` must refresh that cache (the `didSet`s do); - `stylesheets` is `@EquatableIgnored`, so it stays out of `BContext` equality. -- **`BRootViewController` defers setup** — child creation, trait observation, - and context are wired on `viewIsAppearing`, so `context` is `nil` before the - controller enters a valid hierarchy. -- **Broadway's hosted bundles (`BroadwayCoreTests`, `BroadwayUITests`) run in - the shared `StuffTestHost`** via `TestHostSupport` - (`show`, `hostKeyWindow`). The host stamps its window with - `isMainTestHostWindow` and `hostKeyWindow()` selects only that window — don't - reintroduce a "first key window" or `UIApplication.shared.delegate?.window` - lookup. +- **`BContext` owns a cached `BStylesheets`.** When you mutate `baseTraits`, `traitOverrides`, or `themes`, refresh that cache. The `didSet`s do this. `stylesheets` is `@EquatableIgnored`, so it stays out of `BContext` equality. +- **Defer `BRootViewController` setup.** Wire child creation, trait observation, and context on `viewIsAppearing`. Before the controller enters a valid hierarchy, `context` is `nil`. +- **Run Broadway's hosted bundles in the shared `StuffTestHost`.** `BroadwayCoreTests` and `BroadwayUITests` use `TestHostSupport` (`show`, `hostKeyWindow`). The host stamps its window with `isMainTestHostWindow`. `hostKeyWindow()` selects only that window. Do not reintroduce a "first key window" or `UIApplication.shared.delegate?.window` lookup. ## Testing -`BroadwayCoreTests` and `BroadwayUITests` run in `StuffTestHost` and link -`TestHostSupport`; `BroadwayCatalogTests` is currently hosted by the -**BroadwayCatalog** app itself — a deviation from the shared-host convention, -tracked in [`TODOs.md`](TODOs.md). 1:1 test files per the root rules. +`BroadwayCoreTests` and `BroadwayUITests` run in `StuffTestHost` and link `TestHostSupport`. `BroadwayCatalogTests` is currently hosted by the **BroadwayCatalog** app itself. That deviates from the shared-host convention. Track it in [`TODOs.md`](TODOs.md). Use 1:1 test files per the root rules. diff --git a/Shared/Broadway/BroadwayCatalog/AGENTS.md b/Shared/Broadway/BroadwayCatalog/AGENTS.md index 834422b8..efdaa96b 100644 --- a/Shared/Broadway/BroadwayCatalog/AGENTS.md +++ b/Shared/Broadway/BroadwayCatalog/AGENTS.md @@ -1,19 +1,14 @@ # BroadwayCatalog – Module Shape -The catalog **app** — a showcase of BroadwayUI components. Depends on -**BroadwayUI**. Entry point `BroadwayApp.swift` (`@main`). See -[`README.md`](README.md). +BroadwayCatalog is the catalog **app**. It showcases BroadwayUI components. It depends on **BroadwayUI**. Entry point: `BroadwayApp.swift` (`@main`). See [`README.md`](README.md). -Complements the root [`AGENTS.md`](../../../AGENTS.md) and the group -[`../AGENTS.md`](../AGENTS.md). Read those first. +Read the root [`AGENTS.md`](../../../AGENTS.md) and the group [`../AGENTS.md`](../AGENTS.md) first. ## Scope -- App-specific views live here, not in BroadwayUI. Resources bundle via the - `Resources/**` glob in [`Project.swift`](../../../Project.swift). -- Declared as a Tuist `.app` target (`com.stuff.broadway.catalog`), - iPhone/iPad destinations. +- **Put app-specific views here, not in BroadwayUI.** Resources bundle through the `Resources/**` glob in [`Project.swift`](../../../Project.swift). +- **Declare a Tuist `.app` target** (`com.stuff.broadway.catalog`) for iPhone and iPad destinations. -Tests: `BroadwayCatalogTests` (`./test BroadwayCatalogTests`), currently -hosted by this app itself — a deviation from the shared-`StuffTestHost` -convention, tracked in [`../TODOs.md`](../TODOs.md). +## Testing + +Run `BroadwayCatalogTests` (`./test BroadwayCatalogTests`). This app currently hosts its own tests. That deviates from the shared-`StuffTestHost` convention. Track it in [`../TODOs.md`](../TODOs.md). diff --git a/Shared/Broadway/BroadwayCore/AGENTS.md b/Shared/Broadway/BroadwayCore/AGENTS.md index a47a8d5b..1287831a 100644 --- a/Shared/Broadway/BroadwayCore/AGENTS.md +++ b/Shared/Broadway/BroadwayCore/AGENTS.md @@ -1,29 +1,15 @@ # BroadwayCore – Module Shape -Foundation of the Broadway stack: the `BContext` environment (traits, themes, -lazily-cached stylesheets) plus supporting value types (`AnyEquatable`, -`CopyOnWrite`, `TypeIdentifier`, `EquatableIgnored`). Foundation + UIKit; no app -or sibling-module imports. See [`README.md`](README.md). +BroadwayCore is the foundation of the Broadway stack. It provides the `BContext` environment (traits, themes, lazily-cached stylesheets) and supporting value types (`AnyEquatable`, `CopyOnWrite`, `TypeIdentifier`, `EquatableIgnored`). It uses Foundation and UIKit. It imports no app or sibling modules. See [`README.md`](README.md). -Complements the root [`AGENTS.md`](../../../AGENTS.md) and the group -[`../AGENTS.md`](../AGENTS.md). Read those first. +Read the root [`AGENTS.md`](../../../AGENTS.md) and the group [`../AGENTS.md`](../AGENTS.md) first. ## Scope & invariants -- **`BContext` keeps its `BStylesheets` lookup key in sync.** Every `didSet` on - `baseTraits` / `traitOverrides` / `themes` calls `updateTraits` / - `updateThemes`; `stylesheets` is `@EquatableIgnored`, so it stays out of - equality. -- **The `BStylesheets` cache is shared across `BContext` copies.** `get(_:)` is - non-mutating and writes newly-created sheets into the copy-on-write box in - place (via `_unsafeUnderlyingValue`), so value copies of a context share one - cache — a stylesheet is created once per `(type, traits, themes)` key and - reused across copies and repeated access. A trait/theme change only moves the - *key*: entries under the old key stay in the dictionary (nothing evicts them - today — see the `TODO` in `BStylesheets.swift`) while lookups resolve fresh - sheets under the new one. Don't assume reading `context.stylesheets` - re-resolves. (See `BStylesheetCacheSharingTests`.) -- **`@_spi(CopyOnWrite)`** exposes the copy-on-write box internals - (`_unsafeUnderlyingValue`) — used by that in-place cache write and by tests. +- **Keep the `BStylesheets` lookup key in sync on `BContext`.** Every `didSet` on `baseTraits`, `traitOverrides`, or `themes` must call `updateTraits` or `updateThemes`. `stylesheets` is `@EquatableIgnored`, so it stays out of equality. +- **Share the `BStylesheets` cache across `BContext` copies.** `get(_:)` is non-mutating. It writes newly-created sheets into the copy-on-write box in place through `_unsafeUnderlyingValue`. Value copies of a context share one cache. A stylesheet is created once per `(type, traits, themes)` key. A trait or theme change only moves the key. Entries under the old key stay in the dictionary. Nothing evicts them today. See the `TODO` in `BStylesheets.swift`. Lookups resolve fresh sheets under the new key. Do not assume reading `context.stylesheets` re-resolves. See `BStylesheetCacheSharingTests`. +- **Expose copy-on-write box internals through `@_spi(CopyOnWrite)`.** `_unsafeUnderlyingValue` supports that in-place cache write and tests. -Tests: `BroadwayCoreTests` in `StuffTestHost` (`./test BroadwayCoreTests`). +## Testing + +Run `BroadwayCoreTests` in `StuffTestHost` (`./test BroadwayCoreTests`). diff --git a/Shared/Broadway/BroadwayUI/AGENTS.md b/Shared/Broadway/BroadwayUI/AGENTS.md index 6e4e25aa..423c9e58 100644 --- a/Shared/Broadway/BroadwayUI/AGENTS.md +++ b/Shared/Broadway/BroadwayUI/AGENTS.md @@ -1,32 +1,16 @@ # BroadwayUI – Module Shape -UIKit + SwiftUI components that own and propagate a `BContext` down the view -hierarchy — `BRootViewController` (UIKit root container + trait observation), -`BRootView` / `.broadwayRoot(themes:)` (the SwiftUI-native root), and -`BTraitOverridesViewController` (scoped overrides). Depends on **BroadwayCore**. -See [`README.md`](README.md). +BroadwayUI provides UIKit and SwiftUI components that own and propagate a `BContext` down the view hierarchy. Key types: `BRootViewController` (UIKit root container and trait observation), `BRootView` / `.broadwayRoot(themes:)` (SwiftUI-native root), and `BTraitOverridesViewController` (scoped overrides). It depends on **BroadwayCore**. See [`README.md`](README.md). -Complements the root [`AGENTS.md`](../../../AGENTS.md) and the group -[`../AGENTS.md`](../AGENTS.md). Read those first. +Read the root [`AGENTS.md`](../../../AGENTS.md) and the group [`../AGENTS.md`](../AGENTS.md) first. ## Scope & invariants -- **Shared components only** — app-specific views belong in BroadwayCatalog. -- **`BRootViewController` defers setup** until it enters a valid hierarchy - (`viewIsAppearing`); `context` is `nil` before then, and the controller - publishes the context to descendants through `traitOverrides.bContext`. -- **`BRootView` has no `BTraitsObserver`** — SwiftUI re-evaluates `body` on - color-scheme / Dynamic Type changes, and a `.task` mirrors - `BAccessibility.changes()` into state; both rebuild the injected `BContext`. - Context-building lives in `BRootContext.make(...)` so the trait mapping is - testable without a host. -- **`\.bContext` prefers a synchronous SwiftUI value, and mirrors to UIKit.** - `BContext+SwiftUI` stores a SwiftUI-set context (via `BRootView` / - `broadwayRoot` / `bTraitOverrides`) in a pure-SwiftUI `EnvironmentKey` — read - synchronously, no `UITraitCollection` round-trip or first-frame lag — *and* - mirrors it into the UIKit trait system so it also reaches nested UIKit views. - With none set, it falls back to the UIKit trait-bridged value (so a - `BRootViewController`-set context still reaches SwiftUI). +- **Keep shared components here only.** Put app-specific views in BroadwayCatalog. +- **Defer `BRootViewController` setup** until the controller enters a valid hierarchy (`viewIsAppearing`). Before then, `context` is `nil`. The controller publishes context to descendants through `traitOverrides.bContext`. +- **Do not add `BTraitsObserver` to `BRootView`.** SwiftUI re-evaluates `body` on color-scheme and Dynamic Type changes. A `.task` mirrors `BAccessibility.changes()` into state. Both rebuild the injected `BContext`. Context-building lives in `BRootContext.make(...)` so the trait mapping is testable without a host. +- **Make `\.bContext` prefer a synchronous SwiftUI value, and mirror to UIKit.** `BContext+SwiftUI` stores a SwiftUI-set context (through `BRootView`, `broadwayRoot`, or `bTraitOverrides`) in a pure-SwiftUI `EnvironmentKey`. Read it synchronously. Do not round-trip through `UITraitCollection`. Do not accept first-frame lag. Mirror the value into the UIKit trait system so nested UIKit views receive it. If none is set, fall back to the UIKit trait-bridged value. Then a `BRootViewController`-set context still reaches SwiftUI. -Tests: `BroadwayUITests` in `StuffTestHost`, linking `TestHostSupport` -(`./test BroadwayUITests`). +## Testing + +Run `BroadwayUITests` in `StuffTestHost`, linking `TestHostSupport` (`./test BroadwayUITests`). diff --git a/Shared/Broadway/README.md b/Shared/Broadway/README.md index e90742fd..e04f10b1 100644 --- a/Shared/Broadway/README.md +++ b/Shared/Broadway/README.md @@ -3,7 +3,7 @@ Broadway is a SwiftUI + UIKit design-system stack built around `BContext` — a type-keyed environment carrying the current traits, themes, and a lazily-cached stylesheet set that propagates through a UIKit/SwiftUI view hierarchy. It was -merged into Stuff from its own repository (git history preserved); the shared +merged into Stuff from its own repository (git history preserved). The shared iOS test host and build scaffolding are Stuff's. ## Modules @@ -23,7 +23,7 @@ bundles live in the shared [`TestHostSupport`](../TestHostSupport) module. ## Build & test -Libraries are declared in the root [`Package.swift`](../../Package.swift); the -Catalog app and hosted test bundles in [`Project.swift`](../../Project.swift) -(bundle IDs `com.stuff.broadway.*`). Run e.g. `./test BroadwayCoreTests`, +Libraries are declared in the root [`Package.swift`](../../Package.swift). The +Catalog app and hosted test bundles are in [`Project.swift`](../../Project.swift) +(bundle IDs `com.stuff.broadway.*`). Run `./test BroadwayCoreTests`, `./test BroadwayUITests`, or `./test BroadwayCatalogTests`. diff --git a/Shared/CreditKit/AGENTS.md b/Shared/CreditKit/AGENTS.md index 6c9219ee..cc501b17 100644 --- a/Shared/CreditKit/AGENTS.md +++ b/Shared/CreditKit/AGENTS.md @@ -1,54 +1,24 @@ # CreditKit — Module Shape -Tools and types for working out what an app owes attribution to, and for -shipping that answer inside the app. See [`README.md`](README.md) for the API -and the report format; the repo-wide build, format, and convention rules are in -the root [`AGENTS.md`](../../AGENTS.md). +CreditKit provides tools and types for working out what an app owes attribution to, and for shipping that answer inside the app. See [`README.md`](README.md) for the API and the report format. Repo-wide build, format, and convention rules are in the root [`AGENTS.md`](../../AGENTS.md). ## Scope & dependencies -- **May import:** Foundation. Nothing else — not even logging. CreditKit is a - leaf that anything may depend on. +- **May import:** Foundation. Nothing else — not even logging. CreditKit is a leaf that anything may depend on. - **Must not import:** any app or feature module, or any UI framework. -- **Wired in:** `Package.swift` (`CreditKit` product) and `Project.swift` - (`CreditKitTests`, in the `Stuff-iOS-Tests` scheme). Presentation belongs to - the consuming UI; `Tools/generate-attribution.rb` is the only thing that - writes a report. +- **Wired in:** `Package.swift` (`CreditKit` product) and `Project.swift` (`CreditKitTests`, in the `Stuff-iOS-Tests` scheme). Presentation belongs to the consuming UI. `Tools/generate-attribution.rb` is the only thing that writes a report. ## Invariants -- **CreditKit ships no credits and no notices.** A report describes one app's - dependency graph and lives in that app's resources (for Where, - `Where/Where/Resources/attribution.json`) — never under `Sources/` here. -- **Nothing here may name a real dependency.** `CreditKitTests` uses fixtures - only; asserting that some package is credited is the app's test - (`AppAttributionTests` in `Where/Where/Tests/`). -- **Failure is thrown, never logged or defaulted** — an empty manifest would - render as "nothing to credit", the one wrong answer. Only the app knows - which of its bundles should carry a report. -- **`Kind` is load-bearing** — a UI must keep `.developmentTool` and library - credits visually distinct. Its raw values are a wire format; renaming a case - invalidates every committed report. The generator validates each source's - `kind` up front so a config typo fails there, not as a decode fault in-app. -- **Credit names are unique across a report** (enforced case-insensitively by - the generator) — `SoftwareCredit` is `Identifiable` by `name`, and a - library's name is its repo basename. -- **Notices are read at the pinned revision**, never the default branch — - HEAD's text may not govern the code in the binary. -- **The generator keys off `.product(name:package:)`, not `dependencies:`** — - that keeps tooling-only packages (BumperBowling, swift-syntax) out of a - report by construction. -- **`kind` is derived from reachability, not declared.** `shippedFrom` names - the app's root package targets; anything inside that closure is a `library`, - any other linked package a `developmentTool` — linking is not shipping. - `shippedFrom` is the only hand-set part. +- **CreditKit ships no credits and no notices.** A report describes one app's dependency graph. It lives in that app's resources (for Where, `Where/Where/Resources/attribution.json`). Never put it under `Sources/` here. +- **Nothing here may name a real dependency.** `CreditKitTests` uses fixtures only. Asserting that some package is credited is the app's test (`AppAttributionTests` in `Where/Where/Tests/`). +- **Throw on failure. Never log or default.** An empty manifest renders as "nothing to credit". That is the one wrong answer. Only the app knows which of its bundles must carry a report. +- **`Kind` is load-bearing.** A UI must keep `.developmentTool` and library credits visually distinct. Raw values are a wire format. Renaming a case invalidates every committed report. The generator validates each source's `kind` up front so a config typo fails there, not as a decode fault in-app. +- **Credit names are unique across a report.** The generator enforces this case-insensitively. `SoftwareCredit` is `Identifiable` by `name`. A library's name is its repo basename. +- **Read notices at the pinned revision.** Never read the default branch. HEAD's text may not govern the code in the binary. +- **The generator keys off `.product(name:package:)`, not `dependencies:`.** That keeps tooling-only packages (BumperBowling, swift-syntax) out of a report by construction. +- **`kind` is derived from reachability, not declared.** `shippedFrom` names the app's root package targets. Anything inside that closure is a `library`. Any other linked package is a `developmentTool`. Linking is not shipping. `shippedFrom` is the only hand-set part. ## Testing -`CreditKitTests` covers the manifest as a format and an API: decoding the -exact JSON the generator writes, rejecting malformed reports and unknown -`kind`s, round-tripping, filtering, and `load` throwing for a bundle with no -report. Shared fixtures live in `CreditKitTestSupport.swift`; its -`SampleReport.json` (a string constant on the `SampleReport` enum, not a -fixture file) is a literal rather than an encoder round-trip so a Swift-side -change that breaks the wire format fails a test. +`CreditKitTests` covers the manifest as a format and an API. It decodes the exact JSON the generator writes. It rejects malformed reports and unknown `kind`s. It round-trips, filters, and makes `load` throw for a bundle with no report. Shared fixtures live in `CreditKitTestSupport.swift`. Its `SampleReport.json` (a string constant on the `SampleReport` enum, not a fixture file) is a literal rather than an encoder round-trip. Then a Swift-side change that breaks the wire format fails a test. diff --git a/Shared/Flyover/AGENTS.md b/Shared/Flyover/AGENTS.md index 7310fb88..f882fea5 100644 --- a/Shared/Flyover/AGENTS.md +++ b/Shared/Flyover/AGENTS.md @@ -1,52 +1,28 @@ # Flyover – Module Shape -Flyover is an app-agnostic SwiftUI developer browser for registered screen -states and their push/modal relationships. See [`README.md`](README.md) for the -public API and integration guide. This file complements the root -[`AGENTS.md`](../../AGENTS.md), which owns build, formatting, and global -conventions. +Flyover is an app-agnostic SwiftUI developer browser for registered screen states and their push/modal relationships. See [`README.md`](README.md) for the public API and integration guide. This file complements the root [`AGENTS.md`](../../AGENTS.md), which owns build, formatting, and global conventions. ## Scope & dependencies -- Flyover may import SwiftUI, BroadwayCore/BroadwayUI, and SnapshotKit; it must - not import WhereCore, WhereUI, persistence frameworks, or any app module. -- Apps own their typed screen IDs, demo/synthetic state, catalog construction, - and the DEBUG-only entry point that hosts ``FlyoverView``. -- Strings in this developer-only shared tool are English literals. An app - localizes the entry point it adds to its own UI. +- **Flyover may import SwiftUI, BroadwayCore/BroadwayUI, and SnapshotKit.** It must not import WhereCore, WhereUI, persistence frameworks, or any app module. +- **Apps own their typed screen IDs, demo/synthetic state, catalog construction, and the DEBUG-only entry point** that hosts ``FlyoverView``. +- **Use English literals for strings** in this developer-only shared tool. An app localizes the entry point it adds to its own UI. ## Invariants -- Catalog registration is explicit and typed; do not add source scanning, - build scripts, or macros without revisiting the API and build-cost tradeoff. -- Present Flyover outside an ambient `NavigationStack`; use a separate - presentation domain such as `fullScreenCover`. -- Route Flyover appearance through `FlyoverStylesheet`; keep fixed tokens in - property defaults and trait-derived adjustments in `init(context:)`. -- Overview screen content is inert. Native interaction is enabled only in the - focused inspector; per-frame controls remain interactive in both modes. -- Every screen receives a `NavigationStack` by default so its navigation chrome - renders in the frame; `.none` is only for self-contained navigation roots and - non-screen surfaces. -- Variant content builders stay lazy; catalog construction must not instantiate - off-screen views or their models. -- Canvas loading follows the viewport and keeps at most six automatic screen - trees live; a manually requested preview replaces that set with one tree, - and presenting the focused inspector suspends the canvas set. -- Open the canvas fitted to its width; reserve whole-graph framing for the - explicit Fit All action. -- Invoke variant builders through the serial deferred load coordinator, never - synchronously from a SwiftUI `body`; preview fixtures may open expensive - in-memory stores. -- Global traits are session-only and apply to registered content, not Flyover - chrome. -- Register forward push/modal routes only. Flyover derives Back/Dismiss cues - from incoming routes. -- Type erase only at the heterogeneous content/control registry boundary. +- **Keep catalog registration explicit and typed.** Do not add source scanning, build scripts, or macros without revisiting the API and build-cost tradeoff. +- **Present Flyover outside an ambient `NavigationStack`.** Use a separate presentation domain such as `fullScreenCover`. +- **Route Flyover appearance through `FlyoverStylesheet`.** Keep fixed tokens in property defaults. Put trait-derived adjustments in `init(context:)`. +- **Keep overview screen content inert.** Enable native interaction only in the focused inspector. Per-frame controls remain interactive in both modes. +- **Give every screen a `NavigationStack` by default** so its navigation chrome renders in the frame. Use `.none` only for self-contained navigation roots and non-screen surfaces. +- **Keep variant content builders lazy.** Catalog construction must not instantiate off-screen views or their models. +- **Load the canvas from the viewport.** Keep at most six automatic screen trees live. A manually requested preview replaces that set with one tree. Presenting the focused inspector suspends the canvas set. +- **Open the canvas fitted to its width.** Reserve whole-graph framing for the explicit Fit All action. +- **Invoke variant builders through the serial deferred load coordinator.** Never invoke them synchronously from a SwiftUI `body`. Preview fixtures may open expensive in-memory stores. +- **Keep global traits session-only.** Apply them to registered content, not Flyover chrome. +- **Register forward push/modal routes only.** Flyover derives Back/Dismiss cues from incoming routes. +- **Type erase only at the heterogeneous content/control registry boundary.** ## Testing -Swift Testing in [`Tests/`](Tests) covers catalog validation, graph layout, and -session state. Rendering is pinned in [`SnapshotTests/`](SnapshotTests) through -the module's `FlyoverSnapshotTests` target in the shared `StuffSnapshotTests` -scheme. +Swift Testing in [`Tests/`](Tests) covers catalog validation, graph layout, and session state. Rendering is pinned in [`SnapshotTests/`](SnapshotTests) through the module's `FlyoverSnapshotTests` target in the shared `StuffSnapshotTests` scheme. diff --git a/Shared/Flyover/README.md b/Shared/Flyover/README.md index e7f4faa2..9d2f658b 100644 --- a/Shared/Flyover/README.md +++ b/Shared/Flyover/README.md @@ -7,7 +7,7 @@ can carry local controls for switching variants or changing the state it displays. Selecting a card opens a full-screen live inspector. Flyover owns presentation, not app discovery or data. The host supplies a typed -catalog and should build its screen content from an isolated in-memory world. +catalog and must build its screen content from an isolated in-memory world. Flyover never opens a store, persists preferences, or resolves app globals. Its chrome resolves through Broadway's trait-aware `FlyoverStylesheet`. @@ -139,7 +139,7 @@ Present Flyover outside the app's ambient `NavigationStack`, such as from a several nested screen stacks into an ancestor stack; a separate presentation domain keeps that chrome local to each frame. -Registration is explicit in version one. Apps should colocate each screen's +Registration is explicit in version one. Apps must colocate each screen's typed registration and outgoing routes beside the represented view, then keep their central catalog limited to grouping and assembly. Swift macros cannot discover all conformers or navigation destinations across a module, and a diff --git a/Shared/Inspector/AGENTS.md b/Shared/Inspector/AGENTS.md index e4ce4dbd..4e91c035 100644 --- a/Shared/Inspector/AGENTS.md +++ b/Shared/Inspector/AGENTS.md @@ -1,62 +1,30 @@ # Inspector – Module Shape -Inspector is an app-agnostic developer runtime for inspecting and deleting -configured filesystem, persistent UserDefaults, and SwiftData state. See -[`README.md`](README.md) for the public API and behavior. +Inspector is an app-agnostic developer runtime for inspecting and deleting configured filesystem, persistent UserDefaults, and SwiftData state. See [`README.md`](README.md) for the public API and behavior. -This file complements the root [`AGENTS.md`](../../AGENTS.md), which owns build, -formatting, and repository-wide conventions. +This file complements the root [`AGENTS.md`](../../AGENTS.md), which owns build, formatting, and repository-wide conventions. ## Scope and dependencies -- Depend only on SwiftUI, SwiftData, Foundation, Observation, QuickLook, and - UIKit. Never import Where or another app module; applications provide every - source through `InspectorConfiguration`. -- Keep boot selection outside this module. `InspectorModeController` persists - next-launch choice and pending recovery erasures in one dedicated suite. -- Treat the entire module as developer tooling. Consumers compile entry points - behind `#if DEBUG`; strings remain unlocalized literals. -- Keep `InspectorView`, `InspectorConfiguration`, - `InspectorSwiftDataConfiguration`, `InspectorSwiftDataView`, and - `InspectorModeController` public. Other implementation types stay internal. +- **Depend only on SwiftUI, SwiftData, Foundation, Observation, QuickLook, and UIKit.** Never import Where or another app module. Applications provide every source through `InspectorConfiguration`. +- **Keep boot selection outside this module.** `InspectorModeController` persists next-launch choice and pending recovery erasures in one dedicated suite. +- **Treat the entire module as developer tooling.** Consumers compile entry points behind `#if DEBUG`. Strings remain unlocalized literals. +- **Keep `InspectorView`, `InspectorConfiguration`, `InspectorSwiftDataConfiguration`, `InspectorSwiftDataView`, and `InspectorModeController` public.** Keep other implementation types internal. ## Invariants -- Never permit deletion of a configured filesystem root or an ancestor that - contains one. -- Resolve every configured SwiftData source before enabling filesystem - deletion; protect its store family, exact `recoveryStorageURLs`, and - containing ancestors, or disable deletion in the unresolved storage tree. -- Keep raw store files protected in the generic filesystem browser. An - unreadable source may erase only its explicitly configured store URL's known - SQLite/support family and exact in-root `recoveryStorageURLs` through the - confirmed recovery action, then remove that source from the current Inspector - session only after verifying every member is absent and latching a - second-pass cleanup for the next process. -- Complete pending recovery erasures before constructing either application - runtime; retain failed requests and select Inspector rather than opening the - regular stack against a possibly unreadable store. -- Keep file browsing, previews, and mutations inside canonical configured - roots; never follow a symlink outside one. -- Enumerate only configured persistent defaults domains. Existing scalar values - may retain their type or be deleted; complex values stay read-only and keys - cannot be created. -- Keep every SwiftData context and model instance on - `InspectorSwiftDataStore`; only value snapshots and persistent identifiers - cross to the main actor. -- Erase an open store through `ModelContainer.erase()`, remove its exact - `recoveryStorageURLs`, then replace the actor's container with one reopened by - the configured factory; honor cancellation only before destructive work. -- Expose whole-store erase from `InspectorSwiftDataConfiguration` only when its - caller supplies a fresh-container factory. -- Keep private SwiftData reflection in - [`SwiftDataReflection.swift`](Sources/SwiftDataReflection.swift). Tables must - not fault blobs or relationships merely to render. -- Grow pagination by re-fetching one longer prefix, not offset pages. +- **Never permit deletion of a configured filesystem root or an ancestor that contains one.** +- **Resolve every configured SwiftData source before you enable filesystem deletion.** Protect its store family, exact `recoveryStorageURLs`, and containing ancestors. If a source is unresolved, disable deletion in the unresolved storage tree. +- **Keep raw store files protected in the generic filesystem browser.** An unreadable source may erase only its explicitly configured store URL's known SQLite/support family and exact in-root `recoveryStorageURLs` through the confirmed recovery action. Remove that source from the current Inspector session only after you verify every member is absent. Latch a second-pass cleanup for the next process. +- **Complete pending recovery erasures before you construct either application runtime.** Retain failed requests and select Inspector rather than opening the regular stack against a possibly unreadable store. +- **Keep file browsing, previews, and mutations inside canonical configured roots.** Never follow a symlink outside one. +- **Enumerate only configured persistent defaults domains.** Existing scalar values may retain their type or be deleted. Complex values stay read-only. Keys cannot be created. +- **Keep every SwiftData context and model instance on `InspectorSwiftDataStore`.** Only value snapshots and persistent identifiers cross to the main actor. +- **Erase an open store through `ModelContainer.erase()`.** Remove its exact `recoveryStorageURLs`. Replace the actor's container with one reopened by the configured factory. Honor cancellation only before destructive work. +- **Expose whole-store erase from `InspectorSwiftDataConfiguration` only when its caller supplies a fresh-container factory.** +- **Keep private SwiftData reflection in [`SwiftDataReflection.swift`](Sources/SwiftDataReflection.swift).** Tables must not fault blobs or relationships merely to render. +- **Grow pagination by re-fetching one longer prefix, not offset pages.** ## Testing -Swift Testing lives in [`Tests/`](Tests), split by implementation concern. -Use temporary directories, isolated defaults suites, and in-memory SwiftData -containers. Image references live in [`SnapshotTests/`](SnapshotTests) and run -in the shared `StuffSnapshotTests` scheme. +Swift Testing lives in [`Tests/`](Tests), split by implementation concern. Use temporary directories, isolated defaults suites, and in-memory SwiftData containers. Image references live in [`SnapshotTests/`](SnapshotTests) and run in the shared `StuffSnapshotTests` scheme. diff --git a/Shared/Inspector/README.md b/Shared/Inspector/README.md index 73688d98..7434d743 100644 --- a/Shared/Inspector/README.md +++ b/Shared/Inspector/README.md @@ -151,7 +151,7 @@ Inspector deliberately does not reflectively edit SwiftData attributes. Entity discovery can use an explicit `[PersistentModel.Type]` list or fall back to the container schema. Tables fetch a capped prefix (500 rows by default); -“Load more” re-fetches one longer prefix so offset instability cannot overlap or +"Load more" re-fetches one longer prefix so offset instability cannot overlap or skip rows. Binary values render as sizes/placeholders, and relationships are faulted only after an explicit drill-in. diff --git a/Shared/JournalKit/AGENTS.md b/Shared/JournalKit/AGENTS.md index 9c2085ba..24b5fa63 100644 --- a/Shared/JournalKit/AGENTS.md +++ b/Shared/JournalKit/AGENTS.md @@ -1,40 +1,20 @@ # JournalKit – Module Shape -JournalKit is the generic append-only, crash-durable journal: synchronous -`Data` appends that survive process death, segment rotation under a byte -budget, and torn-tail-tolerant recovery. See [`README.md`](README.md) for -the API and durability model. +JournalKit is a generic append-only, crash-durable journal. It provides synchronous `Data` appends that survive process death, segment rotation under a byte budget, and torn-tail-tolerant recovery. See [`README.md`](README.md) for the API and durability model. -This file complements the root [`AGENTS.md`](../../AGENTS.md), which owns -the build system, formatting, and global conventions. Read that first. +Read the root [`AGENTS.md`](../../AGENTS.md) first. That file owns the build system, formatting, and global conventions. ## Scope & dependencies -- **Foundation + os only.** No logging types, no Periscope imports — the - journal is payload-agnostic by design (PeriscopeCore layers log semantics - on top). Keep it that way. +- **Use Foundation and os only.** Do not import logging types or Periscope. PeriscopeCore layers log semantics on top. Keep the journal payload-agnostic. ## Invariants -- **`append` returning means the entry survives process death.** The write - reaches the kernel page cache synchronously; `.full` extends coverage to - kernel panics via `F_FULLFSYNC`. Nothing may buffer entries in user space. -- **Recovery never throws over a torn tail.** A crash can cut the file at - any byte; recovery yields every wholly-written entry and flags the tear. - The truncation fuzz in `JournalRecoveryTests` pins this at every cut - point — keep it passing. -- **A torn write poisons only its own segment.** A partial `write(2)` - (disk-full's shape) leaves bytes recovery stops at, so the segment is - marked poisoned and the next append rotates to a fresh one — later - entries must never land behind a tear. -- **Drops are whole segments, oldest first,** and always observable - (`droppedSegmentCount`, `droppedOlderEntries`) — the newest entries are - never sacrificed. A segment that fails to delete stays in the byte - accounting (later rotations retry it) and the drop loop moves to the - next-oldest, so the budget still wins. +- **When `append` returns, the entry survives process death.** The write reaches the kernel page cache synchronously. `.full` extends coverage to kernel panics through `F_FULLFSYNC`. Do not buffer entries in user space. +- **Recovery never throws over a torn tail.** A crash can cut the file at any byte. Recovery yields every wholly-written entry and flags the tear. Keep the truncation fuzz in `JournalRecoveryTests` passing at every cut point. +- **A torn write poisons only its own segment.** A partial `write(2)` (disk-full's shape) leaves bytes recovery stops at. Mark the segment poisoned. Rotate to a fresh segment on the next append. Later entries must never land behind a tear. +- **Drop whole segments, oldest first.** Always expose drops (`droppedSegmentCount`, `droppedOlderEntries`). Never sacrifice the newest entries. If a segment fails to delete, keep it in the byte accounting. Later rotations retry it. The drop loop moves to the next-oldest segment so the budget still wins. ## Testing -Swift Testing in [`Tests/`](Tests), hosted in `StuffTestHost` -(`JournalKitTests`). Tests journal into per-test temporary directories and -construct crashed-journal states (truncation, corruption) directly on disk. +Swift Testing lives in [`Tests/`](Tests), hosted in `StuffTestHost` (`JournalKitTests`). Journal into per-test temporary directories. Construct crashed-journal states (truncation, corruption) directly on disk. diff --git a/Shared/LifecycleKit/AGENTS.md b/Shared/LifecycleKit/AGENTS.md index cd7267c8..3c7db776 100644 --- a/Shared/LifecycleKit/AGENTS.md +++ b/Shared/LifecycleKit/AGENTS.md @@ -1,85 +1,27 @@ # LifecycleKit – Module Shape -LifecycleKit is an app-agnostic engine that models app startup (and its -reverse, teardown) as a **typed plan**: steps are types with concrete -`Input`/`Output`, a `LaunchPlan` composes them into a sequential trunk plus -concurrent detached fan-outs with the data flow checked at compile time, and -a `@MainActor @Observable` `LifecycleRunner` walks the plan and -publishes one value-carrying `phase`. Rendering lives in -[LifecycleKitUI](../LifecycleKitUI/AGENTS.md). See [`README.md`](README.md) -for the full narrative and API. +LifecycleKit is an app-agnostic engine that models app startup (and its reverse, teardown) as a **typed plan**. Steps are types with concrete `Input`/`Output`. A `LaunchPlan` composes them into a sequential trunk plus concurrent detached fan-outs with data flow checked at compile time. A `@MainActor @Observable` `LifecycleRunner` walks the plan and publishes one value-carrying `phase`. Rendering lives in [LifecycleKitUI](../LifecycleKitUI/AGENTS.md). See [`README.md`](README.md) for the full narrative and API. -This file complements the root [`AGENTS.md`](../../AGENTS.md), which owns build -system, formatting, and global conventions. Read that first. +Read the root [`AGENTS.md`](../../AGENTS.md) first. That file owns build system, formatting, and global conventions. ## Scope & dependencies -- Pure **Foundation + Observation**. It must **not** import SwiftUI, UIKit, - WhereCore, or any app code — views belong in LifecycleKitUI; app-specific - launch logic lives in the consumer (e.g. `WhereUI/Sources/Launch/`). -- Steps, gates, and the engine are `@MainActor`; heavy work hops to an actor - *inside* a step's `run`, never by loosening isolation on the step. +- **Use Foundation and Observation only.** Do not import SwiftUI, UIKit, WhereCore, or any app code. Views belong in LifecycleKitUI. App-specific launch logic lives in the consumer (for example `WhereUI/Sources/Launch/`). +- **Keep steps, gates, and the engine on `@MainActor`.** Heavy work hops to an actor inside a step's `run`. Never loosen isolation on the step. ## Invariants -- **The type erasure has exactly one home.** `LaunchPlan`'s combinators erase - steps into `LaunchPlanNode` (package-visible for the runner and the UI - proxy seam); their generic constraints guarantee every internal cast. Never - add a second erasure site or a public API that traffics in `Any`. -- **One identity domain per plan.** `LaunchPlan` is generic over - `ID: Hashable & Sendable` and every combinator requires matching `ID`s, so - a plan can't mix domains; `nodeIDs` gives back `[ID]`, not erased keys. - IDs erase to `AnyHashable` *inside* `LaunchPlanNode` and deliberately stay - erased from there on (the runner's memo, `LifecycleFailure.stepID`, - `LifecycleGateHandle.id`) — pushing `ID` past the plan would force it onto - the runner, the container, and every splash/failure/gate closure. Untyped - `failed(at:)` assertions are the priced-in cost, not an oversight. -- **Only pass-through positions may skip.** Value-producing (`init`/`then`) - steps must keep `modes == .all` (plan-construction `precondition`) — a - skipped producer would leave a hole in the data flow. Don't add a skip path - for them. -- **A plan may be rooted at a gate**, for an app that must build nothing until - the user chooses (Where's onboarding/demo choice). `Input` and `Output` are - then the gate's `Value` — safe for the same reason `.gate` is: a gate - transforms nothing. Such a gate declares `modes: .all`, since parking a - headless launch is the point rather than the deadlock the default avoids, and - the choice reaches the next step through its dependencies, not the trunk. - Guard: `LaunchPlanTests.planCanRootAtAGate`. -- **Failure is terminal.** A thrown node parks `.failed` with no retry — the - recovery is relaunching the app. A failed teardown likewise parks and does - not relaunch (a thrown erase leaves state intact). Don't reintroduce a - resume/retry path; if a node is genuinely flaky, retry inside it at the - layer that understands the failure. -- **All drives funnel through a single in-flight task** (cancel-and-drain): - two drives never overlap, and `teardown()`/`enterForeground()` can - interrupt a launch parked on a gate. A cancelled drive is distinct from a - thrown node (`.failed`), a superseded drive never writes the phase the new - drive owns, and a superseded drive's gate handle resolves to a no-op. - Don't add a drive path that bypasses that serialization. -- **Memoized run-once, for promotion.** Completed nodes' outputs are - memoized so an `enterForeground()` promotion's re-walk skips completed - work; skipped gates are deliberately *not* memoized so they re-evaluate on - promotion. Fresh attempts (first `run()`, the start of a teardown, the - post-teardown relaunch) clear the memo — so teardown plans may freely reuse - launch node IDs (no live shared memo, since there is no retry re-walk). -- **Detached children are off the critical path by construction:** they never - block `.ready`, never fail the drive, and surface failures only on - `detachedFailures`. -- **`.undetermined` is the honest UIScene launch reason** — under UIScene, - `UIApplication.applicationState` reads `.background` at `didFinishLaunching` - even for a user tap, so launch `.undetermined` rather than fabricate a - `.background(cause)`. It gates to the background-safe nodes and builds no - view tree until promoted; if no scene ever connects it honestly stays - `.undetermined`. -- **Promotion is idempotent.** `enterForeground()` promotes `.background` and - `.undetermined` and no-ops on `.userForeground`; call it only once the - scene is genuinely `.active` (see `RootView` in WhereUI for the - `scenePhase` gating pattern). +- **Keep type erasure in exactly one home.** `LaunchPlan`'s combinators erase steps into `LaunchPlanNode` (package-visible for the runner and the UI proxy seam). Their generic constraints guarantee every internal cast. Never add a second erasure site or a public API that traffics in `Any`. +- **Use one identity domain per plan.** `LaunchPlan` is generic over `ID: Hashable & Sendable`. Every combinator requires matching `ID`s. A plan cannot mix domains. `nodeIDs` gives back `[ID]`, not erased keys. IDs erase to `AnyHashable` inside `LaunchPlanNode` and deliberately stay erased from there on (the runner's memo, `LifecycleFailure.stepID`, `LifecycleGateHandle.id`). Pushing `ID` past the plan would force it onto the runner, the container, and every splash/failure/gate closure. Untyped `failed(at:)` assertions are the priced-in cost, not an oversight. +- **Allow skip only in pass-through positions.** Value-producing (`init`/`then`) steps must keep `modes == .all` (plan-construction `precondition`). A skipped producer would leave a hole in the data flow. Do not add a skip path for them. +- **A plan may root at a gate** for an app that must build nothing until the user chooses (Where's onboarding/demo choice). `Input` and `Output` are then the gate's `Value`. That is safe for the same reason `.gate` is: a gate transforms nothing. Such a gate declares `modes: .all`. Parking a headless launch is the point rather than the deadlock the default avoids. The choice reaches the next step through its dependencies, not the trunk. Guard: `LaunchPlanTests.planCanRootAtAGate`. +- **Treat failure as terminal.** A thrown node parks `.failed` with no retry. Recovery is relaunching the app. A failed teardown likewise parks and does not relaunch (a thrown erase leaves state intact). Do not reintroduce a resume/retry path. If a node is genuinely flaky, retry inside it at the layer that understands the failure. +- **Funnel all drives through a single in-flight task** (cancel-and-drain). Two drives never overlap. `teardown()`/`enterForeground()` can interrupt a launch parked on a gate. A cancelled drive is distinct from a thrown node (`.failed`). A superseded drive never writes the phase the new drive owns. A superseded drive's gate handle resolves to a no-op. Do not add a drive path that bypasses that serialization. +- **Memoize completed nodes for promotion.** Completed nodes' outputs are memoized so an `enterForeground()` promotion's re-walk skips completed work. Skipped gates are deliberately not memoized so they re-evaluate on promotion. Fresh attempts (first `run()`, the start of a teardown, the post-teardown relaunch) clear the memo. Teardown plans may freely reuse launch node IDs (no live shared memo, since there is no retry re-walk). +- **Keep detached children off the critical path by construction.** They never block `.ready`. They never fail the drive. They surface failures only on `detachedFailures`. +- **Use `.undetermined` as the honest UIScene launch reason.** Under UIScene, `UIApplication.applicationState` reads `.background` at `didFinishLaunching` even for a user tap. Launch `.undetermined` rather than fabricate a `.background(cause)`. It gates to the background-safe nodes and builds no view tree until promoted. If no scene ever connects, it honestly stays `.undetermined`. +- **Keep promotion idempotent.** `enterForeground()` promotes `.background` and `.undetermined` and no-ops on `.userForeground`. Call it only once the scene is genuinely `.active` (see `RootView` in WhereUI for the `scenePhase` gating pattern). ## Testing -Swift Testing in [`Tests/`](Tests), hosted in `StuffTestHost`. Engine tests -build a `LaunchPlan` from the shared `FixtureStep`/`FixtureGate` fixtures and -assert on `phase`; seeded fuzz tests (`LifecycleRunnerFuzzTests`) replay -failures exactly against an independent model. Keep tests deterministic — -park async steps on test-controlled streams/handles, not timing. +Swift Testing lives in [`Tests/`](Tests), hosted in `StuffTestHost`. Engine tests build a `LaunchPlan` from the shared `FixtureStep`/`FixtureGate` fixtures and assert on `phase`. Seeded fuzz tests (`LifecycleRunnerFuzzTests`) replay failures exactly against an independent model. Keep tests deterministic. Park async steps on test-controlled streams/handles, not timing. diff --git a/Shared/LifecycleKit/README.md b/Shared/LifecycleKit/README.md index 805add56..e75fbc41 100644 --- a/Shared/LifecycleKit/README.md +++ b/Shared/LifecycleKit/README.md @@ -8,7 +8,7 @@ whose single published `phase` the UI layer renders. Each step is its own type with concrete `Input`/`Output`. The plan's combinators check the data flow at compile time, so the classic launch bugs — a step running before the thing it needs exists, a skipped step leaving a -hole downstream, the app UI rendering off an optional that "should" have been +hole downstream, the app UI rendering off an optional that was never set — are unrepresentable rather than merely avoided. A thrown trunk step parks the runner in a terminal failure phase (no retry — the recovery is relaunching the app); logout/erase is the same machinery run over a teardown diff --git a/Shared/LifecycleKitUI/AGENTS.md b/Shared/LifecycleKitUI/AGENTS.md index 2925efa4..e1d720f8 100644 --- a/Shared/LifecycleKitUI/AGENTS.md +++ b/Shared/LifecycleKitUI/AGENTS.md @@ -1,53 +1,23 @@ # LifecycleKitUI – Module Shape -The SwiftUI layer for [LifecycleKit](../LifecycleKit): `LifecycleContainer` -renders a `LifecycleRunner`'s `phase` (splash / gate view / failure / app -content), `GateView(for:content:)` registers gate views by gate *type*, and -`LifecycleProxy` (`@Environment(\.lifecycle)`) lets nested views reach -`enterForeground()`/`teardown(_:input:)`. The failure surface is terminal -(no retry). See [`README.md`](README.md) for the full -narrative and API. +LifecycleKitUI is the SwiftUI layer for [LifecycleKit](../LifecycleKit). `LifecycleContainer` renders a `LifecycleRunner`'s `phase` (splash, gate view, failure, app content). `GateView(for:content:)` registers gate views by gate *type*. `LifecycleProxy` (`@Environment(\.lifecycle)`) lets nested views reach `enterForeground()`/`teardown(_:input:)`. The failure surface is terminal (no retry). See [`README.md`](README.md) for the full narrative and API. -This file complements the root [`AGENTS.md`](../../AGENTS.md), which owns -build system, formatting, and global conventions. Read that first. +Read the root [`AGENTS.md`](../../AGENTS.md) first. That file owns build system, formatting, and global conventions. ## Scope & dependencies -- **SwiftUI + LifecycleKit only.** No app imports — app-specific launch UI - (splashes, onboarding) lives in the consumer (e.g. `WhereUI`). -- The engine/UI split is deliberate: LifecycleKit must stay renderable-state - only (no SwiftUI import); anything that builds a `View` belongs here. +- **Use SwiftUI and LifecycleKit only.** Do not import app code. App-specific launch UI (splashes, onboarding) lives in the consumer (for example `WhereUI`). +- **Keep the engine/UI split deliberate.** LifecycleKit must stay renderable-state only (no SwiftUI import). Anything that builds a `View` belongs here. ## Invariants -- **`content` is only ever built from `.ready`'s carried value** — never - re-read from shared state. It is built as soon as the value exists, - *including under a splash hold* (the hold warms the destination). Keep it to - **one** `content` call site — separate held/revealed branches give SwiftUI - two identities and rebuild the destination at the reveal. -- **No view tree when `reason.buildsNoViewTree`** — even at `.ready`. -- **Every splash-showing state resolves to one `LaunchOverlay.splash` case** — - never per-phase `switch` arms, which remount the splash at each boundary - and reset its animations and caption timers. -- **`minimumSplashDuration` only holds a splash that was actually shown** — - armed when the splash *appears*, so an already-`.ready` mount reveals - immediately. Guard: `minimumSplashDurationDoesNotHoldWhenNoSplashWasShown` - (the timing half is device-verified, not host-testable). Assert "revealed" - via the *absent splash*, not via `content` (content is built during a hold - too); `isShowingSplash` must read the runner's own surface, never - `displayedSurfaceIdentity`, which reports `.splash` for a held `.ready` and - would re-arm the hold from its own release. -- **Gate views resolve only their own handle** — a superseded drive's handle - no-ops; don't route gate resolution through anything else. -- **One registration per gate type** (construction `precondition`); a parked - gate with no registration logs (`os`, subsystem `com.stuff.lifecyclekitui`) - and fails the handle with `MissingGateViewError` onto the terminal failure - surface — never an indefinite splash. +- **Build `content` only from `.ready`'s carried value.** Never re-read from shared state. Build it as soon as the value exists, including under a splash hold (the hold warms the destination). Keep one `content` call site. Separate held/revealed branches give SwiftUI two identities and rebuild the destination at the reveal. +- **Build no view tree when `reason.buildsNoViewTree`.** That applies even at `.ready`. +- **Resolve every splash-showing state to one `LaunchOverlay.splash` case.** Never use per-phase `switch` arms. They remount the splash at each boundary and reset its animations and caption timers. +- **Hold `minimumSplashDuration` only for a splash that was actually shown.** Arm it when the splash appears, so an already-`.ready` mount reveals immediately. Guard: `minimumSplashDurationDoesNotHoldWhenNoSplashWasShown` (the timing half is device-verified, not host-testable). Assert "revealed" via the absent splash, not via `content` (content is built during a hold too). `isShowingSplash` must read the runner's own surface, never `displayedSurfaceIdentity`. That reports `.splash` for a held `.ready` and would re-arm the hold from its own release. +- **Resolve gate views only through their own handle.** A superseded drive's handle no-ops. Do not route gate resolution through anything else. +- **Allow one registration per gate type** (construction `precondition`). If a parked gate has no registration, log (`os`, subsystem `com.stuff.lifecyclekitui`) and fail the handle with `MissingGateViewError` onto the terminal failure surface. Never leave an indefinite splash. ## Testing -Swift Testing in [`Tests/`](Tests), hosted in `StuffTestHost` via the -`LifecycleKitUITests` bundle: container tests host `LifecycleContainer` and -assert which branch renders (probe views), proxy tests cover the -connected/disconnected environment paths. Engine behavior is tested in -LifecycleKit's own bundle — don't duplicate it here. +Swift Testing lives in [`Tests/`](Tests), hosted in `StuffTestHost` through the `LifecycleKitUITests` bundle. Container tests host `LifecycleContainer` and assert which branch renders (probe views). Proxy tests cover the connected/disconnected environment paths. Engine behavior is tested in LifecycleKit's own bundle. Do not duplicate it here. diff --git a/Shared/Periscope/AGENTS.md b/Shared/Periscope/AGENTS.md index 3e8cb1ea..eaed3d6b 100644 --- a/Shared/Periscope/AGENTS.md +++ b/Shared/Periscope/AGENTS.md @@ -1,54 +1,28 @@ # Periscope – Module Group Shape -Periscope is the observability stack: typed `Codable` log events on a scope -tree, spans, ambient sources, a SwiftData store, and the on-device surfaces -that browse it. See [`README.md`](README.md) for the map, and each module's own -`README.md` / `AGENTS.md` for its shape — they are the authority, and this file -does not repeat them. +Periscope is the observability stack. It provides typed `Codable` log events on a scope tree, spans, ambient sources, a SwiftData store, and on-device surfaces that browse it. See [`README.md`](README.md) for the map. Each module's own `README.md` / `AGENTS.md` is the authority. This file does not repeat them. -This file complements the root [`AGENTS.md`](../../AGENTS.md), which owns build, -formatting, and global conventions. Read that first. +Read the root [`AGENTS.md`](../../AGENTS.md) first. That file owns build, formatting, and global conventions. ## Modules & dependencies - **PeriscopeCore** — the model and machinery. No SwiftUI, no app code. - **PeriscopeUI** — SwiftUI integration. Depends on PeriscopeCore. -- **PeriscopeTools** — developer surfaces. Depends on PeriscopeCore, - PeriscopeUI, and BroadwayCore/BroadwayUI. +- **PeriscopeTools** — developer surfaces. Depends on PeriscopeCore, PeriscopeUI, and BroadwayCore/BroadwayUI. -Each layer reaches only *down*, and **the Broadway dependency stops at -PeriscopeTools** — Core and UI must stay design-system-free so a consumer can -adopt logging without adopting Broadway. +Each layer reaches only down. **The Broadway dependency stops at PeriscopeTools.** Core and UI must stay design-system-free so a consumer can adopt logging without adopting Broadway. -Durability sits below the stack in [`JournalKit`](../JournalKit), which is -payload-agnostic on purpose: log semantics never leak into it. -[`Prototypes/JournalBenchmark`](Prototypes/JournalBenchmark) is wired into no -target and no CI job. +Durability sits below the stack in [`JournalKit`](../JournalKit). It is payload-agnostic on purpose. Log semantics must never leak into it. + +[`Prototypes/JournalBenchmark`](Prototypes/JournalBenchmark) is wired into no target and no CI job. ## Invariants an agent can't re-derive -- **A consumer owns its own root scope; Periscope owns the system.** An app - declares a facade over a root `Log` scope (Where has `WhereLog`, RegionKit - `RegionLog`) and emits typed `LogEvent`s through it — never a raw string, and - never a second logging system. Those separate roots all record into the one - process-wide `Periscope.shared`, so a single store sink and a single viewer - see every scope subtree. -- **Attaching the store is the host app's job, once.** `PeriscopeStore.make` is - `async`; the app bootstraps it at launch and adds it as a sink. Library code - never attaches one, and processes that shouldn't persist (app extensions) - simply never get a store — they stay OSLog-only rather than opting out - somewhere in the framework. -- **The app names the build; Periscope only carries it.** The session the app - starts the store with supplies `LogSession.attributes` (commit, configuration, - optimization level — see `LogSessionAttributeKey`). Periscope sits below the - app modules, so it cannot read a build stamp, and it must not invent one: a - bundle that wasn't stamped contributes no attributes rather than a build - called `unknown`. Where fills them from `BuildInfo.logSessionAttributes`. -- **Tests never touch `Periscope.shared`.** Build a fresh system with an - in-memory store per test and pass it explicitly (`Log()` defaults to - `.shared`, so an omitted `system:` silently joins the process-wide one). +- **A consumer owns its own root scope. Periscope owns the system.** An app declares a facade over a root `Log` scope (Where has `WhereLog`, RegionKit `RegionLog`). Emit typed `LogEvent`s through it. Never emit a raw string. Never add a second logging system. Those separate roots all record into the one process-wide `Periscope.shared`. Then a single store sink and a single viewer see every scope subtree. +- **Attaching the store is the host app's job, once.** `PeriscopeStore.make` is `async`. The app bootstraps it at launch and adds it as a sink. Library code never attaches one. Processes that must not persist (app extensions) simply never get a store. They stay OSLog-only rather than opting out somewhere in the framework. +- **The app names the build. Periscope only carries it.** The session the app starts the store with supplies `LogSession.attributes` (commit, configuration, optimization level — see `LogSessionAttributeKey`). Periscope sits below the app modules. It cannot read a build stamp. It must not invent one. An unstamped bundle contributes no attributes rather than a build called `unknown`. Where fills them from `BuildInfo.logSessionAttributes`. +- **Tests never touch `Periscope.shared`.** Build a fresh system with an in-memory store per test. Pass it explicitly. `Log()` defaults to `.shared`. An omitted `system:` silently joins the process-wide one. ## Testing -Hosted Swift Testing bundles (`PeriscopeCoreTests`, `PeriscopeUITests`, -`PeriscopeToolsTests`) run in `StuffTestHost`. 1:1 test files per the root rules. +Hosted Swift Testing bundles (`PeriscopeCoreTests`, `PeriscopeUITests`, `PeriscopeToolsTests`) run in `StuffTestHost`. Use 1:1 test files per the root rules. diff --git a/Shared/Periscope/PeriscopeCore/AGENTS.md b/Shared/Periscope/PeriscopeCore/AGENTS.md index e0cf975e..6ac47008 100644 --- a/Shared/Periscope/PeriscopeCore/AGENTS.md +++ b/Shared/Periscope/PeriscopeCore/AGENTS.md @@ -1,120 +1,37 @@ # PeriscopeCore – Module Shape -PeriscopeCore is the core of the **Periscope** observability framework: typed -`Codable` log events, the `Log` scope hierarchy, tags, spans, the sink -pipeline, ambient event sources, and the SwiftData store. See -[`README.md`](README.md) for the narrative and API. +PeriscopeCore is the core of the **Periscope** observability framework. It provides typed `Codable` log events, the `Log` scope hierarchy, tags, spans, the sink pipeline, ambient event sources, and the SwiftData store. See [`README.md`](README.md) for the narrative and API. -This file complements the root [`AGENTS.md`](../../../AGENTS.md), which owns -the build system, formatting, and global conventions. Read that first. +Read the root [`AGENTS.md`](../../../AGENTS.md) first. That file owns the build system, formatting, and global conventions. ## Scope & dependencies -- **Foundation + os + SwiftData + Network + CryptoKit + JournalKit only** - (plus the ObjectiveC runtime for deallocation trackers and target/selector - observation; CryptoKit is used only by `ScopeID.swift`). No SwiftUI, no app - code. UIKit only inside `#if canImport(UIKit)`. -- Layering: `PeriscopeUI` and `PeriscopeTools` depend on this module — never - the reverse. +- **Use Foundation, os, SwiftData, Network, CryptoKit, and JournalKit only** (plus the ObjectiveC runtime for deallocation trackers and target/selector observation. CryptoKit is used only by `ScopeID.swift`). Do not import SwiftUI or app code. Use UIKit only inside `#if canImport(UIKit)`. +- **Keep layering one-way.** `PeriscopeUI` and `PeriscopeTools` depend on this module. Never the reverse. ## Invariants -- **Emitting never blocks the caller.** Log calls append to a lock-guarded - buffer synchronously; sinks drain asynchronously in emission order, scope - definitions first. Observer yields happen *under* the state lock — yielding - outside it lets racing emitters invert live delivery (a span's end before - its began). -- **Scope IDs are deterministic** (hash of parent + name) — span pairing and - cross-layer links rely on the same path being the same scope across - processes and launches. -- **`sequence` is store-global and monotonic**, resuming past the highest - stored value across launches — that is what makes `LogQuery.afterSequence` - a valid incremental cursor. -- **Persistence retains the full hierarchy** — events reference scopes - many-to-many, and scopes keep their parent chain. -- **Custom levels are values, not cases.** `LogLevel` is a struct ordered by - `severity`; never switch exhaustively over "all" levels. -- **Ambient sources log change-only where the signal is chatty** - (`NetworkPathAmbientSource` dedupes `NWPathMonitor`'s repeat callbacks); - notification-based sources are deliberately *not* deduped — each repeated - memory warning is a distinct event. -- **An ambient event declares whether it's a state or an occurrence.** - `AmbientEvent.reporting` decides whether the event folds into the - `AmbientSnapshot` stamped on later records. A momentary signal (a memory - warning) is `.occurrence` and never becomes state — folding it in would - leave every subsequent record claiming the app was mid-memory-warning. A - source whose signal *is* a lasting condition should also report it at - `started()`, or the state is unknown until it next changes (thermal and - low-power do; `AppLifecycleAmbientSource` deliberately doesn't — it has no - way to know the phase it started in). -- **Ambient state is stamped at emit, not joined at read.** `Periscope.buffer` - hands each record the snapshot in force at that moment, and a snapshot keeps - its `id` until a `.state` event actually moves a value — which is what makes - "one stored row per distinct state" true rather than one row per record. - Anything that mutates the snapshot must preserve that: a new identity per - record would multiply the rows by the log volume. -- **Folding outlives the admission gates.** An ambient `.state` event the - level floors discard still folds into the running snapshot (floors route, - they don't scrub); one that redaction *suppresses* clears its kind instead — - folding it would smear the suppressed value onto every later record, and - keeping the old value would lie. The snapshot must never go stale because - the event itself was kept out of the record stream. -- **`remove(_:)` is `async` because it settles the sink first** — the in-flight - drain is awaited and the sink flushed, so a removed sink is owed nothing and - hears nothing more. Removing a `PeriscopeStore` also uninstalls that store's - journal. Guard: `PeriscopeTests.removalDeliversAndFlushesWhatTheSinkWasOwed`. -- **Sink failures never propagate or vanish** — logged to OSLog, counted, and - persisted as a synthetic `StoreWriteFailed` marker; the pipeline reports - drops with a synthetic `DroppedEvents` record. -- **A failed store save rolls back** (`recoverFromFailedWrite`) — one - poisoned batch must never wedge subsequent saves or fork the session. -- **The crash journal is synchronous at emit and silent on failure.** Every - buffered record appends before `record()` returns (sequence stamped under - the state lock, file I/O outside it, fault+ records `F_FULLFSYNC`); journal - failures count and log but never throw into the emit path. Ingest runs - *before* `startSession` so recovered begans join the orphan sweep; a - journal that fails ingest stays for the next launch. -- **Only app processes ingest journals** — extensions journal their own - sessions but skip ingest (ingest deletes journals; an extension launch must - not eat the live app's). Concurrently live processes sharing one on-disk - store is unsupported; see [`TODOs.md`](../TODOs.md). -- **Payloads persist as versioned JSON** (`eventName` + `eventVersion`) — an - event shape change must not require a SwiftData migration. While the app is - pre-release, shape changes need no decode tolerance either: the store is - deleted rather than migrated, so keep `Codable` conformances synthesized - instead of hand-writing defaults for older rows. -- **A session names its build only as far as the app told it.** - `LogSession.attributes` is filled by the host app at bootstrap — - PeriscopeCore sits below the app modules and cannot read a build stamp. An - unstamped bundle yields an empty dictionary; nothing here invents a - placeholder, because a session claiming it was built from a commit named - `unknown` is worse than one that admits it can't say. -- **Keep `PeriscopeStore.inspectorModelTypes`, `inspectorStoreURL`, and - `inspectorRecoveryStorageURLs` identical to the live store and journal - locations.** They are the adapters that let a standalone Inspector enumerate - or recover internal storage without starting the logging pipeline. -- **Every span eventually ends, and its began is delivered first.** `measure` - closes on every path; bounded spans expire via the watchdog; re-begins - supersede; relaunch orphan-closes `endsWithProcess` spans (the - `survivesRelaunch` resume is staged — [`TODOs.md`](../TODOs.md)). Keep all - three protections: begin registration + `SpanBegan` record land atomically - (`LogRecorder.beginSpan`); the overflow drop policy never splits a recorded - pair (`LogEvent.isProtectedFromDropping`); redaction is transform-only for - pair records. -- **Span pairs floor together.** The floor decision is made once, at begin - (`OpenSpan.beganRecorded`, `LogRecord.bypassesFloors`): a recorded began - always gets its end, and a floored began silences the entire span — never a - dangling half. -- **The relaunch sweep decides from a column, and says so when it can't.** - `SDLogEvent.spanRelaunchPolicy` carries `SpanRelaunchPolicy` on began rows, - so the launch-path sweep filters survivors without loading a payload; a - payload that won't decode only costs the synthetic end its recorded name — - and the decode failure is logged, never silently absorbed. +- **Emitting never blocks the caller.** Log calls append to a lock-guarded buffer synchronously. Sinks drain asynchronously in emission order, scope definitions first. Observer yields happen under the state lock. Yielding outside it lets racing emitters invert live delivery (a span's end before its began). +- **Scope IDs are deterministic** (hash of parent + name). Span pairing and cross-layer links rely on the same path being the same scope across processes and launches. +- **`sequence` is store-global and monotonic.** It resumes past the highest stored value across launches. That is what makes `LogQuery.afterSequence` a valid incremental cursor. +- **Persistence retains the full hierarchy.** Events reference scopes many-to-many. Scopes keep their parent chain. +- **Custom levels are values, not cases.** `LogLevel` is a struct ordered by `severity`. Never switch exhaustively over "all" levels. +- **Log change-only where the signal is chatty** (`NetworkPathAmbientSource` dedupes `NWPathMonitor`'s repeat callbacks). Notification-based sources are deliberately not deduped. Each repeated memory warning is a distinct event. +- **An ambient event declares whether it is a state or an occurrence.** `AmbientEvent.reporting` decides whether the event folds into the `AmbientSnapshot` stamped on later records. A momentary signal (a memory warning) is `.occurrence` and never becomes state. Folding it in would leave every subsequent record claiming the app was mid-memory-warning. A source whose signal is a lasting condition must also report it at `started()`, or the state is unknown until it next changes (thermal and low-power do. `AppLifecycleAmbientSource` deliberately does not. It has no way to know the phase it started in). +- **Stamp ambient state at emit, not at read.** `Periscope.buffer` hands each record the snapshot in force at that moment. A snapshot keeps its `id` until a `.state` event actually moves a value. That is what makes "one stored row per distinct state" true rather than one row per record. Anything that mutates the snapshot must preserve that. A new identity per record would multiply the rows by the log volume. +- **Folding outlives the admission gates.** An ambient `.state` event the level floors discard still folds into the running snapshot (floors route, they do not scrub). One that redaction suppresses clears its kind instead. Folding it would smear the suppressed value onto every later record. Keeping the old value would lie. The snapshot must never go stale because the event itself was kept out of the record stream. +- **`remove(_:)` is `async` because it settles the sink first.** Await the in-flight drain and flush the sink. Then a removed sink is owed nothing and hears nothing more. Removing a `PeriscopeStore` also uninstalls that store's journal. Guard: `PeriscopeTests.removalDeliversAndFlushesWhatTheSinkWasOwed`. +- **Sink failures never propagate or vanish.** Log them to OSLog. Count them. Persist a synthetic `StoreWriteFailed` marker. The pipeline reports drops with a synthetic `DroppedEvents` record. +- **Roll back a failed store save** (`recoverFromFailedWrite`). One poisoned batch must never wedge subsequent saves or fork the session. +- **Make the crash journal synchronous at emit and silent on failure.** Every buffered record appends before `record()` returns (sequence stamped under the state lock, file I/O outside it, fault+ records `F_FULLFSYNC`). Journal failures count and log but never throw into the emit path. Run ingest before `startSession` so recovered begans join the orphan sweep. If a journal fails ingest, keep it for the next launch. +- **Only app processes ingest journals.** Extensions journal their own sessions but skip ingest (ingest deletes journals. An extension launch must not eat the live app's). Concurrently live processes sharing one on-disk store is unsupported. See [`TODOs.md`](../TODOs.md). +- **Persist payloads as versioned JSON** (`eventName` + `eventVersion`). An event shape change must not require a SwiftData migration. While the app is pre-release, shape changes need no decode tolerance either. The store is deleted rather than migrated. Keep `Codable` conformances synthesized instead of hand-writing defaults for older rows. +- **A session names its build only as far as the app told it.** `LogSession.attributes` is filled by the host app at bootstrap. PeriscopeCore sits below the app modules and cannot read a build stamp. An unstamped bundle yields an empty dictionary. Nothing here invents a placeholder. A session claiming it was built from a commit named `unknown` is worse than one that admits it cannot say. +- **Keep `PeriscopeStore.inspectorModelTypes`, `inspectorStoreURL`, and `inspectorRecoveryStorageURLs` identical to the live store and journal locations.** They are the adapters that let a standalone Inspector enumerate or recover internal storage without starting the logging pipeline. +- **Every span eventually ends, and its began is delivered first.** `measure` closes on every path. Bounded spans expire through the watchdog. Re-begins supersede. Relaunch orphan-closes `endsWithProcess` spans (the `survivesRelaunch` resume is staged — [`TODOs.md`](../TODOs.md)). Keep all three protections: begin registration and `SpanBegan` record land atomically (`LogRecorder.beginSpan`). The overflow drop policy never splits a recorded pair (`LogEvent.isProtectedFromDropping`). Redaction is transform-only for pair records. +- **Floor span pairs together.** Make the floor decision once, at begin (`OpenSpan.beganRecorded`, `LogRecord.bypassesFloors`). A recorded began always gets its end. A floored began silences the entire span. Never leave a dangling half. +- **Decide the relaunch sweep from a column, and say so when you cannot.** `SDLogEvent.spanRelaunchPolicy` carries `SpanRelaunchPolicy` on began rows. The launch-path sweep filters survivors without loading a payload. A payload that will not decode only costs the synthetic end its recorded name. Log the decode failure. Never silently absorb it. ## Testing -Swift Testing in [`Tests/`](Tests), hosted in `StuffTestHost` -(`PeriscopeCoreTests`). Use in-memory stores, fresh `Periscope` systems per -test (never the shared singleton), and injected clocks. `Log()` -defaults to `.shared` — a deliberate ergonomics exception to the -no-Core-defaults rule — so tests must always pass `system:` explicitly. +Swift Testing lives in [`Tests/`](Tests), hosted in `StuffTestHost` (`PeriscopeCoreTests`). Use in-memory stores and fresh `Periscope` systems per test (never the shared singleton). Use injected clocks. `Log()` defaults to `.shared` — a deliberate ergonomics exception to the no-Core-defaults rule — so tests must always pass `system:` explicitly. diff --git a/Shared/Periscope/PeriscopeTools/AGENTS.md b/Shared/Periscope/PeriscopeTools/AGENTS.md index 9892ebd2..8d038040 100644 --- a/Shared/Periscope/PeriscopeTools/AGENTS.md +++ b/Shared/Periscope/PeriscopeTools/AGENTS.md @@ -1,90 +1,36 @@ # PeriscopeTools – Module Shape -PeriscopeTools is the on-device log exploration tooling for -[`PeriscopeCore`](../PeriscopeCore): the latest-logs viewer, the tracer, the -debug toast, and the log view mode modifier. See [`README.md`](README.md) for -the narrative and API. +PeriscopeTools is the on-device log exploration tooling for [`PeriscopeCore`](../PeriscopeCore). It provides the latest-logs viewer, the tracer, the debug toast, and the log view mode modifier. See [`README.md`](README.md) for the narrative and API. -This file complements the root [`AGENTS.md`](../../../AGENTS.md), which owns -the build system, formatting, and global conventions. Read that first. +Read the root [`AGENTS.md`](../../../AGENTS.md) first. That file owns the build system, formatting, and global conventions. ## Scope & dependencies -- **SwiftUI + PeriscopeCore + PeriscopeUI + BroadwayCore/BroadwayUI.** No app - code — app-specific wiring (which store, which alert handler) comes in via - configuration. -- **Intended for DEBUG / developer surfaces**; consumers gate entry points - behind `#if DEBUG`. Developer-facing strings are plain literals here. -- `Sources/` groups one directory per tool, plus `Components/` for shared - display pieces and `Styling/` for the design system. Tests stay flat, 1:1 - with their source files. +- **Use SwiftUI, PeriscopeCore, PeriscopeUI, and BroadwayCore/BroadwayUI.** Do not import app code. App-specific wiring (which store, which alert handler) comes in through configuration. +- **Target DEBUG and developer surfaces.** Consumers gate entry points behind `#if DEBUG`. Developer-facing strings are plain literals here. +- **`Sources/` groups one directory per tool, plus `Components/` for shared display pieces and `Styling/` for the design system.** Tests stay flat, 1:1 with their source files. ## Design system — `PeriscopeStylesheet` -Appearance tokens live in `PeriscopeStylesheet` -([`Sources/Styling/PeriscopeStylesheet.swift`](Sources/Styling/PeriscopeStylesheet.swift)), -a Broadway `BStylesheet` — never inline in views. Read with -`@Environment(\.stylesheet)`; off the `View` tree use -`PeriscopeStylesheet.default`. +Appearance tokens live in `PeriscopeStylesheet` ([`Sources/Styling/PeriscopeStylesheet.swift`](Sources/Styling/PeriscopeStylesheet.swift)), a Broadway `BStylesheet`. Never put them inline in views. Read with `@Environment(\.stylesheet)`. Off the `View` tree use `PeriscopeStylesheet.default`. -- **Each public tool view seeds its own root** with `periscopeBroadwayRoot()`, - so tooling styles correctly with or without a host Broadway root. -- **Row density** (`comfortable` / `compact`) is a `RowStyle` axis resolved - via `stylesheet.row[density]`, riding the `\.logRowDensity` environment - value; the viewer seeds it from a `UserDefaults`-persisted preference - (`Density.load`/`save`, defaulting `compact`). -- **Color decisions live in `Palette`**, not on `LogLevel` / `SpanExit.Mode` - — `tint(forLevel:)` bands by severity so custom levels inherit a color. -- PeriscopeTools seeds Broadway directly; a consumer must not re-list - `BroadwayCore`/`BroadwayUI` beside a product that already carries them — - the root - [double-linking rule](../../../AGENTS.md#never-double-link-a-product-whereui-already-carries). +- **Seed each public tool view with its own root** through `periscopeBroadwayRoot()`. Then tooling styles correctly with or without a host Broadway root. +- **Resolve row density** (`comfortable` / `compact`) as a `RowStyle` axis through `stylesheet.row[density]`, riding the `\.logRowDensity` environment value. The viewer seeds it from a `UserDefaults`-persisted preference (`Density.load`/`save`, defaulting `compact`). +- **Keep color decisions in `Palette`, not on `LogLevel` / `SpanExit.Mode`.** `tint(forLevel:)` bands by severity so custom levels inherit a color. +- **PeriscopeTools seeds Broadway directly.** A consumer must not re-list `BroadwayCore`/`BroadwayUI` beside a product that already carries them. See the root [double-linking rule](../../../AGENTS.md#never-double-link-a-product-whereui-already-carries). ## Invariants -- **Read-only over the store.** Tooling queries `PeriscopeCore`'s store and - live buffer; it never records events of its own (except through the normal - logging API). -- **The tools report their own failures to OSLog, not to Periscope.** - `PeriscopeToolsLog.failures` is the channel for a store read that threw or a - stored payload that wouldn't decode. Logging those through Periscope would - commit a change these surfaces then reload for — one corrupt row becomes a - refresh loop. Every `catch` still logs; a `.failed` state alone isn't enough. -- **A reading never claims more than the row can say.** Values that come from - different sources — an exit mode from an indexed column, a duration from a - payload — must be modeled as one state, or a decode failure renders - contradictions (`SpanNode.Outcome` exists because an ended span used to show - an exit chip beside a "running" duration). A name recovered from a row's - message is labelled as recovered rather than passed off as the recorded one. -- **The toast is hookable** — apps override the default handler. Handlers - must not log at or above the alerter threshold (they'd alert themselves in - a loop). -- **`Periscope.isInspectModeEnabled` is the inspect flag's source of truth** - — `PeriscopeInspector` is its observable mirror, synced both ways via - `inspectModeChanges()`. -- **Merged multi-query results sort by `(date, sequence)`** — the store's - insertion sequence is the tiebreak that keeps same-millisecond events - stable. -- **Live tree/hierarchy models refresh incrementally.** `LogHierarchyModel` - and `SpanTreeModel` accumulate derived state and fetch only past their - highest merged `sequence` (`LogQuery.afterSequence`) — never a full-store - re-read; the merge re-filters on `sequence` so restarts stay idempotent. - This trades exact reflection of deletions (retention prune / clear, neither - wired into the live app) for a bounded per-commit fetch; the in-memory - rebuild is still O(accumulated) — see [`TODOs.md`](../TODOs.md). A store - swap makes the hosting view build a fresh model. -- **Tool views rebind on in-place input swaps** — each view's `.task(id:)` is - keyed on store identity plus its other inputs; a new identity-relevant - input must join the key, or the view silently keeps serving the old inputs. -- **A timing reading names the builds it pools.** `SpanHistoryScope` filters - the accumulated ends (never a refetch), and `SpanHistoryView` labels the - active scope — percentiles mixing an `-Onone` build with an `-O` one measure - nothing, and an unlabelled reading can't be told apart from a narrowed one. - A scope the sessions can't resolve is not offered, and a selection that - stops resolving falls back to `.all`. +- **Stay read-only over the store.** Tooling queries `PeriscopeCore`'s store and live buffer. It never records events of its own (except through the normal logging API). +- **Report tool failures to OSLog, not to Periscope.** `PeriscopeToolsLog.failures` is the channel for a store read that threw or a stored payload that would not decode. Logging those through Periscope would commit a change these surfaces then reload for. One corrupt row becomes a refresh loop. Every `catch` still logs. A `.failed` state alone is not enough. +- **Never let a reading claim more than the row can say.** Values from different sources — an exit mode from an indexed column, a duration from a payload — must be one state. Otherwise a decode failure renders contradictions (`SpanNode.Outcome` exists because an ended span used to show an exit chip beside a "running" duration). Label a name recovered from a row's message as recovered. Do not pass it off as the recorded one. +- **The toast is hookable.** Apps override the default handler. Handlers must not log at or above the alerter threshold. They would alert themselves in a loop. +- **`Periscope.isInspectModeEnabled` is the inspect flag's source of truth.** `PeriscopeInspector` is its observable mirror, synced both ways through `inspectModeChanges()`. +- **Sort merged multi-query results by `(date, sequence)`.** The store's insertion sequence is the tiebreak that keeps same-millisecond events stable. +- **Refresh live tree/hierarchy models incrementally.** `LogHierarchyModel` and `SpanTreeModel` accumulate derived state and fetch only past their highest merged `sequence` (`LogQuery.afterSequence`). Never do a full-store re-read. The merge re-filters on `sequence` so restarts stay idempotent. This trades exact reflection of deletions (retention prune / clear, neither wired into the live app) for a bounded per-commit fetch. The in-memory rebuild is still O(accumulated) — see [`TODOs.md`](../TODOs.md). A store swap makes the hosting view build a fresh model. +- **Rebind tool views on in-place input swaps.** Each view's `.task(id:)` is keyed on store identity plus its other inputs. A new identity-relevant input must join the key. Otherwise the view silently keeps serving the old inputs. +- **Name the builds a timing reading pools.** `SpanHistoryScope` filters the accumulated ends (never a refetch). `SpanHistoryView` labels the active scope. Percentiles mixing an `-Onone` build with an `-O` one measure nothing. An unlabelled reading cannot be told apart from a narrowed one. Do not offer a scope the sessions cannot resolve. If a selection stops resolving, fall back to `.all`. ## Testing -Swift Testing in [`Tests/`](Tests), hosted in `StuffTestHost` -(`PeriscopeToolsTests`). Seed an in-memory store, drive the view models -directly, and host views with `TestHostSupport`'s `show()` helpers. +Swift Testing lives in [`Tests/`](Tests), hosted in `StuffTestHost` (`PeriscopeToolsTests`). Seed an in-memory store. Drive the view models directly. Host views with `TestHostSupport`'s `show()` helpers. diff --git a/Shared/Periscope/PeriscopeUI/AGENTS.md b/Shared/Periscope/PeriscopeUI/AGENTS.md index e94e5f86..6fbdf29a 100644 --- a/Shared/Periscope/PeriscopeUI/AGENTS.md +++ b/Shared/Periscope/PeriscopeUI/AGENTS.md @@ -1,31 +1,19 @@ # PeriscopeUI – Module Shape -PeriscopeUI is the SwiftUI integration for -[`PeriscopeCore`](../PeriscopeCore): the `logContext` modifier and -environment accessors that flow log scopes through a view hierarchy. See -[`README.md`](README.md) for the narrative and API. +PeriscopeUI is the SwiftUI integration for [`PeriscopeCore`](../PeriscopeCore). It provides the `logContext` modifier and environment accessors that flow log scopes through a view hierarchy. See [`README.md`](README.md) for the narrative and API. -This file complements the root [`AGENTS.md`](../../../AGENTS.md), which owns -the build system, formatting, and global conventions. Read that first. +Read the root [`AGENTS.md`](../../../AGENTS.md) first. That file owns the build system, formatting, and global conventions. ## Scope & dependencies -- **SwiftUI + PeriscopeCore.** No app code; the developer tooling views live - in [`PeriscopeTools`](../PeriscopeTools), not here. -- This module adapts Core to SwiftUI — logging behavior, persistence, and - policy all belong in Core. +- **Use SwiftUI and PeriscopeCore only.** Do not import app code. Developer tooling views live in [`PeriscopeTools`](../PeriscopeTools), not here. +- **Keep logging behavior, persistence, and policy in Core.** This module adapts Core to SwiftUI only. ## Invariants -- **Stacked `logContext` modifiers link, not replace** — a child's context is - the union of every ancestor's scopes plus merged tags, nearest modifier - primary (`Log.linked(with:)` semantics; don't reimplement the merge here). -- **`\.logContext` always yields a usable logger** — outside any modifier it - falls back to a root `Log` on `Periscope.shared`, mirroring - `Log.current`. +- **Stacked `logContext` modifiers link, not replace.** A child's context is the union of every ancestor's scopes plus merged tags. The nearest modifier is primary (`Log.linked(with:)` semantics). Do not reimplement the merge here. +- **`\.logContext` always yields a usable logger.** Outside any modifier, it falls back to a root `Log` on `Periscope.shared`. That mirrors `Log.current`. ## Testing -Swift Testing in [`Tests/`](Tests), hosted in `StuffTestHost` -(`PeriscopeUITests`). Host views with `TestHostSupport`'s `show()` helpers and -assert against a fresh `Periscope` system per test. +Swift Testing lives in [`Tests/`](Tests), hosted in `StuffTestHost` (`PeriscopeUITests`). Host views with `TestHostSupport`'s `show()` helpers. Assert against a fresh `Periscope` system per test. diff --git a/Shared/Periscope/Prototypes/JournalBenchmark/AGENTS.md b/Shared/Periscope/Prototypes/JournalBenchmark/AGENTS.md index 1cefb18d..530c0c03 100644 --- a/Shared/Periscope/Prototypes/JournalBenchmark/AGENTS.md +++ b/Shared/Periscope/Prototypes/JournalBenchmark/AGENTS.md @@ -1,9 +1,11 @@ # JournalBenchmark – Module Shape -A standalone macOS benchmark prototype (see [`README.md`](README.md)) -comparing journal implementations for Periscope's crash-durability design — -it is **not** wired into the root `Package.swift`, any Tuist target, or CI, -and never ships. Build and run it directly with SwiftPM (`swift build -c -release`). Results and caveats live in the README; keep them updated if the -harness changes. Repo-wide rules live in the root -[`AGENTS.md`](../../../../AGENTS.md). +JournalBenchmark is a standalone macOS benchmark prototype. See [`README.md`](README.md). It compares journal implementations for Periscope's crash-durability design. + +It is **not** wired into the root `Package.swift`, any Tuist target, or CI. It never ships. + +Build and run it directly with SwiftPM (`swift build -c release`). + +Results and caveats live in the README. If the harness changes, update them. + +Repo-wide rules live in the root [`AGENTS.md`](../../../../AGENTS.md). diff --git a/Shared/Periscope/Prototypes/JournalBenchmark/README.md b/Shared/Periscope/Prototypes/JournalBenchmark/README.md index 5bec34c1..f9aa9c39 100644 --- a/Shared/Periscope/Prototypes/JournalBenchmark/README.md +++ b/Shared/Periscope/Prototypes/JournalBenchmark/README.md @@ -29,7 +29,7 @@ swift build -c release && ./.build/release/JournalBenchmark ## Results (2026-07-15, M-series macOS, release build) Caveats: macOS NVMe/APFS, not iPhone storage — absolute numbers will shift -on device, relative ordering should not. Darwin `fsync` does not force +on device, relative ordering must not. Darwin `fsync` does not force platter durability (that's `F_FULLFSYNC`); every variant here is measured at its app-crash-durable configuration, which is the design target. diff --git a/Shared/Periscope/README.md b/Shared/Periscope/README.md index c0ca50e6..7dced411 100644 --- a/Shared/Periscope/README.md +++ b/Shared/Periscope/README.md @@ -1,9 +1,9 @@ # Periscope -Periscope is a typed, hierarchical observability stack: structured `Codable` -log events emitted through typed loggers (`Log`) arranged in a scope -tree, timed with spans, persisted to SwiftData so days of history stay -queryable on device, and browsable from inside the app. +Periscope is a typed, hierarchical observability stack. It emits structured `Codable` +log events through typed loggers (`Log`) arranged in a scope +tree, times work with spans, persists to SwiftData so days of history stay +queryable on device, and is browsable from inside the app. Each module has its own `README.md` with the narrative and API — this file is just the map. @@ -32,8 +32,8 @@ and no CI job. ## Build & test -Libraries are declared in the root [`Package.swift`](../../Package.swift); -their hosted test bundles in [`Project.swift`](../../Project.swift). Run e.g. +Libraries are declared in the root [`Package.swift`](../../Package.swift). +Their hosted test bundles are in [`Project.swift`](../../Project.swift). Run `./test PeriscopeCoreTests`, `./test PeriscopeUITests`, or `./test PeriscopeToolsTests`. diff --git a/Shared/SnapshotKit/AGENTS.md b/Shared/SnapshotKit/AGENTS.md index fbcc3d22..335bb7df 100644 --- a/Shared/SnapshotKit/AGENTS.md +++ b/Shared/SnapshotKit/AGENTS.md @@ -1,53 +1,22 @@ # SnapshotKit – Module Shape -The generic, shippable half of the snapshot-testing framework: the appearance -*matrix* (`SnapshotConfiguration` + presets + `combinations` + identifiers), the -`SnapshotProviding` protocol, the `SnapshotCase` descriptor, and the -`snapshotPreviews` cutsheet. It drives both SwiftUI previews and the image -snapshot tests from one source of truth. See [`README.md`](README.md). +SnapshotKit is the generic, shippable half of the snapshot-testing framework. It provides the appearance *matrix* (`SnapshotConfiguration` + presets + `combinations` + identifiers), the `SnapshotProviding` protocol, the `SnapshotCase` descriptor, and the `snapshotPreviews` cutsheet. It drives both SwiftUI previews and image snapshot tests from one source of truth. See [`README.md`](README.md). -Complements the root [`AGENTS.md`](../../AGENTS.md) — read that first. +Read the root [`AGENTS.md`](../../AGENTS.md) first. ## Scope & dependencies -- **SwiftUI + Foundation + UIKit only. No snapshot-comparison engine.** This is - load-bearing: UI modules link SnapshotKit (including in release) to drive - previews, so it must never pull in `SnapshotTesting`/XCTest. The capture + - comparison pipeline lives in [`SnapshotKitTesting`](../SnapshotKitTesting). -- Library target in [`Package.swift`](../../Package.swift); consumed by UI - modules (currently `WhereUI`) for previews and by `SnapshotKitTesting` for the - config→traits mapping. Tested by `SnapshotKitTests` (pure logic). +- **Use SwiftUI, Foundation, and UIKit only. Do not link a snapshot-comparison engine.** UI modules link SnapshotKit (including in release) to drive previews. It must never pull in `SnapshotTesting`/XCTest. The capture and comparison pipeline lives in [`SnapshotKitTesting`](../SnapshotKitTesting). +- **Declare the library target in [`Package.swift`](../../Package.swift).** UI modules (currently `WhereUI`) consume it for previews. `SnapshotKitTesting` consumes it for the config→traits mapping. `SnapshotKitTests` covers pure logic. ## Invariants an agent can't re-derive -- **`identifierParts` omit default axes.** Only non-default trait/frame/type - values appear in a config's `identifier` (so `dark`, `xxxl`, `contrast`, - `rtl`, `bold`, `accessibility`, `iPad` show up, but the - light/standard/default baseline stays empty). Reference-image filenames - depend on this, so changing the omission rules renames every snapshot — - treat it as a wire format. Adding an axis is safe only with a default that - is omitted (how `layoutDirection`/`legibilityWeight` landed). -- **`.accessibility` configs are preview-filtered.** `snapshotPreviews` drops - them because VoiceOver annotations require the test-only library; they only - render as snapshot tests. Don't "fix" previews to include them. -- **`SnapshotCase` content builders stay lazy.** Constructing a provider's - descriptor array must not instantiate every view or model; each content - access creates the independent value rendered by that configuration. -- **`\.isCapturingSnapshot` is for motion end-states only.** A view may read - it only to freeze motion at a deterministic phase — never to change layout, - content, or behavior. The one carve-out (documented on the property): - content no settle window can make deterministic — externally-loaded - substrates, wall-clock-dependent system controls, wall-clock timers — may - substitute a placeholder of identical layout. It is a **hybrid** accessor - (pure-SwiftUI `EnvironmentKey` first, `UITraitBridgedEnvironmentKey` - fallback; the setter mirrors into both) — mechanics and why on - `SnapshotCaptureFlag.swift`; don't simplify it to a plain `@Entry`. -- **Design-system-agnostic.** SnapshotKit never imports Broadway/WhereUI; the - Broadway root wrap is a consumer concern (`WhereUI`'s `whereSnapshot(...)`). +- **`identifierParts` omit default axes.** Only non-default trait, frame, and type values appear in a config's `identifier` (so `dark`, `xxxl`, `contrast`, `rtl`, `bold`, `accessibility`, `iPad` show up, but the light/standard/default baseline stays empty). Reference-image filenames depend on this. Treat omission rules as a wire format. When you add an axis, give it a default that is omitted (how `layoutDirection`/`legibilityWeight` landed). +- **Filter `.accessibility` configs out of previews.** `snapshotPreviews` drops them because VoiceOver annotations require the test-only library. They render only as snapshot tests. Do not "fix" previews to include them. +- **Keep `SnapshotCase` content builders lazy.** Constructing a provider's descriptor array must not instantiate every view or model. Each content access creates the independent value rendered by that configuration. +- **Use `\.isCapturingSnapshot` for motion end-states only.** A view may read it only to freeze motion at a deterministic phase. Never use it to change layout, content, or behavior. One carve-out (documented on the property): content no settle window can make deterministic — externally-loaded substrates, wall-clock-dependent system controls, wall-clock timers — may substitute a placeholder of identical layout. It is a **hybrid** accessor (pure-SwiftUI `EnvironmentKey` first, `UITraitBridgedEnvironmentKey` fallback. The setter mirrors into both). See mechanics and rationale on `SnapshotCaptureFlag.swift`. Do not simplify it to a plain `@Entry`. +- **Keep SnapshotKit design-system-agnostic.** SnapshotKit never imports Broadway/WhereUI. The Broadway root wrap is a consumer concern (`WhereUI`'s `whereSnapshot(...)`). ## Testing -`SnapshotKitTests` covers the matrix logic — `combinations` counts and -`identifierParts` omission — as pure value assertions (no rendering). The -rendering pipeline is exercised by consumers' snapshot bundles via -`SnapshotKitTesting`. +`SnapshotKitTests` covers the matrix logic — `combinations` counts and `identifierParts` omission — as pure value assertions (no rendering). Consumers' snapshot bundles exercise the rendering pipeline through `SnapshotKitTesting`. diff --git a/Shared/SnapshotKitTesting/AGENTS.md b/Shared/SnapshotKitTesting/AGENTS.md index 14f0e9aa..0be2fc98 100644 --- a/Shared/SnapshotKitTesting/AGENTS.md +++ b/Shared/SnapshotKitTesting/AGENTS.md @@ -1,176 +1,41 @@ # SnapshotKitTesting – Module Shape -The test-only half of the snapshot-testing framework: the capture + comparison -pipeline and the `assertSnapshots` runner over a [`SnapshotKit`](../SnapshotKit) -matrix. See [`README.md`](README.md). +SnapshotKitTesting is the test-only half of the snapshot-testing framework. It provides the capture and comparison pipeline and the `assertSnapshots` runner over a [`SnapshotKit`](../SnapshotKit) matrix. See [`README.md`](README.md). -Complements the root [`AGENTS.md`](../../AGENTS.md) — read that first. +Read the root [`AGENTS.md`](../../AGENTS.md) first. ## Scope & dependencies -- Depends on `SnapshotKit`, `TestHostSupport`, `SnapshotTesting` - (swift-snapshot-testing), and `AccessibilitySnapshot` (cashapp). It links the - comparison engine + XCTest/Testing, so it is **only** consumed by test - bundles via `extraPackageProducts` — the per-module image bundles - (`WhereUISnapshotTests`, `PeriscopeToolsSnapshotTests`, - `InspectorSnapshotTests`, gathered into the `StuffSnapshotTests` - *scheme*) and `SnapshotKitTestingTests` — **never** a shipping app or - `StuffTestHost`. -- **"Process-global" state here is module-global — one copy per consuming - `.xctest` — and that is safe only because each bundle gets its own host - process.** Two copies co-loaded into one process would flip the safe-area - swizzle's parity and hide captures from each other's lock. Tripwire: if a - toolchain ever shares one host process across bundles, re-measure before - adding a consumer — topology and measurement in the snapshot-bundle comment - in `Project.swift` and the root [`AGENTS.md`](../../AGENTS.md#targets). -- **`WhereUISnapshotTests` double-embeds `SnapshotKit`, tolerated and - guarded** (this product's closure plus WhereUI's own copy in one image; the - other image bundles don't link WhereUI). Guard: - `WhereUISnapshotTests.SnapshotCaptureFlagProbeTests` fails loudly if the - copies split; mechanism: PR #145. -- Re-exports `SnapshotKit` and `SnapshotTesting` so consumers need one import. -- Library target in [`Package.swift`](../../Package.swift). +- **Depend on `SnapshotKit`, `TestHostSupport`, `SnapshotTesting` (swift-snapshot-testing), and `AccessibilitySnapshot` (cashapp).** It links the comparison engine and XCTest/Testing. Consume it only from test bundles through `extraPackageProducts` — the per-module image bundles (`WhereUISnapshotTests`, `PeriscopeToolsSnapshotTests`, `InspectorSnapshotTests`, gathered into the `StuffSnapshotTests` *scheme*) and `SnapshotKitTestingTests`. Never link it from a shipping app or `StuffTestHost`. +- **"Process-global" state here is module-global — one copy per consuming `.xctest`.** That is safe only because each bundle gets its own host process. Two copies co-loaded into one process would flip the safe-area swizzle's parity and hide captures from each other's lock. Tripwire: if a toolchain ever shares one host process across bundles, re-measure before adding a consumer. See topology and measurement in the snapshot-bundle comment in `Project.swift` and the root [`AGENTS.md`](../../AGENTS.md#targets). +- **`WhereUISnapshotTests` double-embeds `SnapshotKit`, tolerated and guarded** (this product's closure plus WhereUI's own copy in one image. The other image bundles do not link WhereUI). Guard: `WhereUISnapshotTests.SnapshotCaptureFlagProbeTests` fails loudly if the copies split. Mechanism: PR #145. +- **Re-export `SnapshotKit` and `SnapshotTesting`** so consumers need one import. +- **Declare the library target in [`Package.swift`](../../Package.swift).** ## Invariants an agent can't re-derive -- **The rendering pipeline is one async function.** All captures (standard and - accessibility) flow through `renderSnapshotImage(...)`; its `async` is - load-bearing — a synchronous `Snapshotting` pullback could never settle - `.task`-driven content. -- **The compare sees on-disk bytes.** Every capture round-trips through PNG - encoding before comparison; removing it re-opens the wide-gamut vs. sRGB - flake (see `renderSnapshotImage`'s doc). -- **`CILabDeltaE` is not perceptually uniform, so the ΔE tolerance is loose by - design.** The verdict's metric is far steeper near black than the CIE76 it - approximates: measured on this toolchain, a ±1/255 drift reads as ΔE - 0.15-0.19 in pastels, up to 4.2 in dark greys, and up to **12.1** in the - worst near-black corner when channels move in opposite directions — where - CIE76 calls the same drift ~0.3. That is why - `defaultSnapshotPerceptualPrecision` is **0.90** (ΔE 10) rather than - something eye-shaped like 0.98 (ΔE 2). Relax *this* knob, never - `defaultSnapshotPrecision`: environmental noise is bounded in per-pixel - amplitude but scatters over whatever content is dark, so widening the *area* - budget instead is what would hide a real regression confined to one - component. Evidence, from the CI attachments of run 30390830180 - (`calendarContent.FullContent_fullHeight`, which 0.98 failed): every one of - its 30,572 differing pixels was off by exactly one unit, 87% of them - near-black glyph pixels, true CIE76 maximum **0.99** — invisible, yet 17,007 - pixels (0.157%) cleared ΔE 2 and blew the 0.1% budget. At ΔE 10 that capture - contributes **zero** pixels, while the genuine glyph-shift regression in - `inspectorSurfaces.SwiftData_iPhone_dark` (differing pixels massed - at ΔE 62) still fails at 0.178%. 0.95 (ΔE 5) was rejected: it passes, but - leaves 7,120 noise pixels at 66% of the budget, i.e. one bad CI day from red. -- **Only the pipeline prints a report channel; a test asks for the payload.** - `./test` recovers `SNAPSHOT_TIMING` and `SNAPSHOT_DIFF` (and, by hand, - `SNAPSHOT_SETTLE`) by grepping them out of the run logs, and it counts timing - lines as *captured images* for the progress line — so anything that prints one - is a row in a report and an image in the count, with nothing marking it - synthetic. Each channel is split for that reason: `report(...)` / `emit()` - print, `line(...)` only returns the JSON, and a test pinning the wire shape - calls `line(...)`. Not hypothetical — when they were one function, this - module's own tests put a fabricated reference at the *top* of - `./test --review` (its numbers were borrowed from a real regression) and five - invented captures into `--timings`, so a run that captured nothing at all - reported "5 captures, 0.024s per image". -- **The runner fails fast, once, on setup problems** (a simulator that doesn't - match the `SNAPSHOT_EXPECTED_*` pins, two variants sharing one reference - name) — one clear issue, never hundreds of pixel diffs. -- **An unsettled capture is a failure, not a silent fallback.** Don't "fix" a - settle timeout by widening the budget — freeze the motion behind - `\.isCapturingSnapshot`, or use `.settledAtLeast` only for genuinely slow - (not endless) content. -- **A settled capture is not a ready capture.** The loop proves the pixels - stopped changing, not that the content the case meant to show ever arrived — - a loading placeholder is perfectly pixel-stable, so a gap between phases of - async work settles clean and bakes the spinner, and the suite reports green. - Pixel stability can't be strengthened into a readiness signal (nothing public - sees pending dispatch or Swift-concurrency work — see below), so a case whose - content arrives asynchronously must be made deterministic instead: seed the - fixture so its first frame is final (`resolution.Empty`), or await a - completion signal from `onReadyToSnapshot` (`root.LoggedIn`). Both incidents, - and how each was found, are ledgered in - [`Where/TODOs.md`](../../Where/TODOs.md). -- **`.timedOut` requires observed motion; starvation is `.starved`.** A - change-free settle loop keeps running until it can prove stability (a - starved machine can fit fewer passes than stability needs), and only a hard - cap gives up as `.starved` — an environment failure, not view motion. - Guard: `SnapshotRenderingSupportTests`. -- **Captures are single-tenant per process** — `renderSnapshotImage` - serializes through a FIFO `@MainActor` mutex, the safe-area swizzle is - depth-counted, and nested captures trap. Keep the suite serial anyway: - concurrent scheduling degrades to queued-serial, gaining nothing. Guard: - `SnapshotKitTestingTests.ConcurrentCaptureTests`; the interleaving failure - is recorded in the snapshot job comment in `.github/workflows/ci.yml`. -- **Rendering requires `StuffTestHost`'s key window** - (`TestHostSupport.hostKeyWindow()`) — not usable from a non-hosted bundle. -- **Determinism is pinned.** The pipeline overrides safe-area insets, - quiesces animations, and sets `SnapshotCaptureTrait` on the *content* - controller (not a wrapper — it must survive the intrinsic-measurement - re-hosting) so views can freeze never-settling motion. -- **Tile-and-stitch is load-bearing, not legacy.** UIKit renders a blank - image for views past ~2000pt on iOS 27.0; don't remove the tiling without - re-running the probe. Guard: - `SnapshotKitTestingTests.LargeViewCaptureTests`. -- **A settle phase costs its floor, not its passes.** Measured over all 260 - references with `SNAPSHOT_TIMING=1`: 192 captures sit at 0.25-0.35s, the - `minDuration` floor plus a pass or two, and the floor accounts for ~70s of - the ~84s of settle time. The render passes themselves are ~14s across the - whole suite. So making passes cheaper is worth ~11% and removing floors is - worth ~54% — but a floor can only come off with a **deterministic completion - seam** for that case (as `root.LoggedIn` does by awaiting `launcher.run()` - from `onReadyToSnapshot`), never by introspection. +- **The rendering pipeline is one async function.** All captures (standard and accessibility) flow through `renderSnapshotImage(...)`. Its `async` is load-bearing. A synchronous `Snapshotting` pullback could never settle `.task`-driven content. +- **The compare sees on-disk bytes.** Every capture round-trips through PNG encoding before comparison. Removing it re-opens the wide-gamut vs. sRGB flake (see `renderSnapshotImage`'s doc). +- **`CILabDeltaE` is not perceptually uniform, so the ΔE tolerance is loose by design.** The verdict's metric is far steeper near black than the CIE76 it approximates. Measured on this toolchain, a ±1/255 drift reads as ΔE 0.15-0.19 in pastels, up to 4.2 in dark greys, and up to **12.1** in the worst near-black corner when channels move in opposite directions — where CIE76 calls the same drift ~0.3. That is why `defaultSnapshotPerceptualPrecision` is **0.90** (ΔE 10) rather than something eye-shaped like 0.98 (ΔE 2). Relax this knob, never `defaultSnapshotPrecision`. Environmental noise is bounded in per-pixel amplitude but scatters over whatever content is dark. Widening the area budget instead is what would hide a real regression confined to one component. Evidence, from the CI attachments of run 30390830180 (`calendarContent.FullContent_fullHeight`, which 0.98 failed): every one of its 30,572 differing pixels was off by exactly one unit, 87% of them near-black glyph pixels, true CIE76 maximum **0.99** — invisible, yet 17,007 pixels (0.157%) cleared ΔE 2 and blew the 0.1% budget. At ΔE 10 that capture contributes **zero** pixels, while the genuine glyph-shift regression in `inspectorSurfaces.SwiftData_iPhone_dark` (differing pixels massed at ΔE 62) still fails at 0.178%. 0.95 (ΔE 5) was rejected: it passes, but leaves 7,120 noise pixels at 66% of the budget, i.e. one bad CI day from red. +- **Only the pipeline prints a report channel. A test asks for the payload.** `./test` recovers `SNAPSHOT_TIMING` and `SNAPSHOT_DIFF` (and, by hand, `SNAPSHOT_SETTLE`) by grepping them out of the run logs. It counts timing lines as captured images for the progress line. Anything that prints one is a row in a report and an image in the count, with nothing marking it synthetic. Split each channel for that reason: `report(...)` / `emit()` print. `line(...)` only returns the JSON. A test that pins the wire shape calls `line(...)`. When they were one function, this module's own tests put a fabricated reference at the top of `./test --review` (its numbers were borrowed from a real regression) and five invented captures into `--timings`. Then a run that captured nothing at all reported "5 captures, 0.024s per image". +- **The runner fails fast, once, on setup problems** (a simulator that does not match the `SNAPSHOT_EXPECTED_*` pins, two variants sharing one reference name). Report one clear issue, never hundreds of pixel diffs. +- **An unsettled capture is a failure, not a silent fallback.** Do not "fix" a settle timeout by widening the budget. Freeze the motion behind `\.isCapturingSnapshot`. Use `.settledAtLeast` only for genuinely slow (not endless) content. +- **A settled capture is not a ready capture.** The loop proves the pixels stopped changing, not that the content the case meant to show ever arrived. A loading placeholder is perfectly pixel-stable. A gap between phases of async work settles clean and bakes the spinner. Then the suite reports green. Pixel stability cannot become a readiness signal (nothing public sees pending dispatch or Swift-concurrency work — see below). If content arrives asynchronously, make the case deterministic instead. Seed the fixture so its first frame is final (`resolution.Empty`). Or await a completion signal from `onReadyToSnapshot` (`root.LoggedIn`). Both incidents, and how each was found, are ledgered in [`Where/TODOs.md`](../../Where/TODOs.md). +- **`.timedOut` requires observed motion. Starvation is `.starved`.** A change-free settle loop keeps running until it can prove stability (a starved machine can fit fewer passes than stability needs). Only a hard cap gives up as `.starved` — an environment failure, not view motion. Guard: `SnapshotRenderingSupportTests`. +- **Captures are single-tenant per process.** `renderSnapshotImage` serializes through a FIFO `@MainActor` mutex. The safe-area swizzle is depth-counted. Nested captures trap. Keep the suite serial anyway. Concurrent scheduling degrades to queued-serial, gaining nothing. Guard: `SnapshotKitTestingTests.ConcurrentCaptureTests`. The interleaving failure is recorded in the snapshot job comment in `.github/workflows/ci.yml`. +- **Rendering requires `StuffTestHost`'s key window** (`TestHostSupport.hostKeyWindow()`). It is not usable from a non-hosted bundle. +- **Pin determinism.** The pipeline overrides safe-area insets, quiesces animations, and sets `SnapshotCaptureTrait` on the content controller (not a wrapper — it must survive the intrinsic-measurement re-hosting) so views can freeze never-settling motion. +- **Tile-and-stitch is load-bearing, not legacy.** UIKit renders a blank image for views past ~2000pt on iOS 27.0. Do not remove the tiling without re-running the probe. Guard: `SnapshotKitTestingTests.LargeViewCaptureTests`. +- **A settle phase costs its floor, not its passes.** Measured over all 260 references with `SNAPSHOT_TIMING=1`: 192 captures sit at 0.25-0.35s, the `minDuration` floor plus a pass or two, and the floor accounts for ~70s of the ~84s of settle time. The render passes themselves are ~14s across the whole suite. Making passes cheaper is worth ~11%. Removing floors is worth ~54%. A floor can come off only with a **deterministic completion seam** for that case (as `root.LoggedIn` does by awaiting `launcher.run()` from `onReadyToSnapshot`). Never remove a floor through introspection. ## Three things measured and rejected — don't re-derive them -- **Sharding the suite across simulators is 2.7x slower, and wrong.** Measured - 2026-07-28 on a 10-core / 24 GB machine: the serial suite runs in **142s** - (twice, 142.2 and 142.1); the same suite split into four duration-balanced - slices across four booted simulators, each its own process with its own - `StuffTestHost`, took **387s** — and produced **9 failures** (two settle - timeouts, one image mismatch). Separate processes fix the shared-state - interleaving that sank the earlier in-process attempt, but they don't fix the - real constraint: every shard contends for one render server, so - `drawHierarchy` slows down enough to push captures past their settle budget. - The bar for keeping it was a 30% win. Don't reach for - `-parallel-testing-enabled` either — it distributes XCTest *classes*, and - Swift Testing presents none, so it lands everything on one worker and lets - Swift Testing's own parallelism interleave captures in a single host process - (24+ spurious mismatches, 1.2-3x slower). -- **Quiescence can't replace the pixel digest.** `SNAPSHOT_SETTLE` selects - `pixel` (default), `quiescence` (a `beforeWaiting` run-loop observer plus a - recursive `needsLayout`/`needsDisplay`/`animationKeys` walk), or `both`, which - runs them together and reports disagreements. Run in `both` mode over all 260 - references: 226 settle phases, 134 with some disagreement, and **8 where - quiescence declared settled *earlier* than the digest** — every one a - `Loaded_*` case whose content arrives late. That is the one dangerous - direction (it would capture a frame no reference recorded), and it is what - `settleContent`'s doc comment predicts: a SwiftUI update deep in the hosted - tree never dirties the root, and flags read after a commit has flushed look - clean. The mechanism is kept so the experiment is re-runnable after a - toolchain change; it is not a candidate default. +- **Sharding the suite across simulators is 2.7x slower, and wrong.** Measured 2026-07-28 on a 10-core / 24 GB machine: the serial suite runs in **142s** (twice, 142.2 and 142.1). The same suite split into four duration-balanced slices across four booted simulators, each its own process with its own `StuffTestHost`, took **387s** — and produced **9 failures** (two settle timeouts, one image mismatch). Separate processes fix the shared-state interleaving that sank the earlier in-process attempt. They do not fix the real constraint: every shard contends for one render server. Then `drawHierarchy` slows down enough to push captures past their settle budget. The bar for keeping it was a 30% win. Do not reach for `-parallel-testing-enabled` either. It distributes XCTest *classes*, and Swift Testing presents none. Then it lands everything on one worker and lets Swift Testing's own parallelism interleave captures in a single host process (24+ spurious mismatches, 1.2-3x slower). +- **Quiescence cannot replace the pixel digest.** `SNAPSHOT_SETTLE` selects `pixel` (default), `quiescence` (a `beforeWaiting` run-loop observer plus a recursive `needsLayout`/`needsDisplay`/`animationKeys` walk), or `both`, which runs them together and reports disagreements. Run in `both` mode over all 260 references: 226 settle phases, 134 with some disagreement, and **8 where quiescence declared settled *earlier* than the digest** — every one a `Loaded_*` case whose content arrives late. That is the one dangerous direction (it would capture a frame no reference recorded). That is what `settleContent`'s doc comment predicts: a SwiftUI update deep in the hosted tree never dirties the root, and flags read after a commit has flushed look clean. Keep the mechanism so the experiment is re-runnable after a toolchain change. It is not a candidate default. - Two details that make those numbers mean what they say, both of which were - wrong in the first attempt at this measurement. Pending layout is sampled - **before** the loop's own `layoutIfNeeded`, because reading it afterwards makes - that third of the signal vacuously clean. And the two mechanisms keep - **separate** observed-change flags, so `both` genuinely leaves the verdict to - the digest — sharing one let quiescence flapping return `.timedOut` for content - the digest never saw change, i.e. the experiment altering its own result. - Guard: `SnapshotQuiescenceTests.staticContentSettlesRegardlessOfMechanism`. -- **No public API sees pending dispatch or Swift-concurrency work.** - `CFRunLoopGetNextTimerFireDate` reports only `CFRunLoopTimer`s, so "is - something scheduled to land in 200ms?" is unanswerable — which is why the - floors exist and why they need per-case seams. Relatedly, - `CATransaction.addCommitHandler` is **macOS-only** and absent from the iOS - SDK, so a commit-counting variant of the above isn't available either. + Two details make those numbers mean what they say. Both were wrong in the first attempt at this measurement. Sample pending layout **before** the loop's own `layoutIfNeeded`. Reading it afterwards makes that third of the signal vacuously clean. Keep **separate** observed-change flags for the two mechanisms. Then `both` genuinely leaves the verdict to the digest. Sharing one let quiescence flapping return `.timedOut` for content the digest never saw change, i.e. the experiment altering its own result. Guard: `SnapshotQuiescenceTests.staticContentSettlesRegardlessOfMechanism`. +- **No public API sees pending dispatch or Swift-concurrency work.** `CFRunLoopGetNextTimerFireDate` reports only `CFRunLoopTimer`s. Then "is something scheduled to land in 200ms?" is unanswerable. That is why the floors exist and why they need per-case seams. Relatedly, `CATransaction.addCommitHandler` is **macOS-only** and absent from the iOS SDK. A commit-counting variant of the above is not available either. ## Testing -`SnapshotKitTestingTests` (`Tests/`, in the `Stuff-iOS-Tests` scheme) owns the -pipeline's own regression tests. They render through `renderSnapshotImage` -(so they need the `StuffTestHost` key window) but assert on probed pixels via -the `@_spi(Testing)` `PixelSample`/`probePixel` API rather than LFS reference -images — fast, no `__Snapshots__/`, main `test` job. The matrixed image -assertions live in the per-module image bundles; the cross-boundary flag -probe stays in `WhereUISnapshotTests`, since only a WhereUI-defined view can -detect a duplicate-`SnapshotKit` split. +`SnapshotKitTestingTests` (`Tests/`, in the `Stuff-iOS-Tests` scheme) owns the pipeline's own regression tests. They render through `renderSnapshotImage` (so they need the `StuffTestHost` key window) but assert on probed pixels through the `@_spi(Testing)` `PixelSample`/`probePixel` API rather than LFS reference images — fast, no `__Snapshots__/`, main `test` job. The matrixed image assertions live in the per-module image bundles. The cross-boundary flag probe stays in `WhereUISnapshotTests`, since only a WhereUI-defined view can detect a duplicate-`SnapshotKit` split. diff --git a/Shared/StuffCore/AGENTS.md b/Shared/StuffCore/AGENTS.md index 4ed55136..37d901ef 100644 --- a/Shared/StuffCore/AGENTS.md +++ b/Shared/StuffCore/AGENTS.md @@ -1,7 +1,9 @@ # StuffCore – Module Shape -Scaffold library — **Foundation only**, no app imports. Placeholder -[`StuffCore.version`](Sources/StuffCore.swift) until real shared API ships. +StuffCore is a scaffold library. It uses **Foundation only**. It imports no app modules. Placeholder [`StuffCore.version`](Sources/StuffCore.swift) ships until real shared API lands. -Complements root [`AGENTS.md`](../../AGENTS.md). Tests: `StuffCoreTests` in -`StuffTestHost` (`./test StuffCoreTests`). +Read the root [`AGENTS.md`](../../AGENTS.md) first. This file adds module rules. + +## Testing + +Run `StuffCoreTests` in `StuffTestHost` (`./test StuffCoreTests`). diff --git a/Shared/StuffTestHost/AGENTS.md b/Shared/StuffTestHost/AGENTS.md index 629b2091..0f023d67 100644 --- a/Shared/StuffTestHost/AGENTS.md +++ b/Shared/StuffTestHost/AGENTS.md @@ -1,48 +1,26 @@ # StuffTestHost – Module Shape -StuffTestHost is the **iOS test host app** for hosted Swift Testing bundles — -a UIKit-only `.app` target declared in [`Project.swift`](../../Project.swift) -(not a library). Every `unitTests(...)` bundle depends on it so Xcode injects -the host at test time. See [`README.md`](README.md) for scope. +StuffTestHost is the **iOS test host app** for hosted Swift Testing bundles. It is a UIKit-only `.app` target in [`Project.swift`](../../Project.swift), not a library. Every `unitTests(...)` bundle depends on it so Xcode injects the host at test time. See [`README.md`](README.md) for scope. -This file complements the root [`AGENTS.md`](../../AGENTS.md), which owns build -system, formatting, and global conventions. Read that first. +Read the root [`AGENTS.md`](../../AGENTS.md) first. That file owns build system, formatting, and global conventions. ## Scope & invariants -- **UIKit only** — no feature UI, no SwiftUI entry point, no test assertions - in production sources. -- **Key window with root VC.** Hosted tests assume - `TestHostSupport.hostKeyWindow()` returns a window whose `rootViewController` - is non-nil; don't defer window creation or leave root unset. -- **`SceneDelegate` stamps the window `isMainTestHostWindow`.** - `TestHostSupport.hostKeyWindow()` finds the host window *only* by that marker - (not "the first key window"), so the stamp is load-bearing — keep it in - `scene(_:willConnectTo:)`. -- **Scene name matches plist.** `"Default Configuration"` is spelled in two - places — `AppDelegate`'s `configurationForConnecting` and the - `UIApplicationSceneManifest` in `Project.swift` — and they must stay aligned - or the scene never connects and every hosted test loses its window. +- **Use UIKit only.** Do not add feature UI, a SwiftUI entry point, or test assertions in production sources. +- **Provide a key window with a root VC.** Hosted tests assume `TestHostSupport.hostKeyWindow()` returns a window whose `rootViewController` is non-nil. Do not defer window creation. Do not leave root unset. +- **Stamp the window in `SceneDelegate`.** Set `isMainTestHostWindow` in `scene(_:willConnectTo:)`. `TestHostSupport.hostKeyWindow()` finds the host window only by that marker, not by "the first key window". +- **Keep the scene name aligned with the plist.** `"Default Configuration"` appears in `AppDelegate`'s `configurationForConnecting` and in the `UIApplicationSceneManifest` in `Project.swift`. If they diverge, the scene never connects and every hosted test loses its window. ## Don't add products here for `Bundle.module` -The host depends on `TestHostSupport` and nothing else, and embeds no resource -bundles. Hosted tests' `Bundle.module` lookups resolve through -`PACKAGE_RESOURCE_BUNDLE_PATH` — the accessors' own DEBUG-only override, -pointed at the built-products directory by every test scheme and by `./test` -(see `packageResourceEnvironment` in [`Project.swift`](../../Project.swift) -for the whole story, including why Xcode 27 beta 4 made the override -necessary and why the old WhereCore host embed cannot come back). - -Never fix a missing-resource failure by adding a product here (the embed -breaks String Catalog symbol generation under beta 4) or by adding a product -`WhereUI` already embeds to a test bundle's `extraPackageProducts` — that -mints the duplicate type metadata the root -[`AGENTS.md`](../../AGENTS.md#never-double-link-a-product-whereui-already-carries) -double-linking rule exists to prevent. +The host depends on `TestHostSupport` and nothing else. It embeds no resource bundles. + +Hosted tests resolve `Bundle.module` through `PACKAGE_RESOURCE_BUNDLE_PATH`. Accessors use a DEBUG-only override. Every test scheme and `./test` point it at the built-products directory. See `packageResourceEnvironment` in [`Project.swift`](../../Project.swift) for the full story. That includes why Xcode 27 beta 4 made the override necessary and why the old WhereCore host embed cannot return. + +If a resource is missing, do not add a product here. The embed breaks String Catalog symbol generation under beta 4. + +If a resource is missing, do not add a product that `WhereUI` already embeds to a test bundle's `extraPackageProducts`. That mints duplicate type metadata. The root [`AGENTS.md`](../../AGENTS.md#never-double-link-a-product-whereui-already-carries) double-linking rule exists to prevent that. ## Testing -The host itself has no test target; its invariants are covered by -`StuffTestHostSmokeTests` (in `LifecycleKitTests`) and every -`TestHostSupport.show` call site. +The host has no test target. `StuffTestHostSmokeTests` (in `LifecycleKitTests`) and every `TestHostSupport.show` call site cover its invariants. diff --git a/Shared/TestHostSupport/AGENTS.md b/Shared/TestHostSupport/AGENTS.md index 1721637f..23515a0a 100644 --- a/Shared/TestHostSupport/AGENTS.md +++ b/Shared/TestHostSupport/AGENTS.md @@ -1,38 +1,22 @@ # TestHostSupport – Module Shape -UIKit hosting + run-loop helpers (`show`, `hostKeyWindow`, `waitFor`) for the -hosted Swift Testing bundles that run inside `StuffTestHost` — the single, -dependency-free home for them. See [`README.md`](README.md). +TestHostSupport provides UIKit hosting and run-loop helpers (`show`, `hostKeyWindow`, `waitFor`) for hosted Swift Testing bundles in `StuffTestHost`. See [`README.md`](README.md). -Complements the root [`AGENTS.md`](../../AGENTS.md) — read that first. +Read the root [`AGENTS.md`](../../AGENTS.md) first. This file adds module rules. ## Scope & dependencies -- **UIKit + Foundation + ObjectiveC only, no sibling deps** (ObjectiveC for - the associated-object window marker below). Keeping it dependency-free is - the point: every test tree links it without dragging in any domain module. -- Library target in [`Package.swift`](../../Package.swift); consumed by hosted - test bundles via the `unitTests` helper in [`Project.swift`](../../Project.swift) - and by the `StuffTestHost` app. **Never linked from a shipping app target.** +- **Use UIKit, Foundation, and ObjectiveC only.** Use no sibling dependencies. ObjectiveC supports the associated-object window marker below. +- **Keep this module dependency-free.** Every test tree must link it without dragging in domain modules. +- **Declare the library target in [`Package.swift`](../../Package.swift).** Hosted test bundles consume it through the `unitTests` helper in [`Project.swift`](../../Project.swift) and through the `StuffTestHost` app. +- **Never link this module from a shipping app target.** ## Invariants an agent can't re-derive -- **The host stamps its window; we don't guess.** `hostKeyWindow()` returns only - the window marked `isMainTestHostWindow` (set by `StuffTestHost`'s - `SceneDelegate`) — never "the first key window". Don't reintroduce a - `first { $0.isKeyWindow } ?? first` search. -- **`isMainTestHostWindow` is keyed on a name-interned `Selector`.** This module - is statically embedded into the host app *and* each `.xctest` bundle, so an - associated-object key must resolve to the same pointer in every image. A - per-image `static var key: UInt8` would not match across the host↔bundle - boundary and would silently read `nil` — the exact flake this replaces. -- **`show` waits for readiness.** It pumps the run loop for the host window + root - VC before hosting, so a test running before the scene connects doesn't fail - spuriously; it follows Apple's parent/child VC order and always restores - `layer.speed` via a `defer` at entry. +- **The host stamps its window. Do not guess.** `hostKeyWindow()` returns only the window marked `isMainTestHostWindow`. `StuffTestHost`'s `SceneDelegate` sets that marker. Do not reintroduce a `first { $0.isKeyWindow } ?? first` search. +- **Key `isMainTestHostWindow` on a name-interned `Selector`.** This module is statically embedded into the host app and each `.xctest` bundle. An associated-object key must resolve to the same pointer in every image. A per-image `static var key: UInt8` does not match across the host↔bundle boundary. That mismatch silently reads `nil`. That flake is what this replaces. +- **`show` waits for readiness.** It pumps the run loop for the host window and root VC before hosting. Then a test that runs before the scene connects does not fail spuriously. It follows Apple's parent/child VC order. It always restores `layer.speed` through a `defer` at entry. ## Testing -No dedicated test bundle; exercised by every hosted bundle that calls `show` -(`StuffTestHostSmokeTests`/`ShowLifecycleTests` in `LifecycleKitTests`, the -`BroadwayUITests`/`WhereUITests` suites, …). +No dedicated test bundle exists. Every hosted bundle that calls `show` exercises this module. Examples include `StuffTestHostSmokeTests`/`ShowLifecycleTests` in `LifecycleKitTests` and the `BroadwayUITests`/`WhereUITests` suites. diff --git a/Where/AGENTS.md b/Where/AGENTS.md index 97106ed7..b9d74b5e 100644 --- a/Where/AGENTS.md +++ b/Where/AGENTS.md @@ -1,30 +1,29 @@ # Where – Feature Shape -Where is an iOS/iPadOS app for answering "what region was I in on which -day?" It ingests passive GPS (Visits + significant-change), accepts -user-asserted history (manual coordinates, whole-day overlays, evidence like -boarding passes), and rolls everything up into per-day region presence and -per-year reports. A day "counts" for a region if **any** sample in that -calendar day fell inside the region's polygon, so a single day can belong to -multiple regions. +Where is an iOS/iPadOS app. It answers "what region was I in on which day?" +It ingests passive GPS (Visits + significant-change). It accepts user-asserted +history (manual coordinates, whole-day overlays, evidence like boarding passes). +It rolls everything up into per-day region presence and per-year reports. A day +"counts" for a region if **any** sample in that calendar day fell inside the +region's polygon. A single day can belong to multiple regions. -This file complements the root [`AGENTS.md`](../AGENTS.md), which owns build -system, formatting, and global conventions. Read that first. +This file complements the root [`AGENTS.md`](../AGENTS.md). That file owns the +build system, formatting, and global conventions. Read that first. ## Modules -The layering stack, bottom-up: **RegionKit** (geometry + region lookup) → -**WhereCore** (domain; never imports SwiftUI/UIKit) → **WhereUI** (SwiftUI +The layering stack runs bottom-up: **RegionKit** (geometry + region lookup) → +**WhereCore** (domain. It never imports SwiftUI/UIKit) → **WhereUI** (SwiftUI views + view models) → the thin hosts (**Where** app, **WhereIntents**, **WhereWidgets**, **WhereShareExtension**, **RegionViewer**). Each layer -reaches only *down*; each module's own `AGENTS.md` / `README.md` is the -authority on what it is. Add domain behavior to WhereCore and presentation to -WhereUI — the app target stays tiny. +reaches only *down*. Each module's own `AGENTS.md` / `README.md` is the +authority on what it is. Add domain behavior to WhereCore. Add presentation to +WhereUI. The app target stays tiny. The DEBUG app has a second boot runtime from [`Shared/Inspector`](../Shared/Inspector). `AppDelegate` selects either the -regular composition root or the standalone Inspector before launch; Inspector -is not a `WhereScope` and must never construct regular app services. +regular composition root or the standalone Inspector before launch. Inspector +is not a `WhereScope`. It must never construct regular app services. ## Layering @@ -34,264 +33,264 @@ is not a `WhereScope` and must never construct regular app services. | **View model** | `WhereUI` (`WhereModel`, the `WhereSession` coordinator, the scoped `YearReportModel` / `ResolveModel` / `BackupModel` / `RemindersSettingsModel`) | Lifecycle wiring, observable mirrors of service output, UI intent methods. | | **Views** | `WhereUI` (`*View`) | Layout, navigation, localized copy, bindings. Never store I/O, detection, or cache/throttle policy. | -When in doubt: if the behavior would still be correct without SwiftUI, it -belongs in `WhereCore` (or on the coordinator / a scoped model — still not a -`View`). +When in doubt, ask this: if the behavior would still be correct without +SwiftUI, it belongs in `WhereCore` (or on the coordinator / a scoped model — +still not a `View`). Rules the code enforces and agents must preserve: -- **`WhereServices` is the domain entry point** — UI never talks to the store +- **`WhereServices` is the domain entry point.** UI must never talk to the store or location source directly. -- **All store mutations run inside `WhereStore.perform { … }`** (the - production store traps otherwise); values cross the boundary, never +- **All store mutations run inside `WhereStore.perform { … }`.** The + production store traps otherwise. Values cross the boundary. Never pass SwiftData records. -- **One read path.** Every committed write pings `WhereStore.changes()`, and - readers refresh purely off that signal — write intents commit, they don't +- **One read path.** Every committed write pings `WhereStore.changes()`. + Readers refresh purely off that signal. Write intents commit. They do not refresh inline. Launch is a typed [`LifecycleKit`](../Shared/LifecycleKit) - `LaunchPlan` (`WhereLaunch` in WhereUI), rendered by + `LaunchPlan` (`WhereLaunch` in WhereUI). It renders in [`LifecycleKitUI`](../Shared/LifecycleKitUI)'s container in `RootView`. -- **All logging goes through [Periscope](../Shared/Periscope)** as typed - `LogEvent`s off the `WhereLog` facade, never a raw string; each module keeps - its `*Log.swift` event types in its `Sources/Logging/` folder. Not - re-derivable from source: events log `.public`, so **keep PII out**; `info` - = important success, `warning` = degraded-but-handled, `error`/`fault` = - outright failure; hot paths stay quiet by design. RegionKit emits a separate +- **All logging goes through [Periscope](../Shared/Periscope).** Use typed + `LogEvent`s off the `WhereLog` facade. Never use a raw string. Each module + keeps its `*Log.swift` event types in its `Sources/Logging/` folder. Not + re-derivable from source: events log `.public`, so **keep PII out**. `info` + = important success. `warning` = degraded-but-handled. `error`/`fault` = + outright failure. Hot paths stay quiet by design. RegionKit emits a separate `"RegionKit"` root into the *same* `Periscope.shared`. Only the app process - attaches a store — widgets and the share extension are OSLog-only; App + attaches a store. Widgets and the share extension are OSLog-only. App Intents run in the app process. An event about a store object stamps its - `externalID` with the object's `store://` identity; RegionKit's parallel + `externalID` with the object's `store://` identity. RegionKit's parallel scheme is `region://` (see [`RegionKit/AGENTS.md`](RegionKit/AGENTS.md)). -- **Spans measure work, and declare what "too slow" means** — see +- **Spans measure work and declare what "too slow" means.** See [Spans](#spans). -- **Location comes through the `LocationSource` protocol** — - `CoreLocationSource` in production, `ScriptedLocationSource` in +- **Location comes through the `LocationSource` protocol.** + `CoreLocationSource` runs in production. `ScriptedLocationSource` runs in tests/previews. The one-shot `requestCurrentLocation()` returns `nil` rather than throwing when no fix is available. -- **Manual entries carry a `ManualEntryAudit`**; `DayJournal`'s write methods +- **Manual entries carry a `ManualEntryAudit`.** `DayJournal`'s write methods take an explicit `audit:` (no default). An additive backfill can't downgrade - an authoritative row's regions, but the newer audit always wins. + an authoritative row's regions. The newer audit always wins. - **`WhereServices.recentActivity`** (the on-demand Foundation Models summarizer, behind `ActivitySummaryGenerating`) is distinct from - `WhereServices.summary` (the daily notification recap); model unavailability - surfaces as a typed reason, never a silent empty summary. + `WhereServices.summary` (the daily notification recap). Model unavailability + surfaces as a typed reason. Never use a silent empty summary. ## Spans -Anything plausibly expensive is measured — `logger.measure(.name, budget:)` on -the owning type's `*Log` — so the [Periscope](../Shared/Periscope) span history -can say which work is slow on a real device rather than only that a screen felt -slow. +Anything plausibly expensive is measured. Use `logger.measure(.name, budget:)` +on the owning type's `*Log`. Then the [Periscope](../Shared/Periscope) span +history can say which work is slow on a real device. It does not only say that +a screen felt slow. -- **Names are a typed `enum SpanName`** nested on the `*Log`, never a raw - string. When a name carries a value, give it `CustomStringConvertible` so the - history buckets by something readable — `step(resolve-scope)`, +- **Names are a typed `enum SpanName`** nested on the `*Log`. Never use a raw + string. When a name carries a value, give it `CustomStringConvertible`. Then + the history buckets by something readable — `step(resolve-scope)`, `loadRegion(us-CA)`, `detect(border-drift)` — not the Swift case's shape. - **The budget is the promise, and it lives next to the work.** Overrunning it - emits a `SpanOverdue` warning while the span keeps running, so a budget is a - claim about this specific call ("a widget publish shouldn't take 2s"), not a - timeout. Omit it only where no ceiling is meaningful — user-driven backup - export/import, which scales with the archive. + emits a `SpanOverdue` warning while the span keeps running. A budget is a + claim about this specific call ("a widget publish must not take 2s"). It is + not a timeout. Omit it only where no ceiling is meaningful — user-driven + backup export/import, which scales with the archive. - **Launch and reset steps declare a budget, not a `measure` call.** Every step - in `WhereLaunch`'s plans conforms to `BudgetedLaunchStep` and joins the plan - through `.measured()`, which wraps it in `MeasuredStep` — so a new step is - spanned by declaring `budget`, and `MeasuredStep` pointedly isn't itself - budgeted, so nothing can be measured twice into nested duplicate spans. Gates - are exempt: the onboarding gate parks on the user, so it has nothing to - promise. + in `WhereLaunch`'s plans conforms to `BudgetedLaunchStep`. It joins the plan + through `.measured()`, which wraps it in `MeasuredStep`. A new step is + spanned by declaring `budget`. `MeasuredStep` pointedly isn't itself + budgeted. Nothing can be measured twice into nested duplicate spans. Gates + are exempt. The onboarding gate parks on the user. It has nothing to promise. - **Span the work, not the property.** Composite orchestration that reflects user-perceived latency is worth a span even when its callees have their own (`WhereSession.appBecameActive`, `YearReportModel.refreshAll`, an intent's - `perform`). A SwiftUI computed property re-evaluated per `body` pass is not: - it would emit continuously and bury the real signal. -- **A type that needs spans but has no events** gets a span-only facade: a - `struct` conforming to `LogEvent` with a `private init` and an empty `message` - (`ReportReaderLog`, `DataIssueScannerLog`, `PresenceCalendarLog`). It names - spans without inventing an event nobody emits. + `perform`). A SwiftUI computed property re-evaluated per `body` pass is not. + It would emit continuously and bury the real signal. +- **If a type needs spans but has no events,** give it a span-only facade. Use + a `struct` conforming to `LogEvent` with a `private init` and an empty + `message` (`ReportReaderLog`, `DataIssueScannerLog`, `PresenceCalendarLog`). + It names spans without inventing an event nobody emits. - **Spans emitted before a scope's durable store attaches are - half-persisted.** A `SpanBegan` from the pre-sink window is only in OSLog; - the `SpanEnded` lands in the store, so durations survive but the pair - doesn't. That gap is Periscope's to close (P0 in its - [`TODOs.md`](../Shared/Periscope/TODOs.md)) — don't work around it here. + half-persisted.** A `SpanBegan` from the pre-sink window is only in OSLog. + The `SpanEnded` lands in the store. Durations survive but the pair doesn't. + That gap is Periscope's to close (P0 in its + [`TODOs.md`](../Shared/Periscope/TODOs.md)). Do not work around it here. ## Scopes and the launch -- **A `WhereScope` is what the app is logged in *to*** — one open store's +- **A `WhereScope` is what the app is logged in *to*.** It is one open store's `WhereServices`, the `WherePreferences` driving it, and the durable log store - they record into. Created whole; `WhereSession` is built from one, so a + they record into. It is created whole. `WhereSession` is built from one. A surface can't read one world's store against another's preferences. - **Nothing opens until the user picks a world.** The trunk is rooted at the - onboarding gate, so an install that never onboards creates no store file, + onboarding gate. An install that never onboards creates no store file, contacts no CloudKit, and opens no log store. Guard: `WhereLaunchTests.firstRunForegroundLaunchParksOnTheOnboardingGateBeforeOpeningAnything`. - **At most one scope is active and log-routing at a time.** Logging out — a - reset, or leaving a demo — releases and tears down the scope; logging back in - builds a fresh one. Flyover is the narrow exception to "one open world": it + reset, or leaving a demo — releases and tears down the scope. Logging back in + builds a fresh one. Flyover is the narrow exception to "one open world". It may retain one separately built, in-memory demo scope beside the active app - scope, but never activates or log-routes it and never opens a second copy of + scope. It never activates or log-routes it. It never opens a second copy of the real store. Guards: `WhereResetTests.loggingOutReleasesTheScopeBeforeTheNextLoginOpensOne`. `WhereFlyoverWorldTests.buildsASeededSiblingWithoutActivatingIt`. - **The onboarding gate declares `modes: .all`,** not the `.foreground` - default: parking a headless launch is the point. A background wake needs the - permission this flow asks for, so `isNeeded` is false by then. -- **A gate carries no value,** so a choice made *at* it reaches `resolve-scope` - through `WhereModel` — the one step that reads model state rather than the - trunk. -- **Ambient log sources start at process launch; the durable sink is a + default. Parking a headless launch is the point. A background wake needs the + permission this flow asks for. By then `isNeeded` is false. +- **A gate carries no value.** A choice made *at* it reaches `resolve-scope` + through `WhereModel`. That is the one step that reads model state rather than + the trunk. +- **Ambient log sources start at process launch. The durable sink is a scope's.** Records emitted before a scope exists reach OSLog only. - **Publish durable-log bring-up through `WhereModel.logStoreState`.** The - active scope owns the store, while the process model mirrors opening, ready, + active scope owns the store. The process model mirrors opening, ready, unavailable, and failed states for the DEBUG developer surface. Guards: `WhereModelTests`. ### Demo mode -- **Demo mode is a second scope, not a flag** — in-memory store seeded by - `DemoDataBuilder`, in-memory preferences and log store, noop schedulers, - outbox, and widget refresher. Entered from the onboarding intro, left from the - first block of Settings (`WhereLaunch.exitDemoPlan`); quitting mid-demo needs - no teardown. +- **Demo mode is a second scope, not a flag.** It uses an in-memory store + seeded by `DemoDataBuilder`, in-memory preferences and log store, noop + schedulers, outbox, and widget refresher. Enter from the onboarding intro. + Leave from the first block of Settings (`WhereLaunch.exitDemoPlan`). Quitting + mid-demo needs no teardown. - **A demo leaves no mark on the device.** Anything that writes outside its own store is injected as a no-op or skipped at the call site (Spotlight indexing - in `AppDelegate`), and Settings hides the groups that would reach past it + in `AppDelegate`). Settings hides the groups that would reach past it (`SettingsDestination.isAvailableInDemoMode`). A new persisting surface needs the same treatment. - **`WhereModel` decides when a scope routes its logs.** A scope holds its log - store from birth and routes only while active, so one that opens while - shadowed is remembered rather than attached. Guard: + store from birth and routes only while active. One that opens while shadowed + is remembered rather than attached. Guard: `DemoModeTests.aLogStoreOpeningLateNeverAttachesToAShadowedScope`. - **Flyover builds but never activates its demo scope.** Its frames share that - one in-memory world while the real app keeps its current scope; dismissing + one in-memory world while the real app keeps its current scope. Dismissing Flyover releases the sibling. The process-global `WhereLog` facade remains a known exception tracked in [`TODOs.md`](TODOs.md). -- **The logging system is injected, not global** — `WhereModel.logSystem` has no - default, so a test can't silently attach sinks to `Periscope.shared`. (The - `WhereLog` facade still emits into `.shared`; pre-existing.) -- **Demo mode asks for no permission and presents a granted user** — the - scripted location source reports `.always`, and the noop schedulers are built - `authorized: true` so no surface nags about a permission the demo can't - obtain. Guard: `DemoModeTests.demoPresentsAFullyGrantedUser`. -- **Views branch on `\.isInDemoMode`,** seeded once at `RootView` via +- **The logging system is injected, not global.** `WhereModel.logSystem` has no + default. A test can't silently attach sinks to `Periscope.shared`. (The + `WhereLog` facade still emits into `.shared`. Pre-existing.) +- **Demo mode asks for no permission and presents a granted user.** The + scripted location source reports `.always`. The noop schedulers are built + `authorized: true`. No surface nags about a permission the demo can't obtain. + Guard: `DemoModeTests.demoPresentsAFullyGrantedUser`. +- **Views branch on `\.isInDemoMode`.** Seed once at `RootView` via `demoMode(of:)`. Guard: `DemoModeEnvironmentTests`. -- App Intents answer from the demo store while it is active: process-scoped and - self-correcting on exit, accepted rather than special-cased (#150). +- App Intents answer from the demo store while it is active. That is + process-scoped and self-correcting on exit. It is accepted rather than + special-cased (#150). ## Navigation -The logged-in shell is `MainTabs` — **three fixed tabs**: Locations, Your -Year, Settings; everything else hangs off one of them. A new screen is a -pushed destination, a sheet, or a Settings row inside that shape — a fourth +The logged-in shell is `MainTabs`. It has **three fixed tabs**: Locations, Your +Year, Settings. Everything else hangs off one of them. A new screen is a +pushed destination, a sheet, or a Settings row inside that shape. A fourth tab is a product decision to raise before building. `MainTabs` passes the -scene-scoped `YearReportModel` by explicit init injection; the always-on +scene-scoped `YearReportModel` by explicit init injection. The always-on `WhereSession` coordinator travels in the environment. Settings is a -typed-route list (`SettingsSearch.swift`; every switch is exhaustive), so a -new drill-in is a set of compile errors to fill in; About stays the last -block and the demo-mode exit the first. - -The About screen renders three live sources — the generated attribution -report (`WhereCore.AppAttribution`), `RegionDataSource`, and `BuildInfo` — -never a list hard-coded in the view. A missing report or unstamped build -renders an honest empty state, and shipped libraries stay a separate section +typed-route list (`SettingsSearch.swift`. Every switch is exhaustive). A +new drill-in is a set of compile errors to fill in. About stays the last +block. The demo-mode exit is the first. + +The About screen renders three live sources. They are the generated attribution +report (`WhereCore.AppAttribution`), `RegionDataSource`, and `BuildInfo`. +Never hard-code a list in the view. A missing report or unstamped build +renders an honest empty state. Shipped libraries stay a separate section from development tools. Design and rationale: PR #140. ## Localization All user-facing copy resolves through each module's `Localizable.xcstrings` -via Xcode's generated `LocalizedStringResource` symbols, so a typo'd or -removed key is a compile error. Add a key as a **manual** entry first (so its -symbol generates), then reference `.thatSymbol` — never a raw +via Xcode's generated `LocalizedStringResource` symbols. A typo'd or removed +key is a compile error. Add a key as a **manual** entry first (so its symbol +generates). Then reference `.thatSymbol`. Never use a raw `String(localized: "literal.key")`, a hand-maintained key facade, or an English literal in `Text` / `errorDescription`. -- **WhereUI:** reference symbols directly; composition, pluralization, and +- **WhereUI:** reference symbols directly. Composition, pluralization, and number/coordinate formatting go through [`WhereFormat`](WhereUI/Sources/Shared/WhereFormat.swift). - **RegionKit:** region names resolve dynamically from `regions.json` - (+ optional `localizationKey`) — the one deliberate exception to static + (+ optional `localizationKey`). That is the one deliberate exception to static symbols (see [`RegionKit/AGENTS.md`](RegionKit/AGENTS.md)). - **Extensions** use their own generated symbols for chrome and WhereUI's public helpers for shared copy. **DEBUG-only UI** is still localized. - The catalogs carry a few value-less **auto-extracted** entries (`""`, - `%lld`): Xcode's, not ours — an IDE build re-adds a deleted one, so remove + `%lld`). They are Xcode's, not ours. An IDE build re-adds a deleted one. Remove the *source* literal instead. Catalogs stay byte-identical to Xcode's own serialization (root [Formatting](../AGENTS.md#formatting)). ## Dates & presentation -- **A logical day is a `CalendarDay` (Y-M-D), not a `Date`** — see +- **A logical day is a `CalendarDay` (Y-M-D), not a `Date`.** See [`WhereCore/AGENTS.md`](WhereCore/AGENTS.md). Never persist a day as an absolute instant. -- **Year bounds are half-open; day ranges are inclusive** +- **Year bounds are half-open. Day ranges are inclusive** (`Date.calendarDays(through:in:)`, `CalendarDay.days(through:)`). -- **The app is Gregorian-only: never `Calendar.current`** — a non-Gregorian +- **The app is Gregorian-only. Never use `Calendar.current`.** A non-Gregorian device calendar silently mismatches the stored reports. Use the calendar the owning type vends, or a fresh `Calendar(identifier: .gregorian)` with the current time zone (see `Calendar.whereIntents`). -- **Inject `Calendar`, don't reach for globals**; prefer calendar APIs over +- **Inject `Calendar`. Do not reach for globals.** Prefer calendar APIs over hardcoded day/weekday counts (`Calendar.dayCount(ofYear:)`). -- **Core layout APIs throw on failure**; views surface - `ContentUnavailableView` + log, never `!`. +- **Core layout APIs throw on failure.** Views surface + `ContentUnavailableView` + log. Never use `!`. - Appearance tokens live in `WhereStylesheet` - ([`WhereUI/AGENTS.md`](WhereUI/AGENTS.md)); shared date-range copy in - `DateRangeFormatting`; numbers and dates use `FormatStyle`, not string + ([`WhereUI/AGENTS.md`](WhereUI/AGENTS.md)). Shared date-range copy lives in + `DateRangeFormatting`. Numbers and dates use `FormatStyle`, not string interpolation. Expensive layout computes once into state, not per `body` pass. Sharing uses `ShareLink` / `Transferable`. ## SwiftUI views & previews Every previewable component in `WhereUI` (any `View`, `Widget`, or -`WidgetBundle`) **must** ship at least one `#Preview` in the same file, -wrapped in `#if DEBUG` at the bottom, built from -[`PreviewSupport`](WhereUI/Sources/Preview/PreviewSupport.swift) fixtures — -synchronous, in-memory, never disk/CloudKit/CoreLocation. Cover empty, -loaded, and distinct edge states, not just the happy path. - -- **Animate transitions between distinct states** — `.transition` on each - `switch` arm plus `.animation(_:value:)`; hidden means *out of the tree*, +`WidgetBundle`) **must** ship at least one `#Preview` in the same file. +Wrap it in `#if DEBUG` at the bottom. Build from +[`PreviewSupport`](WhereUI/Sources/Preview/PreviewSupport.swift) fixtures. +Use synchronous, in-memory fixtures. Never use disk/CloudKit/CoreLocation. +Cover empty, loaded, and distinct edge states, not just the happy path. + +- **Animate transitions between distinct states.** Use `.transition` on each + `switch` arm plus `.animation(_:value:)`. Hidden means *out of the tree*, not opacity zero. -- **A displayed value that can change under the user morphs, too** — a +- **If a displayed value can change under the user, it morphs too.** A `.contentTransition` needs a paired `.animation(_:value:)` or it silently - hard-cuts, and the transition and its animation are one stylesheet token + hard-cuts. The transition and its animation are one stylesheet token (see `CardStyles.DayCountStyle`). -- **Derive UI dimensions; don't repeat them** — measure real chrome via a - preference key / `onGeometryChange` (see `DeveloperTabBarInset`), scale - controls with `@ScaledMetric`, prefer semantic font styles. -- **Custom full-screen surfaces must work under VoiceOver** — the `.isModal` +- **Derive UI dimensions. Do not repeat them.** Measure real chrome via a + preference key / `onGeometryChange` (see `DeveloperTabBarInset`). Scale + controls with `@ScaledMetric`. Prefer semantic font styles. +- **Custom full-screen surfaces must work under VoiceOver.** Use the `.isModal` trait plus `.screenChanged` across the modal boundary (see `DeveloperOverlay`). ## Adding things -- **New library target:** root [`Package.swift`](../Package.swift) under - `Where//Sources`, plus a hosted test bundle via `Project.swift`'s +- **New library target:** add it in root [`Package.swift`](../Package.swift) under + `Where//Sources`. Add a hosted test bundle via `Project.swift`'s `unitTests` helper. -- **New region:** pure data — no `Region` case, no code; see +- **New region:** pure data. No `Region` case, no code. See [`RegionKit/README.md`](RegionKit/README.md#adding-a-region). -- **New evidence kind / sample source:** add the case and follow the compile +- **New evidence kind / sample source:** add the case. Follow the compile errors through the exhaustive switches. -- **New app icon:** `./icons --add` (root - [`AGENTS.md`](../AGENTS.md#managing-app-icons)) — never hand-edit the +- **New app icon:** run `./icons --add` (root + [`AGENTS.md`](../AGENTS.md#managing-app-icons)). Never hand-edit the catalogs or manifest. ## Installing to a device `./Where/install` builds, signs, and installs the app onto a connected iPhone -from the CLI — macOS-only, one-time `./ide --team-id ` setup. It defaults -to Debug with compiler optimizations forced on, so DEBUG-only developer +from the CLI. It is macOS-only. It needs one-time `./ide --team-id ` setup. +It defaults to Debug with compiler optimizations forced on. DEBUG-only developer surfaces survive at near-Release speed. Options: `./Where/install --help`. ## Testing -Root [testing conventions](../AGENTS.md#testing) apply. What's specific here: +Root [testing conventions](../AGENTS.md#testing) apply. What is specific here: - Test bundles run in `StuffTestHost` via the `unitTests` helper in - `Project.swift` and link `TestHostSupport` (`show(_:perform:)`, `waitFor`). -- Use `ScriptedLocationSource` and `SwiftDataStore.inMemory()` — never + `Project.swift`. They link `TestHostSupport` (`show(_:perform:)`, `waitFor`). +- Use `ScriptedLocationSource` and `SwiftDataStore.inMemory()`. Never use `CoreLocationSource` or the user's on-disk/CloudKit store. The CloudKit remote-import path uses the `@_spi(Testing)` `inMemory(remoteChangeSource:)` + `ScriptedStoreRemoteChangeSource`. - How screens render is pinned by the image snapshots in `WhereUI/SnapshotTests/` (the `WhereUISnapshotTests` bundle, run from the - shared `StuffSnapshotTests` scheme + CI job, not `Stuff-iOS-Tests`) — see - [`WhereUI/AGENTS.md`](WhereUI/AGENTS.md#testing). Don't add "hosts without + shared `StuffSnapshotTests` scheme + CI job, not `Stuff-iOS-Tests`). See + [`WhereUI/AGENTS.md`](WhereUI/AGENTS.md#testing). Do not add "hosts without crashing" smoke tests for surfaces those suites cover. diff --git a/Where/RegionKit/AGENTS.md b/Where/RegionKit/AGENTS.md index b3e629cb..c510839f 100644 --- a/Where/RegionKit/AGENTS.md +++ b/Where/RegionKit/AGENTS.md @@ -1,8 +1,8 @@ # RegionKit – Module Shape -RegionKit is the geometry and region-lookup engine for the Where feature: -coordinate-to-`Region` attribution over bundled GeoJSON polygons, plus the -geometry primitives and the developer-viewer geometry catalog. See +RegionKit is the geometry and region-lookup engine for the Where feature. It +maps coordinates to `Region` attribution over bundled GeoJSON polygons. It also +provides geometry primitives and the developer-viewer geometry catalog. See [`README.md`](README.md) for the public API and usage. This file complements the root [`AGENTS.md`](../../AGENTS.md) and the feature @@ -12,61 +12,62 @@ This file complements the root [`AGENTS.md`](../../AGENTS.md) and the feature - **Pure Swift + Foundation**, plus [`PeriscopeCore`](../../Shared/Periscope/PeriscopeCore) for logging. It must - **not** import SwiftUI, UIKit, SwiftData, CoreLocation, or `WhereCore` — it is - the lowest layer of the feature, and `WhereCore` depends on *it*, never the + **not** import SwiftUI, UIKit, SwiftData, CoreLocation, or `WhereCore`. It is + the lowest layer of the feature. `WhereCore` depends on *it*, never the reverse. - Library target in [`Package.swift`](../../Package.swift) (`Where/RegionKit/Sources`). The generated catalog manifest + per-region - polygons and the region-name string catalog ship in `Sources/Resources/`; the + polygons and the region-name string catalog ship in `Sources/Resources/`. The (non-bundled) source geometry lives in `Tools/source/`. ## Invariants - **`Region` is a data-driven value type, not a hardcoded enum.** It wraps a - stable `rawValue` id; the set of available regions and their metadata live in + stable `rawValue` id. The set of available regions and their metadata live in the bundled `regions.json` manifest, read by `RegionCatalog`. Adding a region - is a data change (regenerate via `Tools/generate-regions.rb`), never a new case - — see [README](README.md#adding-a-region). `regions/` + `regions.json` are - generated; never hand-edit them. + is a data change (regenerate via `Tools/generate-regions.rb`), never a new + case — see [README](README.md#adding-a-region). `regions/` + `regions.json` + are generated. Never hand-edit them. - **The catalog's canonical order (`RegionCatalog.all`, hence `Region.allCases` - = catalog order then `.other`) fixes attribution priority** — an attributor - checks its regions in order and the first polygon match wins (regions are - mutually exclusive at our resolution). (Day-count ranking lives in `WhereCore`'s - `Region+Ordering`, not here.) + = catalog order then `.other`) fixes attribution priority.** An attributor + checks its regions in order. The first polygon match wins (regions are + mutually exclusive at our resolution). (Day-count ranking lives in + `WhereCore`'s `Region+Ordering`, not here.) - **Attribution is per-region, on demand.** `RegionAttributor(for:)` loads only - the passed regions' `regions/.geojson` files, so the app parses only the - tracked set — never the whole US at launch. `.all` loads the whole catalog - (dev viewer/tests); `.shared` the default four. It's UI-free: `BoundingBox` / - `LongitudeSpan` expose the min/max math, but MapKit conversion lives in the UI - layer. `RegionAttributing` lets `WhereCore` supply a live, swappable attributor. -- **Bundled geometry is credited in code, not only in prose.** `RegionDataSource` - states each boundary set's origin, license, and fidelity, and derives its - coverage from the catalog — the US sources by the `us-` id prefix the generator - mints, everything else by an explicit id list, deliberately *not* an + the passed regions' `regions/.geojson` files. The app parses only the + tracked set. Never parse the whole US at launch. `.all` loads the whole + catalog (dev viewer/tests). `.shared` loads the default four. It's UI-free. + `BoundingBox` / `LongitudeSpan` expose the min/max math. MapKit conversion + lives in the UI layer. `RegionAttributing` lets `WhereCore` supply a live, + swappable attributor. +- **Credit bundled geometry in code, not only in prose.** `RegionDataSource` + states each boundary set's origin, license, and fidelity. It derives its + coverage from the catalog. The US sources use the `us-` id prefix the generator + mints. Everything else uses an explicit id list. Deliberately *not* an "everything else" fallback that would silently mis-credit a new region. - `RegionDataSourceTests` fails when a region is covered zero times or twice, so - regenerating the catalog can't ship uncredited data. Keep it in step with the + `RegionDataSourceTests` fails when a region is covered zero times or twice. + Regenerating the catalog can't ship uncredited data. Keep it in step with the [README](README.md#source-data-not-bundled) provenance notes. - **Region names are manifest data (a documented trade-off).** `localizedName` resolves a manifest entry's optional `localizationKey` from the string catalog, - else the manifest's English `name` — so dynamic ids cost static string-catalog + else the manifest's English `name`. Dynamic ids cost static string-catalog extraction for region names. -- **Missing/corrupt bundled geometry (or manifest) is a programmer error** — the - loader logs a `fault` via `RegionLog` *and* `assertionFailure`s (debug), - degrading to `.other`/an empty catalog in release rather than crashing. -- **Logging goes through `RegionLog`**, RegionKit's own `"RegionKit"` root - scope — never `WhereLog`, which it can't see — emitted into the shared - `Periscope.shared` so the app's sink still captures it. The bundled-data loads - are spanned against a budget (the manifest decode, the whole polygon load, and - each region's geometry separately as `loadRegion(us-CA)`), because one region +- **Missing or corrupt bundled geometry (or manifest) is a programmer error.** + The loader logs a `fault` via `RegionLog` *and* `assertionFailure`s (debug). + In release it degrades to `.other`/an empty catalog rather than crashing. +- **Logging goes through `RegionLog`.** That is RegionKit's own `"RegionKit"` + root scope. Never use `WhereLog`, which it can't see. Emit into the shared + `Periscope.shared` so the app's sink still captures it. Span the bundled-data + loads against a budget (the manifest decode, the whole polygon load, and + each region's geometry separately as `loadRegion(us-CA)`). One region with heavy geometry is otherwise invisible inside a slow attributor build. -- **Object identities are `region://` URLs** — `RegionURL` (RegionKit's local +- **Object identities are `region://` URLs.** `RegionURL` (RegionKit's local analog of WhereCore's `StoreURL`) builds/parses `region:///` - URLs, and `Region.regionURL` vends `region://regions/`. Used to key a + URLs. `Region.regionURL` vends `region://regions/`. Used to key a `LogEvent.externalID` (see `RegionAttributorLog`) so inspect-by-object works - without RegionKit reaching up into the app's `store://` scheme — a separate, - intentionally parallel namespace. Distinct from `Region`'s bare-`rawValue` - `Codable`, which stays the persisted form. + without RegionKit reaching up into the app's `store://` scheme. That is a + separate, intentionally parallel namespace. Distinct from `Region`'s + bare-`rawValue` `Codable`, which stays the persisted form. ## Testing diff --git a/Where/RegionKit/README.md b/Where/RegionKit/README.md index c1575ba5..c94c3175 100644 --- a/Where/RegionKit/README.md +++ b/Where/RegionKit/README.md @@ -6,7 +6,7 @@ GeoJSON polygons loaded **on demand, per region**. It is pure Swift + Foundation (no SwiftUI, UIKit, SwiftData, or CoreLocation), so it can be reused and unit-tested in isolation. -RegionKit is the lowest layer of the Where feature: `WhereCore` (and, through +RegionKit is the lowest layer of the Where feature. `WhereCore` (and, through it, `WhereUI`, the widgets, and the RegionViewer) depend on RegionKit and call into it for lookup. RegionKit depends only on [`PeriscopeCore`](../../Shared/Periscope/PeriscopeCore) for logging. @@ -130,7 +130,7 @@ what the app credits on its About screen; keep the two in step. works are public domain (17 U.S.C. § 105); attribution requested (see the repo `README.md`). - **`canada.geojson` / `europeanUnion.geojson`** — hand-simplified outlines, - deliberately coarse (fine for `RegionAttributorTests` spot-checks; should be + deliberately coarse (fine for `RegionAttributorTests` spot-checks; must be replaced with higher-fidelity public-domain sources before any production residency-audit use). diff --git a/Where/RegionViewer/AGENTS.md b/Where/RegionViewer/AGENTS.md index 08f71e4a..8c19f388 100644 --- a/Where/RegionViewer/AGENTS.md +++ b/Where/RegionViewer/AGENTS.md @@ -1,8 +1,8 @@ # RegionViewer – Module Shape -A thin standalone **Mac Catalyst** (and iOS) app hosting the WhereUI -`RegionMapView` developer tool for inspecting bundled region geometry. See -[`README.md`](README.md) for what it shows and how to run it. +RegionViewer is a thin standalone **Mac Catalyst** (and iOS) app. It hosts the +WhereUI `RegionMapView` developer tool for inspecting bundled region geometry. +See [`README.md`](README.md) for what it shows and how to run it. This file complements the root [`AGENTS.md`](../../AGENTS.md) and the feature [`Where/AGENTS.md`](../AGENTS.md). Read those first. @@ -12,13 +12,13 @@ This file complements the root [`AGENTS.md`](../../AGENTS.md) and the feature - **Tuist app target** (bundle ID `com.stuff.regionviewer`), depending on **WhereUI**, **WhereCore**, and **RegionKit** (geometry + GeoJSON, whose resource bundle is embedded for `RegionGeometryCatalog`). The `@main` - body is `WindowGroup { NavigationStack { RegionMapView() } }` — that's the + body is `WindowGroup { NavigationStack { RegionMapView() } }`. That is the whole target. - **Shell only, session-less.** No domain logic, SwiftData, App Group, or - `WhereSession` here; `RegionMapView` is self-contained on purpose. If a + `WhereSession` here. `RegionMapView` is self-contained on purpose. If a feature needs more, add it in `WhereUI`/`WhereCore`. -- **The repo's only Catalyst target** — keep it buildable for `ios-macabi` - (`tuist build RegionViewer` on macOS verifies). -- No test bundle; the geometry catalog is covered by `RegionKitTests` - (`RegionGeometryCatalogTests`), and `RegionMapView` by WhereUI's snapshot - bundle (`WhereUISnapshotTests`). +- **This is the repo's only Catalyst target.** Keep it buildable for + `ios-macabi` (`tuist build RegionViewer` on macOS verifies). +- No test bundle. The geometry catalog is covered by `RegionKitTests` + (`RegionGeometryCatalogTests`). `RegionMapView` is covered by WhereUI's + snapshot bundle (`WhereUISnapshotTests`). diff --git a/Where/Where/AGENTS.md b/Where/Where/AGENTS.md index 06f4ce41..7259263e 100644 --- a/Where/Where/AGENTS.md +++ b/Where/Where/AGENTS.md @@ -1,85 +1,85 @@ # Where (app target) – Module Shape -The **Where** iOS app target: the process's composition root and nothing else. -`AppDelegate` selects one process-lifetime `WhereApplicationRuntime`; -`RegularApplicationRuntime` owns the shipping stack and the DEBUG-only +The **Where** iOS app target is the process's composition root and nothing else. +`AppDelegate` selects one process-lifetime `WhereApplicationRuntime`. +`RegularApplicationRuntime` owns the shipping stack. The DEBUG-only `WhereInspectorApplicationRuntime` owns the alternate Inspector stack. See [`README.md`](README.md). This file complements the root [`AGENTS.md`](../../AGENTS.md) and the feature -[`Where/AGENTS.md`](../AGENTS.md) — read those first; they own build/format, +[`Where/AGENTS.md`](../AGENTS.md). Read those first. They own build/format, layering, and the domain rules this target merely starts up. ## Scope -- **Keep it tiny.** Domain behavior goes in `WhereCore`, presentation in +- **Keep it tiny.** Domain behavior goes in `WhereCore`. Presentation goes in `WhereUI`. If a change here is more than wiring, it belongs in a module. The target is a Tuist `.app` ([`Project.swift`](../../Project.swift), bundle ID - `com.stuff.where`), and its Info.plist keys, entitlements, and build settings - live in that manifest — there is no checked-in plist to edit. + `com.stuff.where`). Its Info.plist keys, entitlements, and build settings + live in that manifest. There is no checked-in plist to edit. - `Scripts/` holds this target's build-phase scripts, not dev commands (those are the repo-root executables). Today that is - [`stamp-build-info.sh`](Scripts/stamp-build-info.sh), which stamps the commit - and the Swift compiler settings into the built Info.plist — see [Version and + [`stamp-build-info.sh`](Scripts/stamp-build-info.sh). It stamps the commit + and the Swift compiler settings into the built Info.plist. See [Version and build metadata](../../AGENTS.md#version-and-build-metadata) for the constraints on it. - `Resources/AppIcon.xcassets` is managed by `./icons` (see the root - [`AGENTS.md`](../../AGENTS.md#managing-app-icons)) — never hand-edit it. -- `Resources/attribution.json` is the app's generated attribution report; + [`AGENTS.md`](../../AGENTS.md#managing-app-icons)). Never hand-edit it. +- `Resources/attribution.json` is the app's generated attribution report. `attribution-sources.json` at this module's root declares where it reads from. Both are `./attribution`'s - ([Attribution](../../AGENTS.md#attribution)) — never hand-edit the report. - Only this bundle carries one, so `AppAttributionTests` lives in this + ([Attribution](../../AGENTS.md#attribution)). Never hand-edit the report. + Only this bundle carries one. `AppAttributionTests` lives in this target's test bundle (the one hosted by `Where.app`, where `Bundle.main` is - the shipping bundle); `./attribution --check` in CI covers the report still + the shipping bundle). `./attribution --check` in CI covers the report still matching the dependency graph, which no test bundle can see. ## Invariants - **Select exactly one runtime in `AppDelegate.init`.** The delegate and - `WhereApp` forward through `WhereApplicationRuntime`; never add mode switches + `WhereApp` forward through `WhereApplicationRuntime`. Never add mode switches to lifecycle callbacks, `RootView`, or feature code. In DEBUG, finish Inspector's latched store-family recovery before constructing that runtime. - **Release always builds `RegularApplicationRuntime`.** Boot preference reads, Inspector configuration, and menu integration stay under `#if DEBUG`. -- **Regular launch is wired in `didFinishLaunching`, not a SwiftUI `.task`.** When - CoreLocation relaunches the app after termination there is no UI, so a view's - `.task` is not a reliable hook; `didFinishLaunching` always runs. The regular - runtime builds - the `LifecycleRunner` (whose synchronous `initializePrerequisites` installs - the `CLLocationManager` in time to receive the queued event) and hands it to - `RootView` through `WhereApp`. Don't move this wiring into a view. -- **The regular runtime owns exactly one of each shared thing** — one `WhereModel`, one - `IntentServices`, one launcher — created here and injected down, per - [Composition](../../AGENTS.md#composition-create-once-inject-down). The - launch's `resolve-scope` step is the process's only store open and runs - *behind* the onboarding gate, so this target opens nothing at startup; the +- **Wire regular launch in `didFinishLaunching`, not a SwiftUI `.task`.** When + CoreLocation relaunches the app after termination there is no UI. A view's + `.task` is not a reliable hook. `didFinishLaunching` always runs. The regular + runtime builds the `LifecycleRunner` (whose synchronous + `initializePrerequisites` installs the `CLLocationManager` in time to receive + the queued event). It hands it to `RootView` through `WhereApp`. Do not move + this wiring into a view. +- **The regular runtime owns exactly one of each shared thing** — one + `WhereModel`, one `IntentServices`, one launcher. Create them here and inject + down, per [Composition](../../AGENTS.md#composition-create-once-inject-down). + The launch's `resolve-scope` step is the process's only store open. It runs + *behind* the onboarding gate. This target opens nothing at startup. The intents stack derives from whatever scope the launch resolves, in the `onServicesReady` hook. - **Nothing here may assume the user has a store.** `didFinishLaunching` starts - the ambient log sources and drives the launch; anything wanting the user's - data waits for `.ready` and checks what it got — the Spotlight indexing after - `launcher.run()` skips a demo session, whose data must not reach an index that + the ambient log sources and drives the launch. Anything wanting the user's + data waits for `.ready` and checks what it got. The Spotlight indexing after + `launcher.run()` skips a demo session. Demo data must not reach an index that outlives the process. See [Scopes and the launch](../AGENTS.md#scopes-and-the-launch). - **Register the App Intents dependency before anything async.** The `AppDependencyManager.shared.add(...)` call must stay at the top of - `didFinishLaunching` so `@Dependency` always resolves once the system starts + `didFinishLaunching`. Then `@Dependency` always resolves once the system starts delivering intents. - **The app launches `.undetermined`.** Under the UIScene lifecycle - `applicationState` reads `.background` here even for a user tap, so the - reason stays honest until `RootView`'s `enterForeground()` promotes it. Don't + `applicationState` reads `.background` here even for a user tap. The reason + stays honest until `RootView`'s `enterForeground()` promotes it. Do not substitute a guessed `.background(cause)` or `.userForeground`. -- **`WhereShortcuts` lives here on purpose** — App Intents metadata extraction - discovers phrases reliably from the main bundle, which is why the provider +- **`WhereShortcuts` lives here on purpose.** App Intents metadata extraction + discovers phrases reliably from the main bundle. That is why the provider isn't in `WhereIntents` (whose types are `public` so this file can reference them). Every phrase must contain `\(.applicationName)`. ## Testing `WhereTests` is the one bundle hosted by the **Where app itself** rather than -`StuffTestHost`, so the host's own launch has already run — including the +`StuffTestHost`. The host's own launch has already run, including the intent-services registration. Inject runtime spies without launching a second regular runtime. Tests may construct an `AppDelegate(runtime:)` only with such -a spy; a second `RegularApplicationRuntime.didFinishLaunching` would +a spy. A second `RegularApplicationRuntime.didFinishLaunching` would re-register the handoff, whose behavior is undocumented. diff --git a/Where/Where/README.md b/Where/Where/README.md index 3ab663bc..280c6bca 100644 --- a/Where/Where/README.md +++ b/Where/Where/README.md @@ -1,6 +1,6 @@ # Where (app target) -The iOS/iPadOS app bundle for **Where**. It is deliberately a shell: it starts +The iOS/iPadOS app bundle for **Where**. It is deliberately a shell. It starts the process, builds the objects everything else shares, and shows `WhereUI`'s `RootView`. All the behavior lives in the modules below it — [`WhereCore`](../WhereCore) (domain, persistence, GPS), @@ -30,7 +30,7 @@ target, see [`AGENTS.md`](AGENTS.md). ## Launch, briefly `AppDelegate.init` makes one boot-time selection. In release this is always -`RegularApplicationRuntime`; in DEBUG a dedicated UserDefaults suite can select +`RegularApplicationRuntime`. In DEBUG a dedicated UserDefaults suite can select `WhereInspectorApplicationRuntime` for the next process. Every later callback and root-view request uses protocol dispatch, so no feature or lifecycle code switches on a mode. Before that selection, DEBUG boot completes any store-family @@ -44,7 +44,7 @@ callback is guaranteed to run. It registers the App Intents dependency, starts logging, and builds a [`LifecycleKit`](../../Shared/LifecycleKit) runner with the reason `.undetermined`, since the UIScene lifecycle can't yet distinguish a user tap from a headless wake. The runner drives the background-safe launch -steps immediately and builds no view tree; when a scene actually activates, +steps immediately and builds no view tree. When a scene actually activates, `RootView` promotes the launch to `.userForeground` and the remaining steps run. The Inspector runtime returns its standalone `InspectorView` and starts none of @@ -57,7 +57,7 @@ with its error and a confirmed action that deletes only its configured store family and Periscope crash-journal directory before removing the source from the current Inspector session and scheduling one pre-runtime cleanup pass for the next process. Its exit control selects the regular runtime for the next manual -relaunch; neither runtime swaps live. +relaunch. Neither runtime swaps live. ## Build & run diff --git a/Where/Where/Resources/attribution.json b/Where/Where/Resources/attribution.json index 99064336..01033f11 100644 --- a/Where/Where/Resources/attribution.json +++ b/Where/Where/Resources/attribution.json @@ -30,6 +30,16 @@ "text": "MIT License\n\nCopyright (c) 2019 Point-Free, Inc.\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n" } }, + { + "name": "simple-english", + "kind": "developmentTool", + "version": "379728b51981", + "homepageURL": "https://github.com/AminBlg/SimpleEnglish", + "license": { + "name": "MIT License", + "text": "MIT License\n\nCopyright (c) 2026 AminBlg\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n" + } + }, { "name": "swift-concurrency-pro", "kind": "developmentTool", diff --git a/Where/WhereCore/AGENTS.md b/Where/WhereCore/AGENTS.md index 0357a0d3..dd1f898d 100644 --- a/Where/WhereCore/AGENTS.md +++ b/Where/WhereCore/AGENTS.md @@ -1,116 +1,117 @@ # WhereCore – Module Shape -WhereCore is the domain layer of the Where feature: the persistence boundary, -GPS ingestion, per-day / per-year aggregation, data-quality detection, and -the side effects that hang off a committed write. It is assembled behind one -`Sendable` value — `WhereServices` — that the UI and the App Intents stack -talk to (widgets never do; they read the published `WidgetSnapshot` from the -App Group). See [`README.md`](README.md) for the public API and collaborators. +WhereCore is the domain layer of the Where feature. It owns the persistence +boundary, GPS ingestion, per-day / per-year aggregation, data-quality +detection, and the side effects that hang off a committed write. It is +assembled behind one `Sendable` value — `WhereServices`. The UI and the App +Intents stack talk to it. Widgets never do. They read the published +`WidgetSnapshot` from the App Group. See [`README.md`](README.md) for the +public API and collaborators. The domain/presentation split and the rules WhereCore must uphold live in the -feature [`Where/AGENTS.md`](../AGENTS.md#layering) — read that and the root +feature [`Where/AGENTS.md`](../AGENTS.md#layering). Read that and the root [`AGENTS.md`](../../AGENTS.md) first. This file adds only the module's internal shape. ## Scope & dependencies - Dependencies live in the root [`Package.swift`](../../Package.swift). It - must **not** import SwiftUI or UIKit — if a behavior would still be correct + must **not** import SwiftUI or UIKit. If a behavior would still be correct without SwiftUI, it belongs here, not in `WhereUI`. ## Shape & invariants - **`WhereServices` is the composition root, not a god-object.** It wires focused single-responsibility collaborators (the live list is its - initializer; `README.md` describes them) and owns the one + initializer. `README.md` describes them). It owns the one cross-collaborator operation, `reset()`. Add new behavior to the collaborator it belongs to. - **`WhereStore` is a value-type boundary.** Everything crossing it is a - value, never a SwiftData record; every mutation runs inside - `perform { … }` (the production store traps otherwise), and each committed + value, never a SwiftData record. Every mutation runs inside + `perform { … }` (the production store traps otherwise). Each committed transaction pings `changes()`. Never expose its `ModelContainer` through - `WhereServices`; the separate DEBUG Inspector runtime uses + `WhereServices`. The separate DEBUG Inspector runtime uses `SwiftDataStore.makeContainer`, `inspectorModelTypes`, and `inspectorStoreURL` as its schema/storage adapter. -- **Each process opens its on-disk store once and injects it** — the app's - launch opens it; the App Intents stack shares it via +- **Each process opens its on-disk store once and injects it.** The app's + launch opens it. The App Intents stack shares it via `WhereServices.forIntents(sharingStoreOf:)`. A second container over the same file is how a fresh install once raced the launch into failure (root [Composition](../../AGENTS.md#composition-create-once-inject-down)). - **Primary regions *are* the tracked-region set.** `primaryRegions()` / `setPrimaryRegions(_:)` read/write the same `SDTrackedRegion` rows as - `trackedRegions()` — picking scopes GPS attribution *and* carries each + `trackedRegions()`. Picking scopes GPS attribution *and* carries each region's `RegionAppearance` + pick order. `RegionAppearance` is data - (WhereCore); the token→`Color` mapping is presentation (WhereUI). -- **Backups mirror the persisted model — keep them lossless.** Any persisted - change is reflected end-to-end: add it to `BackupArchive`, write it in - `BackupService.makeArchiveFile`, read it back in - `BackupCoordinator.importBackup` for **both** `.replace` and `.merge`, and - add a round-trip test (`BackupServiceTests` / `BackupCoordinatorTests`). - The archive is strict synthesized `Codable` — no in-code legacy decode; a + (WhereCore). The token→`Color` mapping is presentation (WhereUI). +- **Backups mirror the persisted model. Keep them lossless.** Any persisted + change is reflected end-to-end. Add it to `BackupArchive`. Write it in + `BackupService.makeArchiveFile`. Read it back in + `BackupCoordinator.importBackup` for **both** `.replace` and `.merge`. Add + a round-trip test (`BackupServiceTests` / `BackupCoordinatorTests`). + The archive is strict synthesized `Codable`. No in-code legacy decode. A shape change bumps `BackupArchive.currentFormatVersion` and extends [`../Tools/upgrade-backup.rb`](../Tools/upgrade-backup.rb) instead. - **A logical day is a `CalendarDay`, not a `Date`.** `CalendarDay` (Y-M-D) is the timezone-independent identity every stored user record and day - comparison keys on; persisting a `Date` makes a day drift across time-zone - changes — the residency bug this exists to prevent. Use a `Date` only for - genuine instants (GPS bucketing via `CalendarDay(from:in:)`, grid geometry, - sorting, display), derived via `CalendarDay.startOfDay(in:)`. - **Scope boundary:** only user-asserted records are travel-proof — a GPS + comparison keys on. Persisting a `Date` makes a day drift across time-zone + changes. That is the residency bug this exists to prevent. Use a `Date` only + for genuine instants (GPS bucketing via `CalendarDay(from:in:)`, grid + geometry, sorting, display). Derive via `CalendarDay.startOfDay(in:)`. + **Scope boundary:** only user-asserted records are travel-proof. A GPS sample is bucketed into a `CalendarDay` by the *current* calendar at read - time, so a GPS-derived day (and a dismissed GPS-only issue keyed on it) can + time. A GPS-derived day (and a dismissed GPS-only issue keyed on it) can still shift by one across a time-zone change. Deliberate: "where was I on - this *local* day?" — don't bucket GPS by a fixed home zone. + this *local* day?" Do not bucket GPS by a fixed home zone. - **Composite identity keys are `store://` URLs, not joined strings.** - Conform to `WhereStoreURLCodable` (see `DataIssueID`), building/parsing - with `StoreURL`; families without a dedicated identity type get theirs from + Conform to `WhereStoreURLCodable` (see `DataIssueID`). Build and parse + with `StoreURL`. Families without a dedicated identity type get theirs from `WhereStoreID`. Used to stamp Periscope `LogEvent.externalID`s. - **No in-app data migration or legacy recovery.** `SD….toValue()` reads only the current shape and drops (fault-logs) a row it can't place. The one-time reshape path is backup **export → transform ([`../Tools/upgrade-backup.rb`](../Tools/upgrade-backup.rb)) → - replace-import**. Deliberate pre-release; the durable successor + replace-import**. Deliberate pre-release. The durable successor (per-entity schema versioning) is filed in [`../TODOs.md`](../TODOs.md). -- **Writes await their side effects.** `DayJournal` commits, then awaits the - reminder reconcile + widget publish in sequence, so a reader on the next +- **Writes await their side effects.** `DayJournal` commits. Then it awaits + the reminder reconcile + widget publish in sequence. A reader on the next `changes()` ping never observes a half-applied write. - **Filter persistent-store remote-change notifications by the Where store URL.** Never let another store in the process (notably Periscope) ping - `WhereStore.changes()`; guard: `StoreRemoteChangeSourceTests`. + `WhereStore.changes()`. Guard: `StoreRemoteChangeSourceTests`. - **Post-write reconciliation is defined once.** Every write and import routes through `DayJournal.reconcileAfterDayChange()` (or its widget-less - subset `reconcileIssueState()`) — never copy the fan-out into a new write + subset `reconcileIssueState()`). Never copy the fan-out into a new write path. Cross-collaborator hooks take a single closure wired at the composition root (`BackupCoordinator.onImport`). -- **Detectors read aggregated input; the speed-based one needs raw fixes.** +- **Detectors read aggregated input. The speed-based one needs raw fixes.** `DataIssueInput.daySamples` carries per-day GPS fixes only (`.gpsVisit` / - `.gpsSignificantChange`, sorted) — manual and evidence-implied samples are + `.gpsSignificantChange`, sorted). Manual and evidence-implied samples are excluded so `FlightDayDetector`'s speed math isn't skewed. -- **`LocationSource` abstracts GPS** — `CoreLocationSource` in production, - `ScriptedLocationSource` in tests/previews; `requestCurrentLocation()` - returns `nil`, never throws, and backs +- **`LocationSource` abstracts GPS.** `CoreLocationSource` runs in production. + `ScriptedLocationSource` runs in tests/previews. `requestCurrentLocation()` + returns `nil`, never throws. It backs `LocationIngestor.captureTodayIfNeeded(now:)`. -- **Tracked regions live in the store, not preferences** — one - `SDTrackedRegion` row per region so cross-device edits merge; read as a +- **Tracked regions live in the store, not preferences.** One + `SDTrackedRegion` row per region so cross-device edits merge. Read as a `Set` defaulting to the four. `RegionAttribution` derives the attributor - from them and rebuilds on `changes()`; assemble via the async + from them and rebuilds on `changes()`. Assemble via the async `WhereServices.make(...)` / `forIntents()` so both attribute against the same synced set. `distanceToBoundary` is `nil` outside the tracked set. - **`DemoDataBuilder` seeds through the ordinary write paths** (`DayJournal`, - `setPrimaryRegions`) — no private door into the store, so a demo exercises + `setPrimaryRegions`). No private door into the store. A demo exercises the code a real user does. Its data is sized against the *elapsed* year, not - the calendar; fixed sizes made a January demo mostly-unlogged. Guard: + the calendar. Fixed sizes made a January demo mostly-unlogged. Guard: `DemoDataBuilderTests.holdsItsShapeWhereverInTheYearItIsEntered`. -- **Impossible states trap; recoverable ones surface.** `WhereStore` methods - are `async throws`; a `catch` logs a typed `WhereLog` event (PII-free, - `.public`, error as `LogAttachment.error(_:)`) and leaves state honest — - never a benign-looking default. The `WhereLog` facade and every +- **Impossible states trap. Recoverable ones surface.** `WhereStore` methods + are `async throws`. A `catch` logs a typed `WhereLog` event (PII-free, + `.public`, error as `LogAttachment.error(_:)`) and leaves state honest. + Never use a benign-looking default. The `WhereLog` facade and every `*Log.swift` event type live together in `Sources/Logging/`. -- **Expensive Core work is spanned, with a budget** — bulk reads and `perform` - commits, aggregation, calendar layout, issue detection, the reconcile - fan-out, backup, GPS acquisition. Names come from each `*Log`'s nested - `SpanName`; see [Spans](../AGENTS.md#spans) for the convention. A detector - names its own span through `DataIssueDetecting.detects`, so +- **Expensive Core work is spanned, with a budget.** That includes bulk reads + and `perform` commits, aggregation, calendar layout, issue detection, the + reconcile fan-out, backup, GPS acquisition. Names come from each `*Log`'s + nested `SpanName`. See [Spans](../AGENTS.md#spans) for the convention. A + detector names its own span through `DataIssueDetecting.detects`. Then `DataIssueScanner` reports per-category cost (`detect(border-drift)`) without a switch over concrete detector types. @@ -118,22 +119,22 @@ internal shape. Swift Testing in [`Tests/`](Tests) (`WhereCoreTests`), hosted in `StuffTestHost`. Drive collaborators against `SwiftDataStore.inMemory()` + -`ScriptedLocationSource` — never the on-disk/CloudKit store or +`ScriptedLocationSource`. Never use the on-disk/CloudKit store or `CoreLocationSource`. The CloudKit remote-import path uses the `@_spi(Testing)` `inMemory(remoteChangeSource:)` + `ScriptedStoreRemoteChangeSource`. Internal types are reached via `@testable import WhereCore`. `InMemoryKeyValueStore` and the noop schedulers/refreshers are plain `public` -production API, not test scaffolding: demo mode assembles a session out of -them. Don't restore the `@_spi(Testing)` + `#if DEBUG` gating the first two +production API, not test scaffolding. Demo mode assembles a session out of +them. Do not restore the `@_spi(Testing)` + `#if DEBUG` gating the first two once carried (#150). The notification and widget seams run the other way round from most defaults -here: the `@_spi(Testing)` `init` defaults them to the **no-ops**, while the +here. The `@_spi(Testing)` `init` defaults them to the **no-ops**. The public `make(...)` requires them. The reconcilers behind them fire on ordinary -writes, so a suite that named nothing would schedule real notifications and +writes. A suite that named nothing would schedule real notifications and reload the user's widget timelines as a side effect of saving a day. Only `WhereBootstrap` names the real ones. `forIntents(sharingStoreOf:)` inherits -them from its base for the same reason it inherits the attributor — a stack +them from its base for the same reason it inherits the attributor. A stack derived from the demo world must stay made of no-ops. diff --git a/Where/WhereCore/README.md b/Where/WhereCore/README.md index a49033d6..49c2e784 100644 --- a/Where/WhereCore/README.md +++ b/Where/WhereCore/README.md @@ -1,6 +1,6 @@ # WhereCore -The domain layer of the **Where** app: it ingests location, persists it, rolls +The domain layer of the **Where** app. It ingests location, persists it, rolls it up into per-day and per-year region presence, finds the data-quality problems worth resolving, and drives the side effects that follow a change (reminders, widget snapshots, backups, on-device activity summaries). It is pure Swift + @@ -12,11 +12,11 @@ UIKit** — so all of it is unit-testable off-screen. It builds on Everything is reached through one `Sendable` container, **`WhereServices`**, which the presentation layer (`WhereUI`) and the widget extension talk to. For the domain/presentation layering and the rules this module enforces, see the -feature [`Where/AGENTS.md`](../AGENTS.md); this file is the human-facing tour. +feature [`Where/AGENTS.md`](../AGENTS.md). This file is the human-facing tour. ## What you get -`WhereServices` is a small struct of focused collaborators — add behavior to the +`WhereServices` is a small struct of focused collaborators. Add behavior to the one it belongs to rather than to a god-object: ### Persistence & writes @@ -173,12 +173,12 @@ for await _ in services.dataChangeUpdates() { ## How it works -A single **read-refresh signal** ties the module together: every write origin — +A single **read-refresh signal** ties the module together. Every write origin — a manual edit, a live GPS sample, or a CloudKit import from another device — funnels through `WhereStore.perform` (or the remote-import path) and pings `changes()`. Readers (the UI's session, the issue scanner) re-derive purely off -that ping, so nothing goes stale behind a write it didn't initiate; and because -writes await their own side effects, a reader on the next ping sees a +that ping, so nothing goes stale behind a write it didn't initiate. Writes await +their own side effects, so a reader on the next ping sees a fully-applied change. `WhereServices.reset()` is the one inherently cross-collaborator operation — it quiesces GPS ingestion *before* wiping the store so the retry queue can't repopulate it mid-erase. diff --git a/Where/WhereIntents/AGENTS.md b/Where/WhereIntents/AGENTS.md index e8786ac5..ca8209e9 100644 --- a/Where/WhereIntents/AGENTS.md +++ b/Where/WhereIntents/AGENTS.md @@ -1,87 +1,88 @@ # WhereIntents – Module Shape -WhereIntents is the App Intents layer of the Where feature: the query + -action intents (and their interactive snippet cards) that expose Where to +WhereIntents is the App Intents layer of the Where feature. It owns the query +and action intents (and their interactive snippet cards) that expose Where to Siri, Spotlight, and the Shortcuts app. See [`README.md`](README.md) for the intent list and data paths. This file complements the root [`AGENTS.md`](../../AGENTS.md) and the feature -[`Where/AGENTS.md`](../AGENTS.md) — read those first. +[`Where/AGENTS.md`](../AGENTS.md). Read those first. ## Scope & dependencies - Dependencies live in the root [`Package.swift`](../../Package.swift). It - depends on **WhereUI** for its snippet cards, so it must **not** link + depends on **WhereUI** for its snippet cards. It must **not** link `BroadwayUI`/`BroadwayCore` directly (root [double-link rule](../../AGENTS.md#never-double-link-a-product-whereui-already-carries)). -- Intents stay **thin adapters**: they `await intentServices.current()` and +- Intents stay **thin adapters**. They `await intentServices.current()` and delegate to that `WhereServices`' collaborators. Domain rules stay in - `WhereCore`, card bodies in `WhereUI`. + `WhereCore`. Card bodies stay in `WhereUI`. ## Invariants - **The `AppShortcutsProvider` lives in the Where app target** - (`Where/Where/Sources/WhereShortcuts.swift`) so metadata extraction - reliably discovers the phrases; intent/entity types are `public` for it. + (`Where/Where/Sources/WhereShortcuts.swift`). Metadata extraction + reliably discovers the phrases from there. Intent/entity types are `public` + for it. - **Intents never start GPS.** `WhereServices.forIntents(sharingStoreOf:)` - wires an `IdleLocationSource`; an intent-logged manual entry records a + wires an `IdleLocationSource`. An intent-logged manual entry records a "Logged with Siri" audit and no captured location. -- **Resolve services through the `@Dependency`-injected `IntentServices`; - intents never open a store.** The app's `AppDelegate` owns the one instance - and registers it in `didFinishLaunching`; the launch's `resolve-scope` step is - the process's only store open, and the `onServicesReady` hook derives and +- **Resolve services through the `@Dependency`-injected `IntentServices`. + Intents never open a store.** The app's `AppDelegate` owns the one instance + and registers it in `didFinishLaunching`. The launch's `resolve-scope` step is + the process's only store open. The `onServicesReady` hook derives and installs the store-sharing intents stack (re-fired on retry and reset - relaunches). An intent that fires before installation **parks** in - `current()` (cancellation-aware) — there is deliberately no self-open + relaunches). If an intent fires before installation, it **parks** in + `current()` (cancellation-aware). There is deliberately no self-open fallback. A `LogDayIntent` write therefore pings the same `changes()` signal the running UI refreshes from. -- **Every `perform()` wraps its work in `measureIntent(_:)`**, and each - `WhereIntentsLog.IntentName` carries the budget for its own kind of work — so - the span history reads per intent (`perform(days-in-region)`) and a slow Siri - answer is attributable. Build the `IntentResult` *outside* the measured - closure: keep the span around the fetch/write, and the result's type inference - out of it. `IntentServices.current()` spans only the parking path, so a +- **Every `perform()` wraps its work in `measureIntent(_:)`.** Each + `WhereIntentsLog.IntentName` carries the budget for its own kind of work. + Then the span history reads per intent (`perform(days-in-region)`). A slow + Siri answer is attributable. Build the `IntentResult` *outside* the measured + closure. Keep the span around the fetch/write. Keep the result's type inference + out of it. `IntentServices.current()` spans only the parking path. A measured wait means the intent actually raced the app's launch. -- **Use `Calendar.whereIntents` for all year/day math**, never - `Calendar.current` — Gregorian in the current time zone, matching +- **Use `Calendar.whereIntents` for all year/day math.** Never use + `Calendar.current`. It is Gregorian in the current time zone, matching `DayAggregator()`. Guard: `Calendar+WhereIntentsTests`. - **Snippet `perform()` is side-effect-free and re-run on reload.** Mutation - goes through a separate action intent (`LogDayIntent`); never mutate inside + goes through a separate action intent (`LogDayIntent`). Never mutate inside a `SnippetIntent`. -- **`Region` is exposed as `RegionEntity`, not an `AppEnum`** — an `AppEnum` - requires compile-time-constant display literals, and an entity's runtime +- **`Region` is exposed as `RegionEntity`, not an `AppEnum`.** An `AppEnum` + requires compile-time-constant display literals. An entity's runtime `displayRepresentation` keeps RegionKit the single source of a region's spelling. `RegionEntity`/`RegionEntityQuery` are `rawValue`-keyed. -- **Suggestions and Spotlight surface the *tracked* set; resolution is - *full-catalog*** — `suggestedEntities()` / `RegionSpotlightIndexer` read - `RegionEntity.tracked(from:)`, while `entities(for:)` resolves any region +- **Suggestions and Spotlight surface the *tracked* set. Resolution is + *full-catalog*.** `suggestedEntities()` / `RegionSpotlightIndexer` read + `RegionEntity.tracked(from:)`. `entities(for:)` resolves any region by id (a spoken untracked region still answers, with a zero count). -- **App Intents static metadata is literal; dialog copy is catalog-backed.** +- **App Intents static metadata is literal. Dialog copy is catalog-backed.** Titles and display names are `LocalizedStringResource` literals (the - framework requires constants); runtime `IntentDialog` copy goes through + framework requires constants). Runtime `IntentDialog` copy goes through `IntentStrings`, which composes this module's generated symbols. -- **Only the `dialog.*` / `snippet.*` / `audit.*` keys are `manual`; leave +- **Only the `dialog.*` / `snippet.*` / `audit.*` keys are `manual`. Leave the rest of the catalog alone.** The other entries are the framework's own - extracted literals, and one of them is `%@` — marking that `manual` fails + extracted literals. One of them is `%@`. Marking that `manual` fails the build with *"Unable to derive a symbol name from this key."* ## Testing Swift Testing in [`Tests/`](Tests) (`WhereIntentsTests`, hosted in `StuffTestHost`). Drive intent read/write logic against -`PreviewSupport.previewServices()` seeded via `DayJournal` — never the -on-disk store. No `extraPackageProducts`; everything arrives transitively +`PreviewSupport.previewServices()` seeded via `DayJournal`. Never use the +on-disk store. No `extraPackageProducts`. Everything arrives transitively through WhereUI. **Never call an intent's `perform()` in a test.** `perform()` resolves its -`@Dependency` from the process-wide `AppDependencyManager`: in -`StuffTestHost`-hosted bundles nothing registers one (the resolution traps), -and in the app-hosted `WhereTests` process an intent would silently ride the +`@Dependency` from the process-wide `AppDependencyManager`. In +`StuffTestHost`-hosted bundles nothing registers one (the resolution traps). +In the app-hosted `WhereTests` process an intent would silently ride the host app's own registration. Test read/write logic against injected services, and the handoff on per-test `IntentServices` instances (`IntentServicesTests`). The registration→`@Dependency` plumbing is not -unit-testable — the framework fatal-errors on any `@Dependency` access -outside the intent perform flow (a probe was tried and trapped); verify it by -invoking a Siri/Shortcuts intent on a device. Don't construct extra -`AppDelegate`s in tests: each `didFinishLaunching` re-registers the handoff, -and `AppDependencyManager`'s re-registration behavior is undocumented. +unit-testable. The framework fatal-errors on any `@Dependency` access +outside the intent perform flow (a probe was tried and trapped). Verify it by +invoking a Siri/Shortcuts intent on a device. Do not construct extra +`AppDelegate`s in tests. Each `didFinishLaunching` re-registers the handoff. +`AppDependencyManager`'s re-registration behavior is undocumented. diff --git a/Where/WhereIntents/README.md b/Where/WhereIntents/README.md index 5b38af6b..a87d808f 100644 --- a/Where/WhereIntents/README.md +++ b/Where/WhereIntents/README.md @@ -1,6 +1,6 @@ # WhereIntents -The **Where** App Intents layer: it brings Where's region / day-count data and +The **Where** App Intents layer. It brings Where's region / day-count data and manual day logging to **Siri**, **Spotlight**, and the **Shortcuts app**, and presents results as interactive snippet cards. diff --git a/Where/WhereShareExtension/AGENTS.md b/Where/WhereShareExtension/AGENTS.md index c4d68bf4..d7f64271 100644 --- a/Where/WhereShareExtension/AGENTS.md +++ b/Where/WhereShareExtension/AGENTS.md @@ -1,6 +1,6 @@ # WhereShareExtension – Module Shape -The **Where** share extension: a Share-sheet action that writes shared content +The **Where** share extension is a Share-sheet action. It writes shared content (PDFs, images, Wallet passes, emails, links) into the app's store as a new `Evidence`. See [`README.md`](README.md) for the data path and design. @@ -11,38 +11,38 @@ This file complements the root [`AGENTS.md`](../../AGENTS.md) and the feature - **Tuist app-extension target** ([`Project.swift`](../../Project.swift), bundle ID `com.stuff.where.share`), depending on **WhereCore**, **WhereUI**, - and **PeriscopeCore**. Embedded by the **Where** app; shares the + and **PeriscopeCore**. Embedded by the **Where** app. Shares the `group.com.stuff.where` App Group entitlement. Logs via the `WhereLog` facade - (typed `ShareExtensionLog` events); as a separate process its + (typed `ShareExtensionLog` events). As a separate process its `Periscope.shared` is OSLog-only (no store). -- Presentation reuses WhereUI's public `EvidenceKind.symbolName`/`displayName`; - only extension chrome lives in this target's catalog, referenced through its +- Presentation reuses WhereUI's public `EvidenceKind.symbolName`/`displayName`. + Only extension chrome lives in this target's catalog. Reference it through its generated `LocalizedStringResource` symbols. -- No test bundle; the store write contract is covered from **WhereCore** store +- No test bundle. The store write contract is covered from **WhereCore** store tests. This target's own compose/save model (`ShareEvidenceModel`) is - untested — tracked in [`Where/TODOs.md`](../TODOs.md). + untested. Tracked in [`Where/TODOs.md`](../TODOs.md). ## Invariants -- **Writes directly through `SwiftDataStore.perform { write(evidence:blob:) }`, - not `WhereServices`/`DayJournal`.** A short-lived share process must not spin - up the GPS ingestor, notifiers, or widget publisher; the store commit's - persistent-history ping is what the app reconciles from later. -- **Opens `.localOnly` storage, never CloudKit.** The extension holds only the - App Group entitlement (no iCloud), so it must not initialize the CloudKit - mirror; the app's container syncs the shared store's history. -- **`NSExtensionPrincipalClass` is `$(PRODUCT_MODULE_NAME).ShareViewController`** - — keep the class name and Info.plist in sync. Save/cancel bridge to - `extensionContext` completion; the root view has no `@Environment(\.dismiss)`. -- **A share with no loadable bytes still composes** a metadata-only note rather - than failing — but a provider that *reported* a reason for the empty result - logs it. `SharedItemLoader` reduces each callback to one `LoadedValue`, so +- **Write directly through `SwiftDataStore.perform { write(evidence:blob:) }`. + Do not use `WhereServices`/`DayJournal`.** A short-lived share process must + not spin up the GPS ingestor, notifiers, or widget publisher. The store + commit's persistent-history ping is what the app reconciles from later. +- **Open `.localOnly` storage. Never use CloudKit.** The extension holds only + the App Group entitlement (no iCloud). It must not initialize the CloudKit + mirror. The app's container syncs the shared store's history. +- **`NSExtensionPrincipalClass` is `$(PRODUCT_MODULE_NAME).ShareViewController`.** + Keep the class name and Info.plist in sync. Save/cancel bridge to + `extensionContext` completion. The root view has no `@Environment(\.dismiss)`. +- **If a share has no loadable bytes, still compose** a metadata-only note + rather than failing. If a provider *reported* a reason for the empty result, + log it. `SharedItemLoader` reduces each callback to one `LoadedValue`. Then "nothing, and here's why" can't be flattened into the same silence as "nothing - was offered". The load is also the extension's one span (attachment count and - size are what the wait scales with). + was offered". The load is also the extension's one span. Attachment count and + size are what the wait scales with. ## Testing No hosted bundle. Exercise `EvidenceContentType.classify` and the store write -contract in **WhereCore**; preview the compose sheet via the in-file `#Preview` -(DEBUG), which uses an `.inMemory` model with no shared-container access. +contract in **WhereCore**. Preview the compose sheet via the in-file `#Preview` +(DEBUG). It uses an `.inMemory` model with no shared-container access. diff --git a/Where/WhereShareExtension/README.md b/Where/WhereShareExtension/README.md index ebeaf617..1d62f131 100644 --- a/Where/WhereShareExtension/README.md +++ b/Where/WhereShareExtension/README.md @@ -1,6 +1,6 @@ # WhereShareExtension -The **Where** share extension: a Share-sheet action that saves shared content — +The **Where** share extension. It is a Share-sheet action that saves shared content — a boarding pass, a PDF receipt, a screenshot, a forwarded reservation email, a Wallet ticket — into Where as a new piece of [`Evidence`](../WhereCore/Sources/Evidence/Evidence.swift). diff --git a/Where/WhereUI/AGENTS.md b/Where/WhereUI/AGENTS.md index 6fb1049a..492cf535 100644 --- a/Where/WhereUI/AGENTS.md +++ b/Where/WhereUI/AGENTS.md @@ -1,129 +1,128 @@ # WhereUI – Module Shape -WhereUI is the SwiftUI layer of the Where feature: the screens, the shared -components and widget views, and the `@Observable` view models that +WhereUI is the SwiftUI layer of the Where feature. It owns the screens, the +shared components and widget views, and the `@Observable` view models that orchestrate `WhereCore` for them (`WhereModel`, the `WhereSession` coordinator, and the scoped `YearReportModel` / `ResolveModel` / `BackupModel` / `RemindersSettingsModel`). Layering, localization, preview, -and testing conventions live in the feature [`Where/AGENTS.md`](../AGENTS.md) -— read that and the root [`AGENTS.md`](../../AGENTS.md) first. +and testing conventions live in the feature [`Where/AGENTS.md`](../AGENTS.md). +Read that and the root [`AGENTS.md`](../../AGENTS.md) first. ## Scope & dependencies -- Presentation layer only — no domain rules, persistence, or store I/O here +- Presentation layer only. No domain rules, persistence, or store I/O here ([Layering](../AGENTS.md#layering)). Dependencies live in the root [`Package.swift`](../../Package.swift). -- Composition is the one exception: `WhereScope` and `WhereModel` decide which - world the app is logged in to and assemble it. That's launch wiring, not - domain logic — see [Scopes and the launch](../AGENTS.md#scopes-and-the-launch). +- Composition is the one exception. `WhereScope` and `WhereModel` decide which + world the app is logged in to and assemble it. That is launch wiring, not + domain logic. See [Scopes and the launch](../AGENTS.md#scopes-and-the-launch). - The DEBUG developer accordion may only latch or clear `InspectorModeController` for the next launch. It must not host a live SwiftData inspector or switch the current runtime. - Keep the DEBUG Logs destination visible for every - `WhereModel.logStoreState`; opening, unavailable, and failed stores are + `WhereModel.logStoreState`. Opening, unavailable, and failed stores are diagnostics to render, not reasons to hide the tool. - Flyover infrastructure stays under `#if DEBUG` in - [`Sources/Developer/Flyover`](Sources/Developer/Flyover), while each - represented screen declares a DEBUG-only `WhereFlyoverProviding` extension - in its own source file. The integration may import the app-agnostic - `Flyover` module and build one unactivated in-memory `WhereScope`; the shared - module must never import WhereUI. -- Derive `WhereFlyoverScreenID` from the represented view type, and keep that + [`Sources/Developer/Flyover`](Sources/Developer/Flyover). Each represented + screen declares a DEBUG-only `WhereFlyoverProviding` extension in its own + source file. The integration may import the app-agnostic `Flyover` module + and build one unactivated in-memory `WhereScope`. The shared module must + never import WhereUI. +- Derive `WhereFlyoverScreenID` from the represented view type. Keep that screen's variants, viewport/navigation settings, and outgoing routes in its - colocated registration; never restore a centralized screen enum or catalog + colocated registration. Never restore a centralized screen enum or catalog factory methods. -- Construct and retain the Where Flyover catalog once after its world loads; - never rebuild fixture state from a SwiftUI `body`. -- Present Where Flyover from the developer accordion with `fullScreenCover`, - outside the selected-tool `NavigationStack`. -- Register leaf screens against Flyover's default navigation container; use +- Construct and retain the Where Flyover catalog once after its world loads. + Never rebuild fixture state from a SwiftUI `body`. +- Present Where Flyover from the developer accordion with `fullScreenCover`. + Place it outside the selected-tool `NavigationStack`. +- Register leaf screens against Flyover's default navigation container. Use `.none` only for views that own their root stack and for widgets/snippets. -- Consumers (`WhereWidgets`, `WhereIntents`) get Broadway *through* WhereUI - and must **not** link `BroadwayUI`/`BroadwayCore` themselves (root - [double-link rule](../../AGENTS.md#never-double-link-a-product-whereui-already-carries)); - that's why `whereBroadwayRoot()` lives here rather than being called as +- Consumers (`WhereWidgets`, `WhereIntents`) get Broadway *through* WhereUI. + They must **not** link `BroadwayUI`/`BroadwayCore` themselves (root + [double-link rule](../../AGENTS.md#never-double-link-a-product-whereui-already-carries)). + That is why `whereBroadwayRoot()` lives here rather than being called as `broadwayRoot` at each site. - Continuous/looping motion (repeat-forever pulses, `TimelineView(.animation)`, typewriter reveals) must consult the shared `@MotionIsStatic` helper ([`Sources/Shared/MotionIsStatic.swift`](Sources/Shared/MotionIsStatic.swift)) - for its static end-state — never hand-roll the + for its static end-state. Never hand-roll the `\.accessibilityReduceMotion` + `\.isCapturingSnapshot` pair. -- A step joins `WhereLaunch`'s plan through `.measured()` and so must declare a - `budget` (`BudgetedLaunchStep`) — see [Spans](../AGENTS.md#spans). WhereUI also - owns log retention: `LogHistoryPruner` bounds the store by age *and* event - count, and both bounds are load-bearing (an age window alone leaves a - heavy-logging device unbounded inside it). +- A step joins `WhereLaunch`'s plan through `.measured()`. It must declare a + `budget` (`BudgetedLaunchStep`). See [Spans](../AGENTS.md#spans). WhereUI also + owns log retention. `LogHistoryPruner` bounds the store by age *and* event + count. Both bounds are load-bearing. An age window alone leaves a + heavy-logging device unbounded inside it. - A compact form `DatePicker` goes through `WhereDatePicker` - ([`Sources/Shared/WhereDatePicker.swift`](Sources/Shared/WhereDatePicker.swift)), - which substitutes a deterministic stand-in under capture — the live control - renders relative to *today*, so no reference containing one is stable across - days. Views don't read `\.isCapturingSnapshot` to branch themselves; capture + ([`Sources/Shared/WhereDatePicker.swift`](Sources/Shared/WhereDatePicker.swift)). + It substitutes a deterministic stand-in under capture. The live control + renders relative to *today*. No reference containing one is stable across + days. Views don't read `\.isCapturingSnapshot` to branch themselves. Capture handling stays inside the shared component. ## Design system — `WhereStylesheet` All appearance tokens — geometry, fonts, colors, motion — live in `WhereStylesheet` -([`Sources/Shared/WhereStylesheet.swift`](Sources/Shared/WhereStylesheet.swift)), -a Broadway `BStylesheet` read via `@Environment(\.stylesheet)`; off the +([`Sources/Shared/WhereStylesheet.swift`](Sources/Shared/WhereStylesheet.swift)). +It is a Broadway `BStylesheet` read via `@Environment(\.stylesheet)`. Off the `View` tree (layout helpers, tests) use `WhereStylesheet.default`. How to consume and extend it — per-component style groups, variant subscripts, the `RegionStyle` resolver — is in [`README.md`](README.md#design-system). The rules: -- **Never hardcode appearance in a view** or collect constants into a flat - grab-bag; a new value lands on the owning component's style group, or on a +- **Never hardcode appearance in a view.** Do not collect constants into a flat + grab-bag. A new value lands on the owning component's style group, or on a shared scale (`Spacing`, `Size`, `Palette`, `Typography`, `Motion`) only when genuinely cross-component. -- **Never borrow another component's style** — a component defines its own +- **Never borrow another component's style.** A component defines its own group rather than reading a value off someone else's. - **Resolve a variant once** (a `Variant` enum + `subscript`, see - `CardStyles`) — don't branch `compact ? … : …` through a body. -- **Don't bake trait-derived values into the defaults** — `.standard` / - property defaults hold the fixed set; the reactive slice applies only in - `init(context:)`, so a default/system context reproduces + `CardStyles`). Do not branch `compact ? … : …` through a body. +- **Do not bake trait-derived values into the defaults.** `.standard` / + property defaults hold the fixed set. The reactive slice applies only in + `init(context:)`. A default/system context reproduces `WhereStylesheet.default`. -- **Derive accessibility settings in the sheet, not the view** — vend one - resolved token, and a *single* token when a setting changes more than one +- **Derive accessibility settings in the sheet, not the view.** Vend one + resolved token. Use a *single* token when a setting changes more than one value (`CardStyles.DayCountStyle` pairs the morph with its animation). Exception: the `motion` group keeps full-motion values a view picks between - (`motion.reducedReveal` over `motion.reveal`), because the launch reveal's - fallback swaps an `AnyTransition`, which isn't `Equatable` and can't be a - token. -- **Per-region tints stay in `RegionStyle`**, resolved via - `@Environment(\.regionStyles)` and seeded by - `whereBroadwayRoot(regionStyles:)` — no global accessor or hardcoded + (`motion.reducedReveal` over `motion.reveal`). The launch reveal's fallback + swaps an `AnyTransition`, which isn't `Equatable` and can't be a token. +- **Per-region tints stay in `RegionStyle`.** Resolve via + `@Environment(\.regionStyles)` and seed by + `whereBroadwayRoot(regionStyles:)`. No global accessor or hardcoded per-region look in a view. Adaptive system roles (`.secondary`) and `.accentColor` stay inline. -- `WhereThemes` is deliberately empty — the seam a future app-wide theme +- `WhereThemes` is deliberately empty. It is the seam a future app-wide theme plugs into. ## Testing -`WhereStylesheetTests` pins every token default and trait-aware derivation; +`WhereStylesheetTests` pins every token default and trait-aware derivation. `WhereStylesheetEnvironmentTests` covers the `@Environment(\.stylesheet)` glue and `whereBroadwayRoot()` seeding, including the WhereWidgets path. -Adding, renaming, or retuning a token means updating those assertions in the +When you add, rename, or retune a token, update those assertions in the same change. `WhereFlyoverCatalogTests` pins the catalog against the colocated registrations -assembled by `WhereFlyoverCatalog`; add a registration beside every new -top-level screen and list its type in the appropriate catalog group. Flyover -frames share one `WhereFlyoverWorld`; synthetic preview models are reserved for +assembled by `WhereFlyoverCatalog`. Add a registration beside every new +top-level screen. List its type in the appropriate catalog group. Flyover +frames share one `WhereFlyoverWorld`. Synthetic preview models are reserved for states the seeded demo cannot express. Screens, widgets, and app-flow surfaces are pinned as matrixed image -snapshots under [`SnapshotTests/`](SnapshotTests) — those, not hosting smoke +snapshots under [`SnapshotTests/`](SnapshotTests). Those, not hosting smoke tests, own "does this screen render". They build as this module's -`WhereUISnapshotTests` bundle, run from the shared `StuffSnapshotTests` +`WhereUISnapshotTests` bundle. Run from the shared `StuffSnapshotTests` scheme and its CI job, deliberately outside `Stuff-iOS-Tests` (root [`AGENTS.md`](../../AGENTS.md#targets)). **Each view declares its matrix once, in its own source file**, via a `SnapshotProviding` conformance under -`#if DEBUG` whose `#Preview` renders `Self.snapshotPreviews` — one +`#if DEBUG` whose `#Preview` renders `Self.snapshotPreviews`. One declaration drives both the Xcode cutsheet and the image tests (helpers in -[`Sources/Preview/WhereSnapshot.swift`](Sources/Preview/WhereSnapshot.swift)); -suites are one `FooSnapshotTests` per view. To re-record a reference, delete -the PNG under `SnapshotTests/__Snapshots__/` (LFS-tracked) and run the scheme -— the suites record `.missing`, and a recording run fails by design. Bulk +[`Sources/Preview/WhereSnapshot.swift`](Sources/Preview/WhereSnapshot.swift)). +Suites are one `FooSnapshotTests` per view. To re-record a reference, delete +the PNG under `SnapshotTests/__Snapshots__/` (LFS-tracked) and run the scheme. +The suites record `.missing`. A recording run fails by design. Bulk re-records forward `TEST_RUNNER_SNAPSHOT_RECORD=failed` (see the [SnapshotKitTesting README](../../Shared/SnapshotKitTesting/README.md#recording)). diff --git a/Where/WhereUI/README.md b/Where/WhereUI/README.md index 4e0daddd..df152757 100644 --- a/Where/WhereUI/README.md +++ b/Where/WhereUI/README.md @@ -1,12 +1,12 @@ # WhereUI -The SwiftUI layer of the **Where** app: every screen the user sees, the shared +The SwiftUI layer of the **Where** app. It contains every screen the user sees, the shared components and widget views, and the `@Observable` view models that turn `WhereCore`'s domain services into something SwiftUI can render. It sits on top of `WhereCore` (domain, persistence, GPS) and `RegionKit` (geometry) — both of which stay UI-free — and leans on the Broadway design system for its tokens. The app target is a thin shell that builds a model at launch and shows WhereUI's -`RootView`; the **WhereWidgets** extension reuses WhereUI's views to render a +`RootView`. The **WhereWidgets** extension reuses WhereUI's views to render a published snapshot. For the module's *rules* — the domain/presentation layering, localization, @@ -41,7 +41,7 @@ the feature [`Where/AGENTS.md`](../AGENTS.md) and this module's durable store is ready it reports whether the open is still running, unavailable, or failed with the actual error. - **`WhereLaunch`** — the launch, reset, and exit-demo plans themselves. Every - step declares how long it should take (`BudgetedLaunchStep`) and joins the + step declares how long it must take (`BudgetedLaunchStep`) and joins the plan through `.measured()`, so each run is one Periscope span named after the step (`step(resolve-scope)`) that warns while it overruns its budget — the launch's cost breaks down per step instead of arriving as one slow diff --git a/Where/WhereWidgets/AGENTS.md b/Where/WhereWidgets/AGENTS.md index 24797523..a211bf02 100644 --- a/Where/WhereWidgets/AGENTS.md +++ b/Where/WhereWidgets/AGENTS.md @@ -1,7 +1,7 @@ # WhereWidgets – Module Shape -The **Where** widget extension: WidgetKit configurations that read a published -`WidgetSnapshot` from the App Group and render via shared views in **WhereUI**. +The **Where** widget extension is a WidgetKit target. It reads a published +`WidgetSnapshot` from the App Group and renders via shared views in **WhereUI**. See [`README.md`](README.md) for the data path and widget list. This file complements the root [`AGENTS.md`](../../AGENTS.md) and the feature @@ -13,31 +13,31 @@ This file complements the root [`AGENTS.md`](../../AGENTS.md) and the feature bundle ID `com.stuff.where.widgets`), depending on **WhereCore**, **WhereUI**, **RegionKit**, and **PeriscopeCore**. - Must **not** import SwiftData, open the user's store, or duplicate - aggregation logic — the app publishes; the extension only reads and renders. -- Logs via the `WhereLog` facade (typed `WhereWidgetsLog` events); as a + aggregation logic. The app publishes. The extension only reads and renders. +- Logs via the `WhereLog` facade (typed `WhereWidgetsLog` events). As a separate WidgetKit process its `Periscope.shared` is OSLog-only (no store). -- No test bundle; behavior is covered from **WhereCore** and **WhereUI**. +- No test bundle. Behavior is covered from **WhereCore** and **WhereUI**. ## Refresh contract -1. App commits a store change → `WidgetSnapshotPublisher` rebuilds the - snapshot → writes JSON + `WidgetCenter.reloadAllTimelines()`. -2. The provider reads the JSON on each timeline request and schedules +1. App commits a store change. Then `WidgetSnapshotPublisher` rebuilds the + snapshot. It writes JSON and calls `WidgetCenter.reloadAllTimelines()`. +2. The provider reads the JSON on each timeline request. It schedules `.after(nextMidnight)` so WidgetKit re-queries even without an app reload. ## Invariants -- **Read-only App Group access** — only the app writes `widget-snapshot.json`. +- **Read-only App Group access.** Only the app writes `widget-snapshot.json`. - **No stale-day invalidation in the provider.** A snapshot whose `day` rolled - past today is still shown until the app republishes — intentional. -- In-widget strings come from WhereUI (shared views + `WhereFormat`); the + past today is still shown until the app republishes. That is intentional. +- In-widget strings come from WhereUI (shared views + `WhereFormat`). The gallery name/description resolve through this extension's own generated catalog symbols (`String(localized: .widgetGalleryTodayName)`). Widgets ship `#Preview` timelines like any other WhereUI view. - **Seed the Broadway root via WhereUI's `whereBroadwayRoot()`** (applied in each - widget's `StaticConfiguration` content) so the shared WhereUI views resolve + widget's `StaticConfiguration` content). Then the shared WhereUI views resolve trait-aware `@Environment(\.stylesheet)` tokens instead of `.default`. Never - add a direct `BroadwayCore`/`BroadwayUI` dependency — Broadway arrives through - `WhereUI`, which is why the seam lives there rather than a `broadwayRoot` call + add a direct `BroadwayCore`/`BroadwayUI` dependency. Broadway arrives through + `WhereUI`. That is why the seam lives there rather than a `broadwayRoot` call here (see the root [`AGENTS.md`](../../AGENTS.md#never-double-link-a-product-whereui-already-carries)). diff --git a/Where/WhereWidgets/README.md b/Where/WhereWidgets/README.md index 8b82b4f1..496f298d 100644 --- a/Where/WhereWidgets/README.md +++ b/Where/WhereWidgets/README.md @@ -1,6 +1,6 @@ # WhereWidgets -The **Where** widget extension: home-screen and lock-screen widgets that show +The **Where** widget extension. It provides home-screen and lock-screen widgets that show today's region presence and year-to-date day counts per region. Widgets never open the SwiftData store. The app publishes a single aggregated