Skip to content
10 changes: 0 additions & 10 deletions .bumper/RULES.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,12 +98,6 @@ fully expresses Where's constructor facts.
production sources. Day and year math uses an injected Gregorian calendar or,
inside WhereIntents, `Calendar.whereIntents`.

The current source tree intentionally contains three violations. They are left
visible during this bootstrap so the live Bumper lint demonstrates the
difference between a documented decision and an enforced one. The rule suite
still passes because its fixtures assert both accepted Gregorian construction
and rejected `Calendar.current` access.

The architecture DSL and standard shapers cannot distinguish two static
members of the same Foundation type, so this uses a typed
`MemberAccessExprSyntax` query. TheButtonHeist's
Expand Down Expand Up @@ -140,10 +134,6 @@ typed event declarations live.
that declares a `View`, `Widget`, or `WidgetBundle` struct to contain at least
one `#Preview`.

The current source tree intentionally contains preview-coverage violations.
They remain visible during this bootstrap alongside the calendar violations so
the live lint demonstrates documented rules finding existing drift.

## Transitive Broadway ownership

WhereIntents and WhereWidgets explicitly forbid direct `BroadwayCore` and
Expand Down
351 changes: 129 additions & 222 deletions AGENTS.md

Large diffs are not rendered by default.

37 changes: 15 additions & 22 deletions Project.swift
Original file line number Diff line number Diff line change
Expand Up @@ -433,34 +433,27 @@ let project = Project(
sources: ["Where/WhereCore/Tests/**"],
extraPackageProducts: ["RegionKit"],
),
// WhereUITests deliberately lists no `extraPackageProducts`. WhereUI is a
// dynamic framework that statically embeds its own dependencies, so any
// product *also* linked here would land a second copy in this bundle;
// with several .xctest bundles loaded into one StuffTestHost that
// duplicates the module's type metadata, and any type-keyed lookup
// crossing the WhereUI boundary (SwiftUI `EnvironmentKey`s,
// `UITraitBridgedEnvironmentKey` bridging, the type-keyed
// BTraits/BThemes/BStylesheets containers) then silently resolves against
// the wrong copy — the writer stores under one copy's key type, the
// reader looks it up under another's. Everything the tests need
// (BroadwayCore/BroadwayUI, LifecycleKit/LifecycleKitUI, PeriscopeCore/UI/Tools,
// SwiftDataInspector, RegionKit + its GeoJSON bundle) is reached
// transitively through WhereUI.
// See "Never double-link a product a dynamic framework already
// carries" in the root AGENTS.md.
// WhereUITests deliberately lists no `extraPackageProducts`: everything it
// needs (Broadway, LifecycleKit/LifecycleKitUI, Periscope, SwiftDataInspector,
// RegionKit + its GeoJSON bundle) arrives statically through WhereUI, and
// re-listing one lands a second copy in this image, silently breaking
// type-keyed lookups — only in the full multi-bundle scheme, never in an
// isolated `tuist test WhereUITests` run.
// Guard: WhereStylesheetTests.resolvesTraitAwareTokensFromTheBroadwayRoot.
// See "Never double-link a product WhereUI already carries" in the root
// AGENTS.md; mechanism: PR #145.
unitTests(
name: "WhereUITests",
bundleIdSuffix: "whereui",
productDependency: "WhereUI",
sources: ["Where/WhereUI/Tests/**"],
),
// WhereIntents depends on WhereUI (a dynamic framework) for its snippet
// cards, so — exactly like WhereUITests above — this bundle lists no
// `extraPackageProducts`: WhereUI/WhereCore/RegionKit/Broadway all arrive
// transitively, and re-listing any of them would land a duplicate copy
// that splits the module's type metadata across the WhereUI boundary.
// See "Never double-link a product a dynamic framework already
// carries" in the root AGENTS.md.
// WhereIntents depends on WhereUI for its snippet cards, so — exactly like
// WhereUITests above — this bundle lists no `extraPackageProducts`:
// WhereUI/WhereCore/RegionKit/Broadway all arrive transitively, and
// re-listing any of them would land a duplicate copy in this image.
// See "Never double-link a product WhereUI already carries" in the root
// AGENTS.md.
unitTests(
name: "WhereIntentsTests",
bundleIdSuffix: "whereintents",
Expand Down
17 changes: 9 additions & 8 deletions Shared/Broadway/AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
# Broadway – Module Group Shape

Broadway is a design-system stack imported into Stuff (git history preserved)
from its own repo. It centers 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` — a type-keyed
environment (traits, themes, lazily-cached stylesheets) that flows through a
UIKit + 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.
Expand All @@ -29,14 +28,16 @@ bundles in [`Project.swift`](../../Project.swift) (the shared `unitTests` helper
- **`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 tests run in the shared `StuffTestHost`** via `TestHostSupport`
- **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.

## Testing

Hosted Swift Testing bundles (`BroadwayCoreTests`, `BroadwayUITests`,
`BroadwayCatalogTests`) run in `StuffTestHost` and link `TestHostSupport`. 1:1
test files per the root rules.
`BroadwayCoreTests` and `BroadwayUITests` run in `StuffTestHost` and link
`TestHostSupport`; `BroadwayCatalogTests` is hosted by the **BroadwayCatalog**
app itself (its target depends on the app, not `StuffTestHost` —
[`Project.swift`](../../Project.swift)). 1:1 test files per the root rules.
2 changes: 1 addition & 1 deletion Shared/Broadway/BroadwayCatalog/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,5 @@ Complements the root [`AGENTS.md`](../../../AGENTS.md) and the group
- Declared as a Tuist `.app` target (`com.stuff.broadway.catalog`),
iPhone/iPad destinations.

Tests: `BroadwayCatalogTests` in `StuffTestHost`
Tests: `BroadwayCatalogTests`, hosted by this app itself — not `StuffTestHost`
(`tuist test BroadwayCatalogTests`).
100 changes: 37 additions & 63 deletions Shared/CreditKit/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,74 +7,48 @@ the root [`AGENTS.md`](../../AGENTS.md).

## Scope & dependencies

- **May import:** Foundation. Nothing else — not even logging.
- **Must not import:** any app or feature module (`WhereCore`, `WhereUI`, …), or
any UI framework. CreditKit is a leaf that anything may depend on; an edge
pointing *out* of it would defeat the reason it exists.
- **Wired in:** `Package.swift` (`CreditKit` product, consumed by `WhereCore`
and `WhereUI`) and `Project.swift` (`CreditKitTests`, in the
`Stuff-iOS-Tests` scheme).

## Layering

`SoftwareCredit` and `LicenseNotice` are the values; `AttributionManifest` is a
decoded report plus the two ways to get one (`decode(from:)`,
`load(from:resource:)`). Nothing holds state, nothing is a singleton, and
nothing knows how a report is presented — grouping, labeling, and translation
belong to the consuming UI.

`Tools/generate-attribution.rb` is the other half of the module and is the only
thing that writes a report.
- **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.

## Invariants

- **CreditKit ships no credits and no notices.** A report describes one app's
dependency graph, so it belongs in that app's resources — for Where, in
`Where/Where/Resources/attribution.json`. If a license file or a `credits.json`
ever reappears under `Sources/`, the module has drifted back into being one
app's data.
- **Nothing here may name a real dependency.** `CreditKitTests` covers the
format and the API with fixtures only; asserting that some package is credited
is the *app's* test to write, because only the app knows what it links. See
`AppAttributionTests` in `Where/Where/Tests/`.
- **Failure is thrown, never logged or defaulted.** The module has no logger by
design, and a missing report is not inherently an error — only the app knows
which of its bundles are expected to carry one. Returning an empty manifest
instead of throwing would render as "nothing to credit", which is the one
wrong answer.
- **`Kind` is load-bearing, not decoration.** `.developmentTool` credits are not
in the shipping binary. A UI that renders both kinds in one undifferentiated
list would misrepresent the app, so the distinction must survive into the
presentation. Its raw values are a wire format the generator writes; renaming
a case silently invalidates every committed report. The generator validates
each source's `kind` against them before it does any work, so a config typo
fails there rather than as a decode fault inside the app.
- **Credit names are unique across a report.** `SoftwareCredit` is `Identifiable`
by `name`, so a duplicate hands a SwiftUI `ForEach` two rows with one id — and
a library's name is its repo basename, so two orgs publishing the same repo
name is all it takes. The generator enforces this (case-insensitively) over the
assembled report, since that is the only place holding every credit at once.
- **Notices are read at the pinned revision.** Not the default branch — upstream
edits a notice between releases, and shipping HEAD's text would attribute
terms that don't govern the code in the binary.
- **The generator keys off `.product(name:package:)`, not the `dependencies:`
list.** That is what keeps a package resolved for tooling alone (BumperBowling,
and swift-syntax beneath it) out of a report by construction.
- **Linking is not shipping, so `kind` is derived from reachability.** The
config's `shippedFrom` names the app's root package targets; the generator
walks the manifest's target graph from there, and a package inside that
closure is a `library` while any other linked package is a `developmentTool`.
A test-support target's dependencies (the snapshot engine, the accessibility
parser) are linked by the package but never reach a device — crediting them as
libraries would misdescribe the binary, which is the one thing `Kind` exists to
prevent. `shippedFrom` is the only hand-set part; everything downstream of it
follows from the graph, so a new dependency can't be silently misfiled.
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.

