From f693f724ed3ff4d3ad90a46406fd6b8c3ea68614 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sun, 26 Jul 2026 13:47:51 -0700 Subject: [PATCH 01/13] Stamp the build's commit into the app's Info.plist Settings > About needs to answer "which build am I running?", and the app carried no build metadata at all: version and build number were Tuist's `.extendingDefault` values and nothing recorded the commit. State the version keys explicitly in the manifest so a user-visible number is one we chose, and add a post-build script phase that writes `WhereGitSHA` / `WhereGitStatus` into the built product's plist. Post (not pre) so it edits the plist after "Process Info.plist" writes it and before signing seals the bundle, and always-out-of-date so an unchanged source tree can't ship the previous commit's SHA. A checkout without git metadata stamps `unknown` rather than failing the build. Groundwork: nothing reads these keys yet. Co-authored-by: Cursor --- AGENTS.md | 19 ++++++++++++++ Project.swift | 16 +++++++++++ Where/Where/AGENTS.md | 6 +++++ Where/Where/Scripts/stamp-build-info.sh | 35 +++++++++++++++++++++++++ 4 files changed, 76 insertions(+) create mode 100755 Where/Where/Scripts/stamp-build-info.sh diff --git a/AGENTS.md b/AGENTS.md index ea4dff43..e439f311 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -62,6 +62,25 @@ simulator](#selecting-a-simulator--address-it-by-udid-not-name)). `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 diff --git a/Project.swift b/Project.swift index 756eb120..00b13f48 100644 --- a/Project.swift +++ b/Project.swift @@ -95,6 +95,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.", ), @@ -105,6 +110,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"), diff --git a/Where/Where/AGENTS.md b/Where/Where/AGENTS.md index ab4e44df..69e59dec 100644 --- a/Where/Where/AGENTS.md +++ b/Where/Where/AGENTS.md @@ -15,6 +15,12 @@ layering, and the domain rules this target merely starts up. 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. +- `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 + 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. diff --git a/Where/Where/Scripts/stamp-build-info.sh b/Where/Where/Scripts/stamp-build-info.sh new file mode 100755 index 00000000..80f6e644 --- /dev/null +++ b/Where/Where/Scripts/stamp-build-info.sh @@ -0,0 +1,35 @@ +#!/bin/bash +# +# Stamps the commit the app was built from into the built product's Info.plist, +# so the Settings > About screen can show it (read back by `WhereCore.BuildInfo`). +# +# Runs as a post-build script phase on the `Where` target — after "Process +# Info.plist" writes the file and before code signing seals the bundle — with +# dependency analysis off so it re-runs on every build rather than caching a +# stale SHA. Only the app is stamped; the extensions never read these keys. +# +# A checkout without git metadata (an exported tarball, a sandboxed script +# phase) is not a failure: the keys are still written, marked `unknown`, and the +# About screen says so rather than showing an invented commit. + +set -euo pipefail + +plist="${TARGET_BUILD_DIR}/${INFOPLIST_PATH}" + +sha=$(git -C "$SRCROOT" rev-parse --short=12 HEAD 2>/dev/null || echo "") +if [ -z "$sha" ]; then + sha="unknown" + status="unknown" +elif [ -n "$(git -C "$SRCROOT" status --porcelain 2>/dev/null)" ]; then + status="dirty" +else + status="clean" +fi + +set_key() { + /usr/libexec/PlistBuddy -c "Set :$1 $2" "$plist" 2>/dev/null || + /usr/libexec/PlistBuddy -c "Add :$1 string $2" "$plist" +} + +set_key WhereGitSHA "$sha" +set_key WhereGitStatus "$status" From 681f7c7825db4faf9a1c07c68ea03d0ba2586a54 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sun, 26 Jul 2026 13:50:05 -0700 Subject: [PATCH 02/13] Read the app's version and commit back out of the bundle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds WhereCore's BuildInfo, which reads the Xcode version keys and the commit keys the stamp script writes. Unstamped bundles — RegionViewer, StuffTestHost, the extensions — and a stamp git couldn't fill in both report no commit rather than a placeholder, so a caller can't render an invented build identity. Groundwork: no UI reads it yet. Co-authored-by: Cursor --- Where/WhereCore/Sources/About/BuildInfo.swift | 98 +++++++++++++++++++ Where/WhereCore/Tests/BuildInfoTests.swift | 72 ++++++++++++++ 2 files changed, 170 insertions(+) create mode 100644 Where/WhereCore/Sources/About/BuildInfo.swift create mode 100644 Where/WhereCore/Tests/BuildInfoTests.swift diff --git a/Where/WhereCore/Sources/About/BuildInfo.swift b/Where/WhereCore/Sources/About/BuildInfo.swift new file mode 100644 index 00000000..34dd7aba --- /dev/null +++ b/Where/WhereCore/Sources/About/BuildInfo.swift @@ -0,0 +1,98 @@ +import Foundation + +/// Which build of the app is running: the marketing version, the build number, +/// and the commit it was built from — read back from the bundle's Info.plist. +/// +/// The version keys are Xcode's; the commit keys are stamped into the built +/// product by the `Where` target's "Stamp Build Info" script phase (see the root +/// `AGENTS.md`). Only the app target is stamped, so a bundle that never ran that +/// phase — the RegionViewer developer tool, `StuffTestHost`, an app extension — +/// reports `commit == nil` rather than a placeholder, and the About screen says +/// the build is unidentified instead of showing an invented one. +public struct BuildInfo: Sendable, Equatable { + /// The commit the binary was built from, when the bundle carries one. + public struct Commit: Sendable, Equatable { + /// Abbreviated (12-character) commit hash. + public let sha: String + /// Whether the working tree had uncommitted changes at build time, so a + /// SHA from a developer build can't be mistaken for a reproducible one. + public let isDirty: Bool + + public init(sha: String, isDirty: Bool) { + self.sha = sha + self.isDirty = isDirty + } + } + + /// `CFBundleShortVersionString`, or `nil` in a bundle without one. + public let version: String? + /// `CFBundleVersion`, or `nil` in a bundle without one. + public let build: String? + /// The stamped commit, or `nil` in an unstamped bundle. + public let commit: Commit? + + /// Reads the build metadata out of `bundle`. Pass `.main` from the app; the + /// bundle is explicit (no default) so a caller can't accidentally read the + /// wrong one from an extension or a test host. + public static func current(bundle: Bundle) -> BuildInfo { + let info = bundle.infoDictionary ?? [:] + return BuildInfo(infoDictionary: info.compactMapValues { $0 as? String }) + } + + /// Builds from a raw Info.plist string dictionary. `@_spi(Testing)` because + /// production always goes through ``current(bundle:)`` — synthesizing a + /// bundle just to test the stamped / unstamped / dirty readings isn't worth + /// it. + @_spi(Testing) + public init(infoDictionary: [String: String]) { + version = Self.value(for: .version, in: infoDictionary) + build = Self.value(for: .build, in: infoDictionary) + commit = Self.commit(from: infoDictionary) + } + + private static func commit(from info: [String: String]) -> Commit? { + guard + let sha = value(for: .gitSHA, in: info), + let raw = value(for: .gitStatus, in: info), + let status = Status(rawValue: raw) + else { + return nil + } + switch status { + case .clean: return Commit(sha: sha, isDirty: false) + case .dirty: return Commit(sha: sha, isDirty: true) + // The stamp ran but git couldn't answer, so the SHA beside it is the + // literal "unknown" placeholder rather than a hash. + case .unknown: return nil + } + } + + /// The value for `key`, treating a missing entry and a blank one alike — a + /// plist string key can exist and hold `""`. + private static func value(for key: Key, in info: [String: String]) -> String? { + guard + let value = info[key.rawValue]?.trimmingCharacters(in: .whitespacesAndNewlines), + !value.isEmpty + else { + return nil + } + return value + } + + /// The Info.plist keys read here — the two Xcode writes and the two the + /// stamp script adds. The script writes the same literals; they have to + /// match. + private enum Key: String { + case version = "CFBundleShortVersionString" + case build = "CFBundleVersion" + case gitSHA = "WhereGitSHA" + case gitStatus = "WhereGitStatus" + } + + /// The values the stamp script writes for `WhereGitStatus`. + private enum Status: String { + case clean + case dirty + case unknown + } +} diff --git a/Where/WhereCore/Tests/BuildInfoTests.swift b/Where/WhereCore/Tests/BuildInfoTests.swift new file mode 100644 index 00000000..6d2700ea --- /dev/null +++ b/Where/WhereCore/Tests/BuildInfoTests.swift @@ -0,0 +1,72 @@ +import Foundation +import Testing +@_spi(Testing) @testable import WhereCore + +/// Covers reading build metadata out of an Info.plist dictionary: the stamped +/// app bundle, the unstamped ones (RegionViewer, the test host, extensions), and +/// the degraded stamps the script writes when git can't answer. +struct BuildInfoTests { + private func info( + version: String? = "1.4", + build: String? = "27", + sha: String? = "a18a9309c5d6", + status: String? = "clean", + ) -> [String: String] { + var info: [String: String] = [:] + if let version { info["CFBundleShortVersionString"] = version } + if let build { info["CFBundleVersion"] = build } + if let sha { info["WhereGitSHA"] = sha } + if let status { info["WhereGitStatus"] = status } + return info + } + + @Test func readsAStampedBundle() { + let buildInfo = BuildInfo(infoDictionary: info()) + #expect(buildInfo.version == "1.4") + #expect(buildInfo.build == "27") + #expect(buildInfo.commit == BuildInfo.Commit(sha: "a18a9309c5d6", isDirty: false)) + } + + @Test func marksACommitBuiltFromADirtyTree() { + let buildInfo = BuildInfo(infoDictionary: info(status: "dirty")) + #expect(buildInfo.commit?.isDirty == true) + #expect(buildInfo.commit?.sha == "a18a9309c5d6") + } + + @Test func reportsNoCommitForAnUnstampedBundle() { + // The RegionViewer / StuffTestHost case: versions present, no stamp. + let buildInfo = BuildInfo(infoDictionary: info(sha: nil, status: nil)) + #expect(buildInfo.version == "1.4") + #expect(buildInfo.commit == nil) + } + + @Test func reportsNoCommitWhenTheStampCouldNotReadGit() { + // A checkout without git metadata: the script still writes both keys. + let buildInfo = BuildInfo(infoDictionary: info(sha: "unknown", status: "unknown")) + #expect(buildInfo.commit == nil) + } + + @Test func reportsNoCommitForAHalfWrittenStamp() { + #expect(BuildInfo(infoDictionary: info(status: nil)).commit == nil) + #expect(BuildInfo(infoDictionary: info(sha: nil)).commit == nil) + } + + @Test func reportsNoCommitForAnUnrecognizedStatus() { + // A status the reader doesn't know is not silently treated as clean. + #expect(BuildInfo(infoDictionary: info(status: "probably-fine")).commit == nil) + } + + @Test func treatsBlankValuesAsAbsent() { + let buildInfo = BuildInfo(infoDictionary: info(version: "", build: " ", sha: "")) + #expect(buildInfo.version == nil) + #expect(buildInfo.build == nil) + #expect(buildInfo.commit == nil) + } + + @Test func readsTheHostBundleWithoutTrapping() { + // StuffTestHost is unstamped, so this only pins that reading a real + // bundle is safe and honest — not any particular value. + let buildInfo = BuildInfo.current(bundle: .main) + #expect(buildInfo.commit == nil) + } +} From 7397df8755eff110258a8f5be6047fe831eb5da1 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sun, 26 Jul 2026 13:54:06 -0700 Subject: [PATCH 03/13] Let RegionKit state where its bundled geometry came from MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Provenance for the 54 bundled GeoJSON files lived only in prose, so the app had no way to credit its data and nothing failed when a regenerated catalog added a region nobody had traced. RegionDataSource makes it a value RegionKit owns: origin, links, license, and fidelity, so a consumer can distinguish boundaries simplified from the Census set from the coarse hand-drawn Canada/EU outlines without knowing which is which. Coverage derives from the catalog — the US sources by the `us-` prefix the generator mints, the rest by an explicit id list rather than an "everything else" bucket, so a new region from a real source can't be quietly credited as hand-drawn. The test fails when a region is covered zero times or twice. Groundwork: no UI reads it yet. Co-authored-by: Cursor --- Where/RegionKit/AGENTS.md | 8 ++ Where/RegionKit/README.md | 14 ++- .../RegionKit/Sources/RegionDataSource.swift | 92 +++++++++++++++++++ .../Tests/RegionDataSourceTests.swift | 72 +++++++++++++++ 4 files changed, 185 insertions(+), 1 deletion(-) create mode 100644 Where/RegionKit/Sources/RegionDataSource.swift create mode 100644 Where/RegionKit/Tests/RegionDataSourceTests.swift diff --git a/Where/RegionKit/AGENTS.md b/Where/RegionKit/AGENTS.md index d616c633..a4a08426 100644 --- a/Where/RegionKit/AGENTS.md +++ b/Where/RegionKit/AGENTS.md @@ -39,6 +39,14 @@ This file complements the root [`AGENTS.md`](../../AGENTS.md) and the feature (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 + "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 + [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 diff --git a/Where/RegionKit/README.md b/Where/RegionKit/README.md index c1358cc6..c45cf0b0 100644 --- a/Where/RegionKit/README.md +++ b/Where/RegionKit/README.md @@ -35,6 +35,12 @@ into it for lookup. RegionKit depends only on - **`RegionGeometryCatalog`** — read-only drawable `RegionOutline`s for the developer region-map viewer (`.attribution` for a given attributor vs `.source` for the whole catalog). +- **`RegionDataSource`** — where the bundled geometry came from: the boundary + set's name, its links, its `License`, its `Fidelity` (`.authoritative` vs the + `.approximate` hand-drawn outlines), and the regions it covers. + `RegionDataSource.all` derives coverage from the catalog, and the Where app's + Settings > About screen renders it. Untranslated by design — these are proper + nouns and legal terms, and the UI supplies the localized framing. - **`RegionLog`** — RegionKit's Periscope logging facade: one `"RegionKit"` root scope with a typed `LogEvent` per collaborator (`RegionAttributor`, `RegionCatalog`, `RegionGeometryCatalog`), emitted into the process-wide @@ -109,6 +115,9 @@ ruby Where/RegionKit/Tools/generate-regions.rb ### Source data (not bundled) +Each entry below is also expressed in code as a `RegionDataSource`, which is +what the app credits on its About screen; keep the two in step. + - **`us-states.geojson`** — US state boundaries (50 states + DC + PR), `MultiPolygon` per feature keyed by `properties.NAME`; the generator splits it into one `regions/us-.geojson` per feature. Originally @@ -132,7 +141,10 @@ Adding a region is now **pure data** — no new `Region` case, no code: a new US feature; blocs/countries get an entry in the script's `NON_US` list). 3. Optionally add a `region.` entry to `Localizable.xcstrings` and point the manifest entry's `localizationKey` at it (otherwise the English `name` shows). -4. Add a `RegionAttributorTests` spot-check. +4. Attribute the geometry in `RegionDataSource` — a US state is already covered + by the `us-` rule, anything else needs its source named. + `RegionDataSourceTests` fails until it is. +5. Add a `RegionAttributorTests` spot-check. Everything downstream (`RegionStyle`, region pickers, the App Intents `RegionEntity`) derives from the catalog automatically. diff --git a/Where/RegionKit/Sources/RegionDataSource.swift b/Where/RegionKit/Sources/RegionDataSource.swift new file mode 100644 index 00000000..7e1c077e --- /dev/null +++ b/Where/RegionKit/Sources/RegionDataSource.swift @@ -0,0 +1,92 @@ +import Foundation + +/// Where the bundled region geometry came from, so the app can credit its data +/// the way it credits its code. +/// +/// RegionKit owns this because RegionKit owns the GeoJSON: provenance is a fact +/// about the data, not presentation. The values here are proper nouns, URLs, and +/// license terms, so they are deliberately **not** localized — the UI supplies +/// the translated framing around them, and reads ``fidelity`` rather than +/// hard-coding which source happens to be approximate. +/// +/// Coverage is derived from ``RegionCatalog`` rather than listed by hand, so a +/// regenerated catalog can't leave a region silently uncredited — +/// `RegionDataSourceTests` fails if one is covered zero times or twice. +public struct RegionDataSource: Sendable, Hashable { + /// The terms the geometry is available under. + public enum License: Sendable, Hashable { + /// A public-domain publication; the associated value names *why* it's in + /// the public domain. Attribution is still requested for these. + case publicDomain(String) + /// Drawn in this repository, so it carries the project's own license + /// rather than a third party's. + case originalWork + } + + /// How closely the geometry follows the real boundary — the difference + /// between "simplified from a published boundary set" and "sketched well + /// enough to spot-check with". + public enum Fidelity: Sendable, Hashable { + /// Simplified from an authoritative published boundary set. + case authoritative + /// A coarse outline with no authoritative basis. Fine for tests and a + /// rough answer, not for a residency audit. + case approximate + } + + /// The boundary set's own name, e.g. "US Census Bureau Cartographic + /// Boundary Files". Untranslated (see the type note). + public let name: String + /// Where the publisher documents the boundary set, when there is one. + public let sourceURL: URL? + /// The intermediate the bundled files were actually taken from, when the + /// data didn't come straight from the publisher — a conversion we credit + /// separately rather than implying we pulled from the primary source. + public let obtainedFromURL: URL? + public let license: License + public let fidelity: Fidelity + /// The regions this source provides geometry for, in catalog order. + public let regions: [Region] + + /// Every data source behind the bundled geometry, each covering at least one + /// catalog region. + public static let all: [RegionDataSource] = sources(coveringRegionsIn: .shared) + + /// The sources for `catalog`'s regions. Split out from ``all`` so tests can + /// drive it with a catalog of their own. + static func sources(coveringRegionsIn catalog: RegionCatalog) -> [RegionDataSource] { + // The `us-` prefix is the provenance marker, not a naming coincidence: + // `generate-regions.rb` mints exactly these ids while splitting the + // single Census `us-states.geojson` into one file per feature. + let censusStates = catalog.all.filter { $0.rawValue.hasPrefix(usStateIDPrefix) } + // Listed explicitly rather than "everything else", so a new non-US + // region from a real source can't be quietly credited as hand-drawn — + // it stays uncovered until someone files where it came from. + let handDrawn = catalog.all.filter { handDrawnIDs.contains($0.rawValue) } + + return [ + RegionDataSource( + name: "US Census Bureau Cartographic Boundary Files", + sourceURL: URL( + string: "https://www.census.gov/geographies/mapping-files/time-series/geo/cartographic-boundary.html", + ), + obtainedFromURL: URL(string: "https://eric.clst.org/tech/usgeojson/"), + license: .publicDomain("US Government works — 17 U.S.C. § 105"), + fidelity: .authoritative, + regions: censusStates, + ), + RegionDataSource( + name: "Hand-drawn outlines", + sourceURL: nil, + obtainedFromURL: nil, + license: .originalWork, + fidelity: .approximate, + regions: handDrawn, + ), + ] + .filter { !$0.regions.isEmpty } + } + + private static let usStateIDPrefix = "us-" + private static let handDrawnIDs: Set = ["canada", "european-union"] +} diff --git a/Where/RegionKit/Tests/RegionDataSourceTests.swift b/Where/RegionKit/Tests/RegionDataSourceTests.swift new file mode 100644 index 00000000..57cb86f7 --- /dev/null +++ b/Where/RegionKit/Tests/RegionDataSourceTests.swift @@ -0,0 +1,72 @@ +import Foundation +@testable import RegionKit +import Testing + +/// The drift guard behind the About screen's data credits: every bundled +/// region's geometry is attributed to exactly one source, so regenerating the +/// catalog can't quietly ship uncredited data. +struct RegionDataSourceTests { + private let catalog = RegionCatalog.shared + + @Test func everyCatalogRegionIsCoveredExactlyOnce() { + let covered = RegionDataSource.all.flatMap(\.regions) + #expect(Set(covered) == Set(catalog.all)) + // Set equality alone would accept a region claimed by two sources. + #expect(covered.count == catalog.all.count) + } + + @Test func noSourceClaimsARegionOutsideTheCatalog() { + let known = Set(catalog.all) + for source in RegionDataSource.all { + #expect(source.regions.allSatisfy { known.contains($0) }) + } + } + + @Test func aRegionFromAnUnknownSourceStaysUncovered() { + // The failure this guard exists for: a new non-US region lands in the + // catalog and no source claims it. It must not be swept into the + // hand-drawn bucket as an "everything else" fallback. + let unattributed = Region(unchecked: "atlantis") + let catalog = RegionCatalog(entries: [ + RegionCatalog.Entry( + region: unattributed, + name: "Atlantis", + localizationKey: nil, + geometryFile: "atlantis.geojson", + ), + ]) + let covered = RegionDataSource.sources(coveringRegionsIn: catalog).flatMap(\.regions) + #expect(!covered.contains(unattributed)) + } + + @Test func creditsTheCensusBoundaryFilesForUSStates() throws { + let source = try #require(RegionDataSource.all.first { $0.regions.contains(.california) }) + #expect(source.name == "US Census Bureau Cartographic Boundary Files") + #expect(source.fidelity == .authoritative) + #expect(source.license == .publicDomain("US Government works — 17 U.S.C. § 105")) + #expect(source.sourceURL != nil) + // Credited separately from the publisher: the bundled files came via a + // conversion, and saying otherwise would overstate the provenance. + #expect(source.obtainedFromURL != nil) + } + + @Test func marksTheHandDrawnOutlinesAsApproximate() throws { + let source = try #require(RegionDataSource.all.first { $0.regions.contains(.canada) }) + #expect(source.regions.contains(.europeanUnion)) + #expect(source.fidelity == .approximate) + #expect(source.license == .originalWork) + #expect(source.sourceURL == nil) + } + + @Test func listsRegionsInCatalogOrder() { + for source in RegionDataSource.all { + let expected = catalog.all.filter { source.regions.contains($0) } + #expect(source.regions == expected) + } + } + + @Test func dropsSourcesThatCoverNothing() { + // An empty catalog credits nobody rather than listing bare source names. + #expect(RegionDataSource.sources(coveringRegionsIn: RegionCatalog(entries: [])).isEmpty) + } +} From 778d7e8523d11ae09fcc39368e2d52354c78f197 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sun, 26 Jul 2026 13:54:29 -0700 Subject: [PATCH 04/13] Credit ZIPFoundation and bundle its license notice The app links ZIPFoundation for backup archives but reproduced its MIT notice nowhere, which the license requires. SoftwareCredit lists the third-party libraries WhereCore pulls in and vends the notice from a verbatim copy bundled under Resources/Licenses. A credit whose license file is missing fault-logs and asserts rather than returning empty text: shipping a dependency without its notice is a licensing defect, so the caller has to be able to tell "unavailable" from "no terms". Groundwork: no UI reads it yet. Co-authored-by: Cursor --- Where/WhereCore/AGENTS.md | 9 +++ Where/WhereCore/README.md | 6 ++ .../Sources/About/SoftwareCredit.swift | 76 +++++++++++++++++++ .../Sources/Logging/SoftwareCreditLog.swift | 28 +++++++ .../Resources/Licenses/ZIPFoundation.txt | 21 +++++ .../WhereCore/Tests/SoftwareCreditTests.swift | 48 ++++++++++++ 6 files changed, 188 insertions(+) create mode 100644 Where/WhereCore/Sources/About/SoftwareCredit.swift create mode 100644 Where/WhereCore/Sources/Logging/SoftwareCreditLog.swift create mode 100644 Where/WhereCore/Sources/Resources/Licenses/ZIPFoundation.txt create mode 100644 Where/WhereCore/Tests/SoftwareCreditTests.swift diff --git a/Where/WhereCore/AGENTS.md b/Where/WhereCore/AGENTS.md index be240d57..a7fbd309 100644 --- a/Where/WhereCore/AGENTS.md +++ b/Where/WhereCore/AGENTS.md @@ -134,6 +134,15 @@ internal shape. that derives attribution from a seeded store instead.) Detection is naturally scoped to the set — the attributor only loads tracked-region geometry, so `distanceToBoundary` is `nil` elsewhere. +- **Adding an external package means adding a credit.** A running app can't read + `Package.resolved`, so `SoftwareCredit.all` is hand-maintained against the root + [`Package.swift`](../../Package.swift): a new third-party dependency needs an + entry *and* its license notice vendored verbatim into + `Sources/Resources/Licenses/`, or the app ships a library whose license it + doesn't reproduce. `SoftwareCreditTests` pins the list and asserts every + credit's text loads. Build identity is the neighbouring `BuildInfo`, which + reads keys the app target's stamp script writes — see [Version and build + metadata](../../AGENTS.md#version-and-build-metadata). - **Impossible states trap; recoverable ones surface.** `WhereStore` methods are `async throws` so the CloudKit-backed store can report I/O failure; a `catch` must log via a `WhereLog` typed `LogEvent` (PII-free, `.public`) and leave diff --git a/Where/WhereCore/README.md b/Where/WhereCore/README.md index e1e5a36b..acd4c632 100644 --- a/Where/WhereCore/README.md +++ b/Where/WhereCore/README.md @@ -96,6 +96,12 @@ one it belongs to rather than to a god-object: a selectable look-back `RecentActivityWindow`. - **`WherePreferences`** — persisted user intent (onboarding, tracking intent, reminder / summary schedules) behind a `KeyValueStore`. +- **`BuildInfo`** + **`SoftwareCredit`** — what Settings > About shows about the + binary itself. `BuildInfo.current(bundle:)` reads the marketing version, build + number, and the commit the app was built from; `SoftwareCredit.all` lists the + third-party libraries the app links, each with the license notice bundled + under `Resources/Licenses/`. (Data-source credits are RegionKit's — see + [`RegionDataSource`](../RegionKit/README.md).) - **`WhereLog`** — the Periscope logging facade: a `"Where"` root scope with grouping scopes (`location`, `reminders`, `backup`, `widgets`, …) and a typed `LogEvent` per collaborator, emitted into `Periscope.shared`. diff --git a/Where/WhereCore/Sources/About/SoftwareCredit.swift b/Where/WhereCore/Sources/About/SoftwareCredit.swift new file mode 100644 index 00000000..006ef5a8 --- /dev/null +++ b/Where/WhereCore/Sources/About/SoftwareCredit.swift @@ -0,0 +1,76 @@ +import Foundation + +/// A third-party library the app links, with the license text it ships under. +/// +/// WhereCore owns this because WhereCore is what pulls the dependencies in — +/// today only ZIPFoundation, for backup archives. The names, versions, and +/// license titles are proper nouns and legal terms, so they are deliberately +/// **not** localized; the UI supplies the translated framing around them. +/// +/// The list is hand-maintained against the root `Package.swift` / +/// `Package.resolved`, because a running app can't read either. Adding an +/// external package means adding a credit here and vendoring its license text — +/// see [`WhereCore/AGENTS.md`](../../AGENTS.md). +public struct SoftwareCredit: Sendable, Hashable, Identifiable { + /// The package's name as it appears in `Package.swift`. + public let name: String + /// The resolved version in `Package.resolved`. + public let version: String + /// Where the project lives, for a "learn more" link. + public let homepageURL: URL? + /// The license's title, e.g. "MIT License". + public let licenseName: String + /// Stem of the vendored license file under `Resources/Licenses/`. + private let licenseResource: String + + public var id: String { + name + } + + /// Every third-party library linked into the app. + public static let all: [SoftwareCredit] = [ + SoftwareCredit( + name: "ZIPFoundation", + version: "0.9.20", + homepageURL: URL(string: "https://github.com/weichsel/ZIPFoundation"), + licenseName: "MIT License", + licenseResource: "ZIPFoundation", + ), + ] + + /// The full license text, or `nil` when the bundled file is missing or + /// unreadable. + /// + /// MIT and most permissive licenses require shipping the notice verbatim, so + /// a missing file is a real defect rather than a cosmetic one: it fault-logs + /// and trips an `assertionFailure` in debug, and returns `nil` in release so + /// the caller can say the text is unavailable instead of rendering a blank + /// screen that reads like a license with no terms. + public func licenseText() -> String? { + // `.process("Resources")` preserves the `Licenses/` subdirectory, but + // fall back to a top-level lookup in case a bundler flattens it (the + // same defense `RegionCatalog` takes for its `regions/` folder). + let url = Bundle.module.url( + forResource: licenseResource, + withExtension: "txt", + subdirectory: "Licenses", + ) ?? Bundle.module.url(forResource: licenseResource, withExtension: "txt") + + guard let url else { + Self.logger { .missingLicense(credit: name, resource: licenseResource) } + assertionFailure("Missing bundled license text for \(name)") + return nil + } + do { + return try String(contentsOf: url, encoding: .utf8) + } catch { + Self.logger(attachments: [.error(error, name: "read-error")]) { + .unreadableLicense(credit: name, description: error.localizedDescription) + } + assertionFailure("Failed to read bundled license text for \(name): \(error)") + return nil + } + } + + private static let logger = WhereLog.root(SoftwareCreditLog.self) +} diff --git a/Where/WhereCore/Sources/Logging/SoftwareCreditLog.swift b/Where/WhereCore/Sources/Logging/SoftwareCreditLog.swift new file mode 100644 index 00000000..6e09c45b --- /dev/null +++ b/Where/WhereCore/Sources/Logging/SoftwareCreditLog.swift @@ -0,0 +1,28 @@ +import PeriscopeCore + +/// Structured events for loading a bundled third-party license text. A credit +/// naming a resource the bundle doesn't carry is a programmer error (the credit +/// and the file went out of sync), so it logs at `.fault` to match the paired +/// `assertionFailure` — and it matters: shipping a dependency without its +/// license text is a licensing problem, not a cosmetic one. +enum SoftwareCreditLog: LogEvent { + /// The credit's license file is absent from the bundle. + case missingLicense(credit: String, resource: String) + /// The license file is present but could not be read as text. + case unreadableLicense(credit: String, description: String) + + static let eventName = "SoftwareCredit" + + var level: LogLevel { + .fault + } + + var message: String { + switch self { + case let .missingLicense(credit, resource): + "Missing bundled license text \(resource).txt for \(credit)" + case let .unreadableLicense(credit, description): + "Failed to read bundled license text for \(credit): \(description)" + } + } +} diff --git a/Where/WhereCore/Sources/Resources/Licenses/ZIPFoundation.txt b/Where/WhereCore/Sources/Resources/Licenses/ZIPFoundation.txt new file mode 100644 index 00000000..07362ff8 --- /dev/null +++ b/Where/WhereCore/Sources/Resources/Licenses/ZIPFoundation.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2017-2025 Thomas Zoechling (https://www.peakstep.com) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Where/WhereCore/Tests/SoftwareCreditTests.swift b/Where/WhereCore/Tests/SoftwareCreditTests.swift new file mode 100644 index 00000000..eae099a8 --- /dev/null +++ b/Where/WhereCore/Tests/SoftwareCreditTests.swift @@ -0,0 +1,48 @@ +import Foundation +import Testing +@testable import WhereCore + +/// The guard behind the About screen's open-source credits: every credited +/// library ships the license notice it's required to, and the list matches what +/// the app actually links. +struct SoftwareCreditTests { + @Test func creditsEveryLinkedThirdPartyLibrary() { + // ZIPFoundation (BackupService's archive reader/writer) is the app's only + // external package — everything else in the graph is first-party. Update + // this alongside the root Package.swift. + #expect(SoftwareCredit.all.map(\.name) == ["ZIPFoundation"]) + } + + @Test func everyCreditShipsItsLicenseText() throws { + for credit in SoftwareCredit.all { + let text = try #require( + credit.licenseText(), + "\(credit.name) is credited but its license text isn't bundled", + ) + #expect(!text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } + + @Test func theZIPFoundationNoticeCarriesItsCopyrightLine() throws { + let credit = try #require(SoftwareCredit.all.first { $0.name == "ZIPFoundation" }) + let text = try #require(credit.licenseText()) + // MIT requires the copyright notice and permission notice verbatim, so a + // truncated or placeholder file has to fail rather than merely look full. + #expect(text.contains("MIT License")) + #expect(text.contains("Copyright (c) 2017-2025 Thomas Zoechling")) + #expect(text.contains("THE SOFTWARE IS PROVIDED \"AS IS\"")) + #expect(credit.licenseName == "MIT License") + #expect(credit.version == "0.9.20") + } + + @Test func everyCreditLinksItsProject() { + for credit in SoftwareCredit.all { + #expect(credit.homepageURL != nil) + } + } + + @Test func creditsAreUniquelyIdentified() { + let ids = SoftwareCredit.all.map(\.id) + #expect(Set(ids).count == ids.count) + } +} From b49ebcee3a6f414cbb0bda67de47f0f69cdb8ffb Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sun, 26 Jul 2026 14:16:55 -0700 Subject: [PATCH 05/13] Add an About screen to Settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an About block below Storage covering the three things the app couldn't answer before: which build is running, which open-source libraries it links, and where its bundled region boundaries came from. The screen only renders — every fact is vended by the module that owns it, so adding a dependency or a dataset means updating that module's credit rather than editing this view. A library's full notice pushes on as its own screen, since a license belongs in full text rather than a truncated row. Each data source's details stack under its name instead of trailing it: a boundary set's name wraps, and a count that sometimes drops to its own line reads as a different layout per row. The source links name their host so a tap target says where it goes. Co-authored-by: Cursor --- .../Sources/Preview/PreviewSupport.swift | 20 ++ .../Sources/Resources/Localizable.xcstrings | 265 ++++++++++++++++++ .../Sources/Settings/AboutSettingsView.swift | 198 +++++++++++++ .../Sources/Settings/LicenseView.swift | 65 +++++ .../Sources/Settings/SettingsSearch.swift | 12 +- .../Sources/Settings/SettingsView.swift | 13 +- .../WhereUI/Sources/Shared/WhereFormat.swift | 54 ++++ .../Tests/AboutSettingsViewTests.swift | 95 +++++++ Where/WhereUI/Tests/LicenseViewTests.swift | 19 ++ Where/WhereUI/Tests/SettingsSearchTests.swift | 7 + Where/WhereUI/Tests/WhereFormatTests.swift | 46 +++ 11 files changed, 788 insertions(+), 6 deletions(-) create mode 100644 Where/WhereUI/Sources/Settings/AboutSettingsView.swift create mode 100644 Where/WhereUI/Sources/Settings/LicenseView.swift create mode 100644 Where/WhereUI/Tests/AboutSettingsViewTests.swift create mode 100644 Where/WhereUI/Tests/LicenseViewTests.swift diff --git a/Where/WhereUI/Sources/Preview/PreviewSupport.swift b/Where/WhereUI/Sources/Preview/PreviewSupport.swift index db166785..25170472 100644 --- a/Where/WhereUI/Sources/Preview/PreviewSupport.swift +++ b/Where/WhereUI/Sources/Preview/PreviewSupport.swift @@ -87,6 +87,26 @@ BackupModel(services: previewServices()) } + // MARK: - Build metadata (About sub-screen) + + /// Build metadata as the shipping app carries it — the About screen's + /// normal state. A preview or test bundle is never stamped, so this can't + /// come from the real bundle. + public static func stampedBuildInfo(isDirty: Bool = false) -> BuildInfo { + BuildInfo(infoDictionary: [ + "CFBundleShortVersionString": "1.0", + "CFBundleVersion": "42", + "WhereGitSHA": "a18a9309c5d6", + "WhereGitStatus": isDirty ? "dirty" : "clean", + ]) + } + + /// Build metadata from a bundle the stamp script never ran on — the + /// RegionViewer, an extension, a test host. + public static func unstampedBuildInfo() -> BuildInfo { + BuildInfo(infoDictionary: [:]) + } + // MARK: - Region picker / customization /// A primary-region selection model seeded with a few US picks + looks, diff --git a/Where/WhereUI/Sources/Resources/Localizable.xcstrings b/Where/WhereUI/Sources/Resources/Localizable.xcstrings index 55786dd3..044ede56 100644 --- a/Where/WhereUI/Sources/Resources/Localizable.xcstrings +++ b/Where/WhereUI/Sources/Resources/Localizable.xcstrings @@ -3008,6 +3008,238 @@ } } }, + "settings.about.build.row" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Build" + } + } + } + }, + "settings.about.commit.modified" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ (modified)" + } + } + } + }, + "settings.about.commit.row" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Commit" + } + } + } + }, + "settings.about.dataSource.approximate" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Approximate outline drawn for this app — coarse, and not authoritative." + } + } + } + }, + "settings.about.dataSource.authoritative" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Simplified from the published boundary set." + } + } + } + }, + "settings.about.dataSource.obtainedFromLink" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Obtained from %@" + } + } + } + }, + "settings.about.dataSource.originalWork" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No external source; covered by the app's own license." + } + } + } + }, + "settings.about.dataSource.publicDomain" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Public domain — %@" + } + } + } + }, + "settings.about.dataSource.publisherLink" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Published at %@" + } + } + } + }, + "settings.about.dataSource.regionCount" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld region" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld regions" + } + } + } + } + } + } + }, + "settings.about.dataSources.footer" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The region boundaries bundled with the app, and where they came from." + } + } + } + }, + "settings.about.dataSources.header" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Data Sources" + } + } + } + }, + "settings.about.dependencies.footer" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Where is built with these open-source libraries. Tap one to read its license." + } + } + } + }, + "settings.about.dependencies.header" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Open Source" + } + } + } + }, + "settings.about.header" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "About" + } + } + } + }, + "settings.about.license.unavailable" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This license text couldn't be loaded from the app bundle." + } + } + } + }, + "settings.about.value.unknown" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unknown" + } + } + } + }, + "settings.about.version.footer" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Include the version and commit when you report a problem." + } + } + } + }, + "settings.about.version.header" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Version" + } + } + } + }, + "settings.about.version.row" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Version" + } + } + } + }, "settings.alerts.group" : { "extractionState" : "manual", "localizations" : { @@ -3429,6 +3661,39 @@ } } }, + "settings.keywords.aboutDataSources" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "data, data sources, attribution, geojson, boundaries, borders, census, maps" + } + } + } + }, + "settings.keywords.aboutDependencies" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "license, licenses, open source, credits, acknowledgements, libraries, dependencies, third party" + } + } + } + }, + "settings.keywords.aboutVersion" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "version, build, build number, commit, sha, git, about, release" + } + } + } + }, "settings.keywords.appIcon" : { "extractionState" : "manual", "localizations" : { diff --git a/Where/WhereUI/Sources/Settings/AboutSettingsView.swift b/Where/WhereUI/Sources/Settings/AboutSettingsView.swift new file mode 100644 index 00000000..d9fb0a6f --- /dev/null +++ b/Where/WhereUI/Sources/Settings/AboutSettingsView.swift @@ -0,0 +1,198 @@ +import RegionKit +import SwiftUI +import WhereCore + +/// Settings drill-in for what the app *is* rather than what it does: which build +/// is running, the open-source libraries it links, and where its bundled region +/// boundaries came from. +/// +/// Every fact here is vended by the module that owns it — `BuildInfo` and +/// `SoftwareCredit` from `WhereCore`, `RegionDataSource` from `RegionKit` — so +/// this screen only renders and localizes. Adding a dependency or a dataset +/// means updating that module's credit, not this view. +struct AboutSettingsView: View { + var focus: SettingsFocus? + + @Environment(\.stylesheet) private var stylesheet + + private let buildInfo: BuildInfo + private let credits: [SoftwareCredit] + private let dataSources: [RegionDataSource] + + /// Defaults read the live values; the parameters exist so previews and tests + /// can render a stamped build and fixed credits, which an unstamped preview + /// bundle can't produce on its own. + init( + focus: SettingsFocus?, + buildInfo: BuildInfo = .current(bundle: .main), + credits: [SoftwareCredit] = SoftwareCredit.all, + dataSources: [RegionDataSource] = RegionDataSource.all, + ) { + self.focus = focus + self.buildInfo = buildInfo + self.credits = credits + self.dataSources = dataSources + } + + var body: some View { + SettingsFocusScope(focus: focus) { + Form { + versionSection + dependenciesSection + dataSourcesSection + } + } + .navigationTitle(String(localized: .settingsAboutHeader)) + .navigationBarTitleDisplayMode(.inline) + } + + // MARK: Version + + private var versionSection: some View { + Section { + LabeledContent( + String(localized: .settingsAboutVersionRow), + value: WhereFormat.aboutValue(buildInfo.version), + ) + LabeledContent( + String(localized: .settingsAboutBuildRow), + value: WhereFormat.aboutValue(buildInfo.build), + ) + LabeledContent( + String(localized: .settingsAboutCommitRow), + value: WhereFormat.aboutCommit(buildInfo.commit), + ) + } header: { + Text(String(localized: .settingsAboutVersionHeader)) + } footer: { + Text(String(localized: .settingsAboutVersionFooter)) + } + // Selectable so a bug report can carry the exact commit rather than a + // squinted-at transcription of it. + .textSelection(.enabled) + .settingsRow(Item.version) + } + + // MARK: Open source + + private var dependenciesSection: some View { + Section { + ForEach(credits) { credit in + // A closure-form link, not a `SettingsRoute`: routes are the + // top-level group vocabulary, and a leaf pushed from inside one + // group would need its own `navigationDestination` to match. + NavigationLink { + LicenseView(credit: credit) + } label: { + LabeledContent(credit.name) { + Text(credit.version) + } + } + } + } header: { + Text(String(localized: .settingsAboutDependenciesHeader)) + } footer: { + Text(String(localized: .settingsAboutDependenciesFooter)) + } + .settingsRow(Item.dependencies) + } + + // MARK: Data sources + + private var dataSourcesSection: some View { + Section { + ForEach(dataSources, id: \.name) { source in + dataSourceRow(source) + } + } header: { + Text(String(localized: .settingsAboutDataSourcesHeader)) + } footer: { + Text(String(localized: .settingsAboutDataSourcesFooter)) + } + .settingsRow(Item.dataSources) + } + + private func dataSourceRow(_ source: RegionDataSource) -> some View { + // Everything below the name is stacked rather than trailing-aligned: a + // boundary set's name is long enough to wrap, and a trailing count that + // sometimes drops to its own line reads as a different layout per row. + VStack(alignment: .leading, spacing: stylesheet.spacing.xSmall) { + Text(source.name) + VStack(alignment: .leading, spacing: stylesheet.spacing.xxSmall) { + // Only the prose is dimmed — a link tinted secondary alongside + // it stops reading as tappable. + Group { + Text(WhereFormat.regionDataSourceRegionCount(source.regions.count)) + Text(WhereFormat.regionDataSourceFidelity(source.fidelity)) + Text(WhereFormat.regionDataSourceLicense(source.license)) + } + .foregroundStyle(.secondary) + if let url = source.sourceURL { + Link(WhereFormat.regionDataSourcePublisher(url), destination: url) + } + if let url = source.obtainedFromURL { + Link(WhereFormat.regionDataSourceObtainedFrom(url), destination: url) + } + } + .font(.caption) + } + } +} + +extension AboutSettingsView: SettingsSection { + static var destination: SettingsDestination { + .about + } + + enum Item: SettingsItem { + case version + case dependencies + case dataSources + + var title: String { + switch self { + case .version: String(localized: .settingsAboutVersionHeader) + case .dependencies: String(localized: .settingsAboutDependenciesHeader) + case .dataSources: String(localized: .settingsAboutDataSourcesHeader) + } + } + + var keywords: [String] { + switch self { + case .version: splitKeywords(String(localized: .settingsKeywordsAboutVersion)) + case .dependencies: + splitKeywords(String(localized: .settingsKeywordsAboutDependencies)) + case .dataSources: + splitKeywords(String(localized: .settingsKeywordsAboutDataSources)) + } + } + } +} + +#if DEBUG + #Preview("Stamped build") { + NavigationStack { + AboutSettingsView(focus: nil, buildInfo: PreviewSupport.stampedBuildInfo()) + } + .whereBroadwayRoot() + } + + #Preview("Built from a dirty tree") { + NavigationStack { + AboutSettingsView( + focus: nil, + buildInfo: PreviewSupport.stampedBuildInfo(isDirty: true), + ) + } + .whereBroadwayRoot() + } + + #Preview("Unstamped build") { + // What a bundle the stamp script never ran on shows: honest unknowns + // rather than blank rows. + NavigationStack { + AboutSettingsView(focus: nil, buildInfo: PreviewSupport.unstampedBuildInfo()) + } + .whereBroadwayRoot() + } +#endif diff --git a/Where/WhereUI/Sources/Settings/LicenseView.swift b/Where/WhereUI/Sources/Settings/LicenseView.swift new file mode 100644 index 00000000..4a905f4f --- /dev/null +++ b/Where/WhereUI/Sources/Settings/LicenseView.swift @@ -0,0 +1,65 @@ +import SwiftUI +import WhereCore + +/// The full license notice for one third-party library, pushed from Settings > +/// About. Permissive licenses require the notice verbatim, so it is rendered as +/// plain monospaced text, unreflowed and untruncated. +struct LicenseView: View { + let credit: SoftwareCredit + + @Environment(\.stylesheet) private var stylesheet + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: stylesheet.spacing.xxLarge) { + header + notice + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + } + .navigationTitle(credit.name) + .navigationBarTitleDisplayMode(.inline) + } + + private var header: some View { + VStack(alignment: .leading, spacing: stylesheet.spacing.xxSmall) { + Text(credit.licenseName) + .font(.headline) + Text(credit.version) + .font(.subheadline) + .foregroundStyle(.secondary) + if let url = credit.homepageURL { + Link(url.absoluteString, destination: url) + .font(.subheadline) + } + } + } + + @ViewBuilder + private var notice: some View { + if let text = credit.licenseText() { + Text(text) + .font(.footnote.monospaced()) + .textSelection(.enabled) + } else { + // The bundled file is missing or unreadable — `licenseText()` has + // already fault-logged it. Say so, rather than showing an empty page + // that reads like a license with no terms. + Text(String(localized: .settingsAboutLicenseUnavailable)) + .font(.footnote) + .foregroundStyle(.secondary) + } + } +} + +#if DEBUG + #Preview { + NavigationStack { + if let credit = SoftwareCredit.all.first { + LicenseView(credit: credit) + } + } + .whereBroadwayRoot() + } +#endif diff --git a/Where/WhereUI/Sources/Settings/SettingsSearch.swift b/Where/WhereUI/Sources/Settings/SettingsSearch.swift index d5c7ba7a..180e4af3 100644 --- a/Where/WhereUI/Sources/Settings/SettingsSearch.swift +++ b/Where/WhereUI/Sources/Settings/SettingsSearch.swift @@ -14,6 +14,7 @@ enum SettingsDestination: Hashable, CaseIterable { case year case backup case data + case about /// The localized title shown on the top-level drill-in row and as the parent /// group label under a search result. @@ -28,6 +29,7 @@ enum SettingsDestination: Hashable, CaseIterable { case .year: String(localized: .settingsYearHeader) case .backup: String(localized: .settingsBackupHeader) case .data: String(localized: .settingsDataHeader) + case .about: String(localized: .settingsAboutHeader) } } @@ -43,6 +45,7 @@ enum SettingsDestination: Hashable, CaseIterable { case .year: "calendar" case .backup: "externaldrive.fill" case .data: "trash.fill" + case .about: "info" } } @@ -60,6 +63,7 @@ enum SettingsDestination: Hashable, CaseIterable { case .year: .orange case .backup: .teal case .data: .gray + case .about: .brown } } @@ -69,7 +73,8 @@ enum SettingsDestination: Hashable, CaseIterable { var isSheet: Bool { switch self { case .regions: true - case .attachments, .loggedDays, .location, .alerts, .appearance, .year, .backup, .data: + case .attachments, .loggedDays, .location, .alerts, .appearance, .year, .backup, .data, + .about: false } } @@ -85,6 +90,9 @@ enum SettingsListSection: CaseIterable { case notifications case display case storage + /// Last on purpose: About is reference material, so it sits below everything + /// actionable, where iOS Settings puts its own. + case about var destinations: [SettingsDestination] { switch self { @@ -93,6 +101,7 @@ enum SettingsListSection: CaseIterable { case .notifications: [.alerts] case .display: [.appearance, .year] case .storage: [.backup, .data] + case .about: [.about] } } } @@ -199,6 +208,7 @@ enum SettingsCatalog { + VisibleYearSettingsView.searchResults + BackupSettingsView.searchResults + DataSettingsView.searchResults + + AboutSettingsView.searchResults /// The results matching a (trimmed, non-empty) query. static func results(matching query: String) -> [SettingsSearchResult] { diff --git a/Where/WhereUI/Sources/Settings/SettingsView.swift b/Where/WhereUI/Sources/Settings/SettingsView.swift index 66bc2cc9..f4f8fa8d 100644 --- a/Where/WhereUI/Sources/Settings/SettingsView.swift +++ b/Where/WhereUI/Sources/Settings/SettingsView.swift @@ -4,9 +4,9 @@ import WhereCore /// Settings tab: an iOS-Settings-style top-level list of icon rows that drill /// into grouped sub-screens — a Data group at the top (attachments, logged days, -/// regions), then location, alerts, appearance, report year, backup, and erase -/// — plus a search field that filters individual settings and deep-links to the -/// screen — and the row — containing each. +/// regions), then location, alerts, appearance, report year, backup, erase, and +/// About — plus a search field that filters individual settings and deep-links +/// to the screen — and the row — containing each. /// /// The top level owns nothing but navigation; behavior lives in the sub-screens /// (`LocationSettingsView`, `AlertsSettingsView`, …). The scene's report model and @@ -106,7 +106,8 @@ struct SettingsView: View { switch destination { case .regions: showRegions = true - case .attachments, .loggedDays, .location, .alerts, .appearance, .year, .backup, .data: + case .attachments, .loggedDays, .location, .alerts, .appearance, .year, .backup, .data, + .about: assertionFailure("\(destination) is a push destination, not a sheet") } } @@ -139,7 +140,7 @@ struct SettingsView: View { ) case .year: report.selectedYear.formatted(.number.grouping(.never)) - case .attachments, .loggedDays, .regions, .alerts, .appearance, .backup, .data: + case .attachments, .loggedDays, .regions, .alerts, .appearance, .backup, .data, .about: nil } } @@ -185,6 +186,8 @@ struct SettingsView: View { BackupSettingsView(backup: backup, focus: route.focus) case .data: DataSettingsView(report: report, focus: route.focus) + case .about: + AboutSettingsView(focus: route.focus) } } diff --git a/Where/WhereUI/Sources/Shared/WhereFormat.swift b/Where/WhereUI/Sources/Shared/WhereFormat.swift index 83778628..64f33d23 100644 --- a/Where/WhereUI/Sources/Shared/WhereFormat.swift +++ b/Where/WhereUI/Sources/Shared/WhereFormat.swift @@ -341,4 +341,58 @@ enum WhereFormat { String(localized: .recentActivityUnavailableUnknown) } } + + // MARK: About + + /// A build-metadata value, or a localized "Unknown" when the bundle doesn't + /// carry one — so a blank row can never read as a real, empty value. + static func aboutValue(_ value: String?) -> String { + value ?? String(localized: .settingsAboutValueUnknown) + } + + /// The commit the app was built from, flagged when the working tree had + /// uncommitted changes so a developer build isn't mistaken for a + /// reproducible one. + static func aboutCommit(_ commit: BuildInfo.Commit?) -> String { + guard let commit else { return String(localized: .settingsAboutValueUnknown) } + return commit.isDirty + ? String(localized: .settingsAboutCommitModified(commit.sha)) + : commit.sha + } + + static func regionDataSourceRegionCount(_ count: Int) -> String { + String(localized: .settingsAboutDataSourceRegionCount(count)) + } + + static func regionDataSourceFidelity(_ fidelity: RegionDataSource.Fidelity) -> String { + switch fidelity { + case .authoritative: String(localized: .settingsAboutDataSourceAuthoritative) + case .approximate: String(localized: .settingsAboutDataSourceApproximate) + } + } + + static func regionDataSourceLicense(_ license: RegionDataSource.License) -> String { + switch license { + case let .publicDomain(rationale): + String(localized: .settingsAboutDataSourcePublicDomain(rationale)) + case .originalWork: + String(localized: .settingsAboutDataSourceOriginalWork) + } + } + + static func regionDataSourcePublisher(_ url: URL) -> String { + String(localized: .settingsAboutDataSourcePublisherLink(linkHost(url))) + } + + static func regionDataSourceObtainedFrom(_ url: URL) -> String { + String(localized: .settingsAboutDataSourceObtainedFromLink(linkHost(url))) + } + + /// A link's bare host ("census.gov"), so a data-source link names where it + /// goes instead of reading as an unlabeled "Publisher". Falls back to the + /// whole URL for anything without a host. + private static func linkHost(_ url: URL) -> String { + guard let host = url.host() else { return url.absoluteString } + return host.hasPrefix("www.") ? String(host.dropFirst(4)) : host + } } diff --git a/Where/WhereUI/Tests/AboutSettingsViewTests.swift b/Where/WhereUI/Tests/AboutSettingsViewTests.swift new file mode 100644 index 00000000..624126d6 --- /dev/null +++ b/Where/WhereUI/Tests/AboutSettingsViewTests.swift @@ -0,0 +1,95 @@ +import RegionKit +import SwiftUI +import TestHostSupport +import Testing +@_spi(Testing) import WhereCore +@testable import WhereUI + +/// Covers the About screen's registration in Settings search and that it renders +/// each build-metadata state — including the unstamped one, which is what every +/// bundle other than the shipping app produces. +@MainActor +struct AboutSettingsViewTests { + // MARK: Placement + + @Test func aboutIsTheLastBlockInTheSettingsList() throws { + let last = try #require(SettingsListSection.allCases.last) + #expect(last.destinations == [.about]) + } + + @Test func aboutPushesRatherThanPresentingASheet() { + #expect(!SettingsDestination.about.isSheet) + } + + // MARK: Search + + @Test func everyItemIsRegisteredForSearch() { + let registered = AboutSettingsView.searchResults + #expect(registered.count == AboutSettingsView.Item.allCases.count) + #expect(registered.allSatisfy { $0.destination == .about }) + } + + @Test func searchFindsTheAboutSettingsByKeyword() { + // None of these words appear in the section titles, so a match proves the + // keyword lists are wired rather than the titles happening to overlap. + for query in ["licenses", "sha", "geojson"] { + let destinations = Set(SettingsCatalog.results(matching: query).map(\.destination)) + #expect(destinations.contains(.about), "no About result for \"\(query)\"") + } + } + + // MARK: Rendering + + @Test func hostsAStampedBuild() throws { + let rootView = NavigationStack { + AboutSettingsView(focus: nil, buildInfo: PreviewSupport.stampedBuildInfo()) + } + try show(UIHostingController(rootView: rootView)) { hosted in + #expect(hosted.view != nil) + } + } + + @Test func hostsAnUnstampedBuild() throws { + // The RegionViewer / test-host case: no version keys and no commit. + let rootView = NavigationStack { + AboutSettingsView(focus: nil, buildInfo: PreviewSupport.unstampedBuildInfo()) + } + try show(UIHostingController(rootView: rootView)) { hosted in + #expect(hosted.view != nil) + } + } + + @Test func hostsWithASearchFocus() throws { + let focus = try #require( + SettingsCatalog.results.first { $0.destination == .about }, + ).focus + let rootView = NavigationStack { + AboutSettingsView(focus: focus, buildInfo: PreviewSupport.stampedBuildInfo()) + } + try show(UIHostingController(rootView: rootView)) { hosted in + #expect(hosted.view != nil) + } + } + + @Test func hostsWithNothingToCredit() throws { + // Defensive: an empty credit list must render empty sections rather than + // trip on a force-unwrapped "first" credit. + let rootView = NavigationStack { + AboutSettingsView( + focus: nil, + buildInfo: PreviewSupport.unstampedBuildInfo(), + credits: [], + dataSources: [], + ) + } + try show(UIHostingController(rootView: rootView)) { hosted in + #expect(hosted.view != nil) + } + } + + @Test func creditsTheLiveDependencyAndDataSourceLists() { + // The screen shows what the owning modules vend, not a copy of its own. + #expect(!SoftwareCredit.all.isEmpty) + #expect(!RegionDataSource.all.isEmpty) + } +} diff --git a/Where/WhereUI/Tests/LicenseViewTests.swift b/Where/WhereUI/Tests/LicenseViewTests.swift new file mode 100644 index 00000000..4487876e --- /dev/null +++ b/Where/WhereUI/Tests/LicenseViewTests.swift @@ -0,0 +1,19 @@ +import SwiftUI +import TestHostSupport +import Testing +import WhereCore +@testable import WhereUI + +/// Covers the license notice pushed from Settings > About: it renders the +/// bundled text for a real credit, which is the compliance-relevant path. +@MainActor +struct LicenseViewTests { + @Test func hostsEveryCreditsNotice() throws { + for credit in SoftwareCredit.all { + let rootView = NavigationStack { LicenseView(credit: credit) } + try show(UIHostingController(rootView: rootView)) { hosted in + #expect(hosted.view != nil) + } + } + } +} diff --git a/Where/WhereUI/Tests/SettingsSearchTests.swift b/Where/WhereUI/Tests/SettingsSearchTests.swift index 40b7a891..3cb70079 100644 --- a/Where/WhereUI/Tests/SettingsSearchTests.swift +++ b/Where/WhereUI/Tests/SettingsSearchTests.swift @@ -41,6 +41,13 @@ struct SettingsSearchTests { #expect(destinations.contains(.alerts)) } + @Test func matchesTheAboutScreenOnALicenseKeyword() { + // "license" is nowhere in a section title, so this only passes if the + // About screen's keywords are registered. + let results = SettingsCatalog.results(matching: "license") + #expect(results.contains { $0.destination == .about }) + } + @Test func focusedRouteCarriesTheResultsDestinationAndFocus() throws { let result = try #require(SettingsCatalog.results.first) let route = SettingsRoute(result) diff --git a/Where/WhereUI/Tests/WhereFormatTests.swift b/Where/WhereUI/Tests/WhereFormatTests.swift index 1af97d05..bfe65b28 100644 --- a/Where/WhereUI/Tests/WhereFormatTests.swift +++ b/Where/WhereUI/Tests/WhereFormatTests.swift @@ -136,4 +136,50 @@ struct WhereFormatTests { "Every feature decoded straight from the bundled GeoJSON, at full authored fidelity.", ) } + + // MARK: About + + @Test func aboutValueNamesAMissingBuildValue() { + #expect(WhereFormat.aboutValue("1.0") == "1.0") + // An absent value must read as unknown, never as a blank that looks + // like a real (empty) version. + #expect(WhereFormat.aboutValue(nil) == "Unknown") + } + + @Test func aboutCommitFlagsADirtyTree() { + let sha = "a18a9309c5d6" + #expect(WhereFormat.aboutCommit(BuildInfo.Commit(sha: sha, isDirty: false)) == sha) + #expect( + WhereFormat.aboutCommit(BuildInfo.Commit(sha: sha, isDirty: true)) + == "\(sha) (modified)", + ) + #expect(WhereFormat.aboutCommit(nil) == "Unknown") + } + + @Test func regionDataSourceCountPluralizes() { + #expect(WhereFormat.regionDataSourceRegionCount(1) == "1 region") + #expect(WhereFormat.regionDataSourceRegionCount(52) == "52 regions") + } + + @Test func regionDataSourceFidelitySwitchesResolve() { + #expect( + WhereFormat.regionDataSourceFidelity(.authoritative) + == "Simplified from the published boundary set.", + ) + #expect( + WhereFormat.regionDataSourceFidelity(.approximate) + == "Approximate outline drawn for this app — coarse, and not authoritative.", + ) + } + + @Test func regionDataSourceLicenseSwitchesResolve() { + #expect( + WhereFormat.regionDataSourceLicense(.publicDomain("17 U.S.C. § 105")) + == "Public domain — 17 U.S.C. § 105", + ) + #expect( + WhereFormat.regionDataSourceLicense(.originalWork) + == "No external source; covered by the app's own license.", + ) + } } From a521bcb1ca7302058752d1e6b9e9ed9cbd9a5afa Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sun, 26 Jul 2026 14:38:43 -0700 Subject: [PATCH 06/13] Document the About screen and the credit-ownership rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Records the rule the previous commits establish: a credit belongs to the module that owns the thing being credited, and the About screen only renders. Without it the natural next move — hard-coding a new library into the view — looks fine. Files the follow-up the build stamp makes possible: a LogSession names only a version and build number, both pinned in the manifest, so every developer build logs as v1.0 (1). Periscope sits below WhereCore and can't read BuildInfo, so carrying the commit needs a seam rather than a direct adoption. Co-authored-by: Cursor --- TODOs.md | 1 + Where/AGENTS.md | 15 +++++++++++++++ Where/WhereUI/README.md | 6 ++++-- 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/TODOs.md b/TODOs.md index cb584604..b2ef0c5e 100644 --- a/TODOs.md +++ b/TODOs.md @@ -98,6 +98,7 @@ inbox rather than here. ## P1s (Should do) ## P2s (Nice to have) +- feat(PeriscopeCore) [needs-design]: A `LogSession` can't name the build it came from. `LogSession.current()` reads only `CFBundleShortVersionString` / `CFBundleVersion` off the main bundle (`Shared/Periscope/PeriscopeCore/Sources/Store/LogSession.swift:32`), and the viewer renders that pair as the session's identity (`PeriscopeTools/Sources/Viewer/PeriscopeViewer.swift:269`) — but the Where app pins both in the manifest (`Project.swift:101`), so every developer build reads `v1.0 (1)` and weeks-old logs can't be tied to the code that produced them. The commit is now available: the app stamps `WhereGitSHA` / `WhereGitStatus` into its Info.plist and `WhereCore.BuildInfo` reads them back. PeriscopeCore can't depend on WhereCore (Periscope sits below the Where modules), so this needs a seam rather than a direct adoption — an optional commit field, or general resource attributes on `LogSession` that the host app fills at bootstrap. Spans Periscope and Where, hence here. (agent 2026-07-26) - perf(StuffTestHost) [needs-design]: Two loose ends in the shared test host, both reaching the root Tuist manifest, which is why they sit here rather than in a StuffTestHost file. The WhereCore-always-embedded build trade-off is documented and verified load-bearing at `Project.swift:256` — decide whether to keep documenting it or split the host so unrelated bundles don't pay for it. Separately, the scene configuration name is spelled twice, in `Shared/StuffTestHost/Sources/AppDelegate.swift:11` and `Project.swift:244`, so the two can drift silently. (audit 2026-07-26) # Completed issues diff --git a/Where/AGENTS.md b/Where/AGENTS.md index b3b367c9..4e466697 100644 --- a/Where/AGENTS.md +++ b/Where/AGENTS.md @@ -107,6 +107,21 @@ A new screen belongs *inside* that shape — a destination pushed from a tab, a sheet, or a Settings row. Adding a fourth tab is a product decision, not a refactor: raise it before building. +Settings itself is a typed-route list: `SettingsSearch.swift` owns +`SettingsDestination` / `SettingsListSection` / `SettingsRoute`, and every +switch over them is exhaustive, so a new drill-in is a set of compile errors to +fill in rather than a screen you can forget to register. **About is deliberately +the last block**, below everything actionable. + +**Credits are vended by the module that owns the thing being credited**, and the +About screen only renders them: `SoftwareCredit` (WhereCore) for a linked +library plus its bundled notice, `RegionDataSource` (RegionKit) for bundled +geometry, `BuildInfo` (WhereCore) for the running build. Adding a dependency or +a dataset means adding its credit *there* — see +[`WhereCore/AGENTS.md`](WhereCore/AGENTS.md) and +[`RegionKit/AGENTS.md`](RegionKit/AGENTS.md) — never a list hard-coded in the +view. + ## Localization All user-facing copy resolves through each module's `Localizable.xcstrings` via diff --git a/Where/WhereUI/README.md b/Where/WhereUI/README.md index 6c988771..70d5159b 100644 --- a/Where/WhereUI/README.md +++ b/Where/WhereUI/README.md @@ -22,8 +22,10 @@ the feature [`Where/AGENTS.md`](../AGENTS.md) and this module's [`LifecycleKit`](../../Shared/LifecycleKit)) gated in front of `MainTabs`, the Liquid Glass tab bar over three tabs — Locations, Your Year, Settings. Elsewhere is an entry card on Locations, Resolve a Locations toolbar button, - and the data screens (attachments, logged days, regions) sit in the Settings - "Data" group. The app injects the launch-built model + runner + the data screens (attachments, logged days, regions) sit in the Settings + "Data" group, and `AboutSettingsView` is the last Settings block (build + identity, open-source notices, and bundled-data provenance, each vended by the + module that owns it). The app injects the launch-built model + runner (`init(model:launcher:)`); a no-arg `init()` builds its own for previews and the hosted UI test. - **`WhereModel`** — app-level state: the onboarding flag, the owned From f6fe23bf392e5d6bc120b8f12531fb9d6226bd9d Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sun, 26 Jul 2026 16:23:55 -0700 Subject: [PATCH 07/13] Move attribution into CreditKit and credit the vendored skills Attribution lived in WhereCore only because WhereCore happened to be the one module linking an external package. That made the dependency edge point the wrong way: a package pulled in by Broadway or Periscope could not be credited without those modules depending on app code. Attribution now has its own Shared leaf module, CreditKit, that anything may depend on. Its manifest and license notices are generated rather than hand-kept -- generate-credits.rb derives libraries from the packages a target actually links via .product(name:package:) and reads each notice at the pinned revision, so a new dependency is credited wherever it lands, and a package resolved only for tooling (BumperBowling, swift-syntax) correctly is not. The same script credits the four agent skills sync-agents vendors: we make copies of MIT-licensed work and were shipping none of the notices, because sync-agents copies only the skill subdirectory and drops the upstream LICENSE. SoftwareCredit.Kind keeps them apart from linked libraries, and About renders them as a separate section -- they are credited because the repo copies them, not because they are in the binary, and one merged list would describe the app falsely. Co-authored-by: Cursor --- AGENTS.md | 36 +++++ Package.swift | 12 ++ Project.swift | 9 ++ Shared/CreditKit/AGENTS.md | 52 +++++++ Shared/CreditKit/README.md | 79 +++++++++++ Shared/CreditKit/Sources/CreditCatalog.swift | 58 ++++++++ .../Sources/Logging/CreditCatalogLog.swift | 35 +++++ .../CreditKit/Sources/Logging/CreditLog.swift | 28 ++++ .../Sources/Logging/SoftwareCreditLog.swift | 10 +- .../Resources/Licenses/ZIPFoundation.txt | 0 .../Licenses/swift-concurrency-pro.txt | 21 +++ .../Resources/Licenses/swift-testing-pro.txt | 21 +++ .../Resources/Licenses/swiftdata-pro.txt | 21 +++ .../Resources/Licenses/swiftui-pro.txt | 21 +++ .../CreditKit/Sources/Resources/credits.json | 42 ++++++ Shared/CreditKit/Sources/SoftwareCredit.swift | 94 +++++++++++++ .../CreditKit/Tests/CreditCatalogTests.swift | 54 ++++++++ .../Tests/CreditKitTestSupport.swift | 22 +++ .../CreditKit/Tests/SoftwareCreditTests.swift | 36 +++++ Shared/CreditKit/Tools/generate-credits.rb | 130 ++++++++++++++++++ Where/AGENTS.md | 16 ++- Where/WhereCore/AGENTS.md | 16 +-- Where/WhereCore/README.md | 11 +- .../Sources/About/SoftwareCredit.swift | 76 ---------- .../WhereCore/Tests/SoftwareCreditTests.swift | 48 ------- .../Sources/Resources/Localizable.xcstrings | 33 +++++ .../Sources/Settings/AboutSettingsView.swift | 56 ++++++-- .../Sources/Settings/LicenseView.swift | 10 +- .../Tests/AboutSettingsViewTests.swift | 20 ++- Where/WhereUI/Tests/LicenseViewTests.swift | 4 +- 30 files changed, 903 insertions(+), 168 deletions(-) create mode 100644 Shared/CreditKit/AGENTS.md create mode 100644 Shared/CreditKit/README.md create mode 100644 Shared/CreditKit/Sources/CreditCatalog.swift create mode 100644 Shared/CreditKit/Sources/Logging/CreditCatalogLog.swift create mode 100644 Shared/CreditKit/Sources/Logging/CreditLog.swift rename {Where/WhereCore => Shared/CreditKit}/Sources/Logging/SoftwareCreditLog.swift (71%) rename {Where/WhereCore => Shared/CreditKit}/Sources/Resources/Licenses/ZIPFoundation.txt (100%) create mode 100644 Shared/CreditKit/Sources/Resources/Licenses/swift-concurrency-pro.txt create mode 100644 Shared/CreditKit/Sources/Resources/Licenses/swift-testing-pro.txt create mode 100644 Shared/CreditKit/Sources/Resources/Licenses/swiftdata-pro.txt create mode 100644 Shared/CreditKit/Sources/Resources/Licenses/swiftui-pro.txt create mode 100644 Shared/CreditKit/Sources/Resources/credits.json create mode 100644 Shared/CreditKit/Sources/SoftwareCredit.swift create mode 100644 Shared/CreditKit/Tests/CreditCatalogTests.swift create mode 100644 Shared/CreditKit/Tests/CreditKitTestSupport.swift create mode 100644 Shared/CreditKit/Tests/SoftwareCreditTests.swift create mode 100755 Shared/CreditKit/Tools/generate-credits.rb delete mode 100644 Where/WhereCore/Sources/About/SoftwareCredit.swift delete mode 100644 Where/WhereCore/Tests/SoftwareCreditTests.swift diff --git a/AGENTS.md b/AGENTS.md index e439f311..31219bf3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -96,6 +96,35 @@ read these keys. churn around the one real entry. Write a catalog through Xcode, or normalize it afterwards; the script only touches formatting, never content. +## Attribution + +Third-party attribution lives in **one** place — +[`Shared/CreditKit`](Shared/CreditKit/AGENTS.md) — rather than wherever a +dependency happened to be introduced. It is a leaf module anything may depend +on, so a package pulled in by *any* module can be credited without inverting the +dependency that introduced it. + +Its manifest and license notices are **generated, never hand-edited**: + +```bash +ruby Shared/CreditKit/Tools/generate-credits.rb +``` + +The script derives what to credit 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` — and fetches each notice 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 it and commit the result whenever you add or bump a package or a +skill**; nothing fails a build if you forget, but `CreditKitTests` pins the +expected names and will. + +`SoftwareCredit.Kind` keeps a **linked library** apart from a **development +tool**, and that distinction must survive into any UI: a tool is credited +because the repo copies it, not because it ships. 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 @@ -132,6 +161,13 @@ 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 `CreditKit` credits them (as *development tools*, +distinct from libraries the app links). After adding a skill or running +`./sync-agents --update`, re-run `ruby Shared/CreditKit/Tools/generate-credits.rb` +and commit the regenerated manifest and notices — 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 diff --git a/Package.swift b/Package.swift index a2e69a97..613013ae 100644 --- a/Package.swift +++ b/Package.swift @@ -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: "JournalKit", targets: ["JournalKit"]), .library(name: "PeriscopeCore", targets: ["PeriscopeCore"]), @@ -35,6 +36,16 @@ let package = Package( name: "StuffCore", path: "Shared/StuffCore/Sources", ), + .target( + name: "CreditKit", + dependencies: [ + .target(name: "PeriscopeCore"), + ], + path: "Shared/CreditKit/Sources", + resources: [ + .process("Resources"), + ], + ), .target( name: "LifecycleKit", path: "Shared/LifecycleKit/Sources", @@ -106,6 +117,7 @@ let package = Package( .target(name: "WhereCore"), .target(name: "BroadwayCore"), .target(name: "BroadwayUI"), + .target(name: "CreditKit"), .target(name: "LifecycleKit"), .target(name: "PeriscopeCore"), .target(name: "PeriscopeTools"), diff --git a/Project.swift b/Project.swift index 00b13f48..1edde71b 100644 --- a/Project.swift +++ b/Project.swift @@ -291,6 +291,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", @@ -450,6 +456,7 @@ let project = Project( "RegionViewer", "StuffTestHost", "StuffCoreTests", + "CreditKitTests", "LifecycleKitTests", "JournalKitTests", "PeriscopeCoreTests", @@ -468,6 +475,7 @@ let project = Project( ]), testAction: .targets([ "StuffCoreTests", + "CreditKitTests", "LifecycleKitTests", "JournalKitTests", "PeriscopeCoreTests", @@ -485,6 +493,7 @@ let project = Project( ]), ), testScheme(name: "StuffCoreTests"), + testScheme(name: "CreditKitTests"), testScheme(name: "LifecycleKitTests"), testScheme(name: "JournalKitTests"), testScheme(name: "PeriscopeCoreTests"), diff --git a/Shared/CreditKit/AGENTS.md b/Shared/CreditKit/AGENTS.md new file mode 100644 index 00000000..5f49c5b0 --- /dev/null +++ b/Shared/CreditKit/AGENTS.md @@ -0,0 +1,52 @@ +# CreditKit — Module Shape + +Attribution for the third-party work a Stuff app is built with, as data the app +can render. See [`README.md`](README.md) for the API and the generation flow; +the repo-wide build, format, and convention rules are in the root +[`AGENTS.md`](../../AGENTS.md). + +## Scope & dependencies + +- **May import:** Foundation, `PeriscopeCore` (logging only). +- **Must not import:** any app or feature module (`WhereCore`, `WhereUI`, …), + or any UI framework. CreditKit is a leaf that anything may depend on; a + dependency edge pointing *out* of it would defeat the reason it exists. +- **Wired in:** `Package.swift` (`CreditKit` product, consumed by `WhereUI`) + and `Project.swift` (`CreditKitTests`, in the `Stuff-iOS-Tests` scheme). + +## Layering + +`SoftwareCredit` is the value; `CreditCatalog` is the bundled list. Nothing +else in the module holds state, and neither type knows how it is presented — +grouping, labeling, and translation belong to the consuming UI. + +## Invariants + +- **The manifest and the notices are generated, never hand-edited.** + `Sources/Resources/credits.json` and `Sources/Resources/Licenses/*.txt` are + written by `Tools/generate-credits.rb`; editing them by hand puts them at odds + with `Package.resolved` and `.agents/external-skills.json` and the next run + silently reverts it. Re-run the script and commit the result. +- **Adding a dependency anywhere means re-running the script.** The generator + reads `.product(name:package:)` out of the root `Package.swift`, so a package + linked by *any* module is picked up — but only when someone runs it. + `CreditCatalogTests` pins the expected names and is what fails if that is + skipped, so update the test in the same change as a deliberate dependency + change. +- **`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. +- **Only packages that are actually linked are `.library` credits.** Packages + resolved for tooling alone (BumperBowling, and swift-syntax beneath it) are + excluded by construction, because the generator keys off `.product(…)` usage + rather than the `dependencies:` list. + +## Testing + +`CreditKitTests` covers the bundled catalog end to end: that the manifest +decodes, that the expected libraries and tools are present, and that every +credit resolves a non-empty notice. Shared fixtures live in +`CreditKitTestSupport.swift`. The missing-notice path is deliberately untested — +it trips an `assertionFailure`, which traps the process rather than raising a +catchable issue. diff --git a/Shared/CreditKit/README.md b/Shared/CreditKit/README.md new file mode 100644 index 00000000..c839549f --- /dev/null +++ b/Shared/CreditKit/README.md @@ -0,0 +1,79 @@ +# CreditKit + +Attribution for the third-party work a Stuff app is built with — the libraries +it links and the tools it is developed with — as data the app can render. + +CreditKit exists so attribution has one home instead of living wherever a +dependency happened to be introduced. It is a Shared module at the bottom of the +graph, so any module's dependency can be credited without inverting the +dependency that introduced it. + +## Install + +Add the `CreditKit` product to a target in the root `Package.swift`. It depends +only on `PeriscopeCore`, for logging. + +## Quick start + +```swift +import CreditKit + +for credit in CreditCatalog.shared.credits(ofKind: .library) { + print(credit.name, credit.version, credit.licenseName) + print(credit.licenseText() ?? "notice unavailable") +} +``` + +The Where app renders this in Settings → About: `WhereUI`'s `AboutSettingsView` +shows one section per kind, and `LicenseView` pushes the full notice. + +## Public API + +- **`SoftwareCredit`** — one credited work: `name`, `kind`, `version`, + `homepageURL`, `licenseName`, and `licenseText()` for the verbatim notice. +- **`SoftwareCredit.Kind`** — `.library` (compiled into the binary) or + `.developmentTool` (used to build the project, absent from the shipped app). +- **`CreditCatalog`** — `shared` for the bundled catalog, `credits` for + everything in manifest order, and `credits(ofKind:)` to filter. + +## How it works + +`CreditCatalog.shared` decodes a bundled `credits.json`, and each credit's +notice is a vendored `Resources/Licenses/.txt`. Both are **generated**: + +```bash +ruby Shared/CreditKit/Tools/generate-credits.rb +``` + +The script derives the list from what the repository already declares, so the +manifest can't drift from reality: + +| Kind | Derived from | Notice fetched from | +|------|--------------|---------------------| +| `.library` | packages a target links via `.product(name:package:)` in `Package.swift`, pinned by `Package.resolved` | the project's GitHub repo, at the pinned revision | +| `.developmentTool` | `.agents/external-skills.json` — the agent skills `./sync-agents` vendors | the skill's GitHub repo, at the pinned ref | + +Deriving rather than hand-maintaining is the point: a package linked by *any* +module is credited the next time the script runs, so no module has to remember +to vend a credit. It needs network and an authenticated `gh`, and it is +idempotent — re-run it after changing a dependency or running +`./sync-agents --update`, and commit the result. + +Notices are read at the **pinned revision**, not the default branch, so the text +shipped is the one governing the revision actually in use. + +## Contracts and limitations + +- **A credit is only as current as the last script run.** Nothing fails a build + when a new dependency lands without regenerating; `CreditCatalogTests` pins + the expected names, so that suite is what catches the drift. +- **Development tools are not in the binary.** They are credited because the + repository makes copies of them, which permissive licenses ask us to + attribute. Any UI must keep the two kinds visually distinct so a reader isn't + told something untrue about the app they are running. +- **A missing or unreadable notice is a defect, not a cosmetic gap.** It + fault-logs and trips an `assertionFailure` in debug; in release + `licenseText()` returns `nil` so a caller can say the text is unavailable + rather than render a blank screen that reads like a license with no terms. +- **Names, versions, and license titles are never localized.** They are proper + nouns and legal terms; a UI supplies the translated framing around them. diff --git a/Shared/CreditKit/Sources/CreditCatalog.swift b/Shared/CreditKit/Sources/CreditCatalog.swift new file mode 100644 index 00000000..2ed891bd --- /dev/null +++ b/Shared/CreditKit/Sources/CreditCatalog.swift @@ -0,0 +1,58 @@ +import Foundation + +/// Every third-party work the project credits, loaded once from the bundled +/// `credits.json` manifest. +/// +/// The manifest is **generated**, not hand-written: `Tools/generate-credits.rb` +/// derives it from the root `Package.swift` / `Package.resolved` (for linked +/// libraries) and `.agents/external-skills.json` (for development tools), and +/// vendors each license notice beside it. A running app can read neither of +/// those files, which is why the answer is baked into a resource at build time. +/// +/// Deriving the list rather than maintaining it by hand is what keeps it honest +/// as the dependency graph changes: a package linked by *any* module shows up +/// the next time the script runs, so a credit is not something a module has to +/// remember to vend. +public struct CreditCatalog: Sendable { + /// Every credit, in manifest order. + public let credits: [SoftwareCredit] + + /// The process-wide catalog, decoded from the bundle on first use. + public static let shared: CreditCatalog = .loadFromBundle() + + @_spi(Testing) public init(credits: [SoftwareCredit]) { + self.credits = credits + } + + /// The credits of one kind, in manifest order. + public func credits(ofKind kind: SoftwareCredit.Kind) -> [SoftwareCredit] { + credits.filter { $0.kind == kind } + } +} + +extension CreditCatalog { + private static let logger = CreditLog.catalog + + @_spi(Testing) public static func decode(from data: Data) throws -> CreditCatalog { + try CreditCatalog(credits: JSONDecoder().decode([SoftwareCredit].self, from: data)) + } + + private static func loadFromBundle() -> CreditCatalog { + guard let url = Bundle.module.url(forResource: "credits", withExtension: "json") else { + logger { .missingManifest } + assertionFailure("Missing bundled credits.json") + return CreditCatalog(credits: []) + } + do { + let catalog = try decode(from: Data(contentsOf: url)) + logger { .loaded(creditCount: catalog.credits.count) } + return catalog + } catch { + logger(attachments: [.error(error, name: "decode-error")]) { + .decodeFailed(description: error.localizedDescription) + } + assertionFailure("Failed to decode bundled credits.json: \(error)") + return CreditCatalog(credits: []) + } + } +} diff --git a/Shared/CreditKit/Sources/Logging/CreditCatalogLog.swift b/Shared/CreditKit/Sources/Logging/CreditCatalogLog.swift new file mode 100644 index 00000000..ac4e7375 --- /dev/null +++ b/Shared/CreditKit/Sources/Logging/CreditCatalogLog.swift @@ -0,0 +1,35 @@ +import PeriscopeCore + +/// Structured events for `CreditCatalog`'s bundled-manifest load. A missing or +/// unparseable `credits.json` is a programmer error (corrupt bundled resource), +/// so those cases log at `.fault` to match the paired `assertionFailure` — and +/// it matters beyond a blank screen: the manifest is how the app discharges its +/// attribution obligations. +enum CreditCatalogLog: LogEvent { + /// The bundled `credits.json` manifest is absent from the bundle. + case missingManifest + /// The manifest decoded successfully into `creditCount` entries. + case loaded(creditCount: Int) + /// The manifest was present but could not be decoded. + case decodeFailed(description: String) + + static let eventName = "CreditCatalog" + + var level: LogLevel { + switch self { + case .missingManifest, .decodeFailed: .fault + case .loaded: .info + } + } + + var message: String { + switch self { + case .missingManifest: + "Missing required bundled credits.json manifest" + case let .loaded(creditCount): + "Loaded credit catalog with \(creditCount) credit(s)" + case let .decodeFailed(description): + "Failed to decode bundled credits.json: \(description)" + } + } +} diff --git a/Shared/CreditKit/Sources/Logging/CreditLog.swift b/Shared/CreditKit/Sources/Logging/CreditLog.swift new file mode 100644 index 00000000..dcd93fbb --- /dev/null +++ b/Shared/CreditKit/Sources/Logging/CreditLog.swift @@ -0,0 +1,28 @@ +import PeriscopeCore + +/// Phantom root event naming CreditKit's log scope tree. It is never emitted — +/// its only job is to give ``CreditLog``'s root `Log` the scope name +/// `"CreditKit"`, so every CreditKit event sits under one filterable subtree. +struct CreditKitRoot: LogEvent { + static let eventName = "CreditKit" + var message: String { + "" + } +} + +/// Logging facade for `CreditKit`. Every logger site derives from one root +/// `Log` scoped `"CreditKit"`, so CreditKit's events form a single subtree +/// under Periscope's process-wide system (``Periscope/shared``). +/// +/// CreditKit owns its own root scope rather than borrowing a host app's: it is +/// a standalone lower-level module that must not depend on app code. +enum CreditLog { + /// The `"CreditKit"` root every CreditKit logger descends from. + static let root = Log(system: .shared) + + /// `CreditCatalog` — the bundled `credits.json` manifest load. + static let catalog = root(CreditCatalogLog.self) + + /// `SoftwareCredit` — vendored license-text loads. + static let softwareCredit = root(SoftwareCreditLog.self) +} diff --git a/Where/WhereCore/Sources/Logging/SoftwareCreditLog.swift b/Shared/CreditKit/Sources/Logging/SoftwareCreditLog.swift similarity index 71% rename from Where/WhereCore/Sources/Logging/SoftwareCreditLog.swift rename to Shared/CreditKit/Sources/Logging/SoftwareCreditLog.swift index 6e09c45b..6fa99c72 100644 --- a/Where/WhereCore/Sources/Logging/SoftwareCreditLog.swift +++ b/Shared/CreditKit/Sources/Logging/SoftwareCreditLog.swift @@ -1,10 +1,10 @@ import PeriscopeCore -/// Structured events for loading a bundled third-party license text. A credit -/// naming a resource the bundle doesn't carry is a programmer error (the credit -/// and the file went out of sync), so it logs at `.fault` to match the paired -/// `assertionFailure` — and it matters: shipping a dependency without its -/// license text is a licensing problem, not a cosmetic one. +/// Structured events for loading a vendored third-party license text. A credit +/// naming a resource the bundle doesn't carry is a programmer error (the +/// manifest and the vendored files went out of sync), so it logs at `.fault` to +/// match the paired `assertionFailure` — and it matters: shipping a dependency +/// without its license text is a licensing problem, not a cosmetic one. enum SoftwareCreditLog: LogEvent { /// The credit's license file is absent from the bundle. case missingLicense(credit: String, resource: String) diff --git a/Where/WhereCore/Sources/Resources/Licenses/ZIPFoundation.txt b/Shared/CreditKit/Sources/Resources/Licenses/ZIPFoundation.txt similarity index 100% rename from Where/WhereCore/Sources/Resources/Licenses/ZIPFoundation.txt rename to Shared/CreditKit/Sources/Resources/Licenses/ZIPFoundation.txt diff --git a/Shared/CreditKit/Sources/Resources/Licenses/swift-concurrency-pro.txt b/Shared/CreditKit/Sources/Resources/Licenses/swift-concurrency-pro.txt new file mode 100644 index 00000000..e9f6fd1f --- /dev/null +++ b/Shared/CreditKit/Sources/Resources/Licenses/swift-concurrency-pro.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Paul Hudson. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/Shared/CreditKit/Sources/Resources/Licenses/swift-testing-pro.txt b/Shared/CreditKit/Sources/Resources/Licenses/swift-testing-pro.txt new file mode 100644 index 00000000..e9f6fd1f --- /dev/null +++ b/Shared/CreditKit/Sources/Resources/Licenses/swift-testing-pro.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Paul Hudson. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/Shared/CreditKit/Sources/Resources/Licenses/swiftdata-pro.txt b/Shared/CreditKit/Sources/Resources/Licenses/swiftdata-pro.txt new file mode 100644 index 00000000..e9f6fd1f --- /dev/null +++ b/Shared/CreditKit/Sources/Resources/Licenses/swiftdata-pro.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Paul Hudson. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/Shared/CreditKit/Sources/Resources/Licenses/swiftui-pro.txt b/Shared/CreditKit/Sources/Resources/Licenses/swiftui-pro.txt new file mode 100644 index 00000000..e9f6fd1f --- /dev/null +++ b/Shared/CreditKit/Sources/Resources/Licenses/swiftui-pro.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Paul Hudson. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/Shared/CreditKit/Sources/Resources/credits.json b/Shared/CreditKit/Sources/Resources/credits.json new file mode 100644 index 00000000..feb64bc1 --- /dev/null +++ b/Shared/CreditKit/Sources/Resources/credits.json @@ -0,0 +1,42 @@ +[ + { + "name": "ZIPFoundation", + "kind": "library", + "version": "0.9.20", + "homepageURL": "https://github.com/weichsel/ZIPFoundation", + "licenseName": "MIT License", + "licenseResource": "ZIPFoundation" + }, + { + "name": "swift-concurrency-pro", + "kind": "developmentTool", + "version": "e710f8d577cc", + "homepageURL": "https://github.com/twostraws/Swift-Concurrency-Agent-Skill", + "licenseName": "MIT License", + "licenseResource": "swift-concurrency-pro" + }, + { + "name": "swift-testing-pro", + "kind": "developmentTool", + "version": "2d6bba14a3c8", + "homepageURL": "https://github.com/twostraws/Swift-Testing-Agent-Skill", + "licenseName": "MIT License", + "licenseResource": "swift-testing-pro" + }, + { + "name": "swiftdata-pro", + "kind": "developmentTool", + "version": "922d989473a9", + "homepageURL": "https://github.com/twostraws/SwiftData-Agent-Skill", + "licenseName": "MIT License", + "licenseResource": "swiftdata-pro" + }, + { + "name": "swiftui-pro", + "kind": "developmentTool", + "version": "61b74001b64b", + "homepageURL": "https://github.com/twostraws/swiftui-agent-skill", + "licenseName": "MIT License", + "licenseResource": "swiftui-pro" + } +] diff --git a/Shared/CreditKit/Sources/SoftwareCredit.swift b/Shared/CreditKit/Sources/SoftwareCredit.swift new file mode 100644 index 00000000..f41d7dcf --- /dev/null +++ b/Shared/CreditKit/Sources/SoftwareCredit.swift @@ -0,0 +1,94 @@ +import Foundation + +/// A third-party work the project uses, with the license it is used under. +/// +/// Credits cover two different relationships, which ``Kind`` keeps apart: a +/// ``Kind/library`` is compiled into the shipping binary, while a +/// ``Kind/developmentTool`` is only used to build the project and never reaches +/// a user's device. Both need attribution — permissive licenses ask for the +/// notice to travel with every copy — but conflating them would tell a reader +/// of the About screen something untrue about the app they are running, so the +/// distinction is modeled rather than left to a comment. +/// +/// Names, versions, and license titles are proper nouns and legal terms, so +/// they are deliberately **not** localized; the UI supplies the translated +/// framing around them. +public struct SoftwareCredit: Sendable, Hashable, Identifiable, Codable { + /// How a credited work relates to the shipping app. + public enum Kind: String, Sendable, Hashable, Codable, CaseIterable { + /// Linked into the app binary — the user is running this code. + case library + /// Used to build or develop the app; absent from the shipped bundle. + case developmentTool + } + + /// The work's name as its author publishes it. + public let name: String + /// Whether this ships in the binary or only builds it. + public let kind: Kind + /// The pinned version: a semantic version for a package, a short commit for + /// a work pinned by revision. + public let version: String + /// Where the project lives, for a "learn more" link. + public let homepageURL: URL? + /// The license's title, e.g. "MIT License". + public let licenseName: String + /// Stem of the vendored license file under `Resources/Licenses/`. + let licenseResource: String + + public var id: String { + name + } + + /// The full license text, or `nil` when the vendored file is missing or + /// unreadable. + /// + /// MIT and most permissive licenses require shipping the notice verbatim, so + /// a missing file is a real defect rather than a cosmetic one: it fault-logs + /// and trips an `assertionFailure` in debug, and returns `nil` in release so + /// the caller can say the text is unavailable instead of rendering a blank + /// screen that reads like a license with no terms. + public func licenseText() -> String? { + // `.process("Resources")` preserves the `Licenses/` subdirectory, but + // fall back to a top-level lookup in case a bundler flattens it (the + // same defense `CreditCatalog` takes for its manifest). + let url = Bundle.module.url( + forResource: licenseResource, + withExtension: "txt", + subdirectory: "Licenses", + ) ?? Bundle.module.url(forResource: licenseResource, withExtension: "txt") + + guard let url else { + Self.logger { .missingLicense(credit: name, resource: licenseResource) } + assertionFailure("Missing bundled license text for \(name)") + return nil + } + do { + return try String(contentsOf: url, encoding: .utf8) + } catch { + Self.logger(attachments: [.error(error, name: "read-error")]) { + .unreadableLicense(credit: name, description: error.localizedDescription) + } + assertionFailure("Failed to read bundled license text for \(name): \(error)") + return nil + } + } + + @_spi(Testing) public init( + name: String, + kind: Kind, + version: String, + homepageURL: URL?, + licenseName: String, + licenseResource: String, + ) { + self.name = name + self.kind = kind + self.version = version + self.homepageURL = homepageURL + self.licenseName = licenseName + self.licenseResource = licenseResource + } + + private static let logger = CreditLog.softwareCredit +} diff --git a/Shared/CreditKit/Tests/CreditCatalogTests.swift b/Shared/CreditKit/Tests/CreditCatalogTests.swift new file mode 100644 index 00000000..475b1791 --- /dev/null +++ b/Shared/CreditKit/Tests/CreditCatalogTests.swift @@ -0,0 +1,54 @@ +@_spi(Testing) import CreditKit +import Foundation +import Testing + +struct CreditCatalogTests { + @Test("loads the bundled manifest") + func loadsTheBundledManifest() { + #expect(!CreditCatalog.shared.credits.isEmpty) + } + + @Test("credits every package a target links") + func creditsEveryLinkedPackage() { + let libraries = CreditCatalog.shared.credits(ofKind: .library).map(\.name) + // The one external package any target links via `.product(name:package:)`. + // BumperBowling and swift-syntax are resolved for architecture linting + // and never reach the binary, so they are deliberately absent. + #expect(libraries == ["ZIPFoundation"]) + } + + @Test("credits the vendored agent skills") + func creditsTheVendoredAgentSkills() { + let tools = Set(CreditCatalog.shared.credits(ofKind: .developmentTool).map(\.name)) + #expect(tools == [ + "swift-concurrency-pro", + "swift-testing-pro", + "swiftdata-pro", + "swiftui-pro", + ]) + } + + @Test("groups libraries ahead of development tools") + func groupsLibrariesAheadOfDevelopmentTools() { + let kinds = CreditCatalog.shared.credits.map(\.kind) + #expect(kinds == kinds.sorted { first, _ in first == .library }) + } + + @Test("decoding rejects a malformed manifest") + func decodingRejectsAMalformedManifest() { + let data = Data(#"[{"name": "Nope"}]"#.utf8) + #expect(throws: (any Error).self) { + try CreditCatalog.decode(from: data) + } + } + + @Test("filters to a single kind") + func filtersToASingleKind() { + let catalog = CreditCatalog(credits: [ + .fixture(name: "Linked", kind: .library), + .fixture(name: "Tool", kind: .developmentTool), + ]) + #expect(catalog.credits(ofKind: .library).map(\.name) == ["Linked"]) + #expect(catalog.credits(ofKind: .developmentTool).map(\.name) == ["Tool"]) + } +} diff --git a/Shared/CreditKit/Tests/CreditKitTestSupport.swift b/Shared/CreditKit/Tests/CreditKitTestSupport.swift new file mode 100644 index 00000000..991ebd14 --- /dev/null +++ b/Shared/CreditKit/Tests/CreditKitTestSupport.swift @@ -0,0 +1,22 @@ +@_spi(Testing) import CreditKit +import Foundation + +extension SoftwareCredit { + /// A credit pointing at a real vendored notice by default, so + /// ``SoftwareCredit/licenseText()`` resolves without tripping the + /// missing-resource `assertionFailure`. + static func fixture( + name: String, + kind: Kind, + resource: String = "ZIPFoundation", + ) -> SoftwareCredit { + SoftwareCredit( + name: name, + kind: kind, + version: "1.2.3", + homepageURL: URL(string: "https://example.com"), + licenseName: "MIT License", + licenseResource: resource, + ) + } +} diff --git a/Shared/CreditKit/Tests/SoftwareCreditTests.swift b/Shared/CreditKit/Tests/SoftwareCreditTests.swift new file mode 100644 index 00000000..3d845203 --- /dev/null +++ b/Shared/CreditKit/Tests/SoftwareCreditTests.swift @@ -0,0 +1,36 @@ +@_spi(Testing) import CreditKit +import Foundation +import Testing + +struct SoftwareCreditTests { + // These iterate rather than taking `arguments:`: a parameterized case list + // is evaluated when the test is discovered, which is before `Bundle.module` + // resolves the manifest — the suite would silently register zero cases and + // report as passing without checking anything. + + @Test("every credit ships a non-empty license notice") + func everyCreditShipsALicenseNotice() { + for credit in CreditCatalog.shared.credits { + let text = credit.licenseText() + #expect(text?.isEmpty == false, "\(credit.name) has no vendored license text") + } + } + + @Test("every credit names its license, version, and home") + func everyCreditNamesItsLicenseVersionAndHome() { + for credit in CreditCatalog.shared.credits { + #expect(!credit.licenseName.isEmpty, "\(credit.name) has no license name") + #expect(!credit.version.isEmpty, "\(credit.name) has no version") + #expect(credit.homepageURL != nil, "\(credit.name) has no homepage") + } + } + + @Test("identifies by name") + func identifiesByName() { + #expect(SoftwareCredit.fixture(name: "ZIPFoundation", kind: .library).id == "ZIPFoundation") + } + + // A credit naming an absent resource is deliberately not covered: the + // missing-license path trips an `assertionFailure`, which traps the test + // process rather than raising a catchable issue in a debug build. +} diff --git a/Shared/CreditKit/Tools/generate-credits.rb b/Shared/CreditKit/Tools/generate-credits.rb new file mode 100755 index 00000000..9714234b --- /dev/null +++ b/Shared/CreditKit/Tools/generate-credits.rb @@ -0,0 +1,130 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Generates CreditKit's `credits.json` manifest and vendors the license notice +# for every third-party work the project uses. +# +# It derives the list rather than trusting a hand-kept one, which is the point: +# a package linked by *any* module is credited the next time this runs, so no +# module has to remember to vend a credit. Two inputs, one per credit kind: +# +# - `Package.swift` + `Package.resolved` -> `library` credits. Only packages +# a target actually links via `.product(name:package:)` count; a package +# declared for tooling alone (BumperBowling, and swift-syntax underneath it) +# never reaches the binary and so is deliberately not credited. +# - `.agents/external-skills.json` -> `developmentTool` credits. These are the +# agent skills `./sync-agents` vendors into `.agents/skills/`. They are not +# in the app, but we do make copies of them, which is what their licenses +# ask us to attribute. +# +# and writes, into `Sources/Resources/`: +# - `credits.json` — the manifest `CreditCatalog` decodes: `{ name, kind, +# version, homepageURL?, licenseName, licenseResource }`, libraries first, +# each group alphabetical. +# - `Licenses/.txt` — the verbatim notice, fetched from the project's +# GitHub repository (which also reports the license's title). +# +# Needs network and an authenticated `gh`. Idempotent: re-run after changing a +# dependency or running `./sync-agents --update`. From the repo root: +# ruby Shared/CreditKit/Tools/generate-credits.rb + +require "json" +require "fileutils" +require "open3" + +ROOT = File.expand_path("../../..", __dir__) +RESOURCES = File.expand_path("../Sources/Resources", __dir__) +LICENSES_DIR = File.join(RESOURCES, "Licenses") + +def fail_with(message) + abort "generate-credits: #{message}" +end + +# GitHub's license endpoint resolves the notice's filename for us (LICENSE, +# LICENSE.md, COPYING, …) and reports the license's title alongside it, so one +# call answers both "what license" and "what text". +# +# Always read it at the pinned revision rather than the default branch: a +# notice we ship should be the one that governs the revision we actually use, +# and upstream edits it (a bumped copyright year, a relicense) between releases. +def github_license(slug, ref) + out, err, status = Open3.capture3("gh", "api", "repos/#{slug}/license?ref=#{ref}") + fail_with("could not read the license for #{slug}: #{err.strip}") unless status.success? + payload = JSON.parse(out) + text = payload["content"].to_s.unpack1("m") + fail_with("#{slug} reports a license with no text") if text.strip.empty? + [payload.dig("license", "name") || "See notice", text] +end + +def github_slug(location) + location[%r{github\.com[:/](.+?)(?:\.git)?/?\z}, 1] +end + +# Packages some target actually links, by SPM identity (lowercased package name +# as it appears in `.product(name:package:)`). +def linked_package_identities + manifest = File.read(File.join(ROOT, "Package.swift")) + manifest.scan(/\.product\(\s*name:\s*"[^"]+",\s*package:\s*"([^"]+)"/) + .flatten.map(&:downcase).uniq +end + +def library_credits + resolved = JSON.parse(File.read(File.join(ROOT, "Package.resolved"))) + linked = linked_package_identities + fail_with("no linked packages found in Package.swift") if linked.empty? + + pins = resolved.fetch("pins").select { |pin| linked.include?(pin["identity"]) } + missing = linked - pins.map { |pin| pin["identity"] } + fail_with("#{missing.join(", ")} linked but absent from Package.resolved") unless missing.empty? + + pins.map do |pin| + location = pin.fetch("location") + slug = github_slug(location) || fail_with("#{pin["identity"]} is not a GitHub package") + state = pin.fetch("state") + revision = state.fetch("revision") + # A branch-pinned package has no version, so fall back to the short revision. + version = state["version"] || revision[0, 12] + name = slug.split("/").last + license_name, text = github_license(slug, revision) + { name: name, kind: "library", version: version, + homepage: "https://github.com/#{slug}", license_name: license_name, text: text } + end +end + +def development_tool_credits + path = File.join(ROOT, ".agents", "external-skills.json") + return [] unless File.exist?(path) + + JSON.parse(File.read(path)).map do |name, entry| + slug = entry.fetch("repo") + license_name, text = github_license(slug, entry.fetch("ref")) + { name: name, kind: "developmentTool", version: entry.fetch("ref")[0, 12], + homepage: "https://github.com/#{slug}", license_name: license_name, text: text } + end +end + +credits = library_credits.sort_by { |credit| credit[:name].downcase } + + development_tool_credits.sort_by { |credit| credit[:name].downcase } +fail_with("no credits generated") if credits.empty? + +FileUtils.mkdir_p(LICENSES_DIR) +# Rewrite the vendored notices from scratch so a dropped dependency doesn't +# leave a stale license behind for a credit that no longer exists. +FileUtils.rm_f(Dir.glob(File.join(LICENSES_DIR, "*.txt"))) + +manifest = credits.map do |credit| + resource = credit[:name] + File.write(File.join(LICENSES_DIR, "#{resource}.txt"), credit[:text]) + { + "name" => credit[:name], + "kind" => credit[:kind], + "version" => credit[:version], + "homepageURL" => credit[:homepage], + "licenseName" => credit[:license_name], + "licenseResource" => resource, + } +end + +File.write(File.join(RESOURCES, "credits.json"), "#{JSON.pretty_generate(manifest)}\n") +puts "Wrote #{manifest.count} credit(s) and #{manifest.count} license notice(s)." +manifest.each { |credit| puts " #{credit["kind"]}: #{credit["name"]} #{credit["version"]}" } diff --git a/Where/AGENTS.md b/Where/AGENTS.md index 4e466697..63933198 100644 --- a/Where/AGENTS.md +++ b/Where/AGENTS.md @@ -114,14 +114,20 @@ fill in rather than a screen you can forget to register. **About is deliberately the last block**, below everything actionable. **Credits are vended by the module that owns the thing being credited**, and the -About screen only renders them: `SoftwareCredit` (WhereCore) for a linked -library plus its bundled notice, `RegionDataSource` (RegionKit) for bundled -geometry, `BuildInfo` (WhereCore) for the running build. Adding a dependency or -a dataset means adding its credit *there* — see -[`WhereCore/AGENTS.md`](WhereCore/AGENTS.md) and +About screen only renders them: `SoftwareCredit` (CreditKit) for third-party +software plus its notice, `RegionDataSource` (RegionKit) for bundled geometry, +`BuildInfo` (WhereCore) for the running build. Adding a dependency or a dataset +means updating it *there* — see +[`CreditKit/AGENTS.md`](../Shared/CreditKit/AGENTS.md) and [`RegionKit/AGENTS.md`](RegionKit/AGENTS.md) — never a list hard-coded in the view. +CreditKit distinguishes a **linked library** from a **development tool** (the +agent skills `./sync-agents` vendors), and the About screen renders them as +separate sections. Keep them separate: a development tool is credited because +the repository copies it, not because it is in the binary, and one merged list +would tell a reader something untrue about the app they are running. + ## Localization All user-facing copy resolves through each module's `Localizable.xcstrings` via diff --git a/Where/WhereCore/AGENTS.md b/Where/WhereCore/AGENTS.md index a7fbd309..3c4208df 100644 --- a/Where/WhereCore/AGENTS.md +++ b/Where/WhereCore/AGENTS.md @@ -134,14 +134,14 @@ internal shape. that derives attribution from a seeded store instead.) Detection is naturally scoped to the set — the attributor only loads tracked-region geometry, so `distanceToBoundary` is `nil` elsewhere. -- **Adding an external package means adding a credit.** A running app can't read - `Package.resolved`, so `SoftwareCredit.all` is hand-maintained against the root - [`Package.swift`](../../Package.swift): a new third-party dependency needs an - entry *and* its license notice vendored verbatim into - `Sources/Resources/Licenses/`, or the app ships a library whose license it - doesn't reproduce. `SoftwareCreditTests` pins the list and asserts every - credit's text loads. Build identity is the neighbouring `BuildInfo`, which - reads keys the app target's stamp script writes — see [Version and build +- **Adding an external package means regenerating the credits.** WhereCore links + the app's only third-party package (ZIPFoundation), but it does **not** own + attribution — that is [`CreditKit`](../../Shared/CreditKit/AGENTS.md), whose + `Tools/generate-credits.rb` derives the credit and vendors the notice from the + root [`Package.swift`](../../Package.swift) and `Package.resolved`. Run it and + commit the result, or the app ships a library whose license it doesn't + reproduce. Build *identity* does live here, in `BuildInfo`, which reads keys + the app target's stamp script writes — see [Version and build metadata](../../AGENTS.md#version-and-build-metadata). - **Impossible states trap; recoverable ones surface.** `WhereStore` methods are `async throws` so the CloudKit-backed store can report I/O failure; a `catch` diff --git a/Where/WhereCore/README.md b/Where/WhereCore/README.md index acd4c632..5bf5844d 100644 --- a/Where/WhereCore/README.md +++ b/Where/WhereCore/README.md @@ -96,12 +96,11 @@ one it belongs to rather than to a god-object: a selectable look-back `RecentActivityWindow`. - **`WherePreferences`** — persisted user intent (onboarding, tracking intent, reminder / summary schedules) behind a `KeyValueStore`. -- **`BuildInfo`** + **`SoftwareCredit`** — what Settings > About shows about the - binary itself. `BuildInfo.current(bundle:)` reads the marketing version, build - number, and the commit the app was built from; `SoftwareCredit.all` lists the - third-party libraries the app links, each with the license notice bundled - under `Resources/Licenses/`. (Data-source credits are RegionKit's — see - [`RegionDataSource`](../RegionKit/README.md).) +- **`BuildInfo`** — which build is running, for Settings > About. + `BuildInfo.current(bundle:)` reads the marketing version, build number, and the + commit the app was built from. (Attribution is *not* WhereCore's: third-party + credits are [`CreditKit`](../../Shared/CreditKit/README.md)'s and data-source + credits are [`RegionKit`](../RegionKit/README.md)'s.) - **`WhereLog`** — the Periscope logging facade: a `"Where"` root scope with grouping scopes (`location`, `reminders`, `backup`, `widgets`, …) and a typed `LogEvent` per collaborator, emitted into `Periscope.shared`. diff --git a/Where/WhereCore/Sources/About/SoftwareCredit.swift b/Where/WhereCore/Sources/About/SoftwareCredit.swift deleted file mode 100644 index 006ef5a8..00000000 --- a/Where/WhereCore/Sources/About/SoftwareCredit.swift +++ /dev/null @@ -1,76 +0,0 @@ -import Foundation - -/// A third-party library the app links, with the license text it ships under. -/// -/// WhereCore owns this because WhereCore is what pulls the dependencies in — -/// today only ZIPFoundation, for backup archives. The names, versions, and -/// license titles are proper nouns and legal terms, so they are deliberately -/// **not** localized; the UI supplies the translated framing around them. -/// -/// The list is hand-maintained against the root `Package.swift` / -/// `Package.resolved`, because a running app can't read either. Adding an -/// external package means adding a credit here and vendoring its license text — -/// see [`WhereCore/AGENTS.md`](../../AGENTS.md). -public struct SoftwareCredit: Sendable, Hashable, Identifiable { - /// The package's name as it appears in `Package.swift`. - public let name: String - /// The resolved version in `Package.resolved`. - public let version: String - /// Where the project lives, for a "learn more" link. - public let homepageURL: URL? - /// The license's title, e.g. "MIT License". - public let licenseName: String - /// Stem of the vendored license file under `Resources/Licenses/`. - private let licenseResource: String - - public var id: String { - name - } - - /// Every third-party library linked into the app. - public static let all: [SoftwareCredit] = [ - SoftwareCredit( - name: "ZIPFoundation", - version: "0.9.20", - homepageURL: URL(string: "https://github.com/weichsel/ZIPFoundation"), - licenseName: "MIT License", - licenseResource: "ZIPFoundation", - ), - ] - - /// The full license text, or `nil` when the bundled file is missing or - /// unreadable. - /// - /// MIT and most permissive licenses require shipping the notice verbatim, so - /// a missing file is a real defect rather than a cosmetic one: it fault-logs - /// and trips an `assertionFailure` in debug, and returns `nil` in release so - /// the caller can say the text is unavailable instead of rendering a blank - /// screen that reads like a license with no terms. - public func licenseText() -> String? { - // `.process("Resources")` preserves the `Licenses/` subdirectory, but - // fall back to a top-level lookup in case a bundler flattens it (the - // same defense `RegionCatalog` takes for its `regions/` folder). - let url = Bundle.module.url( - forResource: licenseResource, - withExtension: "txt", - subdirectory: "Licenses", - ) ?? Bundle.module.url(forResource: licenseResource, withExtension: "txt") - - guard let url else { - Self.logger { .missingLicense(credit: name, resource: licenseResource) } - assertionFailure("Missing bundled license text for \(name)") - return nil - } - do { - return try String(contentsOf: url, encoding: .utf8) - } catch { - Self.logger(attachments: [.error(error, name: "read-error")]) { - .unreadableLicense(credit: name, description: error.localizedDescription) - } - assertionFailure("Failed to read bundled license text for \(name): \(error)") - return nil - } - } - - private static let logger = WhereLog.root(SoftwareCreditLog.self) -} diff --git a/Where/WhereCore/Tests/SoftwareCreditTests.swift b/Where/WhereCore/Tests/SoftwareCreditTests.swift deleted file mode 100644 index eae099a8..00000000 --- a/Where/WhereCore/Tests/SoftwareCreditTests.swift +++ /dev/null @@ -1,48 +0,0 @@ -import Foundation -import Testing -@testable import WhereCore - -/// The guard behind the About screen's open-source credits: every credited -/// library ships the license notice it's required to, and the list matches what -/// the app actually links. -struct SoftwareCreditTests { - @Test func creditsEveryLinkedThirdPartyLibrary() { - // ZIPFoundation (BackupService's archive reader/writer) is the app's only - // external package — everything else in the graph is first-party. Update - // this alongside the root Package.swift. - #expect(SoftwareCredit.all.map(\.name) == ["ZIPFoundation"]) - } - - @Test func everyCreditShipsItsLicenseText() throws { - for credit in SoftwareCredit.all { - let text = try #require( - credit.licenseText(), - "\(credit.name) is credited but its license text isn't bundled", - ) - #expect(!text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) - } - } - - @Test func theZIPFoundationNoticeCarriesItsCopyrightLine() throws { - let credit = try #require(SoftwareCredit.all.first { $0.name == "ZIPFoundation" }) - let text = try #require(credit.licenseText()) - // MIT requires the copyright notice and permission notice verbatim, so a - // truncated or placeholder file has to fail rather than merely look full. - #expect(text.contains("MIT License")) - #expect(text.contains("Copyright (c) 2017-2025 Thomas Zoechling")) - #expect(text.contains("THE SOFTWARE IS PROVIDED \"AS IS\"")) - #expect(credit.licenseName == "MIT License") - #expect(credit.version == "0.9.20") - } - - @Test func everyCreditLinksItsProject() { - for credit in SoftwareCredit.all { - #expect(credit.homepageURL != nil) - } - } - - @Test func creditsAreUniquelyIdentified() { - let ids = SoftwareCredit.all.map(\.id) - #expect(Set(ids).count == ids.count) - } -} diff --git a/Where/WhereUI/Sources/Resources/Localizable.xcstrings b/Where/WhereUI/Sources/Resources/Localizable.xcstrings index 044ede56..81a718d5 100644 --- a/Where/WhereUI/Sources/Resources/Localizable.xcstrings +++ b/Where/WhereUI/Sources/Resources/Localizable.xcstrings @@ -3174,6 +3174,28 @@ } } }, + "settings.about.developmentTools.footer" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tools used to build Where. They aren't part of the app on your device, but the project ships copies of them, so their licenses are included here." + } + } + } + }, + "settings.about.developmentTools.header" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Development Tools" + } + } + } + }, "settings.about.header" : { "extractionState" : "manual", "localizations" : { @@ -3683,6 +3705,17 @@ } } }, + "settings.keywords.aboutDevelopmentTools" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "tools, development, build, agent, skills, license, licenses, credits, acknowledgements, third party" + } + } + } + }, "settings.keywords.aboutVersion" : { "extractionState" : "manual", "localizations" : { diff --git a/Where/WhereUI/Sources/Settings/AboutSettingsView.swift b/Where/WhereUI/Sources/Settings/AboutSettingsView.swift index d9fb0a6f..3b129ba2 100644 --- a/Where/WhereUI/Sources/Settings/AboutSettingsView.swift +++ b/Where/WhereUI/Sources/Settings/AboutSettingsView.swift @@ -1,15 +1,20 @@ +import CreditKit import RegionKit import SwiftUI import WhereCore /// Settings drill-in for what the app *is* rather than what it does: which build -/// is running, the open-source libraries it links, and where its bundled region -/// boundaries came from. +/// is running, the third-party work it is built with, and where its bundled +/// region boundaries came from. /// -/// Every fact here is vended by the module that owns it — `BuildInfo` and -/// `SoftwareCredit` from `WhereCore`, `RegionDataSource` from `RegionKit` — so -/// this screen only renders and localizes. Adding a dependency or a dataset -/// means updating that module's credit, not this view. +/// Every fact here is vended by the module that owns it — `BuildInfo` from +/// `WhereCore`, `SoftwareCredit` from `CreditKit`, `RegionDataSource` from +/// `RegionKit` — so this screen only renders and localizes. Adding a dependency +/// or a dataset means regenerating that module's data, not editing this view. +/// +/// Credits are split by `SoftwareCredit.Kind` into two sections rather than one +/// combined list, because a development tool is *not* in the binary the reader +/// is running; merging them would credit honestly but describe the app falsely. struct AboutSettingsView: View { var focus: SettingsFocus? @@ -25,7 +30,7 @@ struct AboutSettingsView: View { init( focus: SettingsFocus?, buildInfo: BuildInfo = .current(bundle: .main), - credits: [SoftwareCredit] = SoftwareCredit.all, + credits: [SoftwareCredit] = CreditCatalog.shared.credits, dataSources: [RegionDataSource] = RegionDataSource.all, ) { self.focus = focus @@ -39,6 +44,7 @@ struct AboutSettingsView: View { Form { versionSection dependenciesSection + developmentToolsSection dataSourcesSection } } @@ -76,8 +82,32 @@ struct AboutSettingsView: View { // MARK: Open source private var dependenciesSection: some View { + creditSection( + kind: .library, + header: String(localized: .settingsAboutDependenciesHeader), + footer: String(localized: .settingsAboutDependenciesFooter), + ) + .settingsRow(Item.dependencies) + } + + // MARK: Development tools + + private var developmentToolsSection: some View { + creditSection( + kind: .developmentTool, + header: String(localized: .settingsAboutDevelopmentToolsHeader), + footer: String(localized: .settingsAboutDevelopmentToolsFooter), + ) + .settingsRow(Item.developmentTools) + } + + private func creditSection( + kind: SoftwareCredit.Kind, + header: String, + footer: String, + ) -> some View { Section { - ForEach(credits) { credit in + ForEach(credits.filter { $0.kind == kind }) { credit in // A closure-form link, not a `SettingsRoute`: routes are the // top-level group vocabulary, and a leaf pushed from inside one // group would need its own `navigationDestination` to match. @@ -90,11 +120,10 @@ struct AboutSettingsView: View { } } } header: { - Text(String(localized: .settingsAboutDependenciesHeader)) + Text(header) } footer: { - Text(String(localized: .settingsAboutDependenciesFooter)) + Text(footer) } - .settingsRow(Item.dependencies) } // MARK: Data sources @@ -147,12 +176,15 @@ extension AboutSettingsView: SettingsSection { enum Item: SettingsItem { case version case dependencies + case developmentTools case dataSources var title: String { switch self { case .version: String(localized: .settingsAboutVersionHeader) case .dependencies: String(localized: .settingsAboutDependenciesHeader) + case .developmentTools: + String(localized: .settingsAboutDevelopmentToolsHeader) case .dataSources: String(localized: .settingsAboutDataSourcesHeader) } } @@ -162,6 +194,8 @@ extension AboutSettingsView: SettingsSection { case .version: splitKeywords(String(localized: .settingsKeywordsAboutVersion)) case .dependencies: splitKeywords(String(localized: .settingsKeywordsAboutDependencies)) + case .developmentTools: + splitKeywords(String(localized: .settingsKeywordsAboutDevelopmentTools)) case .dataSources: splitKeywords(String(localized: .settingsKeywordsAboutDataSources)) } diff --git a/Where/WhereUI/Sources/Settings/LicenseView.swift b/Where/WhereUI/Sources/Settings/LicenseView.swift index 4a905f4f..d4e9da9c 100644 --- a/Where/WhereUI/Sources/Settings/LicenseView.swift +++ b/Where/WhereUI/Sources/Settings/LicenseView.swift @@ -1,9 +1,9 @@ +import CreditKit import SwiftUI -import WhereCore -/// The full license notice for one third-party library, pushed from Settings > -/// About. Permissive licenses require the notice verbatim, so it is rendered as -/// plain monospaced text, unreflowed and untruncated. +/// The full license notice for one credited work, pushed from Settings > About. +/// Permissive licenses require the notice verbatim, so it is rendered as plain +/// monospaced text, unreflowed and untruncated. struct LicenseView: View { let credit: SoftwareCredit @@ -56,7 +56,7 @@ struct LicenseView: View { #if DEBUG #Preview { NavigationStack { - if let credit = SoftwareCredit.all.first { + if let credit = CreditCatalog.shared.credits.first { LicenseView(credit: credit) } } diff --git a/Where/WhereUI/Tests/AboutSettingsViewTests.swift b/Where/WhereUI/Tests/AboutSettingsViewTests.swift index 624126d6..7dcd4bf7 100644 --- a/Where/WhereUI/Tests/AboutSettingsViewTests.swift +++ b/Where/WhereUI/Tests/AboutSettingsViewTests.swift @@ -1,3 +1,4 @@ +import CreditKit import RegionKit import SwiftUI import TestHostSupport @@ -32,7 +33,7 @@ struct AboutSettingsViewTests { @Test func searchFindsTheAboutSettingsByKeyword() { // None of these words appear in the section titles, so a match proves the // keyword lists are wired rather than the titles happening to overlap. - for query in ["licenses", "sha", "geojson"] { + for query in ["licenses", "sha", "geojson", "skills"] { let destinations = Set(SettingsCatalog.results(matching: query).map(\.destination)) #expect(destinations.contains(.about), "no About result for \"\(query)\"") } @@ -89,7 +90,22 @@ struct AboutSettingsViewTests { @Test func creditsTheLiveDependencyAndDataSourceLists() { // The screen shows what the owning modules vend, not a copy of its own. - #expect(!SoftwareCredit.all.isEmpty) + #expect(!CreditCatalog.shared.credits.isEmpty) #expect(!RegionDataSource.all.isEmpty) } + + @Test func separatesLinkedLibrariesFromDevelopmentTools() { + // The two kinds must reach the screen as distinct sections: a development + // tool isn't in the binary, so listing it under "Open Source" alongside + // the libraries would describe the running app inaccurately. + for kind in SoftwareCredit.Kind.allCases { + #expect( + !CreditCatalog.shared.credits(ofKind: kind).isEmpty, + "nothing credited for \(kind), so the section would render empty", + ) + } + let titles = Set(AboutSettingsView.Item.allCases.map(\.title)) + #expect(titles.contains(String(localized: .settingsAboutDevelopmentToolsHeader))) + #expect(titles.contains(String(localized: .settingsAboutDependenciesHeader))) + } } diff --git a/Where/WhereUI/Tests/LicenseViewTests.swift b/Where/WhereUI/Tests/LicenseViewTests.swift index 4487876e..70380d0d 100644 --- a/Where/WhereUI/Tests/LicenseViewTests.swift +++ b/Where/WhereUI/Tests/LicenseViewTests.swift @@ -1,7 +1,7 @@ +import CreditKit import SwiftUI import TestHostSupport import Testing -import WhereCore @testable import WhereUI /// Covers the license notice pushed from Settings > About: it renders the @@ -9,7 +9,7 @@ import WhereCore @MainActor struct LicenseViewTests { @Test func hostsEveryCreditsNotice() throws { - for credit in SoftwareCredit.all { + for credit in CreditCatalog.shared.credits { let rootView = NavigationStack { LicenseView(credit: credit) } try show(UIHostingController(rootView: rootView)) { hosted in #expect(hosted.view != nil) From 149c17c2e2083295a0fd3177d3e331cee2ed3df9 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sun, 26 Jul 2026 16:51:13 -0700 Subject: [PATCH 08/13] Make CreditKit a reporting toolkit, not one app's credits CreditKit was the Where app's attribution data wearing a library's clothes: it carried this repo's credits.json and five vendored notices in its own resource bundle, its generator hardcoded Package.swift and .agents/external-skills.json, and its tests asserted that ZIPFoundation was linked -- a library making claims about its consumer's dependency graph. A second app could not have used any of it. It now owns the format and the tooling and nothing else. An app declares its own inputs in an attribution-sources.json, ./attribution runs the report over them, and the result lands in that app's resources, because a report describes one app's dependency graph and is therefore that app's data. Dropping the resource bundle also drops the last dependency: CreditKit is Foundation-only and throws rather than logs, leaving the caller to decide what a missing report means. Notices are inlined into the report rather than shipped as sidecar files, so one decode yields everything needed to discharge the attribution and the missing-file path stops existing. The generated notices are byte-identical to the ones previously vendored by hand. WhereCore.AppAttribution reads the report from Bundle.main beside BuildInfo, and splits the two failures those two share: a bundle with no report is legitimate -- only the app target ships one -- while a report that will not decode is a corrupt resource and faults. About renders an explicit "no report" state instead of empty sections. The assertions about what Where actually credits moved to its own test bundle, the one hosted by Where.app, where Bundle.main is the shipping bundle. Co-authored-by: Cursor --- AGENTS.md | 53 +++--- Package.swift | 7 +- Shared/CreditKit/AGENTS.md | 82 +++++---- Shared/CreditKit/README.md | 118 ++++++++---- .../Sources/AttributionManifest.swift | 59 ++++++ Shared/CreditKit/Sources/CreditCatalog.swift | 58 ------ .../Sources/Logging/CreditCatalogLog.swift | 35 ---- .../CreditKit/Sources/Logging/CreditLog.swift | 28 --- .../Sources/Logging/SoftwareCreditLog.swift | 28 --- .../Resources/Licenses/ZIPFoundation.txt | 21 --- .../Licenses/swift-concurrency-pro.txt | 21 --- .../Resources/Licenses/swift-testing-pro.txt | 21 --- .../Resources/Licenses/swiftdata-pro.txt | 21 --- .../Resources/Licenses/swiftui-pro.txt | 21 --- .../CreditKit/Sources/Resources/credits.json | 42 ----- Shared/CreditKit/Sources/SoftwareCredit.swift | 75 +++----- .../Tests/AttributionManifestTests.swift | 96 ++++++++++ .../CreditKit/Tests/CreditCatalogTests.swift | 54 ------ .../Tests/CreditKitTestSupport.swift | 46 +++-- .../CreditKit/Tests/SoftwareCreditTests.swift | 37 +--- .../CreditKit/Tools/generate-attribution.rb | 173 ++++++++++++++++++ Shared/CreditKit/Tools/generate-credits.rb | 130 ------------- Where/AGENTS.md | 32 ++-- Where/Where/AGENTS.md | 7 + Where/Where/Resources/attribution.json | 54 ++++++ Where/Where/Tests/AppAttributionTests.swift | 61 ++++++ Where/Where/attribution-sources.json | 16 ++ Where/WhereCore/AGENTS.md | 23 ++- Where/WhereCore/README.md | 13 +- .../Sources/About/AppAttribution.swift | 43 +++++ .../Sources/Logging/AppAttributionLog.swift | 39 ++++ Where/WhereUI/README.md | 9 +- .../Sources/Preview/PreviewSupport.swift | 34 ++++ .../Sources/Resources/Localizable.xcstrings | 11 ++ .../Sources/Settings/AboutSettingsView.swift | 64 ++++--- .../Sources/Settings/LicenseView.swift | 20 +- .../Tests/AboutSettingsViewTests.swift | 59 ++++-- Where/WhereUI/Tests/LicenseViewTests.swift | 23 ++- attribution | 68 +++++++ 39 files changed, 1049 insertions(+), 753 deletions(-) create mode 100644 Shared/CreditKit/Sources/AttributionManifest.swift delete mode 100644 Shared/CreditKit/Sources/CreditCatalog.swift delete mode 100644 Shared/CreditKit/Sources/Logging/CreditCatalogLog.swift delete mode 100644 Shared/CreditKit/Sources/Logging/CreditLog.swift delete mode 100644 Shared/CreditKit/Sources/Logging/SoftwareCreditLog.swift delete mode 100644 Shared/CreditKit/Sources/Resources/Licenses/ZIPFoundation.txt delete mode 100644 Shared/CreditKit/Sources/Resources/Licenses/swift-concurrency-pro.txt delete mode 100644 Shared/CreditKit/Sources/Resources/Licenses/swift-testing-pro.txt delete mode 100644 Shared/CreditKit/Sources/Resources/Licenses/swiftdata-pro.txt delete mode 100644 Shared/CreditKit/Sources/Resources/Licenses/swiftui-pro.txt delete mode 100644 Shared/CreditKit/Sources/Resources/credits.json create mode 100644 Shared/CreditKit/Tests/AttributionManifestTests.swift delete mode 100644 Shared/CreditKit/Tests/CreditCatalogTests.swift create mode 100755 Shared/CreditKit/Tools/generate-attribution.rb delete mode 100755 Shared/CreditKit/Tools/generate-credits.rb create mode 100644 Where/Where/Resources/attribution.json create mode 100644 Where/Where/Tests/AppAttributionTests.swift create mode 100644 Where/Where/attribution-sources.json create mode 100644 Where/WhereCore/Sources/About/AppAttribution.swift create mode 100644 Where/WhereCore/Sources/Logging/AppAttributionLog.swift create mode 100755 attribution diff --git a/AGENTS.md b/AGENTS.md index 31219bf3..6801563c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -49,10 +49,11 @@ 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 (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 (see [Managing app icons](#managing-app-icons), +[Attribution](#attribution), and [Selecting a simulator](#selecting-a-simulator--address-it-by-udid-not-name)). ### Managing app icons @@ -98,26 +99,31 @@ read these keys. ## Attribution -Third-party attribution lives in **one** place — -[`Shared/CreditKit`](Shared/CreditKit/AGENTS.md) — rather than wherever a -dependency happened to be introduced. It is a leaf module anything may depend -on, so a package pulled in by *any* module can be credited without inverting the -dependency that introduced it. - -Its manifest and license notices are **generated, never hand-edited**: +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 -ruby Shared/CreditKit/Tools/generate-credits.rb +./attribution ``` -The script derives what to credit 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` — and fetches each notice 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 it and commit the result whenever you add or bump a package or a -skill**; nothing fails a build if you forget, but `CreditKitTests` pins the -expected names and will. +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.** Nothing fails a build if you forget; each app asserts its +own report instead (`AppAttributionTests` for Where), which is where that +assertion belongs, since only the app knows what it should contain. `SoftwareCredit.Kind` keeps a **linked library** apart from a **development tool**, and that distinction must survive into any UI: a tool is credited @@ -162,11 +168,10 @@ 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 `CreditKit` credits them (as *development tools*, +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 `ruby Shared/CreditKit/Tools/generate-credits.rb` -and commit the regenerated manifest and notices — see -[`Attribution`](#attribution). +`./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 diff --git a/Package.swift b/Package.swift index 613013ae..b2d76c3b 100644 --- a/Package.swift +++ b/Package.swift @@ -38,13 +38,7 @@ let package = Package( ), .target( name: "CreditKit", - dependencies: [ - .target(name: "PeriscopeCore"), - ], path: "Shared/CreditKit/Sources", - resources: [ - .process("Resources"), - ], ), .target( name: "LifecycleKit", @@ -102,6 +96,7 @@ let package = Package( .target( name: "WhereCore", dependencies: [ + .target(name: "CreditKit"), .target(name: "PeriscopeCore"), .target(name: "RegionKit"), .product(name: "ZIPFoundation", package: "ZIPFoundation"), diff --git a/Shared/CreditKit/AGENTS.md b/Shared/CreditKit/AGENTS.md index 5f49c5b0..edf6614b 100644 --- a/Shared/CreditKit/AGENTS.md +++ b/Shared/CreditKit/AGENTS.md @@ -1,52 +1,64 @@ # CreditKit — Module Shape -Attribution for the third-party work a Stuff app is built with, as data the app -can render. See [`README.md`](README.md) for the API and the generation flow; -the repo-wide build, format, and convention rules are in the root -[`AGENTS.md`](../../AGENTS.md). +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, `PeriscopeCore` (logging only). -- **Must not import:** any app or feature module (`WhereCore`, `WhereUI`, …), - or any UI framework. CreditKit is a leaf that anything may depend on; a - dependency edge pointing *out* of it would defeat the reason it exists. -- **Wired in:** `Package.swift` (`CreditKit` product, consumed by `WhereUI`) - and `Project.swift` (`CreditKitTests`, in the `Stuff-iOS-Tests` scheme). +- **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` is the value; `CreditCatalog` is the bundled list. Nothing -else in the module holds state, and neither type knows how it is presented — -grouping, labeling, and translation belong to the consuming UI. +`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 -- **The manifest and the notices are generated, never hand-edited.** - `Sources/Resources/credits.json` and `Sources/Resources/Licenses/*.txt` are - written by `Tools/generate-credits.rb`; editing them by hand puts them at odds - with `Package.resolved` and `.agents/external-skills.json` and the next run - silently reverts it. Re-run the script and commit the result. -- **Adding a dependency anywhere means re-running the script.** The generator - reads `.product(name:package:)` out of the root `Package.swift`, so a package - linked by *any* module is picked up — but only when someone runs it. - `CreditCatalogTests` pins the expected names and is what fails if that is - skipped, so update the test in the same change as a deliberate dependency - change. +- **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. -- **Only packages that are actually linked are `.library` credits.** Packages - resolved for tooling alone (BumperBowling, and swift-syntax beneath it) are - excluded by construction, because the generator keys off `.product(…)` usage - rather than the `dependencies:` list. + presentation. Its raw values are a wire format the generator writes; renaming + a case silently invalidates every committed report. +- **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. ## Testing -`CreditKitTests` covers the bundled catalog end to end: that the manifest -decodes, that the expected libraries and tools are present, and that every -credit resolves a non-empty notice. Shared fixtures live in -`CreditKitTestSupport.swift`. The missing-notice path is deliberately untested — -it trips an `assertionFailure`, which traps the process rather than raising a -catchable issue. +`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. diff --git a/Shared/CreditKit/README.md b/Shared/CreditKit/README.md index c839549f..3db2a453 100644 --- a/Shared/CreditKit/README.md +++ b/Shared/CreditKit/README.md @@ -1,79 +1,117 @@ # CreditKit -Attribution for the third-party work a Stuff app is built with — the libraries -it links and the tools it is developed with — as data the app can render. +Tools and types for working out what an app owes attribution to, and for +shipping that answer inside the app. -CreditKit exists so attribution has one home instead of living wherever a -dependency happened to be introduced. It is a Shared module at the bottom of the -graph, so any module's dependency can be credited without inverting the -dependency that introduced it. +CreditKit holds no credits of its own. It defines the shape of an **attribution +report** and provides the reporting tool that produces one; each app runs the +report over its own declared sources and ships the result in its own resources. +That split is deliberate — a report describes one app's dependency graph, so it +is that app's data, and a second app can adopt CreditKit without inheriting the +first one's credits. ## Install -Add the `CreditKit` product to a target in the root `Package.swift`. It depends -only on `PeriscopeCore`, for logging. +Add the `CreditKit` product to a target in the root `Package.swift`. It has no +dependencies beyond Foundation. ## Quick start +Run the report (see [Generating a report](#generating-a-report)), then decode it +wherever the app wants to show it: + ```swift import CreditKit -for credit in CreditCatalog.shared.credits(ofKind: .library) { - print(credit.name, credit.version, credit.licenseName) - print(credit.licenseText() ?? "notice unavailable") +let report = try AttributionManifest.load(from: .main, resource: "attribution") + +for credit in report.credits(ofKind: .library) { + print(credit.name, credit.version, credit.license.name) + print(credit.license.text) } ``` -The Where app renders this in Settings → About: `WhereUI`'s `AboutSettingsView` -shows one section per kind, and `LicenseView` pushes the full notice. +In the Where app that load is wrapped by `WhereCore.AppAttribution`, which knows +which of its bundles are expected to carry a report, and `WhereUI`'s +`AboutSettingsView` renders one section per kind. ## Public API +- **`AttributionManifest`** — a decoded report. `credits` in report order, + `credits(ofKind:)` to filter, `decode(from:)` for raw JSON, and + `load(from:resource:)` to read one out of a bundle. - **`SoftwareCredit`** — one credited work: `name`, `kind`, `version`, - `homepageURL`, `licenseName`, and `licenseText()` for the verbatim notice. + `homepageURL`, and its `license`. - **`SoftwareCredit.Kind`** — `.library` (compiled into the binary) or `.developmentTool` (used to build the project, absent from the shipped app). -- **`CreditCatalog`** — `shared` for the bundled catalog, `credits` for - everything in manifest order, and `credits(ofKind:)` to filter. +- **`LicenseNotice`** — a license's `name` and its verbatim `text`. +- **`AttributionError`** — `.reportMissing(resource:)` when a bundle carries no + report. Decoding failures surface as `DecodingError` unwrapped, so the coding + path still names the offending field. -## How it works +## Generating a report -`CreditCatalog.shared` decodes a bundled `credits.json`, and each credit's -notice is a vendored `Resources/Licenses/.txt`. Both are **generated**: +An app declares its sources in an `attribution-sources.json`, and the generator +turns that into a manifest: ```bash -ruby Shared/CreditKit/Tools/generate-credits.rb +./attribution # every configured app +ruby Shared/CreditKit/Tools/generate-attribution.rb # just one +``` + +```json +{ + "output": "Where/Where/Resources/attribution.json", + "sources": [ + { "type": "swiftPackageManager", "kind": "library", + "manifest": "Package.swift", "resolved": "Package.resolved" }, + { "type": "agentSkills", "kind": "developmentTool", + "manifest": ".agents/external-skills.json" } + ] +} ``` -The script derives the list from what the repository already declares, so the -manifest can't drift from reality: +Paths are relative to the repository root. Two source types are understood: + +| Type | Reads | Credits | +|------|-------|---------| +| `swiftPackageManager` | packages a target links via `.product(name:package:)`, pinned by the resolved file | one per linked package | +| `agentSkills` | a `./sync-agents` manifest of `name -> { repo, ref }` | one per vendored skill | + +Deriving the list rather than maintaining it is the point: a package linked by +*any* module shows up the next time the report runs, so no module has to +remember to vend a credit — and a package declared for tooling alone is never +linked, so it is correctly left out. -| Kind | Derived from | Notice fetched from | -|------|--------------|---------------------| -| `.library` | packages a target links via `.product(name:package:)` in `Package.swift`, pinned by `Package.resolved` | the project's GitHub repo, at the pinned revision | -| `.developmentTool` | `.agents/external-skills.json` — the agent skills `./sync-agents` vendors | the skill's GitHub repo, at the pinned ref | +The tool needs network and an authenticated `gh`. It is idempotent: re-running +with nothing changed rewrites the same bytes. + +## How it works -Deriving rather than hand-maintaining is the point: a package linked by *any* -module is credited the next time the script runs, so no module has to remember -to vend a credit. It needs network and an authenticated `gh`, and it is -idempotent — re-run it after changing a dependency or running -`./sync-agents --update`, and commit the result. +Each notice is read from the project's GitHub repository **at the pinned +revision**, not the default branch, so the text shipped is the one governing the +code actually in use. Upstream edits notices between releases — a bumped +copyright year, a relicense — and reading HEAD would attribute the wrong terms. -Notices are read at the **pinned revision**, not the default branch, so the text -shipped is the one governing the revision actually in use. +Notices are stored **inline** in the manifest rather than as sidecar files. One +decode then yields everything needed to discharge the attribution, with no +second lookup that can come back empty, and no missing-file failure path to +handle at runtime. ## Contracts and limitations -- **A credit is only as current as the last script run.** Nothing fails a build - when a new dependency lands without regenerating; `CreditCatalogTests` pins - the expected names, so that suite is what catches the drift. +- **A report is only as current as its last run.** Nothing fails a build when a + dependency lands without regenerating. Each app is expected to assert its own + report's contents in its own tests — see `AppAttributionTests` in the Where + app — because only the app knows what it should contain. - **Development tools are not in the binary.** They are credited because the repository makes copies of them, which permissive licenses ask us to attribute. Any UI must keep the two kinds visually distinct so a reader isn't told something untrue about the app they are running. -- **A missing or unreadable notice is a defect, not a cosmetic gap.** It - fault-logs and trips an `assertionFailure` in debug; in release - `licenseText()` returns `nil` so a caller can say the text is unavailable - rather than render a blank screen that reads like a license with no terms. +- **A missing report is not automatically an error.** Only the app target ships + one, so `load` throwing `.reportMissing` is routine in a developer tool or + test host. CreditKit reports it and leaves the judgement to the caller. - **Names, versions, and license titles are never localized.** They are proper nouns and legal terms; a UI supplies the translated framing around them. +- **GitHub-hosted sources only.** Both source types resolve notices through the + GitHub API; a dependency hosted elsewhere would need a new source type. diff --git a/Shared/CreditKit/Sources/AttributionManifest.swift b/Shared/CreditKit/Sources/AttributionManifest.swift new file mode 100644 index 00000000..35cde87b --- /dev/null +++ b/Shared/CreditKit/Sources/AttributionManifest.swift @@ -0,0 +1,59 @@ +import Foundation + +/// A generated attribution report for one app: every third-party work it is +/// built with, each carrying its own license notice. +/// +/// A manifest is **produced by tooling and read at runtime**, never assembled +/// by hand. `Tools/generate-attribution.rb` runs a report over an app's +/// declared sources — its Swift package graph, the agent skills the repository +/// vendors — and writes the result into that app's own resources, which is +/// where it belongs: the report describes one app's dependency graph, so it is +/// the app's data rather than this module's. +/// +/// Deriving it is what keeps it honest. A hand-kept list silently goes stale +/// the moment some *other* module adds a dependency, whereas a report re-run +/// picks that up wherever it landed. +public struct AttributionManifest: Sendable, Hashable, Codable { + /// Every credit, in the order the report generated them. + public let credits: [SoftwareCredit] + + public init(credits: [SoftwareCredit]) { + self.credits = credits + } + + /// The credits of one kind, in report order. + public func credits(ofKind kind: SoftwareCredit.Kind) -> [SoftwareCredit] { + credits.filter { $0.kind == kind } + } +} + +extension AttributionManifest { + /// Decodes a manifest from the JSON a report wrote. + public static func decode(from data: Data) throws -> AttributionManifest { + try JSONDecoder().decode(AttributionManifest.self, from: data) + } + + /// Loads the report `resource`.json from `bundle`. + /// + /// Throws rather than logging: CreditKit has no opinion about how a missing + /// report should be reported, and an app knows things this module can't — + /// notably that some of its bundles (a developer tool, a test host) are + /// *expected* to carry no report, while a malformed one is always a defect. + public static func load( + from bundle: Bundle, + resource: String, + ) throws -> AttributionManifest { + guard let url = bundle.url(forResource: resource, withExtension: "json") else { + throw AttributionError.reportMissing(resource: resource) + } + return try decode(from: Data(contentsOf: url)) + } +} + +/// A failure loading an attribution report. Decoding failures surface as +/// `DecodingError` from the underlying decoder rather than being wrapped, so a +/// caller keeps the coding path that names the bad field. +public enum AttributionError: Error, Hashable { + /// The bundle carries no report under that resource name. + case reportMissing(resource: String) +} diff --git a/Shared/CreditKit/Sources/CreditCatalog.swift b/Shared/CreditKit/Sources/CreditCatalog.swift deleted file mode 100644 index 2ed891bd..00000000 --- a/Shared/CreditKit/Sources/CreditCatalog.swift +++ /dev/null @@ -1,58 +0,0 @@ -import Foundation - -/// Every third-party work the project credits, loaded once from the bundled -/// `credits.json` manifest. -/// -/// The manifest is **generated**, not hand-written: `Tools/generate-credits.rb` -/// derives it from the root `Package.swift` / `Package.resolved` (for linked -/// libraries) and `.agents/external-skills.json` (for development tools), and -/// vendors each license notice beside it. A running app can read neither of -/// those files, which is why the answer is baked into a resource at build time. -/// -/// Deriving the list rather than maintaining it by hand is what keeps it honest -/// as the dependency graph changes: a package linked by *any* module shows up -/// the next time the script runs, so a credit is not something a module has to -/// remember to vend. -public struct CreditCatalog: Sendable { - /// Every credit, in manifest order. - public let credits: [SoftwareCredit] - - /// The process-wide catalog, decoded from the bundle on first use. - public static let shared: CreditCatalog = .loadFromBundle() - - @_spi(Testing) public init(credits: [SoftwareCredit]) { - self.credits = credits - } - - /// The credits of one kind, in manifest order. - public func credits(ofKind kind: SoftwareCredit.Kind) -> [SoftwareCredit] { - credits.filter { $0.kind == kind } - } -} - -extension CreditCatalog { - private static let logger = CreditLog.catalog - - @_spi(Testing) public static func decode(from data: Data) throws -> CreditCatalog { - try CreditCatalog(credits: JSONDecoder().decode([SoftwareCredit].self, from: data)) - } - - private static func loadFromBundle() -> CreditCatalog { - guard let url = Bundle.module.url(forResource: "credits", withExtension: "json") else { - logger { .missingManifest } - assertionFailure("Missing bundled credits.json") - return CreditCatalog(credits: []) - } - do { - let catalog = try decode(from: Data(contentsOf: url)) - logger { .loaded(creditCount: catalog.credits.count) } - return catalog - } catch { - logger(attachments: [.error(error, name: "decode-error")]) { - .decodeFailed(description: error.localizedDescription) - } - assertionFailure("Failed to decode bundled credits.json: \(error)") - return CreditCatalog(credits: []) - } - } -} diff --git a/Shared/CreditKit/Sources/Logging/CreditCatalogLog.swift b/Shared/CreditKit/Sources/Logging/CreditCatalogLog.swift deleted file mode 100644 index ac4e7375..00000000 --- a/Shared/CreditKit/Sources/Logging/CreditCatalogLog.swift +++ /dev/null @@ -1,35 +0,0 @@ -import PeriscopeCore - -/// Structured events for `CreditCatalog`'s bundled-manifest load. A missing or -/// unparseable `credits.json` is a programmer error (corrupt bundled resource), -/// so those cases log at `.fault` to match the paired `assertionFailure` — and -/// it matters beyond a blank screen: the manifest is how the app discharges its -/// attribution obligations. -enum CreditCatalogLog: LogEvent { - /// The bundled `credits.json` manifest is absent from the bundle. - case missingManifest - /// The manifest decoded successfully into `creditCount` entries. - case loaded(creditCount: Int) - /// The manifest was present but could not be decoded. - case decodeFailed(description: String) - - static let eventName = "CreditCatalog" - - var level: LogLevel { - switch self { - case .missingManifest, .decodeFailed: .fault - case .loaded: .info - } - } - - var message: String { - switch self { - case .missingManifest: - "Missing required bundled credits.json manifest" - case let .loaded(creditCount): - "Loaded credit catalog with \(creditCount) credit(s)" - case let .decodeFailed(description): - "Failed to decode bundled credits.json: \(description)" - } - } -} diff --git a/Shared/CreditKit/Sources/Logging/CreditLog.swift b/Shared/CreditKit/Sources/Logging/CreditLog.swift deleted file mode 100644 index dcd93fbb..00000000 --- a/Shared/CreditKit/Sources/Logging/CreditLog.swift +++ /dev/null @@ -1,28 +0,0 @@ -import PeriscopeCore - -/// Phantom root event naming CreditKit's log scope tree. It is never emitted — -/// its only job is to give ``CreditLog``'s root `Log` the scope name -/// `"CreditKit"`, so every CreditKit event sits under one filterable subtree. -struct CreditKitRoot: LogEvent { - static let eventName = "CreditKit" - var message: String { - "" - } -} - -/// Logging facade for `CreditKit`. Every logger site derives from one root -/// `Log` scoped `"CreditKit"`, so CreditKit's events form a single subtree -/// under Periscope's process-wide system (``Periscope/shared``). -/// -/// CreditKit owns its own root scope rather than borrowing a host app's: it is -/// a standalone lower-level module that must not depend on app code. -enum CreditLog { - /// The `"CreditKit"` root every CreditKit logger descends from. - static let root = Log(system: .shared) - - /// `CreditCatalog` — the bundled `credits.json` manifest load. - static let catalog = root(CreditCatalogLog.self) - - /// `SoftwareCredit` — vendored license-text loads. - static let softwareCredit = root(SoftwareCreditLog.self) -} diff --git a/Shared/CreditKit/Sources/Logging/SoftwareCreditLog.swift b/Shared/CreditKit/Sources/Logging/SoftwareCreditLog.swift deleted file mode 100644 index 6fa99c72..00000000 --- a/Shared/CreditKit/Sources/Logging/SoftwareCreditLog.swift +++ /dev/null @@ -1,28 +0,0 @@ -import PeriscopeCore - -/// Structured events for loading a vendored third-party license text. A credit -/// naming a resource the bundle doesn't carry is a programmer error (the -/// manifest and the vendored files went out of sync), so it logs at `.fault` to -/// match the paired `assertionFailure` — and it matters: shipping a dependency -/// without its license text is a licensing problem, not a cosmetic one. -enum SoftwareCreditLog: LogEvent { - /// The credit's license file is absent from the bundle. - case missingLicense(credit: String, resource: String) - /// The license file is present but could not be read as text. - case unreadableLicense(credit: String, description: String) - - static let eventName = "SoftwareCredit" - - var level: LogLevel { - .fault - } - - var message: String { - switch self { - case let .missingLicense(credit, resource): - "Missing bundled license text \(resource).txt for \(credit)" - case let .unreadableLicense(credit, description): - "Failed to read bundled license text for \(credit): \(description)" - } - } -} diff --git a/Shared/CreditKit/Sources/Resources/Licenses/ZIPFoundation.txt b/Shared/CreditKit/Sources/Resources/Licenses/ZIPFoundation.txt deleted file mode 100644 index 07362ff8..00000000 --- a/Shared/CreditKit/Sources/Resources/Licenses/ZIPFoundation.txt +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2017-2025 Thomas Zoechling (https://www.peakstep.com) - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/Shared/CreditKit/Sources/Resources/Licenses/swift-concurrency-pro.txt b/Shared/CreditKit/Sources/Resources/Licenses/swift-concurrency-pro.txt deleted file mode 100644 index e9f6fd1f..00000000 --- a/Shared/CreditKit/Sources/Resources/Licenses/swift-concurrency-pro.txt +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2026 Paul Hudson. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. \ No newline at end of file diff --git a/Shared/CreditKit/Sources/Resources/Licenses/swift-testing-pro.txt b/Shared/CreditKit/Sources/Resources/Licenses/swift-testing-pro.txt deleted file mode 100644 index e9f6fd1f..00000000 --- a/Shared/CreditKit/Sources/Resources/Licenses/swift-testing-pro.txt +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2026 Paul Hudson. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. \ No newline at end of file diff --git a/Shared/CreditKit/Sources/Resources/Licenses/swiftdata-pro.txt b/Shared/CreditKit/Sources/Resources/Licenses/swiftdata-pro.txt deleted file mode 100644 index e9f6fd1f..00000000 --- a/Shared/CreditKit/Sources/Resources/Licenses/swiftdata-pro.txt +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2026 Paul Hudson. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. \ No newline at end of file diff --git a/Shared/CreditKit/Sources/Resources/Licenses/swiftui-pro.txt b/Shared/CreditKit/Sources/Resources/Licenses/swiftui-pro.txt deleted file mode 100644 index e9f6fd1f..00000000 --- a/Shared/CreditKit/Sources/Resources/Licenses/swiftui-pro.txt +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2026 Paul Hudson. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. \ No newline at end of file diff --git a/Shared/CreditKit/Sources/Resources/credits.json b/Shared/CreditKit/Sources/Resources/credits.json deleted file mode 100644 index feb64bc1..00000000 --- a/Shared/CreditKit/Sources/Resources/credits.json +++ /dev/null @@ -1,42 +0,0 @@ -[ - { - "name": "ZIPFoundation", - "kind": "library", - "version": "0.9.20", - "homepageURL": "https://github.com/weichsel/ZIPFoundation", - "licenseName": "MIT License", - "licenseResource": "ZIPFoundation" - }, - { - "name": "swift-concurrency-pro", - "kind": "developmentTool", - "version": "e710f8d577cc", - "homepageURL": "https://github.com/twostraws/Swift-Concurrency-Agent-Skill", - "licenseName": "MIT License", - "licenseResource": "swift-concurrency-pro" - }, - { - "name": "swift-testing-pro", - "kind": "developmentTool", - "version": "2d6bba14a3c8", - "homepageURL": "https://github.com/twostraws/Swift-Testing-Agent-Skill", - "licenseName": "MIT License", - "licenseResource": "swift-testing-pro" - }, - { - "name": "swiftdata-pro", - "kind": "developmentTool", - "version": "922d989473a9", - "homepageURL": "https://github.com/twostraws/SwiftData-Agent-Skill", - "licenseName": "MIT License", - "licenseResource": "swiftdata-pro" - }, - { - "name": "swiftui-pro", - "kind": "developmentTool", - "version": "61b74001b64b", - "homepageURL": "https://github.com/twostraws/swiftui-agent-skill", - "licenseName": "MIT License", - "licenseResource": "swiftui-pro" - } -] diff --git a/Shared/CreditKit/Sources/SoftwareCredit.swift b/Shared/CreditKit/Sources/SoftwareCredit.swift index f41d7dcf..2f0848b4 100644 --- a/Shared/CreditKit/Sources/SoftwareCredit.swift +++ b/Shared/CreditKit/Sources/SoftwareCredit.swift @@ -1,17 +1,17 @@ import Foundation -/// A third-party work the project uses, with the license it is used under. +/// A third-party work an app is built with, and the license it is used under. /// /// Credits cover two different relationships, which ``Kind`` keeps apart: a /// ``Kind/library`` is compiled into the shipping binary, while a /// ``Kind/developmentTool`` is only used to build the project and never reaches /// a user's device. Both need attribution — permissive licenses ask for the /// notice to travel with every copy — but conflating them would tell a reader -/// of the About screen something untrue about the app they are running, so the -/// distinction is modeled rather than left to a comment. +/// something untrue about the app they are running, so the distinction is +/// modeled rather than left to a comment. /// /// Names, versions, and license titles are proper nouns and legal terms, so -/// they are deliberately **not** localized; the UI supplies the translated +/// they are deliberately **not** localized; a UI supplies the translated /// framing around them. public struct SoftwareCredit: Sendable, Hashable, Identifiable, Codable { /// How a credited work relates to the shipping app. @@ -31,64 +31,43 @@ public struct SoftwareCredit: Sendable, Hashable, Identifiable, Codable { public let version: String /// Where the project lives, for a "learn more" link. public let homepageURL: URL? - /// The license's title, e.g. "MIT License". - public let licenseName: String - /// Stem of the vendored license file under `Resources/Licenses/`. - let licenseResource: String + /// The license, carrying its own notice text. + public let license: LicenseNotice public var id: String { name } - /// The full license text, or `nil` when the vendored file is missing or - /// unreadable. - /// - /// MIT and most permissive licenses require shipping the notice verbatim, so - /// a missing file is a real defect rather than a cosmetic one: it fault-logs - /// and trips an `assertionFailure` in debug, and returns `nil` in release so - /// the caller can say the text is unavailable instead of rendering a blank - /// screen that reads like a license with no terms. - public func licenseText() -> String? { - // `.process("Resources")` preserves the `Licenses/` subdirectory, but - // fall back to a top-level lookup in case a bundler flattens it (the - // same defense `CreditCatalog` takes for its manifest). - let url = Bundle.module.url( - forResource: licenseResource, - withExtension: "txt", - subdirectory: "Licenses", - ) ?? Bundle.module.url(forResource: licenseResource, withExtension: "txt") - - guard let url else { - Self.logger { .missingLicense(credit: name, resource: licenseResource) } - assertionFailure("Missing bundled license text for \(name)") - return nil - } - do { - return try String(contentsOf: url, encoding: .utf8) - } catch { - Self.logger(attachments: [.error(error, name: "read-error")]) { - .unreadableLicense(credit: name, description: error.localizedDescription) - } - assertionFailure("Failed to read bundled license text for \(name): \(error)") - return nil - } - } - - @_spi(Testing) public init( + public init( name: String, kind: Kind, version: String, homepageURL: URL?, - licenseName: String, - licenseResource: String, + license: LicenseNotice, ) { self.name = name self.kind = kind self.version = version self.homepageURL = homepageURL - self.licenseName = licenseName - self.licenseResource = licenseResource + self.license = license } +} - private static let logger = CreditLog.softwareCredit +/// A license and the notice that has to travel with it. +/// +/// The text is carried inline rather than referenced by filename so a manifest +/// is self-contained: one decode yields everything needed to discharge the +/// attribution, with no second lookup that can come back empty. Permissive +/// licenses require the notice verbatim, which is why it is stored rather than +/// summarized or reflowed. +public struct LicenseNotice: Sendable, Hashable, Codable { + /// The license's title, e.g. "MIT License". + public let name: String + /// The full notice, verbatim. + public let text: String + + public init(name: String, text: String) { + self.name = name + self.text = text + } } diff --git a/Shared/CreditKit/Tests/AttributionManifestTests.swift b/Shared/CreditKit/Tests/AttributionManifestTests.swift new file mode 100644 index 00000000..0f8c854c --- /dev/null +++ b/Shared/CreditKit/Tests/AttributionManifestTests.swift @@ -0,0 +1,96 @@ +import CreditKit +import Foundation +import Testing + +/// Covers the manifest as a *format* and an API. Deliberately names no real +/// dependency: which packages an app links is that app's fact to assert, not +/// this library's — see `AppAttributionTests` in the Where app's bundle. +struct AttributionManifestTests { + // MARK: Decoding the generated shape + + @Test func decodesTheShapeTheReportWrites() throws { + let manifest = try AttributionManifest.decode(from: Data(SampleReport.json.utf8)) + + #expect(manifest.credits.map(\.name) == ["Linked", "Tool"]) + let linked = try #require(manifest.credits.first) + #expect(linked.kind == .library) + #expect(linked.version == "0.9.20") + #expect(linked.homepageURL == URL(string: "https://github.com/example/linked")) + #expect(linked.license.name == "MIT License") + #expect(linked.license.text == "Linked notice.") + } + + @Test func decodingRejectsAMalformedReport() { + #expect(throws: (any Error).self) { + try AttributionManifest.decode(from: Data(#"{"credits":[{"name":"Nope"}]}"#.utf8)) + } + } + + @Test func decodingRejectsAnUnknownKind() { + // `Kind` is a closed vocabulary: a report naming something else is a + // generator/runtime mismatch and must fail loudly, not decode to a + // silent default. + let json = """ + {"credits":[{"name":"X","kind":"plugin","version":"1", + "license":{"name":"MIT","text":"t"}}]} + """ + #expect(throws: (any Error).self) { + try AttributionManifest.decode(from: Data(json.utf8)) + } + } + + @Test func decodesAReportWithNoCredits() throws { + // Structurally valid and distinct from a *missing* report, which is the + // case a caller is expected to treat differently. + let manifest = try AttributionManifest.decode(from: Data(#"{"credits":[]}"#.utf8)) + #expect(manifest.credits.isEmpty) + } + + @Test func aCreditWithoutAHomepageIsAllowed() throws { + let json = """ + {"credits":[{"name":"X","kind":"library","version":"1", + "license":{"name":"MIT","text":"t"}}]} + """ + let manifest = try AttributionManifest.decode(from: Data(json.utf8)) + #expect(try #require(manifest.credits.first).homepageURL == nil) + } + + // MARK: Round trip + + @Test func survivesAnEncodeDecodeRoundTrip() throws { + let original = AttributionManifest(credits: [ + .fixture(name: "Linked", kind: .library), + .fixture(name: "Tool", kind: .developmentTool), + ]) + let data = try JSONEncoder().encode(original) + #expect(try AttributionManifest.decode(from: data) == original) + } + + // MARK: Filtering + + @Test func filtersToASingleKind() { + let manifest = AttributionManifest(credits: [ + .fixture(name: "Linked", kind: .library), + .fixture(name: "Tool", kind: .developmentTool), + .fixture(name: "AlsoLinked", kind: .library), + ]) + #expect(manifest.credits(ofKind: .library).map(\.name) == ["Linked", "AlsoLinked"]) + #expect(manifest.credits(ofKind: .developmentTool).map(\.name) == ["Tool"]) + } + + @Test func filteringPreservesReportOrder() { + let names = ["c", "a", "b"] + let manifest = AttributionManifest( + credits: names.map { .fixture(name: $0, kind: .library) }, + ) + #expect(manifest.credits(ofKind: .library).map(\.name) == names) + } + + // MARK: Loading from a bundle + + @Test func loadingThrowsWhenTheBundleCarriesNoReport() { + #expect(throws: AttributionError.reportMissing(resource: "attribution")) { + try AttributionManifest.load(from: .main, resource: "attribution") + } + } +} diff --git a/Shared/CreditKit/Tests/CreditCatalogTests.swift b/Shared/CreditKit/Tests/CreditCatalogTests.swift deleted file mode 100644 index 475b1791..00000000 --- a/Shared/CreditKit/Tests/CreditCatalogTests.swift +++ /dev/null @@ -1,54 +0,0 @@ -@_spi(Testing) import CreditKit -import Foundation -import Testing - -struct CreditCatalogTests { - @Test("loads the bundled manifest") - func loadsTheBundledManifest() { - #expect(!CreditCatalog.shared.credits.isEmpty) - } - - @Test("credits every package a target links") - func creditsEveryLinkedPackage() { - let libraries = CreditCatalog.shared.credits(ofKind: .library).map(\.name) - // The one external package any target links via `.product(name:package:)`. - // BumperBowling and swift-syntax are resolved for architecture linting - // and never reach the binary, so they are deliberately absent. - #expect(libraries == ["ZIPFoundation"]) - } - - @Test("credits the vendored agent skills") - func creditsTheVendoredAgentSkills() { - let tools = Set(CreditCatalog.shared.credits(ofKind: .developmentTool).map(\.name)) - #expect(tools == [ - "swift-concurrency-pro", - "swift-testing-pro", - "swiftdata-pro", - "swiftui-pro", - ]) - } - - @Test("groups libraries ahead of development tools") - func groupsLibrariesAheadOfDevelopmentTools() { - let kinds = CreditCatalog.shared.credits.map(\.kind) - #expect(kinds == kinds.sorted { first, _ in first == .library }) - } - - @Test("decoding rejects a malformed manifest") - func decodingRejectsAMalformedManifest() { - let data = Data(#"[{"name": "Nope"}]"#.utf8) - #expect(throws: (any Error).self) { - try CreditCatalog.decode(from: data) - } - } - - @Test("filters to a single kind") - func filtersToASingleKind() { - let catalog = CreditCatalog(credits: [ - .fixture(name: "Linked", kind: .library), - .fixture(name: "Tool", kind: .developmentTool), - ]) - #expect(catalog.credits(ofKind: .library).map(\.name) == ["Linked"]) - #expect(catalog.credits(ofKind: .developmentTool).map(\.name) == ["Tool"]) - } -} diff --git a/Shared/CreditKit/Tests/CreditKitTestSupport.swift b/Shared/CreditKit/Tests/CreditKitTestSupport.swift index 991ebd14..514909ae 100644 --- a/Shared/CreditKit/Tests/CreditKitTestSupport.swift +++ b/Shared/CreditKit/Tests/CreditKitTestSupport.swift @@ -1,22 +1,46 @@ -@_spi(Testing) import CreditKit +import CreditKit import Foundation extension SoftwareCredit { - /// A credit pointing at a real vendored notice by default, so - /// ``SoftwareCredit/licenseText()`` resolves without tripping the - /// missing-resource `assertionFailure`. static func fixture( - name: String, - kind: Kind, - resource: String = "ZIPFoundation", + name: String = "Example", + kind: Kind = .library, + version: String = "1.2.3", + licenseName: String = "MIT License", + licenseText: String = "Copyright (c) 2026 Example Author", ) -> SoftwareCredit { SoftwareCredit( name: name, kind: kind, - version: "1.2.3", - homepageURL: URL(string: "https://example.com"), - licenseName: "MIT License", - licenseResource: resource, + version: version, + homepageURL: URL(string: "https://example.com/\(name)"), + license: LicenseNotice(name: licenseName, text: licenseText), ) } } + +enum SampleReport { + /// The JSON shape `generate-attribution.rb` writes. Kept as a literal rather + /// than round-tripped from an encoder so a change to the Swift types that + /// silently breaks the wire format fails a test. + static let json = """ + { + "credits": [ + { + "name": "Linked", + "kind": "library", + "version": "0.9.20", + "homepageURL": "https://github.com/example/linked", + "license": { "name": "MIT License", "text": "Linked notice." } + }, + { + "name": "Tool", + "kind": "developmentTool", + "version": "e710f8d577cc", + "homepageURL": "https://github.com/example/tool", + "license": { "name": "Apache License 2.0", "text": "Tool notice." } + } + ] + } + """ +} diff --git a/Shared/CreditKit/Tests/SoftwareCreditTests.swift b/Shared/CreditKit/Tests/SoftwareCreditTests.swift index 3d845203..5f5fabfc 100644 --- a/Shared/CreditKit/Tests/SoftwareCreditTests.swift +++ b/Shared/CreditKit/Tests/SoftwareCreditTests.swift @@ -1,36 +1,17 @@ -@_spi(Testing) import CreditKit +import CreditKit import Foundation import Testing struct SoftwareCreditTests { - // These iterate rather than taking `arguments:`: a parameterized case list - // is evaluated when the test is discovered, which is before `Bundle.module` - // resolves the manifest — the suite would silently register zero cases and - // report as passing without checking anything. - - @Test("every credit ships a non-empty license notice") - func everyCreditShipsALicenseNotice() { - for credit in CreditCatalog.shared.credits { - let text = credit.licenseText() - #expect(text?.isEmpty == false, "\(credit.name) has no vendored license text") - } - } - - @Test("every credit names its license, version, and home") - func everyCreditNamesItsLicenseVersionAndHome() { - for credit in CreditCatalog.shared.credits { - #expect(!credit.licenseName.isEmpty, "\(credit.name) has no license name") - #expect(!credit.version.isEmpty, "\(credit.name) has no version") - #expect(credit.homepageURL != nil, "\(credit.name) has no homepage") - } + @Test func identifiesByName() { + #expect(SoftwareCredit.fixture(name: "ZIPFoundation").id == "ZIPFoundation") } - @Test("identifies by name") - func identifiesByName() { - #expect(SoftwareCredit.fixture(name: "ZIPFoundation", kind: .library).id == "ZIPFoundation") + @Test func kindEncodesAsItsWireName() throws { + // The generator writes these strings, so renaming a case would silently + // stop matching every report already committed. + let encoded = try JSONEncoder().encode(SoftwareCredit.Kind.allCases) + let names = try JSONDecoder().decode([String].self, from: encoded) + #expect(names == ["library", "developmentTool"]) } - - // A credit naming an absent resource is deliberately not covered: the - // missing-license path trips an `assertionFailure`, which traps the test - // process rather than raising a catchable issue in a debug build. } diff --git a/Shared/CreditKit/Tools/generate-attribution.rb b/Shared/CreditKit/Tools/generate-attribution.rb new file mode 100755 index 00000000..d85ea812 --- /dev/null +++ b/Shared/CreditKit/Tools/generate-attribution.rb @@ -0,0 +1,173 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Runs an attribution report for one app and writes it as a self-contained +# manifest that `CreditKit.AttributionManifest` decodes at runtime. +# +# The report is generated rather than hand-kept, and that is the whole point: a +# hand-maintained list silently goes stale the moment some *other* module adds +# a dependency, whereas a re-run picks it up wherever it landed. +# +# The app declares what to report on; this script knows *source types*, not any +# one repository's paths. Config (paths relative to the repo root): +# +# { +# "output": "Where/Where/Resources/attribution.json", +# "sources": [ +# { "type": "swiftPackageManager", "kind": "library", +# "manifest": "Package.swift", "resolved": "Package.resolved" }, +# { "type": "agentSkills", "kind": "developmentTool", +# "manifest": ".agents/external-skills.json" } +# ] +# } +# +# Source types: +# +# - `swiftPackageManager` — packages a target actually links via +# `.product(name:package:)` in the manifest, pinned by the resolved file. A +# package declared for tooling alone (an architecture linter, say) is never +# linked and so is deliberately not credited. +# - `agentSkills` — a `./sync-agents` external-skills manifest of +# `name -> { repo, ref }`. These are not in the binary, but the repository +# makes copies of them, which is what their licenses ask us to attribute. +# +# Each credit carries its notice **inline**, read at the pinned revision, so one +# decode yields everything needed to discharge the attribution and there is no +# second lookup that can come back empty. +# +# Needs network and an authenticated `gh`. Idempotent — re-run after changing a +# dependency or running `./sync-agents --update`. Prefer the repo-root wrapper: +# ./attribution +# or point it at one config directly: +# ruby Shared/CreditKit/Tools/generate-attribution.rb + +require "json" +require "fileutils" +require "open3" + +ROOT = File.expand_path("../../..", __dir__) + +def fail_with(message) + abort "generate-attribution: #{message}" +end + +# GitHub's license endpoint resolves the notice's filename for us (LICENSE, +# LICENSE.md, COPYING, …) and reports the license's title alongside it, so one +# call answers both "what license" and "what text". +# +# Always read it at the pinned revision rather than the default branch: a +# notice we ship should be the one that governs the revision we actually use, +# and upstream edits it (a bumped copyright year, a relicense) between releases. +def github_license(slug, ref) + out, err, status = Open3.capture3("gh", "api", "repos/#{slug}/license?ref=#{ref}") + fail_with("could not read the license for #{slug}: #{err.strip}") unless status.success? + payload = JSON.parse(out) + text = payload["content"].to_s.unpack1("m") + fail_with("#{slug} reports a license with no text") if text.strip.empty? + { "name" => payload.dig("license", "name") || "See notice", "text" => text } +end + +def github_slug(location) + location[%r{github\.com[:/](.+?)(?:\.git)?/?\z}, 1] +end + +def credit(name:, kind:, version:, slug:, ref:) + { + "name" => name, + "kind" => kind, + "version" => version, + "homepageURL" => "https://github.com/#{slug}", + "license" => github_license(slug, ref), + } +end + +def read_json(relative_path, source_type) + path = File.join(ROOT, relative_path) + fail_with("#{source_type}: no file at #{relative_path}") unless File.exist?(path) + JSON.parse(File.read(path)) +end + +# Packages some target actually links, by SPM identity (the lowercased package +# name as it appears in `.product(name:package:)`). +def linked_package_identities(manifest_path) + path = File.join(ROOT, manifest_path) + fail_with("swiftPackageManager: no manifest at #{manifest_path}") unless File.exist?(path) + File.read(path) + .scan(/\.product\(\s*name:\s*"[^"]+",\s*package:\s*"([^"]+)"/) + .flatten.map(&:downcase).uniq +end + +def swift_package_manager_credits(source) + kind = source.fetch("kind") + linked = linked_package_identities(source.fetch("manifest")) + fail_with("swiftPackageManager: no linked packages found") if linked.empty? + + resolved = read_json(source.fetch("resolved"), "swiftPackageManager") + pins = resolved.fetch("pins").select { |pin| linked.include?(pin["identity"]) } + missing = linked - pins.map { |pin| pin["identity"] } + fail_with("#{missing.join(", ")} linked but unresolved") unless missing.empty? + + pins.map do |pin| + slug = github_slug(pin.fetch("location")) + fail_with("#{pin["identity"]} is not hosted on GitHub") unless slug + state = pin.fetch("state") + revision = state.fetch("revision") + credit( + name: slug.split("/").last, + kind: kind, + # A branch-pinned package has no version, so fall back to the revision. + version: state["version"] || revision[0, 12], + slug: slug, + ref: revision, + ) + end +end + +def agent_skills_credits(source) + kind = source.fetch("kind") + read_json(source.fetch("manifest"), "agentSkills").map do |name, entry| + ref = entry.fetch("ref") + credit( + name: name, + kind: kind, + version: ref[0, 12], + slug: entry.fetch("repo"), + ref: ref, + ) + end +end + +SOURCE_TYPES = { + "swiftPackageManager" => method(:swift_package_manager_credits), + "agentSkills" => method(:agent_skills_credits), +}.freeze + +def report(config_path) + config = JSON.parse(File.read(config_path)) + sources = config.fetch("sources") + fail_with("#{config_path} declares no sources") if sources.empty? + + credits = sources.flat_map do |source| + type = source.fetch("type") + generate = SOURCE_TYPES[type] + fail_with("unknown source type #{type.inspect} in #{config_path}") unless generate + # Sort within a source so the report has a stable order and re-running it + # produces no diff when nothing changed. + generate.call(source).sort_by { |entry| entry["name"].downcase } + end + fail_with("#{config_path} produced no credits") if credits.empty? + + output = File.join(ROOT, config.fetch("output")) + FileUtils.mkdir_p(File.dirname(output)) + File.write(output, "#{JSON.pretty_generate({ "credits" => credits })}\n") + + puts "#{config.fetch("output")}: #{credits.count} credit(s)" + credits.each { |entry| puts " #{entry["kind"]}: #{entry["name"]} #{entry["version"]}" } +end + +configs = ARGV +fail_with("usage: generate-attribution.rb ...") if configs.empty? +configs.each do |config| + fail_with("no config at #{config}") unless File.exist?(config) + report(config) +end diff --git a/Shared/CreditKit/Tools/generate-credits.rb b/Shared/CreditKit/Tools/generate-credits.rb deleted file mode 100755 index 9714234b..00000000 --- a/Shared/CreditKit/Tools/generate-credits.rb +++ /dev/null @@ -1,130 +0,0 @@ -#!/usr/bin/env ruby -# frozen_string_literal: true - -# Generates CreditKit's `credits.json` manifest and vendors the license notice -# for every third-party work the project uses. -# -# It derives the list rather than trusting a hand-kept one, which is the point: -# a package linked by *any* module is credited the next time this runs, so no -# module has to remember to vend a credit. Two inputs, one per credit kind: -# -# - `Package.swift` + `Package.resolved` -> `library` credits. Only packages -# a target actually links via `.product(name:package:)` count; a package -# declared for tooling alone (BumperBowling, and swift-syntax underneath it) -# never reaches the binary and so is deliberately not credited. -# - `.agents/external-skills.json` -> `developmentTool` credits. These are the -# agent skills `./sync-agents` vendors into `.agents/skills/`. They are not -# in the app, but we do make copies of them, which is what their licenses -# ask us to attribute. -# -# and writes, into `Sources/Resources/`: -# - `credits.json` — the manifest `CreditCatalog` decodes: `{ name, kind, -# version, homepageURL?, licenseName, licenseResource }`, libraries first, -# each group alphabetical. -# - `Licenses/.txt` — the verbatim notice, fetched from the project's -# GitHub repository (which also reports the license's title). -# -# Needs network and an authenticated `gh`. Idempotent: re-run after changing a -# dependency or running `./sync-agents --update`. From the repo root: -# ruby Shared/CreditKit/Tools/generate-credits.rb - -require "json" -require "fileutils" -require "open3" - -ROOT = File.expand_path("../../..", __dir__) -RESOURCES = File.expand_path("../Sources/Resources", __dir__) -LICENSES_DIR = File.join(RESOURCES, "Licenses") - -def fail_with(message) - abort "generate-credits: #{message}" -end - -# GitHub's license endpoint resolves the notice's filename for us (LICENSE, -# LICENSE.md, COPYING, …) and reports the license's title alongside it, so one -# call answers both "what license" and "what text". -# -# Always read it at the pinned revision rather than the default branch: a -# notice we ship should be the one that governs the revision we actually use, -# and upstream edits it (a bumped copyright year, a relicense) between releases. -def github_license(slug, ref) - out, err, status = Open3.capture3("gh", "api", "repos/#{slug}/license?ref=#{ref}") - fail_with("could not read the license for #{slug}: #{err.strip}") unless status.success? - payload = JSON.parse(out) - text = payload["content"].to_s.unpack1("m") - fail_with("#{slug} reports a license with no text") if text.strip.empty? - [payload.dig("license", "name") || "See notice", text] -end - -def github_slug(location) - location[%r{github\.com[:/](.+?)(?:\.git)?/?\z}, 1] -end - -# Packages some target actually links, by SPM identity (lowercased package name -# as it appears in `.product(name:package:)`). -def linked_package_identities - manifest = File.read(File.join(ROOT, "Package.swift")) - manifest.scan(/\.product\(\s*name:\s*"[^"]+",\s*package:\s*"([^"]+)"/) - .flatten.map(&:downcase).uniq -end - -def library_credits - resolved = JSON.parse(File.read(File.join(ROOT, "Package.resolved"))) - linked = linked_package_identities - fail_with("no linked packages found in Package.swift") if linked.empty? - - pins = resolved.fetch("pins").select { |pin| linked.include?(pin["identity"]) } - missing = linked - pins.map { |pin| pin["identity"] } - fail_with("#{missing.join(", ")} linked but absent from Package.resolved") unless missing.empty? - - pins.map do |pin| - location = pin.fetch("location") - slug = github_slug(location) || fail_with("#{pin["identity"]} is not a GitHub package") - state = pin.fetch("state") - revision = state.fetch("revision") - # A branch-pinned package has no version, so fall back to the short revision. - version = state["version"] || revision[0, 12] - name = slug.split("/").last - license_name, text = github_license(slug, revision) - { name: name, kind: "library", version: version, - homepage: "https://github.com/#{slug}", license_name: license_name, text: text } - end -end - -def development_tool_credits - path = File.join(ROOT, ".agents", "external-skills.json") - return [] unless File.exist?(path) - - JSON.parse(File.read(path)).map do |name, entry| - slug = entry.fetch("repo") - license_name, text = github_license(slug, entry.fetch("ref")) - { name: name, kind: "developmentTool", version: entry.fetch("ref")[0, 12], - homepage: "https://github.com/#{slug}", license_name: license_name, text: text } - end -end - -credits = library_credits.sort_by { |credit| credit[:name].downcase } + - development_tool_credits.sort_by { |credit| credit[:name].downcase } -fail_with("no credits generated") if credits.empty? - -FileUtils.mkdir_p(LICENSES_DIR) -# Rewrite the vendored notices from scratch so a dropped dependency doesn't -# leave a stale license behind for a credit that no longer exists. -FileUtils.rm_f(Dir.glob(File.join(LICENSES_DIR, "*.txt"))) - -manifest = credits.map do |credit| - resource = credit[:name] - File.write(File.join(LICENSES_DIR, "#{resource}.txt"), credit[:text]) - { - "name" => credit[:name], - "kind" => credit[:kind], - "version" => credit[:version], - "homepageURL" => credit[:homepage], - "licenseName" => credit[:license_name], - "licenseResource" => resource, - } -end - -File.write(File.join(RESOURCES, "credits.json"), "#{JSON.pretty_generate(manifest)}\n") -puts "Wrote #{manifest.count} credit(s) and #{manifest.count} license notice(s)." -manifest.each { |credit| puts " #{credit["kind"]}: #{credit["name"]} #{credit["version"]}" } diff --git a/Where/AGENTS.md b/Where/AGENTS.md index 63933198..195347e2 100644 --- a/Where/AGENTS.md +++ b/Where/AGENTS.md @@ -113,20 +113,26 @@ switch over them is exhaustive, so a new drill-in is a set of compile errors to fill in rather than a screen you can forget to register. **About is deliberately the last block**, below everything actionable. -**Credits are vended by the module that owns the thing being credited**, and the -About screen only renders them: `SoftwareCredit` (CreditKit) for third-party -software plus its notice, `RegionDataSource` (RegionKit) for bundled geometry, -`BuildInfo` (WhereCore) for the running build. Adding a dependency or a dataset -means updating it *there* — see +**Nothing on the About screen is a list hard-coded in the view.** It renders +three sources: the generated attribution report (via `WhereCore.AppAttribution`), +`RegionDataSource` (RegionKit) for bundled geometry, and `BuildInfo` (WhereCore) +for the running build. Adding a dependency means re-running `./attribution`; +adding a dataset means regenerating RegionKit's — see [`CreditKit/AGENTS.md`](../Shared/CreditKit/AGENTS.md) and -[`RegionKit/AGENTS.md`](RegionKit/AGENTS.md) — never a list hard-coded in the -view. - -CreditKit distinguishes a **linked library** from a **development tool** (the -agent skills `./sync-agents` vendors), and the About screen renders them as -separate sections. Keep them separate: a development tool is credited because -the repository copies it, not because it is in the binary, and one merged list -would tell a reader something untrue about the app they are running. +[`RegionKit/AGENTS.md`](RegionKit/AGENTS.md). + +The report lives in `Where/Where/Resources/attribution.json` — the **app +target's** resources, so only the app bundle carries one. Every other bundle +(RegionViewer, `StuffTestHost`, the extensions) reads `nil` and the screen says +so, exactly as it does for an unstamped `BuildInfo`. `AppAttributionTests`, in +the app's own test bundle because it is the one hosted by `Where.app`, asserts +what the report contains. + +A **linked library** and a **development tool** (the agent skills +`./sync-agents` vendors) render as separate sections. Keep them separate: a +development tool is credited because the repository copies it, not because it is +in the binary, and one merged list would tell a reader something untrue about +the app they are running. ## Localization diff --git a/Where/Where/AGENTS.md b/Where/Where/AGENTS.md index 69e59dec..fd66543c 100644 --- a/Where/Where/AGENTS.md +++ b/Where/Where/AGENTS.md @@ -23,6 +23,13 @@ layering, and the domain rules this target merely starts up. 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, and + `attribution-sources.json` beside it declares where the report reads from. + Both are `./attribution`'s (see + [Attribution](../../AGENTS.md#attribution)) — never hand-edit the report. This + is the **only** bundle that carries one, which is why `AppAttributionTests` + lives in this target's test bundle: it is the one hosted by `Where.app`, so + `Bundle.main` is the shipping bundle. ## Invariants diff --git a/Where/Where/Resources/attribution.json b/Where/Where/Resources/attribution.json new file mode 100644 index 00000000..8a573090 --- /dev/null +++ b/Where/Where/Resources/attribution.json @@ -0,0 +1,54 @@ +{ + "credits": [ + { + "name": "ZIPFoundation", + "kind": "library", + "version": "0.9.20", + "homepageURL": "https://github.com/weichsel/ZIPFoundation", + "license": { + "name": "MIT License", + "text": "MIT License\n\nCopyright (c) 2017-2025 Thomas Zoechling (https://www.peakstep.com)\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", + "version": "e710f8d577cc", + "homepageURL": "https://github.com/twostraws/Swift-Concurrency-Agent-Skill", + "license": { + "name": "MIT License", + "text": "MIT License\n\nCopyright (c) 2026 Paul Hudson.\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." + } + }, + { + "name": "swift-testing-pro", + "kind": "developmentTool", + "version": "2d6bba14a3c8", + "homepageURL": "https://github.com/twostraws/Swift-Testing-Agent-Skill", + "license": { + "name": "MIT License", + "text": "MIT License\n\nCopyright (c) 2026 Paul Hudson.\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." + } + }, + { + "name": "swiftdata-pro", + "kind": "developmentTool", + "version": "922d989473a9", + "homepageURL": "https://github.com/twostraws/SwiftData-Agent-Skill", + "license": { + "name": "MIT License", + "text": "MIT License\n\nCopyright (c) 2026 Paul Hudson.\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." + } + }, + { + "name": "swiftui-pro", + "kind": "developmentTool", + "version": "61b74001b64b", + "homepageURL": "https://github.com/twostraws/swiftui-agent-skill", + "license": { + "name": "MIT License", + "text": "MIT License\n\nCopyright (c) 2026 Paul Hudson.\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." + } + } + ] +} diff --git a/Where/Where/Tests/AppAttributionTests.swift b/Where/Where/Tests/AppAttributionTests.swift new file mode 100644 index 00000000..9b96003d --- /dev/null +++ b/Where/Where/Tests/AppAttributionTests.swift @@ -0,0 +1,61 @@ +import CreditKit +import Foundation +import Testing +import WhereCore + +/// Covers the Where app's *own* attribution report, which is why it lives here +/// rather than in `CreditKitTests`: this bundle is hosted by `Where.app`, so +/// `Bundle.main` is the shipping bundle, and a generic attribution library has +/// no business asserting which packages its consumer links. +/// +/// These are the drift guard. `./attribution` regenerates the report, but +/// nothing forces it to be re-run, so a dependency added without regenerating +/// fails here rather than silently shipping an incomplete About screen. +@MainActor +struct AppAttributionTests { + private func report() throws -> AttributionManifest { + try #require( + AppAttribution.current(bundle: .main), + "the app bundle carries no attribution report — run ./attribution", + ) + } + + @Test func theAppBundleCarriesAReport() throws { + #expect(try !report().credits.isEmpty) + } + + @Test func creditsEveryPackageATargetLinks() throws { + // The one external package any target links via `.product(name:package:)`. + // BumperBowling and swift-syntax are resolved for architecture linting + // and never reach the binary, so they are deliberately absent. + #expect(try report().credits(ofKind: .library).map(\.name) == ["ZIPFoundation"]) + } + + @Test func creditsTheVendoredAgentSkills() throws { + let tools = try Set(report().credits(ofKind: .developmentTool).map(\.name)) + #expect(tools == [ + "swift-concurrency-pro", + "swift-testing-pro", + "swiftdata-pro", + "swiftui-pro", + ]) + } + + @Test func everyCreditCarriesItsNoticeInline() throws { + for credit in try report().credits { + #expect(!credit.license.text.isEmpty, "\(credit.name) ships no license notice") + #expect(!credit.license.name.isEmpty, "\(credit.name) names no license") + #expect(!credit.version.isEmpty, "\(credit.name) records no version") + #expect(credit.homepageURL != nil, "\(credit.name) records no homepage") + } + } + + @Test func aBundleWithoutAReportReadsAsNone() { + // Every other bundle in the process — the test bundle itself included — + // legitimately carries no report, and that must stay a quiet `nil` + // rather than the fault path. + #expect(AppAttribution.current(bundle: Bundle(for: BundleMarker.self)) == nil) + } +} + +private final class BundleMarker {} diff --git a/Where/Where/attribution-sources.json b/Where/Where/attribution-sources.json new file mode 100644 index 00000000..7a00b35c --- /dev/null +++ b/Where/Where/attribution-sources.json @@ -0,0 +1,16 @@ +{ + "output": "Where/Where/Resources/attribution.json", + "sources": [ + { + "type": "swiftPackageManager", + "kind": "library", + "manifest": "Package.swift", + "resolved": "Package.resolved" + }, + { + "type": "agentSkills", + "kind": "developmentTool", + "manifest": ".agents/external-skills.json" + } + ] +} diff --git a/Where/WhereCore/AGENTS.md b/Where/WhereCore/AGENTS.md index 3c4208df..ccf0eee2 100644 --- a/Where/WhereCore/AGENTS.md +++ b/Where/WhereCore/AGENTS.md @@ -134,15 +134,20 @@ internal shape. that derives attribution from a seeded store instead.) Detection is naturally scoped to the set — the attributor only loads tracked-region geometry, so `distanceToBoundary` is `nil` elsewhere. -- **Adding an external package means regenerating the credits.** WhereCore links - the app's only third-party package (ZIPFoundation), but it does **not** own - attribution — that is [`CreditKit`](../../Shared/CreditKit/AGENTS.md), whose - `Tools/generate-credits.rb` derives the credit and vendors the notice from the - root [`Package.swift`](../../Package.swift) and `Package.resolved`. Run it and - commit the result, or the app ships a library whose license it doesn't - reproduce. Build *identity* does live here, in `BuildInfo`, which reads keys - the app target's stamp script writes — see [Version and build - metadata](../../AGENTS.md#version-and-build-metadata). +- **Adding an external package means re-running `./attribution`.** WhereCore + links the app's only third-party package (ZIPFoundation), but it does **not** + own attribution: the report format and tooling are + [`CreditKit`](../../Shared/CreditKit/AGENTS.md)'s, and the report itself is the + app target's resource. Regenerate and commit it, or the app ships a library + whose license it doesn't reproduce. +- **`AppAttribution` and `BuildInfo` both read *this bundle*, and both treat + absence as legitimate.** Only the app target gets a stamped Info.plist and a + generated report, so a bundle without either (RegionViewer, `StuffTestHost`, + an extension) reports `nil` rather than a placeholder. `AppAttribution` splits + that from a report that is present and won't decode, which is a corrupt + resource and faults — don't collapse the two back together. See [Version and + build metadata](../../AGENTS.md#version-and-build-metadata) and + [Attribution](../../AGENTS.md#attribution). - **Impossible states trap; recoverable ones surface.** `WhereStore` methods are `async throws` so the CloudKit-backed store can report I/O failure; a `catch` must log via a `WhereLog` typed `LogEvent` (PII-free, `.public`) and leave diff --git a/Where/WhereCore/README.md b/Where/WhereCore/README.md index 5bf5844d..05b1a098 100644 --- a/Where/WhereCore/README.md +++ b/Where/WhereCore/README.md @@ -96,11 +96,14 @@ one it belongs to rather than to a god-object: a selectable look-back `RecentActivityWindow`. - **`WherePreferences`** — persisted user intent (onboarding, tracking intent, reminder / summary schedules) behind a `KeyValueStore`. -- **`BuildInfo`** — which build is running, for Settings > About. - `BuildInfo.current(bundle:)` reads the marketing version, build number, and the - commit the app was built from. (Attribution is *not* WhereCore's: third-party - credits are [`CreditKit`](../../Shared/CreditKit/README.md)'s and data-source - credits are [`RegionKit`](../RegionKit/README.md)'s.) +- **`BuildInfo`** + **`AppAttribution`** — what Settings > About says about the + bundle it is running in. `BuildInfo.current(bundle:)` reads the marketing + version, build number, and the commit the app was built from; + `AppAttribution.current(bundle:)` reads the generated attribution report. Both + return `nil`-shaped honesty for a bundle outside the app target, which carries + neither. (The report's *format* and tooling are + [`CreditKit`](../../Shared/CreditKit/README.md)'s; data-source provenance is + [`RegionKit`](../RegionKit/README.md)'s.) - **`WhereLog`** — the Periscope logging facade: a `"Where"` root scope with grouping scopes (`location`, `reminders`, `backup`, `widgets`, …) and a typed `LogEvent` per collaborator, emitted into `Periscope.shared`. diff --git a/Where/WhereCore/Sources/About/AppAttribution.swift b/Where/WhereCore/Sources/About/AppAttribution.swift new file mode 100644 index 00000000..2a80e918 --- /dev/null +++ b/Where/WhereCore/Sources/About/AppAttribution.swift @@ -0,0 +1,43 @@ +import CreditKit +import Foundation + +/// The Where app's attribution report, read back out of the bundle it ships in. +/// +/// The report is generated by `./attribution` and committed into the app +/// target's resources, so — exactly like the commit `BuildInfo` reads — **only +/// the app target carries one**. A bundle that never received it (the +/// RegionViewer developer tool, `StuffTestHost`, an app extension) reports `nil` +/// rather than a placeholder, and the About screen says attribution is +/// unavailable instead of rendering empty sections that read as "nothing to +/// credit". +/// +/// That distinction is the whole reason this sits above `CreditKit`: absent is a +/// legitimate state only the app knows how to interpret, while a report that is +/// present and won't decode is a corrupt resource. CreditKit throws for both; +/// here they diverge. +public enum AppAttribution { + /// Name of the generated resource inside the app bundle. + static let resource = "attribution" + + /// Reads the attribution report out of `bundle`, or `nil` when the bundle + /// carries none. Pass `.main` from the app; the bundle is explicit (no + /// default) so a caller can't accidentally read the wrong one. + public static func current(bundle: Bundle) -> AttributionManifest? { + do { + let manifest = try AttributionManifest.load(from: bundle, resource: resource) + logger { .loaded(creditCount: manifest.credits.count) } + return manifest + } catch AttributionError.reportMissing { + logger { .noReport } + return nil + } catch { + logger(attachments: [.error(error, name: "decode-error")]) { + .decodeFailed(description: error.localizedDescription) + } + assertionFailure("Failed to decode the bundled attribution report: \(error)") + return nil + } + } + + private static let logger = WhereLog.root(AppAttributionLog.self) +} diff --git a/Where/WhereCore/Sources/Logging/AppAttributionLog.swift b/Where/WhereCore/Sources/Logging/AppAttributionLog.swift new file mode 100644 index 00000000..b2f01c23 --- /dev/null +++ b/Where/WhereCore/Sources/Logging/AppAttributionLog.swift @@ -0,0 +1,39 @@ +import PeriscopeCore + +/// Structured events for loading the app's bundled attribution report. +/// +/// The two failure cases are deliberately not the same severity. A bundle +/// without a report is a *legitimate* state — only the app target ships one, so +/// the RegionViewer, `StuffTestHost`, and the extensions have none — and says so +/// at `.info`. A report that is present but won't decode is a corrupt bundled +/// resource, so it logs at `.fault` to match the paired `assertionFailure`, and +/// it matters beyond a blank screen: the report is how the app discharges its +/// attribution obligations. +enum AppAttributionLog: LogEvent { + /// The bundle carries no report. Expected outside the app target. + case noReport + /// The report decoded successfully into `creditCount` entries. + case loaded(creditCount: Int) + /// The report was present but could not be decoded. + case decodeFailed(description: String) + + static let eventName = "AppAttribution" + + var level: LogLevel { + switch self { + case .decodeFailed: .fault + case .noReport, .loaded: .info + } + } + + var message: String { + switch self { + case .noReport: + "Bundle carries no attribution report" + case let .loaded(creditCount): + "Loaded attribution report with \(creditCount) credit(s)" + case let .decodeFailed(description): + "Failed to decode bundled attribution report: \(description)" + } + } +} diff --git a/Where/WhereUI/README.md b/Where/WhereUI/README.md index 70d5159b..172060aa 100644 --- a/Where/WhereUI/README.md +++ b/Where/WhereUI/README.md @@ -23,9 +23,12 @@ the feature [`Where/AGENTS.md`](../AGENTS.md) and this module's Liquid Glass tab bar over three tabs — Locations, Your Year, Settings. Elsewhere is an entry card on Locations, Resolve a Locations toolbar button, the data screens (attachments, logged days, regions) sit in the Settings - "Data" group, and `AboutSettingsView` is the last Settings block (build - identity, open-source notices, and bundled-data provenance, each vended by the - module that owns it). The app injects the launch-built model + runner + "Data" group, and `AboutSettingsView` is the last Settings block — build + identity, the app's generated attribution report (linked libraries and + development tools as separate sections), and bundled-data provenance, each + vended by whoever owns it rather than listed in the view. The screen renders + an explicit "no report" state, since only the app bundle carries one. The app + injects the launch-built model + runner (`init(model:launcher:)`); a no-arg `init()` builds its own for previews and the hosted UI test. - **`WhereModel`** — app-level state: the onboarding flag, the owned diff --git a/Where/WhereUI/Sources/Preview/PreviewSupport.swift b/Where/WhereUI/Sources/Preview/PreviewSupport.swift index 25170472..ada97b2f 100644 --- a/Where/WhereUI/Sources/Preview/PreviewSupport.swift +++ b/Where/WhereUI/Sources/Preview/PreviewSupport.swift @@ -1,4 +1,5 @@ #if DEBUG + import CreditKit import Foundation import PeriscopeCore import RegionKit @@ -107,6 +108,39 @@ BuildInfo(infoDictionary: [:]) } + /// An attribution report shaped like the generated one: a linked library + /// and a development tool, so both About sections render. Only the app + /// target ships a real report, so a preview or test bundle can't read one. + public static func sampleAttribution() -> AttributionManifest { + AttributionManifest(credits: [ + SoftwareCredit( + name: "ZIPFoundation", + kind: .library, + version: "0.9.20", + homepageURL: URL(string: "https://github.com/weichsel/ZIPFoundation"), + license: LicenseNotice(name: "MIT License", text: sampleNotice), + ), + SoftwareCredit( + name: "swiftui-pro", + kind: .developmentTool, + version: "61b74001b64b", + homepageURL: URL(string: "https://github.com/twostraws/swiftui-agent-skill"), + license: LicenseNotice(name: "MIT License", text: sampleNotice), + ), + ]) + } + + /// Stand-in notice text. Deliberately not a real license: a fixture that + /// reproduced one verbatim would read as an attribution the app makes. + private static let sampleNotice = """ + Sample License + + Copyright (c) 2026 Example Author + + Placeholder notice text for previews and tests. The shipping app renders + the real notice carried by its generated attribution report. + """ + // MARK: - Region picker / customization /// A primary-region selection model seeded with a few US picks + looks, diff --git a/Where/WhereUI/Sources/Resources/Localizable.xcstrings b/Where/WhereUI/Sources/Resources/Localizable.xcstrings index 81a718d5..6c39b940 100644 --- a/Where/WhereUI/Sources/Resources/Localizable.xcstrings +++ b/Where/WhereUI/Sources/Resources/Localizable.xcstrings @@ -3008,6 +3008,17 @@ } } }, + "settings.about.attribution.unavailable" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This build carries no attribution report." + } + } + } + }, "settings.about.build.row" : { "extractionState" : "manual", "localizations" : { diff --git a/Where/WhereUI/Sources/Settings/AboutSettingsView.swift b/Where/WhereUI/Sources/Settings/AboutSettingsView.swift index 3b129ba2..32b84c8f 100644 --- a/Where/WhereUI/Sources/Settings/AboutSettingsView.swift +++ b/Where/WhereUI/Sources/Settings/AboutSettingsView.swift @@ -7,10 +7,11 @@ import WhereCore /// is running, the third-party work it is built with, and where its bundled /// region boundaries came from. /// -/// Every fact here is vended by the module that owns it — `BuildInfo` from -/// `WhereCore`, `SoftwareCredit` from `CreditKit`, `RegionDataSource` from -/// `RegionKit` — so this screen only renders and localizes. Adding a dependency -/// or a dataset means regenerating that module's data, not editing this view. +/// Every fact here is vended by whoever owns it — `BuildInfo` and the generated +/// attribution report from `WhereCore`, `RegionDataSource` from `RegionKit` — so +/// this screen only renders and localizes. Adding a dependency means re-running +/// `./attribution`, and adding a dataset means regenerating RegionKit's; neither +/// is a change to this view. /// /// Credits are split by `SoftwareCredit.Kind` into two sections rather than one /// combined list, because a development tool is *not* in the binary the reader @@ -21,21 +22,21 @@ struct AboutSettingsView: View { @Environment(\.stylesheet) private var stylesheet private let buildInfo: BuildInfo - private let credits: [SoftwareCredit] + private let attribution: AttributionManifest? private let dataSources: [RegionDataSource] /// Defaults read the live values; the parameters exist so previews and tests - /// can render a stamped build and fixed credits, which an unstamped preview - /// bundle can't produce on its own. + /// can render a stamped build and a populated report, neither of which a + /// bundle outside the app target carries. init( focus: SettingsFocus?, buildInfo: BuildInfo = .current(bundle: .main), - credits: [SoftwareCredit] = CreditCatalog.shared.credits, + attribution: AttributionManifest? = AppAttribution.current(bundle: .main), dataSources: [RegionDataSource] = RegionDataSource.all, ) { self.focus = focus self.buildInfo = buildInfo - self.credits = credits + self.attribution = attribution self.dataSources = dataSources } @@ -107,17 +108,25 @@ struct AboutSettingsView: View { footer: String, ) -> some View { Section { - ForEach(credits.filter { $0.kind == kind }) { credit in - // A closure-form link, not a `SettingsRoute`: routes are the - // top-level group vocabulary, and a leaf pushed from inside one - // group would need its own `navigationDestination` to match. - NavigationLink { - LicenseView(credit: credit) - } label: { - LabeledContent(credit.name) { - Text(credit.version) + if let attribution { + ForEach(attribution.credits(ofKind: kind)) { credit in + // A closure-form link, not a `SettingsRoute`: routes are the + // top-level group vocabulary, and a leaf pushed from inside + // one group would need its own `navigationDestination`. + NavigationLink { + LicenseView(credit: credit) + } label: { + LabeledContent(credit.name) { + Text(credit.version) + } } } + } else { + // No report in this bundle — `AppAttribution` has already said so. + // Say it here too, rather than showing an empty section that + // reads as "nothing to credit". + Text(String(localized: .settingsAboutAttributionUnavailable)) + .foregroundStyle(.secondary) } } header: { Text(header) @@ -206,7 +215,11 @@ extension AboutSettingsView: SettingsSection { #if DEBUG #Preview("Stamped build") { NavigationStack { - AboutSettingsView(focus: nil, buildInfo: PreviewSupport.stampedBuildInfo()) + AboutSettingsView( + focus: nil, + buildInfo: PreviewSupport.stampedBuildInfo(), + attribution: PreviewSupport.sampleAttribution(), + ) } .whereBroadwayRoot() } @@ -216,16 +229,21 @@ extension AboutSettingsView: SettingsSection { AboutSettingsView( focus: nil, buildInfo: PreviewSupport.stampedBuildInfo(isDirty: true), + attribution: PreviewSupport.sampleAttribution(), ) } .whereBroadwayRoot() } - #Preview("Unstamped build") { - // What a bundle the stamp script never ran on shows: honest unknowns - // rather than blank rows. + #Preview("Unstamped, unattributed build") { + // What a bundle outside the app target shows: honest unknowns and an + // explicit "no report" rather than blank rows and empty sections. NavigationStack { - AboutSettingsView(focus: nil, buildInfo: PreviewSupport.unstampedBuildInfo()) + AboutSettingsView( + focus: nil, + buildInfo: PreviewSupport.unstampedBuildInfo(), + attribution: nil, + ) } .whereBroadwayRoot() } diff --git a/Where/WhereUI/Sources/Settings/LicenseView.swift b/Where/WhereUI/Sources/Settings/LicenseView.swift index d4e9da9c..16cbd6fa 100644 --- a/Where/WhereUI/Sources/Settings/LicenseView.swift +++ b/Where/WhereUI/Sources/Settings/LicenseView.swift @@ -24,7 +24,7 @@ struct LicenseView: View { private var header: some View { VStack(alignment: .leading, spacing: stylesheet.spacing.xxSmall) { - Text(credit.licenseName) + Text(credit.license.name) .font(.headline) Text(credit.version) .font(.subheadline) @@ -38,17 +38,17 @@ struct LicenseView: View { @ViewBuilder private var notice: some View { - if let text = credit.licenseText() { - Text(text) - .font(.footnote.monospaced()) - .textSelection(.enabled) - } else { - // The bundled file is missing or unreadable — `licenseText()` has - // already fault-logged it. Say so, rather than showing an empty page - // that reads like a license with no terms. + // The report carries the notice inline, so there is no file to fail to + // load — an empty one would mean the generator wrote a credit without + // text, which it refuses to do. + if credit.license.text.isEmpty { Text(String(localized: .settingsAboutLicenseUnavailable)) .font(.footnote) .foregroundStyle(.secondary) + } else { + Text(credit.license.text) + .font(.footnote.monospaced()) + .textSelection(.enabled) } } } @@ -56,7 +56,7 @@ struct LicenseView: View { #if DEBUG #Preview { NavigationStack { - if let credit = CreditCatalog.shared.credits.first { + if let credit = PreviewSupport.sampleAttribution().credits.first { LicenseView(credit: credit) } } diff --git a/Where/WhereUI/Tests/AboutSettingsViewTests.swift b/Where/WhereUI/Tests/AboutSettingsViewTests.swift index 7dcd4bf7..76a4273b 100644 --- a/Where/WhereUI/Tests/AboutSettingsViewTests.swift +++ b/Where/WhereUI/Tests/AboutSettingsViewTests.swift @@ -43,7 +43,11 @@ struct AboutSettingsViewTests { @Test func hostsAStampedBuild() throws { let rootView = NavigationStack { - AboutSettingsView(focus: nil, buildInfo: PreviewSupport.stampedBuildInfo()) + AboutSettingsView( + focus: nil, + buildInfo: PreviewSupport.stampedBuildInfo(), + attribution: PreviewSupport.sampleAttribution(), + ) } try show(UIHostingController(rootView: rootView)) { hosted in #expect(hosted.view != nil) @@ -53,7 +57,11 @@ struct AboutSettingsViewTests { @Test func hostsAnUnstampedBuild() throws { // The RegionViewer / test-host case: no version keys and no commit. let rootView = NavigationStack { - AboutSettingsView(focus: nil, buildInfo: PreviewSupport.unstampedBuildInfo()) + AboutSettingsView( + focus: nil, + buildInfo: PreviewSupport.unstampedBuildInfo(), + attribution: PreviewSupport.sampleAttribution(), + ) } try show(UIHostingController(rootView: rootView)) { hosted in #expect(hosted.view != nil) @@ -65,21 +73,39 @@ struct AboutSettingsViewTests { SettingsCatalog.results.first { $0.destination == .about }, ).focus let rootView = NavigationStack { - AboutSettingsView(focus: focus, buildInfo: PreviewSupport.stampedBuildInfo()) + AboutSettingsView( + focus: focus, + buildInfo: PreviewSupport.stampedBuildInfo(), + attribution: PreviewSupport.sampleAttribution(), + ) + } + try show(UIHostingController(rootView: rootView)) { hosted in + #expect(hosted.view != nil) + } + } + + @Test func hostsABundleCarryingNoReport() throws { + // Every bundle but the app target's is in this state, so it has to render + // rather than trip on a force-unwrapped manifest. + let rootView = NavigationStack { + AboutSettingsView( + focus: nil, + buildInfo: PreviewSupport.unstampedBuildInfo(), + attribution: nil, + dataSources: [], + ) } try show(UIHostingController(rootView: rootView)) { hosted in #expect(hosted.view != nil) } } - @Test func hostsWithNothingToCredit() throws { - // Defensive: an empty credit list must render empty sections rather than - // trip on a force-unwrapped "first" credit. + @Test func hostsAnEmptyReport() throws { let rootView = NavigationStack { AboutSettingsView( focus: nil, buildInfo: PreviewSupport.unstampedBuildInfo(), - credits: [], + attribution: AttributionManifest(credits: []), dataSources: [], ) } @@ -88,9 +114,10 @@ struct AboutSettingsViewTests { } } - @Test func creditsTheLiveDependencyAndDataSourceLists() { - // The screen shows what the owning modules vend, not a copy of its own. - #expect(!CreditCatalog.shared.credits.isEmpty) + @Test func showsTheLiveDataSourceList() { + // The screen shows what RegionKit vends, not a copy of its own. The + // software credits are the app's generated report, asserted against the + // real bundle in the Where app's `AppAttributionTests`. #expect(!RegionDataSource.all.isEmpty) } @@ -98,14 +125,16 @@ struct AboutSettingsViewTests { // The two kinds must reach the screen as distinct sections: a development // tool isn't in the binary, so listing it under "Open Source" alongside // the libraries would describe the running app inaccurately. + let titles = Set(AboutSettingsView.Item.allCases.map(\.title)) + #expect(titles.contains(String(localized: .settingsAboutDevelopmentToolsHeader))) + #expect(titles.contains(String(localized: .settingsAboutDependenciesHeader))) + + let sample = PreviewSupport.sampleAttribution() for kind in SoftwareCredit.Kind.allCases { #expect( - !CreditCatalog.shared.credits(ofKind: kind).isEmpty, - "nothing credited for \(kind), so the section would render empty", + !sample.credits(ofKind: kind).isEmpty, + "the fixture credits nothing for \(kind), so that section goes untested", ) } - let titles = Set(AboutSettingsView.Item.allCases.map(\.title)) - #expect(titles.contains(String(localized: .settingsAboutDevelopmentToolsHeader))) - #expect(titles.contains(String(localized: .settingsAboutDependenciesHeader))) } } diff --git a/Where/WhereUI/Tests/LicenseViewTests.swift b/Where/WhereUI/Tests/LicenseViewTests.swift index 70380d0d..2c65b6f9 100644 --- a/Where/WhereUI/Tests/LicenseViewTests.swift +++ b/Where/WhereUI/Tests/LicenseViewTests.swift @@ -4,16 +4,33 @@ import TestHostSupport import Testing @testable import WhereUI -/// Covers the license notice pushed from Settings > About: it renders the -/// bundled text for a real credit, which is the compliance-relevant path. +/// Covers the license notice pushed from Settings > About: it renders the text +/// the report carries, which is the compliance-relevant path. @MainActor struct LicenseViewTests { @Test func hostsEveryCreditsNotice() throws { - for credit in CreditCatalog.shared.credits { + for credit in PreviewSupport.sampleAttribution().credits { let rootView = NavigationStack { LicenseView(credit: credit) } try show(UIHostingController(rootView: rootView)) { hosted in #expect(hosted.view != nil) } } } + + @Test func hostsACreditWhoseNoticeIsEmpty() throws { + // The generator refuses to write one, so this is the defensive path — it + // must say the notice is unavailable rather than render a blank page + // that reads like a license with no terms. + let credit = SoftwareCredit( + name: "Empty", + kind: .library, + version: "1.0", + homepageURL: nil, + license: LicenseNotice(name: "MIT License", text: ""), + ) + let rootView = NavigationStack { LicenseView(credit: credit) } + try show(UIHostingController(rootView: rootView)) { hosted in + #expect(hosted.view != nil) + } + } } diff --git a/attribution b/attribution new file mode 100755 index 00000000..0434b61c --- /dev/null +++ b/attribution @@ -0,0 +1,68 @@ +#!/bin/bash +set -euo pipefail + +# attribution — regenerate each app's attribution report. +# +# A report lists every third-party work an app is built with, each carrying its +# license notice inline, and lands in that app's own resources for the About +# screen to decode. It is generated rather than hand-kept, which is the point: a +# hand-maintained list silently goes stale the moment some *other* module adds a +# dependency, whereas a re-run picks it up wherever it landed. +# +# Each app declares its own sources in an attribution-sources.json; the generic +# reporting lives in CreditKit. Re-run after adding or bumping a package, or +# after `./sync-agents --update` moves a pinned skill, and commit the result. +# +# Needs network and an authenticated `gh` (notices are read at the pinned +# revision, so the text shipped is the one governing the code actually in use). + +cd "$(dirname "$0")" + +GENERATOR="Shared/CreditKit/Tools/generate-attribution.rb" + +# One entry per app that ships a report. +CONFIGS=( + "Where/Where/attribution-sources.json" +) + +usage() { + cat <<'USAGE' +Usage: + ./attribution [config ...] + ./attribution --list + ./attribution --help + +Regenerates the attribution report for each configured app. With no arguments, +regenerates every app's report; pass a config path to do just one. +USAGE +} + +case "${1-}" in + -h | --help) + usage + exit 0 + ;; + --list) + printf '%s\n' "${CONFIGS[@]}" + exit 0 + ;; +esac + +if ! command -v gh >/dev/null 2>&1; then + echo "attribution: needs the GitHub CLI (gh) to read license notices" >&2 + exit 1 +fi + +if [ "$#" -gt 0 ]; then + targets=("$@") +else + targets=("${CONFIGS[@]}") +fi + +# The pinned Ruby via mise when available, since the repo pins one; the system +# Ruby otherwise, so a bare checkout can still run this. +if command -v mise >/dev/null 2>&1; then + mise exec -- ruby "$GENERATOR" "${targets[@]}" +else + ruby "$GENERATOR" "${targets[@]}" +fi From ed067e1ae7844a035a0614cad89cb1060b376938 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sun, 26 Jul 2026 17:07:05 -0700 Subject: [PATCH 09/13] Fix attribution report caching, empty sections, and generator guards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four defects found reviewing the attribution refactor. `AboutSettingsView` took the report as a default argument, and Swift evaluates those on every `init` — which SwiftUI does on every body pass of the Settings stack. Each one re-read a 6.8KB file, re-ran the decoder over five inlined notices, and emitted a log line, so typing in the search field did all of it per keystroke. The catalog this replaced was a `static let` that decoded once. `AppAttribution.main` restores that; `current(bundle:)` stays for tests and other bundles. The "no attribution" message was keyed on the report being absent rather than on the section being empty, so a report carrying only libraries rendered the tools header and footer over no rows — with the footer still promising a list to tap through. Both cases now say something, in different words: an absent report means the build is unattributed, an empty section means only that this kind is. The generator could also emit two credits with one name, which `SoftwareCredit.id` hands SwiftUI as a duplicate `ForEach` id. A library's name is its repo basename, so two orgs publishing the same name is all it takes. It now rejects collisions case-insensitively over the assembled report, the only place holding every credit at once. Finally, `kind` went from config into the report unvalidated, so a typo generated and committed cleanly and then failed to decode in the app as a fault plus a debug trap. It is now checked against the two cases `SoftwareCredit.Kind` decodes, before any network work. Report output is byte-identical; the new guards only add failure paths. Co-authored-by: Cursor --- Shared/CreditKit/AGENTS.md | 9 ++++- Shared/CreditKit/README.md | 5 +++ Shared/CreditKit/Sources/SoftwareCredit.swift | 7 ++++ .../CreditKit/Tools/generate-attribution.rb | 17 ++++++++ Where/AGENTS.md | 5 ++- Where/Where/Tests/AppAttributionTests.swift | 25 +++++++++++- Where/WhereCore/AGENTS.md | 6 +++ Where/WhereCore/README.md | 5 ++- .../Sources/About/AppAttribution.swift | 18 ++++++++- .../Sources/Resources/Localizable.xcstrings | 11 ++++++ .../Sources/Settings/AboutSettingsView.swift | 30 +++++++++----- .../Tests/AboutSettingsViewTests.swift | 39 +++++++++++++++++++ 12 files changed, 160 insertions(+), 17 deletions(-) diff --git a/Shared/CreditKit/AGENTS.md b/Shared/CreditKit/AGENTS.md index edf6614b..ae274c31 100644 --- a/Shared/CreditKit/AGENTS.md +++ b/Shared/CreditKit/AGENTS.md @@ -46,7 +46,14 @@ thing that writes a report. 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. + 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. diff --git a/Shared/CreditKit/README.md b/Shared/CreditKit/README.md index 3db2a453..93e3bb72 100644 --- a/Shared/CreditKit/README.md +++ b/Shared/CreditKit/README.md @@ -111,6 +111,11 @@ handle at runtime. - **A missing report is not automatically an error.** Only the app target ships one, so `load` throwing `.reportMissing` is routine in a developer tool or test host. CreditKit reports it and leaves the judgement to the caller. +- **Credit names must be unique within a report.** `SoftwareCredit` is + `Identifiable` by `name`, so a duplicate breaks list identity in any UI that + iterates credits. The generator enforces it — a library's name is its repo + basename, and two orgs can publish the same one — but a hand-written manifest + is on its own; the type can't check what it can't see. - **Names, versions, and license titles are never localized.** They are proper nouns and legal terms; a UI supplies the translated framing around them. - **GitHub-hosted sources only.** Both source types resolve notices through the diff --git a/Shared/CreditKit/Sources/SoftwareCredit.swift b/Shared/CreditKit/Sources/SoftwareCredit.swift index 2f0848b4..647ae120 100644 --- a/Shared/CreditKit/Sources/SoftwareCredit.swift +++ b/Shared/CreditKit/Sources/SoftwareCredit.swift @@ -34,6 +34,13 @@ public struct SoftwareCredit: Sendable, Hashable, Identifiable, Codable { /// The license, carrying its own notice text. public let license: LicenseNotice + /// The name, which a report **must** keep unique across the whole manifest. + /// + /// Nothing in this type can enforce that, so the generator does: names come + /// from a repo basename, two orgs can publish the same one, and a collision + /// would hand a SwiftUI `ForEach` two rows sharing an id. Uniqueness is + /// checked where the report is assembled, not here, because that is the only + /// place holding every credit at once. public var id: String { name } diff --git a/Shared/CreditKit/Tools/generate-attribution.rb b/Shared/CreditKit/Tools/generate-attribution.rb index d85ea812..b722e8d2 100755 --- a/Shared/CreditKit/Tools/generate-attribution.rb +++ b/Shared/CreditKit/Tools/generate-attribution.rb @@ -51,6 +51,12 @@ def fail_with(message) abort "generate-attribution: #{message}" end +# The `kind` values `SoftwareCredit.Kind` decodes. Checked up front because a +# typo would otherwise produce a report that generates fine and commits fine, +# then fails to decode inside the app — surfacing as a fault and a debug trap on +# the About screen, a long way from the config line that caused it. +KINDS = %w[library developmentTool].freeze + # GitHub's license endpoint resolves the notice's filename for us (LICENSE, # LICENSE.md, COPYING, …) and reports the license's title alongside it, so one # call answers both "what license" and "what text". @@ -151,12 +157,23 @@ def report(config_path) type = source.fetch("type") generate = SOURCE_TYPES[type] fail_with("unknown source type #{type.inspect} in #{config_path}") unless generate + kind = source.fetch("kind") + fail_with("unknown kind #{kind.inspect} in #{config_path} (expected #{KINDS.join(" or ")})") unless KINDS.include?(kind) # Sort within a source so the report has a stable order and re-running it # produces no diff when nothing changed. generate.call(source).sort_by { |entry| entry["name"].downcase } end fail_with("#{config_path} produced no credits") if credits.empty? + # `SoftwareCredit` is `Identifiable` by name, so a collision hands SwiftUI two + # rows sharing one id. It takes only two orgs publishing the same repo name, + # since a library's name is its repo basename. Compared case-insensitively: + # a pair differing only in case is legal but unreadable in a list. + collisions = credits.group_by { |entry| entry["name"].downcase } + .select { |_, entries| entries.size > 1 } + .keys + fail_with("duplicate credit name(s): #{collisions.join(", ")}") unless collisions.empty? + output = File.join(ROOT, config.fetch("output")) FileUtils.mkdir_p(File.dirname(output)) File.write(output, "#{JSON.pretty_generate({ "credits" => credits })}\n") diff --git a/Where/AGENTS.md b/Where/AGENTS.md index 195347e2..03cbc509 100644 --- a/Where/AGENTS.md +++ b/Where/AGENTS.md @@ -124,7 +124,10 @@ adding a dataset means regenerating RegionKit's — see The report lives in `Where/Where/Resources/attribution.json` — the **app target's** resources, so only the app bundle carries one. Every other bundle (RegionViewer, `StuffTestHost`, the extensions) reads `nil` and the screen says -so, exactly as it does for an unstamped `BuildInfo`. `AppAttributionTests`, in +so, exactly as it does for an unstamped `BuildInfo`. A section with nothing of +its kind says so too, in different words: "no report at all" and "nothing of +this kind" describe different builds, and a header and footer over no rows would +describe neither. `AppAttributionTests`, in the app's own test bundle because it is the one hosted by `Where.app`, asserts what the report contains. diff --git a/Where/Where/Tests/AppAttributionTests.swift b/Where/Where/Tests/AppAttributionTests.swift index 9b96003d..0d2c4373 100644 --- a/Where/Where/Tests/AppAttributionTests.swift +++ b/Where/Where/Tests/AppAttributionTests.swift @@ -15,7 +15,7 @@ import WhereCore struct AppAttributionTests { private func report() throws -> AttributionManifest { try #require( - AppAttribution.current(bundle: .main), + AppAttribution.main, "the app bundle carries no attribution report — run ./attribution", ) } @@ -50,6 +50,29 @@ struct AppAttributionTests { } } + @Test func namesEveryCreditUniquely() throws { + // `SoftwareCredit` is `Identifiable` by name, so a collision would hand + // the About screen's `ForEach` two rows sharing one id. `./attribution` + // refuses to write a report with duplicates; this catches one edited in + // by hand, and names the offenders rather than just counting them. + let names = try report().credits.map(\.name) + let collisions = Dictionary(grouping: names) { $0.lowercased() } + .filter { $0.value.count > 1 } + .keys + .sorted() + #expect( + collisions.isEmpty, + "duplicate credit name(s): \(collisions.joined(separator: ", "))", + ) + } + + @Test func cachesTheReportItReadsFromTheMainBundle() { + // `AppAttribution.main` is what the About screen's default argument + // reads on every view init, so the cached value has to answer exactly + // what a fresh read of the same bundle would. + #expect(AppAttribution.main == AppAttribution.current(bundle: .main)) + } + @Test func aBundleWithoutAReportReadsAsNone() { // Every other bundle in the process — the test bundle itself included — // legitimately carries no report, and that must stay a quiet `nil` diff --git a/Where/WhereCore/AGENTS.md b/Where/WhereCore/AGENTS.md index ccf0eee2..e5a32611 100644 --- a/Where/WhereCore/AGENTS.md +++ b/Where/WhereCore/AGENTS.md @@ -148,6 +148,12 @@ internal shape. resource and faults — don't collapse the two back together. See [Version and build metadata](../../AGENTS.md#version-and-build-metadata) and [Attribution](../../AGENTS.md#attribution). +- **Read the app's report through `AppAttribution.main`, not + `current(bundle: .main)`.** The report inlines every notice, so decoding it is + not free, and its caller is a SwiftUI default argument — which Swift + re-evaluates on every `init`, and SwiftUI re-inits views constantly. `main` + caches the decode for the process; `current(bundle:)` stays for tests and for + any bundle that isn't `.main`. - **Impossible states trap; recoverable ones surface.** `WhereStore` methods are `async throws` so the CloudKit-backed store can report I/O failure; a `catch` must log via a `WhereLog` typed `LogEvent` (PII-free, `.public`) and leave diff --git a/Where/WhereCore/README.md b/Where/WhereCore/README.md index 05b1a098..7f6da6d4 100644 --- a/Where/WhereCore/README.md +++ b/Where/WhereCore/README.md @@ -99,8 +99,9 @@ one it belongs to rather than to a god-object: - **`BuildInfo`** + **`AppAttribution`** — what Settings > About says about the bundle it is running in. `BuildInfo.current(bundle:)` reads the marketing version, build number, and the commit the app was built from; - `AppAttribution.current(bundle:)` reads the generated attribution report. Both - return `nil`-shaped honesty for a bundle outside the app target, which carries + `AppAttribution.main` reads the generated attribution report, decoding it once + per process (`current(bundle:)` for any other bundle). Both return + `nil`-shaped honesty for a bundle outside the app target, which carries neither. (The report's *format* and tooling are [`CreditKit`](../../Shared/CreditKit/README.md)'s; data-source provenance is [`RegionKit`](../RegionKit/README.md)'s.) diff --git a/Where/WhereCore/Sources/About/AppAttribution.swift b/Where/WhereCore/Sources/About/AppAttribution.swift index 2a80e918..486d2b36 100644 --- a/Where/WhereCore/Sources/About/AppAttribution.swift +++ b/Where/WhereCore/Sources/About/AppAttribution.swift @@ -19,9 +19,23 @@ public enum AppAttribution { /// Name of the generated resource inside the app bundle. static let resource = "attribution" + /// The running app's report, read and decoded **once per process**. + /// + /// Prefer this to `current(bundle: .main)` at a call site that runs more + /// than once. The report is an immutable resource in an immutable bundle, + /// but it is not small — every notice is inlined — and the natural caller + /// is a SwiftUI default argument, which Swift re-evaluates on every single + /// `init`. `AboutSettingsView` is rebuilt on each body pass of the Settings + /// stack, so reading eagerly there re-read the file, re-ran the decoder, + /// and re-emitted a log line per keystroke in the search field. + /// + /// Same shape as `RegionDataSource.all`, the sibling default on that init. + public static let main: AttributionManifest? = current(bundle: .main) + /// Reads the attribution report out of `bundle`, or `nil` when the bundle - /// carries none. Pass `.main` from the app; the bundle is explicit (no - /// default) so a caller can't accidentally read the wrong one. + /// carries none. The bundle is explicit (no default) so a caller can't + /// accidentally read the wrong one; from the app, prefer ``main``, which + /// caches this for `.main`. public static func current(bundle: Bundle) -> AttributionManifest? { do { let manifest = try AttributionManifest.load(from: bundle, resource: resource) diff --git a/Where/WhereUI/Sources/Resources/Localizable.xcstrings b/Where/WhereUI/Sources/Resources/Localizable.xcstrings index 6c39b940..c572db4b 100644 --- a/Where/WhereUI/Sources/Resources/Localizable.xcstrings +++ b/Where/WhereUI/Sources/Resources/Localizable.xcstrings @@ -3008,6 +3008,17 @@ } } }, + "settings.about.attribution.none" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nothing to credit here." + } + } + } + }, "settings.about.attribution.unavailable" : { "extractionState" : "manual", "localizations" : { diff --git a/Where/WhereUI/Sources/Settings/AboutSettingsView.swift b/Where/WhereUI/Sources/Settings/AboutSettingsView.swift index 32b84c8f..f1590879 100644 --- a/Where/WhereUI/Sources/Settings/AboutSettingsView.swift +++ b/Where/WhereUI/Sources/Settings/AboutSettingsView.swift @@ -31,7 +31,7 @@ struct AboutSettingsView: View { init( focus: SettingsFocus?, buildInfo: BuildInfo = .current(bundle: .main), - attribution: AttributionManifest? = AppAttribution.current(bundle: .main), + attribution: AttributionManifest? = AppAttribution.main, dataSources: [RegionDataSource] = RegionDataSource.all, ) { self.focus = focus @@ -107,9 +107,18 @@ struct AboutSettingsView: View { header: String, footer: String, ) -> some View { - Section { - if let attribution { - ForEach(attribution.credits(ofKind: kind)) { credit in + let credits = attribution?.credits(ofKind: kind) ?? [] + return Section { + if credits.isEmpty { + // Keyed on *this section* being empty, not on the report being + // absent: a report that carries only libraries would otherwise + // render the tools header and footer over no rows, and a footer + // still promising a list to tap through reads as a broken screen + // rather than as an empty one. + Text(String(localized: emptyMessage)) + .foregroundStyle(.secondary) + } else { + ForEach(credits) { credit in // A closure-form link, not a `SettingsRoute`: routes are the // top-level group vocabulary, and a leaf pushed from inside // one group would need its own `navigationDestination`. @@ -121,12 +130,6 @@ struct AboutSettingsView: View { } } } - } else { - // No report in this bundle — `AppAttribution` has already said so. - // Say it here too, rather than showing an empty section that - // reads as "nothing to credit". - Text(String(localized: .settingsAboutAttributionUnavailable)) - .foregroundStyle(.secondary) } } header: { Text(header) @@ -135,6 +138,13 @@ struct AboutSettingsView: View { } } + /// Why a section is empty, which the reader needs distinguished: no report + /// at all means the whole build is unattributed, while an empty section of + /// a real report means only that this kind has nothing in it. + var emptyMessage: LocalizedStringResource { + attribution == nil ? .settingsAboutAttributionUnavailable : .settingsAboutAttributionNone + } + // MARK: Data sources private var dataSourcesSection: some View { diff --git a/Where/WhereUI/Tests/AboutSettingsViewTests.swift b/Where/WhereUI/Tests/AboutSettingsViewTests.swift index 76a4273b..80add30c 100644 --- a/Where/WhereUI/Tests/AboutSettingsViewTests.swift +++ b/Where/WhereUI/Tests/AboutSettingsViewTests.swift @@ -114,6 +114,45 @@ struct AboutSettingsViewTests { } } + @Test func hostsAReportThatCreditsOnlyOneKind() throws { + // Reachable for real: a config whose agent-skills manifest is empty still + // generates a valid report, and the tools section then has nothing to + // list while the libraries section is full. + let libraries = PreviewSupport.sampleAttribution().credits(ofKind: .library) + let rootView = NavigationStack { + AboutSettingsView( + focus: nil, + buildInfo: PreviewSupport.unstampedBuildInfo(), + attribution: AttributionManifest(credits: libraries), + dataSources: [], + ) + } + try show(UIHostingController(rootView: rootView)) { hosted in + #expect(hosted.view != nil) + } + } + + @Test func distinguishesAnAbsentReportFromAnEmptySection() { + // The two say different things about the build: no report means nothing + // is attributed at all, while an empty section of a real report means + // only that this one kind is unpopulated. + let absent = AboutSettingsView(focus: nil, attribution: nil, dataSources: []) + #expect( + String(localized: absent.emptyMessage) + == String(localized: .settingsAboutAttributionUnavailable), + ) + + let empty = AboutSettingsView( + focus: nil, + attribution: AttributionManifest(credits: []), + dataSources: [], + ) + #expect( + String(localized: empty.emptyMessage) + == String(localized: .settingsAboutAttributionNone), + ) + } + @Test func showsTheLiveDataSourceList() { // The screen shows what RegionKit vends, not a copy of its own. The // software credits are the app's generated report, asserted against the From 86f69d61b2f60213b5360c4c2f5a54f9b4876e53 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sun, 26 Jul 2026 17:09:29 -0700 Subject: [PATCH 10/13] File the two deferred attribution review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neither is addressed in this branch, so they go to the backlog rather than being dropped. The drift guard's blind spot is the more serious one: AppAttributionTests compares credit names, so a bumped pin leaves every name unchanged and the report keeps stale versions and stale notice text silently. That defeats the pinned-revision read, so it lands as a P1. It sits at root because the fix reaches the ./attribution wrapper and CI as well as the generator, and it is needs-design because the obvious regenerate-and-diff check would make CI depend on GitHub being up. The loose github_slug regex is a clarity fix, not a security one, and is CreditKit's alone — which gives that module its first TODOs.md. Co-authored-by: Cursor --- Shared/CreditKit/TODOs.md | 12 ++++++++++++ TODOs.md | 1 + 2 files changed, 13 insertions(+) create mode 100644 Shared/CreditKit/TODOs.md diff --git a/Shared/CreditKit/TODOs.md b/Shared/CreditKit/TODOs.md new file mode 100644 index 00000000..c5b317f6 --- /dev/null +++ b/Shared/CreditKit/TODOs.md @@ -0,0 +1,12 @@ +# CreditKit todos + +The item format and the placement rule live in the root +[`TODOs.md`](../../TODOs.md); raw notes go in [`INBOX.md`](../../INBOX.md), not +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) + +# Completed issues diff --git a/TODOs.md b/TODOs.md index b2ef0c5e..ff57d9f0 100644 --- a/TODOs.md +++ b/TODOs.md @@ -96,6 +96,7 @@ inbox rather than here. - docs(Bumper) [quick-win]: Correct `.bumper/RULES.md:101` and `:143`, which claim three calendar violations and some preview-coverage violations are "left visible during this bootstrap". Neither exists — the lint gate is green, and `52f0136` closed the preview ones. Delete both paragraphs, and re-add the calendar one only if the widened rule genuinely finds drift. (audit 2026-07-26) ## P1s (Should do) +- test(CreditKit) [needs-design]: The attribution drift guard catches an added dependency but not a bumped one, so the app can ship notices that don't govern the code in it. `AppAttributionTests` asserts credit *names* only — `map(\.name)` against a literal list at `Where/Where/Tests/AppAttributionTests.swift:31` and `:35` — so adding or removing a package or a skill fails, while `./sync-agents --update` or an SPM bump leaves every name unchanged and the committed `Where/Where/Resources/attribution.json` keeps the old versions and the old license text with nothing red. That silently defeats the reason notices are read at the pinned revision (`Shared/CreditKit/Tools/generate-attribution.rb:67`): upstream edits a notice between releases, which is exactly the drift the pinning exists to catch. Suggested fix is a **network-free** `./attribution --check` that recomputes each credit's version from the pins with the generator's own derivation (`:125` for SPM's `state["version"] || revision[0, 12]`, `:139` for a skill's `ref[0, 12]`) and fails when the report disagrees, gated in CI beside `./swiftformat --lint` and `./xcstrings --lint` (`.github/workflows/ci.yml:24`, `:31`). `needs-design` because the obvious alternative — regenerate and diff — needs network and an authenticated `gh`, which would make every CI run depend on GitHub availability, while the cheap comparison verifies the *ref* and not the notice text; that trade-off is the decision. Sits here rather than in [`Shared/CreditKit/TODOs.md`](Shared/CreditKit/TODOs.md) because it lands in the root `./attribution` wrapper and CI as well as the generator. (pr#140 review) ## P2s (Nice to have) - feat(PeriscopeCore) [needs-design]: A `LogSession` can't name the build it came from. `LogSession.current()` reads only `CFBundleShortVersionString` / `CFBundleVersion` off the main bundle (`Shared/Periscope/PeriscopeCore/Sources/Store/LogSession.swift:32`), and the viewer renders that pair as the session's identity (`PeriscopeTools/Sources/Viewer/PeriscopeViewer.swift:269`) — but the Where app pins both in the manifest (`Project.swift:101`), so every developer build reads `v1.0 (1)` and weeks-old logs can't be tied to the code that produced them. The commit is now available: the app stamps `WhereGitSHA` / `WhereGitStatus` into its Info.plist and `WhereCore.BuildInfo` reads them back. PeriscopeCore can't depend on WhereCore (Periscope sits below the Where modules), so this needs a seam rather than a direct adoption — an optional commit field, or general resource attributes on `LogSession` that the host app fills at bootstrap. Spans Periscope and Where, hence here. (agent 2026-07-26) From 86b82f330fe78980b3379f9b647b2e653daf79c9 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sun, 26 Jul 2026 18:48:21 -0700 Subject: [PATCH 11/13] Re-record Settings snapshots and cover the About screens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The snapshot bundle from #101 landed on main after this branch wrote the About screen, so the two had never met: adding an About row to the Settings list invalidated 9 of its 11 references, and the two new screens had no coverage at all. That bundle runs in its own scheme outside Stuff-iOS-Tests, which is why the main suite stayed green through it. Re-recorded the 9 Settings references, and gave AboutSettingsView and LicenseView the conformance + suite the module convention asks for. The About matrix is built around absence, since the shipping build is the only one that is both stamped and attributed: a full report, a dirty tree, no report at all, and a report that credits only one kind. That last one pins the empty-section bug from the review as an image, which is the right guard for it — the failure was purely visual, a header and footer over no rows. Recording surfaced a stale string. LicenseView's empty-notice case still read "This license text couldn't be loaded from the app bundle", which described the old architecture where notices were separate resources. There is no file to load now — the notice is inlined into the report — so it reads "This credit's report entry carries no license text". Also moved the hand-built SoftwareCredit fixture into PreviewSupport as sampleCredit(noticeText:); it had been spelled out in three places. WhereUISnapshotTests: 31 suites green on a clean re-run. Stuff-iOS-Tests, swiftformat --lint, xcstrings --lint, and bumper lint all green too. --- .../AboutSettingsViewSnapshotTests.swift | 10 +++ .../LicenseViewSnapshotTests.swift | 10 +++ .../about.Default_iPad.png | 3 + .../about.Default_iPad_accessibility.png | 3 + .../about.Default_iPad_ax5.png | 3 + .../about.Default_iPad_contrast.png | 3 + .../about.Default_iPad_dark.png | 3 + .../about.Default_iPhone.png | 3 + .../about.Default_iPhone_accessibility.png | 3 + .../about.Default_iPhone_ax5.png | 3 + .../about.Default_iPhone_contrast.png | 3 + .../about.Default_iPhone_dark.png | 3 + .../about.DirtyTree_iPhone.png | 3 + .../about.DirtyTree_iPhone_dark.png | 3 + .../about.LibrariesOnly_iPhone.png | 3 + .../about.LibrariesOnly_iPhone_dark.png | 3 + .../about.Unattributed_iPhone.png | 3 + .../about.Unattributed_iPhone_dark.png | 3 + .../license.Default_iPad.png | 3 + .../license.Default_iPad_accessibility.png | 3 + .../license.Default_iPad_ax5.png | 3 + .../license.Default_iPad_contrast.png | 3 + .../license.Default_iPad_dark.png | 3 + .../license.Default_iPhone.png | 3 + .../license.Default_iPhone_accessibility.png | 3 + .../license.Default_iPhone_ax5.png | 3 + .../license.Default_iPhone_contrast.png | 3 + .../license.Default_iPhone_dark.png | 3 + .../license.NoNotice_iPhone.png | 3 + .../license.NoNotice_iPhone_dark.png | 3 + .../settings.Default_iPad.png | 4 +- .../settings.Default_iPad_accessibility.png | 4 +- .../settings.Default_iPad_contrast.png | 4 +- .../settings.Default_iPad_dark.png | 4 +- .../settings.Default_iPhone.png | 4 +- .../settings.Default_iPhone_accessibility.png | 4 +- .../settings.Default_iPhone_contrast.png | 4 +- .../settings.Default_iPhone_dark.png | 4 +- .../settings.Default_iPhone_rtl.png | 4 +- .../Sources/Preview/PreviewSupport.swift | 23 ++++-- .../Sources/Resources/Localizable.xcstrings | 2 +- .../Sources/Settings/AboutSettingsView.swift | 81 ++++++++++++------- .../Sources/Settings/LicenseView.swift | 28 +++++-- Where/WhereUI/Tests/LicenseViewTests.swift | 8 +- 44 files changed, 213 insertions(+), 69 deletions(-) create mode 100644 Where/WhereUI/SnapshotTests/AboutSettingsViewSnapshotTests.swift create mode 100644 Where/WhereUI/SnapshotTests/LicenseViewSnapshotTests.swift create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPad.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPad_accessibility.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPad_ax5.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPad_contrast.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPad_dark.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPhone.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPhone_accessibility.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPhone_ax5.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPhone_contrast.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPhone_dark.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.DirtyTree_iPhone.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.DirtyTree_iPhone_dark.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.LibrariesOnly_iPhone.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.LibrariesOnly_iPhone_dark.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Unattributed_iPhone.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Unattributed_iPhone_dark.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/LicenseViewSnapshotTests/license.Default_iPad.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/LicenseViewSnapshotTests/license.Default_iPad_accessibility.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/LicenseViewSnapshotTests/license.Default_iPad_ax5.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/LicenseViewSnapshotTests/license.Default_iPad_contrast.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/LicenseViewSnapshotTests/license.Default_iPad_dark.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/LicenseViewSnapshotTests/license.Default_iPhone.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/LicenseViewSnapshotTests/license.Default_iPhone_accessibility.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/LicenseViewSnapshotTests/license.Default_iPhone_ax5.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/LicenseViewSnapshotTests/license.Default_iPhone_contrast.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/LicenseViewSnapshotTests/license.Default_iPhone_dark.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/LicenseViewSnapshotTests/license.NoNotice_iPhone.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/LicenseViewSnapshotTests/license.NoNotice_iPhone_dark.png diff --git a/Where/WhereUI/SnapshotTests/AboutSettingsViewSnapshotTests.swift b/Where/WhereUI/SnapshotTests/AboutSettingsViewSnapshotTests.swift new file mode 100644 index 00000000..83d02fb9 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/AboutSettingsViewSnapshotTests.swift @@ -0,0 +1,10 @@ +import SnapshotKitTesting +import Testing +@testable import WhereUI + +@MainActor +struct AboutSettingsViewSnapshotTests { + @Test func about() async { + await assertSnapshots(of: AboutSettingsView.self) + } +} diff --git a/Where/WhereUI/SnapshotTests/LicenseViewSnapshotTests.swift b/Where/WhereUI/SnapshotTests/LicenseViewSnapshotTests.swift new file mode 100644 index 00000000..a5b1f1ff --- /dev/null +++ b/Where/WhereUI/SnapshotTests/LicenseViewSnapshotTests.swift @@ -0,0 +1,10 @@ +import SnapshotKitTesting +import Testing +@testable import WhereUI + +@MainActor +struct LicenseViewSnapshotTests { + @Test func license() async { + await assertSnapshots(of: LicenseView.self) + } +} diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPad.png b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPad.png new file mode 100644 index 00000000..ae7bcb34 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPad.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d16877a26de7cc1262f0c5e081b3a7bffe748922fbe876c03be50a14ce9e3519 +size 406231 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPad_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPad_accessibility.png new file mode 100644 index 00000000..e01a0af4 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPad_accessibility.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0e6c79b9d0b9429ad023e5774952453db4a32fe6c31cd6ee0d7b2b9252c5d08a +size 796723 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPad_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPad_ax5.png new file mode 100644 index 00000000..edde49c5 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPad_ax5.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d5f0bd8128cf203d7509350955212b9f2b80ee35b85b24e8330dc29c706863bd +size 427898 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPad_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPad_contrast.png new file mode 100644 index 00000000..529034cd --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPad_contrast.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f15676ecafb05821a1cacc4dba3017586856b903b5727cd3cdb8a680de26c6e7 +size 411907 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPad_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPad_dark.png new file mode 100644 index 00000000..f821ec39 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPad_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:22e2a092e740e002c382e1c6ca9eb20e967f336a9755a061474b4b028fc3f215 +size 414527 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPhone.png new file mode 100644 index 00000000..aaf1a582 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPhone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f36b11999560df92ae16d3040cccc46e6a33411b9ce05f05aa8187a431b81ed6 +size 251612 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPhone_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPhone_accessibility.png new file mode 100644 index 00000000..bb6c5c15 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPhone_accessibility.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9446a5b49591cee473e80b85bed80ef191b1043acb88ff47e5cc06ce58eef5f4 +size 583176 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPhone_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPhone_ax5.png new file mode 100644 index 00000000..b4d60a53 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPhone_ax5.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a630c2183190275da6e6104af26c35cbc722809bcabea9a29489f80733cacfd8 +size 180823 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPhone_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPhone_contrast.png new file mode 100644 index 00000000..4aa51153 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPhone_contrast.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:68cbdf83ad7bc523135f27b820cafda7ca1296660c905217f6c5c7f6078487a0 +size 254956 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPhone_dark.png new file mode 100644 index 00000000..d73721f1 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPhone_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3c8e5b983839c6e005574edcd28880443a5b440cbbbd5da123aa0fb9fe130aa1 +size 258418 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.DirtyTree_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.DirtyTree_iPhone.png new file mode 100644 index 00000000..95341c36 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.DirtyTree_iPhone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:67723aed0d350c0d3291ee576f868d2df963d7f788c716c8420ac41ccf930191 +size 254847 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.DirtyTree_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.DirtyTree_iPhone_dark.png new file mode 100644 index 00000000..9472c6ac --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.DirtyTree_iPhone_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6fff7e9d2db0e717f123d507b633762340ba108cbb1821a4ca69de902519b865 +size 261816 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.LibrariesOnly_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.LibrariesOnly_iPhone.png new file mode 100644 index 00000000..73748ddc --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.LibrariesOnly_iPhone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fe2e51ca3cfeb4581bc42d1d4307aaecf71af43af39586afdd9a6d542ee03cb6 +size 247914 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.LibrariesOnly_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.LibrariesOnly_iPhone_dark.png new file mode 100644 index 00000000..1d756567 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.LibrariesOnly_iPhone_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:015abffbcf61d81ca485768b6c624bde3afc86c92e557a7b5c0afe748ad5ab7e +size 254744 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Unattributed_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Unattributed_iPhone.png new file mode 100644 index 00000000..f95fcf82 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Unattributed_iPhone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:34210c696d0ef302e9e3c0d309645084825ba73b0a484579b2c5f66018bb1b93 +size 250420 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Unattributed_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Unattributed_iPhone_dark.png new file mode 100644 index 00000000..26776462 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Unattributed_iPhone_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:64c3cd1528ac0b2ffd7c4ea72894f9e6adf7d7ca9321238592841da11d52c5f1 +size 257061 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LicenseViewSnapshotTests/license.Default_iPad.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LicenseViewSnapshotTests/license.Default_iPad.png new file mode 100644 index 00000000..6a34cb67 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LicenseViewSnapshotTests/license.Default_iPad.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b09ccdf25615b58b6e57a54d820a10a260bae3cf92d4e7779ecb576a534cb5dc +size 234042 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LicenseViewSnapshotTests/license.Default_iPad_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LicenseViewSnapshotTests/license.Default_iPad_accessibility.png new file mode 100644 index 00000000..61c2d1d2 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LicenseViewSnapshotTests/license.Default_iPad_accessibility.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2fd7ef865df350d7065c02a29f07ae60cc278b5d05290f4f514c511ac2198b27 +size 415249 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LicenseViewSnapshotTests/license.Default_iPad_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LicenseViewSnapshotTests/license.Default_iPad_ax5.png new file mode 100644 index 00000000..9582a6f9 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LicenseViewSnapshotTests/license.Default_iPad_ax5.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:db20743e5f477b45c5b33cf0bdc66a2bd61d60589f53aff229d08da2550171ee +size 428256 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LicenseViewSnapshotTests/license.Default_iPad_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LicenseViewSnapshotTests/license.Default_iPad_contrast.png new file mode 100644 index 00000000..12ebcf62 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LicenseViewSnapshotTests/license.Default_iPad_contrast.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:eb3dc7e160a512d51d4e0fc16bbe739e2240a31b76e006a81d8b986e13f3f8f2 +size 234452 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LicenseViewSnapshotTests/license.Default_iPad_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LicenseViewSnapshotTests/license.Default_iPad_dark.png new file mode 100644 index 00000000..5c6de7f7 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LicenseViewSnapshotTests/license.Default_iPad_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:39e711d9bd781d6270ac26c69522457c3a309efdf1da983f46e50647b30c9ba4 +size 254695 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LicenseViewSnapshotTests/license.Default_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LicenseViewSnapshotTests/license.Default_iPhone.png new file mode 100644 index 00000000..a85dc576 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LicenseViewSnapshotTests/license.Default_iPhone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cc35d18dfaca0eb6279e2734b89e6239f9a8f33eaba4eda4a08d8fac2f7b68da +size 130913 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LicenseViewSnapshotTests/license.Default_iPhone_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LicenseViewSnapshotTests/license.Default_iPhone_accessibility.png new file mode 100644 index 00000000..9669de64 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LicenseViewSnapshotTests/license.Default_iPhone_accessibility.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7952dbb35334c63917df8fa50f92305b5c2d00deba3216029f0af1b1316d9869 +size 283914 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LicenseViewSnapshotTests/license.Default_iPhone_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LicenseViewSnapshotTests/license.Default_iPhone_ax5.png new file mode 100644 index 00000000..7610e973 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LicenseViewSnapshotTests/license.Default_iPhone_ax5.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4a951cc3c62004f2dd5758bafda41e04ab222054d3f4df9a1c3706a53791547d +size 208168 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LicenseViewSnapshotTests/license.Default_iPhone_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LicenseViewSnapshotTests/license.Default_iPhone_contrast.png new file mode 100644 index 00000000..980489ba --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LicenseViewSnapshotTests/license.Default_iPhone_contrast.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0771da176143fc03ef058f8b958124760f7b3b213dd8653d9b8e03504e1d0a0c +size 131198 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LicenseViewSnapshotTests/license.Default_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LicenseViewSnapshotTests/license.Default_iPhone_dark.png new file mode 100644 index 00000000..ef375e47 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LicenseViewSnapshotTests/license.Default_iPhone_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:025984aa3a209a42a52c84f83ad46f4c298b85db56688798d0eef81130e4ce81 +size 140123 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LicenseViewSnapshotTests/license.NoNotice_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LicenseViewSnapshotTests/license.NoNotice_iPhone.png new file mode 100644 index 00000000..e5a6866b --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LicenseViewSnapshotTests/license.NoNotice_iPhone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e8f3722b01575f6e6308396f749f482a71393c177e95213e6b9bdf00a7abf1ae +size 95407 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LicenseViewSnapshotTests/license.NoNotice_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LicenseViewSnapshotTests/license.NoNotice_iPhone_dark.png new file mode 100644 index 00000000..2551303e --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LicenseViewSnapshotTests/license.NoNotice_iPhone_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cc794788e1d38be656ab221d501166cd1068ceda0f9b1d3deb7470c2bf0cf818 +size 104225 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPad.png b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPad.png index 94e77bfa..8c786431 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPad.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPad.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:55a60081d9d0414c3168a8540d4606130bf5b1223150b690cf84c0b14f941066 -size 328001 +oid sha256:68947ab4464a1d1b52b0549aa43ac758c7746619307e26155598695802cfb52d +size 336297 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPad_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPad_accessibility.png index ff92ac1e..8c92b8f2 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPad_accessibility.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPad_accessibility.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:175d677ef711e76f568ac5cc1d393c2c1542e5f90a425c3c1ab663bacd205125 -size 551655 +oid sha256:8b87a0b2df455320fdc8471464dc1f5d1dc31fe1af715d3b0fc9e3a42463dd10 +size 568290 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPad_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPad_contrast.png index 0b4d0247..cb46ddfe 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPad_contrast.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPad_contrast.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:fc732879d445908a0a94520ca1b771e057d3d6f1a6f6bf95a8895d78fc18280e -size 328416 +oid sha256:e23309bb8cd235d23e02efe2f1900971ca47d879b0e326ccc018ac1fd64dc2d4 +size 337282 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPad_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPad_dark.png index c20e3d2e..2c2a21a2 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPad_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPad_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:bb4b01c56284002c10ee1b4c465fdd5202d1c969e46aa28281150a14b483eddb -size 327280 +oid sha256:0c3c62472a1abd219be5dbf805ea381b4db153b8b5f03115c742421dc81aed7a +size 336599 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPhone.png index 7368c876..cf13fd43 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:52a1832904759c5ce11172afb02712060609a44633cd631972bd4b5cb00aff1a -size 237695 +oid sha256:a37ac3c9f6a3e54a047107c081d10d3afdfbd6f4f6510dd81abda7d7ea1d330a +size 241947 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPhone_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPhone_accessibility.png index 2a5eca3f..a64d4300 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPhone_accessibility.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPhone_accessibility.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:554a939eb4d8a7ace1025cabe365fe0e91531c13c764057b6104b95772511051 -size 422232 +oid sha256:d9547af2b7b2775b3a4ef482d98232ccea2fac5d7329eeb35a2b0ee0766a5c2e +size 431753 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPhone_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPhone_contrast.png index 59f92b62..1e94d0b5 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPhone_contrast.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPhone_contrast.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:516751addcec7ea857111a63289e647a36ac7573a7beb5c8b7ec8d93c1ef248b -size 222330 +oid sha256:8a5df37b9e9d8ea2b6d0e2995d132f9ac1bad4c2ae6051b262b87d1ca31518bf +size 228636 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPhone_dark.png index 3574d14f..49c804da 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8e0ecc24e561310f316f62ea87f2d09ecf63db30753c684d362a8c135d84a799 -size 215681 +oid sha256:410d961b35614d81f9619379d4f2b486d3714caa3e926f6f7729ea0f65fe2df4 +size 234659 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPhone_rtl.png b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPhone_rtl.png index 5db6567d..2a0297ff 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPhone_rtl.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPhone_rtl.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e4f59fc1727ef85603e870148796490486ddf3e8f73a55d7965a057374ec0c2f -size 237161 +oid sha256:f4ddf50c790047b612ff1e3e44a2d1944e847d0849871bd936ccf1345adeffd5 +size 241545 diff --git a/Where/WhereUI/Sources/Preview/PreviewSupport.swift b/Where/WhereUI/Sources/Preview/PreviewSupport.swift index e01a8fd0..fe4e267d 100644 --- a/Where/WhereUI/Sources/Preview/PreviewSupport.swift +++ b/Where/WhereUI/Sources/Preview/PreviewSupport.swift @@ -128,13 +128,7 @@ /// target ships a real report, so a preview or test bundle can't read one. public static func sampleAttribution() -> AttributionManifest { AttributionManifest(credits: [ - SoftwareCredit( - name: "ZIPFoundation", - kind: .library, - version: "0.9.20", - homepageURL: URL(string: "https://github.com/weichsel/ZIPFoundation"), - license: LicenseNotice(name: "MIT License", text: sampleNotice), - ), + sampleCredit(noticeText: sampleNotice), SoftwareCredit( name: "swiftui-pro", kind: .developmentTool, @@ -145,9 +139,22 @@ ]) } + /// The library credit from ``sampleAttribution()``, with its notice text + /// substitutable: pass `""` for the no-notice state a hand-edited report + /// could reach, which the generator itself refuses to write. + public static func sampleCredit(noticeText: String) -> SoftwareCredit { + SoftwareCredit( + name: "ZIPFoundation", + kind: .library, + version: "0.9.20", + homepageURL: URL(string: "https://github.com/weichsel/ZIPFoundation"), + license: LicenseNotice(name: "MIT License", text: noticeText), + ) + } + /// Stand-in notice text. Deliberately not a real license: a fixture that /// reproduced one verbatim would read as an attribution the app makes. - private static let sampleNotice = """ + public static let sampleNotice = """ Sample License Copyright (c) 2026 Example Author diff --git a/Where/WhereUI/Sources/Resources/Localizable.xcstrings b/Where/WhereUI/Sources/Resources/Localizable.xcstrings index c572db4b..51144530 100644 --- a/Where/WhereUI/Sources/Resources/Localizable.xcstrings +++ b/Where/WhereUI/Sources/Resources/Localizable.xcstrings @@ -3235,7 +3235,7 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "This license text couldn't be loaded from the app bundle." + "value" : "This credit's report entry carries no license text." } } } diff --git a/Where/WhereUI/Sources/Settings/AboutSettingsView.swift b/Where/WhereUI/Sources/Settings/AboutSettingsView.swift index f1590879..75aaf8f9 100644 --- a/Where/WhereUI/Sources/Settings/AboutSettingsView.swift +++ b/Where/WhereUI/Sources/Settings/AboutSettingsView.swift @@ -1,5 +1,6 @@ import CreditKit import RegionKit +import SnapshotKit import SwiftUI import WhereCore @@ -223,38 +224,58 @@ extension AboutSettingsView: SettingsSection { } #if DEBUG - #Preview("Stamped build") { - NavigationStack { - AboutSettingsView( - focus: nil, - buildInfo: PreviewSupport.stampedBuildInfo(), - attribution: PreviewSupport.sampleAttribution(), - ) - } - .whereBroadwayRoot() - } - - #Preview("Built from a dirty tree") { - NavigationStack { - AboutSettingsView( - focus: nil, - buildInfo: PreviewSupport.stampedBuildInfo(isDirty: true), - attribution: PreviewSupport.sampleAttribution(), - ) + extension AboutSettingsView: SnapshotProviding { + /// Every state the screen has, which for this screen means every degree + /// of *absence*: the shipping build is the only one that is both stamped + /// and attributed, so the interesting cases are what each missing piece + /// renders as. + static var snapshots: [SnapshotCase] { + whereSnapshot(name: "Default", configurations: .screenDefaults) { + NavigationStack { + AboutSettingsView( + focus: nil, + buildInfo: PreviewSupport.stampedBuildInfo(), + attribution: PreviewSupport.sampleAttribution(), + ) + } + } + whereSnapshot(name: "DirtyTree", configurations: .phoneLightDark) { + NavigationStack { + AboutSettingsView( + focus: nil, + buildInfo: PreviewSupport.stampedBuildInfo(isDirty: true), + attribution: PreviewSupport.sampleAttribution(), + ) + } + } + whereSnapshot(name: "Unattributed", configurations: .phoneLightDark) { + // What a bundle outside the app target shows: honest unknowns and + // an explicit "no report" rather than blank rows and empty sections. + NavigationStack { + AboutSettingsView( + focus: nil, + buildInfo: PreviewSupport.unstampedBuildInfo(), + attribution: nil, + ) + } + } + whereSnapshot(name: "LibrariesOnly", configurations: .phoneLightDark) { + // A real report that credits nothing of one kind. Pinned as an + // image because the failure mode is purely visual: a header and + // footer over no rows, promising a list that isn't there. + let libraries = PreviewSupport.sampleAttribution().credits(ofKind: .library) + NavigationStack { + AboutSettingsView( + focus: nil, + buildInfo: PreviewSupport.stampedBuildInfo(), + attribution: AttributionManifest(credits: libraries), + ) + } + } } - .whereBroadwayRoot() } - #Preview("Unstamped, unattributed build") { - // What a bundle outside the app target shows: honest unknowns and an - // explicit "no report" rather than blank rows and empty sections. - NavigationStack { - AboutSettingsView( - focus: nil, - buildInfo: PreviewSupport.unstampedBuildInfo(), - attribution: nil, - ) - } - .whereBroadwayRoot() + #Preview { + AboutSettingsView.snapshotPreviews } #endif diff --git a/Where/WhereUI/Sources/Settings/LicenseView.swift b/Where/WhereUI/Sources/Settings/LicenseView.swift index 16cbd6fa..72e1b88b 100644 --- a/Where/WhereUI/Sources/Settings/LicenseView.swift +++ b/Where/WhereUI/Sources/Settings/LicenseView.swift @@ -1,4 +1,5 @@ import CreditKit +import SnapshotKit import SwiftUI /// The full license notice for one credited work, pushed from Settings > About. @@ -54,12 +55,29 @@ struct LicenseView: View { } #if DEBUG - #Preview { - NavigationStack { - if let credit = PreviewSupport.sampleAttribution().credits.first { - LicenseView(credit: credit) + extension LicenseView: SnapshotProviding { + static var snapshots: [SnapshotCase] { + whereSnapshot(name: "Default", configurations: .screenDefaults) { + NavigationStack { + LicenseView( + credit: PreviewSupport.sampleCredit( + noticeText: PreviewSupport.sampleNotice, + ), + ) + } + } + whereSnapshot(name: "NoNotice", configurations: .phoneLightDark) { + // The generator refuses to emit a credit without notice text, so + // this is the state a hand-edited report would reach — worth an + // image because it is the one case that renders no notice at all. + NavigationStack { + LicenseView(credit: PreviewSupport.sampleCredit(noticeText: "")) + } } } - .whereBroadwayRoot() + } + + #Preview { + LicenseView.snapshotPreviews } #endif diff --git a/Where/WhereUI/Tests/LicenseViewTests.swift b/Where/WhereUI/Tests/LicenseViewTests.swift index 2c65b6f9..78d5afa2 100644 --- a/Where/WhereUI/Tests/LicenseViewTests.swift +++ b/Where/WhereUI/Tests/LicenseViewTests.swift @@ -21,13 +21,7 @@ struct LicenseViewTests { // The generator refuses to write one, so this is the defensive path — it // must say the notice is unavailable rather than render a blank page // that reads like a license with no terms. - let credit = SoftwareCredit( - name: "Empty", - kind: .library, - version: "1.0", - homepageURL: nil, - license: LicenseNotice(name: "MIT License", text: ""), - ) + let credit = PreviewSupport.sampleCredit(noticeText: "") let rootView = NavigationStack { LicenseView(credit: credit) } try show(UIHostingController(rootView: rootView)) { hosted in #expect(hosted.view != nil) From dc619497bddc1e4309f6b4f70a9588159b911d26 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sun, 26 Jul 2026 19:47:14 -0700 Subject: [PATCH 12/13] Derive attribution kind from target reachability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The .product(name:package:) heuristic meant "linked by something in the package graph", and it was only accidentally the same as "ships in the app" — ZIPFoundation in WhereCore was the sole .product site when it was written. Merging main broke that: SnapshotKitTesting links the snapshot-testing engine and the accessibility parser, so the generator would have credited both as libraries, telling a reader their binary contains a test harness. That is exactly the misdescription Kind exists to prevent. Kind is now derived rather than declared. The config's shippedFrom names the package targets the app and its extensions link (WhereUI and WhereIntents cover both, and SnapshotKitTesting sits outside that closure); the generator parses the manifest's target graph and walks it from those roots, and a package inside the closure is a library while any other linked package is a developmentTool. shippedFrom is the only value set by hand — everything downstream follows from the graph, so a new dependency can't be silently misfiled. That also brings back the two packages the report had been missing entirely: the committed report predated the merge, so it credited neither. They are development tools for the same reason the vendored agent skills are — the repo depends on them, they just never reach a device. Verified the derivation rather than the output: adding SnapshotKitTesting to shippedFrom flips both packages to library, so reachability is really driving it. Report is idempotent (same sha256 across runs). Since a source can now emit more than one kind, credits sort by kind then name, so adding a test-only package doesn't reshuffle shipping libraries. Source validation also moved up front, ahead of any network work, and now checks required keys per source type. --- AGENTS.md | 13 +- Shared/CreditKit/AGENTS.md | 9 ++ Shared/CreditKit/README.md | 13 +- .../CreditKit/Tools/generate-attribution.rb | 122 ++++++++++++++---- Where/AGENTS.md | 11 +- Where/Where/Resources/attribution.json | 20 +++ Where/Where/Tests/AppAttributionTests.swift | 15 ++- Where/Where/attribution-sources.json | 4 +- 8 files changed, 164 insertions(+), 43 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c7a7e96b..6e1d6446 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -126,11 +126,16 @@ package or a skill.** Nothing fails a build if you forget; each app asserts its own report instead (`AppAttributionTests` for Where), which is where that assertion belongs, since only the app knows what it should contain. -`SoftwareCredit.Kind` keeps a **linked library** apart from a **development +`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 copies it, not because it ships. Data-source provenance for -bundled geometry is separate and stays with its data, in -[`RegionKit`](Where/RegionKit/AGENTS.md). +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 diff --git a/Shared/CreditKit/AGENTS.md b/Shared/CreditKit/AGENTS.md index ae274c31..cf6f05f6 100644 --- a/Shared/CreditKit/AGENTS.md +++ b/Shared/CreditKit/AGENTS.md @@ -60,6 +60,15 @@ thing that writes a report. - **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 diff --git a/Shared/CreditKit/README.md b/Shared/CreditKit/README.md index 93e3bb72..60642e43 100644 --- a/Shared/CreditKit/README.md +++ b/Shared/CreditKit/README.md @@ -63,8 +63,8 @@ ruby Shared/CreditKit/Tools/generate-attribution.rb # just one { "output": "Where/Where/Resources/attribution.json", "sources": [ - { "type": "swiftPackageManager", "kind": "library", - "manifest": "Package.swift", "resolved": "Package.resolved" }, + { "type": "swiftPackageManager", "manifest": "Package.swift", + "resolved": "Package.resolved", "shippedFrom": ["WhereUI"] }, { "type": "agentSkills", "kind": "developmentTool", "manifest": ".agents/external-skills.json" } ] @@ -83,6 +83,15 @@ Deriving the list rather than maintaining it is the point: a package linked by remember to vend a credit — and a package declared for tooling alone is never linked, so it is correctly left out. +`swiftPackageManager` derives each credit's **kind** the same way, from +`shippedFrom`: it names the package targets the shipping app and its extensions +link, the generator walks the manifest's target graph out from them, and a +package inside that closure is a `library` while any other linked package is a +`developmentTool`. Linking is not shipping — a snapshot-testing engine linked by +a test-support target is credited (the repo depends on it) but must not be +described as being in the binary. `shippedFrom` is the only part set by hand, so +adding a dependency can't quietly land under the wrong kind. + The tool needs network and an authenticated `gh`. It is idempotent: re-running with nothing changed rewrites the same bytes. diff --git a/Shared/CreditKit/Tools/generate-attribution.rb b/Shared/CreditKit/Tools/generate-attribution.rb index b722e8d2..f3b686a1 100755 --- a/Shared/CreditKit/Tools/generate-attribution.rb +++ b/Shared/CreditKit/Tools/generate-attribution.rb @@ -14,8 +14,8 @@ # { # "output": "Where/Where/Resources/attribution.json", # "sources": [ -# { "type": "swiftPackageManager", "kind": "library", -# "manifest": "Package.swift", "resolved": "Package.resolved" }, +# { "type": "swiftPackageManager", "manifest": "Package.swift", +# "resolved": "Package.resolved", "shippedFrom": ["WhereUI"] }, # { "type": "agentSkills", "kind": "developmentTool", # "manifest": ".agents/external-skills.json" } # ] @@ -27,6 +27,13 @@ # `.product(name:package:)` in the manifest, pinned by the resolved file. A # package declared for tooling alone (an architecture linter, say) is never # linked and so is deliberately not credited. +# +# `kind` is **derived, not declared**: `shippedFrom` names the package +# targets the shipping app and its extensions link, and a package reachable +# from that closure is a `library` while any other linked package is a +# `developmentTool`. Linking alone doesn't mean shipping — a snapshot-testing +# engine is linked by a test-support target and never reaches a device — and +# crediting one as a `library` would tell a reader their binary contains it. # - `agentSkills` — a `./sync-agents` external-skills manifest of # `name -> { repo, ref }`. These are not in the binary, but the repository # makes copies of them, which is what their licenses ask us to attribute. @@ -51,11 +58,13 @@ def fail_with(message) abort "generate-attribution: #{message}" end -# The `kind` values `SoftwareCredit.Kind` decodes. Checked up front because a -# typo would otherwise produce a report that generates fine and commits fine, -# then fails to decode inside the app — surfacing as a fault and a debug trap on -# the About screen, a long way from the config line that caused it. -KINDS = %w[library developmentTool].freeze +# The `kind` values `SoftwareCredit.Kind` decodes. A declared one is checked up +# front because a typo would otherwise produce a report that generates fine and +# commits fine, then fails to decode inside the app — surfacing as a fault and a +# debug trap on the About screen, a long way from the config line that caused it. +KIND_LIBRARY = "library" +KIND_DEVELOPMENT_TOOL = "developmentTool" +KINDS = [KIND_LIBRARY, KIND_DEVELOPMENT_TOOL].freeze # GitHub's license endpoint resolves the notice's filename for us (LICENSE, # LICENSE.md, COPYING, …) and reports the license's title alongside it, so one @@ -93,20 +102,61 @@ def read_json(relative_path, source_type) JSON.parse(File.read(path)) end -# Packages some target actually links, by SPM identity (the lowercased package -# name as it appears in `.product(name:package:)`). -def linked_package_identities(manifest_path) +# A target declaration, as distinct from a `.target(name:)` *dependency* entry: +# only the declaration puts `name:` on its own line. Keying off that rather than +# indentation keeps the parse independent of how deeply the array is nested. +TARGET_DECLARATION = /\.(?:target|testTarget|executableTarget)\(\s*\n\s*name:\s*"([^"]+)"/ +TARGET_DEPENDENCY = /\.target\(name:\s*"([^"]+)"/ +PRODUCT_DEPENDENCY = /\.product\(\s*name:\s*"[^"]+",\s*package:\s*"([^"]+)"/ + +# The manifest's target graph: each target with the sibling targets and the +# external packages (by SPM identity — the lowercased `package:` name) it links. +def package_targets(manifest_path) path = File.join(ROOT, manifest_path) fail_with("swiftPackageManager: no manifest at #{manifest_path}") unless File.exist?(path) - File.read(path) - .scan(/\.product\(\s*name:\s*"[^"]+",\s*package:\s*"([^"]+)"/) - .flatten.map(&:downcase).uniq + text = File.read(path) + declarations = text.to_enum(:scan, TARGET_DECLARATION).map { Regexp.last_match } + fail_with("swiftPackageManager: no targets found in #{manifest_path}") if declarations.empty? + + declarations.each_with_index.to_h do |declaration, index| + # Everything up to the next declaration is this target's body. + body = text[declaration.end(0)...(declarations[index + 1]&.begin(0) || text.length)] + [ + declaration[1], + { + "targets" => body.scan(TARGET_DEPENDENCY).flatten, + "packages" => body.scan(PRODUCT_DEPENDENCY).flatten.map(&:downcase), + }, + ] + end +end + +# Package identities reachable from `roots` — the ones that end up in the app +# binary, as opposed to those linked only by test-support or tooling targets. +def shipped_package_identities(targets, roots) + unknown = roots - targets.keys + fail_with("swiftPackageManager: shippedFrom names no such target: #{unknown.join(", ")}") unless unknown.empty? + + visited = [] + queue = roots.dup + shipped = [] + until queue.empty? + name = queue.shift + next if visited.include?(name) + visited << name + target = targets[name] + next unless target + shipped.concat(target["packages"]) + queue.concat(target["targets"]) + end + shipped.uniq end def swift_package_manager_credits(source) - kind = source.fetch("kind") - linked = linked_package_identities(source.fetch("manifest")) + targets = package_targets(source.fetch("manifest")) + linked = targets.values.flat_map { |target| target["packages"] }.uniq fail_with("swiftPackageManager: no linked packages found") if linked.empty? + shipped = shipped_package_identities(targets, source.fetch("shippedFrom")) resolved = read_json(source.fetch("resolved"), "swiftPackageManager") pins = resolved.fetch("pins").select { |pin| linked.include?(pin["identity"]) } @@ -120,7 +170,7 @@ def swift_package_manager_credits(source) revision = state.fetch("revision") credit( name: slug.split("/").last, - kind: kind, + kind: shipped.include?(pin["identity"]) ? KIND_LIBRARY : KIND_DEVELOPMENT_TOOL, # A branch-pinned package has no version, so fall back to the revision. version: state["version"] || revision[0, 12], slug: slug, @@ -144,24 +194,46 @@ def agent_skills_credits(source) end SOURCE_TYPES = { - "swiftPackageManager" => method(:swift_package_manager_credits), - "agentSkills" => method(:agent_skills_credits), + "swiftPackageManager" => { + required: %w[manifest resolved shippedFrom], + generate: method(:swift_package_manager_credits), + }, + "agentSkills" => { + required: %w[manifest kind], + generate: method(:agent_skills_credits), + }, }.freeze +# Checked for every source before any of them runs, so a config mistake costs a +# second rather than surfacing after the first source's network round trips. +def validate_source(source, config_path) + type = source.fetch("type") + spec = SOURCE_TYPES[type] + fail_with("unknown source type #{type.inspect} in #{config_path}") unless spec + + missing = spec[:required] - source.keys + fail_with("#{type} in #{config_path} is missing #{missing.join(", ")}") unless missing.empty? + + kind = source["kind"] + return if kind.nil? || KINDS.include?(kind) + fail_with("unknown kind #{kind.inspect} in #{config_path} (expected #{KINDS.join(" or ")})") +end + def report(config_path) config = JSON.parse(File.read(config_path)) sources = config.fetch("sources") fail_with("#{config_path} declares no sources") if sources.empty? + sources.each { |source| validate_source(source, config_path) } + credits = sources.flat_map do |source| - type = source.fetch("type") - generate = SOURCE_TYPES[type] - fail_with("unknown source type #{type.inspect} in #{config_path}") unless generate - kind = source.fetch("kind") - fail_with("unknown kind #{kind.inspect} in #{config_path} (expected #{KINDS.join(" or ")})") unless KINDS.include?(kind) # Sort within a source so the report has a stable order and re-running it - # produces no diff when nothing changed. - generate.call(source).sort_by { |entry| entry["name"].downcase } + # produces no diff when nothing changed. A source can now emit more than one + # kind, so sort by kind first — otherwise adding a test-only package would + # reshuffle the shipping libraries around it. + SOURCE_TYPES.fetch(source.fetch("type"))[:generate] + .call(source) + .sort_by { |entry| [KINDS.index(entry["kind"]), entry["name"].downcase] } end fail_with("#{config_path} produced no credits") if credits.empty? diff --git a/Where/AGENTS.md b/Where/AGENTS.md index 5014de17..dda9e325 100644 --- a/Where/AGENTS.md +++ b/Where/AGENTS.md @@ -134,11 +134,12 @@ describe neither. `AppAttributionTests`, in the app's own test bundle because it is the one hosted by `Where.app`, asserts what the report contains. -A **linked library** and a **development tool** (the agent skills -`./sync-agents` vendors) render as separate sections. Keep them separate: a -development tool is credited because the repository copies it, not because it is -in the binary, and one merged list would tell a reader something untrue about -the app they are running. +A **shipped library** and a **development tool** render as separate sections. +Development tools are the agent skills `./sync-agents` vendors *and* packages +linked only outside the app's target closure (the snapshot-testing engine, the +accessibility parser) — credited because the repository depends on them, not +because they reach a device. Keep the sections separate: one merged list would +tell a reader something untrue about the app they are running. ## Localization diff --git a/Where/Where/Resources/attribution.json b/Where/Where/Resources/attribution.json index 8a573090..99064336 100644 --- a/Where/Where/Resources/attribution.json +++ b/Where/Where/Resources/attribution.json @@ -10,6 +10,26 @@ "text": "MIT License\n\nCopyright (c) 2017-2025 Thomas Zoechling (https://www.peakstep.com)\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": "AccessibilitySnapshot", + "kind": "developmentTool", + "version": "0.11.0", + "homepageURL": "https://github.com/cashapp/AccessibilitySnapshot", + "license": { + "name": "Apache License 2.0", + "text": "\n Apache License\n Version 2.0, January 2004\n http://www.apache.org/licenses/\n\n TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n 1. Definitions.\n\n \"License\" shall mean the terms and conditions for use, reproduction,\n and distribution as defined by Sections 1 through 9 of this document.\n\n \"Licensor\" shall mean the copyright owner or entity authorized by\n the copyright owner that is granting the License.\n\n \"Legal Entity\" shall mean the union of the acting entity and all\n other entities that control, are controlled by, or are under common\n control with that entity. For the purposes of this definition,\n \"control\" means (i) the power, direct or indirect, to cause the\n direction or management of such entity, whether by contract or\n otherwise, or (ii) ownership of fifty percent (50%) or more of the\n outstanding shares, or (iii) beneficial ownership of such entity.\n\n \"You\" (or \"Your\") shall mean an individual or Legal Entity\n exercising permissions granted by this License.\n\n \"Source\" form shall mean the preferred form for making modifications,\n including but not limited to software source code, documentation\n source, and configuration files.\n\n \"Object\" form shall mean any form resulting from mechanical\n transformation or translation of a Source form, including but\n not limited to compiled object code, generated documentation,\n and conversions to other media types.\n\n \"Work\" shall mean the work of authorship, whether in Source or\n Object form, made available under the License, as indicated by a\n copyright notice that is included in or attached to the work\n (an example is provided in the Appendix below).\n\n \"Derivative Works\" shall mean any work, whether in Source or Object\n form, that is based on (or derived from) the Work and for which the\n editorial revisions, annotations, elaborations, or other modifications\n represent, as a whole, an original work of authorship. For the purposes\n of this License, Derivative Works shall not include works that remain\n separable from, or merely link (or bind by name) to the interfaces of,\n the Work and Derivative Works thereof.\n\n \"Contribution\" shall mean any work of authorship, including\n the original version of the Work and any modifications or additions\n to that Work or Derivative Works thereof, that is intentionally\n submitted to Licensor for inclusion in the Work by the copyright owner\n or by an individual or Legal Entity authorized to submit on behalf of\n the copyright owner. For the purposes of this definition, \"submitted\"\n means any form of electronic, verbal, or written communication sent\n to the Licensor or its representatives, including but not limited to\n communication on electronic mailing lists, source code control systems,\n and issue tracking systems that are managed by, or on behalf of, the\n Licensor for the purpose of discussing and improving the Work, but\n excluding communication that is conspicuously marked or otherwise\n designated in writing by the copyright owner as \"Not a Contribution.\"\n\n \"Contributor\" shall mean Licensor and any individual or Legal Entity\n on behalf of whom a Contribution has been received by Licensor and\n subsequently incorporated within the Work.\n\n 2. Grant of Copyright License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n copyright license to reproduce, prepare Derivative Works of,\n publicly display, publicly perform, sublicense, and distribute the\n Work and such Derivative Works in Source or Object form.\n\n 3. Grant of Patent License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n (except as stated in this section) patent license to make, have made,\n use, offer to sell, sell, import, and otherwise transfer the Work,\n where such license applies only to those patent claims licensable\n by such Contributor that are necessarily infringed by their\n Contribution(s) alone or by combination of their Contribution(s)\n with the Work to which such Contribution(s) was submitted. If You\n institute patent litigation against any entity (including a\n cross-claim or counterclaim in a lawsuit) alleging that the Work\n or a Contribution incorporated within the Work constitutes direct\n or contributory patent infringement, then any patent licenses\n granted to You under this License for that Work shall terminate\n as of the date such litigation is filed.\n\n 4. Redistribution. You may reproduce and distribute copies of the\n Work or Derivative Works thereof in any medium, with or without\n modifications, and in Source or Object form, provided that You\n meet the following conditions:\n\n (a) You must give any other recipients of the Work or\n Derivative Works a copy of this License; and\n\n (b) You must cause any modified files to carry prominent notices\n stating that You changed the files; and\n\n (c) You must retain, in the Source form of any Derivative Works\n that You distribute, all copyright, patent, trademark, and\n attribution notices from the Source form of the Work,\n excluding those notices that do not pertain to any part of\n the Derivative Works; and\n\n (d) If the Work includes a \"NOTICE\" text file as part of its\n distribution, then any Derivative Works that You distribute must\n include a readable copy of the attribution notices contained\n within such NOTICE file, excluding those notices that do not\n pertain to any part of the Derivative Works, in at least one\n of the following places: within a NOTICE text file distributed\n as part of the Derivative Works; within the Source form or\n documentation, if provided along with the Derivative Works; or,\n within a display generated by the Derivative Works, if and\n wherever such third-party notices normally appear. The contents\n of the NOTICE file are for informational purposes only and\n do not modify the License. You may add Your own attribution\n notices within Derivative Works that You distribute, alongside\n or as an addendum to the NOTICE text from the Work, provided\n that such additional attribution notices cannot be construed\n as modifying the License.\n\n You may add Your own copyright statement to Your modifications and\n may provide additional or different license terms and conditions\n for use, reproduction, or distribution of Your modifications, or\n for any such Derivative Works as a whole, provided Your use,\n reproduction, and distribution of the Work otherwise complies with\n the conditions stated in this License.\n\n 5. Submission of Contributions. Unless You explicitly state otherwise,\n any Contribution intentionally submitted for inclusion in the Work\n by You to the Licensor shall be under the terms and conditions of\n this License, without any additional terms or conditions.\n Notwithstanding the above, nothing herein shall supersede or modify\n the terms of any separate license agreement you may have executed\n with Licensor regarding such Contributions.\n\n 6. Trademarks. This License does not grant permission to use the trade\n names, trademarks, service marks, or product names of the Licensor,\n except as required for reasonable and customary use in describing the\n origin of the Work and reproducing the content of the NOTICE file.\n\n 7. Disclaimer of Warranty. Unless required by applicable law or\n agreed to in writing, Licensor provides the Work (and each\n Contributor provides its Contributions) on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n implied, including, without limitation, any warranties or conditions\n of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n PARTICULAR PURPOSE. You are solely responsible for determining the\n appropriateness of using or redistributing the Work and assume any\n risks associated with Your exercise of permissions under this License.\n\n 8. Limitation of Liability. In no event and under no legal theory,\n whether in tort (including negligence), contract, or otherwise,\n unless required by applicable law (such as deliberate and grossly\n negligent acts) or agreed to in writing, shall any Contributor be\n liable to You for damages, including any direct, indirect, special,\n incidental, or consequential damages of any character arising as a\n result of this License or out of the use or inability to use the\n Work (including but not limited to damages for loss of goodwill,\n work stoppage, computer failure or malfunction, or any and all\n other commercial damages or losses), even if such Contributor\n has been advised of the possibility of such damages.\n\n 9. Accepting Warranty or Additional Liability. While redistributing\n the Work or Derivative Works thereof, You may choose to offer,\n and charge a fee for, acceptance of support, warranty, indemnity,\n or other liability obligations and/or rights consistent with this\n License. However, in accepting such obligations, You may act only\n on Your own behalf and on Your sole responsibility, not on behalf\n of any other Contributor, and only if You agree to indemnify,\n defend, and hold each Contributor harmless for any liability\n incurred by, or claims asserted against, such Contributor by reason\n of your accepting any such warranty or additional liability.\n\n END OF TERMS AND CONDITIONS\n\n APPENDIX: How to apply the Apache License to your work.\n\n To apply the Apache License to your work, attach the following\n boilerplate notice, with the fields enclosed by brackets \"[]\"\n replaced with your own identifying information. (Don't include\n the brackets!) The text should be enclosed in the appropriate\n comment syntax for the file format. We also recommend that a\n file or class name and description of purpose be included on the\n same \"printed page\" as the copyright notice for easier\n identification within third-party archives.\n\n Copyright [yyyy] [name of copyright owner]\n\n Licensed under the Apache License, Version 2.0 (the \"License\");\n you may not use this file except in compliance with the License.\n You may obtain a copy of the License at\n\n http://www.apache.org/licenses/LICENSE-2.0\n\n Unless required by applicable law or agreed to in writing, software\n distributed under the License is distributed on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n See the License for the specific language governing permissions and\n limitations under the License.\n" + } + }, + { + "name": "swift-snapshot-testing", + "kind": "developmentTool", + "version": "1.19.3", + "homepageURL": "https://github.com/pointfreeco/swift-snapshot-testing", + "license": { + "name": "MIT License", + "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": "swift-concurrency-pro", "kind": "developmentTool", diff --git a/Where/Where/Tests/AppAttributionTests.swift b/Where/Where/Tests/AppAttributionTests.swift index 0d2c4373..9a8fe724 100644 --- a/Where/Where/Tests/AppAttributionTests.swift +++ b/Where/Where/Tests/AppAttributionTests.swift @@ -24,16 +24,21 @@ struct AppAttributionTests { #expect(try !report().credits.isEmpty) } - @Test func creditsEveryPackageATargetLinks() throws { - // The one external package any target links via `.product(name:package:)`. - // BumperBowling and swift-syntax are resolved for architecture linting - // and never reach the binary, so they are deliberately absent. + @Test func creditsEveryPackageTheAppShips() throws { + // The one external package inside the app's target closure. BumperBowling + // and swift-syntax are resolved for architecture linting and never linked + // at all, so they are deliberately absent. #expect(try report().credits(ofKind: .library).map(\.name) == ["ZIPFoundation"]) } - @Test func creditsTheVendoredAgentSkills() throws { + @Test func creditsWhatTheRepoUsesWithoutShipping() throws { let tools = try Set(report().credits(ofKind: .developmentTool).map(\.name)) #expect(tools == [ + // Linked only by the test-support target, so credited but not in the + // binary — listing these as libraries would misdescribe the app. + "AccessibilitySnapshot", + "swift-snapshot-testing", + // Vendored into the repo by ./sync-agents. "swift-concurrency-pro", "swift-testing-pro", "swiftdata-pro", diff --git a/Where/Where/attribution-sources.json b/Where/Where/attribution-sources.json index 7a00b35c..db51814f 100644 --- a/Where/Where/attribution-sources.json +++ b/Where/Where/attribution-sources.json @@ -3,9 +3,9 @@ "sources": [ { "type": "swiftPackageManager", - "kind": "library", "manifest": "Package.swift", - "resolved": "Package.resolved" + "resolved": "Package.resolved", + "shippedFrom": ["WhereUI", "WhereIntents"] }, { "type": "agentSkills", From 1f1426a94e999d8d39ee2d53216362a40f22e5cf Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sun, 26 Jul 2026 19:52:43 -0700 Subject: [PATCH 13/13] Gate the attribution report on ./attribution --check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the P1 drift-guard item, and corrects what that item claimed. I filed it saying the guard caught an added dependency but not a bumped one. It caught neither. AppAttributionTests compared the report against a hardcoded list of names, so a dependency added without regenerating left the report and the list still agreeing — which is exactly what happened on this branch: the suite passed while the committed report was missing the two snapshot packages the main merge had added. A literal can only ever restate what someone wrote down, and a test bundle can't read Package.swift to do better. --check re-derives the expected report from Package.swift, Package.resolved, and the skills manifest, then diffs it against the committed one. It makes no network calls, which is what lets it gate CI: notices are fetched at the pinned revision, so a matching revision means matching text by construction, and the notice being present is checked directly. Runs in well under a second, so it rides along in the format job beside the catalog lint rather than taking a new required check name. Verified against the three ways a report goes stale — a credit missing after a dependency lands, a version left behind by a bump (the case the old guard was supposed to catch), and a notice blanked by hand. Each fails with the offending credit named. AppAttributionTests keeps only what the shipping bundle alone can answer: the report is present, decodable, carries its notices inline, has unique names, and populates both kinds. The name lists are gone rather than updated, since keeping them means editing a test on every dependency change for no added safety. --- .github/workflows/ci.yml | 7 ++ AGENTS.md | 10 ++- Shared/CreditKit/README.md | 10 ++- .../CreditKit/Tools/generate-attribution.rb | 76 +++++++++++++++++-- TODOs.md | 2 +- Where/AGENTS.md | 7 +- Where/Where/AGENTS.md | 4 +- Where/Where/Tests/AppAttributionTests.swift | 41 +++++----- attribution | 18 ++++- 9 files changed, 129 insertions(+), 46 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 15f00bdf..2140fdde 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/AGENTS.md b/AGENTS.md index 6e1d6446..e4cf15dc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -122,9 +122,13 @@ skills in `.agents/external-skills.json` — with each notice read at the **pinn 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.** Nothing fails a build if you forget; each app asserts its -own report instead (`AppAttributionTests` for Where), which is where that -assertion belongs, since only the app knows what it should contain. +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 diff --git a/Shared/CreditKit/README.md b/Shared/CreditKit/README.md index 60642e43..7004ef0c 100644 --- a/Shared/CreditKit/README.md +++ b/Shared/CreditKit/README.md @@ -109,10 +109,12 @@ handle at runtime. ## Contracts and limitations -- **A report is only as current as its last run.** Nothing fails a build when a - dependency lands without regenerating. Each app is expected to assert its own - report's contents in its own tests — see `AppAttributionTests` in the Where - app — because only the app knows what it should contain. +- **A report goes stale silently unless something checks it.** Nothing about + adding or bumping a dependency forces a re-run, so `--check` exists to fail + the build: it re-derives the expected report from the same manifests and diffs + it against the committed one, offline. Reach for that rather than asserting + credit names in a test — a test bundle can't read the manifests, so it can + only compare the report to a literal, which a stale report matches too. - **Development tools are not in the binary.** They are credited because the repository makes copies of them, which permissive licenses ask us to attribute. Any UI must keep the two kinds visually distinct so a reader isn't diff --git a/Shared/CreditKit/Tools/generate-attribution.rb b/Shared/CreditKit/Tools/generate-attribution.rb index f3b686a1..47302c0d 100755 --- a/Shared/CreditKit/Tools/generate-attribution.rb +++ b/Shared/CreditKit/Tools/generate-attribution.rb @@ -47,6 +47,11 @@ # ./attribution # or point it at one config directly: # ruby Shared/CreditKit/Tools/generate-attribution.rb +# +# `--check` verifies a committed report still matches what the repo declares, +# without writing anything. It makes **no network calls** — every field it +# compares comes from files in the repo — so it is cheap enough to gate CI, which +# is the only thing that actually stops a stale report from shipping. require "json" require "fileutils" @@ -86,16 +91,31 @@ def github_slug(location) location[%r{github\.com[:/](.+?)(?:\.git)?/?\z}, 1] end +# The fields of a credit that come from files already in the repo. The notice is +# deliberately not one of them: it is the only field needing the network, which +# is what lets `--check` derive the whole expected report offline. +NOTICE_FREE_KEYS = %w[name kind version homepageURL].freeze + def credit(name:, kind:, version:, slug:, ref:) { "name" => name, "kind" => kind, "version" => version, "homepageURL" => "https://github.com/#{slug}", - "license" => github_license(slug, ref), + # Carried for the notice fetch, then dropped before the report is written. + "slug" => slug, + "ref" => ref, } end +def notice_free(entry) + NOTICE_FREE_KEYS.to_h { |key| [key, entry[key]] } +end + +def describe(entry) + "#{entry["kind"]}: #{entry["name"]} #{entry["version"]}" +end + def read_json(relative_path, source_type) path = File.join(ROOT, relative_path) fail_with("#{source_type}: no file at #{relative_path}") unless File.exist?(path) @@ -219,7 +239,7 @@ def validate_source(source, config_path) fail_with("unknown kind #{kind.inspect} in #{config_path} (expected #{KINDS.join(" or ")})") end -def report(config_path) +def report(config_path, check:) config = JSON.parse(File.read(config_path)) sources = config.fetch("sources") fail_with("#{config_path} declares no sources") if sources.empty? @@ -246,17 +266,57 @@ def report(config_path) .keys fail_with("duplicate credit name(s): #{collisions.join(", ")}") unless collisions.empty? - output = File.join(ROOT, config.fetch("output")) + return check_report(credits, config.fetch("output")) if check + + write_report(credits, config.fetch("output")) +end + +def write_report(credits, output_path) + output = File.join(ROOT, output_path) + entries = credits.map do |entry| + notice_free(entry).merge("license" => github_license(entry["slug"], entry["ref"])) + end FileUtils.mkdir_p(File.dirname(output)) - File.write(output, "#{JSON.pretty_generate({ "credits" => credits })}\n") + File.write(output, "#{JSON.pretty_generate({ "credits" => entries })}\n") + + puts "#{output_path}: #{entries.count} credit(s)" + entries.each { |entry| puts " #{describe(entry)}" } +end + +# Fails when the committed report disagrees with what the repo now declares. +# +# Runs entirely offline, which is the whole reason it can gate CI: every field +# it compares is derived from `Package.swift`, `Package.resolved`, and the skills +# manifest. It can't re-read a notice, but it doesn't need to — a notice is +# fetched at the pinned revision, so a matching revision means matching text by +# construction, and the notice being *present* is checked here directly. +def check_report(credits, output_path) + path = File.join(ROOT, output_path) + fail_with("#{output_path} does not exist — run ./attribution") unless File.exist?(path) + committed = JSON.parse(File.read(path))["credits"] + fail_with("#{output_path} carries no credits — run ./attribution") if committed.nil? || committed.empty? + + expected = credits.map { |entry| notice_free(entry) } + actual = committed.map { |entry| notice_free(entry) } + unless expected == actual + lines = (expected - actual).map { |entry| " missing: #{describe(entry)}" } + + (actual - expected).map { |entry| " unexpected: #{describe(entry)}" } + # Same list, different order, is still a mismatch worth reporting plainly. + lines << " (same credits, different order)" if lines.empty? + fail_with("#{output_path} is stale — run ./attribution\n#{lines.join("\n")}") + end + + bare = committed.select { |entry| entry.dig("license", "text").to_s.strip.empty? } + .map { |entry| entry["name"] } + fail_with("#{output_path}: no notice for #{bare.join(", ")}") unless bare.empty? - puts "#{config.fetch("output")}: #{credits.count} credit(s)" - credits.each { |entry| puts " #{entry["kind"]}: #{entry["name"]} #{entry["version"]}" } + puts "#{output_path}: up to date (#{committed.count} credit(s))" end +check = !ARGV.delete("--check").nil? configs = ARGV -fail_with("usage: generate-attribution.rb ...") if configs.empty? +fail_with("usage: generate-attribution.rb [--check] ...") if configs.empty? configs.each do |config| fail_with("no config at #{config}") unless File.exist?(config) - report(config) + report(config, check: check) end diff --git a/TODOs.md b/TODOs.md index ff57d9f0..a560f5e9 100644 --- a/TODOs.md +++ b/TODOs.md @@ -96,10 +96,10 @@ inbox rather than here. - docs(Bumper) [quick-win]: Correct `.bumper/RULES.md:101` and `:143`, which claim three calendar violations and some preview-coverage violations are "left visible during this bootstrap". Neither exists — the lint gate is green, and `52f0136` closed the preview ones. Delete both paragraphs, and re-add the calendar one only if the widened rule genuinely finds drift. (audit 2026-07-26) ## P1s (Should do) -- test(CreditKit) [needs-design]: The attribution drift guard catches an added dependency but not a bumped one, so the app can ship notices that don't govern the code in it. `AppAttributionTests` asserts credit *names* only — `map(\.name)` against a literal list at `Where/Where/Tests/AppAttributionTests.swift:31` and `:35` — so adding or removing a package or a skill fails, while `./sync-agents --update` or an SPM bump leaves every name unchanged and the committed `Where/Where/Resources/attribution.json` keeps the old versions and the old license text with nothing red. That silently defeats the reason notices are read at the pinned revision (`Shared/CreditKit/Tools/generate-attribution.rb:67`): upstream edits a notice between releases, which is exactly the drift the pinning exists to catch. Suggested fix is a **network-free** `./attribution --check` that recomputes each credit's version from the pins with the generator's own derivation (`:125` for SPM's `state["version"] || revision[0, 12]`, `:139` for a skill's `ref[0, 12]`) and fails when the report disagrees, gated in CI beside `./swiftformat --lint` and `./xcstrings --lint` (`.github/workflows/ci.yml:24`, `:31`). `needs-design` because the obvious alternative — regenerate and diff — needs network and an authenticated `gh`, which would make every CI run depend on GitHub availability, while the cheap comparison verifies the *ref* and not the notice text; that trade-off is the decision. Sits here rather than in [`Shared/CreditKit/TODOs.md`](Shared/CreditKit/TODOs.md) because it lands in the root `./attribution` wrapper and CI as well as the generator. (pr#140 review) ## P2s (Nice to have) - feat(PeriscopeCore) [needs-design]: A `LogSession` can't name the build it came from. `LogSession.current()` reads only `CFBundleShortVersionString` / `CFBundleVersion` off the main bundle (`Shared/Periscope/PeriscopeCore/Sources/Store/LogSession.swift:32`), and the viewer renders that pair as the session's identity (`PeriscopeTools/Sources/Viewer/PeriscopeViewer.swift:269`) — but the Where app pins both in the manifest (`Project.swift:101`), so every developer build reads `v1.0 (1)` and weeks-old logs can't be tied to the code that produced them. The commit is now available: the app stamps `WhereGitSHA` / `WhereGitStatus` into its Info.plist and `WhereCore.BuildInfo` reads them back. PeriscopeCore can't depend on WhereCore (Periscope sits below the Where modules), so this needs a seam rather than a direct adoption — an optional commit field, or general resource attributes on `LogSession` that the host app fills at bootstrap. Spans Periscope and Where, hence here. (agent 2026-07-26) - perf(StuffTestHost) [needs-design]: Two loose ends in the shared test host, both reaching the root Tuist manifest, which is why they sit here rather than in a StuffTestHost file. The WhereCore-always-embedded build trade-off is documented and verified load-bearing at `Project.swift:256` — decide whether to keep documenting it or split the host so unrelated bundles don't pay for it. Separately, the scene configuration name is spelled twice, in `Shared/StuffTestHost/Sources/AppDelegate.swift:11` and `Project.swift:244`, so the two can drift silently. (audit 2026-07-26) # Completed issues +- test(CreditKit) [needs-design]: The attribution drift guard didn't detect a stale report, so the app could ship notices that don't govern the code in it. **Closed by `./attribution --check`**, a network-free mode that re-derives the expected report from `Package.swift`, `Package.resolved`, and the skills manifest and diffs it against the committed one, gated in CI beside the other lints (`.github/workflows/ci.yml`). The literal name lists in `AppAttributionTests` are gone — a test bundle can't read the manifests, so all it could compare against was a literal — and that suite now asserts only what the shipping bundle can answer. **As originally filed this item was wrong on a detail worth recording:** it claimed the old guard caught an added dependency but not a bumped one. It caught neither. Comparing the report to a hardcoded list means a dependency added without regenerating leaves report and list still agreeing, which is exactly what happened — the guard passed on a report missing the two snapshot packages that merging `main` had added. (pr#140 review, closed 2026-07-26) diff --git a/Where/AGENTS.md b/Where/AGENTS.md index dda9e325..1d0024c3 100644 --- a/Where/AGENTS.md +++ b/Where/AGENTS.md @@ -130,9 +130,10 @@ target's** resources, so only the app bundle carries one. Every other bundle so, exactly as it does for an unstamped `BuildInfo`. A section with nothing of its kind says so too, in different words: "no report at all" and "nothing of this kind" describe different builds, and a header and footer over no rows would -describe neither. `AppAttributionTests`, in -the app's own test bundle because it is the one hosted by `Where.app`, asserts -what the report contains. +describe neither. `AppAttributionTests`, in the app's own test bundle because it +is the one hosted by `Where.app`, asserts the report is usable — present, +decodable, both kinds populated. Whether it still *matches* the dependency graph +is `./attribution --check`'s job in CI, not a literal here. A **shipped library** and a **development tool** render as separate sections. Development tools are the agent skills `./sync-agents` vendors *and* packages diff --git a/Where/Where/AGENTS.md b/Where/Where/AGENTS.md index fd66543c..8bd21ea2 100644 --- a/Where/Where/AGENTS.md +++ b/Where/Where/AGENTS.md @@ -29,7 +29,9 @@ layering, and the domain rules this target merely starts up. [Attribution](../../AGENTS.md#attribution)) — never hand-edit the report. This is the **only** bundle that carries one, which is why `AppAttributionTests` lives in this target's test bundle: it is the one hosted by `Where.app`, so - `Bundle.main` is the shipping bundle. + `Bundle.main` is the shipping bundle. Those tests cover the report being + usable; `./attribution --check` in CI covers it still matching the dependency + graph, which no test bundle can see. ## Invariants diff --git a/Where/Where/Tests/AppAttributionTests.swift b/Where/Where/Tests/AppAttributionTests.swift index 9a8fe724..d0f8b9bc 100644 --- a/Where/Where/Tests/AppAttributionTests.swift +++ b/Where/Where/Tests/AppAttributionTests.swift @@ -8,9 +8,14 @@ import WhereCore /// `Bundle.main` is the shipping bundle, and a generic attribution library has /// no business asserting which packages its consumer links. /// -/// These are the drift guard. `./attribution` regenerates the report, but -/// nothing forces it to be re-run, so a dependency added without regenerating -/// fails here rather than silently shipping an incomplete About screen. +/// These assert the report is *usable* — present, decodable, and complete +/// enough to render. They deliberately do **not** assert which packages it +/// names: this bundle can't read `Package.swift`, so any such assertion is a +/// literal that a stale report agrees with just as happily as a fresh one. That +/// is not hypothetical — a hardcoded list here passed while the committed report +/// was missing two packages a merge had added. `./attribution --check` owns +/// agreement with the dependency graph and gates CI; this owns everything only +/// the shipping bundle can answer. @MainActor struct AppAttributionTests { private func report() throws -> AttributionManifest { @@ -24,26 +29,16 @@ struct AppAttributionTests { #expect(try !report().credits.isEmpty) } - @Test func creditsEveryPackageTheAppShips() throws { - // The one external package inside the app's target closure. BumperBowling - // and swift-syntax are resolved for architecture linting and never linked - // at all, so they are deliberately absent. - #expect(try report().credits(ofKind: .library).map(\.name) == ["ZIPFoundation"]) - } - - @Test func creditsWhatTheRepoUsesWithoutShipping() throws { - let tools = try Set(report().credits(ofKind: .developmentTool).map(\.name)) - #expect(tools == [ - // Linked only by the test-support target, so credited but not in the - // binary — listing these as libraries would misdescribe the app. - "AccessibilitySnapshot", - "swift-snapshot-testing", - // Vendored into the repo by ./sync-agents. - "swift-concurrency-pro", - "swift-testing-pro", - "swiftdata-pro", - "swiftui-pro", - ]) + @Test func creditsBothKindsOfWork() throws { + // Not a list of names — that's `--check`'s job. This pins the shape the + // About screen depends on: two populated sections, so neither renders + // its "nothing to credit" state in the shipping app. + for kind in SoftwareCredit.Kind.allCases { + #expect( + try !report().credits(ofKind: kind).isEmpty, + "the report credits nothing of kind \(kind)", + ) + } } @Test func everyCreditCarriesItsNoticeInline() throws { diff --git a/attribution b/attribution index 0434b61c..5179c310 100755 --- a/attribution +++ b/attribution @@ -29,11 +29,16 @@ usage() { cat <<'USAGE' Usage: ./attribution [config ...] + ./attribution --check [config ...] ./attribution --list ./attribution --help Regenerates the attribution report for each configured app. With no arguments, regenerates every app's report; pass a config path to do just one. + + --check Verify each committed report still matches what the repo declares, + without writing anything. Runs offline and needs no gh, so CI can + gate on it — which is what stops a stale report from shipping. USAGE } @@ -48,7 +53,14 @@ case "${1-}" in ;; esac -if ! command -v gh >/dev/null 2>&1; then +check="" +if [ "${1-}" = "--check" ]; then + check="--check" + shift +fi + +# Only a regenerating run reads notices off GitHub; --check is entirely local. +if [ -z "$check" ] && ! command -v gh >/dev/null 2>&1; then echo "attribution: needs the GitHub CLI (gh) to read license notices" >&2 exit 1 fi @@ -62,7 +74,7 @@ fi # The pinned Ruby via mise when available, since the repo pins one; the system # Ruby otherwise, so a bare checkout can still run this. if command -v mise >/dev/null 2>&1; then - mise exec -- ruby "$GENERATOR" "${targets[@]}" + mise exec -- ruby "$GENERATOR" ${check:+"$check"} "${targets[@]}" else - ruby "$GENERATOR" "${targets[@]}" + ruby "$GENERATOR" ${check:+"$check"} "${targets[@]}" fi