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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@ jobs:
# check names.
- name: String Catalog lint
run: ./xcstrings --lint
# The only thing that actually stops a stale attribution report from
# shipping: nothing about adding or bumping a dependency forces a re-run,
# and the app can't check itself (its test bundle can't read Package.swift).
# Offline and sub-second, so it rides along here for the same reason the
# catalog lint does.
- name: Attribution report check
run: ./attribution --check

architecture:
name: Bumper Bowling
Expand Down
79 changes: 74 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,12 @@ Xcode project](#generating-the-xcode-project)). A fresh machine needs `./ide
generating; plain `./ide` fails fast pointing at it.

The executables in the repo root are the dev scripts — `ide`, `swiftformat`,
`sync-agents`, `profile`, `icons`, `flaky`, `simulator`, `xcstrings` — and each
takes `--help`. Reach for one rather than hand-rolling its job: `icons` and
`simulator` in particular own state that is easy to corrupt by hand — the
latter owns a simulator device per checkout (see [Managing app
icons](#managing-app-icons) and [Selecting a
`sync-agents`, `profile`, `icons`, `flaky`, `simulator`, `xcstrings`,
`attribution` — and each takes `--help`. Reach for one rather than hand-rolling
its job: `icons`, `attribution`, and `simulator` in particular own state that is
easy to corrupt by hand — the last owns a simulator device per checkout (see
[Managing app icons](#managing-app-icons), [Attribution](#attribution), and
[Selecting a
simulator](#selecting-a-simulator--one-device-per-checkout-addressed-by-udid)).

### Managing app icons
Expand All @@ -63,6 +64,25 @@ simulator](#selecting-a-simulator--one-device-per-checkout-addressed-by-udid)).
`AppIcons.json` manifest in sync — never hand-edit those or add icon Swift.
Run `./ide --no-open` after adding one.

### Version and build metadata

The Where app's `CFBundleShortVersionString` / `CFBundleVersion` are stated
**explicitly** in [`Project.swift`](Project.swift) rather than left to Tuist's
`.extendingDefault` values, because Settings > About shows them — bump them
there. The commit is *not* a manifest value: a post-build script phase
([`Where/Where/Scripts/stamp-build-info.sh`](Where/Where/Scripts/stamp-build-info.sh))
writes `WhereGitSHA` and `WhereGitStatus` into the built product's Info.plist,
which `WhereCore`'s `BuildInfo` reads back.

The two constraints worth knowing before touching it: it must stay a **post**
script (it edits the plist "Process Info.plist" already wrote, and must land
before signing seals the bundle), and it must keep
`basedOnDependencyAnalysis: false`, or an unchanged source tree ships the
previous commit's SHA. It reads `.git`, so it also depends on
`ENABLE_USER_SCRIPT_SANDBOXING` staying unset (Xcode defaults it off; new
project templates set it on). Only the app is stamped — the extensions never
read these keys.

## Formatting

- **SwiftFormat** uses [`.swiftformat`](.swiftformat). Run `./swiftformat` to
Expand All @@ -78,6 +98,49 @@ Run `./ide --no-open` after adding one.
churn around the one real entry. Write a catalog through Xcode, or normalize
it afterwards; the script only touches formatting, never content.

## Attribution

An app ships an **attribution report**: every third-party work it is built with,
each carrying its license notice inline. Regenerate every app's report with:

```bash
./attribution
```

The split matters. [`Shared/CreditKit`](Shared/CreditKit/AGENTS.md) owns the
*types and the reporting tool* and holds **no credits of its own**; each app
declares its sources in an `attribution-sources.json` and ships the resulting
manifest in **its own resources** (for Where,
`Where/Where/Resources/attribution.json`). A report describes one app's
dependency graph, so it is that app's data — and CreditKit stays a Foundation-only
leaf that anything may depend on, so a package pulled in by *any* module gets
credited without inverting the dependency that introduced it.

The report is derived from what the repo already declares — packages a target
links via `.product(name:package:)` (pinned by `Package.resolved`) and the agent
skills in `.agents/external-skills.json` — with each notice read at the **pinned
revision**. So a new dependency is credited wherever it lands, and a package
resolved only for tooling (BumperBowling, swift-syntax) correctly isn't.
**Re-run `./attribution` and commit the result whenever you add or bump a
package or a skill.** `./attribution --check` fails CI if you forget: it
re-derives the expected report from `Package.swift`, `Package.resolved`, and the
skills manifest and diffs it against the committed one. It runs offline (a
notice is fetched at the pinned revision, so a matching revision means matching
text) and takes well under a second. An app's own tests can't do this job — a
test bundle can't read `Package.swift`, so all it can assert is a literal that a
stale report satisfies just as happily as a fresh one.

`SoftwareCredit.Kind` keeps a **shipped library** apart from a **development
tool**, and that distinction must survive into any UI: a tool is credited
because the repo uses it, not because it reaches a device. **Kind is derived,
not declared** — the config's `shippedFrom` names the package targets the app
and its extensions link, and anything reachable from that closure is a library
while any other linked package is a tool. Linking is not shipping: the
snapshot-testing engine is linked by a test-support target and never reaches a
binary, and crediting it as a library would tell a reader their app contains it.
Data-source provenance for bundled geometry is separate and stays with its data,
in [`RegionKit`](Where/RegionKit/AGENTS.md).

## Architecture lint

Bumper Bowling enforces the production Where module graph and selected
Expand Down Expand Up @@ -114,6 +177,12 @@ SwiftData) — and `.agents/skills/.gitignore` excludes those fetched copies, so
anything else under `.agents/skills/` is a **repo-owned** skill and is committed
(currently `todo-triage`).

That manifest is also an **attribution** input: external skills are third-party
work the repo vendors, so an app's report credits them (as *development tools*,
distinct from libraries the app links). After adding a skill or running
`./sync-agents --update`, re-run `./attribution` and commit the regenerated
reports — see [`Attribution`](#attribution).

**Cursor reads `.agents/skills/` natively** — that directory is the real home. The
`.claude/skills/` mirror exists for Claude Code, so run `./sync-agents` after
adding or editing a skill, and edit the source rather than the mirror. (Cursor
Expand Down
7 changes: 7 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ let package = Package(
],
products: [
.library(name: "StuffCore", targets: ["StuffCore"]),
.library(name: "CreditKit", targets: ["CreditKit"]),
.library(name: "LifecycleKit", targets: ["LifecycleKit"]),
.library(name: "LifecycleKitUI", targets: ["LifecycleKitUI"]),
.library(name: "JournalKit", targets: ["JournalKit"]),
Expand Down Expand Up @@ -43,6 +44,10 @@ let package = Package(
name: "StuffCore",
path: "Shared/StuffCore/Sources",
),
.target(
name: "CreditKit",
path: "Shared/CreditKit/Sources",
),
.target(
name: "LifecycleKit",
path: "Shared/LifecycleKit/Sources",
Expand Down Expand Up @@ -121,6 +126,7 @@ let package = Package(
.target(
name: "WhereCore",
dependencies: [
.target(name: "CreditKit"),
.target(name: "PeriscopeCore"),
.target(name: "RegionKit"),
.product(name: "ZIPFoundation", package: "ZIPFoundation"),
Expand All @@ -136,6 +142,7 @@ let package = Package(
.target(name: "WhereCore"),
.target(name: "BroadwayCore"),
.target(name: "BroadwayUI"),
.target(name: "CreditKit"),
.target(name: "LifecycleKit"),
.target(name: "LifecycleKitUI"),
.target(name: "PeriscopeCore"),
Expand Down
25 changes: 25 additions & 0 deletions Project.swift
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,11 @@ let project = Project(
infoPlist: .extendingDefault(with: [
"UILaunchScreen": .dictionary([:]),
"UIApplicationSupportsIndirectInputEvents": .boolean(true),
// Stated explicitly rather than left to Tuist's `1.0` / `1`
// defaults, because Settings > About shows them: the version a
// user reads off the screen should be one this manifest chose.
"CFBundleShortVersionString": .string("1.0"),
"CFBundleVersion": .string("1"),
"NSLocationWhenInUseUsageDescription": .string(
"Where uses your location to figure out which region you're in.",
),
Expand All @@ -116,6 +121,17 @@ let project = Project(
sources: ["Where/Where/Sources/**"],
resources: ["Where/Where/Resources/**"],
entitlements: whereAppGroupEntitlements,
// Writes `WhereGitSHA` / `WhereGitStatus` into the built Info.plist
// for Settings > About. A *post* script so it lands after "Process
// Info.plist" and before signing, and `basedOnDependencyAnalysis:
// false` so an unchanged source tree still re-stamps a new commit.
scripts: [
.post(
path: "Where/Where/Scripts/stamp-build-info.sh",
name: "Stamp Build Info",
basedOnDependencyAnalysis: false,
),
],
dependencies: [
.package(product: "LifecycleKit"),
.package(product: "RegionKit"),
Expand Down Expand Up @@ -286,6 +302,12 @@ let project = Project(
productDependency: "StuffCore",
sources: ["Shared/StuffCore/Tests/**"],
),
unitTests(
name: "CreditKitTests",
bundleIdSuffix: "creditkit",
productDependency: "CreditKit",
sources: ["Shared/CreditKit/Tests/**"],
),
unitTests(
name: "LifecycleKitTests",
bundleIdSuffix: "lifecyclekit",
Expand Down Expand Up @@ -530,6 +552,7 @@ let project = Project(
"RegionViewer",
"StuffTestHost",
"StuffCoreTests",
"CreditKitTests",
"LifecycleKitTests",
"LifecycleKitUITests",
"JournalKitTests",
Expand All @@ -551,6 +574,7 @@ let project = Project(
]),
testAction: .targets([
"StuffCoreTests",
"CreditKitTests",
"LifecycleKitTests",
"LifecycleKitUITests",
"JournalKitTests",
Expand All @@ -571,6 +595,7 @@ let project = Project(
]),
),
testScheme(name: "StuffCoreTests"),
testScheme(name: "CreditKitTests"),
testScheme(name: "LifecycleKitTests"),
testScheme(name: "LifecycleKitUITests"),
testScheme(name: "JournalKitTests"),
Expand Down
80 changes: 80 additions & 0 deletions Shared/CreditKit/AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# 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).

## 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.

## 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.

## 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
change that breaks the wire format fails a test.
Loading
Loading