## Testing

`CreditKitTests` covers the manifest as a format and an API: decoding the exact
JSON the generator writes, rejecting a malformed report or an unknown `kind`,
round-tripping, filtering by kind, and `load` throwing for a bundle with no
report. Shared fixtures live in `CreditKitTestSupport.swift`, and
`SampleReport.json` is a literal rather than an encoder round-trip so a Swift-side
`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.
2 changes: 1 addition & 1 deletion Shared/CreditKit/TODOs.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,6 @@ here.
# Open issues

## P2s (Nice to have)
- fix [quick-win]: `github_slug` accepts anything after the host, so a malformed pin becomes a malformed API path instead of a clear error. It captures `.+?` (`Tools/generate-attribution.rb:76`) and the result is interpolated straight into `repos/#{slug}/license?ref=#{ref}` (`:67`), so a `location` of `https://github.com/foo/bar?x=y` asks for `repos/foo/bar?x=y/license?ref=…` and fails with whatever `gh` makes of that. Not a security issue: both inputs are repo-controlled (`Package.resolved`, `.agents/external-skills.json`) and `Open3.capture3` passes argv with no shell, so nothing is injectable. Constrain the capture to `[\w.-]+/[\w.-]+` so a bad pin fails as a bad pin. (pr#140 review)
- fix [quick-win]: `github_slug` accepts anything after the host, so a malformed pin becomes a malformed API path instead of a clear error. It captures `.+?` (`Tools/generate-attribution.rb:91`) and the result is interpolated straight into `repos/#{slug}/license?ref=#{ref}` (`:82`), so a `location` of `https://github.com/foo/bar?x=y` asks for `repos/foo/bar?x=y/license?ref=…` and fails with whatever `gh` makes of that. Not a security issue: both inputs are repo-controlled (`Package.resolved`, `.agents/external-skills.json`) and `Open3.capture3` passes argv with no shell, so nothing is injectable. Constrain the capture to `[\w.-]+/[\w.-]+` so a bad pin fails as a bad pin. (pr#140 review)

# Completed issues
42 changes: 17 additions & 25 deletions Shared/LifecycleKit/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,18 +27,13 @@ system, formatting, and global conventions. Read that first.
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 the node's
`ID` to match, so a plan can't mix domains and a node keyed for another
plan can't be composed in; `nodeIDs` gives back `[ID]`, not erased keys.
IDs erase to `AnyHashable` *inside* `LaunchPlanNode` and stay erased from
there on — the runner's memo, `LifecycleFailure.stepID`,
`LifecycleStepContext.stepID`, and `LifecycleGateHandle.id` are all
`AnyHashable`, deliberately: `LifecycleDriving` (the seam behind the
non-generic `\.lifecycle` environment value) traffics in `[LaunchPlanNode]`,
so pushing `ID` past the plan would force it onto the runner, the container's
generic list, and every splash/failure/gate closure. If you want typed
`failed(at:)` / `isRunning(_:)` assertions, that's the (deliberate) cost to
price in — it isn't an oversight.
`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
Expand All @@ -63,19 +58,16 @@ system, formatting, and global conventions. Read that first.
- **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 the UIScene
lifecycle `UIApplication.applicationState` reads `.background` at
`didFinishLaunching` even for a user tap, so a consumer that can't yet tell a
headless wake from a user launch should launch `.undetermined` rather than
fabricate a `.background(cause)`. It gates to the background-safe nodes and
builds no view tree until `enterForeground()` promotes it; if no scene ever
connects it honestly stays `.undetermined`, never claiming a cause it didn't
observe.
- **Promotion resolves a not-yet-foreground launch and is idempotent.**
`enterForeground()` no-ops once the reason *is* `.userForeground`, so a
repeat call costs nothing while `.background` and `.undetermined` both
promote; consumers must only call it once the scene is genuinely `.active`
(see `RootView` in WhereUI for the `scenePhase` gating pattern).
- **`.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).

## Testing

Expand Down
2 changes: 1 addition & 1 deletion Shared/LifecycleKit/TODOs.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ here.
# Open issues

## P2s (Nice to have)
- test [quick-win]: Add a test that duplicate node IDs trap. `LaunchPlan.append` `precondition`s on a duplicate (and `LifecycleContainer` does the same for duplicate gate-view registrations), but nothing exercises either. (audit 2026-07-26)
- test [quick-win]: Add a test that duplicate node IDs trap. `LaunchPlan.append` `precondition`s on a duplicate (`LaunchPlan.swift:113`), and `LifecycleContainer` — now in LifecycleKitUI — does the same for duplicate gate-view registrations (`LifecycleKitUI/Sources/LifecycleContainer.swift:101`), but nothing exercises either. (audit 2026-07-26)

# Completed issues

Expand Down
Loading
Loading