diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 603a28c..01e4ae4 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -64,6 +64,9 @@ SKIP_PATTERNS=( '^_archive/' '^Tests/Fixtures/acme/' '^Tests/Fixtures/real-yaml/' + # Vendored upstream rules canon (mirror of rules-swift). Its enforcement docs + # legitimately quote the forbidden phrases. See docs/rules-swift/PROVENANCE.md. + '^docs/rules-swift/' # Rule files that DEFINE the prohibitions; they MUST be able to cite # the forbidden strings to document them. '/github-discipline\.md$' diff --git a/docs/rules-swift/CHANGELOG.md b/docs/rules-swift/CHANGELOG.md new file mode 100644 index 0000000..6961eb4 --- /dev/null +++ b/docs/rules-swift/CHANGELOG.md @@ -0,0 +1,18 @@ +# Changelog + +All notable changes to this rule set are documented here. This project follows Semantic Versioning. + +## [1.0.0] - 2026-06-15 + +Initial public release of the Swift domain rules, built on the engineering-discipline core spine (vendored in `core/`). + +### Added + +- Where an app starts: `business-rules-and-constraints` (MANDATORY first artifact: business rules as explicit checkable statements, plus the constraints that bound them, which REST services, what compliance, which auth model) and `domain-first` (MANDATORY: start at the domain and business rules, framework-free and headless-provable; the UI framework is the last, reversible, and plural choice). +- Swift craft: `code-style`, `namespacing`, `dependency-injection`, `concurrency`, `cross-platform`, `linux-server`, `testing`, `formatting-and-linting`, `documentation`, `documentation-search`, `package-structure`, `package-architecture`, `package-import-contract`, `shared-protocols`. +- UI (`ui/`): `pre-ui-layer` (the display-less model seam, MANDATORY: Domain + Surface, one `perform(Intent)` channel, renderers the only UI import; one model drives the SwiftUI, AppKit, and UIKit renderers at once, both the architecture's payoff and a comparison instrument), `pom` (Page Object Model for UI tests), `flowspec` (declarative UI scenarios via the [FlowSpec](https://github.com/mihaelamj/FlowSpec) package), plus three renderers over the one model (`swiftui-views`, `uikit-views`, `appkit-views`), `view-models`, `components`, `colors`, `fonts`. +- Parsing and validation: `parsing-rules` and `validation-rules` (the OpenAPIKit idiom). Validation requires every public type to be validated or excluded with a reason, enforced mechanically. +- Apple-platform stance: `framework-policy` (Apple-only mandate), `openapi-generated`. +- Drop-in kit: `.gitignore`, `.swiftformat`, `.swiftlint.yml`, `.pre-commit-config.yaml`, and `.githooks/` (a `pre-commit` format-and-lint hook and a `commit-msg` attribution/em-dash check). +- Vendored `core/` spine: the cross-cutting engineering-discipline rules (incl. `round-trip-transformation`). +- Dual license: prose under CC BY 4.0, code under MIT. diff --git a/docs/rules-swift/CONTRIBUTING.md b/docs/rules-swift/CONTRIBUTING.md new file mode 100644 index 0000000..85b59bf --- /dev/null +++ b/docs/rules-swift/CONTRIBUTING.md @@ -0,0 +1,31 @@ +# Contributing + +This is the **Swift domain** rule set. The language- and platform-specific craft lives at the repository root; the cross-cutting engineering discipline it builds on is vendored in `core/`. + +> **This is the canonical home for the Swift domain rules.** Edit them here. The one exception is `core/`: it is a vendored copy of the shared engineering-discipline spine, so propose changes to those files upstream in the engineering-discipline repo, not in this repo's `core/`. + +## Where a change belongs + +- **Swift-specific rules** (architecture, concurrency, packages, views, formatting and linting, and so on): propose them here. +- **Cross-cutting discipline** (the engineering ethic, proof discipline, commits, git, testing discipline, and the rest of `core/`): `core/` is a synced copy of the `rules-engineering-discipline` repository. Do not edit it here. Propose those changes upstream so every domain inherits them. + +## How to propose a change + +1. Open an issue describing the rule and why it generalizes beyond a single project. +2. Keep each rule a single, self-contained document: state the rule, show how to apply it, and give a way to check conformance. +3. A new rule earns its place only by a recurring, transferable need. See `core/self-improve.md` for the threshold. + +## The shipped kit + +This repository ships drop-in starting configuration: `.gitignore`, `.swiftformat`, `.swiftlint.yml`, and `.githooks/` (a `pre-commit` hook that formats and lints, and a `commit-msg` hook that rejects tool attribution and em dashes). Install the hooks per clone with `git config core.hooksPath .githooks`. Tune the configs to your project, but keep them committed. + +## House conventions (this repo follows its own rules) + +- **Commits** follow Conventional Commits and `core/commits.md`. Committed text names human contributors only and carries no tool attribution of any kind. +- **No em dashes** in any committed text. Use commas, colons, periods, or restructure. +- **Format and lint** before committing (the pre-commit hook does both); see `formatting-and-linting.md`. +- **Prose** contributions are licensed under CC BY 4.0; any code under MIT. By opening a pull request you agree to license your contribution under those terms. + +## Style + +Match the existing register: clean prose, a short framing of what the rule governs, concrete DO and DON'T guidance, and an acceptance check the reader can run. No provenance footers, no first person. diff --git a/docs/rules-swift/CONVENTIONS.md b/docs/rules-swift/CONVENTIONS.md new file mode 100644 index 0000000..b14c1d9 --- /dev/null +++ b/docs/rules-swift/CONVENTIONS.md @@ -0,0 +1,121 @@ +# Tiledown Conventions + +The Swift coding conventions for Tiledown. They keep the codebase consistent, +testable, and portable. Read this before opening a PR. When in doubt, read the +surrounding files and match what is already there: consistency with existing code +outranks personal preference. + +These are the **Swift domain** conventions: the language- and platform-specific +craft. They sit on top of a vendored **core spine** of cross-cutting engineering +discipline (the optimal-path ethic, proof and verification, testing discipline, +systematic debugging, commits, and git process). That spine lives in +[`core/`](rules/core/); read it first, the Swift rules build on it and do not +repeat it. + +This page is the overview. The full per-area Swift rules live in +[`docs/rules/`](rules/) (index at [`docs/rules/README.md`](rules/README.md)), and +the cross-cutting rules in [`docs/rules/core/`](rules/core/). + +## Language + +Swift for everything. The single exception is JavaScript, and only where it is +intrinsic to the output: client-side tiles (Mermaid, charts, forms, polls) emit +HTML and JS that run in the visitor's browser. JS is never used for build logic or +tooling. + +## Engineering principles + +1. **Optimal over fast.** Respect existing code and idioms. Clarify ambiguity + before coding rather than assuming requirements. When a real trade-off exists, + surface two or three options instead of guessing. +2. **Progressive architecture.** Start with the simplest thing that works. Add a + protocol only when a second concrete consumer exists. Generalise only when a + pattern has actually emerged. Do not pre-abstract. +3. **Make impossible states unrepresentable.** Use exhaustive enums with + associated values. Never force-unwrap (`!`, `try!`) in shipping code. Errors + carry both a human-readable reason and an actionable recovery path. +4. **Testable by design.** Inject every collaborator through the initialiser. Test + behaviour through the public API, not implementation details. +5. **Profile, then optimise.** Value semantics by default. Pick the right data + structure first. Optimise only with a profile in hand. + +## Dependency injection + +- **No singletons.** No `static let shared`, no process-wide config reached + through static accessors. Every dependency appears at the `init` site so + coupling is visible, testable, and removable. +- **Every external collaborator goes through `init`.** Not method parameters at + the call site, not static fallbacks, not environment lookup. Pure free functions + that compute from their arguments are fine. +- **Cross-module seams are protocols, not concrete imports.** A library module + does not reach into another module's concrete types. The executable (the + composition root) is the only place that wires concretes together. +- **No closure typealiases for named cross-module contracts.** Use a named + protocol. Closures as ordinary method parameters (`onProgress:`) are fine. + +## Namespacing and file layout + +- **Every public type lives under an `enum`/`struct` namespace that mirrors its + folder.** No public type at file scope. Reading `Module.Sub.Leaf` should tell you + where the type lives and what it does. +- **Namespace anchors are caseless `enum`.** Use `struct` only when the type is + also a value. Never `class` for a namespace. Shared mutable state is an `actor` + or an injected value, never a `class`. +- **Drop redundant context.** Under `Availability`, the error is `Availability.Error`, + not `AvailabilityError`. +- **Concrete types are declared via extensions** on the leaf namespace. + Conformances may be separate extensions on the qualified path. +- **One non-private type per file.** Private helper types may co-locate when they + exist only to support the main type. +- **File naming.** A file declaring `extension Foo.Bar { public struct X }` is + named `Foo.Bar.X.swift` (dots, matching the qualified name). Anchor files + contain only the namespace declaration, no implementation. + +## Concurrency + +- Swift 6 strict concurrency is on. Types crossing concurrency boundaries are + `Sendable`; prove it, do not silence it with `@unchecked` unless you have a + documented reason. +- Shared mutable state goes in an `actor`. UI-affine state is `@MainActor`. +- Use structured concurrency. No arbitrary `Task.sleep` to paper over ordering. + +## Cross-platform + +Tiledown builds and runs on Apple platforms and Linux, with Windows where explicitly noted. The TileKit engine itself targets macOS and Linux; the broader cross-platform discipline (and the CI matrix in [cross-platform.md](cross-platform.md)) extends to Windows for code that opts into it. + +- Guard platform-divergent code and abstract platform-specific dependencies behind + a protocol seam: one implementation per platform, wired by the composition root. + Subprocess use is allowed. +- Prefer pure-Swift implementations over Foundation-only conveniences in the core. +- On the server side, prefer cross-platform libraries (for example AsyncHTTPClient + over URLSession, swift-log over os.log). + +## Testing + +- Use the **Swift Testing** framework: `@Test`, `@Suite`, `#expect`. Not XCTest. +- One behaviour per test. Descriptive names. Deterministic data. No live network + or filesystem dependence; inject test doubles. +- One test target per source target, named `Tests`. +- Use parameterised tests (`@Test(arguments:)`) for families of similar cases. + +## Verification before "done" + +The discipline lives in [`core/verification.md`](rules/core/verification.md): no +claim of done, fixed, or passing without fresh command output in the same response. +For Swift, match the command to the claim: + +| Claim | Command | +|---|---| +| Build succeeds | `swift build` | +| Tests pass | `swift test` (cite the count, e.g. `42 / 42`) | +| Bug fixed | the test that reproduced it, now passing | + +"Looks good" and "should pass" are not evidence. + +## Commits, branches, PRs + +See [`core/commits.md`](rules/core/commits.md) and +[`core/git-discipline.md`](rules/core/git-discipline.md), and the project's +[CONTRIBUTING.md](CONTRIBUTING.md). In short: Conventional Commits, one focused +change per PR, a `CHANGELOG.md` entry for any change touching shipping source, and +no AI attribution or em dashes in any committed text. diff --git a/docs/rules-swift/LICENSE b/docs/rules-swift/LICENSE new file mode 100644 index 0000000..ac99645 --- /dev/null +++ b/docs/rules-swift/LICENSE @@ -0,0 +1,35 @@ +Copyright (c) 2026 Mihaela Mihaljevic. + +This repository is dual-licensed by content type. + +PROSE (the rule documents, all .md files) + Licensed under the Creative Commons Attribution 4.0 International License + (CC BY 4.0). You are free to share and adapt the material, including + commercially, provided you give appropriate credit. Full text: + https://creativecommons.org/licenses/by/4.0/legalcode + SPDX-License-Identifier: CC-BY-4.0 + +CODE (any scripts, hooks, or sample configuration) + Licensed under the MIT License, reproduced below. + SPDX-License-Identifier: MIT + +---------------------------------------------------------------------- +MIT License + +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/docs/rules-swift/PROVENANCE.md b/docs/rules-swift/PROVENANCE.md new file mode 100644 index 0000000..055f7fc --- /dev/null +++ b/docs/rules-swift/PROVENANCE.md @@ -0,0 +1,43 @@ +# Vendored: rules-swift canon + +This directory is a vendored, read-only mirror of the shared Swift rules canon. +Do not edit files here. Change the rules upstream, then re-vendor. + +## Source + +- Repository: https://github.com/mihaelamj/rules-swift +- Commit: `4ebb8923aa636b5280ae9be225e19968303e1889` (`4ebb892`) +- Upstream commit date: 2026-06-16 +- Vendored into PureYAML: 2026-06-26 + +## What is included + +The rule content verbatim: every top-level rule document plus the `core/`, +`exp/`, `ui/`, and `scripts/` subtrees, and the upstream `README.md`, +`CONTRIBUTING.md`, `CONVENTIONS.md`, `CHANGELOG.md`, and `LICENSE` for context +and attribution. + +## What is excluded + +The upstream repository's own VCS, CI, and tooling configuration, which is that +repository's machinery rather than rule content and would conflict with +PureYAML's own setup: + +- `.git/`, `.github/`, `.githooks/` +- `.gitignore`, `.pre-commit-config.yaml` +- `.swiftformat`, `.swiftlint.yml` + +## Style-gate exclusion + +The whole tree is excluded from PureYAML's style gate +(`scripts/check-style.sh`) and the `.githooks/pre-commit` hook. The canon's own +enforcement documents (`core/git-discipline.md`, `core/commits.md`) quote the +forbidden AI-attribution phrases in order to define and detect them, the same +reason PureYAML excludes its own enforcement files from the scan. + +## Relationship to `docs/rules/` + +`docs/rules/` holds PureYAML's tailored, repo-specific rule set, which is what +`CLAUDE.md` and `AGENTS.md` point coding agents to. This `docs/rules-swift/` +tree is the upstream canon those tailored rules derive from, kept for reference +and audit. diff --git a/docs/rules-swift/README.md b/docs/rules-swift/README.md new file mode 100644 index 0000000..b6f1629 --- /dev/null +++ b/docs/rules-swift/README.md @@ -0,0 +1,144 @@ +# Swift coding rules (canonical) + +The canonical, scrubbed Swift coding rules. This is the **Swift domain**: the +language- and platform-specific craft of writing Swift well. It sits on top of a +vendored **core spine** of cross-cutting engineering discipline (see +[`core/`](core/)). + +Each file here is one Swift rule area. `CONVENTIONS.md` is the short overview; this +repository is the full set, with the cross-cutting discipline vendored under +[`core/`](core/). Examples use a sample tile-based static site generator named +Tiledown (the `TileKit` library plus the `tile-down` executable); replace the +example names with your project's when you adopt these. + +## The core spine + +The cross-cutting engineering discipline that governs *how* work is done and +reported, independent of language or domain, lives in [`core/`](core/). The Swift +rules here do not duplicate it; they build on it. Read the core spine first: + +- `core/no-shortcuts-first-principles.md` - the core ethic: no shortcuts, derive + from first principles, hold to Knuth's standard of care. +- `core/proof-discipline.md` - framing a correctness claim as separately provable + layers, each with an explicit epistemic status. +- `core/round-trip-transformation.md` - any two-way transform (parse/print, + encode/decode) is one invertible description, proven by the round-trip law. +- `core/first-principles-analysis.md` - depth target for analysis, plus + measurement discipline (every number traceable to a command). +- `core/verification.md` - no "done" without fresh command output. +- `core/testing-discipline.md` - real tests on every change; a build is not a test. +- `core/systematic-debugging.md` - reproduce, isolate, explain, then fix. +- `core/brainstorming.md` and `core/writing-plans.md` - design and planning gates + before non-trivial work. +- `core/commits.md`, `core/git-discipline.md` - Conventional Commits; issues, + labels, branches, PRs, remotes. +- `core/file-naming.md`, `core/folder-grouping.md` - filename conventions and when + to flatten one-file folders. +- `core/rules.md`, `core/self-improve.md` - authoring rules for an agent, and when + a recurring correction should become one. + +## Where an app starts + +Before any UI framework is named, an app starts at its business rules and the +domain that enforces them. + +- [business-rules-and-constraints.md](business-rules-and-constraints.md) - + MANDATORY, the first artifact: the business rules written as explicit checkable + statements, plus the constraints that bound them (which external REST services + and their contracts, what compliance, which auth model), defined up front. +- [domain-first.md](domain-first.md) - MANDATORY: an app starts at the domain and + business rules, framework-free and headless-provable; the UI framework is the + last, reversible, and plural choice, never the first decision. + +## Language and platform craft (engine, today) + +- [code-style.md](code-style.md) - namespacing discipline, file naming, + one-type-per-file. +- [namespacing.md](namespacing.md) - caseless `enum` vs `struct` vs `class` for + namespace anchors. +- [dependency-injection.md](dependency-injection.md) - no singletons, inject every + collaborator through `init`, protocol seams. +- [concurrency.md](concurrency.md) - Swift 6 strict concurrency: `Sendable`, + actors, `@MainActor`. +- [cross-platform.md](cross-platform.md) - the same sources build on Apple + platforms and Linux (and Windows where noted); guard platform-divergent code + behind a protocol seam. +- [linux-server.md](linux-server.md) - server-side operational rules for the + `serve` command and any networking. +- [testing.md](testing.md) - Swift Testing, `@Test` / `#expect`, test isolation. +- [formatting-and-linting.md](formatting-and-linting.md) - SwiftFormat and + SwiftLint, enforced by a pre-commit hook and again in CI. +- [documentation.md](documentation.md) - DocC catalogs and `///` requirements. +- [documentation-search.md](documentation-search.md) - verify API facts against an + authoritative documentation index, not from memory: query it with keywords and + read the results for Apple-API facts; it is a search engine, not an agent. +- [framework-policy.md](framework-policy.md) - stay inside Apple's own SDKs and the + Swift-native ecosystem; Linux falls back to C or C++ only. + +## Packages + +- [package-structure.md](package-structure.md) - workspace and package layout: one + `Package.swift` under `Packages/`, many targets, `Apps/` for app targets. +- [package-architecture.md](package-architecture.md) - single-responsibility + targets with unidirectional dependencies. +- [package-import-contract.md](package-import-contract.md) - per-target allowed + imports; applies now, the engine and CLI are already two targets. +- [shared-protocols.md](shared-protocols.md) - the cross-target protocol seam. +- [openapi-generated.md](openapi-generated.md) - rules for code generated from an + OpenAPI document and the hand-written code that surrounds it. + +## ExtremePackaging + +The granular packaging doctrine lives under [`exp/`](exp/): a monorepo split into +many single-responsibility SPM packages with explicit, unidirectional dependencies, +for isolated compilation, parallel builds, and per-package testability. The two +core files are loaded on every Swift session; the rest load on demand. + +- [exp/critical-rules.md](exp/critical-rules.md) - core: the five rules that gate + every package decision (single responsibility, explicit deps, granularity, + naming, layer architecture). +- [exp/when-to-create.md](exp/when-to-create.md) - core: the decision tree and + DO/DON'T list for when a new package is warranted. +- [exp/implementation-patterns.md](exp/implementation-patterns.md) - patterns for + foundation, feature, middleware, API, component, and font packages, and for + aggregators. +- [exp/dependency-management.md](exp/dependency-management.md) - cross-package + dependencies, platform-conditional code, avoiding cycles. +- [exp/package-swift.md](exp/package-swift.md) - the closure-with-local-variables + pattern for the `deps`, `allProducts`, and `targets` arrays. +- [exp/common-mistakes.md](exp/common-mistakes.md) - anti-patterns paired with the + canonical fix, for code review of `Package.swift` changes. +- [exp/app-target.md](exp/app-target.md) - adding or modifying an app target + (SwiftUI, UIKit, AppKit entry points). +- [exp/testing.md](exp/testing.md) - the per-package test target and cross-package + test doubles (architecture angle; full rules in [testing.md](testing.md)). +- [exp/migration.md](exp/migration.md) - extracting code into a new package or + splitting a large one. +- [exp/build-performance.md](exp/build-performance.md) - the build-time rationale + behind the granularity. +- [exp/verification.md](exp/verification.md) - the final checklist before an + ExtremePackaging change is considered done. + +## Parsing and validation + +- [parsing-rules.md](parsing-rules.md) - parse first, validate second; the + OpenAPIKit parsing idiom over any structured input. +- [validation-rules.md](validation-rules.md) - composable `Validation` + values, never imperative if-trees; every public type is validated or excluded + with a reason, enforced mechanically. + +## UI + +The UI rules live under [`ui/`](ui/). Any code with UI keeps the renderer thin over a framework-free model, and tests the flows from a shared scenario corpus. + +- [ui/pre-ui-layer.md](ui/pre-ui-layer.md) - MANDATORY: all display state and behavior in a framework-free, value-typed model (Domain + Surface); one `perform(Intent)` write channel; renderers are the only UI import, gated mechanically. +- [ui/three-renderers.md](ui/three-renderers.md) - build the same UI in all three Apple SDKs (SwiftUI, UIKit, AppKit) over the one model: the architecture's payoff and a comparison instrument for which framework behaves best in a scenario. +- [ui/swiftui-views.md](ui/swiftui-views.md) - the SwiftUI renderer: view architecture and identity. +- [ui/uikit-views.md](ui/uikit-views.md) - the UIKit renderer: `UIViewController`/`UIView` adapters, diffable data sources, re-pull on the change signal. +- [ui/appkit-views.md](ui/appkit-views.md) - the AppKit renderer: `NSViewController`/`NSView` adapters, `withObservationTracking`, programmatic menus. +- [ui/view-models.md](ui/view-models.md) - view-model responsibilities and patterns. +- [ui/components.md](ui/components.md) - the component system. +- [ui/colors.md](ui/colors.md) - the color system. +- [ui/fonts.md](ui/fonts.md) - font registration in SPM packages. +- [ui/pom.md](ui/pom.md) - Page Object Model for UI tests: screens behind page objects, identifiers from one shared source. +- [ui/flowspec.md](ui/flowspec.md) - declarative UI scenarios driven by [FlowSpec](https://github.com/mihaelamj/FlowSpec): one flow, many renderers, replayed headless and on-device. diff --git a/docs/rules-swift/business-rules-and-constraints.md b/docs/rules-swift/business-rules-and-constraints.md new file mode 100644 index 0000000..2f58e25 --- /dev/null +++ b/docs/rules-swift/business-rules-and-constraints.md @@ -0,0 +1,103 @@ +# Business Rules and Constraints (the starting artifact) + +**Status: MANDATORY, the first artifact of any app.** Before the domain model, +before any renderer, the starting point is a clear, explicit definition of the +**business rules** and the **constraints** that bound them. You cannot formalize a +domain you have not defined, and you cannot defer the renderer (see +`domain-first.md`) until you know what the app must do and what limits it operates +under. This artifact is the input the domain and engine are built from. + +## The two halves + +### 1. The business rules + +What the system must do, must guarantee, and must never allow, stated as explicit, +checkable statements, not as prose that sounds agreed but means something different +to each reader. "An order with no line items cannot be submitted" is a rule; "we +validate orders carefully" is not. Each rule names the correct state, in the +positive, the same way a `Validation` does (see `validation-rules.md`). +These statements become the domain invariants, the documented effect of each +engine intent, and the validations on every public surface type. A rule you cannot +state as a checkable sentence is a rule you have not finished defining. + +### 2. The constraints (the limitations), each defined up front + +The business rules do not float free; they run inside limits that shape the domain. +Name each as early as it is known; a constraint you cannot yet pin down is recorded +as an explicit open risk, never silently deferred until it surfaces mid-build: + +- **External services.** Which REST services (and other I/O) the app depends on, + and their contracts: endpoints, request and response payloads, status and error + modes, idempotency, pagination, rate limits. Each external service becomes a + data-layer port the engine reaches only through an injected, interface-segregated + seam (see `dependency-injection.md`); where the contract is an OpenAPI document, + generate the types and client from it (see `openapi-generated.md`). An undefined + service contract is an undefined data layer. +- **Compliance.** The regulatory and data-handling requirements the app is bound by: + data retention and residency, PII and consent, audit trails, and any + jurisdiction-specific rule. These are not features to add later; they harden into + domain invariants and validations that the build proves, and they constrain what + state is even representable. +- **Auth.** The authentication and authorization model, defined, not assumed. + *Authorization* (who may do what) shapes the read capability slices and the + enabled or disabled surface flags the engine computes, and it gates which intents + are permitted. *Authentication* (how identity is proven, how a session is held + and refreshed) shapes the data-layer seams and the session surface state. Leaving + auth implicit produces an app that is permissive by accident. + +## Why this is the starting point, not a later pass + +Each constraint determines the shape of the domain and the engine: a REST contract +defines a port, a compliance requirement defines an invariant, an authorization +rule defines which intents exist and when they are enabled. Define them first and +the domain is built to fit. Discover them late and you retrofit them through code +already written, threading auth through call sites that assumed none and bolting +compliance onto state that was never designed to carry it, which is the expensive, +error-prone path this rule exists to avoid. The clarity is also what makes the +domain formalizable at all: explicit rules and named limits are the structure the +rest of the discipline (validation as values, round-trip laws, proof discipline) +operates on. + +## DO + +- Write the business rules as explicit, positive, checkable statements before + modeling the domain; each becomes an invariant, an intent effect, or a validation. +- Enumerate the external services and their contracts up front; turn each into a + data-layer port, and generate from the OpenAPI document where one exists. +- Define the compliance requirements and the auth model (authentication and + authorization) as first-class constraints, before any feature is built on top. + +## DON'T + +- Do not start from vague intentions ("validate carefully," "secure the app"); a + rule that is not a checkable sentence is not yet defined. +- Do not begin building against an unnamed service contract, an unstated compliance + requirement, or an assumed auth model; name them first. +- Do not treat auth or compliance as a later hardening pass; retrofitting them + through written code is the failure this rule prevents. + +## Acceptance check + +A conforming project has, before its domain is modeled: (1) the business rules +written as explicit, positive, checkable statements, each traceable to an invariant, +an intent effect, or a validation; (2) every external service named with its +contract, each mapped to a data-layer port (generated from its OpenAPI document +where one exists); (3) the compliance requirements stated and expressed as +invariants or validations the build proves; and (4) the authentication and +authorization model defined, with authorization gating intents and capability +slices and authentication shaping the session seams. A project that begins coding +against undefined rules, an unnamed service contract, unstated compliance, or an +implicit auth model fails this rule even if it compiles. + +## Companion rules + +- `domain-first.md`: this artifact is what you start from; the renderer is deferred + until it is defined. +- `ui/pre-ui-layer.md`: the business rules become the engine's intent effects and + the domain invariants; the constraints become the data-layer ports. +- `validation-rules.md`: rules and constraints expressed as composable + `Validation` values; every public type validated or excluded with a + reason. +- `openapi-generated.md`: the REST service contracts as generated types and clients. +- `dependency-injection.md`: each external service reached only through an injected + protocol seam. diff --git a/docs/rules-swift/code-style.md b/docs/rules-swift/code-style.md new file mode 100644 index 0000000..5a6ba5b --- /dev/null +++ b/docs/rules-swift/code-style.md @@ -0,0 +1,362 @@ +# Code style: namespacing and structure + +The operational namespacing discipline for TileKit and the wider Tiledown codebase: folder mirroring, file naming, anchor placement, one-type-per-file, reserved names, namespace-vs-module collisions. + +Swift does not have true namespaces, so we simulate them with root types and extensions. + +For the narrower question "should the anchor be `enum`, `struct`, or `class`?", see [namespacing.md](namespacing.md) (the type-choice canonical rule). This file covers the broader operational discipline. + +## Namespacing discipline (mandatory) + +**Every public type lives under a struct or enum namespace that mirrors its folder on disk.** No public type stays at file scope. The qualified name carries module + folder + role; reading `Module.Sub.Leaf` should be enough to know where the type lives and what it does. + +This applies to every Swift project. The TileDown-style root + sub-namespace pattern below is one specific instance of this discipline. + +### Core rules + +1. **No exceptions.** Every `public struct / enum / class / actor / protocol` declared at file scope MUST be wrapped in an `extension { ... }` block (or declared inside a nested type). Free-standing top-level public types are not allowed. + +2. **Mirror the folder.** When a folder `Sources/Foo/Bar/` holds a type `X`, the type lives at `Foo.Bar.X`. The namespace tree is a literal reflection of the file system tree. + +3. **No name repetition.** When the parent namespace already supplies a word, drop it from the type's bare name. Names should not carry redundant context. + + | Before | After | Reason | + |---|---|---| + | `TileError` | `Tile.Error` | parent says `Tile` | + | `RenderProgress` | `Render.Progress` | parent says `Render` | + | `ParserToken` | `Parser.Token` | parent says `Parser` | + | `BuildCommand` | `Build.Command.Run` | both `Build` and `Command` are supplied | + | `SiteBuildCommand` | `Build.Command.Site` | drops `Build` AND `Command` | + +4. **Subnamespace anchor.** Each subnamespace MUST be declared exactly once in a single anchor file (typically `.swift` next to its sibling subnamespaces). Empty enums are the canonical form. The root namespace file is a *map*, not an implementation. + +5. **Concrete types via extensions.** Types implement via `extension Namespace.Sub { public struct/enum/class/actor/protocol X { ... } }`. Conformances (`Codable`, `Sendable`, `Identifiable`, etc.) MAY be separate extensions on the qualified path: `extension Namespace.Sub.X: Codable { ... }`. + +6. **`public extension` for protocol defaults.** Default-implementation extensions on a nested protocol use the qualified path: `public extension Module.Sub.MyProtocol { func defaultImpl() { ... } }`. Bare `extension MyProtocol` no longer resolves once the type is nested. + +### Cross-cutting namespaces + +Some concepts span multiple modules, e.g. *Tile* touches parsing, rendering, indexing, and formatting. When the cross-cutting concept is more semantically primary than the per-module organisation, lift the related types into a cross-cutting namespace **with a sub-namespace per source module**: + +```swift +Tile +├─ Parse (was Parse.TileParser) +│ └─ Parser +├─ Core (was Core.TileCatalog etc.) +│ ├─ Catalog +│ ├─ Entry +│ └─ Statistics +├─ Render (was Render.TileRenderService etc.) +│ ├─ Service +│ ├─ Query +│ └─ Result +├─ Index (was the entire TileIndex SPM target) +└─ Format (was Format.Tile*) + ├─ Markdown + ├─ JSON + └─ Text +``` + +The sub-namespace name preserves provenance (which module the type used to live in). The cross-cutting namespace lives in the lowest common ancestor that every consumer already imports, typically a `Shared*` foundation target. + +### Command-tool pattern + +Subcommand types in CLI binaries (`AsyncParsableCommand` conformers) nest under a `Command` namespace: + +```swift +// In the main CLI binary: +enum Command {} // declared in Commands/Command.swift +extension Command { + struct Build: AsyncParsableCommand { ... } // was BuildCommand + struct Serve: AsyncParsableCommand { ... } // was ServeCommand + struct Watch: AsyncParsableCommand { ... } // was WatchCommand +} + +// In sibling CLI binaries, the outer namespace is the tool's own name: +enum Release { + enum Command {} // declared in ReleaseTool/Release.swift +} +extension Release.Command { + struct Bump: AsyncParsableCommand { ... } // was BumpCommand + struct Database: AsyncParsableCommand { ... } // was DatabaseReleaseCommand +} +``` + +The root dispatcher (the `@main AsyncParsableCommand` that holds `subcommands: [...]`) stays at file scope; it's not a subcommand. `swift-argument-parser` conformance stays on every struct. + +### Namespace-vs-module collisions + +When a nested namespace shares a name with an imported SPM target (e.g. the CLI declares `Command.Render` but also imports the `Render` SPM target), Swift's name lookup checks enclosing types before imported modules. Bare `Render.Type` inside any `extension Command { ... }` scope resolves to the local subcommand struct, not the module. + +Fix: declare **one module-internal typealias** in a small alias file at the consuming target's root, pinning the SPM target under a renamed identifier: + +```swift +// Sources/CLI/RenderModuleAlias.swift +import Render +typealias RenderModule = Render +``` + +Then call sites in the affected target write `RenderModule.Type` for module-level references. One declaration covers every consumer in the target; no per-file typealiases. + +Same pattern for any `Module / Namespace` name clash. + +### Same leaf name under different roots + +Two unrelated namespaces under different roots MAY share a leaf name. Example: `Parser.CSV` (top-level format-parser namespace holding `CSV`/`HTML`/`XML`/etc.) plus `Core.Parser.Encoding` (parser-primitives sub-namespace under `Core`). Swift resolves on the fully-qualified path; the compiler never gets confused. The only friction is the cognitive cost at the callsite of remembering which root a reader means. + +When this emerges, prefer **renaming one of the two roots** to a more specific concept (e.g. `Core/Parser/` to `Core/Parsing/` so the leaf names diverge) over inserting per-file disambiguating typealiases. Typealiases create a per-target rename that has to be maintained; folder renames are cheap once-only moves. + +If renaming is genuinely not worth it (e.g. the two roots have stable independent meanings and most readers never see them in the same file), the collision is acceptable. The case is *cognitive*, not *syntactic*. + +### Reserved names + +- **`Protocol`** is a reserved member on every type expression (the metatype literal `T.Protocol`). A namespace enum cannot be named `Protocol`. Use `Protocols` (plural), `Wire`, or `Spec`. The folder on disk MAY still be named `Protocol/`; only the Swift-level namespace name diverges. +- **`Error`** as a nested type name collides with `Swift.Error` in `catch` patterns. Choices: + - Keep as a sibling: `Module.Error` (e.g. `Tile.RenderError`) + - Or nest with full Swift.Error qualification: `extension Module. { public enum Error: Swift.Error, ... }`, and use `catch let error as Module..Error` explicitly at call sites +- **Self-naming sub-namespace conflict.** If a sub-namespace and the type inside share a name (e.g. `Transport` namespace holding a `Transport` protocol), rename the inner type (`Transport.Channel`, `Transport.Service`). Don't try `Transport.Transport`. + +### Multi-target and mono-repo behaviour + +Multiple SwiftPM targets MAY contribute additional concrete types under the same shared root namespace via extensions: + +```text +TileDown.Parser.Model.Token // from TileKitCore +TileDown.Generator.Command // from TileKitCLI +TileDown.Parser.Scanning.Scanner // from TileKitTools +``` + +Targets MUST NOT redefine conflicting namespace trees, and the root namespace meaning MUST be identical across all modules. + +## Root + sub-namespace pattern (TileDown style) + +This is one specific instance of the namespacing discipline above. Use it as the canonical example when designing a new project's namespace tree. + +A project MAY use a hierarchical namespace tree where: + +- A root namespace enum defines the domain +- Sub-namespaces are nested enums inside it +- All concrete types live in extensions on the leaf namespace + +### Root namespace with nested sub-namespaces + +The root namespace file MUST define the full tree of namespace enums, and MUST contain only namespaces (no concrete types). + +Example: + +```swift +/// Root namespace for all TileDown types across all modules +public enum TileDown { + /// Model types for tile metadata and structure + public enum Model {} + + /// Parser types for processing tiles + public enum Parser { + /// Model types for parsing (tokens, transformations, etc.) + public enum Model {} + + /// Metadata parsing types + public enum Metadata {} + + /// Resolution and variable context types + public enum Resolution {} + + /// Scanning and tile discovery types + public enum Scanning {} + + /// Tree building and hierarchy types + public enum Tree {} + + /// Content and variable parsing types + public enum Content {} + } + + /// Generator types for writing output + public enum Generator {} +} +``` + +Rules: + +- Root namespace = one file (e.g. `TileDown.swift`) +- Only namespace enums inside (no concrete logic) +- No stored properties, no methods, no initializers, no nested concrete types +- Doc comments are allowed and encouraged + +This file is a map, not an implementation. + +### Concrete types via extensions on leaf namespaces + +Concrete model, parsing, resolving, generating, or utility types MUST be implemented using `extension` on the leaf namespace where the type belongs. + +Example: + +```swift +// Token.swift +import Foundation +import Models + +extension TileDown.Parser.Model { + /// Represents a parsed piece of tile content + public enum Token: Equatable, Sendable { + case text(String) + case variable(VariableToken) + } +} + +extension TileDown.Parser.Model.Token: CustomStringConvertible { + public var description: String { + switch self { + case .text(let str): return "Text(\"\(str)\")" + case .variable(let token): return "Variable(\(token))" + } + } +} +``` + +Rules: + +- Implementation and conformances MUST be extensions +- Type lives under the correct semantic path, e.g.: + - `TileDown.Model` + - `TileDown.Parser.Model` + - `TileDown.Parser.Metadata` + - `TileDown.Generator` +- Conformances (`Codable`, `Sendable`, `CustomStringConvertible`, etc.) MAY be separate extensions + +### File layout for TileDown-style namespacing + +Files SHOULD be placed to reflect the namespace tree. Two equivalent conventions: + +**Folder-based approach**: the folder tree mirrors the namespace tree, one file per leaf type: + +```text +TileDown/ + TileDown.swift // namespace anchor: public enum TileDown {} + Parser/Model/Token.swift // extension TileDown.Parser.Model { public enum Token {} } + Parser/Scanning/Scanner.swift // extension TileDown.Parser.Scanning { public struct Scanner {} } + Generator/Writer.swift // extension TileDown.Generator { public struct Writer {} } +``` + +**`..swift` approach**: flat folder, file name encodes the namespace path with dots: + +```text +TileDown.swift // namespace anchor: public enum TileDown {} +TileDown.Parser.Model.Token.swift // extension TileDown.Parser.Model { public enum Token {} } +TileDown.Parser.Metadata.Header.swift // extension TileDown.Parser.Metadata { public struct Header {} } +``` + +Within a single SPM target, pick ONE pattern and stay with it. + +### File-naming rule for namespace extensions (mandatory) + +When a file contains `extension { public X { ... } }`, the file name MUST be `..swift`, the dot-separated qualified path of the type. This applies regardless of whether the type would also be unique without the `.` suffix. + +Examples: + +- `extension Indexer { public enum TilesService { ... } }` to `Indexer.TilesService.swift` +- `extension Indexer { public enum PackagesService { ... } }` to `Indexer.PackagesService.swift` +- `extension Tile.Index { public actor Builder { ... } }` to `Tile.Index.Builder.swift` +- `extension Core.Protocols { public protocol ContentFetcher { ... } }` to `Core.Protocols.ContentFetcher.swift` + +Dots match the Swift qualified name exactly: `Tile.Index.Builder` lives at `Tile.Index.Builder.swift`. The older `+.swift` convention (with a `+` separator) is deprecated; new files MUST use dots, and pre-existing `+` files SHOULD be renamed when touched. + +Why uniformity even when not strictly needed: + +- The namespace path is visible from the file listing without opening any file. +- Adding a second type to the same namespace later doesn't require renaming the first. +- Search and tooling (Xcode's filter, `rg --files-with-matches`, the project navigator) work as a flat index on the qualified path. + +The only file in a namespace folder that does NOT carry `.` is the **namespace anchor**: + +- `TileDown.swift` declaring `public enum TileDown { /* sub-namespaces */ }` +- `Tile.swift` declaring `public enum Tile { public enum Parse {} public enum Core {} ... }` + +Anchor files contain only namespace enums, never concrete types. + +#### Namespace anchor file placement (mandatory) + +The anchor file for a namespace MUST live at the **root of the namespace's owning folder**, not buried in a sub-target folder. + +When `` is the umbrella for several SPM sub-targets that each live in a child folder, the anchor file `.swift` goes at the parent folder root, alongside (not inside) the sub-target folders. The sub-target whose SPM `path:` spans the parent folder picks up the anchor file as part of its sources. + +Concrete example, a `TileKit` namespace umbrella: + +```text +Sources/TileKit/ +├── TileKit.swift // namespace anchor: extension TileKit { ... } <-- AT ROOT, not Core/TileKit.swift +├── Client/ // TileKitClient SPM target +├── Core/ // TileKitCore SPM target (path includes ../TileKit.swift) +│ ├── Protocol/ +│ ├── Server/ +│ └── Transport/ +├── SharedTools/ // TileKitSharedTools SPM target +└── Support/ // TileKitSupport SPM target +``` + +The TileKitCore target's `Package.swift` entry uses `path: "Sources/TileKit"` with `exclude: ["Client", "SharedTools", "Support"]` so it picks up the anchor file plus the `Core/` subtree. Every sibling target (Client, SharedTools, Support) depends on TileKitCore in production, so the `TileKit` namespace anchor reaches them through that dep. + +Why this matters: + +- Reading the file listing immediately tells you "TileKit is the umbrella here": the anchor sits visually at the top. +- Moving an anchor into `Core/TileKit.swift` (or any other sub-target folder) hides the umbrella relationship behind one extra `cd` and one extra mental indirection. +- The same parent-folder pattern applies to `Shared` (anchor at `Sources/Shared/Shared.swift`, not `Sources/Shared/Constants/Shared.swift`), and to any future grouped target family. + +For cross-cutting namespaces (e.g. a `Tile` namespace that touches multiple SPM targets and is not owned by any single folder), the anchor lives in the lowest-leaf foundation target that every consumer reaches, AT THAT TARGET'S FOLDER ROOT, not in a sub-folder: e.g. `Sources/Shared/Tile.swift` (alongside `Shared.swift`), not `Sources/Shared/Constants/Tile.swift`. + +#### One non-private type per file (mandatory) + +Each file contains **exactly one** `public`, `package`, or `internal` (default-visibility) top-level type. Private/fileprivate helper types MAY co-locate with the main type when they exist solely to support it. This is enforced by `scripts/check-namespacing.sh` locally (pre-push) and in CI; see [verification.md](verification.md). + +Strict reading: + +- A file `Foo.Bar.X.swift` declares exactly one of `extension Foo.Bar { public enum X { ... } }`, `... public struct X`, `... public protocol X`, `... public actor X`, `... public class X`, etc. +- Inner / nested types DECLARED INSIDE the main type (`extension Foo.Bar.X { public enum Error { ... } }` or `extension Foo.Bar.X { public struct Stats { ... } }`) MAY live in the same file. Each nested type also goes in its own file once it grows beyond ~50 lines or gains independent reuse. +- File-private decode-only Codable models, internal-detail helper structs, etc. (anything you'd mark `private` or `fileprivate` so it cannot leak), MAY share the file. Mark them with the access modifier explicitly; don't rely on default-internal access. + +Anti-pattern to avoid: + +```swift +// Bad: Core.Parser.HTML.swift declares two unrelated public types +extension Core.Parser { + public struct HTML: ContentTransformer { ... } +} + +extension Core.Parser { + public struct XML: ContentTransformer { ... } // <-- belongs in its own file Core.Parser.XML.swift +} +``` + +Fix: + +```swift +// Core.Parser.HTML.swift: one type +extension Core.Parser { + public struct HTML: ContentTransformer { ... } +} + +// Core.Parser.HTML.Error.swift: nested type that grew (or extract inline if small) +extension Core.Parser.HTML { + public enum Error: Swift.Error { ... } +} + +// Core.Parser.XML.swift: sibling type, own file +extension Core.Parser { + public struct XML: ContentTransformer { ... } +} +``` + +Acceptance check: + +```bash +for f in $(find Packages/Sources -name "*.swift"); do + count=$(grep -cE "^(public |package |internal )?(actor|struct|enum|protocol|class|final class) [A-Z]" "$f") + [ "$count" -gt 1 ] && echo "$count $f" +done +``` + +Output MUST be empty; any reported file is a violation. Add the `private`/`fileprivate` modifier to helper types so they drop out of the count, or split the file. + +#### File renames ship in their own PR + +Type renames (`TilesIndexerService` to `Indexer.TilesService`) and file renames (`TilesIndexerService.swift` to `Indexer.TilesService.swift`) are different PRs. Type-rename first; easier review surface, can revert without filesystem churn. File-rename follows once the type change is settled. diff --git a/docs/rules-swift/concurrency.md b/docs/rules-swift/concurrency.md new file mode 100644 index 0000000..e945946 --- /dev/null +++ b/docs/rules-swift/concurrency.md @@ -0,0 +1,235 @@ +# Swift 6 Strict Concurrency + +Concurrency posture for TileKit and Tiledown: immutability first, sparse actors, escape hatches always justified, grounded in the Swift Evolution proposals. + +Triggers: `async`, `await`, `actor`, `Sendable`, `@MainActor`, `Task`, `nonisolated`, `sending`, `AsyncSequence`, `AsyncStream`, `withTaskGroup`, `@unchecked`, isolation, data race, cross-actor. + +**Canonical source: Swift Evolution proposals.** Each rule below points at the proposal(s) that introduced and justify the feature. When in doubt about a rule, re-read the proposal's Motivation section. That is the load-bearing context, not the rule's summary here. + +## Overall posture + +Strict concurrency is achievable without region-based isolation, complex task hierarchies, or pervasive locking. The discipline is: + +1. **Immutability first.** Default to value-typed structs that get implicit `Sendable`. Reach for `actor` only when global mutable state genuinely exists. +2. **Sparse actor use.** Reserve `actor` for cross-task shared state (transports, file watchers, registries). Don't actor-ify every stateful type. +3. **`@MainActor` is rare in CLI/library code.** Wrap UI-touching types only. Don't smear `@MainActor` across non-UI code to "make it compile." +4. **`@unchecked Sendable` and `nonisolated(unsafe)` are escape hatches.** Every use carries a one-line comment justifying why it is safe. + +## Sendable + +**Rule.** Implicit > explicit > `@unchecked`. + +- Value types whose stored properties are all `Sendable` are **implicitly** `Sendable`. Don't restate. +- Add explicit `: Sendable` on **public** value types so the API surface declares intent: + ```swift + public struct Progress: Sendable { + public let current: Int + public let total: Int + } + ``` +- Error enums: always explicit. +- `@unchecked Sendable` only for wrappers around known-safe non-value types, with a one-line justification: + ```swift + // URLSession is thread-safe; this wrapper is stateless. + struct ContentFetcher: @unchecked Sendable { ... } + ``` +- Never combine `@unchecked Sendable` with hand-rolled `NSLock` / `DispatchQueue` in new code. If you need locks, use `actor`. + +**Why (SE-0302, Swift 5.5).** `Sendable` was introduced because passing arbitrary references across isolation boundaries makes data-race safety impossible to verify locally. The protocol turns *"is this safe to send across a concurrency domain?"* into a property the compiler can check by induction on stored properties. Implicit conformance for value types is the design intent: most structs should be `Sendable` without ceremony, because most value types compose `Sendable` parts. Explicit annotation on public types is for API clarity, not for the compiler. + +**Why (SE-0418, Swift 6.0): inferred `Sendable` for methods and key path literals.** Function values and key paths needed parallel inference. Before SE-0418, you had to mark every closure parameter `@Sendable` by hand even when the closure captured only `Sendable` state. Rule of thumb after this proposal: if every captured / referenced type is `Sendable`, the function is `Sendable`. Bookkeeping cost goes to zero. + +**Why (SE-0463, Swift 6.2): Obj-C completion handlers as `@Sendable`.** Objective-C completion handlers were imported without `@Sendable`, so calling Swift code from an ObjC framework had a hole in its concurrency proof. Swift 6.2 closes the hole. Concrete impact: bridging code that was previously `@unchecked Sendable` to silence the warning can now drop the `@unchecked`. + +**Why (SE-0331, Swift 5.6): unsafe pointers are NOT Sendable.** `Unsafe(Mutable)(Buffer)Pointer` originally conformed to `Sendable` unconditionally. SE-0331 removed that conformance because the inference rules made arbitrary types accidentally `Sendable` when they contained an unsafe pointer to non-`Sendable` data. Lesson: `Sendable`'s safety model only holds if every conformance is honest. Don't add `@unchecked Sendable` to wave away a real race. + +## Actors + +**Rule.** `actor` is for **cross-task shared mutable state**: transports, file watchers, registries with concurrent readers/writers. Not for everyday data. + +- Don't actor-ify data pipelines. A struct that gets copied is a better default than an actor that gets awaited. +- Inside an `actor`, methods are isolated by default. Mark synchronous reads of immutable storage or global resources as `nonisolated`: + ```swift + actor Screen { + let columns: Int // immutable, captured at init + nonisolated var size: (Int, Int) { // POSIX ioctl, no actor state + var ws = winsize() + _ = ioctl(STDOUT_FILENO, TIOCGWINSZ, &ws) + return (Int(ws.ws_col), Int(ws.ws_row)) + } + } + ``` + +**Why (SE-0306, Swift 5.5).** Actors exist because mutable state shared across concurrent contexts is the canonical source of data races. Before actors, the Swift solution was *"don't share mutable state"* (value semantics) or *"use a lock"* (manual). Neither composes with `async/await`. An `actor` provides a structured, compiler-enforced way to express "this state is mutable but accessed serially through a single isolation domain." The serialisation is the safety guarantee; reentrancy (multiple suspended tasks on one actor) is the cost. + +The reentrancy point matters for design: an `actor`'s state can change *between* `await` points inside the actor. Don't write `actor` methods that assume invariants survive a suspension. Re-read after every `await`. + +**Why (SE-0327, Swift 5.10): actor initialisation.** Originally, actor `init` rules were strict: every stored property had to be initialised before any method call. SE-0327 relaxed this so an actor's `init` can be synchronous and call its own (nonisolated) methods. Practical impact: actor init is *not* isolated until the first hop, so you can do synchronous setup work in `init` without `await`. + +## `@MainActor` + +**Rule.** Type-level on UI-owning types, method-level in tests, rarely on protocols. + +- **Type-level `@MainActor`** for types that own UI/AppKit/UIKit objects: + ```swift + @MainActor public final class WindowCoordinator { + private let window: NSWindow + ... + } + ``` +- **Method-level `@MainActor`** in test code where you want the test on main: + ```swift + @MainActor func testTileRendering() async throws { ... } + ``` +- **Protocol-level `@MainActor`** only when every conforming implementation truly needs main-thread isolation. Prefer leaving protocols isolation-agnostic. +- CLI / library code rarely needs `@MainActor`. If you reach for it in a non-UI target, ask whether you actually want `actor`. + +**Why (SE-0316, Swift 5.5): global actors.** Sometimes the right isolation is not per-instance (an `actor`) but per-application (the main thread). `@MainActor` is a global actor that says *"any type or method bearing this annotation runs on the main thread, full stop."* The motivating use case is UI frameworks: AppKit / UIKit are inherently main-thread, and `@MainActor` lifts that constraint into the type system so the compiler enforces it instead of runtime queue assertions. + +**Why (SE-0434, Swift 6.0): usability of global-actor-isolated types.** Before SE-0434, `@MainActor` types had awkward interactions: stored properties could not be read from non-isolated contexts even when they were `let` and `Sendable`, you could not conform `@MainActor` types to non-isolated protocols, and so on. SE-0434 closes those gaps. Practical impact: `@MainActor` is now significantly more ergonomic in Swift 6.0+. + +**Why (SE-0470, Swift 6.2): global-actor isolated conformances.** A type can now have a conformance that is itself isolated to a global actor. Lets you say "this type conforms to `Codable`, but its `Codable` requirements run on `@MainActor`." Niche, but unblocks cases where the underlying type stays UI-bound while still participating in serialization or other protocol contracts. + +## `nonisolated` and `nonisolated(unsafe)` + +**Rule.** `nonisolated` for methods that don't touch isolated state. `nonisolated(unsafe)` is the escape hatch for fire-and-forget globals or test escape hatches, always commented. + +- **`nonisolated` (safe)** on methods/properties of an isolated type that don't read isolated state: + ```swift + // POSIX file descriptors are global; no actor state read. + nonisolated func writeRaw(_ bytes: [UInt8]) { + _ = write(STDOUT_FILENO, bytes, bytes.count) + } + ``` +- **`nonisolated(unsafe)`** reserved for: + - Fire-and-forget logging state (read-mostly, last-write-wins is acceptable) + - Test-only escape hatches (stdin overrides, DI test seams) + + One-line comment justifying: + ```swift + // Logging is fire-and-forget; last-write-wins is acceptable for these flags. + nonisolated(unsafe) static var minimumLevel: Logging.Level = .info + ``` +- Never `nonisolated(unsafe)` to silence a warning on shared mutable state that needs synchronisation. Fix the design. + +**Why (SE-0412, Swift 6.0): strict concurrency for global variables.** Before SE-0412, global `var`s were a backdoor: they bypassed the actor model entirely and were a frequent source of data races in Swift 5 code. SE-0412 closes the backdoor by requiring global mutable state to be either (a) isolated to a global actor, (b) immutable (`let`), or (c) explicitly marked `nonisolated(unsafe)` with the developer accepting the responsibility. + +The `(unsafe)` marker is **intentionally ugly**. It is there so reviewers can grep for it and ask "is this race actually acceptable?" The proposal's stance: most globals should be `let`, some should be `@MainActor`-isolated, and `nonisolated(unsafe)` should be rare and justified. + +## Region-based isolation (`sending`) + +**Rule.** Almost never needed if you stay immutable-by-default. If you reach for `sending`, first ask whether the value can be made `Sendable` by construction (immutable struct of `Sendable` parts). + +**Why (SE-0414, Swift 6.0): region-based isolation.** Strict `Sendable` checking forbids passing non-`Sendable` values across isolation boundaries. But many real programs *create* a non-`Sendable` value in one isolation domain, then *hand it off* to another and stop using it themselves. That is a race-free pattern, but pre-0414, the compiler could not see it. + +Region-based isolation lets the compiler track "this value's region of access" and prove the hand-off is safe even though the type is not `Sendable`. The proposal's example: an actor builds up a non-`Sendable` mutable buffer, then transfers it to another actor. After the transfer, the original actor cannot reach the buffer; the receiving actor has exclusive access. + +**Why (SE-0430, Swift 6.0): `sending` parameter and result.** The `sending` annotation is how you express the transfer in an API signature: *"This parameter is sent to me, the caller cannot use it after this call. This result is sent to you, I cannot use it after returning."* The proposal preserves the semantics from SE-0414 but moves them from inferred regions to explicit annotations at API boundaries. + +Practical takeaway: most codebases do not need `sending`. It is for libraries that build non-`Sendable` graphs (parser ASTs, large mutable buffers) and need to hand them across actor boundaries. If your data is immutable, you do not need it. + +## Task patterns + +**Rule.** `Task { ... }` for cleanup and isolation hops. `Task.detached { @Sendable ... }` for bridging synchronous APIs. `withTaskGroup` for bounded parallelism. + +- **`Task { ... }`** inherits the caller's actor isolation: + ```swift + defer { Task { await transport.disconnect() } } // cleanup + Task { @MainActor in coordinator.onClose() } // hop to MainActor + ``` +- **`Task.detached { @Sendable ... }`** starts a new, unisolated task. Use only for bridging synchronous APIs (FileManager enumerators, blocking IO, legacy callbacks): + ```swift + Task.detached(priority: .userInitiated) { @Sendable () -> [URL] in + // sync-only FileManager work + } + ``` +- **`withTaskGroup { ... }`** for bounded parallelism. Always bound the group; never spawn unbounded. +- **Cancellation:** rely on `await` points to check cancellation implicitly. Explicit `try Task.checkCancellation()` at the top of long synchronous loops; `Task.isCancelled` for graceful bail-out without throwing. + +**Why, structured concurrency.** `Task { ... }` is the structured form: the task inherits priority and isolation from the spawning context, and its lifetime is bound to the enclosing scope. `Task.detached` is the unstructured form: a new, independent task with no inheritance. The default should be structured; reach for `Task.detached` only when you specifically need to break the inheritance chain (for example detaching from `@MainActor` to do sync work, or detaching from a parent that might be cancelled when the child should not be). + +**Why cancellation is cooperative.** Swift's cancellation model is intentionally **cooperative**: setting `Task.isCancelled` does not halt execution. The task must check the flag and exit. This is by design; abrupt cancellation makes resource cleanup impossible. Practical pattern: every `await` is a cancellation point (the framework checks for you); for long synchronous loops, add explicit `try Task.checkCancellation()` at iteration boundaries. + +## `AsyncSequence` and `AsyncStream` + +**Rule.** `AsyncStream` is the canonical channel for "events arrive over time." For single-consumer progress callbacks, prefer a `@Sendable (Progress) -> Void` closure. + +Typical shape: +```swift +actor Channel { + let messages: AsyncStream + private var continuation: AsyncStream.Continuation! + + init() { + (messages, continuation) = AsyncStream.makeStream() + } + + func send(_ m: Message) { continuation.yield(m) } +} +``` + +Back-pressure: pick a buffering policy deliberately (`.unbounded`, `.bufferingNewest(n)`, `.bufferingOldest(n)`). Default is unbounded, fine for low-rate event streams, dangerous for high-rate producers. + +**Why.** `AsyncSequence` generalises the iterator pattern over time. `AsyncStream` is the concrete bridge from "push" producers (callback APIs, delegate methods, system event sources) to "pull" consumers (`for await`). The split between protocol and concrete type lets stdlib types (network responses, file reads) and your own channels share the same `for await` syntax. + +When to **not** use `AsyncStream`: single-callback progress reporting. A `@Sendable (Progress) -> Void` closure passed at the call site is simpler, has no buffering question, and is easier to test. + +## Cross-actor calls + +**Rule.** Pass `Sendable` values, await across the boundary. Explicit `await MainActor.run { ... }` for event boundaries only. + +- Default: the compiler handles the isolation hop when you `await` an isolated method. +- Don't pass non-`Sendable` references across isolation domains. If you find yourself wanting to, either (a) make the type `Sendable`, (b) marshal data into a `Sendable` value, or (c) use `sending` (rare). +- `await MainActor.run { ... }` is for event boundaries (UI callback, terminal redraw), not as a workaround for "this code happens to need to be on main." + +**Why.** The actor model only delivers data-race safety if values crossing boundaries are `Sendable`. The compiler enforces this; you cannot "just pass it", you have to either prove safety (via `Sendable`) or accept responsibility (via `@unchecked`/`sending`). + +## Test concurrency + +**Rule.** Native `async throws` test methods. Actor-isolated state read via `await actor.value`. No `XCTestExpectation` in new code. + +- Async tests: + ```swift + @Test func renderer_returns_expected_output() async throws { + let index = await TileIndex.build(...) + let results = await index.search("foo") + #expect(results.count == 3) + } + ``` +- Actor under test: `await actorInstance.value` to read across the boundary. Use a dedicated `actor SendableCounter` test double when accumulating state from outside. +- `XCTestExpectation` / `fulfillment(of:)` only when interoperating with legacy callback APIs. Native async/await is simpler. + +## Anti-patterns + +Avoid in new Swift 6 code: + +- `DispatchQueue.main.async { ... }` → use `Task { @MainActor in ... }` or `await MainActor.run { ... }`. +- `NSLock` / `pthread_mutex` + `@unchecked Sendable` → use `actor`. +- `withCheckedContinuation` / `withUnsafeContinuation` → only when wrapping legacy callback APIs. +- `@MainActor` smeared across non-UI types to silence warnings → fix the design. +- `nonisolated(unsafe)` to bypass a warning without thinking about the race → fix the design. +- `Task.detached` as the default → use `Task { ... }` unless you specifically need unisolated execution. + +## Vision + +The current direction of Swift concurrency is captured in *"Improving the approachability of data-race safety"* (vision document, referenced by SE-0463, SE-0466, SE-0470). The vision: data-race safety should be the default, but the cost of opting in (annotations, refactors) should keep dropping. Each Swift 6.x release closes another gap that previously forced an `@unchecked` or a `@MainActor` smear. + +When designing a new type, ask: *"will this need annotations to be `Sendable`/isolated, or can I make the design itself simple enough that the compiler infers everything?"* The vision answer is the second. + +## Cross-references + +- `dependency-injection.md` rule 4: no closure typealiases at cross-module seams. Closures as method parameters / property values are still fine; only the named cross-module typealiases are banned. +- Point-Free Dependencies (within-module, closure-based with `@Sendable` closures) is an optional dependency-wiring choice, not required by these rules; plain constructor injection is the alternative. + +## Reading the proposals + +The rules above are summaries. The proposals' Motivation sections carry the full reasoning. When designing something non-trivial, read the relevant proposal end-to-end: + +- SE-0306: actors +- SE-0302: Sendable +- SE-0414: region-based isolation +- SE-0412: strict concurrency for globals + +The Swift Programming Language book's Concurrency chapter is the second canonical source. Apple also publishes a data-race-safety migration guide. + +When a rule above and a proposal disagree, the proposal wins. Update this file. diff --git a/docs/rules-swift/core/README.md b/docs/rules-swift/core/README.md new file mode 100644 index 0000000..cf3a27b --- /dev/null +++ b/docs/rules-swift/core/README.md @@ -0,0 +1,33 @@ +# Engineering discipline + +A domain-agnostic standard of care for building software. These rules govern *how* work is done and *how* it is reported, independent of language, platform, or domain. They are the foundation that the language- and domain-specific rule sets build on. + +The premise is simple and demanding: **choose the optimal path, not the fastest one.** Correctness is total, not "works on the input I tried." Claims are proven or measured, never hoped. Bad states are designed out, not patched after. Limits are disclosed, never hidden. + +## The rules + +| Rule | What it governs | +|---|---| +| `no-shortcuts-first-principles.md` | The core ethic: no shortcuts, derive from first principles, hold to Knuth's standard of care. | +| `proof-discipline.md` | Framing a correctness claim: decompose into provable layers, label each claim's status, treat references as fallible witnesses, name the unproven frontier. | +| `round-trip-transformation.md` | Build a bidirectional transform (parse/print, encode/decode, compile/decompile) as one invertible description; state the round-trip law and prove it, not hope the two directions agree. | +| `first-principles-analysis.md` | Depth target for analysis docs, plus measurement discipline (every number traceable to a command). | +| `verification.md` | No "done" without fresh evidence: run the check, cite the output. | +| `testing-discipline.md` | Real tests on every change; a build is not a test. | +| `systematic-debugging.md` | Reproduce, isolate, explain, then fix. Root cause before patch. | +| `brainstorming.md` | A short design gate before non-trivial implementation. | +| `writing-plans.md` | An ordered, verifiable plan before multi-step work. | +| `commits.md` | Conventional Commits. | +| `file-naming.md` | Lowercase, dashed, ASCII, ISO-dated filenames. | +| `folder-grouping.md` | One folder per unit of work; flatten noise; group siblings. | +| `git-discipline.md` | Issues, labels, branches, PRs, remotes. | +| `rules.md` | How to author rules for an AI coding agent. | +| `self-improve.md` | When a recurring correction should become a rule. | + +## How to use these + +Point your agent (or yourself) at this directory as always-loaded context. The ethic rules (`no-shortcuts-first-principles`, `proof-discipline`, `verification`, `testing-discipline`) apply on every task. The process rules (`brainstorming`, `writing-plans`, `commits`, `file-naming`) apply when their topic comes up. + +## License + +Prose under CC BY 4.0, any shipped code under MIT. See `LICENSE.md`. diff --git a/docs/rules-swift/core/brainstorming.md b/docs/rules-swift/core/brainstorming.md new file mode 100644 index 0000000..8cd5592 --- /dev/null +++ b/docs/rules-swift/core/brainstorming.md @@ -0,0 +1,109 @@ +# Brainstorming Before Code + +For any new feature, refactor, or change scoped beyond the trivial (as a rough heuristic, anything touching more than a couple of files, or more than one module or package), present a design and get approval before writing implementation code, scaffolding files, or running generators. Quick fixes (one file, an obvious diff, no new public surface) are exempt. + +## Core rules + +### Rule 1: The design gate + +Present a design and pause for approval before implementation. + +A design covers, at minimum: + +- **Goal**: one sentence. +- **Touched files / modules**: an explicit list with what changes in each. +- **Public surface**: any new types, interfaces, exported signatures, or API the change introduces. +- **Dependency direction**: confirm the change does not violate the project's layering or module-boundary rules. +- **Tests**: which existing tests cover this; which new tests are needed. +- **Migration / compatibility**: anything callers must change. +- **Risks / open questions**: at least one, even if minor. + +### Rule 2: Scope of the gate + +Apply the gate when: + +- A new module, package, or build target is being added. +- A public type or interface is being introduced. +- A user-facing screen, view, or top-level component is being added or replaced. +- A change crosses an architectural layer or module boundary. +- An API contract or schema definition is being modified. +- A migration touches data, schema, or persistence. + +**MAY skip** when: + +- Single file, obvious diff, no new public surface. +- A pure rename or extract that the requester already specified. +- A bug fix where the fix is forced by a failing test. + +When in doubt, present the design. The cost of pausing is low. + +### Rule 3: How to present + +Keep the design short enough to read in about 30 seconds. + +``` +Goal: + +Files: +- src/foo/Bar. (new, holds the BarClient interface) +- src/foo/BarClientLive. (new, real implementation) +- src/foo/BarClientFake. (new, test/preview double) + +Public surface: +- interface BarClient { fetch(...) -> Bar } +- error type BarError { ... } + +Dependencies: standard library only. No upward imports. + +Tests: +- BarClientTests covers fetch happy path, decode error, network error. +- Injects the fake double. + +Risks: +- Decoding from snake_case input: confirm whether a code generator is in + play here, or if this is a hand-rolled client. + +OK to proceed? +``` + +### Rule 4: Banned pre-approval actions + +**MUST NOT**, before approval: + +- Run a project initializer or any code generator. +- Create new files (other than scratch design notes). +- Edit existing source files. +- Run schema or API regeneration. +- Stage or commit anything. +- Spawn implementation subagents. + +You MAY, before approval: + +- Read the codebase to inform the design. +- Run searches to identify touched files. +- Run a build to confirm the current baseline compiles. + +### Rule 5: After approval + +Approval is for the SCOPE PRESENTED. If the implementation reveals a hidden case that changes scope: + +- Stop. +- Surface the divergence. +- Get fresh approval before continuing. + +Do not silently expand scope to "while I am here, I also fixed X." + +## When approval is skipped + +If a direct implementation command arrives without a design (e.g., "just add the new endpoint"), do the design pass anyway as a short, five-bullet message and ask once: +"OK to proceed with this, or do you want changes?" + +A single confirmation is enough; no full ceremony required. + +## Anti-patterns + +- Jumping into code on a "small" change that turns into eight files touched. +- Presenting code as the design ("here is the diff, looks good?"). +- Burying the design in a long preamble before the actual question. +- Designing in isolation without reading the existing patterns first. +- Treating silence as approval. diff --git a/docs/rules-swift/core/commits.md b/docs/rules-swift/core/commits.md new file mode 100644 index 0000000..92ca0bc --- /dev/null +++ b/docs/rules-swift/core/commits.md @@ -0,0 +1,366 @@ +# Git Commit Framework + +**Status: house convention for committed text.** Every commit follows the Conventional Commits specification: imperative mood, one logical change per commit, a message that communicates the what, the why, and the impact for both human readers and automated tooling. This is the companion to `file-naming.md` (the same discipline applied to committed filenames) and to `no-shortcuts-first-principles.md` (a commit that hides what it actually did is a shortcut). + +## Core rules + +### Rule 1: Commit format + +Use this exact format: + +``` +(): + +[optional body] + +[optional footer(s)] +``` + +- MUST use lowercase for the type. +- MUST use present tense, imperative mood ("add", not "added"). +- MUST NOT end the description with a period. +- MUST limit the first line to 72 characters. + +### Rule 2: Commit types + +Select the correct type: + +- `feat`: new feature or functionality. +- `fix`: bug fix or error correction. +- `docs`: documentation changes only. +- `style`: code style/formatting (no logic change). +- `refactor`: code restructuring (no behavior change). +- `perf`: performance improvements. +- `test`: test additions or corrections. +- `build`: build system or dependencies. +- `ci`: CI/CD configuration changes. +- `chore`: maintenance tasks. + +### Rule 3: Breaking changes + +Indicate breaking changes: + +- MUST add `!` after the type/scope for breaking changes. +- MUST include a `BREAKING CHANGE:` footer. +- MUST explain the migration path. + +### Rule 4: Commit scope + +Include a relevant scope: + +- Use a component/module name, a feature area, or a layer. +- Keep scopes consistent across the project. +- Omit the scope only when the change is truly global. + +## Commit type decision tree + +``` +What kind of change are you making? +|- Adding new capability? +| |- User-facing feature? -> feat +| |- Developer tool? -> build/chore +| +- Test coverage? -> test +|- Fixing something broken? +| |- Bug in code? -> fix +| |- Typo in docs? -> docs +| +- Test failure? -> test/fix +|- Changing existing code? +| |- Improving performance? -> perf +| |- Restructuring code? -> refactor +| |- Formatting only? -> style +| +- Updating dependencies? -> build ++- Project maintenance? + |- CI/CD changes? -> ci + |- Documentation? -> docs + +- Other tasks? -> chore +``` + +## Commit message patterns + +### Pattern 1: Feature commits + +``` +# New features state a clear user benefit +feat(auth): add OAuth2 authentication support + +# Include implementation details in the body +feat(api): implement rate limiting for API endpoints + +Adds configurable rate limiting: +- 100 requests per minute for anonymous users +- 1000 requests per minute for authenticated users +- Customizable limits per API key + +Closes #234 +``` + +### Pattern 2: Bug fix commits + +``` +# Describe what was broken and now works +fix(parser): handle empty input without crashing + +# Reference issue numbers +fix(ui): correct button alignment on mobile devices + +The submit button overlapped with the cancel button on +screens smaller than 375px. Added proper flex spacing. + +Fixes #456 +``` + +### Pattern 3: Breaking change commits + +``` +# Use ! and a BREAKING CHANGE footer +feat(api)!: change authentication from sessions to tokens + +BREAKING CHANGE: the API now requires bearer-token authentication. +Session-based auth has been removed. Update clients to send an +Authorization header with the token. + +Migration guide: docs/migration/v2.md +``` + +### Pattern 4: Refactoring commits + +``` +# Explain why, not just what +refactor(database): extract query builder into a separate type + +Improves testability and reduces coupling between the +repository and database layers. No functional changes. + +# Keep behavior identical +refactor(utils): use native collection methods instead of a helper + +Removes a dependency for collection operations. +All tests pass without modification. +``` + +### Pattern 5: Documentation commits + +``` +# Be specific about what was documented +docs(readme): add installation instructions for a fresh machine + +# Include scope for API docs +docs(api): document rate-limiting headers and status codes +``` + +### Pattern 6: Performance commits + +``` +# Include metrics when possible +perf(search): optimize full-text search query + +Reduces search time from ~500ms to ~50ms by adding a +compound index on (title, content, created_at). + +Benchmark results: +- Before: 487ms avg (n=1000) +- After: 52ms avg (n=1000) +``` + +## Commit scope guidelines + +Common scope patterns: + +- Component name: `(Button)`, `(Modal)`, `(Form)`. +- Feature area: `(auth)`, `(payment)`, `(search)`. +- Layer: `(api)`, `(db)`, `(ui)`, `(service)`. +- File type: `(config)`, `(types)`, `(tests)`. + +Omit the scope only for truly global changes (project-wide dependency updates, global configuration, cross-cutting refactors). + +For a multi-file change, use the most specific common scope: + +``` +# Changed files all under the auth area +feat(auth): add session timeout handling + +# Changed files span many unrelated areas +refactor: update imports to use path aliases +``` + +## Commit body guidelines + +Include a body when the change requires explanation: multiple issues addressed, breaking changes, available performance metrics, complex implementation details, or external references. + +Body format rules: + +- Separate the body from the subject with a blank line. +- Wrap the body at 72 characters. +- Use bullet points for multiple items. +- Explain why, not just what. + +``` +fix(cache): prevent memory leak in the LRU cache + +- Add proper cleanup on cache eviction +- Use weak references for cached objects +- Add a memory-limit configuration option + +The previous implementation held strong references to evicted +items, preventing reclamation. This change ensures proper memory +management while keeping O(1) operations. + +Fixes #789 +``` + +## Commit footer patterns + +### Issue references + +``` +fix(api): validate input before processing + +Fixes #123 # Closes the issue +Closes #456 # Also closes the issue +Resolves #789 # Also closes the issue +See #101 # References without closing +Related to #102 # References without closing +``` + +### Co-authors + +Credit every human contributor: + +``` +feat(ui): implement dark mode toggle + +Pair-programmed the styling architecture and state management. + +Co-authored-by: Jane Doe +Co-authored-by: Bob Smith +``` + +### Review references + +``` +refactor(engine): simplify state-machine logic + +See the detailed discussion in PR #234 about the +approach and the alternatives considered. + +Reviewed-by: Alice Johnson +``` + +## No tool attribution in committed text (MANDATORY) + +Committed text is authored by the people who made the change. Commit messages, PR descriptions, branch names, code comments, and any other text that lands in the repo name the human contributors and nothing else. The standard is one question: would a colleague reading this guess a tool wrote it? If yes, remove it. This is tool-agnostic, covering every coding assistant and its vendor, not any single one. + +Specifically forbidden in any commit message, PR description, branch name, file name, or code comment: + +- A `Co-Authored-By:` trailer, a "Generated with" / "Generated by" line, or any "created by" attribution that names a coding assistant or its vendor. In particular, strip the trailers these tools emit: `Co-Authored-By: Claude` and "Generated with Claude Code" (Claude / Anthropic), `Co-Authored-By: Cursor` / "Generated by Cursor" (Cursor), "Co-authored-by" / "Generated with" lines from Codex / ChatGPT / GPT (OpenAI), GitHub Copilot, and Gemini and Antigravity (Google). Several of these reinsert the trailer automatically even after being told not to, and do not honor editor- or settings-level attribution toggles, so removing them cannot rely on configuration alone. +- The exact emitted forms to delete on sight: a `🤖 Generated with Claude Code` line (robot emoji plus a tool link), a `Co-Authored-By: Claude ` or model-named (`Claude Opus`, `Claude Sonnet`, `GPT-4o`) co-author, any no-reply or bot email in a co-author trailer, and tool-named `Assisted-by:`, `Generated-by:`, or `Authored-by:` trailers. The config toggles (`attribution`, `includeCoAuthoredBy`) do not bind tools that ignore them and are overridden in practice, so they do not replace stripping the line. +- Promotional or assistive phrasing that credits tooling: "powered by", "made with", "built with", "with AI assistance", "AI-assisted", "AI-generated", "generated by AI", "with the help of", "courtesy of", "thanks to", "via", or "using" followed by a tool or vendor name, anywhere in a message, PR body, branch name, or comment. +- The name of any AI tool or vendor (Claude, Anthropic, Cursor, Codex, OpenAI, ChatGPT, GPT, Copilot, Gemini, Antigravity, or any other), or the words "AI", "LLM", "agent", "assistant", "automated", "machine-generated", "co-pilot", or "pair-programmer" (when no human pair existed), or any synonym or euphemism for tooling. +- Self-referential phrasing ("as an AI", "the agent did X", "automated cleanup") and any coded wink at tool authorship. +- Stylistic tells: emoji used as a signature or flourish (notably the 🤖 robot and ✨ sparkle), a trailing "generated" / "powered by", or watermarks (zero-width characters, hidden comments naming a tool, `-ai` / `-bot` / tool-name branch suffixes). + +Co-author trailers credit only the humans who collaborated on the change. The history reads as human-authored because the change is the contributors' work. + +Because several assistants reinsert attribution on their own, telling the tool not to is not enough. A project-level commit-message hook is REQUIRED, not optional: install it per clone (hooks live in `.git/` and are not cloned) via a tracked `core.hooksPath` or by copying the hook into `.git/hooks/commit-msg`. The hook is the only tool-agnostic enforcement that holds no matter which assistant wrote the message. See `git-discipline.md` Rule 5.1 for the same prohibition across the broader git surface. + +## Commit validation checklist + +Before committing, verify: + +- [ ] Type is correct (`feat`/`fix`/`docs`/etc.). +- [ ] Scope reflects the affected area. +- [ ] Description is in imperative mood. +- [ ] Description is under 72 characters. +- [ ] No period at the end of the description. +- [ ] Breaking changes marked with `!`. +- [ ] `BREAKING CHANGE:` footer present if needed. +- [ ] Body explains why, not just what. +- [ ] Body wrapped at 72 characters. +- [ ] Issue references use the correct keywords. +- [ ] Co-authors are humans, credited properly. +- [ ] No tool/assistant attribution anywhere in the text. +- [ ] Commit is atomic (one logical change). +- [ ] All tests pass. +- [ ] No debug code or stray logging. + +## Squash fixups before sharing + +A shared history is a sequence of logical changes, not a transcript of how you got there. Squash `fixup!`/`wip`/`oops` commits into the commit they belong to before pushing or opening a PR. One logical change is one commit. + +## Common mistakes to avoid + +### DON'T: use past tense + +``` +# WRONG +feat(auth): added login functionality + +# RIGHT +feat(auth): add login functionality +``` + +### DON'T: be vague + +``` +# WRONG +fix: fix bug +chore: update stuff +refactor: changes + +# RIGHT +fix(parser): handle null input in the JSON parser +chore(deps): update the HTTP client from 1.4.0 to 1.5.0 +refactor(auth): extract token validation into middleware +``` + +### DON'T: combine unrelated changes + +``` +# WRONG +feat(ui): add dark mode and fix login bug and update deps + +# RIGHT, split into separate commits: +feat(ui): add dark mode toggle +fix(auth): resolve login redirect issue +build(deps): update the framework and toolchain +``` + +### DON'T: forget breaking-change notation + +``` +# WRONG +feat(api): change response format + +# RIGHT +feat(api)!: change response format to follow the JSON:API spec + +BREAKING CHANGE: API responses now use the JSON:API format. +Old format: { data: [...] } +New format: { data: [...], meta: {}, links: {} } +``` + +## Branch naming + +Use a consistent branch-name format: + +``` +/- + +# Examples: +feat/123-add-oauth-support +fix/456-resolve-memory-leak +chore/update-dependencies +release/v2.0.0 +hotfix/critical-security-patch +``` + +Branch types mirror commit types: `feat/*`, `fix/*`, `docs/*`, `refactor/*`, `test/*`, `chore/*`. + +## Companion rules + +- `file-naming.md`: the same lowercase/dashed/ASCII/ISO discipline applied to committed filenames. +- `no-shortcuts-first-principles.md`: a commit message that hides what the change did, or bundles unrelated changes to look smaller, is a shortcut. + +## Why this exists + +A commit history is documentation that every future reader, and every automated tool, depends on. A consistent format makes the history greppable, the changelog generable, and the blame legible. The cost is a few seconds of discipline per commit; the payoff is a history that explains itself years later. diff --git a/docs/rules-swift/core/file-naming.md b/docs/rules-swift/core/file-naming.md new file mode 100644 index 0000000..b105e90 --- /dev/null +++ b/docs/rules-swift/core/file-naming.md @@ -0,0 +1,95 @@ +# File Naming Rules + +**Status: house convention for committed filenames.** Every file committed to the repository follows these conventions: lowercase, dash-separated, ASCII, ISO dates. This is the filename companion to `commits.md` (the same discipline applied to commit text): a name a future reader can predict, type, and glob without surprises. + +## General rules + +- **Lowercase only**: no uppercase letters in filenames. +- **Dashes for separators**: use `-` instead of spaces, underscores, or camelCase. +- **No spaces**: ever. +- **No special characters**: no accented letters, `()`, `[]`, `&`. Transliterate accented characters to plain ASCII (for example, c-with-caron to `c`, s-with-caron to `s`, d-with-stroke to `dj`). +- **No trailing dots**: remove any dot before the file extension. +- **ASCII only**: filenames must be plain ASCII. + +## Date format in filenames + +Always use the ISO format `YYYY-MM-DD`: + +- `30.11.2023` -> `2023-11-30` +- `11.03.2024.` -> `2024-03-11` +- `Mar 24, 2025` -> `2025-03-24` + +## Document naming patterns + +### Scanned documents + +``` +Scan MMM DD, YYYY at HH.MM.pdf -> scan-YYYY-MM-DD-HH-MM.pdf +Scan DD.MM.YYYY. at HH.MM.pdf -> scan-YYYY-MM-DD-HH-MM.pdf +Scan DD MMM YYYY at HH.MM.pdf -> scan-YYYY-MM-DD-HH-MM.pdf +``` + +### Photos + +``` +IMG_XXXX.jpeg -> keep as-is (acceptable) +IMG_XXXX.HEIC -> convert to IMG_XXXX.jpg +Photo DD-MM-YYYY.heic -> photo-YYYY-MM-DD.jpg (convert + rename) +``` + +### Dated documents + +``` +--YYYY-MM-DD. +``` + +Use a date suffix only when the document is date-specific; omit it otherwise. Neutral examples: + +``` +invoice-2024-03-01.pdf +design-notes-2025-06-14.md +release-checklist.md # not date-specific, no suffix +``` + +## Renaming existing files + +When renaming, use `git mv` to preserve history: + +```bash +git mv "Old File Name.pdf" "old-file-name.pdf" +``` + +Commit: `rename: normalize filenames` + +## Audit (before any processing) + +Run these in the repo before reporting counts: + +```bash +# Files with spaces +find -name "* *" -not -path "*/.git/*" -not -name "*.md" + +# Files with uppercase +find -regex ".*/[^/]*[A-Z][^/]*" -not -path "*/.git/*" -not -name "README*" -not -name "*.md" + +# Files with underscores (excluding .git) +find -name "*_*" -not -path "*/.git/*" -not -name "*.md" + +# HEIC files +find \( -name "*.heic" -o -name "*.HEIC" \) -not -path "*/.git/*" +``` + +A clean repo returns nothing from the first three queries (the README and `.md` exclusions cover the conventional exceptions). + +## Skip list + +Some asset paths legitimately carry vendor-supplied names and are exempt from the audit. Document the skip paths per repo (for example, browser-extension resource folders, generated asset bundles, vendor media dumps). Keep the list short and justified: every exemption is a name a reader cannot predict, so it must earn its place. + +## Companion rules + +- `commits.md`: the same lowercase/dashed/ASCII/ISO discipline applied to commit messages and branch names. +- `no-shortcuts-first-principles.md`: an unpredictable filename committed "just this once" is a small shortcut that compounds across a tree. + +## Why this exists + +Filenames are an interface. A predictable scheme means a reader can guess a path, a glob can match a set, and a sort orders by date without a parser. Mixed case, spaces, and locale-specific characters break globs, confuse case-insensitive filesystems, and force quoting. The convention costs nothing at creation time and saves every later reader. diff --git a/docs/rules-swift/core/first-principles-analysis.md b/docs/rules-swift/core/first-principles-analysis.md new file mode 100644 index 0000000..0e3047e --- /dev/null +++ b/docs/rules-swift/core/first-principles-analysis.md @@ -0,0 +1,210 @@ +# First-Principles Analysis + +**Status: applies to any deep documentation artifact, in any language or domain.** + +When asked to analyze code, audit specs, write a design document, or describe any non-trivial system, default to **first-principles depth**: explain the system as if the reader has zero prior context. Assume they don't know the language, the libraries, the domain, the regulation, or the file formats. Build everything up from the ground. + +This rule applies to: +- Code analysis docs (e.g. "analyze this library's code", "describe what this library does") +- Design docs (new feature, new subsystem, migration plan) +- Postmortems +- Architecture overviews +- Any "explain it to me" / "describe in plain English" / "go deeper" request + +Quick reference answers and inline comments are exempt. This rule kicks in once the artifact lives in `docs/`, `docs/design/`, or any repo-tracked file a reader expects to come back to. + +## Core principle + +If a competent engineer who has never touched this codebase before would still have unanswered questions after reading the doc, the doc is not done. **Depth, not breadth, is the deliverable.** + +Specifically, the doc must let the reader: +1. Understand *why* the system exists (domain, regulation, business driver). +2. Understand *what* each piece does without referring to the source code. +3. Trace a concrete piece of data end-to-end through the system. +4. Reimplement the system in a different language using only the doc as reference. + +If any of those four are not achievable, the doc has not gone deep enough. + +## What "first-principles" means here + +### 1. No assumed context + +Don't write "uses a schema-validation library" without explaining what that library is, why it matters, and what behavior it enforces. Don't write "a connection pool" without explaining what a connection pool is and why pooling matters. Don't write "per the 2025 reporting regulation" without explaining what that regulation actually requires. + +Rule of thumb: if a reader who's never seen this language/library/domain would have to look a term up, expand it inline (or in a Prerequisites section) instead of forcing the lookup. + +### 2. Prerequisites section up front + +Every first-principles doc has a Prerequisites section covering the technical primitives the rest of the doc relies on: + +- The programming language(s) involved (key idioms a reader of another language wouldn't recognize). +- The protocols (HTTP, gRPC, whatever). +- The file formats (CSV, XML, JSON, ZIP, columnar formats). +- The libraries (what they are, what they do, why they were chosen). +- The data model (databases, schemas, key invariants). +- The domain (regulation, market, business logic). +- The glossary (any non-English or jargon terms used in the source). + +The Prerequisites section is allowed to feel "long", that's the point. A reader can skip it if they already know the material; a newcomer cannot recover the missing context any other way. + +### 3. Worked examples with concrete values + +Every algorithm, every transformation, every parser must be walked through with **real input and real output**. Not pseudocode, not "assume some CSV". Pick one specific row, one specific URL, one specific filename, and trace it through. + +Example: when describing a number-parsing routine, don't just say "handles thousands separators". Show: +- Input: `"1,234.56"` → Output: `1234.56` (comma is the thousands separator) +- Input: `"1.234,56"` → Output: `1234.56` (period is the thousands separator) +- Input: `",99"` → Output: `0.99` (missing leading zero) +- Input: `""` with the value required → raises a validation error + +A reader who sees worked examples can predict edge cases. A reader who only sees the rule cannot. + +### 4. Why-each-design-choice rationale + +For every non-obvious design decision in the source code, the doc must explain *why* the author chose it. If the code uses a fixed-precision decimal type instead of a binary float, say why (precision for currency). If a crawler is synchronous instead of async, say why (latency-dominated, simplicity wins). If a remote endpoint's TLS verification is disabled, say why (broken cert chain) and link the file. + +Where the source code is genuinely silent on rationale (and you can't reconstruct it), say so explicitly: "the code doesn't justify this choice; the apparent reasoning is X but treat this as a guess." Better an honest guess than a false certainty. + +### 5. Failure modes catalogue + +Don't just describe the happy path. For each subsystem, list every realistic failure (network error, schema change, encoding ambiguity, race condition, malformed input) and the system's response. A reader who only knows the happy path will be surprised by every real-world incident. + +### 6. End-to-end worked example + +The doc must have an appendix walking **one concrete unit of data** from input to output, through every component. For a crawler: one product row from the source website to the consumer's API response. For an auth system: one login attempt from credentials to issued token. For a deployment pipeline: one commit from push to running container. + +This is the integration test in narrative form. It surfaces gaps that per-component descriptions miss. + +### 7. Reimplementation roadmap + +Every first-principles doc closes with a section titled "Reimplementation roadmap" or equivalent: an ordered list of how someone would port the system to a different language, with library equivalents, phase ordering, time estimates, and a callout of the *hard parts* (the 2-3 things a naive port would get wrong). + +This serves two purposes: +- It forces you to confirm you actually understand the system (you can't port what you don't understand). +- It gives the reader a sanity check on whether the doc is sufficient. + +## Length expectations + +A first-principles doc for a non-trivial system is typically **3000+ lines**. If your "deep dive" of a 5000-line codebase is under 1000 lines, you almost certainly skipped prerequisites, glossed over algorithms, or omitted worked examples. + +Length is not the goal. Comprehensiveness is. But systems of meaningful size produce docs of meaningful length, and tight prose still totals to thousands of lines once every algorithm has its worked example and every component has its quirks listed. + +When in doubt, more depth. The reader can skim; they can't conjure missing detail. + +## Anti-patterns + +These are the patterns that make a first-principles doc fail: + +- **Bullet-summary instead of prose.** "Uses HTTP, parses CSV, writes to DB." Tells nothing. Expand each bullet into a section. +- **Code without explanation.** Pasting a function and saying "this parses prices" is not analysis. Walk the algorithm. Explain the heuristics. Show inputs and outputs. +- **Pattern lists without per-instance detail.** "There are 8 patterns across 30 sources" is fine as scaffolding, but the doc still needs to walk each source individually, because the patterns hide per-source quirks (the broken TLS cert, the WAF block, the off-by-one date convention). +- **Skipping the boring parts.** The "trivial" CSV writer is exactly where a reimplementer trips up if they don't know the line-ending gotcha or the column-order convention. Document the trivial parts. +- **Assuming domain knowledge.** "Per the 2025 reporting regulation" without explaining the regulation. "EAN barcode" without explaining what an EAN is. "Connection pool" without explaining what pooling is. + +## Measurement discipline (claims must be reproducible) + +Every numeric or factual claim in a first-principles doc is a **claim**, not soft prose. A claim without a command behind it is fabrication, no matter how reasonable it reads. The reader cannot tell the difference between a measured number and a plausible-looking guess; the author has to. + +### The five categories of claim + +Every numeric or factual statement must fall into exactly one of these. Tag each in-doc (footnote-style or inline) so the reader can audit: + +- **MEASURED**: produced by a deterministic command run against data on disk. Record `(command, value, source-path, measured-on)`. +- **DERIVED**: arithmetic on other MEASURED values. Record `(formula, inputs, result)`. +- **RANGED**: summarises N≥5 MEASURED observations across distinct snapshots, chains, or instances. Record `(N, snapshots-or-instances, min, median, max, command-used)`. +- **STRUCTURAL**: a fact about code shape (line numbers, function names, control flow). Record `(file, line-range, exact-snippet, verified-on)`. +- **DOCUMENTED**: claim from an external authoritative document (vendor spec, RFC, language reference, upstream README). Record `(source-url-or-path, exact-quote)`. + +Anything outside these five is **FORBIDDEN**. Remove it, do not weaken it. + +### Forbidden softeners + +These signal that a number was invented and is being hedged into defensibility: + +- "Typically", "usually", "on average", "most chains" without a RANGED tag. +- "Approximately", "roughly", "~" prefixing a number with no MEASURED or RANGED source. +- "Tens of millions", "a few hundred", "around N", "on the order of", and other order-of-magnitude hedges, unless N is the actual measurement and the hedge is dropped. +- "Industry rule of thumb", "in general", "typically compresses ~Nx", any rule-of-thumb size or ratio. +- **Round numbers** (100, 500, 700, 50000, 100MB, 1GB) in empirical claims. Round numbers are a red flag for invention; measurements produce specific digits. If a measurement happens to land on a round value, cite the command anyway. +- Mixed unit bases. Decide `MB = 10^6 bytes` and `MiB = 2^20 bytes` at the top of the doc and never mix. + +### The reach-for rule + +When the next character to type is a digit, the very next action is **not** finishing the sentence. The next action is: + +- Reach for the data directory, not the keyboard. (`wc -l`, `du`, `find`, `ls | wc -l`.) +- Reach for the source file, not the prose describing it. (`grep`, `sed`, line numbers verified.) +- Reach for a second snapshot, not an adjective. (RANGED claims need N≥5.) +- Reach for an actual test, not folklore. ("CSV compresses Nx" requires running `zip` on the real data, not a remembered ratio.) + +The doc is a presentation layer. The truth is in the data and the source. The doc must render that truth, not paraphrase it. + +### The forbidden moves (named failure modes) + +These are the patterns that cause iterative-audit loops: + +- **Re-stating a doc number without re-measuring.** If the doc says "188 stores" and you are auditing it, run `wc -l` again. Do not trust prior prose. +- **Citing a single snapshot to support a "typical" claim.** RANGED requires N≥5. +- **Rounding to soften a measurement.** If the count is 21,657, do not round to "about 22,000." +- **Substituting plausibility for measurement.** "CSV typically compresses 8-10x" is forbidden. Run `zip -9` and record this data's actual ratio. +- **Widening the claim to be technically defensible.** "Around 10 million rows" is not better than "10,190,298"; it is worse. Specificity is mandatory. +- **Carrying old prose forward across rewrites.** If a section is rewritten, every empirical claim in the new section is re-measured. Do not assume a prior measurement still applies in the new framing. +- **Marking findings "low priority" or "stylistic."** Findings are fixed or the claim is removed. + +### Reference protocol + +For any doc with non-trivial empirical content, follow a four-phase verification protocol: Phase 1 inventory of empirical claims; Phase 2 a `measure.sh` script producing a `measurements.json`; Phase 3 in-doc tagging and lint; Phase 4 a three-orthogonal-auditor cycle (data / code / form). Stop condition: two consecutive clean runs of each auditor, lint at zero, and the measurement script stable. + +The point of the protocol is structural prevention over reactive auditing: a claim that cannot be produced by a command cannot enter the doc in the first place. Iterative auditing catches fabricated numbers after the fact; the protocol stops them at the door. + +## Standard structure + +A first-principles analysis doc typically has these sections, in order: + +1. **Background.** Why this system exists. The domain. The regulation or business driver. The system at one glance (architectural diagram with concrete data flow). +2. **Prerequisites.** Every technical primitive the rest of the doc relies on, explained from scratch. +3. **Project shape.** Directory layout. Dependencies (with what each library does and why). Build/run commands. +4. **Subsystem-by-subsystem deep dives.** For each major component: every public method, every algorithm walked through with worked examples, every quirk documented. +5. **Per-instance detail.** Where the system has N instances of a pattern (30 sources, 10 importers, etc.), walk each one individually. +6. **Operational concerns.** Deployment, scheduling, monitoring, failure modes. +7. **Shortcuts and known issues.** Where the code is intentionally less rigorous than it could be, and why. +8. **Reimplementation roadmap.** Ordered phases, library equivalents, time estimates, hard parts. +9. **Worked end-to-end example.** One concrete unit of data traced through every component. +10. **Failure modes catalogue.** Every realistic failure and the system's response, in table form. +11. **Glossary.** Every non-English or jargon term used anywhere in the source or the doc. +12. **License / provenance.** Where the source lives, what license, what's been modified. + +Not every doc needs every section. Pick the ones that fit. But sections 1, 2, 4, 7, 9, and 11 are mandatory. + +## When this rule kicks in + +Cues that imply first-principles depth: +- "explain in plain English" +- "describe in detail" +- "go deeper" / "much more detail" +- "as if you had to implement it in another language" +- "as if you had to explain it to someone with no context" +- "comprehensive analysis" +- "be thorough" +- "be immaculate" +- "design doc" +- "architecture overview" +- "postmortem" + +Cues that do NOT imply first-principles depth (lighter touch is fine): +- "summarize" +- "what does this do?" +- "quick overview" +- "tldr" + +When in doubt, default to first-principles. Lighter docs are easy to retro-fit; sparse docs are not. + +## Companion rules + +- `writing-plans.md`: for multi-step implementation plans (the doc that follows the design doc). +- `brainstorming.md`: the design gate that happens BEFORE writing a design doc. +- `proof-discipline.md`: how to frame and label a correctness claim; its `sampled` / measured labels inherit this rule's MEASURED / DERIVED / RANGED / STRUCTURAL / DOCUMENTED taxonomy. + +## Why this exists + +The reader of a deep doc cannot tell a measured number from a confident guess, and a plausible-sounding figure written without measurement is indistinguishable from a true one until someone audits it. Depth without measurement discipline produces docs that read authoritatively and mislead silently. The depth-target makes the doc complete; the measurement discipline makes it trustworthy. Together they let a newcomer both understand the system and rely on every number in the description. diff --git a/docs/rules-swift/core/folder-grouping.md b/docs/rules-swift/core/folder-grouping.md new file mode 100644 index 0000000..e17ff5f --- /dev/null +++ b/docs/rules-swift/core/folder-grouping.md @@ -0,0 +1,202 @@ +# Folder Grouping + +How to lay out a source tree: one folder per unit of work, with file-grouping rules for the files inside that unit. + +This is a cross-cutting layout rule. It is most concrete in package managers where one-folder-per-module is the default (Swift / SPM is the worked example below), but the same intent applies to any language's package tree, a web project tree, a docs tree, anywhere a tree of files exists. The principle is language-neutral: the filesystem boundary should mirror a semantic boundary, and every folder must answer "what is inside that is not outside?" + +## One folder per buildable unit is the mandate, not just the default + +For a package manager whose default is one folder per module (target, package, crate), the standing rule is: **every buildable unit maps to exactly one top-level source folder, and that folder is the unit's home** (`Sources//`, `Tests/Tests/`). No single `path:` pointing many units into a shared parent, no disjoint `sources:` / `exclude:` slicing to carve multiple units out of one folder. Listing the source root must enumerate every unit; the filesystem boundary IS the unit boundary. + +This supersedes the older "family-parent folder" idea for the unit-grouping case. The file-grouping rules below (grouping FILES *within* a single unit's folder) still apply unchanged. The two earned-exception patterns (same-kind single-file clusters; sub-units sharing an umbrella) are discouraged for new code; flatten unless there is a compelling, documented reason. + +A migration that flattened a deep `Sources/{Core,Executables,...}/...` tree to strict one-folder-per-unit confirmed the payoff: a cluster of executables sliced from one folder via `sources:` became sibling `Sources//` folders, with a byte-identical behaviour set and a fully green test suite across the migration. + +## The rule: grouping FILES within a unit + +1. **Group related files in a shared parent folder.** When you have several files of the same semantic kind (e.g. 27 importers, 5 renderers, 12 view-models), put them under a single parent folder named for the kind (`Importers/`, `Renderers/`, `ViewModels/`). Do not scatter them across the root or across one-folder-per-file silos. + +2. **Don't create a subfolder for a single file.** A subfolder's job is to make a group of related files easy to scan. A subfolder containing exactly one file forces an extra expand without giving anything back; that one file should live one level up. + +3. **Exception: a single-file folder is allowed when the file's semantic differs sharply from its siblings.** Use this when the file *anchors a future group* or when collapsing it up would put it next to unrelated things. Example: the first file dropped into a brand-new utility / domain bucket may keep its own folder so the bucket's purpose is visible even before peers arrive. Apply sparingly. The default is to flatten. + +4. **The rule applies recursively at every nesting level.** Each level of nesting must have a semantic justification. After grouping a kind into a parent folder (`Importers/`), look INSIDE it: are there sub-clusters that share a finer semantic? Pull those into their own subfolders. Then look UP one level: is the parent kind folder a peer of other related kind folders (an importer seam + importer utilities + importer concretes are all import-related)? Wrap them in a common ancestor (`Import/`). Walk the tree once top-down, once bottom-up. Every folder boundary at every depth must answer "what semantic distinguishes the things inside from the things outside?" If it cannot, flatten or rename. + +## Naming the parent folder: singular vs plural + +The kind folder's name is **singular**, not plural. A folder grouping many models is `Model/`, not `Models/`. A folder grouping many passes is `Pass/`, not `Passes/`. The folder name describes the *kind of thing inside*, not the *plural cardinality of that thing*. Singular reads cleaner in paths (`Sources/Enrichment/Model/EnrichmentRunner.swift` vs the noisier `Sources/Enrichment/Models/...`) and matches namespace style (`Indexer.Model.X`, not `Indexer.Models.X`). + +Exception: the kind-folder name is plural *only when the kind itself is grammatically a plural noun* (`Resources/`, `Frameworks/`, `Sources/` itself). When you have to coin a singular vs plural, pick singular. + +## Default: every unit is its own top-level folder + +The package-manager default, `Sources//` with no `path:` override, is the safer baseline for trees with many units. The filesystem location mirrors the unit identity one-to-one, which means: + +- Listing the source root enumerates every unit without reading the manifest. +- Grepping one unit's folder answers "what's in unit Foo" without ambiguity. +- Lift-out is trivial: copy the folder, write a tiny standalone manifest, build. +- New contributors do not have to learn an additional layer of conventions before navigating the tree. + +Deviate from the default ONLY when one of the two patterns below earns its keep. + +## When family-parent folders are worth it: same-kind clusters of single-file units + +A family parent folder with `path:` + `sources:` overrides earns its keep when you have **5 or more peer units of the same semantic kind**, each a single-file standalone unit. The canonical case: 27 importers under `Sources/Importers/` files. The kind folder collapses 27 single-file siblings into one scannable list, while the manifest's `path:` + `sources:` keeps each importer a separate unit. + +Rule of thumb: if you can name the kind in one word and you have many peers, group them. `Importers/`, `Renderers/`, `ViewModels/` qualify. Two units do not. + +## When family-parent folders are *not* worth it: heterogeneous-kind families + +A family folder with **kind-named subfolders** (`Sources//{Core, Model, ...}/`, each subfolder holding a different kind of unit: live concrete vs foundation seam vs adapter) is an anti-pattern. It looks symmetric but: + +- The filesystem path no longer maps 1:1 to a unit. `Sources/Enrichment/Core/` is the unit `Enrichment`; `Sources/Enrichment/Model/` is the unit `EnrichmentModels`. The reader needs the manifest to make the connection. +- Overloads the word "Core" across several meanings (family root, unit's content, kind subfolder). +- Reads worse: `Sources/Source/SampleCode/SampleCodeSource` triples the same word. +- Forces every new contributor to learn a project-specific taxonomy before navigating. + +If a `Foo` unit ships alongside `FooModels`, leave them as `Sources/Foo/` + `Sources/FooModels/` (default). The naming pair already signals the relationship; folder nesting adds nothing. + +**Historical note**: one project tried `Sources//{Core, Model, ...}/` across 13 families before reverting to the default after a single review pass ("this is confusing"). The lesson: filesystem === unit boundary is load-bearing for navigability; family-parent folders that obscure it are not worth the cohesion gain. + +## Unit name vs folder name decoupling + +Unit identity is declared in the manifest (a `path:` override in SPM). The filesystem location and the unit name can diverge, but **do not make them diverge for cosmetic reasons**. Use a `path:` override only when: + +1. **Clustering same-kind single-file units** (the importers case). +2. **Embedding a sub-unit inside a parent unit's folder** (e.g. `Sources/MCP/Core/`, `Sources/MCP/Client/`: each its own unit, sharing the umbrella name because they ship together as one conceptual framework). +3. **Test units pre-organised under a domain folder** when the source side mirrors that shape. + +Anywhere else: keep the default. The discoverability win outweighs any cohesion gain. + +## Rename priority order (highest-stability first) + +When restructuring or renaming, treat the surfaces in this order. The higher the stability rank, the more reluctant you must be to change it. **Always anchor a rename around the highest-stability surface that is not moving**, and let the lower-rank surfaces follow. + +1. **Buildable-unit name** (*highest stability*). This is the string consumers write to import the unit. Changing it cascades to every consumer's source file, every test, every doc that names the import, every script that greps for it. Default position: do not rename. A unit rename is a separate, dedicated change with explicit acceptance criteria (every consumer updated, every test green, every doc swept). + +2. **Public type / namespace name** (*high stability*). Surfaces in consumer code as `.`. Renames are tractable because they can be staged behind a back-compat alias, but every direct reference still needs updating eventually. Stage the rename: introduce the new name + alias in one change, migrate consumers in follow-ups, drop the alias when uses hit zero. + +3. **File name** (*low stability*). Most compilers do not care which file a type lives in. Rename freely. Move the file and you are done; no consumer is affected. + +4. **Folder layout** (*lowest stability*). The filesystem location of source files is an organisational decision, not an identity one. A `path:` override decouples folder from unit name when needed (sparingly, see above). Reorganise folders without touching unit names: every consumer keeps building unchanged. + +**The load-bearing principle**: when renaming, ask first *"can I do this without touching the buildable-unit name?"* If yes, the change is local; only the author sees the file moves. If no, the change is API-breaking and needs the heavy treatment. + +**Worked example**: a family-folder restructure tried to deepen a codebase by moving files into family-parent + kind-subfolder shapes. The instinct was right (organize better) but the execution prioritised the wrong surface (folder layout over unit name). After a single review pass ("this is confusing") the right move was to keep unit names + file names exactly as they were and revert the folder layout to default. The same change also landed type-name deepening (rank 2) behind back-compat aliases, which DID stick because it respected the priority order: unit names unchanged, public types staged behind aliases, file names + folders adjusted to match. The folder restructure (rank 4) cost roughly 250 file moves in then out; the type deepening (rank 2) cost about 6 files and shipped. Different ranks, different blast radii. + +## How to apply + +When adding a new file: + +- Find an existing folder of the same kind. Drop the new file directly into it (no inner subfolder). +- If the file has its own companion files (a 2-or-more-file unit: protocol + helper + fixture, or model + parser), give *that unit* its own subfolder inside the parent kind folder. +- If no kind folder exists yet and the new file is the first of its kind, drop it next to its peers at the current level. Create the kind folder later, when a 2nd peer lands. + +When reviewing an existing tree: + +- Walk it folder-by-folder. Any folder containing exactly one source file is a candidate to flatten. +- Flatten by moving the file up one level and removing the now-empty folder. +- Preserve the unit-with-multiple-files rule: do not flatten a 2-file folder. + +## How this composes with a package manager (Swift / SPM specifics) + +This rule conflicts with SPM's default `Sources//` convention when you have many single-file targets, because SPM expects one folder per target. Resolve by giving each affected target an explicit `path:` and `sources:` declaration in the manifest. Multiple targets MAY share the same `path:` as long as each declares a disjoint `sources:` list: + +```swift +let importersSrc = "Sources/Importers" + +func flatImporter(_ name: String, dependencies: [Target.Dependency]) -> Target { + .target( + name: name, + dependencies: dependencies, + path: importersSrc, + sources: ["\(name).swift"] + ) +} +``` + +Then `flatImporter("AnalysesImporter", ...)`, `flatImporter("FollowersImporter", ...)`, etc. all live as flat files under `Sources/Importers/` while remaining independent targets. A multi-file unit (e.g. one with a protocol + a parser) keeps its own subfolder under the parent kind folder: `path: "Sources/Importers/CsvImporter"`. Verified on Swift 6.0 + SPM: `path:` may overlap between targets when `sources:` partitions them disjointly. + +## Tests follow sources + +Test files mirror their source layout: + +- 26 single-file importers under `Sources/Importers/` map to 26 test files under `Tests/Importers/`. +- A multi-file importer at `Sources/Importers/CsvImporter/` maps to a multi-file test unit at `Tests/Importers/CsvImporterTests/`. + +Use the same flat-helper pattern in the manifest for tests. + +## Why this matters + +Two costs the rule removes: + +- **Scan cost.** A tree with 27 one-file folders is 27 expand-arrows the reader has to click. A flat `Importers/` directory with 27 files visible at once is one find-in-files away from any unit. Brevity wins. +- **Mental overhead during edits.** When all importers are siblings, "show me what every importer does" is a single directory listing. When each importer hides one level down, the same question becomes a recursive find plus a mental merge. + +The cost the rule preserves: unit independence (every unit lifts out of the set cleanly). Folder-grouping is a *filesystem* layout decision; unit boundaries stay sharp via `path:` + `sources:`. + +## Lift-out preservation (non-negotiable) + +Every unit must lift out as a working standalone package: copy the unit + its declared transitive deps to a tmp directory, generate a minimal manifest, build, green. Folder-grouping must not regress this. It does not, because: + +- Each unit still owns a definite set of files (declared via `path:` + `sources:`). The filesystem locations are colocated, but the file-to-unit mapping is unambiguous. +- A lifted unit re-roots its sources under the default convention (`Sources//`); the shared-path trick was a *set-level* layout choice, not part of the unit's identity. The lifted manifest goes back to a plain target declaration with no `path:` override. +- Tests follow the same rule: each test unit's files belong to exactly one test unit, and lifting copies just those files. + +**Mechanical verification (do this when in doubt):** copy the unit + its declared transitive deps to a tmp `Sources//` (one folder per unit, default layout), generate a one-screen manifest, build. Green = lift-out works. + +**Per-unit import audit must still work.** A side-effect of a shared `path:` is that simple `for d in Sources/*; do audit "$d"; done` scripts collapse a cluster of single-file units into one bucket and lose per-unit granularity. Fix: walk to leaf source files inside designated cluster directories. Add an `is_cluster_path` predicate to the audit so each clustered unit is still audited individually; update that predicate when adding a new cluster bucket. If the audit cannot distinguish your units, the refactor is leaking: fix the script or revert the grouping. The lift-out test never lies. + +## Worked example: a three-pass flatten + +Three passes, each from a different angle, illustrating rule item 4. + +**Pass 1, flatten one-file folders.** Before: `Sources/AnalysesImporter/AnalysesImporter`, `Sources/FollowersImporter/FollowersImporter`, ... 27 one-file folders at the source root. After: `Sources/Importers/` flat. The 2-file `CsvImporter` keeps its inner subfolder. + +**Pass 2, sub-cluster inside the kind folder.** The flat list `Importers/` still had visible clusters: several files sharing a platform prefix. Each cluster got its own subfolder under `Importers/`. Single-file importers with no platform peer stayed flat at `Importers/`. + +**Pass 3, wrap the import family at the root.** The source root contained `Importer/` (seam), `ImporterUtilities/` (helpers), and `Importers/` (concretes) as three peer folders mixed with unrelated things. The three import-related folders got wrapped under `Sources/Import/`: + +``` +Sources/Import/ +|-- Importer/ (seam, multi-file) +|-- ImporterUtilities/ (helpers, multi-file) ++-- Importers/ (concretes) + |-- PlatformA/ (multiple PlatformA units) + |-- PlatformB/ (multiple PlatformB units) + |-- AnalysesImporter (flat, no platform peer) + +-- SnapshotImporter +``` + +Tests mirror the same shape under `Tests/Import/`. Build + tests all pass. + +The lesson: pass 1 and pass 2 are both applications of the same rule at different depths. Pass 3 inverts the angle, looking UP from a kind folder at its peers, and finds the next grouping. After three passes the tree settles: every folder at every depth has an answer to "what's inside that is not outside?" + +## Anti-patterns + +- Creating an `Importers/AnalysesImporter/AnalysesImporter` triple where the inner folder holds one file. Flatten. +- Hoisting a multi-file unit (e.g. an importer's protocol + parser) out of its subfolder and into the flat parent. The unit deserves its own folder. +- Inventing a single-file kind folder before peers exist (a brand-new `Renderers/WebDashboardRenderer` when no other renderer is anywhere on the horizon). Wait for a 2nd renderer, then create `Renderers/` and consolidate. +- Creating a parent kind folder that contains only one item *and* duplicates its name (e.g. `Renderers/Renderers/Web`). Same anti-pattern as `Renderers/Web/Web`: the inner layer is empty. + +## Acceptance check + +A tree conforms when: (1) listing the source root enumerates every buildable unit (no `path:` pointing many units into one shared parent except the two earned-exception patterns); (2) no folder holds exactly one source file unless rule item 3 (sharp-semantic anchor) applies; (3) every folder at every depth answers "what is inside that is not outside?"; (4) kind folders are singular unless the kind is grammatically plural; and (5) every unit still lifts out as a standalone package and builds green. Eyeball it before declaring a layout change done: + +```bash +find Sources -mindepth 1 -maxdepth 1 -type d | while read -r d; do + count=$(find "$d" -maxdepth 1 -type f | wc -l | tr -d ' ') + printf "%3s %s\n" "$count" "$d" +done | sort -n +``` + +Any line with `1` is a candidate for flattening (unless rule item 3 applies). + +## Companion rules + +- `no-shortcuts-first-principles.md`: the ethic this specializes. A folder boundary with no semantic behind it is accidental complexity inherited from the first layout that came to mind; deriving the tree from real boundaries is first-principles work. +- A file-naming convention is the layer this layout sits on top of; filenames inside a kind folder follow it. + +## Why this exists + +The filesystem boundary should mean something. When it mirrors a real semantic boundary (one folder per buildable unit, one kind folder per group of same-kind files), the tree is navigable by listing and grepping alone, and every unit lifts out cleanly. When folders proliferate without semantics (one-file folders, kind-subfolders that hide the unit identity), every navigation costs an extra click and every contributor has to learn a private taxonomy first. The rule keeps the boundary load-bearing. diff --git a/docs/rules-swift/core/git-discipline.md b/docs/rules-swift/core/git-discipline.md new file mode 100644 index 0000000..71807b7 --- /dev/null +++ b/docs/rules-swift/core/git-discipline.md @@ -0,0 +1,223 @@ +# Git and Issue-Tracker Discipline + +Conventions for issues, labels, pull requests, branches, commits, and remotes. Commit-*message* format (the `(): summary` shape) is a separate convention; this file covers everything around it. + +A repo that adopts these rules should ship the mechanical backstops that enforce them: issue forms, a PR template, a label set, and git hooks. The rules below are the durable conventions those mechanisms encode. Hooks live under `.git/` and are not cloned, so install them per clone, per machine. + +## 1. Issue tracker + +### Rule 1.1: Status block at the top, dated + +Every issue body carries a `## Status (YYYY-MM-DD)` heading as the first section. New-issue form templates make this required. + +When state changes (work starts, acceptance bullets ship, scope narrows, deps close), edit the status block in place with a new date line. The rest of the body stays as the original framing. + +**Why**: bodies without a dated status block age into fiction within a month. Issues with a status block age well; the ones without age into wrong-default-value claims that shallow audits pass as "well-written, keep." + +### Rule 1.2: No line numbers in issue bodies + +File references use symbol names, not `Foo.swift:142`. Lines drift on every change; symbols do not. When you need a line anchor, write `Foo.swift (the searchSymbols function)` so the symbol survives even if the line moves. + +**Why**: line anchors go dead within one change cycle the moment a file is split or reordered. + +### Rule 1.3: No phantom paths + +Every backtick-quoted file path in an issue body must EXIST in the repo (or in a declared sibling repo) at write time. A mechanical check is straightforward: extract backtick-quoted paths, check the filesystem, flag missing ones. + +**Why**: a cited file that was never written is a fabrication that propagates. The work it implies may be real, but the path is a lie until the file exists. File it as future work instead of citing a path that is not there. + +### Rule 1.4: Cross-reference hygiene + +When citing `#NNN` in blocker phrasing ("blocked on", "pending in", "depends on", "after #N lands", "gated on", "awaits", "waiting on"), the referenced issue must be OPEN at write time. When it closes, edit the citing issue: remove the cross-reference, or rewrite the sentence to say the dep shipped. + +**Why**: "blocked on #X" where #X closed weeks earlier is a silent dependency lie. Wrong issue numbers are worse: they point a reader at unrelated work. + +### Rule 1.5: Schema and code claims are checkable + +Do not cite `.` shapes, function signatures, or default values you have not verified against current source. When the claim is structural (a schema column, a config default, a flag name), an audit script can mechanically verify it. Migrations move columns; flag defaults change; bodies do not auto-update. + +**Why**: a body claiming a column lives in table A when a migration moved it to table B, or claiming a flag defaults to `0.5s` when the real default is `0.05s` (off by 10x), reads as authoritative and misleads. Verify before you write. + +### Rule 1.6: Issue templates use structured forms, not freeform markdown + +Use form templates that enforce structure mechanically at filing time, not freeform markdown templates that only suggest it. Required fields: status date, priority, complexity. Required structured fields per template kind (goal + acceptance for features; symptom + expected + reproduce + acceptance for bugs). + +**Forms gotcha**: dropdown selections produce TEXT in the issue body under `### ` headings; they DO NOT auto-apply as labels. To make dropdowns actually apply matching labels, add a labeler workflow that triggers on issue-opened, extracts the value under each heading, validates against the known label set, and applies the matching label. + +**Why**: forms make filing discipline structural rather than suggestive. The labeler gotcha is easy to miss; the dropdowns look like they should label the issue, and they do not. + +### Rule 1.7: Roadmap status colors + +When the README carries a status-colored roadmap diagram, give it one node per epic, open and closed alike, and a shared legend diagram first. A node's color moves with state in the same change that opens, starts, or closes the epic; the issue body, prose, node label, and class must all describe the same state. + +A consistent, named palette (one fill per lifecycle state: done / active / in-review / next / partial / todo, text `#FFFFFF`) keeps the diagram legible. A gate fails when the first diagram block is not the legend, a class is outside the palette, or an epic-labeled issue has no node in any diagram. Run it; it must print OK. + +## 2. Labels + +### Rule 2.1: Brutal-minimum label set + +The canonical set is small. Labels exist only to mechanically partition the issue space; everything else lives in the issue body or in native tracker primitives (issue state, milestones, project boards). A workable minimum: + +| Label | Use | +|---|---| +| `bug` | Something ships incorrectly | +| `enhancement` | New feature, refactor, or design proposal | +| `epic` | Aggregation parent of related issues; mechanically excluded from "missing kind" check | +| `priority: high` | Critical / release-gating / actively-blocking. Absence means "do when you can." | +| `good first issue` | Newcomer-friendly; trackers often render this specially | + +Kind is determined at filing time (the feature template applies `enhancement`, the bug template applies `bug`). Maintainers add the rest post-filing where warranted. + +**What is not a label here:** + +- **Complexity.** Read the diff. If you need a label to tell readers the issue is hard, your prose is failing. +- **Priority gradient.** `priority: medium` / `low` collapse to "absence of `priority: high`." If the gradient does not change behaviour, it is decoration. +- **Topical categorisation.** That is composition, not type. Encode it in the body or a milestone. +- **Status / lifecycle** (`wishlist`, `blocker`, `awaiting release`, `released-in: vX`). Status belongs to tracker-native primitives, not labels. + +**Why this size**: each surviving label has at least one mechanical reason it cannot fold into the body. Three lenses converge on the set: *cut mercilessly* (cut even fine things that do not earn their place); *classify by type, not status* (route status / lifecycle / release-tracking to native primitives); *prose over labels* (if you need a label to convey something, your prose is failing). Labels exist only for what mechanical tools must partition on. + +### Rule 2.2: Threshold for adding a new label + +Before adding the next label, ask: does it have at least 3 expected open carriers (now or within the next planning cycle)? AND does it survive all three lenses above? Would you ship it or delete it? Is it a type/axis, or status/lifecycle/composition pretending to be a type? Does it mechanically partition the issue space in a way a filter or CI script must use, or is it commentary that belongs in the body? If any lens fails, fold it into the body. A one-line note there is as discoverable as a single-carrier label without the dropdown clutter. + +**Why**: single-carrier labels are footnotes pretending to be axes. Sprawling label sets stop being navigable past about 10. + +### Rule 2.3: Consistent color palette + +Draw label colors from one published, named palette with strong semantic associations, so labels render coherently in any view that supports colour. Pick the hue by meaning (red = urgent/blocking, blue = active work, purple = aggregation, green = newcomer-friendly/shipped), and reserve the rest of the palette for any later additions by the same semantic grouping. + +### Rule 2.4: Label deletion is destructive on closed issues + +Deleting a label removes it from every issue that ever bore it, including closed ones. Rename instead of delete when historical association matters. + +**Why**: closed issues are the project's audit trail. A `phase-1` label on closed refactor issues told a later reader "this was Phase 1 of that refactor"; deleting it removes that context, renaming preserves it. Prefer rename over delete for labels with closed carriers. Brutal trims that delete closed-carrier labels should be a deliberate, documented choice, acceptable when the information still lives in release notes, the changelog, and merge commits. + +## 3. Pull requests + +### Rule 3.1: One focused change per PR + +A PR ships one cohesive change. If the diff spans two unrelated concerns, split. Critic-fix iterations on the same change belong in the same PR (separate commits, same branch). Different concerns belong in different PRs even if they touch the same files. + +### Rule 3.2: Changelog required for non-trivial changes + +Projects that maintain a changelog require an entry per non-trivial PR. "Non-trivial" means production source touched. Trivial means docs / tests / scripts / configuration only. A mechanical pre-commit + CI check enforces this; opt out with a `[no-changelog]` token in the commit message body when the change genuinely does not warrant an entry. + +**Why**: PR descriptions live in the merge graph; the changelog lives in the release. Without enforcement, source ships ahead of its description. + +### Rule 3.3: Critic-fix loop on every non-trivial PR + +After opening a PR, do a self-critic pass: read your own diff as a reviewer would, find issues, fix them in additional commits on the same branch. Iterate until critique surfaces nothing new. Commit naming: `critic-fix(): `. The history shows the iteration; the PR diff shows the converged result. + +**Why**: mechanical edits without re-reading the surrounding paragraph create new bugs. An edit that injects a reference inside a sentence saying "this is independent" creates a contradiction. Critic-fix loops catch the self-introduced bugs the original change did not have. + +### Rule 3.4: PR head is never the canonical branch for release merges + +When merging a release branch into the canonical branch, the PR head is a dedicated `release/v` branch, not the canonical working branch. Auto-delete-on-merge would otherwise kill a long-lived branch. + +**Why**: the auto-delete-branch-on-merge feature is useful for short-lived feature branches and destructive for long-lived development branches. Always interpose a release branch as the head. + +## 4. Branches + +### Rule 4.1: Branch naming + +- `fix/-` for bug fixes (e.g. `fix/284-error-page-filter`) +- `feat/` for features +- `chore/` for tooling, infrastructure, non-functional cleanup +- `docs/` for documentation-only changes +- `refactor/` for structural reorganisation +- `release/v` for release-prep branches (PR head per Rule 3.4) + +Issue-anchored prefixes when an issue exists; otherwise topic-anchored. + +### Rule 4.2: Branch from canonical base + +Branch from the current tip of the canonical base branch (the one the PR will target). The safe form is to fetch the base and branch from the fetched ref, not from a stale local copy, which creates merge conflicts later. + +### Rule 4.3: Issue-first workflow + +For every bug fix: file the issue first, then branch. The branch name carries the issue number; the PR auto-links via `Closes #N`. For features, an issue is optional but recommended when scope is non-trivial. + +**Why**: the issue is the audit trail. A branch + PR without an issue has no place for the symptom / repro / acceptance discussion; the PR body can hold that but does not survive a squash-merge as well as an issue does. + +## 5. Commits + +### Rule 5.1: No AI attribution in committed text + +Generated content carries no AI-tool attribution in any committed text. The standard is "would a colleague reading this guess a tool wrote it?" If yes, remove it. This is tool-agnostic: it covers every coding assistant and its vendor, not any single one. Specifically forbidden: + +- Any AI co-author or "generated with" line in commit messages or PR descriptions. In particular, strip the trailers these tools emit: `Co-Authored-By: Claude` and "Generated with Claude Code" (Claude / Anthropic), `Co-Authored-By: Cursor` / "Generated by Cursor" (Cursor), "Co-authored-by" / "Generated with" lines from Codex / ChatGPT / GPT (OpenAI), GitHub Copilot, and Gemini and Antigravity (Google). Several of these reinsert the trailer automatically even after being told not to, and do not honor editor- or settings-level attribution toggles, so removing them cannot rely on configuration alone. +- The exact emitted forms to delete on sight: a `🤖 Generated with Claude Code` line (robot emoji plus a tool link), a `Co-Authored-By: Claude ` or model-named (`Claude Opus`, `Claude Sonnet`, `GPT-4o`) co-author, any no-reply or bot email in a co-author trailer, and tool-named `Assisted-by:`, `Generated-by:`, or `Authored-by:` trailers. The config toggles (`attribution`, `includeCoAuthoredBy`) do not bind tools that ignore them and are overridden in practice, so they do not replace stripping the line. +- Promotional or assistive phrasing that credits tooling: "powered by", "made with", "built with", "with AI assistance", "AI-assisted", "AI-generated", "generated by AI", "with the help of", "courtesy of", "thanks to", "via", or "using" followed by a tool or vendor name, anywhere in a message, PR body, branch name, or comment. +- The name of any AI tool or vendor (Claude, Anthropic, Cursor, Codex, OpenAI, ChatGPT, GPT, Copilot, Gemini, Antigravity, or any other), or the words "AI", "LLM", "agent", "assistant", "automated", "machine-generated", "co-pilot", "pair-programmer" (when no human pair existed), or any synonym, in commits / PRs / branch names / file names / code comments that could end up in front of a colleague. +- Self-references in casual phrasing ("as an AI", "the agent did X", "automated cleanup", a trailing "generated" or "powered by"). +- AI-tell stylistic patterns: emoji used as a signature/flourish (notably the 🤖 robot and ✨ sparkle), "Here's a quick summary:" preludes, gratuitous emoji-prefixed bullets, "I'll now..." / "Let me..." preambles, a structured summary/test-plan block appended to every body when the project does not already use that shape. +- Watermarks: zero-width characters, unusual unicode spaces, hidden HTML comments naming a tool, branch suffixes like `-ai` / `-bot` / a tool name. + +Anything you would recognise as an AI tell, even if not listed, is out. The work must read as entirely human-authored. + +Because several assistants reinsert attribution on their own, telling the tool not to is not enough, and a project's settings toggle does not bind tools that ignore it. A commit-message hook is REQUIRED, not optional, and must be installed per clone (hooks live in `.git/` and are not cloned) via a tracked `core.hooksPath` or by copying the hook into `.git/hooks/commit-msg`. Install the style hook described in Section 7. Rule 5.1 + Rule 5.2 + the hook together close the loop: the rule states intent, the hook refuses violations at commit time, so no commit reaches `git push` carrying either failure. + +### Rule 5.2: No em dashes in committed text + +The em dash (U+2014) is a recognisable writing tell. Replace it with commas, periods, colons, semicolons, or restructure the sentence. Apply to commit messages, PR descriptions, code comments, issue bodies, documentation, and README content: everything that lands in the repo or in front of a colleague. En dashes (U+2013) and hyphens (U+002D) are fine; only the em dash is forbidden. Enforce it with the commit-message hook for messages, and a `pre-commit` byte check over staged diffs for file content. + +### Rule 5.3: Verify after an amend with no message edit + +A no-edit amend keeps the previous commit's message verbatim. After any such amend, view the head commit to confirm the message still describes the new tree accurately. If the staged change made the previous message wrong, rewrite it instead. + +**Why**: silent message staleness is one of the easiest commit-history bugs to introduce. The no-edit amend anchors the old message, which then lies about content that has since changed. + +## 6. Remotes + +Pushes follow the usual conventions: short-lived feature branches push freely; canonical branches land only via merge of an approved PR. + +**Some remotes are review-gated.** A push to a review-gated or audited remote bypasses the review it exists to enforce. Inspect the remote URL before pushing; do not push to such a remote without explicit authorization, even for a small fix. Local commits, branches, fetches, pulls, and rebases against it are fine; only the push is gated. When in doubt about whether a remote is gated, treat it as gated and ask. + +## 7. Mechanical enforcement + +A repo that adopts these rules should ship a mechanical backstop, because discipline scales with attention and hooks do not: + +- **Body-drift scan**: a scheduled script that greps open issue bodies for renamed paths, phantom paths, stale cross-refs, stale schema claims, and label drift. Output goes into a single tracking issue updated on each run. +- **Changelog gate**: a pre-commit hook + CI gate that refuses commits / PRs touching production source without a changelog entry. +- **Issue forms with labeler**: structured form templates + a workflow on issue-opened that translates form dropdown values into matching labels. +- **Style-tell commit-msg hook**: a `commit-msg` hook that refuses messages containing em dashes (U+2014), the forbidden AI-attribution phrases from Rule 5.1, or AI-signature emojis. Keep it portable (no PCRE dependency). Install per clone by symlink or copy into `.git/hooks/commit-msg`, or point `core.hooksPath` at a tracked hooks directory. Strip comment lines and the diff-scissors block before checking so notes in `# ...` lines are not penalised. + +A disciplined author following Rule 5.2 can still push a commit whose message contains an em dash, because nothing at the commit boundary stops it without a hook. A short `commit-msg` hook prevents that entire failure class at write time, after which `git push` cannot carry a violation regardless of who or what wrote the message. The hook is the only tool-agnostic backstop: it runs on every commit no matter which assistant produced the text. + +## 8. CI workflows and truthful badges + +### Rule 8.1: One workflow file per concern, one badge per workflow + +Each CI concern (style/gates, and each build-or-test platform) is its own workflow file with a unique name. A README badge points at that one workflow's status endpoint, so the badge tracks exactly one job and is truthful. + +Do NOT use a single multi-job workflow with several cosmetically-labeled badges that all point at the same workflow. The workflow-status endpoint reports whole-workflow status, so every such badge renders the same result: a failure in any one job turns all of them red, and none tracks its named platform. That is a lying badge. + +### Rule 8.2: Use the native per-workflow badge, plus a license badge + +Use the tracker's native per-workflow badge (it reflects the real status of that one workflow), not a third-party status badge with a cosmetic label. Add a static license badge that links to the repo's `LICENSE`, with the label matching the actual license. + +### Rule 8.3: Add a platform job and badge ONLY when that platform is genuinely possible + +A badge is a claim; only claim a platform the repo can actually build or test on. An absent badge is honest; a red-or-fictional badge is not. Decide possibility from the repo's topology: + +- UI builds and tests run on the platform that hosts the UI runner only. Do not add a badge for a platform that cannot run that UI. +- A given OS badge belongs only when the product builds on that OS's toolchain. +- When a platform can build but not run the full suite, the workflow is a build gate, not a test gate. Say so in a header comment so the green badge is not read as "tests pass" when it means "compiles." + +### Rule 8.4: Triggers and permissions are uniform across the split + +Every workflow uses the same trigger surface so the badges refresh together (push to the canonical branch, pull request, and manual dispatch). Keep permissions least-privilege (read-only, plus the one extra scope a specific gate needs). The commit-message attribution gate (Rule 5.1) lives in the style workflow and needs full history on checkout plus the PR base/head SHAs to scan the commit range. + +## Acceptance check + +A repo conforms when: (1) every open issue body has a dated status block, no line-number anchors, no phantom paths, and no stale cross-refs; (2) the label set is minimal, every label survives the three lenses, and colors come from one named palette; (3) each PR is one focused change with a changelog entry when non-trivial; (4) branches follow the naming scheme and branch from a fresh canonical base; (5) `git log` shows no AI attribution and no em dashes in any committed text, with a `commit-msg` hook installed to enforce it; (6) no push to a review-gated remote happened without authorization; and (7) each CI badge points at exactly one named workflow and claims only a genuinely-possible platform. Mechanical backstops (body-drift scan, changelog gate, labeler, style hook) are installed, and the hooks are installed per clone. + +## Companion rules + +- `no-shortcuts-first-principles.md`: the ethic this specializes. A phantom path, a stale cross-ref, a lying badge, and a stale amended message are all hidden debt that reads as truth; this rule is the issue-tracker and git-history application of "disclosed limits, never hidden debt." +- `proof-discipline.md`: a badge is a correctness claim; Rule 8.3 is "do not claim a platform you cannot prove," the CI form of "no unlabeled `correct`." + +## Why this exists + +An issue tracker and a git history are read by people who were not in the room. They age into fiction the moment a path moves, a dependency closes, a default changes, or a badge points at the wrong job, and nothing in the bare tools stops that drift. These conventions keep the written record true to the code: dated status, verified references, minimal labels, one-concern PRs, honest badges, and a commit history that reads as human-authored. The mechanical backstops make the discipline survive a tired author, because a hook does not get tired. diff --git a/docs/rules-swift/core/no-shortcuts-first-principles.md b/docs/rules-swift/core/no-shortcuts-first-principles.md new file mode 100644 index 0000000..50ca250 --- /dev/null +++ b/docs/rules-swift/core/no-shortcuts-first-principles.md @@ -0,0 +1,62 @@ +# No Shortcuts, First Principles, Do As Knuth Would + +**Status: MANDATORY, every repo, every language, every session.** This is a standing engineering ethic, not a task-triggered rule. It governs HOW work is done, the way `verification.md` governs how work is reported. It is the operational expansion of one idea: choose the optimal path, not the fastest one. We are never in a rush. + +Three pillars, each binding on its own. + +## 1. No shortcuts + +The optimal path is the deliverable. The fast path that leaves a debt, a stub, a silenced symptom, or a narrowed scope is a failure even when it compiles and the tests are green. + +Forbidden, in any repo and any language: + +- **Silencing over solving.** No swallowed exception, force-cast, force-unwrap, blanket lint-disable, broadened `catch {}`, skipped or `xfail`'d test, or `sleep()` introduced to make a symptom disappear without explaining and fixing its cause. If you reach for one, the cause is not understood yet. (See `systematic-debugging.md`.) +- **Stubbing passed off as done.** A `TODO`, a "not implemented" trap, a hardcoded return, a mock left in a production path, or a function that handles the demo input and nothing else is incomplete work. Either finish it or name it explicitly as unfinished in the response. Never let a stub read as complete. +- **Silent scope narrowing.** "Do the rest" is not a follow-up to schedule; it is the task. If the request was to handle all cases and you handled the three easy ones, you did not do the task. Handle the hard case or surface it loudly and ask. +- **Partial coverage dressed as full.** Running one test suite and reporting "tests pass," fixing one call site of a renamed symbol, validating the happy path only. Completeness is part of correctness. +- **Cargo-cult edits.** Copying a nearby pattern without understanding why it is there, or "fixing" by permutation until the error goes away. If you cannot say why the change works, it is not a fix. + +The honest alternative to a shortcut is always available: do the full thing, or state plainly what you did not do and why, and let the reader decide. Hidden debt is the violation; disclosed limits are not. + +## 2. First principles + +Derive the solution from the actual constraints of the problem, not from pattern-match to the nearest familiar shape. + +- **Understand before you change.** State the problem's real invariants and constraints in one or two sentences before writing code. If you cannot, you are not ready to write it. +- **Question the framing.** The asked-for fix is sometimes a symptom. Trace to the root. A request to "add a retry" over a call that should never fail is the wrong layer; find the layer that owns the data. +- **Reduce to fundamentals.** Strip the problem to its irreducible core: what data, what transformation, what invariant must hold. Build up from there. Do not inherit accidental complexity from the first solution that came to mind. +- **No assumed requirements.** Clarify ambiguity before coding. A guessed requirement is a shortcut wearing a different hat. +- **Reason from the source, not from memory.** When a fact is checkable (an API signature, a default value, a measured count), check it. Memory of how a library worked is a hypothesis, not a citation. This is the behavioral twin of `first-principles-analysis.md`'s measurement discipline. + +## 3. Do as Knuth would + +Knuth is the standard of care: correctness proven not hoped, every case handled, the solution understood deeply enough to explain and to defend, and the result clear enough that its correctness is visible. + +- **Correctness is not negotiable, and it is total.** "Works on the input I tried" is not correct. Enumerate the cases (empty, boundary, malformed, maximal, concurrent) and handle each, or state which you deliberately exclude and why. +- **Analyze, do not guess.** Before claiming a thing is faster, smaller, or equivalent, have the argument or the measurement. "Premature optimization is the root of all evil" is the same author: do not optimize on a hunch, and do not claim a win you have not shown. +- **Literate craftsmanship.** Code and prose are written to be read and understood by a human first. Name things for what they are. Make the structure mirror the reasoning. A solution whose correctness you cannot make legible is not finished being designed. +- **Make impossible states unrepresentable.** Prefer the design where the bug cannot be written over the design where the bug is caught. This is doing the hard up-front thinking instead of the easy downstream patching. +- **Rigor scales with how hard the work is to reverse.** Match care to consequence: a published artifact, a destructive operation, or a schema change gets the most. Confirm before anything irreversible. + +## How to self-audit (the one-question test) + +Before reporting a piece of work done, answer honestly: **"Did I take the optimal path, or a path I am hoping no one inspects?"** If any part of the answer is the latter, it is a violation. Then check the mechanical tells: + +- Grep your own diff for the silencing tokens added this change (swallowed exceptions, force-casts/unwraps, `TODO`, `FIXME`, lint-disables, "not implemented" traps, `xfail`, `.skip`, `sleep(`). Each one is guilty until explained. +- Can you state, in one sentence, why every non-obvious line is correct? If not, that line is cargo-cult. +- Did you run the FULL check, not a sample, and cite it? (`verification.md`.) +- Is every case enumerated, or did you stop at the easy ones? +- Is anything you are calling "done" actually a stub, a partial, or a silenced symptom? + +A shortcut that no test catches is still a shortcut. The standard is internal, not whatever the green checkmark permits. + +## Companion rules + +- `systematic-debugging.md`: Reproduce, Isolate, Explain, Fix. The anti-shortcut procedure for bugs. +- `verification.md`: evidence before "done." The anti-shortcut procedure for reporting. +- `proof-discipline.md`: how to frame and label a correctness claim. +- `first-principles-analysis.md`: depth and measurement discipline for docs. The documentation twin of this rule. + +## Why this exists + +This is the spine of the whole rule set. Every other rule, in every domain, is a specific application of one of these three pillars: a particular way to not take a shortcut, to derive from first principles, or to meet Knuth's standard of care. The self-audit makes the ethic checkable rather than aspirational. diff --git a/docs/rules-swift/core/proof-discipline.md b/docs/rules-swift/core/proof-discipline.md new file mode 100644 index 0000000..d525772 --- /dev/null +++ b/docs/rules-swift/core/proof-discipline.md @@ -0,0 +1,163 @@ +# Proof Discipline for Correctness Claims (MANDATORY) + +When a system claims to **translate, compile, encode, render, or compute** a +result that is supposed to match a specification or a reference (a format +importer, a codec, a converter, a layout or render engine, any "X in yields the +correct Y out") you **MUST** frame the correctness claim as a set of separately +provable sub-claims, each carrying an explicit epistemic status, and you **MUST** +distinguish what is proven from what is merely witnessed or still open. "It works" +is not a claim; it is the absence of one. This is the companion to +`no-shortcuts-first-principles.md` (disclosed limits, never hidden debt) and to +`round-trip-transformation.md`, which governs the special case of an invertible +transform. + +## Reference + +The pattern is Knuth's standard restated: correctness is total over enumerated +cases, never "works on the input I tried." It is the proof-versus-witness +distinction from formal methods (a model checker's counterexample is a witness; +the invariant is the theorem) and the oracle problem from testing theory: the +thing you compare against may itself be wrong. + +## Decompose the claim before proving anything + +"This system is correct" is not one claim; it is several, and they have different +provability. Name the layers first, then prove each on its own terms. A typical +input-to-output pipeline decomposes into something like: + +- **Read**: every element the input format defines is either modeled or + reported; the decode is total over malformed input. (Provable as a theorem.) +- **Map or report**: every feature is handled correctly OR collected into a + report; never silently handled wrong. (Provable as a partition theorem.) +- **Numeric computation**: geometry, transforms, arithmetic match closed-form + values to floating-point epsilon. (Provable as a theorem; sampled quantities + carry a measured bound.) +- **Output equivalence**: the final artifact (pixels, bytes, layout) matches a + golden. (Often NOT provable without tooling; name it as a frontier.) + +Your pipeline may have different layers; the point is that they are distinct +claims of distinct strength. A system that asserts the strong claim ("correct") +while only the weak claims are proven is violating this rule even if every test is +green. + +## Every claim carries an explicit status label + +No claim ships unlabeled. Tag each in the artifact (test name, doc, issue) with +exactly one: + +- **theorem**: proven by construction or closed form, over all cases, both + directions where applicable. +- **theorem (bounded to N)**: a theorem relative to a pinned reference (a spec at + a commit); state the bound. +- **sampled**: verified at a sample grid with a **measured** error bound (record + the bound and how it was derived), never an assumed one. +- **witnessed**: corroborated by an external reference that is itself fallible + (see below). Not proof. +- **assumed**: taken on faith; a debt, must be tracked. +- **blocked (with reason)**: cannot currently be proven; name the blocker. + +"Now measured, not assumed" is the upgrade you are always trying to make: turn an +`assumed` into a `sampled` with a real bound, or a `witnessed` into a `theorem`. + +## The reference is a witness, not the ground truth + +When you check output against an external implementation (a vendor renderer, a +reference codec, another library's result), that implementation is a +**corroborating witness, not the oracle**. The ground truth is the spec and the +closed form. When your closed-form result and the reference disagree, the +reference is the anomaly to **explain**, not the authority to obey: a reference +that computes a result differently from the spec's closed form is the one in +error, and a test that pins "we match the closed form, and the reference happens +to agree" is stronger than one that pins "we match the reference." Demote the +reference in writing; never let a fallible implementation stand in as ground +truth. + +## No silent mismatch: classify every case exactly once + +Every input feature is **handled correctly or reported as unsupported**, never +silently handled wrong, never dropped without a trace. The report is part of the +output. Loss is explicit and complete: every case is classified exactly once, zero +silent mismatches, and the loss counts are **re-derived live from the report** so +they cannot drift from the code. A feature you cannot handle (an input variant +with no decoder, an operation the backend lacks) is **reported, not faked**. +Faking it, producing a plausible-looking wrong result and staying silent, is the +cardinal sin this rule exists to forbid. + +## Name the unproven frontier, with its reason + +The claim you cannot prove gets named explicitly, with the blocker and any partial +instrument, in the same place the proven claims live. For example, final-output +equivalence may be `blocked` because no programmatic reference exists to compare +against; a partial analytic oracle that covers the simple, closed-form cases is +the partial instrument; the path to closure is named. This is the difference +between **open-by-named-frontier** (required) and **open-by-omission** (forbidden, +a hidden gap under `no-shortcuts-first-principles.md`). A reader must be able to +see, in the artifact, exactly which claim is unproven and why. + +## DO + +- Decompose the correctness claim into separately provable layers before writing + any verification. +- Label every claim with one status (theorem / bounded / sampled / witnessed / + assumed / blocked-with-reason); never ship an unlabeled "correct." +- Prove what is provable as a theorem (closed form, both directions); for sampled + quantities, record a measured error bound, not an assumed one. +- Treat any external reference as a fallible witness; pin tests to the closed form + and note that the reference merely agrees. +- Emit a report for every unsupported feature; make it part of the output; + re-derive loss counts live from the report. +- Name the unproven frontier with its blocker and partial instrument, in the + artifact, next to the proven claims. + +## DON'T + +- Do not collapse several claims of different strength into one undifferentiated + "it works." +- Do not let an external reference be the ground truth; do not pin "we match + " as if that implementation were infallible. +- Do not silently produce a wrong result for an unsupported feature, or drop it + without a trace. Report it. +- Do not assume an error bound; measure it. A round-number tolerance with no + derivation is an invented number (see `first-principles-analysis.md`). +- Do not leave a gap open by omission. An unnamed unproven claim reads as proven + and is a hidden debt. + +## Acceptance check + +A system that claims correctness conforms when: (1) the claim is decomposed into +named layers, each independently verified; (2) every claim in the docs/tests +carries one status label, with no bare "correct"; (3) unsupported features are +reported through a report object that is part of the output, and a grep finds no +silent normal-path fallback for a feature the backend cannot do; (4) tests pin +closed-form / analytic ground truth, with any external reference named a witness, +not the oracle; (5) sampled claims record a measured bound, not a round-number +assumption; and (6) every unproven claim is named in the artifact with its blocker +and any partial instrument. A system that asserts "correct" while only its weakest +layers are proven, or that pins an external implementation as ground truth, or +that silently produces a wrong result for an unsupported feature, fails this rule +even if its suite is green. + +## Companion rules + +- `no-shortcuts-first-principles.md`: the ethic this specializes. Disclosed limits + are fine, hidden debt is the violation; total correctness over enumerated cases. +- `round-trip-transformation.md`: the by-construction cousin for invertible + transforms (the round-trip law is itself a theorem; this rule generalizes it to + non-invertible translation and computation). +- `first-principles-analysis.md`: measurement discipline. The claim-tagging this + rule's `sampled` / measured labels inherit (MEASURED / DERIVED / RANGED / + STRUCTURAL / DOCUMENTED). +- `validation-rules.md`: validate after parse; the report this rule requires is + where validation failures land. +- `testing-discipline.md` and `verification.md`: run the + suite, cite counts, before claiming a layer proven. + +## Why this exists + +"It works" routinely masks an unproven output-equivalence claim hiding behind +proven coverage and numeric claims. This rule separates what can be proven as a +theorem (coverage, numeric exactness, complete loss accounting) from what is only +corroborated by a fallible reference, and from what remains a named, +tooling-blocked frontier, so the strength of every claim is legible in the +artifact rather than collapsed into a single optimistic "correct." It is portable +to any translator, compiler, codec, or layout engine. diff --git a/docs/rules-swift/core/round-trip-transformation.md b/docs/rules-swift/core/round-trip-transformation.md new file mode 100644 index 0000000..4c577af --- /dev/null +++ b/docs/rules-swift/core/round-trip-transformation.md @@ -0,0 +1,137 @@ +# Round-Trip Transformation Rules (MANDATORY) + +When you build a **bidirectional transformation** (parse and print, decode and +encode, decompile and compile, deserialize and serialize, lower and lift) you +**MUST** make the two directions a single invertible description, not a parser +and a printer written separately and hoped to agree. This rule governs the layer +where text and model convert *both ways*, the step after parse and before +validate. It is the companion to `proof-discipline.md`: the round-trip law is a +theorem you prove, not a hope. + +## Reference + +Invertible syntax descriptions (Rendel and Ostermann, 2010) and lenses (Foster et +al., 2007). In Swift the canonical tool is Point-Free `swift-parsing`'s +`ParserPrinter`; when a dependency is unwanted, a minimal `Conversion`-based core +(below) delivers the same guarantee. + +## The law comes first (state it before any grammar) + +Before writing a single rule of the surface, state the round-trip law and pick +the smallest provable slice. The laws, named after the lens laws: + +- **Λ1 (GetPut)**: `parse(print(x)) == x` for every model value `x`. The surface + loses nothing the model holds. +- **Λ2 (idempotence)**: `print(parse(s)) == s` for every `s` in the printer's + **normal form**. Idempotence is asked of the tool's own output, never of an + arbitrary author's spacing. +- **Λ3 (inherited exactness)**: when this edge sits on another that is already + exact (a model that round-trips to bytes), the whole composition is exact the + moment Λ1 holds. Inherit the proof; do not repeat it. + +No grammar is declared complete before its round trip is green over real data. +Completeness is a proof, not a feeling. + +## By construction, not by hope (the core requirement) + +A bidirectional transform built as two independent functions is wrong even when +the tests pass: nothing prevents them drifting. Build instead from an **invertible +algebra**, where every primitive defines its forward and backward together and +composition preserves invertibility: + +- **A leaf encoder is honestly invertible.** If you quote, you escape on print and + unescape on parse, so the law holds for *every* value, not only the benign ones + your corpus happens to contain. "It works on our data" is corpus luck, not + soundness; state and enforce the precondition or remove it. +- **`map` goes through a `Conversion` (a partial isomorphism)**, not a one-way + function. Forward (`apply`) may reject an invalid surface; backward (`unapply`) + is total, since every model value has a surface. A plain `(A) -> B` map is not + invertible and breaks the guarantee immediately. +- **`zip` / `oneOf` / `many` / `optional`** sequence, choose, repeat, and skip + while keeping both directions in lockstep. Express structure with these, not with + a hand-written recursive descent paired with a hand-written emitter. + +The minimal Swift core when not adopting `swift-parsing`: + +```swift +struct ParserPrinter { + let parse: (inout Substring) -> Value? // consume a prefix, yield a value + let print: (Value) -> String // render a value +} +struct Conversion { + let apply: (A) -> B? // forward, may reject + let unapply: (B) -> A // backward, total +} +// map(_:_:), zip(_:_:), optional(_:), many(_:), oneOf(_:), terminated(_:by:), literal(_:) +``` + +## The gate (the witness, not the proof) + +The by-construction algebra is the proof; a **round-trip test over real data is its +witness**. Gate every slice with: for each value in the corpus, `parse(print(v))` +recovers `v` (Λ1) and `print(parse(s))` is idempotent (Λ2). Also test, as pure +cases, that a malformed surface is **rejected** (returns nil), not silently +corrupted, and that the awkward leaf values (embedded quotes, escapes, empty, +whitespace) round-trip. Grow the surface one construct at a time, each keeping the +gate green, exactly as a typed model grows one field at a time. + +But a green corpus gate certifies the corpus, not the construction. The corpus is +a sample; it contains the values someone happened to author, not the values the +model can represent. So every slice owes a second, **adversarial** pass that the +corpus cannot give: hunt for a **reachable model value with no surface**, a value +the type can hold and the projection can produce, for which `print` traps, force +unwraps, force-indexes an empty collection, or emits a surface that does not parse +back. The empty collection is the classic one: a `[String: [String: String]]` can +hold `["owner": [:]]`, an array-backed list can be empty, an optional-of-list can +be `.some([])`. If `print` is partial on any such value, the transform is partial +without a stated precondition, and the rule is violated even though the corpus is +green. The fix is always one of two shapes: make `print` **total** (the empty list +prints to nothing, never traps), and make the degenerate value **unrepresentable +in the normal form** (normalize it away, the absent-equals-empty idiom), so it can +neither force an empty wrapper nor crash. Pin the adversarial value as a passing +regression. Prefer an actual adversarial reader (a second person, or a subagent +critic that builds an executable harness and drives hostile values) over your own +reread: the author who built the construction is the worst auditor of it. + +## DO + +- State the round-trip law and the smallest slice before writing the grammar. +- Build both directions from one invertible description (a `ParserPrinter`). +- Make leaf encoders honestly invertible (escape and unescape); never rely on the + corpus being benign. +- Use a `Conversion`/partial-isomorphism for `map`; never a one-way function. +- Compose with `zip` / `oneOf` / `many` / `optional`, not hand-rolled descent. +- Gate every slice with a corpus round trip plus rejection and awkward-value cases. +- Make `print` total: every reachable model value has a surface. No force-unwrap, + no force-index of a possibly-empty collection, on the print side. +- Run an adversarial-construction pass per slice, separate from the corpus gate: + find a reachable value with no surface; if one exists, make print total and + normalize the degenerate value away, then pin it as a regression. +- Inherit exactness from a lower edge that is already proven; do not re-prove it. + +## DON'T + +- Do not write a parser and a printer separately and trust that they agree. +- Do not declare the round trip correct because the corpus passes; corpus-benign + is not invertible, and a corpus gate cannot find a reachable value the corpus + does not contain. Let an adversarial reader certify the construction. +- Do not leave `print` partial: a force-index or force-unwrap that traps on an + empty collection is a reachable value with no surface, not an edge case. +- Do not phrase `print` as a one-way render with a separately maintained `parse`. +- Do not grow the grammar on ad-hoc combinators that cannot compose into the + recursive, nested, heterogeneous structures the real format needs. + +## Acceptance check + +A repo with a bidirectional transform conforms when: (1) the round-trip law is +stated (in the design doc) before the grammar; (2) both directions derive from one +`ParserPrinter`-style description, and `map` uses a `Conversion`, not a one-way +function (grep finds the invertible algebra, not a standalone hand-written +emitter); (3) leaf encoders escape/unescape so Λ1 holds for all values; (4) a +corpus round-trip gate is green, with explicit rejection and awkward-value cases; +and (5) `print` is total over reachable model values, verified by an adversarial +pass that hunts a value with no surface, not by the corpus alone, with any such +value normalized away and pinned as a regression. A transform whose two directions +are independent functions fails this rule even if its tests currently pass; so +does one whose `print` traps on a reachable empty collection that the corpus +happens not to contain. diff --git a/docs/rules-swift/core/rules.md b/docs/rules-swift/core/rules.md new file mode 100644 index 0000000..9a61eda --- /dev/null +++ b/docs/rules-swift/core/rules.md @@ -0,0 +1,173 @@ +# Authoring Rule Files for an AI Coding Agent (MANDATORY) + +**Status: MANDATORY whenever you create, edit, split, merge, or deprecate a rule +file.** A rule file is an instruction an AI coding agent will read and obey under +load, with partial context, and without the chance to ask you what you meant. Its +job is to change the agent's behavior reliably. A rule that is vague, untestable, +or self-contradictory does not do that job; it adds tokens and noise while +changing nothing. This is the meta-rule: how to write the rules. It is a specific +application of `no-shortcuts-first-principles.md` (a rule with no acceptance check +is a stub passed off as done) and the authoring twin of `self-improve.md`, which +governs *when* a correction earns a rule at all. + +## What a good rule is + +A rule is **a behavior the agent must change, stated so an outside reader can check +whether it did.** Three properties, each non-negotiable: + +- **Imperative and unambiguous.** Use MUST / MUST NOT / NEVER / ALWAYS for binding + clauses, SHOULD for strong defaults, MAY for genuine options. "Try to write good + code," "follow best practices," "be consistent" are not rules; they are wishes. + Replace each with the specific, checkable behavior it gestures at. +- **Testable.** Every binding clause must be verifiable by someone other than its + author, ideally by a command. If you cannot describe how to catch a violation, + you cannot tell whether the rule is being followed, and neither can the agent. +- **Non-contradictory and scoped.** A rule must not fight another rule. When two + rules seem to conflict, the fix is almost always scope: name the condition under + which each applies, so exactly one governs any given case. + +## Structure of a rule file + +State the contract first, the mechanism second, the proof last. A rule file +should read top to bottom as: *what the rule is → how to apply it → how to confirm +it was applied.* + +1. **Title + status line.** One line naming the rule and its binding strength: + when it applies (always / on a named trigger), and what it governs. If your + tooling reads frontmatter (a `description`, file-glob, always-apply flag), + include it; if it does not, an opening status sentence carries the same load. +2. **Opening paragraph.** One or two sentences: the primary objective and why it + matters. The reader should know after this paragraph whether the rule applies + to their task. +3. **The rule itself.** The binding clauses, grouped by topic, general before + specific. Plain-prose framing, then bulleted MUST/MUST NOT clauses. Number or + name rules consistently so they can be cited (`§2`, "the silent-mismatch + clause"). +4. **Patterns and anti-patterns.** Show the correct shape and the tempting wrong + shape side by side. A WRONG/RIGHT pair teaches faster than either alone, and + the agent pattern-matches on examples as much as on prose. +5. **Acceptance check.** The command(s) or procedure that prove conformance. This + is the most important section and the most often skipped. See below. +6. **Companion rules.** Cross-references to the rules this one specializes, + complements, or depends on, so a reader lands in the right neighborhood. +7. **Why this exists.** A short paragraph on the failure that motivated the rule. + This is what lets a future reader judge whether the rule still earns its place. + +Not every rule needs every section. A three-line rule does not get a decision tree. +But the spine, *state → apply → prove*, is mandatory regardless of length. + +## When a rule is MANDATORY (and when it is not) + +Binding strength is a claim; do not inflate it. + +- **MANDATORY / MUST** is for behavior whose violation is a defect: it produces + wrong output, hidden debt, an irreversible mistake, or a broken invariant. + Reserve the strongest language for these. If everything is MANDATORY, nothing is. +- **SHOULD** is a strong default with named exceptions. Use it when the right + behavior is clear but a justified deviation exists. Name when the exception + applies. +- **MAY** is a real option with no preference. Use it sparingly; most "MAY"s are + actually a SHOULD with the condition left implicit. +- **Always-on vs. triggered.** An always-on rule is read every session and costs + tokens every session; spend that budget only on rules that genuinely apply + broadly. A triggered rule names its trigger precisely ("when adding a network + call," "when the change touches a schema") so it loads only when relevant. + +## The acceptance check is the rule + +A rule the agent can claim to follow without any way to catch a lie is not a rule; +it is a suggestion. **Every binding rule ships a way to verify conformance**, and +the strongest form is a command whose output decides the question. + +- Prefer a **runnable check**: a grep that MUST return empty, a linter invocation + that MUST pass, a test that MUST be green, a build that MUST succeed. State the + expected result (exit code, empty output, count) so there is no interpretation. +- Where no command exists, ship a **checklist** of observable conditions, each + phrased so two reviewers would agree on pass/fail. +- The check belongs **in the rule file**, next to the clause it proves, not in a + reviewer's head. A rule whose acceptance lives only in prose ("be thorough") has + no acceptance. +- Reading the rule is necessary but not sufficient. If a rule ships an acceptance + command and a session skipped it, that session did not follow the rule. Executing + the check is the act of compliance, not citing the prose. + +Example of the shape (the specifics belong to each rule): + +``` +## Acceptance check +Conforms when: `grep -rn 'TODO\|FIXME' src/` returns no lines added by this +change; the linter exits 0; and every new public symbol has a doc comment +(spot-check or a doc-coverage command). A change that adds a silenced symptom +fails this check even if the suite is green. +``` + +## Examples carry as much weight as prose + +The agent learns the rule from the example at least as much as from the clause. + +- **Pair WRONG with RIGHT.** Show the anti-pattern and the fix together. The + contrast is the lesson. +- **Make examples concrete and self-contained.** A generic example the reader + cannot map to a real case teaches nothing. Use a realistic snippet, name real + types, show the actual command. +- **Keep examples honest.** An example that would not actually pass the rule's own + acceptance check is worse than no example. Verify your examples against the rule. + +## DO + +- State binding strength explicitly and accurately; reserve MUST/NEVER for real + defects. +- Make every binding clause testable, and ship the test (command or checklist) in + the file. +- Open with the objective so a reader can decide relevance in one paragraph. +- Pair anti-patterns with corrected patterns; keep both concrete. +- Scope rules so none contradicts another; resolve apparent conflicts by naming + conditions. +- Cross-reference companion rules so the reader lands in the right neighborhood. +- Name the failure that motivated the rule, so its continued relevance is judgeable. + +## DON'T + +- Do not write a vague rule ("be consistent," "use best practices"); it changes no + behavior and costs tokens. +- Do not ship a rule with no way to verify conformance; an unprovable rule is a + suggestion. +- Do not inflate binding strength; if everything is MANDATORY, the word is noise. +- Do not let two rules contradict; do not paper over the conflict with prose, + scope them. +- Do not write an example you have not checked against the rule's own acceptance. +- Do not bury the acceptance check in someone's head; it belongs in the file. + +## Acceptance check + +A rule file conforms when: (1) it opens with a status/binding line and an +objective paragraph a reader can act on; (2) every binding clause uses precise +modal language (MUST / MUST NOT / SHOULD / MAY) and is individually testable; +(3) it ships an acceptance check, a runnable command with a stated expected result +where one is possible, otherwise an observable checklist, located in the file next +to what it proves; (4) at least one WRONG/RIGHT example pair appears for any +non-obvious clause, and each example would itself pass the rule; (5) no clause +contradicts another rule, or the conflict is resolved by explicit scope; and (6) +it names the failure that motivated it. A "rule" that is only aspirational prose +with no test, or whose every clause is MANDATORY, or whose examples were never +checked, fails this meta-rule even though it is syntactically a rule file. + +## Companion rules + +- `self-improve.md`: when a recurring correction earns a rule in the first place, + and the thresholds that gate rule creation. This file governs *how* to write the + rule that file decides to create. +- `no-shortcuts-first-principles.md`: the ethic this specializes. A rule with no + acceptance check is a stub passed off as done; disclosed limits are fine, hidden + gaps are the violation. +- `proof-discipline.md`: the model for acceptance checks that decompose a claim + into separately provable parts with explicit status labels. + +## Why this exists + +Rules that read well but cannot be checked drift silently: the agent claims +conformance, no command contradicts it, and the gap surfaces only as a defect +later. The fix is to make every rule carry its own proof of compliance, so +following the rule and verifying the rule are the same act. A rule set whose rules +are testable improves under its own weight; one whose rules are aspirational +accumulates noise. Portable to any rule set for any AI coding agent. diff --git a/docs/rules-swift/core/self-improve.md b/docs/rules-swift/core/self-improve.md new file mode 100644 index 0000000..34f9311 --- /dev/null +++ b/docs/rules-swift/core/self-improve.md @@ -0,0 +1,155 @@ +# When a Correction Earns a Rule (MANDATORY) + +**Status: MANDATORY whenever a correction, bug, or review note recurs.** A rule +set is only as good as its discipline about what enters it. Two failures are +symmetric and equally costly: leaving a recurring mistake uncodified, so it keeps +happening, and promoting a one-off into a permanent rule, so the set bloats with +noise that costs tokens every session and changes nothing. This rule sets the +thresholds: when a pattern has recurred enough to deserve a rule, and when it has +not. It is the upstream companion to `rules.md`, which governs *how* to write the +rule once this file says one is warranted. + +## The core question + +Before adding or updating a rule, answer: **has this corrected itself by being +told once, or does it keep coming back?** A rule is justified only by recurrence +or by stakes high enough that a single occurrence is one too many. A thing fixed +correctly the first time it was raised does not need a rule; it needs the fix. + +## Thresholds for creating a rule + +Treat these as the bar, not a ceiling. Meeting one is sufficient grounds to draft +a rule; meeting none means the correction stays a correction. + +- **Recurring pattern (≥3 occurrences).** The same shape of code, the same kind of + fix, or the same structural choice appears across **three or more files or + sessions**. Three is the signal that it is a pattern and not a coincidence. + Extract it into a rule with a concrete example drawn from the real cases. +- **Repeated mistake (≥2 occurrences).** The same error or its near-twin is made, + caught, and corrected **twice**. The second time is the evidence that telling it + once did not stick; write the rule so the third time is caught by a check instead + of a person. +- **Repeated review note (≥3 mentions).** The same feedback is given in reviews + **three or more times**. Recurring human review effort is exactly the cost a rule + exists to eliminate; promote it. +- **Measured win (>20% on a real resource).** An optimization or approach that + saves more than a fifth of a measured resource (time, memory, tokens, build) on + a real workload, with the measurement recorded, earns a rule so the win is not + re-discovered each time. + +## Stakes override the count + +Some things get a rule on the **first** occurrence, because the cost of the second +is unacceptable. Recurrence is not required when: + +- A **security or data-loss vulnerability** is discovered. One is enough. +- A change introduces a **breaking change with wide blast radius** (many files or + consumers affected). Codify the migration path immediately. +- A **new library, framework, or major dependency is adopted.** Its conventions + and gotchas become a rule at adoption, before the patterns scatter. +- A **compliance or correctness invariant** is introduced that must hold + everywhere. Make it a rule the moment it is known, not after it is first violated. + +In these cases the threshold is one, and the rule is written the same session the +need is recognized. + +## What does NOT earn a rule + +The symmetric failure is over-codification. Resist a rule when: + +- It happened **once** and the single correction resolved it, with no sign of + recurrence and no high-stakes reason to pre-empt a second. +- It is a **personal stylistic preference** with no defect behind it. Style with no + correctness or cost consequence is configuration, not a rule. +- It is **already covered** by an existing rule. The fix is to sharpen or + cross-reference that rule, not to add a second one that will drift out of sync + with the first. +- It is **so specific** that it will apply exactly once more, ever. A rule that + fires on a single future case is a comment in the wrong place. + +When in doubt, record the correction (a note, a memory, a commit message) and wait +for the recurrence that promotes it. An uncodified note costs nothing; a premature +rule costs tokens every session it is loaded. + +## Updating beats adding + +The default when a pattern recurs near an existing rule is to **strengthen that +rule, not spawn a new one.** Adding overlapping rules splits the truth across two +files that will disagree the next time one is edited. + +- **Better example found.** Replace the rule's example with the clearer real case. +- **New edge case discovered.** Add a clause or a note to the owning rule; do not + start a sibling. +- **Dependency or API changed.** Revise the affected rule in the same session the + change lands, so its examples and clauses do not go stale. +- **Two rules converging on one topic.** Merge them, keep the cross-references, and + delete the weaker. Fewer, sharper rules beat many overlapping ones. + +## Deprecating a rule + +A rule that no longer earns its tokens is removed deliberately, not deleted on a +whim and not left to rot. + +1. **Mark it deprecated** in place, with the reason and any replacement. +2. **Provide the migration path** to the rule or behavior that supersedes it. +3. **Update dependents** that cross-reference it before removal. +4. **Remove it** once nothing points at it. A rule no longer used, no longer + accurate, or fully absorbed by another is dead weight; cut it. + +## Keep the set lean + +The rule set is read under load; every always-on rule is a recurring tax. Review +periodically for: rules no longer used, examples gone stale against current code, +clauses now covered better elsewhere, and pairs that should be merged. The goal is +the **smallest set of testable rules that prevents the recurring failures**, not +the largest set of plausible advice. + +## DO + +- Promote a pattern to a rule at ≥3 occurrences across files or sessions; promote a + repeated mistake at ≥2; promote repeated review feedback at ≥3. +- Promote on the first occurrence when stakes are high (security, data loss, wide + breaking change, new dependency, correctness invariant). +- Strengthen or merge an existing rule before adding a new one on the same topic. +- Record one-off corrections as notes and wait for the recurrence that earns a rule. +- Deprecate with a migration path; remove only after dependents are updated. + +## DON'T + +- Do not codify a one-off that the single correction already fixed, absent a + high-stakes reason. +- Do not turn a bare stylistic preference into a rule; that is configuration. +- Do not add a rule that overlaps an existing one; sharpen the existing one instead. +- Do not write a rule so specific it will fire exactly once more. +- Do not let deprecated or stale rules linger; they tax every session that loads + them. + +## Acceptance check + +A rule addition conforms when: (1) it cites the recurrence that justified it (the +files, sessions, or reviews where the pattern appeared) **or** names the high-stakes +reason that overrides the count; (2) a search confirms no existing rule already +covers the same ground, or the existing rule was strengthened instead; (3) the new +or updated rule itself meets `rules.md` (testable clauses, acceptance check, honest +example); and (4) any rule it supersedes is deprecated with a migration path, not +left to drift. A rule introduced on a single low-stakes occurrence, or one that +duplicates an existing rule, fails this check even if the rule's text is well +written. + +## Companion rules + +- `rules.md`: how to write the rule this file decides is warranted, testable + clauses, acceptance checks, honest examples, accurate binding strength. +- `no-shortcuts-first-principles.md`: the ethic behind leanness, the smallest + correct set, not the largest plausible one; disclosed limits over hidden bloat. +- `proof-discipline.md`: the standard a new rule's own acceptance check should meet. + +## Why this exists + +A rule set decays in two opposite directions, and both hurt. Under-codify, and the +same mistake recurs because the correction never became a check. Over-codify, and +the set fills with one-off advice that costs tokens every session while preventing +nothing, and the signal drowns. Explicit thresholds (recurrence counts for ordinary +patterns, a count of one for high stakes) keep the set growing only where growth +pays, and a deprecation path keeps it shrinking where it no longer does. Portable to +any rule set for any AI coding agent. diff --git a/docs/rules-swift/core/systematic-debugging.md b/docs/rules-swift/core/systematic-debugging.md new file mode 100644 index 0000000..3daeb78 --- /dev/null +++ b/docs/rules-swift/core/systematic-debugging.md @@ -0,0 +1,86 @@ +# Systematic Debugging + +Find and articulate the root cause of any bug, test failure, or unexpected behavior before proposing or applying a fix. Symptom fixes are forbidden until you can explain why the symptom occurs. This rule is language- and platform-agnostic. + +This is the anti-shortcut procedure for bugs, the specialization of `no-shortcuts-first-principles.md` to the moment something is broken: silencing over solving is forbidden, and a fix you cannot explain is a cargo-cult edit. + +## Core rules + +### Rule 1: Reproduce first, investigate second, fix third + +Complete the phases in order. + +- Get a deterministic reproduction (a failing test, a sample input, a repro script) before reading code. +- Identify the smallest change that flips the outcome before naming it the cause. +- Do not propose a fix until you can describe the cause in one sentence. + +### Rule 2: The four phases + +``` +1. REPRODUCE + - capture the exact failing command or input + - confirm it fails consistently + - if flaky, treat the flake as the primary bug (the race is the cause) + +2. ISOLATE + - bisect: which component, which file, which line + - for tests: shrink to the smallest failing assertion + - for crashes: get the full stack trace with line numbers + +3. EXPLAIN + - state the root cause in one sentence + - identify the invariant that was violated or the assumption that was wrong + - if you cannot explain it, you have not found the cause yet + +4. FIX + - the smallest change that addresses the cause, not the symptom + - add or update a test that would have caught it + - re-run the suite and cite the result (see testing-discipline.md) +``` + +### Rule 3: Common pitfalls to check first + +Consider these before deeper investigation. They are the recurring shapes a root cause tends to take, independent of language: + +- **Null / absent value**: trace where a value that should exist became absent, rather than guarding the read site. +- **Concurrency**: was the failing code on the expected thread, task, or execution context? Look for shared mutable state, ordering assumptions, and missing synchronization. +- **Dependency wiring**: did the failing path receive the dependency it expected, or did a default / live implementation leak in where a test double was intended? +- **State and lifetime**: a value read before it was set, after it was freed, or from a stale copy. +- **Type / contract mismatch**: a fix that "looks right" but the compiler or runtime rejects is often hiding a wrong assumption about the contract. +- **Cache or stale build**: rebuild from clean if behavior contradicts the visible source. + +### Rule 4: Banned behaviors during debugging + +Do not: + +- Apply a fix to make the test pass without understanding why it was failing. +- Wrap the failing call in an error-swallowing construct to silence the symptom. +- Add a synchronization or context annotation to "fix" a concurrency error without confirming the call site needed it. +- Disable, skip, or mark the failing test expected-to-fail and move on. +- Insert a sleep or retry to mask a timing bug whose race you have not explained. +- Attempt multiple fixes in parallel hoping one sticks. + +If a fix does not hold, return to phase 2 (Isolate). Do not stack guesses. + +### Rule 5: Reporting + +State, in this order, when reporting on a debugging session: + +1. The reproduction (command or input + expected vs actual). +2. The root cause (one sentence). +3. The fix (what changed and why it addresses the cause, not the symptom). +4. The verification (which test now passes that did not before, with counts). + +## Anti-patterns + +- "I think it might be X, let me try" applied to multiple guesses in series. +- Adding logging without a hypothesis to test. +- Reading the whole file looking for "something off" instead of bisecting. +- Calling a flake "transient" without finding the race. +- Declaring a fix complete because the failing test now passes, without checking why the others still pass. + +## Companion rules + +- `no-shortcuts-first-principles.md`: the ethic this specializes. Silencing a symptom instead of fixing its cause is the canonical shortcut; if you reach for one, the cause is not understood yet. +- `testing-discipline.md`: the fix is not done until a test that would have caught the bug exists and the suite is re-run with cited counts. +- `proof-discipline.md`: how to frame and label what the fix proves versus what remains open. diff --git a/docs/rules-swift/core/testing-discipline.md b/docs/rules-swift/core/testing-discipline.md new file mode 100644 index 0000000..3677d73 --- /dev/null +++ b/docs/rules-swift/core/testing-discipline.md @@ -0,0 +1,102 @@ +# Testing Discipline + +When a change touches code, that change is not complete until tests have run. This rule defines when to run existing tests, when to write new ones, and what to report. It applies in any language and on any platform. + +If tests exist for the area being changed, run them. If no tests exist for the area being changed and the change is non-trivial, write them. Type-checks and successful builds are not tests. + +This rule applies to any code change: new feature, bug fix, refactor, dependency bump, configuration change that affects behaviour. It does not apply to pure prose edits, documentation-only commits, or single-file readme touches. + +## Core rule + +### Rule 1: If a test suite exists, run it. + +Before declaring the change done, run the project's test command. The command is whatever the project's tooling defines, for example: + +- Compiled / packaged languages: the package or build system's test target. +- Scripting languages: the project's chosen runner (`pytest`, `python -m unittest`, `npm test`, `pnpm test`, `yarn test`, `cargo test`, `go test`, `mvn test`, and so on). +- Shell scripts: `bats`, `shellspec`, or the project's chosen runner. +- Anything else: the command in the project's `README.md`, `Makefile`, `package.json` scripts block, or equivalent task-runner config. + +The required output is the actual runner producing pass/fail counts, not a successful compile. A clean build is not a test run. A type-check pass is not a test run. The test runner must execute and report. + +Failures block the commit. Investigate and fix before shipping. Disabling, skipping, or marking a test expected-to-fail is allowed only when the disabled test is genuinely unrelated to the current change and a follow-up issue is filed. + +### Rule 2: If no test suite exists for the touched area, write one. + +If the change adds a function, a class, a route, a CLI verb, a database view, a schema migration, or any other addressable unit of behaviour, that unit gets at least one test. The test must: + +- Exercise the unit by calling the public entry point (not by re-implementing it inside the test). +- Use real fixtures or a real local resource where the project supports one (a real on-disk database, a real temp directory, a real fixture file). Mocks only when the dependency is a network call, an external service, or something the test infrastructure cannot reach. +- Fail when the implementation is wrong. Author a deliberate-bug version of the unit and confirm the test catches it. A test that passes against a broken implementation is not a test. +- Live in the project's existing test target / directory. Do not create a new top-level test scaffold beside a working one. + +Trivial changes are exempt: a single-character typo fix, a comment, a doc string. Use judgement, but the bar is low: if there is *any* behaviour change, a test exists or gets written. + +### Rule 3: Match the project's testing style. + +A project using one test framework does not get a different framework's stubs grafted onto it. Read one or two existing tests, mirror their structure (fixtures, naming, parameterisation), and write the new test in the same idiom. Inconsistency in test style is itself a defect. + +### Rule 4: Real tests beat smoke tests. + +A "smoke test" that asserts only that the unit returns without throwing is the minimum acceptable form, not the target. Where the project has fixture-driven tests, the new test loads or constructs realistic input and checks realistic output. Prefer fixture-driven tests over smoke-only tests, and convert smoke-only tests into fixture-driven ones when you touch them. + +A test that does not distinguish a correct implementation from a wrong one is no test. If a test can be satisfied by a constant return (`null`, `0`, `true`), strengthen it until it cannot. + +### Rule 5: State the result in the response. + +When reporting that a change is done, name the test command run and the result counts. "Ran the suite: 47 tests, 0 failures" is the deliverable, not "I made the change." If someone is going to verify the work, the test result is the verification, so it must be cited. + +If tests were not run because none exist and the change was too small to justify writing one, say so explicitly. Do not let "I changed X" stand alone as a completion claim when there was a test command available. + +### Rule 6: When testing a tool / CLI / service, verify on raw output, not a format-assuming parser. + +Black-box testing of a running tool (a CLI, a server, an API) produces false findings when you assert on a brittle parse of its output instead of the output itself. Before believing or filing a finding: + +- **Re-run the exact command raw**, with no pipe and no parser, and read the literal output. Do not let `cmd | grep/awk/head` be the evidence. Different surfaces of the same tool may render differently; a single parser will silently lie on the ones it does not match. +- **Assert on a semantic marker in the body**, not on line-format or a count of lines matching a pattern. ("returns a result whose title is X", not "at least one line starts with a bracket".) +- **Check exit codes without a pipe.** `cmd | head; echo $?` reports `head`'s status, not `cmd`'s. Use `cmd >/dev/null 2>&1; echo $?` or the shell's pipe-status array. +- **Verify against the tool, never a doc/README example.** A snippet in docs may be stale; the tool is ground truth. Testing an example pulled from the README and calling the round-trip broken is a false bug if the README is outdated. +- **A maintainer's direct experience overrides your harness.** When they say a finding is wrong ("we use it, it works"), re-verify raw immediately and retract if wrong. Do not defend a harness artifact. + +## Exceptions + +The rule does not require tests for: + +- Documentation-only changes (docs, README, code comments). +- Renames where the compiler or type-checker proves equivalence. +- Configuration changes that have no behavioural effect (formatter config, editor settings). +- Generated files (lockfiles, build output, vendored dependencies). + +When in doubt, write the test. The cost of writing a test you did not strictly need is small. The cost of shipping a regression to a system without a test that would have caught it is large. + +## Mechanical enforcement + +Where the project supports it, add the test run to: + +- A pre-push hook that runs the project's test command and refuses the push on non-zero exit. +- A CI workflow that runs tests on every change request and blocks merge on red. +- A pre-commit hook for fast-running suites (under a few seconds). Slow suites belong on push or in CI. + +The CI gate is the durable backstop. The local hook catches things before they hit CI. Neither replaces the responsibility to run tests during the work; both prevent forgetting at the boundary. + +## Why + +Tests are the only artifact that can answer "does this change do what I claim it does" without re-reading the code. A change that compiles, type-checks, and was reviewed is still untested. The reviewer assumed the author ran tests; the author assumed the type-checker was enough; the regression ships. The rule closes that loop by making "did you run the tests" a question with a citable answer. + +The cost of running an existing suite is seconds to minutes. The cost of debugging a regression after it ships is hours, plus the cost in trust that a doc, a number, or a guarantee in the codebase is reliable. Run the tests. + +## Companion rules + +- `no-shortcuts-first-principles.md`: the ethic this specializes. A change reported done without its tests run is partial coverage dressed as full. +- `systematic-debugging.md`: when a test fails, root-cause it; do not silence it to make the suite green. +- `proof-discipline.md`: a proven layer is one whose tests ran and whose counts are cited. + +## Triggers (when to apply this rule) + +Apply this rule when: + +- Starting any code change task (feature, fix, refactor, dependency bump). +- About to declare a code change done. +- Reviewing whether a change is ready to commit. +- Designing a new project's test infrastructure or CI workflow. +- Auditing an existing project for tests-vs-code coverage. diff --git a/docs/rules-swift/core/verification.md b/docs/rules-swift/core/verification.md new file mode 100644 index 0000000..d4d0fa0 --- /dev/null +++ b/docs/rules-swift/core/verification.md @@ -0,0 +1,78 @@ +# Verification Before Completion + +**Status: applies to every completion claim, in any language or discipline.** + +Never claim work is complete, fixed, or passing without fresh evidence from the relevant verification command. Every completion claim must be backed by command output captured in the same response. The same discipline applies whether the work is code, a written document, or a data transformation: the claim "it's done" is only as good as the check you ran and quoted. + +## Core rules + +### Rule 1: No claim without fresh evidence + +Run the verification command in the same response where you make the claim. +- MUST run the command, not assume it from earlier output +- MUST quote the relevant lines (exit code, failure count, error summary) +- MUST NOT extrapolate ("the lint passed earlier so the build should pass") +- MUST NOT use phrases like "should pass", "looks good", "I believe it works" + +### Rule 2: Match the command to the claim + +Every claim has a command that decides it. Run that command, not a proxy for it. + +| Claim | Required check | What to confirm | +|---|---|---| +| "Build succeeds" | the project's build command | exit 0, no errors | +| "Tests pass" | the test runner (or a targeted filter) | 0 failures, expected count of tests ran | +| "Lint clean" | the linter with the project config | 0 errors (warnings reported separately) | +| "Format clean" | the formatter in check/lint mode | exit 0, no diffs | +| "Bug fixed" | the test or input that reproduced the bug | now passes; state which one | +| "Refactor preserved behavior" | the full test suite | 0 failures | +| "Numbers are correct" | the script that produces the numbers | values match what the doc/report states | +| "Document builds / links resolve" | the doc build or link checker | exit 0, no broken references | + +If you do not have the required command, state that explicitly. Do not guess. + +### Rule 3: Failure reporting + +Report partial results honestly. +- MUST list which checks were run, which passed, which failed, which were skipped +- MUST NOT bundle a partial run under a "done" headline +- MUST quote the first 3 to 5 errors verbatim if any check failed + +### Rule 4: Pre-commit gate + +Run the project's full local gate, in its defined order, before claiming a change is commit-ready: format, then lint, then build, then test (whichever of these the project defines). Cite each step's outcome. Git hooks may run a subset; that is not a substitute for explicit citation in the response. + +### Rule 5: Boundary of "done" + +**Done means**: the change is staged, the gate's commands have output that confirms success, and any user-visible behavior the change affects has been exercised (a UI smoke for screen changes, an integration check for engine/API changes, a re-measurement for a documented number, etc.). + +**Not done**: the typecheck succeeded, the code looks right, tests "should" pass, "ready to commit" without running the gate. + +## Mechanical enforcement: local and CI + +Every gate a machine can decide should run in two places: a local git hook, so a violation is never committed, and CI, so a violation is never merged even if the local hook was skipped. The local hook catches it early; CI is the backstop. + +A typical split: + +| Gate | Local hook | CI job | +|---|---|---| +| Commit-message style | `commit-msg` | `style` | +| File-content style | `pre-commit` | `style` | +| Structural/naming rules | `pre-push` | `style` | +| Format clean | `pre-push` | `build` | +| Lint clean | `pre-push` | `build` | +| Build and tests pass | `pre-push` | `build`/`test` | + +Enable the local hooks once after cloning, per the project's hook setup (for example, pointing the hooks path at the tracked hooks directory). A gate that exists only in CI still lets a broken commit land locally; a gate that exists only locally is skipped the moment someone bypasses the hook. Both layers, or neither claim is safe. + +## Anti-patterns + +- "All set" with no command output in the response +- "Tests pass" while the run was partial (a single-suite filter while another suite is broken) +- Treating a successful build as proof that tests pass +- Quoting old output from earlier in the conversation as if it were fresh +- Marking a task item complete before running the gate + +## Why this exists + +A green checkmark in your memory is not evidence; a fresh command in this response is. The gap between "I changed the code so it should work" and "I ran the check and here is the output" is exactly where regressions ship. Citing the command and its result makes the claim auditable by the reader instead of trusted on faith, and it forces the author to actually run the thing before saying it's done. diff --git a/docs/rules-swift/core/writing-plans.md b/docs/rules-swift/core/writing-plans.md new file mode 100644 index 0000000..4e3b4c6 --- /dev/null +++ b/docs/rules-swift/core/writing-plans.md @@ -0,0 +1,96 @@ +# Writing Plans for Multi-Step Work + +For any change spanning multiple modules, multiple commits, or multiple sessions, write an implementation plan after the design is approved (see `brainstorming.md`) and before touching code. The plan turns the design into ordered, verifiable tasks. + +## Core rules + +### Rule 1: When a plan is required + +Write a plan when: + +- The change touches two or more modules or packages. +- The change spans two or more commits to land safely. +- The change has ordering constraints (e.g., "regenerate the API before updating callers"). +- The change is expected to take more than a single working session. +- The change is a migration with a fallback or rollback path. + +**MAY skip** when the design itself was already a one-module, one-commit shape. + +### Rule 2: Plan structure + +Include, in this order: + +```markdown +# : + +**Goal:** +**Approved design:** + +## File map + +| Path | Change | Notes | +|---|---|---| +| src/foo/Bar. | new | holds the BarClient interface | +| src/app/AppEntry. | edit | wire BarClientLive into the dependency container | +| ... | ... | ... | + +## Tasks (ordered) + +### T1. +**Files:** src/foo/Bar. +**Does:** +**Verifies:** build the Foo module +**Commit:** feat(foo): introduce BarClient interface + +### T2. ... + +## Constraints + +- Layer order: Foo MUST NOT import any higher-level module +- The API is regenerated as part of T3, before T4 starts +- ... + +## Test plan + +- T1 unit tests live alongside the Foo module +- T4 integration test exercises the full chain with the injected double +- The pre-commit verification gate runs on each task + +## Done definition + +- All tasks committed +- Formatter and linter clean across touched modules +- Full build green +- Full test suite green +- Changelog / commit messages convey the externally visible story +``` + +### Rule 3: Tasks are independently verifiable + +- Each task ends in a runnable verification command (build, run a test filter, lint a file). +- Each task ends in a commit with `(): summary`. +- Tasks are ordered by dependency, not by author convenience. +- A task that cannot be verified on its own should be merged into the next one. + +### Rule 4: Plans are living documents + +Update the plan when reality diverges: + +- Discovered a missing file: add a task; do not silently widen an existing task. +- Found that two tasks merge: collapse them with a note. +- Hit an unforeseen dependency: stop, surface it, re-approve before continuing. + +The plan in the repo MUST match what the implementation actually did. Stale plans are worse than no plan. + +### Rule 5: Save location + +Default: `docs/plans/YYYY-MM-DD-.md` in the repo where the work happens. Override only if the project's conventions specify a different location. + +## Anti-patterns + +- Writing the plan AFTER starting implementation. +- Tasks that say "implement everything for module X" with no breakdown. +- Tasks with no verification command. +- Plans without a file map (you cannot verify scope without one). +- Treating the design and the plan as the same thing (the design is "what and why"; the plan is "in what order, with what proofs"). +- Bundling unrelated cleanup into a feature plan (separate plans per concern). diff --git a/docs/rules-swift/cross-platform.md b/docs/rules-swift/cross-platform.md new file mode 100644 index 0000000..6477eda --- /dev/null +++ b/docs/rules-swift/cross-platform.md @@ -0,0 +1,538 @@ +# Cross-platform Swift (Apple + Linux + Windows) + +How to structure Tiledown's Swift so the same sources build and run on Apple platforms, Linux, and Windows without silent breakage. + +Load on demand. Triggers: `canImport`, `Linux`, `FoundationNetworking`, `Darwin`, `Glibc`, `swift-system`, `swift-foundation`, cross-platform, manifest, `.when(platforms:)`, `os(Linux)`, `os(iOS)`, `os(macOS)`, `os(visionOS)`, `os(tvOS)`, Vapor, AsyncHTTPClient, Hummingbird. + +Grounded in Swift Evolution and `swiftlang/swift-foundation`. The TileKit engine targets macOS and Linux (a server-style Apple + Linux split), so it falls under Topology 2/3 below. Subprocess and shell-out are available on both macOS and Linux; the engine may use them. + +## Linux portability and the platform seam (mandatory) + +The engine targets macOS and Linux. Two obligations follow: + +1. Guard every platform-divergent line, preferring `#if canImport()` over `#if os(...)` so future platforms inherit the right branch. +2. Check Linux availability before adding a dependency. Many Apple frameworks, and the packages that wrap them, do not build on Linux. + +When the same functionality needs a different implementation per platform, abstract it behind a protocol seam; do not branch at call sites. Define a foundation-only protocol (see [shared-protocols.md](shared-protocols.md)), implement it once per platform (a macOS target using the Apple package, a Linux target using a Linux-available package), and let the composition root wire the correct one (see [dependency-injection.md](dependency-injection.md)). The core depends only on the protocol. + +```swift +public protocol ClockService: Sendable { var now: Date { get } } +#if canImport(Darwin) +let clock: any ClockService = DarwinClock() +#else +let clock: any ClockService = LinuxClock() +#endif +``` + +## Three topologies (pick one before designing) + +The rest of this rule depends on which topology your target is in. Mixing them silently is the root cause of most cross-platform breakage. + +1. **Apple-only.** iOS, macOS, plus optional visionOS/tvOS/watchOS. UI-heavy, SwiftUI or UIKit/AppKit primary. No Linux build, no server-side targets. +2. **Apple clients + Linux server (split package).** UI lives on Apple, one or more server products build to Linux. The Linux-buildable surface is narrow (typically a single server product). Apple-only stuff is wrapped in conditional product/target arrays in `Package.swift`. +3. **Cross-platform library or CLI.** Runs identically on Apple + Linux + Windows. No UI. Probably uses URLSession-via-FoundationNetworking, Darwin/Glibc conditionals, swift-system. The TileKit engine lives here (macOS + Linux): both platforms support subprocess and shell-out, so the engine may spawn subprocesses when needed. + +A "Linux UI" topology technically exists but is not production-grade. Do not try to render SwiftUI on Linux; if a server needs UI, ship HTTP responses (templates, JSON for an SPA), not native widgets. + +## Three layers of platform conditioning + +Each topology uses some combination of these. The layer determines which directive is correct. + +### Layer A: `Package.swift` manifest + +Use `#if os()` only. **Never `#if canImport()`** in the manifest. The manifest is parsed eagerly; `canImport` is evaluated lazily and silently breaks Linux CI. + +Apple-only products and targets go in conditional arrays so non-Apple builds see an empty list: + +```swift +#if os(iOS) || os(macOS) +let appleOnlyProducts: [Product] = [ + .singleTargetLibrary("TileDownUI"), + .singleTargetLibrary("TileDownComponents"), + // ... +] +#else +let appleOnlyProducts: [Product] = [] +#endif +``` + +Same pattern for `appleOnlyTargets`. Core engine products (e.g. `TileKit`) stay unconditional. + +### Layer B: Swift source files + +Prefer `#if canImport()` over `#if os()` for framework gating. canImport is robust to new Apple platforms inheriting the framework; explicit platform lists need updating each time a new OS ships. + +Canonical use cases: + +- `#if canImport(UIKit)` for UIKit types (iOS, tvOS, visionOS) +- `#if canImport(AppKit)` for AppKit types (macOS) +- `#if canImport(FoundationNetworking)` for URLSession on Linux (see Server patterns below) +- `#if canImport(Darwin)` / `#elseif canImport(Glibc)` for low-level POSIX C (read, write, ioctl) +- `#if canImport(os)` for Apple's unified logging (`os.log`) + +Reserve `#if os()` for behaviour that genuinely varies by OS even when the same framework is present, e.g. iOS-only SwiftUI modifiers that exist alongside macOS SwiftUI. + +### Layer C: Per-target dependency conditioning + +For a target that is mostly cross-platform but has one Apple-only dependency, use `.when(platforms:)`: + +```swift +.target( + name: "TileRenderer", + dependencies: [ + "TileModels", + .product(name: "CryptoKit", package: "swift-crypto"), + ].when(platforms: [.macOS]) +) +``` + +Avoid spreading platform conditioning across both Layer A and Layer C for the same target; pick one place per concern. + +## UI cross-platform patterns + +These patterns (Patterns 1-7 and Pattern 13: iOS-only SwiftUI modifiers, UIKit/AppKit app shells, the forward-compat SDK shim, and the `@available(iOS ...)` examples) describe the planned native Apple UI app, NOT the TileKit engine. The engine targets macOS + Linux and has no UI. Apply these only in the app tier. + +Linux UI is out of scope (no SwiftUI on Linux). + +### Pattern 1: Apple-only UI file (outer gate) + +A SwiftUI file that has no Linux meaning gets a file-level gate: + +```swift +// TileDownUI/MainTabs.swift +import SwiftUI + +#if os(iOS) || os(macOS) + +public struct MainTabs: View { + public var body: some View { ... } +} + +#endif +``` + +The whole file is skipped on Linux. This is the default for UI source files in an Apple-clients-plus-Linux-server topology. Without the outer gate, a Linux build pulling the same source tree hits compile errors on SwiftUI. + +### Pattern 2: iOS-only SwiftUI modifier on a cross-Apple view (inline gate) + +A view that runs on both iOS and macOS, with one modifier that exists only on iOS: + +```swift +public struct TileListView: View { + public var body: some View { + List(tiles) { ... } + #if os(iOS) + .toolbar { EditButton() } // iOS-only + .listStyle(.insetGrouped) // iOS-only + .navigationBarTitleDisplayMode(.inline) // iOS-only + #endif + } +} +``` + +Use this when the file is already inside an Apple-only outer gate (Pattern 1), and only specific SwiftUI modifiers need iOS-vs-macOS branching. Common iOS-only modifiers: `.toolbar { EditButton() }`, `.listStyle(.insetGrouped)`, `.navigationBarTitleDisplayMode()`, `.keyboardType()`, `.textInputAutocapitalization()`. + +macOS-only modifiers (rarer): window-style toolbars, `.toolbar(.windowToolbar, ...)`, `.windowResizability()`. + +### Pattern 3: UIKit/AppKit framework bridge (`canImport`) + +When the same conceptual API ships under different framework names on each platform: + +```swift +// TileColors/TileColors.swift +#if canImport(UIKit) +import UIKit +extension Color { + public static let systemBackground = Color(uiColor: .systemBackground) + public static let systemGroupedBackground = Color(uiColor: .systemGroupedBackground) +} +#elseif canImport(AppKit) +import AppKit +extension Color { + public static let systemBackground = Color(nsColor: .windowBackgroundColor) + public static let systemGroupedBackground = Color(nsColor: .underPageBackgroundColor) +} +#endif +``` + +Bridges from UIKit/AppKit primitives into unified SwiftUI types (Color, Image, Font). Use `canImport` on each branch, not `os()`. The `Color` API itself is cross-Apple; only the bridge to the framework-specific colour is platform-specific. + +### Pattern 4: Layered `canImport` outer + `os()` inner + +The combined pattern when both the framework gate AND the per-platform behaviour matter: + +```swift +// TileColors/Color+Dynamic.swift +#if canImport(UIKit) +import UIKit + +extension Color { + public static func dynamic(light: Color, dark: Color) -> Color { + Color(uiColor: UIColor { trait in + #if os(iOS) + return trait.userInterfaceStyle == .dark ? dark.uiColor : light.uiColor + #else + // tvOS, visionOS handle dynamic colours differently + return light.uiColor + #endif + }) + } +} +#elseif canImport(AppKit) +import AppKit +// macOS dynamic-color implementation +#endif +``` + +The outer `canImport(UIKit)` gates whether UIKit is available; the inner `os(iOS)` distinguishes iOS from other UIKit-using platforms (tvOS, visionOS). Useful when one framework has multiple consumers with different APIs. + +### Pattern 5: Apple-only logging via `canImport(os)` + +Apple's unified logging (`os.log`, `Logger`) does not exist on Linux. Gate on the framework, fall back to `print` on Linux: + +```swift +#if canImport(os) +import os.log + +public struct TileLogger { + private let logger: Logger + + public init(subsystem: String, category: String) { + self.logger = Logger(subsystem: subsystem, category: category) + } + + public func info(_ message: String) { logger.info("\(message, privacy: .public)") } + public func error(_ message: String) { logger.error("\(message, privacy: .public)") } +} +#else +public struct TileLogger { + public init(subsystem: String, category: String) {} + + public func info(_ message: String) { print("[INFO] \(message)") } + public func error(_ message: String) { print("[ERROR] \(message)") } +} +#endif +``` + +Using `canImport(os)` rather than `os(macOS) || os(iOS) || os(visionOS) || os(tvOS) || os(watchOS)` is the gold-standard pattern. Future Apple platforms inherit `os` automatically; explicit platform lists rot. + +### Pattern 6: SwiftUI app target per platform, shared feature package + +Separate Xcode projects for iOS and macOS apps, same SwiftUI feature package providing the root view. Keep the platform-specific app shells thin; put the shared UI in one feature package. + +### Pattern 7: UIKit (iOS) and AppKit (macOS) app shells, shared feature package + +When the app target must be UIKit (iOS scene delegate) or AppKit (NSWindowController), with the feature package providing platform-specific factory functions (`createMainWindow(for:)` / `createWindowController()`). Cross-platform abstractions for the shared types (`PlatformView`, `PlatformViewController`) live in a native-UI package. + +### Pattern 13: Forward-compat shim for unreleased Apple APIs + +When the next SDK release will ship a new SwiftUI / Apple API and you want call sites to use it now without rewriting later, build a forward-compat wrapper. Single edit when the real API ships; zero call-site changes. + +**Shape:** + +1. A wrapper struct on `View` (or whatever the host type is) that takes the wrapped content. +2. A namespace accessor as a computed property: `var upcoming: SwiftUIUpcoming`. Call sites read `view.upcoming.(...)`. +3. **Mirror types** (`UpcomingGlass`, `UpcomingGlassEffectTransition`, etc.): enums or structs with the same shape as the not-yet-shipped Apple types. They exist today so call sites can reference them; when the real types ship, a single `#if false` to real-mapping flip converts them. +4. **Fallback bodies** on the wrapper's methods that return the closest current-SDK approximation. `glassEffect` returns `content.clipShape(shape)`; `glassEffectTransition` returns `content` unchanged; etc. No call-site `if #available` ceremony needed. +5. **`#if false` blocks** holding the future mapping from mirror types to real Apple types, annotated with the future `@available(iOS 26, macOS 26, *)` (or whatever the target SDK is). When that SDK is the build target, flip `#if false` to `#if canImport()` or just remove the gate. + +**Worked example (abbreviated):** + +```swift +import SwiftUI + +public struct SwiftUIUpcoming { + public let content: Content + public init(_ content: Content) { self.content = content } +} + +@available(iOS 18, macOS 15, *) +public extension View { + var upcoming: SwiftUIUpcoming { SwiftUIUpcoming(self) } +} + +@available(iOS 18, macOS 15, *) +public enum UpcomingGlass: Equatable, Sendable { + case regular + case clear + case tinted(Color?) + case interactive(isEnabled: Bool) + // ... mirror the upcoming Apple type's cases +} + +@MainActor +@available(iOS 18, macOS 15, *) +public extension SwiftUIUpcoming where Content: View { + func glassEffect(_ glass: UpcomingGlass = .regular, in shape: some Shape = Capsule()) -> some View { + // Fallback for current SDK. Real glassEffect ships in iOS 26 / macOS 26. + content.clipShape(shape) + } +} + +// Real API mapping. Flip to `#if canImport(...)` or remove gate when iOS 26 / macOS 26 is the build target. +#if false +@available(iOS 26, macOS 26, *) +extension UpcomingGlass { + var toGlass: Glass { + switch self { + case .regular: .regular + case .clear: .clear + case .tinted(let color): .regular.tint(color) + case .interactive(let isEnabled): .regular.interactive(isEnabled) + } + } +} +#endif +``` + +**When to use:** + +- A new SwiftUI / UIKit / AppKit API is announced for the next major Apple release and you want call sites to start adopting it before you can build against the new SDK. +- The new API's shape is stable enough that you can mirror its types reasonably (single Apple beta release usually shows the final surface). +- You want a single switchover point when the new SDK ships, not a sweep through every call site. + +**When NOT to use:** + +- The API's shape is still in flux (avoid mirror-type rot). +- The fallback would be misleading (e.g. a security-sensitive API where "no-op fallback" silently weakens the contract; better to gate the call site explicitly with `if #available`). +- The codebase ships before the new SDK is final and you do not actually need pre-adoption (just wait). + +**Naming:** + +The accessor name and wrapper name are project conventions. `upcoming` and `SwiftUIUpcoming` are the defaults. Mirror types prefixed with `Upcoming` (e.g. `UpcomingGlass`) for findability. Do not use `Forward*` or `forwards`; the name is too generic and collides with view-forwarding patterns. + +**Aging:** + +When the new SDK ships: + +1. Replace `#if false` with the real availability gate (`#if canImport()` or just remove the gate if the SDK is the build minimum). +2. Replace fallback method bodies with `if #available { real API } else { fallback }`. +3. Eventually, when the deployment target moves to the new SDK, delete the wrapper entirely and rewrite call sites to use the real API directly. Plan for this; the wrapper is transitional, not permanent. + +## Server cross-platform patterns + +A Linux server topology typically uses Vapor + AsyncHTTPClient + Fluent + OpenAPI. The key design decision: **avoid URLSession on the server path entirely**. AsyncHTTPClient abstracts POSIX networking and works on Linux without source-level conditionals. + +### Pattern 8: AsyncHTTPClient over URLSession (server pattern) + +For any server-side HTTP code that needs to build on Linux, use `swift-server/async-http-client`: + +```swift +import AsyncHTTPClient +import NIOHTTP1 + +let httpClient = HTTPClient(eventLoopGroupProvider: .singleton) +let request = HTTPClientRequest(url: "https://example.com/api") +let response = try await httpClient.execute(request, timeout: .seconds(30)) +let body = try await response.body.collect(upTo: 1024 * 1024) +``` + +No `#if canImport(FoundationNetworking)` needed. AsyncHTTPClient is the canonical cross-platform HTTP client on the server side. + +### Pattern 9: Vapor (or Hummingbird) on Linux + +Vapor + Fluent-SQLite + OpenAPI builds and runs on Linux out of the box. Manifest declares the package dependencies; no source-level platform conditionals required for the server targets. Production deployment is typically via Docker (`swift:6.0-jammy` or later). + +Anti-pattern: pulling `URLSession`-based code into a Vapor handler. The handler now needs FoundationNetworking conditional + the rest of the rule below. + +### Pattern 10: FoundationNetworking conditional (library pattern, not server) + +For cross-platform library code that uses URLSession (Topology 3), conditionally import FoundationNetworking BEFORE Foundation: + +```swift +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif +import Foundation + +let url = URL(string: "https://example.com/api")! +let (data, response) = try await URLSession.shared.data(from: url) +``` + +`URLSession` lives in `Foundation` on Apple and in `FoundationNetworking` on Linux (where `Foundation` is `swift-corelibs-foundation` and re-exports `FoundationNetworking`). Conditionally importing `FoundationNetworking` first ensures the type is in scope on both platforms. + +Reference: `swiftlang/swift-foundation/README.md` documents the three-tier architecture (Darwin Foundation.framework / `swift-foundation` toolchain / `swift-corelibs-foundation` Linux fallback). + +### Pattern 11: Darwin / Glibc conditional for low-level C (or use swift-system) + +For raw POSIX (read, write, ioctl, stat, file descriptors): + +**Legacy pattern (still works):** + +```swift +#if canImport(Darwin) +import Darwin +#elseif canImport(Glibc) +import Glibc +#endif + +var ws = winsize() +_ = ioctl(STDOUT_FILENO, TIOCGWINSZ, &ws) +``` + +**Preferred modern pattern, `apple/swift-system` + `CInterop`:** + +```swift +import SystemPackage + +let fd = try FileDescriptor.open("/path", .readOnly) +defer { try? fd.close() } +let bytes = try fd.read(into: buffer) +``` + +`swift-system` provides a unified, type-safe surface over Darwin/Glibc/Windows. Reference: SYS-0008 (CInterop.stat back-deploy), SE-0063 / SE-0208 (system packages). + +When to use which: legacy code or operations not covered by `swift-system` use the Darwin/Glibc canImport pattern; new code reaches for `swift-system` first. + +### Pattern 12: Logging on the server path (`canImport(os)` + print fallback) + +Pattern 5 (logging via `canImport(os)`) applies to server code too. On Linux the logger falls back to `print()`; in production Linux deployments, redirect stdout/stderr to the host's logging facility (journald, Docker logs, etc.). + +For more sophisticated logging that's cross-platform native, use `apple/swift-log` (`Logging` package, SSWG standard). Single `LogHandler` protocol works identically on Apple and Linux. + +## Linux-buildable target rules (the parity gaps) + +`swift-corelibs-foundation` (the Linux Foundation re-implementation) has improved significantly in Swift 5.9 / 6.x but still has gaps. Avoid these in any code that must run on Linux: + +- `NSKeyedArchiver` / `NSKeyedUnarchiver`. Use `Codable` + JSON instead. +- Full `NSXMLParser` / `NSXMLDocument`. Use a third-party XML parser if you need DOM-level access. +- `NSEnergyFormatter` and some specialty formatters. +- Distributed notifications, `NSHostName`. + +Prefer `swift-foundation`'s `FoundationEssentials` and `FoundationInternationalization` for new code; they ship the same implementation on every platform. + +## Test framework on cross-platform code + +Swift Testing (`@Test`, `@Suite`, `#expect`, `#require`) is platform-agnostic by design. Use it for cross-platform tests: + +```swift +import Testing + +@Test func crossPlatformBehaviour() async throws { + let result = try await renderer.render(tile) + #expect(result == .expected) +} +``` + +XCTest still works on Linux (auto-discovery since Swift 5.4; `LinuxMain.swift` no longer required), but Swift Testing avoids historical parity gaps. For new tests in a cross-platform target, default to Swift Testing. + +If a specific test needs to skip on a platform: + +```swift +@Test(.disabled(if: ProcessInfo.processInfo.environment["CI"] == "linux")) +func appleOnlyTest() { ... } +``` + +Or simpler, gate the whole test file with `#if os(...)`. Both are fine; the `.disabled` form gives the test runner a record. + +References: ST-0009, ST-0016, ST-0017, ST-0022 (Swift Testing cross-platform behaviour). + +## CI requirements + +### If your repo ships any Linux-buildable product + +Linux CI runs on every PR. Minimum job: + +```yaml +linux-build: + runs-on: ubuntu-latest + container: swift:6.0-jammy + steps: + - uses: actions/checkout@v4 + - run: swift build -c release --product +``` + +Without this, the Linux build can rot silently between deploys. The Docker production deploy is not a CI substitute; it finds breakage at deploy time, not PR time. + +### If your repo is Apple-only (Topology 1) + +macOS CI is sufficient. Optional iOS Simulator + visionOS simulator on tagged PRs. + +### Linux test job (when you have Linux-buildable tests) + +```yaml +linux-test: + runs-on: ubuntu-latest + container: swift:6.0-jammy + steps: + - uses: actions/checkout@v4 + - run: swift test --filter +``` + +Run only the test suites known to be Linux-buildable; gate the others with `#if os(iOS) || os(macOS)` at the file level. + +## Manifest discipline for split-topology repos (Topology 2) + +If your repo ships an Apple-clients-plus-Linux-server split: + +1. **Declare Apple platforms in `platforms:`**. There is no stable Linux entry in the SPM platforms enum; Linux support is implicit when the package builds on a Linux toolchain. For the engine (macOS + Linux), declare macOS only: `platforms: [.macOS(.v15)]`. A repo that also ships an Apple UI app tier adds `.iOS(.v18)` for that tier. + +2. **Document the Linux-buildable surface.** In the repo's `README.md` / `AGENTS.md`, name the products that build to Linux: e.g. "On Linux, build the server product only: `swift build --product tiledownserver`." Otherwise a new developer running `swift build` on Linux hits confusing errors from Apple-only targets. + +3. **Wrap Apple-only products and targets in conditional arrays** (Layer A pattern above). This is the load-bearing rule that makes Layer-A discipline work. + +4. **Server-side targets stay unconditional.** Do not accidentally wrap an unconditional server product in `#if os(iOS) || os(macOS)`; that is the most common error in this topology. + +## Fallthrough discipline + +If your code runs on Linux in production, every `#if os(...)` chain that affects observable behaviour needs an explicit `#elseif os(Linux)` arm. + +Bad (User-Agent header reports "unknown" on Linux even though we ship there): + +```swift +#if os(iOS) +return "Tiledown/iOS/\(version)" +#elseif os(macOS) +return "Tiledown/macOS/\(version)" +#elseif os(visionOS) +return "Tiledown/visionOS/\(version)" +#else +return "Tiledown/unknown/\(version)" // Linux falls into "unknown" +#endif +``` + +Good: + +```swift +#if os(iOS) +return "Tiledown/iOS/\(version)" +#elseif os(macOS) +return "Tiledown/macOS/\(version)" +#elseif os(visionOS) +return "Tiledown/visionOS/\(version)" +#elseif os(Linux) +return "Tiledown/Linux/\(version)" +#else +return "Tiledown/unknown/\(version)" // truly unknown future platform +#endif +``` + +The `#else` arm is reserved for platforms you genuinely do not run on; every platform you DO run on gets a named arm. Otherwise metrics, telemetry, and feature toggles silently lump Linux into "unknown." + +## Cross-references + +- `docs/rules/linux-server.md` is the server-specific operational layer (AsyncHTTPClient, swift-log, graceful shutdown, Docker). Load both together for a server repo. +- `concurrency.md` is platform-agnostic; Sendable + async/await behave identically on Apple and Linux. + +## Authoritative references + +- **Foundation architecture**: `swiftlang/swift-foundation` README, the three-tier model (Darwin / swift-foundation / swift-corelibs-foundation). +- **System packages**: SE-0063, SE-0113, SE-0208. +- **swift-system CInterop**: `apple/swift-system` Proposals/0008-backdeploy-cinterop-stat.md. +- **Swift Testing cross-platform**: ST-0009, ST-0016, ST-0017, ST-0022. +- **swift-log (SSWG logger)**: `apple/swift-log`. +- **Vapor / AsyncHTTPClient / Hummingbird**: their respective package docs. + +## Quick reference table + +| Concern | Apple-only (T1) | Apple+Linux split (T2) | Cross-platform (T3) | +|---|---|---|---| +| Package.swift platforms | `[.iOS, .macOS, ...]` | `[.iOS, .macOS]` + document Linux products | `[.iOS, .macOS, ...]`, Linux implicit | +| Manifest `#if` | rare | `#if os(iOS) \|\| os(macOS)` for Apple-only product/target arrays | Apple-only product/target arrays plus `.when(platforms:)` | +| Source files | `#if os(iOS) \|\| os(macOS)` if needed | outer gate on UI files | `#if canImport()` | +| HTTP | URLSession | AsyncHTTPClient on server, URLSession on client | `canImport(FoundationNetworking)` + URLSession | +| C interop | `import Darwin` | `import Darwin` on Apple, none on server | `swift-system` or canImport(Darwin)/Glibc | +| Logging | `os.log` directly | `canImport(os)` + `print` fallback or `swift-log` | `swift-log` or `canImport(os)` + fallback | +| Tests | Swift Testing | Swift Testing, Linux-buildable suites separate | Swift Testing | +| CI | macOS only | macOS + Linux Docker job for the Linux product | macOS + Linux + (Windows) | +| Linux UI | n/a | server-side: HTTP responses, not native widgets | n/a | diff --git a/docs/rules-swift/dependency-injection.md b/docs/rules-swift/dependency-injection.md new file mode 100644 index 0000000..2737a69 --- /dev/null +++ b/docs/rules-swift/dependency-injection.md @@ -0,0 +1,144 @@ +# Dependency Injection Rules + +Canonical dependency-injection principles for TileKit and the wider Tiledown codebase: no singletons, constructor injection, protocol seams. + +These are standing rules for every change and every PR. They are grounded in *Design Patterns* (Gamma, Helm, Johnson, Vlissides, 1994) and Mark Seemann's *Dependency Injection in .NET* (2011). + +## 1. No Singletons. Ever. + +- **No `static let shared = X()`**. Not on a `final class`. Not when the class is immutable. Not when *Design Patterns* (1994, p. 127) explicitly sanctions the pattern. +- **No static accessors** that reach into a process-wide config holder. That is a Service Locator (Seemann 2011, ch. 5) and it counts. +- **No "but this case is sanctioned" soft framing.** When tempted to document a Singleton as "legitimate per p. 127," stop. Do the injection. +- **Apple's `os.Logger` per-category statics** are allowed; they are Apple's idiom, not ours. +- **Internal-detail caches** (`private static let cache = Cache()` inside a static enum loader) are not the kind of Singleton being banned. They are memoization, not Service Locator, and are not reachable from consumers as a dependency. + +Why: every dependency must appear in the call graph at the type's `init` site, not be conjured from process-wide state at runtime. That makes coupling visible, testable, and removable. + +## 2. Every external collaborator goes through `init`. No exceptions. + +- Paths, factories, strategies, loggers, indexes, anything not owned by the type itself goes through the **constructor**. +- Not through method parameters at the callsite. Not through `static var` fallbacks. Not through environment lookup. +- **Pure free functions** that compute a value from arguments are fine (no state). +- **Stateful types with collaborators** take them via `init`. + +Example for TileKit: + +```swift +public struct TileRenderer { + private let loader: TileLoading + private let logger: Logging + + public init(loader: TileLoading, logger: Logging) { + self.loader = loader + self.logger = logger + } +} +``` + +## 3. Cross-module coupling via protocols, not concrete imports. + +- Code that crosses a module boundary depends on a **named protocol**, not on a concrete type imported from another module. +- The composition root (the executable target / app entry point) is the only place that holds concretes from multiple modules and wires them together. + +If and when Tiledown grows into multiple SPM packages: + +- A feature/producer package may **not** `import` another feature/producer package. +- Cross-package seams are **named protocols**, either declared in a small foundation-only `*Models` package or inlined per package. +- The composition root remains the only place that imports concretes from several packages. + +## 4. No closure typealiases at cross-module seams. + +Forbidden at cross-module seams: + +```swift +public typealias TileLookup = @Sendable (String) async throws -> TileType? +``` + +Required: + +```swift +public protocol TileLookupStrategy: Sendable { + func lookup(uri: String) async throws -> TileType? +} +``` + +Reasons: a named protocol is discoverable (you can find all conformers), it forces a real type at the conformer site, and it matches the Factory Method / Strategy phrasing from *Design Patterns*. + +- This does **not** ban closures as method parameters or property values. `onProgress: (@Sendable (Progress) -> Void)?` is fine. +- It **does** ban closure-typed *named* cross-module contracts. + +## 5. Every module lifts out cleanly. + +If and when Tiledown is split into multiple packages, each package should lift out of the repo without modification: + +- If a package needs a dependency to function, that dependency is **named in its public API**. +- Hidden dependencies (ambient singletons, reach-into-shared-state, undeclared transitive imports) are violations even if they currently build. +- A mechanical lift-out check copies the package plus its declared transitive dependencies to a temp directory, generates a minimal `Package.swift`, and runs `swift build`. Green means it lifts out. + +## 6. Composition root: binary name stays clean. + +If Tiledown ships multiple binaries, the **executable target name** stays normal (no `Impl` suffix). The binary's external identity (filename on disk, `which` output) stays clean. + +Use an `*Impl` namespace for the source files **inside** that target where each module is concretely glued together: + +```swift +// Sources/CLI/CLIImpl.swift +public enum CLIImpl {} // namespace anchor + +// Sources/CLI/CLIImpl.LiveTileLoaderFactory.swift +extension CLIImpl { + struct LiveTileLoaderFactory: TileLoaderFactory { ... } +} +``` + +The `*Impl` namespace says "this file is where we wire concretes." Composition roots can `import` anything; they wire the universe. Library/producer code cannot import beyond its explicit allowed list. + +## 7. Keep the import contract explicit. + +If Tiledown grows into multiple packages, keep a checked-in `docs/package-import-contract.md`: one row per target listing target name, allowed imports, and current state. Anything outside the allowed column is a **violation** and must be fixed in the same change. Back it with a CI script that greps `^import` per target and fails on disallowed imports. + +## 8. Foundation-only is the end state for producer packages. + +The end goal for a multi-package split: producer packages import **only external primitives** (Foundation, system frameworks, vetted third-party SSG dependencies). Zero internal package imports. Each producer declares its own protocols inline. The composition root grows to bridge between per-package protocols. + +Until then, a producer package may import: + +- Foundation primitives and system frameworks +- Its own `*Models` companion and other foundation-only `*Models` protocol-seam targets +- A shared value-type layer, if one exists + +Sequence the work: settle the singleton and static-state removal first, then pursue foundation-only producers as a separate effort. Do not fold the two together. + +## 9. No relitigating a settled principle. Just do it. + +- When the strict-DI / strict-portability path is chosen, **stop offering alternatives** and execute. Surface only **ordering** / **sequencing** questions, not principle-relitigation. +- Do not ask permission to fix obvious blockers (build failures, fatal crashes, CI breakers). Fix them. Reserve "want me to?" for genuinely debatable scope/timing calls. + +## 10. Verify before claiming done. + +- Use the project's canonical build command (`swift build`). +- Run the full test suite and cite the count: `142 / 142 pass`. "Tests pass" without a number is not evidence. +- If incremental build is acting strange (stale module errors, link errors against old signatures), clean the build and rebuild. + +## 11. Keep the roadmap current. + +Update the project roadmap (document or tracker issue) on every phase, release, or scope change. "Closed" means **shipped**, not "PR merged." + +## 12. Documentation mirrors the user-facing surface in the same change. + +When a CLI command, option, or other user-facing surface changes, update the documentation in the same change. Also update the CHANGELOG. Hand-curated, not auto-generated. + +--- + +## Reference + +- Gamma, Helm, Johnson, Vlissides, *Design Patterns* (1994). Factory Method p. 107; Strategy p. 315; Singleton p. 127 (we do not use the third one). +- Mark Seemann, *Dependency Injection in .NET* (2011). Service Locator anti-pattern ch. 5; Composition Root ch. 4. + +## Stop rule (paste at the top of any session that touches imports) + +Before adding `import X` to a producer module: + +1. Is `X` external (Foundation, system framework, a vetted third-party dependency)? Allowed. +2. Is the target an executable target / composition root? Allowed. +3. Otherwise: **STOP.** Surface the situation. The target state is foundation-only. diff --git a/docs/rules-swift/documentation-search.md b/docs/rules-swift/documentation-search.md new file mode 100644 index 0000000..12bee02 --- /dev/null +++ b/docs/rules-swift/documentation-search.md @@ -0,0 +1,43 @@ +# Apple Documentation: Consult cupertino, Do Not Recall + +**Status: MANDATORY.** Any fact about an Apple-platform API (availability, a signature, a deprecation, a conformance, an inheritance relationship, default behavior) comes from the documentation, never from memory. An LLM's recall of Apple APIs is stale at its training cutoff and reliably invents signatures, availability floors, and conformances. Before you state such a fact, look it up. The tool for that is [cupertino](https://github.com/mihaelamj/cupertino) (`brew install mihaelamj/tap/cupertino`), a local index over current Apple documentation, available as a CLI and an MCP server. + +## What cupertino is, and is not + +cupertino is a **SQLite FTS5 keyword index** over Apple's documentation, ranked by field-weighted BM25, with a structured store layered on top (AST-extracted symbols with their `kind`/signature/`is_async`/`is_throws`, per-platform availability floors, a class-inheritance graph, and generic constraints pulled from the Apple SDK symbol graphs). It covers the developer docs, the Human Interface Guidelines, the Apple archive (Core Animation, Quartz 2D, KVO/KVC), Swift Evolution, Swift.org, The Swift Programming Language, and Apple sample code. + +It is **not intelligent.** It does not reason, summarize, design, compare options, or answer a question. A query returns a deterministic, ranked list of document and symbol hits; the hit is a pointer, and the document it points to is the fact. The intelligence is yours; cupertino only retrieves. + +## How to use it (query, then read) + +1. **Query with keywords or symbol names, not a sentence.** A few terms (a symbol, a framework, a concept word) such as `NavigationSplitView`, `URLSession data delegate`, `Observable macro availability`. The text is matched, not understood, and only the first handful of terms are honored; a prose question wastes terms and returns noise. +2. **For an exact API fact, use the typed query** rather than full-text: a symbol search for a signature or whether it is `async`/`throws`; a conformance search for "does this type conform to that protocol"; an inheritance walk for the class tree; the availability filters or the document read for the deployment floors. These query the structured columns, not prose. +3. **Read the winning hit and quote it.** The ranking locates the document; reading it gives the authoritative text. Narrow by source when you know it: current API docs for current APIs, the Human Interface Guidelines for interface guidance, sample code for working examples, the archive for foundational or legacy topics, Swift Evolution for language history. + +## DO + +- Look up every Apple-API fact (availability, signature, deprecation, conformance, inheritance) in cupertino before stating it. +- Send keywords and symbol/framework names; prefer the typed symbol, conformance, and inheritance queries for exact facts. +- Read the returned document and cite what you read. +- Narrow by source and framework when you know them. + +## DON'T + +- Do not recall an Apple-API fact from memory when the documentation can settle it. +- Do not phrase a natural-language question and expect an answer; cupertino returns rows, not reasoning. +- Do not trust a rank as a fact without reading the hit; BM25 is relevance ordering, not a truth claim. +- Do not ask it to compare, weigh trade-offs, or design. That is a category error; it retrieves, you reason. + +## Acceptance check + +Before any Apple-API fact appears in code, a comment, or a claim, it was looked up in cupertino (a keyword or typed query, then the returned document read), not recalled from memory. A signature, an availability floor, or a conformance asserted from memory without a lookup fails this rule, even if it happens to be right. + +## Why it beats memory + +cupertino is the actual Apple documentation, crawled and indexed, plus the Apple SDK symbol graphs, kept current. It is deterministic (the same query always returns the same hits) and local (sub-second). An LLM's training-cutoff recall is the opposite on every axis: stale, non-deterministic, and prone to inventing the exact facts (availability, signatures, deprecations) that break a build or ship a bug. + +## Companion rules + +- `core/no-shortcuts-first-principles.md`: reason from the source, not from memory; a guessed API fact is a shortcut. +- `core/first-principles-analysis.md`: check every checkable fact against the source. +- `framework-policy.md`: the Apple-platform discipline these facts serve. diff --git a/docs/rules-swift/documentation.md b/docs/rules-swift/documentation.md new file mode 100644 index 0000000..885aadf --- /dev/null +++ b/docs/rules-swift/documentation.md @@ -0,0 +1,269 @@ +# Documentation + +How a TileKit-style library organizes its documentation: a single DocC catalog that is the source of truth and moves with the code in every PR. + +Every project's documentation lives in a **DocC catalog** inside the repo as its own SPM target. There is no freeform `Docs/` folder. The catalog is the single source of truth. Every PR that touches code must update the matching catalog article in the same PR. + +## Core rules + +### Rule 1: Documentation lives in a DocC catalog + +Organize repo-level documentation as a Swift-DocC catalog in a dedicated SPM target. + +- MUST create a docs-only SPM target named `{ProductName}Documentation` (Apple's own convention, see `SwiftDocCPluginDocumentation`). +- MUST contain a `.docc` catalog directory inside that target: `{ProductName}Documentation.docc/`. +- MUST have a single minimal Swift source file in the target (DocC requires at least one Swift file). Typical content: `@_exported import Foundation` with a comment explaining the target's purpose. +- MUST have `swift-docc-plugin` listed as a package dependency in `Package.swift` (from 1.4.3 or newer). +- MUST use `@DisplayName("ShortName")` on the landing page to give readers a short sidebar label while the target keeps the long, unambiguous name (e.g. target `TileKitDocumentation`, display name `TileKit`). +- MUST NOT add `@TechnologyRoot` to the landing page; DocC warns it is unnecessary for a landing page tied to a module. + +### Rule 2: Never keep a `Docs/` folder alongside the catalog + +Never let documentation accumulate in a freeform `Docs/` directory. + +- MUST consolidate all project-level prose into the catalog. +- MUST delete any legacy `Docs/` folder during DocC adoption. +- MUST migrate each `.md` file to a PascalCase article inside `{Name}.docc/`. +- MAY keep package-level `README.md` files next to code (they serve different audiences; the catalog cross-references them as plain text paths when needed). + +### Rule 3: Landing page is an Apple-style sample-article + +Model the catalog landing page (`{Name}.md`) on Apple's sample-article format (e.g. the Wishlist or Landmarks sample). + +**MUST have:** +- First line: `# ``{TargetName}``` (the ``-resolving backtick form). +- `@Metadata { @DisplayName("...") }` block. +- One-sentence abstract right under the title. +- `## Overview` with 2 to 3 paragraphs of context. +- `## Configure the sample code project` with clone, build, run steps. +- One or more `## {feature area}` sections, each with prose + a real code listing + optionally a diagram. +- A closing `## Topics` section that groups every article in the catalog. + +**MUST NOT have:** +- Marketing filler ("that's it!", "only N lines", "super simple", "production-ready" status badges). +- Aspirational content; everything in the landing page is accurate to current repo state. + +### Rule 4: Topics structure: flat-wider, not deep + +For article-heavy catalogs (not symbol-heavy frameworks), **prefer a flat-wider Topics layout** over multi-level parent articles. Apple uses flat-wider for sample catalogs. + +- SHOULD have 5 to 10 top-level Topics groups (guidance, not a hard cap; a 3-group catalog is fine if the groups are cohesive). +- SHOULD have 2 to 8 articles per group. +- MUST avoid 1-item groups (merge the lonely article into an adjacent larger group). +- MUST avoid parent articles that duplicate the landing page's overview (extra click, no new information). +- MAY introduce parent articles ONLY when a category has 8+ items AND they naturally sub-group (e.g. by role or layer). + +Apple's framework docs nest via *symbols* (types contain methods); article catalogs stay flat. + +### Rule 5: Article format + +Every article (`.md` file in the catalog) follows this shape: + +```markdown +# Article title in plain language + +One-sentence abstract. + +## Overview + +Two or three paragraphs. Establish context. + +## {First topic} + +Content with real code listings, tables, lists. + +## {Next topic} + +... +``` + +- MUST start with `# Title` (not `# ``Symbol```). +- MUST have an abstract after the title (DocC displays it under the title in the viewer). +- MUST use PascalCase file names (`PackageLayers.md`, not `package-layers.md`). +- MUST match the filename to the `` reference name exactly; DocC is case-sensitive. + +### Rule 6: Cross-linking + +- MUST use `` for catalog-internal links. No angle brackets around spaces ever (causes DocC warnings). +- MUST use plain markdown paths (`Packages/Sources/Foo/README.md`) for files outside the catalog. +- MUST NOT use `` for non-existent articles; DocC emits a warning per broken reference. +- MUST NOT put `` inside example code blocks; DocC sometimes tries to resolve them even inside fences. Use `{example-placeholder}` or HTML entities for placeholders. + +### Rule 7: Diagrams pipeline: mermaid source, PNG output + +Single source of truth is a `.mmd` file; the committed PNG is a generated artifact. + +- MUST place `.mmd` sources in `{Name}.docc/Resources/diagrams/`. +- MUST commit the rendered `.png` alongside the source so the catalog renders without Node at read time. +- MUST provide a helper script that runs `npx -y @mermaid-js/mermaid-cli` to regenerate every PNG. +- MUST reference the PNG by filename-without-extension: `![alt](diagram-name)`. + +Why PNG: DocC does not render mermaid source. Why commit the PNG: readers without Node still see the diagram; web UIs render it inline. + +### Rule 8: Maintainer contract: docs update with every PR + +**The documentation catalog is the single source of truth. Every PR that changes code MUST include a matching catalog update in the same PR. No exceptions.** + +The catalog MUST include a `MaintainingTheDocs.md` article with an explicit "if you change X, update Y" table. + +**Table schema (required columns, in this order):** + +| Change | Update | Reviewer check | +|---|---|---| +| (code change, concrete and specific) | (catalog article name + section) | (what a reviewer must verify before approving) | + +- "Change" is a concrete trigger, not a category (e.g. "Add a new `ServerEnvironment` case", not "environment changes"). +- "Update" names the exact article and section (e.g. "`Environments.md` -> `## Available environments`"). +- "Reviewer check" is one sentence, verifiable without running the code (e.g. "New case appears in the table and has a 1-line description"). + +**MUST cover at minimum** (the table rows): + +- Adding/removing a SPM package +- Renaming a public type or module +- Adding/changing a facade or admin endpoint +- Bumping a spec or schema version +- Changing auth mode or session mechanism +- Adding an environment case or scheme variant +- Adding a brand-namespaced component +- Touching the log panel / observability +- Changing offline / caching behavior +- Touching the CI pipeline +- Adding a diagram + +Reviewers MUST block approval when code drifts from docs. If the column "Update" is empty for a code change, either the article does not cover it yet (add it) or the change is not doc-relevant (rare; document the judgment call in the PR description). + +### Rule 9: Building and previewing + +Build from CLI: + +```bash +cd Packages +swift package generate-documentation --target {Name}Documentation +``` + +Preview in browser (live reload on file save): + +```bash +swift package --disable-sandbox preview-documentation --target {Name}Documentation --port 8765 +``` + +**Default port 8080 collides with common dev servers** (Vapor, Node, etc.). Always pass `--port 8765` (or any free port). + +Open built archive in Xcode: + +```bash +open -a Xcode .build/plugins/Swift-DocC/outputs/{Name}Documentation.doccarchive +``` + +`-a Xcode` is required if Dash (Kapeli) is installed; macOS otherwise hands `.doccarchive` files to Dash. + +### Rule 10: Zero warnings is the bar + +**MUST NOT commit documentation changes if `swift package generate-documentation` emits any warnings.** + +The only tolerated warnings are SPM-level "dependency is not used" notices with this exact shape: + +- `warning: 'PackageName': dependency '' is not used by any target` +- `warning: dependency '' is not used by any target` + +All DocC-emitted warnings (anything from `docc`, anything referencing an article, symbol, resource, or link) MUST be fixed before commit. If a warning appears that does not match the tolerated shape above, treat it as a blocker. + +Common fixable warnings: + +- `` 'SomeName' doesn't exist at '/...' ``: `` points at an article that does not exist. Fix the name or create the article. +- `Resource 'foo.png' couldn't be found`: image referenced but not in `Resources/`. Move or rename. +- `' ' doesn't exist`: DocC parsed a malformed `<...>` pattern as a symbol reference. Usually caused by `` in an example block; switch to `{placeholder}` or HTML-escape the angle brackets. + +### Rule 11: No private or user-specific references in the catalog + +**MUST NOT** reference private / user-specific paths or repos inside the catalog. + +- No private-org paths. +- No absolute machine paths such as user home directories or mounted-volume paths. +- No private helper script names. +- No internal-only chat handles, internal URLs, or credential-adjacent details. + +When referencing an upstream backend you do not control, describe the contract abstractly. The reader is a colleague on the shared repo, not a private-notes reader. + +### Rule 12: No marketing tone + +Technical documentation. Neutral, factual voice. + +**MUST NOT:** +- "That's it!", "Just works!" (as filler), "Super simple", "Even easier", "Absolute simplest" +- "Only N lines of code" (as a boast) +- "Production-ready" / status badges +- Excessive checkmark glyphs in prose (DO/DON'T blocks are fine) +- Exclamation marks in section headings + +**OK:** +- "No `@Environment(\.colorScheme)` needed": factual statement about built-in behavior +- "Just works" when describing a concrete SwiftUI built-in that does auto-adapt +- Short code comments that state fact (not celebrate) + +### Rule 13: Renames, moves, and deletions + +When an article is renamed, moved, or deleted, inbound `` references break silently at build time (Rule 10 catches them, but only if CI runs DocC). The PR that renames is responsible for fixing every inbound reference in the same commit. + +**MUST:** +- Grep the catalog for the old basename before renaming: `grep -rn 'doc:OldName' {Name}Documentation.docc/`. +- Update every hit in the same PR. No stub articles, no redirect shims. DocC does not support redirects, so a stub just duplicates content. +- When deleting an article, remove it from the landing page `## Topics` section in the same commit. + +**MUST NOT:** +- Leave a placeholder article with a "moved to X" pointer. Readers see an empty page; cross-refs still break. +- Rely on a follow-up PR to "clean up" broken links. Rule 10 means the build stays green only when the rename is complete. + +For renamed public Swift symbols, the catalog article describing that symbol MUST be renamed in the same PR and its filename updated to match the new symbol name (DocC's `` is case-sensitive and exact). + +### Rule 14: Symbol-level `///` comments update with public API changes + +Rule 8 covers catalog articles; this rule covers the `///` doc comments attached to declarations in Swift source. Both must move in the same PR as the code change. + +**MUST:** +- Add a `///` doc paragraph when introducing any new `public` type, protocol, enum, actor, method, property, or init. The paragraph explains *why* the declaration exists and any non-obvious behaviour (error branches, actor isolation, side effects, invariants), not a restatement of the signature. +- Revise the `///` in the same commit that changes the semantics of a documented public declaration. Signature-only refactors (renames, parameter reorder) count. +- Delete `///` text that no longer reflects behaviour. Stale docs are worse than missing docs because readers trust them. +- Before deleting a `public` symbol, grep the catalog and the source tree for `` ``SymbolName`` `` references and remove or update each one in the same PR. + +**MAY skip documenting:** +- `var body` on SwiftUI `View` conformances (Apple's own frameworks do not doc it). +- Protocol-default conformance methods where the protocol itself is fully documented (`init(data:)` + `make()` on `Component` implementations, `init(from:)` / `encode(to:)` for `Codable`, forwarders on a mock). +- Data-struct fields whose name IS the documentation (`title: String`, `firstName: String`). "The title" adds no information. +- `Content: View` inner structs that are implementation detail of a documented component. + +**Reviewer check:** +- Any new `public` decl without a `///` paragraph blocks approval (unless it fits a MAY-skip category above; the PR description calls out the skip). +- Any semantic change to a documented public method without a corresponding `///` edit blocks approval. +- Any deletion of a `public` symbol without a matching sweep of `` ``SymbolName`` `` references in the catalog blocks approval. + +**Weighting (how much doc is enough):** +- Types & entry points: one-paragraph abstract + non-obvious-behaviour notes. Always. +- Widely-referenced methods (3 or more caller files outside the declaring package): one-line summary + `- Parameter` / `- Returns` / `- Throws` where non-trivial. +- Leaf / single-caller methods: one-line summary only. + +A weighted audit (undocumented public decls times cross-package usage) should be run before every major release to catch drift. Items referenced in many files but lacking docs are the highest-value gaps; items referenced nowhere outside their file are near-zero priority. + +--- + +## Verification checklist + +Before shipping a docs change: + +- [ ] `swift package generate-documentation --target {Name}Documentation` produces zero warnings (excluding generic "dependency is not used"). +- [ ] Every article referenced in a `` link exists as a file with that exact basename. +- [ ] Every image referenced as `![alt](name)` exists under `Resources/`. +- [ ] Landing page `## Topics` section lists every article, each in exactly one group. +- [ ] No article references a deleted `Docs/` folder or other legacy paths. +- [ ] No private or user-specific references leaked (user home directories, mounted-volume paths, etc.). +- [ ] `Product -> Build Documentation` in Xcode shows the updated content after building. +- [ ] Archive opens in Xcode's Developer Documentation window under "Imported Documentation". +- [ ] Every new/changed `public` declaration has a matching `///` comment in the same PR (or a justified MAY-skip per Rule 14). + +## References + +- [DocC overview (swift.org)](https://www.swift.org/documentation/docc/) +- [swift-docc-plugin](https://github.com/swiftlang/swift-docc-plugin) +- [Adding structure to your documentation pages (Apple)](https://developer.apple.com/documentation/xcode/adding-structure-to-your-documentation-pages) +- [Writing symbol documentation (Apple)](https://developer.apple.com/documentation/xcode/writing-symbol-documentation-in-your-source-files) +- Apple sample catalogs: Wishlist, Backyard Birds, SlothCreator, Landmarks (check their `.docc` directories in the public sample repos) diff --git a/docs/rules-swift/domain-first.md b/docs/rules-swift/domain-first.md new file mode 100644 index 0000000..3838b97 --- /dev/null +++ b/docs/rules-swift/domain-first.md @@ -0,0 +1,109 @@ +# Domain First (defer the renderer) + +**Status: MANDATORY for any app.** An app does not start with a UI framework. The +first decision is never "this is a SwiftUI app," "this is a UIKit app," or "this +is an AppKit app." That decision is made last, and under the pre-UI layer it need +not be made exclusively at all. The first work is the domain and the business +rules: the entities, their invariants, and the behavior that operates on them, all +framework-free and runnable with no display. The renderer is attached to that, not +the other way around. + +## The anti-pattern + +Naming the renderer first ("a SwiftUI app") inverts the dependency. It makes the +framework the root of the project and the domain a thing you reach through it, so +business rules end up scattered across view bodies, controllers, and callbacks, +welded to one SDK and untestable without a screen. Every later question (is this +correct, can I reuse this, can I ship it on macOS too) is then blocked behind the +UI you chose before you understood the problem. The renderer is the most +replaceable layer in the system; choosing it first spends your one +feels-irreversible decision on the one part that should have stayed cheap to +change. + +## What to start with + +The input to this is a clear definition of the business rules and the constraints +that bound them, which external REST services, what compliance, which auth model, +written down first (see `business-rules-and-constraints.md`). Then start with the +domain and the business rules, as the pre-UI layer's two value tiers and three +roles describe (see `ui/pre-ui-layer.md`): Domain (what the +entities are), Surface (display state over them), and the engine that runs every +business rule in response to an enumerable intent vocabulary, reaching I/O only +through injected seams. Build that, prove it headless, and you have an app whose +correctness is established before any pixel exists. The renderer is then a thin +adapter you add at the end. + +## Defer the renderer, then pick by behavior + +Because the model is renderer-agnostic, the SDK choice is deferred, and it is no +longer forced to be singular. You can develop the SwiftUI, AppKit, and UIKit +renderers over the one model and see which one actually fits a given scenario +better (its animation, accessibility, gesture handling, layout under load, +large-list performance), instead of committing to one on faith at the start. The +renderer becomes an experiment you run late and cheaply, not an assumption you +bake in early. See the renderer section of `ui/pre-ui-layer.md`: the triple render +is both the architecture's payoff and a comparison instrument, one model and +identical behavior underneath, so the framework is the only variable. + +## Why this is now a must, not a preference + +Two shifts move starting from a formalized domain from tidy to mandatory. + +First, the cost of building several renderers has collapsed. Machine +pattern-matching at scale now makes it practical to develop all three Apple UIs +over one model and compare them, work that was too expensive to justify when each +renderer was hand-written from scratch. The deferred, plural renderer choice this +rule asks for is now affordable. + +Second, and deeper: that same capability has made tractable, in hours, work that +was prohibitive before. Discovering the latent grammar in a +corpus, naming the possible language it implies, and devising a parser or compiler +for it is now feasible on a timescale that did not exist a few years ago. That +capability rewards a domain already written down as explicit, value-typed, +rule-governed structure, and is nearly blind to one that lives only as scattered +imperative code. A formalized domain (typed entities, named invariants made +unrepresentable or validated as values, an enumerable intent vocabulary, +round-trip laws for every transform) is exactly the structure these tools can +read, check, extend, and translate. So formalizing the domain rules is no longer +optional discipline. In this world it is a must. + +## DO + +- Begin every app at the domain and the business rules, framework-free and + headless-provable, before naming any renderer. +- Write the domain down as explicit structure: typed entities, invariants made + unrepresentable or validated as values (`validation-rules.md`), an enumerable + intent vocabulary (`ui/pre-ui-layer.md`). +- Treat the renderer as a late, reversible, and plural choice: develop more than + one and pick by observed behavior. + +## DON'T + +- Do not open a project by declaring its UI framework ("this is a SwiftUI app"); + the framework is the last decision, not the first. +- Do not put business rules in a view, a controller, or a callback; they belong in + the framework-free domain and engine. +- Do not leave the domain implicit in imperative code on the promise of formalizing + it later; formalize it now. + +## Acceptance check + +A conforming project has: (1) a framework-free domain and engine that build and +pass tests with no UI target present; (2) no business rule reachable only through a +renderer; (3) the renderer named as a late, swappable boundary (one or more), +never as the project's root; and (4) the domain expressed as explicit typed +structure with named, checked invariants, not as imperative code that only a +screen exercises. A project whose first and load-bearing decision is its UI +framework, or whose business rules cannot run without a display, fails this rule. + +## Companion rules + +- `ui/pre-ui-layer.md`: the two value tiers and three roles this rule starts from; + the triple render as payoff and comparison instrument. +- `validation-rules.md`: invariants as composable values; every public type + validated or excluded with a reason. +- `dependency-injection.md`: inject every collaborator, no singletons, so the + domain stays liftable and testable. +- `parsing-rules.md`, `core/round-trip-transformation.md`, `core/proof-discipline.md`: + formalizing structure, transforms, and translation claims, the discipline a + formalized domain makes possible. diff --git a/docs/rules-swift/exp/README.md b/docs/rules-swift/exp/README.md new file mode 100644 index 0000000..93d3c19 --- /dev/null +++ b/docs/rules-swift/exp/README.md @@ -0,0 +1,70 @@ +# exp/: ExtremePackaging Architecture (Swift) + +This folder is the canonical Swift package-architecture rule, formerly the single file `exp.md` (~1700 lines). Split into focused sub-files so the always-loaded set is small and the reference content is loaded on demand. + +Follow the "ExtremePackaging" architecture pattern: a monorepo with maximum granular modularization into distinct SPM packages. Each package represents a single cohesive responsibility with explicit dependencies. This enables isolated compilation, parallel builds, clear dependency graphs, and superior testability. + +## Always-load core + +These two files are loaded in every Swift session. Together they cover the rule statements and the new-package decision flow: + +| File | Purpose | +|---|---| +| `critical-rules.md` | Rule 1 to 5: single responsibility, explicit deps, granularity, naming, layer architecture | +| `when-to-create.md` | Decision tree, DO/DON'T new-package list, decision checklist | + +## Reference (load on demand) + +| File | When to load | +|---|---| +| `implementation-patterns.md` | Adding a foundation/feature/middleware/API/component/font package; building an aggregator | +| `dependency-management.md` | Adding a cross-package dependency; platform-conditional code (`#if os()`); circular-dep concerns | +| `package-swift.md` | Editing `Package.swift` (`deps`, `allProducts`, `targets` arrays) | +| `common-mistakes.md` | Code review or self-review of `Package.swift` changes | +| `app-target.md` | Adding or modifying an app target (SwiftUI, UIKit, AppKit entry points) | +| `testing.md` | Wiring or restructuring a package's test target (architecture angle; full testing rules in top-level `testing.md`) | +| `migration.md` | Extracting code into a new package or splitting a large one | +| `build-performance.md` | Rationale; read once for context, not normally needed during work | +| `verification.md` | Final checklist before considering an ExtremePackaging change done | + +## Concrete example + +The canonical Swift monorepo this rule was derived from has the following 20-package layout. Treat as illustrative, not normative; your project's package list will differ. + +``` +Packages/Sources/ +├── Foundation Layer (0 dependencies) +│ ├── SharedModels # Domain models, no dependencies +│ ├── AppColors # Color system (HSV, semantic colors) +│ └── AppFont # Typography, font loading +│ +├── Design System Layer +│ └── AppTheme # Combines AppColors + AppFont +│ +├── Infrastructure Layer +│ ├── ApiShared # OpenAPI spec + generated DTOs +│ ├── ApiClient # Client-side networking (depends: ApiShared, SharedModels) +│ ├── ApiServer # Vapor backend (depends: ApiShared, SharedModels) +│ ├── ApiServerApp # Server executable (depends: ApiServer) +│ └── OpenAPICachingMiddleware # Caching layer (depends: OpenAPIRuntime) +│ +├── Component Layer +│ ├── Components # CORE component system (AnyComponent, ComponentRegistry, ComponentFactory, etc.) +│ ├── SharedComponents # Hot reload infrastructure (depends: Inject, KZFileWatchers) +│ ├── AppComponents # Production app components (depends: Components, AppColors, AppFont) +│ └── AllComponents # Umbrella package (depends: Components, AppComponents) +│ +├── Feature Layer +│ ├── SharedViews # Reusable views (depends: AppColors, AppFont) +│ ├── AuthFeature # Authentication (depends: SharedModels, SharedViews) +│ ├── AppFeature # Main app (depends: SharedModels, SharedViews, AuthFeature) +│ ├── BetaSettingsFeature # Beta settings (depends: SharedModels, ApiClient) +│ ├── DemoAppFeature # Demo mode (depends: SharedModels, ApiClient) +│ └── PlaybookFeature # Component gallery (depends: Components, AppComponents) +│ +└── Apps/ (not in Packages/Sources) + ├── iosApp # iOS target + ├── macApp # macOS target + ├── ComponentsPreview # Component preview app + └── Demo # Demo app +``` diff --git a/docs/rules-swift/exp/app-target.md b/docs/rules-swift/exp/app-target.md new file mode 100644 index 0000000..2ecf805 --- /dev/null +++ b/docs/rules-swift/exp/app-target.md @@ -0,0 +1,380 @@ +# App Target Structure + +Three patterns for the app entry point: SwiftUI (minimal App shell), UIKit (SceneDelegate), AppKit (NSApplicationDelegate). + +**Reference layouts:** +- SwiftUI pattern: `Apps/iosApp/`, `Apps/macApp/` (one Xcode project per platform under `Apps/`) +- UIKit/AppKit pattern: `Apps/App/UIKitApp/`, `Apps/App/AppKitApp/` (siblings under a single project) + +### Pattern 1: SwiftUI Apps (Minimal App Shell) + +SwiftUI app targets MUST be minimal shells that import a feature package and display a view. + +#### iOS App Target (iosApp or iOSApp) + +```swift +// Apps/iosApp/iosApp/iosAppApp.swift +import AppFeature // ← Import the feature package +import SwiftUI + +@main +struct iosAppApp: App { + var body: some Scene { + WindowGroup { + AppView() // ← Use the view from the feature package + } + } +} +``` + +#### macOS App Target (macApp) + +```swift +// Apps/macApp/macApp/macAppApp.swift +import AppFeature // ← Same feature package as iOS +import SwiftUI + +@main +struct macAppApp: App { + var body: some Scene { + WindowGroup { + AppView() // ← Same view as iOS + } + } +} +``` + +**Directory Structure:** +``` +Apps/ +├── iosApp/ +│ └── iosApp/ +│ ├── iosAppApp.swift # Minimal: import + AppView() +│ └── Assets.xcassets/ +├── macApp/ +│ └── macApp/ +│ ├── macAppApp.swift # Minimal: import + AppView() +│ └── Assets.xcassets/ +``` + +Rules: +1. App targets contain only the `@main` struct, `import AppFeature`, and `AppView()`. +2. All UI logic lives in the feature package (e.g. `AppFeature`). +3. Both iOS and macOS targets use the same feature package. +4. The feature package exports `AppView` (a SwiftUI `View`). +5. Font registration (if needed) goes in `init()` before body + +#### Font Registration in SwiftUI App + +```swift +// Apps/iosApp/iosApp/iosAppApp.swift +import AppFeature +import AppFont // ← Import font package +import SwiftUI + +@main +struct iosAppApp: App { + init() { + FontRegistration.registerFonts() // ← Register before UI + } + + var body: some Scene { + WindowGroup { + AppView() + } + } +} +``` + +--- + +### Pattern 2: UIKit Apps (iOS - SceneDelegate Pattern) + +UIKit app targets MUST delegate to a feature package for window creation. + +**Reference layout:** `Apps/App/UIKitApp/` + +#### AppDelegate.swift + +```swift +// Apps/UIKitApp/AppDelegate.swift +import UIKit + +@main +@MainActor +class AppDelegate: UIResponder, UIApplicationDelegate { + func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + true + } + + // MARK: UISceneSession Lifecycle + + func application( + _ application: UIApplication, + configurationForConnecting connectingSceneSession: UISceneSession, + options: UIScene.ConnectionOptions + ) -> UISceneConfiguration { + UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) + } +} +``` + +#### SceneDelegate.swift + +```swift +// Apps/UIKitApp/SceneDelegate.swift +import CanvasFeature // ← Import the feature package (NOT "Canvas") +import UIKit + +@MainActor +class SceneDelegate: UIResponder, UIWindowSceneDelegate { + var window: UIWindow? + + func scene( + _ scene: UIScene, + willConnectTo session: UISceneSession, + options connectionOptions: UIScene.ConnectionOptions + ) { + guard let windowScene = scene as? UIWindowScene else { return } + window = CanvasFeature.createMainWindow(for: windowScene) // ← Factory from feature + } +} +``` + +**Directory Structure:** +``` +Apps/ +└── UIKitApp/ + ├── AppDelegate.swift # Minimal lifecycle + ├── SceneDelegate.swift # Import CanvasFeature, create window + ├── Info.plist + └── Assets.xcassets/ +``` + +Rules: +1. AppDelegate handles only app lifecycle (minimal code). +2. SceneDelegate imports the feature package. +3. Feature package provides `createMainWindow(for:)` factory function. +4. Feature package name ends with `Feature` (e.g. `CanvasFeature`, not `Canvas`). + +--- + +### Pattern 3: AppKit Apps (macOS - NSApplicationDelegate Pattern) + +AppKit app targets MUST delegate to a feature package, with menu code in a SEPARATE file. + +**Reference layout:** `Apps/App/AppKitApp/` + +#### main.swift + +```swift +// Apps/AppKitApp/main.swift +import Cocoa + +MainActor.assumeIsolated { + let delegate = AppDelegate() + NSApplication.shared.delegate = delegate +} +_ = NSApplicationMain(CommandLine.argc, CommandLine.unsafeArgv) +``` + +#### AppDelegate.swift + +```swift +// Apps/AppKitApp/AppDelegate.swift +import CanvasFeature // ← Import the feature package (NOT "Canvas") +import Cocoa + +@MainActor +class AppDelegate: NSObject, NSApplicationDelegate { + var windowController: NSWindowController? + + func applicationDidFinishLaunching(_ aNotification: Notification) { + setupMainMenu() // ← Separate file for menus + windowController = CanvasFeature.createWindowController() // ← Factory from feature + windowController?.window?.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + } + + func applicationWillTerminate(_ aNotification: Notification) {} + + func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + true + } + + func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + true + } +} +``` + +#### MainMenu.swift (SEPARATE FILE) + +```swift +// Apps/AppKitApp/MainMenu.swift +import Cocoa + +extension AppDelegate { + func setupMainMenu() { + let mainMenu = NSMenu() + + // App menu + let appMenu = NSMenu() + appMenu.addItem(NSMenuItem(title: "About MyApp", action: #selector(NSApplication.orderFrontStandardAboutPanel(_:)), keyEquivalent: "")) + appMenu.addItem(.separator()) + appMenu.addItem(NSMenuItem(title: "Hide MyApp", action: #selector(NSApplication.hide(_:)), keyEquivalent: "h")) + let hideOthersItem = NSMenuItem(title: "Hide Others", action: #selector(NSApplication.hideOtherApplications(_:)), keyEquivalent: "h") + hideOthersItem.keyEquivalentModifierMask = [.command, .option] + appMenu.addItem(hideOthersItem) + appMenu.addItem(NSMenuItem(title: "Show All", action: #selector(NSApplication.unhideAllApplications(_:)), keyEquivalent: "")) + appMenu.addItem(.separator()) + appMenu.addItem(NSMenuItem(title: "Quit MyApp", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q")) + + let appMenuItem = NSMenuItem() + appMenuItem.submenu = appMenu + mainMenu.addItem(appMenuItem) + + // File menu + let fileMenu = NSMenu(title: "File") + fileMenu.addItem(NSMenuItem(title: "New", action: #selector(newDocument(_:)), keyEquivalent: "n")) + fileMenu.addItem(NSMenuItem(title: "Open...", action: #selector(openDocument(_:)), keyEquivalent: "o")) + fileMenu.addItem(.separator()) + fileMenu.addItem(NSMenuItem(title: "Close Window", action: #selector(NSWindow.performClose(_:)), keyEquivalent: "w")) + + let fileMenuItem = NSMenuItem() + fileMenuItem.submenu = fileMenu + mainMenu.addItem(fileMenuItem) + + // Edit menu + let editMenu = NSMenu(title: "Edit") + editMenu.addItem(NSMenuItem(title: "Undo", action: Selector(("undo:")), keyEquivalent: "z")) + editMenu.addItem(NSMenuItem(title: "Redo", action: Selector(("redo:")), keyEquivalent: "Z")) + editMenu.addItem(.separator()) + editMenu.addItem(NSMenuItem(title: "Cut", action: #selector(NSText.cut(_:)), keyEquivalent: "x")) + editMenu.addItem(NSMenuItem(title: "Copy", action: #selector(NSText.copy(_:)), keyEquivalent: "c")) + editMenu.addItem(NSMenuItem(title: "Paste", action: #selector(NSText.paste(_:)), keyEquivalent: "v")) + editMenu.addItem(NSMenuItem(title: "Select All", action: #selector(NSText.selectAll(_:)), keyEquivalent: "a")) + + let editMenuItem = NSMenuItem() + editMenuItem.submenu = editMenu + mainMenu.addItem(editMenuItem) + + // Window menu + let windowMenu = NSMenu(title: "Window") + windowMenu.addItem(NSMenuItem(title: "Minimize", action: #selector(NSWindow.performMiniaturize(_:)), keyEquivalent: "m")) + windowMenu.addItem(NSMenuItem(title: "Zoom", action: #selector(NSWindow.performZoom(_:)), keyEquivalent: "")) + windowMenu.addItem(.separator()) + windowMenu.addItem(NSMenuItem(title: "Bring All to Front", action: #selector(NSApplication.arrangeInFront(_:)), keyEquivalent: "")) + + let windowMenuItem = NSMenuItem() + windowMenuItem.submenu = windowMenu + mainMenu.addItem(windowMenuItem) + + NSApp.mainMenu = mainMenu + NSApp.windowsMenu = windowMenu + } + + // MARK: - Actions + + @objc func newDocument(_ sender: Any?) { + // TODO: Create new document + } + + @objc func openDocument(_ sender: Any?) { + // TODO: Open document + } +} +``` + +**Directory Structure:** +``` +Apps/ +└── AppKitApp/ + ├── main.swift # NSApplicationMain setup + ├── AppDelegate.swift # Import CanvasFeature, create window + ├── MainMenu.swift # Separate file for menu code (extension) + ├── Info.plist + └── Assets.xcassets/ +``` + +Rules: +1. AppKit uses `main.swift` + `AppDelegate.swift` (no `@main` on AppDelegate). +2. Menu code lives in a separate file (`MainMenu.swift`). +3. Menu code is an extension of `AppDelegate`. +4. Feature package provides `createWindowController()` factory function. +5. Feature package name MUST end with `Feature` (e.g., `CanvasFeature`, NOT `Canvas`) + +--- + +### Feature Package Exports + +The feature package (e.g., `AppFeature`, `CanvasFeature`) MUST export: + +#### For SwiftUI + +```swift +// Packages/Sources/AppFeature/AppView.swift +import SwiftUI + +public struct AppView: View { + public init() {} + + public var body: some View { + // App content + } +} +``` + +#### For UIKit + +```swift +// Packages/Sources/CanvasFeature/WindowFactory.swift +import UIKit + +public enum CanvasFeature { + @MainActor + public static func createMainWindow(for windowScene: UIWindowScene) -> UIWindow { + let window = UIWindow(windowScene: windowScene) + window.rootViewController = MainViewController() + window.makeKeyAndVisible() + return window + } +} +``` + +#### For AppKit + +```swift +// Packages/Sources/CanvasFeature/WindowFactory.swift +import AppKit + +public enum CanvasFeature { + @MainActor + public static func createWindowController() -> NSWindowController { + let viewController = MainViewController() + let window = NSWindow(contentViewController: viewController) + window.title = "MyApp" + window.setContentSize(NSSize(width: 1200, height: 800)) + window.center() + return NSWindowController(window: window) + } +} +``` + +--- + +### App Target Checklist + +- [ ] SwiftUI: App file has ONLY `import FeaturePackage` + `FeatureView()` +- [ ] SwiftUI: Both iOS and macOS use the SAME feature package +- [ ] UIKit: SceneDelegate imports feature package and uses factory +- [ ] AppKit: AppDelegate imports feature package and uses factory +- [ ] AppKit: Menu code is in SEPARATE `MainMenu.swift` file +- [ ] AppKit: `main.swift` exists with `NSApplicationMain` +- [ ] Feature package name ends with `Feature` (NOT raw name like `Canvas`) +- [ ] Feature package exports appropriate factory functions +- [ ] Font registration (if needed) happens in app init BEFORE UI + diff --git a/docs/rules-swift/exp/build-performance.md b/docs/rules-swift/exp/build-performance.md new file mode 100644 index 0000000..f685d2a --- /dev/null +++ b/docs/rules-swift/exp/build-performance.md @@ -0,0 +1,45 @@ +# Build Performance Benefits + +Why ExtremePackaging pays off in build time. Rationale, not rule. Read once for context. + +### Incremental Compilation + +**With ExtremePackaging:** +- Change AppFont → Only AppFont rebuilds +- Change AppComponents → Only AppComponents + dependent targets rebuild +- Change SharedModels → More rebuilds (foundation layer), but still isolated + +**Without ExtremePackaging:** +- Change any file → Entire monolith rebuilds + +### Parallel Builds + +``` +Build Graph (simplified): +SharedModels ─┬─> ApiClient ─┬─> AuthFeature ──> iosApp + │ │ + └─> AppTheme ───┴─> AppFeature ───┘ + +SPM builds in parallel: +[SharedModels, AppFont] → [ApiClient, AppTheme, SharedViews] → [AuthFeature, AppFeature] → [iosApp] +``` + +SPM automatically parallelizes independent package builds. + +### CI/CD Optimization + +```yaml +# A CI runner can cache per-package +stages: + - build-foundation + - build-infrastructure + - build-features + - build-apps + +build-foundation: + script: + - swift build --product SharedModels + - swift build --product AppTheme + # Cache: Only rebuild if foundation changed +``` + diff --git a/docs/rules-swift/exp/common-mistakes.md b/docs/rules-swift/exp/common-mistakes.md new file mode 100644 index 0000000..57ed86c --- /dev/null +++ b/docs/rules-swift/exp/common-mistakes.md @@ -0,0 +1,150 @@ +# Common Mistakes to Avoid + +Anti-patterns paired with the canonical fix. Mini-DOs are inline; full canonical forms live in `package-swift.md` (closure-with-locals), `testing.md` (test-target placement), and `implementation-patterns.md` (Pattern 6 for fonts). + +### ❌ DON'T: Create God Packages + +```swift +// WRONG: "Shared" package with everything +Packages/Sources/Shared/ +├── Models/ +├── Views/ +├── Networking/ +├── Database/ +└── Utilities/ +``` + +✅ **DO:** Split into focused packages +``` +Packages/Sources/ +├── SharedModels/ +├── SharedViews/ +├── NetworkClient/ +├── DatabaseClient/ +└── SharedUtilities/ +``` + +### ❌ DON'T: Circular Dependencies + +```swift +// WRONG +AuthFeature depends on AppFeature +AppFeature depends on AuthFeature +``` + +✅ **DO:** Extract shared code +```swift +AuthFeature depends on SharedAuthModels +AppFeature depends on SharedAuthModels +``` + +### ❌ DON'T: Inline `deps`, `allProducts`, or `targets` arrays + +```swift +// WRONG: inline definitions +let deps: [Package.Dependency] = [ + .package(url: "...", from: "1.10.3"), + .package(url: "...", from: "1.8.3"), + // ... dozens more +] + +let allProducts: [Product] = [ + .library(name: "ApiShared", targets: ["ApiShared"]), + .library(name: "ApiClient", targets: ["ApiClient"]), + // ... many more +] + +let targets: [Target] = [ + Target.target(name: "SharedModels", dependencies: []), + Target.testTarget(name: "SharedModelsTests", dependencies: ["SharedModels"]), + // ... more inline +] +``` + +✅ **DO:** closure-with-local-variables pattern, grouped by purpose / layer / platform. Mini-shape for each array: + +```swift +// deps +let deps: [Package.Dependency] = { + // OpenAPI stack + let openAPIRuntimeDep = Package.Dependency.package(url: "...", from: "1.8.3") + // Vapor stack + let vaporDep = Package.Dependency.package(url: "...", from: "4.119.0") + return [openAPIRuntimeDep, vaporDep] +}() + +// allProducts +let allProducts: [Product] = { + let apiSharedProduct = Product.singleTargetLibrary("ApiShared") + let apiClientProduct = Product.singleTargetLibrary("ApiClient") + return [apiSharedProduct, apiClientProduct] +}() + +// targets +let targets: [Target] = { + // foundation + let sharedModelsTarget = Target.target(name: "SharedModels", dependencies: []) + let foundationTargets = [sharedModelsTarget] + // api + let apiClientTarget = Target.target(name: "ApiClient", dependencies: ["SharedModels"]) + let apiTargets = [apiClientTarget] + return foundationTargets + apiTargets +}() +``` + +Full forms (with platform splits, helper extensions, every dep / product / target spelled out) in `package-swift.md` (Dependencies Definition, Product Definition, Target Organization). + +### ❌ DON'T: Skip Test Targets + +Every package MUST ship with a matching `Target.testTarget(name: "Tests", dependencies: [""])`, grouped as a pair with the source target in a named sub-array. See `testing.md > TEST TARGET PLACEMENT` for the canonical form, folder layout, and the rule that mocks live in the test target rather than the source package. + +### ❌ DON'T: Transitive Dependencies + +```swift +// WRONG: Relying on ApiClient importing SharedModels +import ApiClient +// Using User from SharedModels without importing it +``` + +✅ **DO:** Explicit imports +```swift +import ApiClient +import SharedModels // Explicit dependency +``` + +### ❌ DON'T: Feature-to-Feature Dependencies + +```swift +// WRONG: Features depending on each other +let appFeatureTarget = Target.target( + name: "AppFeature", + dependencies: [ + "AuthFeature", // ❌ Feature depending on feature + "ProfileFeature", // ❌ Creates tight coupling + ] +) +``` + +✅ **DO:** Coordinator pattern or shared protocols +```swift +// Extract navigation/coordination to AppFeature (parent) +// Child features (Auth, Profile) depend on parent's protocols +let authFeatureTarget = Target.target( + name: "AuthFeature", + dependencies: [ + "SharedModels", + "AppCoordination", // ✅ Protocol package + ] +) +``` + +### ❌ DON'T: Use Info.plist for Font Registration in Packages + +Three anti-patterns, all covered by `implementation-patterns.md > Pattern 6 (Font/Resource Package)`: + +- `UIAppFonts` / `ATSApplicationFontsPath` in Info.plist: doesn't work in SPM packages +- `.copy("Fonts")` in `Package.swift` resources: breaks `Bundle.module` lookup +- `Bundle.main.urls(...)` inside the package: wrong bundle for SPM resources + +DO: `.process("Fonts")` in resources, `CTFontManagerRegisterFontsForURL` against `Bundle.module`, called from app init before any UI renders. Full implementation in `implementation-patterns.md > Pattern 6`. + diff --git a/docs/rules-swift/exp/critical-rules.md b/docs/rules-swift/exp/critical-rules.md new file mode 100644 index 0000000..2687fe6 --- /dev/null +++ b/docs/rules-swift/exp/critical-rules.md @@ -0,0 +1,53 @@ +# Critical Rules + +Always-loaded core. Five rules that gate every Swift package decision under ExtremePackaging. + +Follow the "ExtremePackaging" architecture pattern: a monorepo with maximum granular modularization into distinct SPM packages. Each package represents a single cohesive responsibility with explicit dependencies. This enables isolated compilation, parallel builds, clear dependency graphs, and superior testability. + +### Rule 1: Single Responsibility per Package + +Create packages with one clear purpose: +- A single, well-defined responsibility per package +- Don't mix concerns (UI + networking, models + API client) +- Name communicates the purpose +- Independently buildable and testable + +### Rule 2: Explicit Dependency Declaration + +Declare dependencies explicitly in `Package.swift`: +- List all dependencies in the package manifest +- No implicit/transitive dependencies +- Minimise cross-package dependencies +- Prefer unidirectional dependency flow + +### Rule 3: Package Granularity + +Prefer smaller, focused packages over larger ones: +- Single-file packages are acceptable when the unit is genuinely standalone (typography, color foundation, a single protocol, a single transport). +- Separate by role, not by topical bundling: foundation primitives, infrastructure, protocols, middleware, services, per-feature or per-CLI-verb operation packages, front-door binaries. +- Each cohesive responsibility (a feature, a CLI verb, a middleware, a transport, a service) gets its own package. + +### Rule 4: Naming Conventions + +Follow a consistent naming scheme. Patterns that apply across project shapes: + +- **Shared foundation:** `Shared*` for cross-target value-types, models, utilities, configuration (e.g. `SharedModels`, `SharedUtils`, `SharedConfiguration`). +- **Core infrastructure:** `Core*` or descriptive single-purpose names for the foundation domain (protocols, parsers, transports, indexers). +- **Per-feature or per-verb operation packages:** one package per user-facing flow (UI app) or one per CLI/MCP verb (CLI/MCP app). Name after the responsibility, not after a role suffix (`Distribution`, `Indexer`, `Ingest` in a CLI; the feature's own name in a UI app). +- **Service packages:** `*Service` or a `Services` aggregator for cross-layer read/write services consumed by multiple front-doors. +- **Aggregators:** umbrella packages only when the umbrella adds real value (preview hosts, all-features aggregators for app composition); don't add one by default. + +Project-specific conventions (UI component packages, API client/server splits, design-system packages, hot-reload infrastructure) live in the project's own `AGENTS.md` or rule files, not in this always-loaded universal rule. + +### Rule 5: Layer Architecture + +Organize packages into clear architectural layers with unidirectional dependency flow (bottom → top). The number and naming of layers varies per project shape; the constants are: + +- **Foundation** (bottom): shared value-types, models, primitive utilities, logging. +- **Infrastructure**: protocols, persistence, networking, file I/O, parsers, transports. +- **Domain**: services, indexers, business logic, per-feature or per-CLI-verb operation packages. +- **Presentation** (UI projects only): component packages, design-system packages, screens. +- **Front-door** (top): binary targets, apps, CLIs, MCP servers, preview hosts, test harnesses. + +Every dependency edge points upward. No back-edges. For the active project's actual layer instantiation, read `Packages/Package.swift`. + diff --git a/docs/rules-swift/exp/dependency-management.md b/docs/rules-swift/exp/dependency-management.md new file mode 100644 index 0000000..71347fa --- /dev/null +++ b/docs/rules-swift/exp/dependency-management.md @@ -0,0 +1,175 @@ +# Dependency Management + +Unidirectional flow, circular-dependency prevention, and platform-specific dependency separation. + +### Unidirectional Flow + +``` +Foundation (SharedModels, AppColors, AppFont) + ↓ +Design System (AppTheme = AppColors + AppFont) + ↓ +Infrastructure (ApiClient, ApiServer, Middleware, SharedViews) + ↓ +Components (Components [core], SharedComponents, AppComponents) + ↓ +Features (AuthFeature, AppFeature, etc.) + ↓ +Apps (iosApp, macApp) +``` + +Dependencies only flow downward. NEVER import from a higher layer. + +### Circular Dependency Prevention + +❌ **DON'T:** +```swift +// AuthFeature → AppFeature +// AppFeature → AuthFeature +// CIRCULAR DEPENDENCY! +``` + +✅ **DO:** +```swift +// Extract shared code to new package +// Both features depend on: SharedAuthModels +``` + +### Platform-Specific Dependencies + +Separate platform-specific products and targets using `#if os()` conditionals. + +#### Products Separation + +```swift +// ---------- Base Products (All Platforms) ---------- +let baseProducts: [Product] = [ + .singleTargetLibrary("ApiShared"), + .singleTargetLibrary("ApiClient"), + .singleTargetLibrary("ApiServer"), + .singleTargetLibrary("SharedModels"), + .singleTargetLibrary("OpenAPICachingMiddleware"), + .executable(name: "apiserverapp", targets: ["ApiServerApp"]), +] + +// ---------- Apple-Only Products (iOS + macOS) ---------- +#if os(iOS) || os(macOS) +let appleOnlyProducts: [Product] = [ + .singleTargetLibrary("AppTheme"), + .singleTargetLibrary("SharedViews"), + .singleTargetLibrary("AuthFeature"), + .singleTargetLibrary("AppFeature"), + .singleTargetLibrary("AppFont"), + .singleTargetLibrary("BetaSettingsFeature"), + .singleTargetLibrary("DemoAppFeature"), + .singleTargetLibrary("SharedComponents"), + .singleTargetLibrary("Components"), + .singleTargetLibrary("BenefitsComponents"), + .singleTargetLibrary("AllComponents"), +] +#else +let appleOnlyProducts: [Product] = [] +#endif + +// ---------- Combine All Products ---------- +let allProducts = baseProducts + appleOnlyProducts + [ + .singleTargetLibrary("PlaybookFeature"), // Always exposed for Xcode scheme visibility +] +``` + +#### Targets Separation + +```swift +let targets: [Target] = { + // ---------- Base Targets (All Platforms) ---------- + let sharedModelsTarget = Target.target( + name: "SharedModels", + dependencies: [] + ) + let apiClientTarget = Target.target( + name: "ApiClient", + dependencies: ["ApiShared", "SharedModels"] + ) + let baseTargets = [ + sharedModelsTarget, + apiClientTarget, + ] + + // ---------- Apple-Only Targets (iOS + macOS) ---------- + #if os(iOS) || os(macOS) + let appColorsTarget = Target.target( + name: "AppColors", + dependencies: [] // Foundation: zero dependencies + ) + let appThemeTarget = Target.target( + name: "AppTheme", + dependencies: [ + "AppColors", // Design system combines colors + fonts + "AppFont", + ] + ) + let sharedViewsTarget = Target.target( + name: "SharedViews", + dependencies: [ + "AppColors", + "AppFont", + .product(name: "Inject", package: "Inject"), + ] + ) + let authFeatureTarget = Target.target( + name: "AuthFeature", + dependencies: ["SharedModels", "SharedViews", "AppColors", "AppFont"] + ) + let appleTargets = [ + appColorsTarget, + appThemeTarget, + sharedViewsTarget, + authFeatureTarget, + ] + #else + let appleTargets: [Target] = [] + #endif + + // ---------- PlaybookFeature (Always Defined, Conditionally Linked) ---------- + let playbookTarget = Target.target( + name: "PlaybookFeature", + dependencies: [ + "Components", + "AppComponents", + "SharedModels", + "ApiClient", + .product(name: "Inject", package: "Inject"), + .product( + name: "Playbook", + package: "playbook-ios", + condition: .when(platforms: [.iOS]) // ← Platform-specific dependency + ), + .product( + name: "PlaybookUI", + package: "playbook-ios", + condition: .when(platforms: [.iOS]) + ), + ] + ) + + return baseTargets + appleTargets + [playbookTarget] +}() +``` + +#### Why Separate Platforms? + +**Benefits:** +- ✅ Server targets build on Linux CI without Apple SDKs +- ✅ UI targets only compile on Apple platforms +- ✅ Clear separation between backend and frontend code +- ✅ Prevents accidental dependencies on Apple frameworks in server code +- ✅ Faster CI builds (Linux can skip UI packages) + +**When to Use:** +- UI components, SwiftUI views → `#if os(iOS) || os(macOS)` +- Shared models, API contracts → No conditional (base products) +- Server code, Vapor endpoints → No conditional (base products) +- Apple-only frameworks (UIKit, AppKit) → `#if os(iOS) || os(macOS)` + +**Use `#if os()` for platform detection in `Package.swift`, never `#if canImport(UIKit)`.** `#if canImport` is evaluated lazily and silently breaks the manifest on Linux CI; `#if os()` is evaluated at manifest-parse time and gives deterministic builds across all platforms. This is the most common SPM landmine on cross-platform packages. + diff --git a/docs/rules-swift/exp/implementation-patterns.md b/docs/rules-swift/exp/implementation-patterns.md new file mode 100644 index 0000000..d1dc242 --- /dev/null +++ b/docs/rules-swift/exp/implementation-patterns.md @@ -0,0 +1,436 @@ +# Implementation Patterns + +Six worked patterns for the package layers used in the canonical Swift monorepo. Foundation, Feature, Middleware, API, Component, Font/Resource. + +Cross-referenced from elsewhere: +- Aggregator pattern: see Pattern 5 (Component Layer Architecture) > AllComponents Package, the canonical example +- Font registration anti-patterns: see Pattern 6 (Font/Resource Package) > Why This Pattern + +### Pattern 1: Foundation Package (No Dependencies) + +```swift +// Packages/Sources/SharedModels/Package.swift (excerpt) +let sharedModelsTarget = Target.target( + name: "SharedModels", + dependencies: [] // RULE: Foundation packages have ZERO dependencies +) +``` + +```swift +// Packages/Sources/SharedModels/User.swift +public struct User: Identifiable, Codable, Sendable { + public let id: UUID + public let firstName: String + public let lastName: String + public let email: String + + public init(id: UUID, firstName: String, lastName: String, email: String) { + self.id = id + self.firstName = firstName + self.lastName = lastName + self.email = email + } +} +``` + +Foundation packages are pure Swift, no dependencies, highly reusable. + +### Pattern 2: Feature Package (Depends on Foundation + Infrastructure) + +```swift +// Packages/Sources/AuthFeature/Package.swift (excerpt) +let authFeatureTarget = Target.target( + name: "AuthFeature", + dependencies: [ + "SharedModels", // Foundation: domain models + "SharedViews", // Infrastructure: reusable UI + "AppColors", // Foundation: colors + "AppFont", // Foundation: typography + "ApiClient", // Infrastructure: networking + ] +) +``` + +```swift +// Packages/Sources/AuthFeature/LoginView.swift +import SwiftUI +import SharedViews +import SharedModels +import AppColors +import AppFont + +public struct LoginView: View { + @StateObject private var viewModel = LoginViewModel() + @Environment(\.appColors) var colors + + public init() {} + + public var body: some View { + VStack { + Text("Welcome") + .bdrFont(.headline) // From AppFont + .foregroundColor(colors.primary) // From AppColors (Apple HIG naming) + + // Use shared components from SharedViews + // Use models from SharedModels + } + } +} +``` + +Features depend on foundations and infrastructure, never on other features (except parent/child relationships). + +### Pattern 3: Middleware Package (Single Purpose Infrastructure) + +```swift +// Packages/Sources/OpenAPICachingMiddleware/Package.swift (excerpt) +let apiCachingTarget = Target.target( + name: "OpenAPICachingMiddleware", + dependencies: [ + .product(name: "OpenAPIRuntime", package: "swift-openapi-runtime"), + ] +) +``` + +Middleware packages are highly focused, single-purpose, minimal dependencies. + +### Pattern 4: API Layer Separation + +```swift +// RULE: Separate packages for each API concern + +// ApiShared: OpenAPI spec + generated DTOs +let apiSharedTarget = Target.target( + name: "ApiShared", + dependencies: [ + .product(name: "OpenAPIRuntime", package: "swift-openapi-runtime"), + ], + plugins: [ + .plugin(name: "OpenAPIGenerator", package: "swift-openapi-generator"), + ] +) + +// ApiClient: Client-side networking +let apiClientTarget = Target.target( + name: "ApiClient", + dependencies: [ + "ApiShared", // Uses generated DTOs + "SharedModels", + "OpenAPICachingMiddleware", + ] +) + +// ApiServer: Server-side handlers +let apiServerTarget = Target.target( + name: "ApiServer", + dependencies: [ + "ApiShared", // Uses generated DTOs + "SharedModels", + .product(name: "Vapor", package: "vapor"), + ] +) + +// ApiServerApp: Executable +let apiServerAppTarget = Target.executableTarget( + name: "ApiServerApp", + dependencies: ["ApiServer"] +) +``` + +API layer has 4 packages: Shared (contract), Client, Server, ServerApp (executable). + +### Pattern 5: Component Layer Architecture + +The component layer has a strict 3-package hierarchy. Start with the Components package first. + +#### Component System Architecture + +``` +Components (core infrastructure) + ↓ +SharedComponents (hot reload) + ↓ +AppComponents (app-specific production components) +``` + +#### 1. Components Package (CORE - comes FIRST) + +**Purpose:** Core component system infrastructure + +**Contains:** +- `AnyComponent` - Type-erased component protocol +- `ComponentsBundle` - Bundle management +- `ComponentFactory` - Component instantiation +- `ComponentRegistry` - Global component registration +- `ComponentListComponent` - Component list rendering +- `ComponentRegistrar` - Registration interface +- `SystemComponentRegistrar` - System component registration +- `ComponentListModel` - Component list data model +- `ComponentListView` - Component list view +- `components.json` - Component configuration + +**Dependencies:** ZERO (foundation infrastructure) + +```swift +// Packages/Package.swift (excerpt) +let componentsTarget = Target.target( + name: "Components", + dependencies: [], // Foundation layer; zero dependencies + resources: [ + .process("components.json"), + ] +) +``` + +**Structure:** +``` +Packages/Sources/Components/ +├── Protocol/ +│ ├── AnyComponent.swift +│ └── ComponentRegistrar.swift +├── Registry/ +│ ├── ComponentRegistry.swift +│ ├── ComponentFactory.swift +│ └── SystemComponentRegistrar.swift +├── Bundle/ +│ └── ComponentsBundle.swift +├── List/ +│ ├── ComponentListModel.swift +│ ├── ComponentListView.swift +│ └── ComponentListComponent.swift +└── components.json +``` + +#### 2. SharedComponents Package (Hot Reload Infrastructure) + +**Purpose:** Hot reload and development-time infrastructure + +**Dependencies:** +- Inject (hot reload) +- KZFileWatchers (file watching) + +```swift +// Packages/Package.swift (excerpt) +let sharedComponentsTarget = Target.target( + name: "SharedComponents", + dependencies: [ + .product(name: "Inject", package: "Inject"), + .product(name: "KZFileWatchers", package: "KZFileWatchers"), + ] +) +``` + +**Purpose:** Enables hot reload of components during development + +#### 3. AppComponents Package (App-Specific Components) + +**Purpose:** Production app-specific components + +**Dependencies:** +- Components (core system) +- AppColors (semantic colors with HSV) +- AppFont (typography) + +```swift +// Packages/Package.swift (excerpt) +let appComponentsTarget = Target.target( + name: "AppComponents", + dependencies: [ + "Components", // Core component system + "AppColors", // Semantic colors (HSV-based) + "AppFont", // App typography + ], + resources: [ + .process("Resources"), // Images, assets + ] +) +``` + +**Examples:** +- `BenefitCardComponent` - Benefit display card +- `LanguageSwitcherComponent` - Language selection +- `ButtonComponent` - App-specific buttons + +**Structure:** +``` +Packages/Sources/AppComponents/ +├── BenefitCardComponent.swift +├── LanguageSwitcherComponent.swift +├── ButtonComponent.swift +├── Resources/ +│ └── Images/ +└── AppComponentsRegistration.swift +``` + +#### 4. AllComponents Package (Aggregator - Optional) + +**Purpose:** Umbrella package for convenient imports. + +**This is also the canonical example of the general aggregator pattern.** An aggregator package re-exports its dependencies via `@_exported import`, so a consumer who writes `import YourAggregator` gets everything the aggregator depends on. The same shape works for any umbrella package, not just AllComponents (e.g., a hypothetical `AllFeatures` for a preview app would depend on every `*Feature` package and `@_exported import` each one). + +**Dependencies:** +- Components +- AppComponents + +```swift +// Packages/Package.swift (excerpt) +let allComponentsTarget = Target.target( + name: "AllComponents", + dependencies: [ + "Components", + "AppComponents", + ] +) +``` + +```swift +// Packages/Sources/AllComponents/AllComponents.swift +@_exported import Components +@_exported import AppComponents +``` + +Use only in preview/demo apps and component libraries, NEVER in production feature paths. + +#### Component Layer Rules + +Rules: +1. Create the Components package first (core infrastructure). +2. SharedComponents depends only on hot reload tools (Inject, KZFileWatchers). +3. AppComponents depends on Components + AppColors + AppFont +4. NEVER skip the Components package - it contains the core system +5. Component configuration goes in `components.json` +6. All components must conform to the protocol defined in Components +7. Registration happens via ComponentRegistry from Components package + +**Order of Creation:** +1. Components (AnyComponent, ComponentRegistry, etc.) +2. SharedComponents (hot reload infrastructure) +3. AppComponents (app-specific components) +4. AllComponents (aggregator - optional) + +### Pattern 6: Font/Resource Package + +Register fonts using CoreText, not via Info.plist. Resources use `.process()` in Package.swift. + +#### Package.swift Configuration + +```swift +// Packages/Package.swift (excerpt from targets closure) +let appFontTarget = Target.target( + name: "AppFont", + dependencies: [], + resources: [ + .process("Fonts"), // .process() registers; .copy() does not + ] +) +``` + +Use `.process("Fonts")` to ensure Bundle.module works correctly. NEVER use `.copy()`. + +#### Font Registration Implementation + +```swift +// Packages/Sources/AppFont/FontRegistration.swift +import CoreGraphics +import CoreText +import Foundation + +#if canImport(UIKit) +import UIKit +#elseif canImport(AppKit) +import AppKit +#endif + +public enum FontRegistration { + /// Register custom fonts from the AppFont package + public static func registerFonts() { + // Get all resource URLs and filter for .otf files + guard let resourceURLs = Bundle.module.urls(forResourcesWithExtension: nil, subdirectory: nil) else { + print("⚠️ No resources found in AppFont bundle") + return + } + + let fontURLs = resourceURLs.filter { $0.pathExtension.lowercased() == "otf" } + + guard !fontURLs.isEmpty else { + print("⚠️ No .otf fonts found in AppFont bundle") + return + } + + for url in fontURLs { + var errorRef: Unmanaged? + let success = CTFontManagerRegisterFontsForURL(url as CFURL, .process, &errorRef) + + if !success { + print("⚠️ Failed to register font: \(url.lastPathComponent)") + if let error = errorRef?.takeRetainedValue() { + print(" Error: \(error)") + } + } else { + print("✅ Registered font: \(url.lastPathComponent)") + } + } + } +} +``` + +#### Package Directory Structure + +``` +Packages/Sources/AppFont/ +├── FontRegistration.swift # Registration using CoreText +├── ScaledFont.swift # Font modifiers (.bdrFont()) +└── Fonts/ # Font resources + ├── MonitorPro-Normal.otf + ├── MonitorPro-Bold.otf + └── MonitorPro-Light.otf +``` + +#### Usage in App + +```swift +// Apps/iosApp/iosAppApp.swift +import SwiftUI +import AppFont + +@main +struct iosAppApp: App { + init() { + // Register fonts before any UI renders + FontRegistration.registerFonts() + } + + var body: some Scene { + WindowGroup { + ContentView() + } + } +} +``` + +#### Why This Pattern? + +**Benefits:** +- ✅ Works in SPM packages (Info.plist approach doesn't work in packages) +- ✅ Explicit font registration with error reporting +- ✅ Cross-platform (iOS + macOS) with `#if canImport()` conditionals +- ✅ Uses `Bundle.module` (SPM automatic bundle) +- ✅ Supports multiple font formats (.otf, .ttf) via filter +- ✅ Clear console output showing which fonts loaded + +**Why `.process()` not `.copy()`:** +- `.process()` → Resources are processed and accessible via `Bundle.module` +- `.copy()` → Resources copied verbatim, may not work with `Bundle.module` + +**Why CoreText not Info.plist:** +- Info.plist font registration only works in app bundles, NOT in SPM packages +- CoreText registration works anywhere (packages, frameworks, apps) + +Rules: +1. Use `CTFontManagerRegisterFontsForURL` for font registration. +2. Use `Bundle.module` in SPM packages, not `Bundle.main`. +3. ALWAYS use `.process()` for font resources in Package.swift +4. ALWAYS use `#if canImport(UIKit)` / `#if canImport(AppKit)` for platform imports +5. ALWAYS call `FontRegistration.registerFonts()` in app init BEFORE any UI renders +6. NEVER use Info.plist `UIAppFonts` / `ATSApplicationFontsPath` in packages + diff --git a/docs/rules-swift/exp/migration.md b/docs/rules-swift/exp/migration.md new file mode 100644 index 0000000..e306380 --- /dev/null +++ b/docs/rules-swift/exp/migration.md @@ -0,0 +1,43 @@ +# Migration Patterns + +Extracting code into a new package; splitting a large package. + +### Extracting Code to New Package + +```bash +# 1. Create new package structure +mkdir -p Packages/Sources/NewPackage +mkdir -p Packages/Tests/NewPackageTests + +# 2. Move files +git mv OldPackage/SomeFeature.swift NewPackage/ + +# 3. Update Package.swift +# Add new target and product + +# 4. Update imports in dependent files +# Change: import OldPackage +# To: import NewPackage + +# 5. Rebuild +cd Packages && swift build + +# 6. Run tests +swift test --filter NewPackageTests +``` + +### Splitting Large Package + +```swift +// Before: Large "Features" package +Features/ +├── Auth/ +├── Profile/ +└── Settings/ + +// After: Separate feature packages +AuthFeature/ +ProfileFeature/ +SettingsFeature/ +``` + diff --git a/docs/rules-swift/exp/package-swift.md b/docs/rules-swift/exp/package-swift.md new file mode 100644 index 0000000..0c5e7d7 --- /dev/null +++ b/docs/rules-swift/exp/package-swift.md @@ -0,0 +1,230 @@ +# Package.swift Patterns + +The closure-with-local-variables pattern for the three top-level arrays in `Package.swift`: `deps`, `allProducts`, `targets`. + +All top-level `Package.swift` arrays (`deps`, `allProducts`, `targets`) use the closure-with-local-variables pattern. + +### Dependencies Definition + +```swift +// ---------- Dependencies ---------- +let deps: [Package.Dependency] = { + // Apple's OpenAPI stack + let openAPIGeneratorDep = Package.Dependency.package( + url: "https://github.com/apple/swift-openapi-generator", + from: "1.10.3" + ) + let openAPIRuntimeDep = Package.Dependency.package( + url: "https://github.com/apple/swift-openapi-runtime", + from: "1.8.3" + ) + let openAPIVaporDep = Package.Dependency.package( + url: "https://github.com/swift-server/swift-openapi-vapor", + from: "1.0.1" + ) + + // Vapor stack + let vaporDep = Package.Dependency.package( + url: "https://github.com/vapor/vapor", + from: "4.119.0" + ) + let fluentDep = Package.Dependency.package( + url: "https://github.com/vapor/fluent", + from: "4.13.0" + ) + let fluentSQLiteDep = Package.Dependency.package( + url: "https://github.com/vapor/fluent-sqlite-driver", + from: "4.8.1" + ) + + // Custom middlewares + let loggingMiddlewareDep = Package.Dependency.package( + url: "https://github.com/mihaelamj/OpenAPILoggingMiddleware", + from: "1.1.0" + ) + let bearerTokenDep = Package.Dependency.package( + url: "https://github.com/mihaelamj/BearerTokenAuthMiddleware", + from: "1.2.0" + ) + + // Apple-only dependencies (safe on Linux CI - only used by Apple targets) + let fileWatchersDep = Package.Dependency.package( + url: "https://github.com/krzysztofzablocki/KZFileWatchers.git", + from: "1.0.0" + ) + let injectDep = Package.Dependency.package( + url: "https://github.com/krzysztofzablocki/Inject.git", + from: "1.2.4" + ) + let playbookDep = Package.Dependency.package( + url: "https://github.com/playbook-ui/playbook-ios", + from: "0.4.0" + ) + + return [ + openAPIGeneratorDep, + openAPIRuntimeDep, + openAPIVaporDep, + vaporDep, + fluentDep, + fluentSQLiteDep, + loggingMiddlewareDep, + bearerTokenDep, + fileWatchersDep, + injectDep, + playbookDep, + ] +}() +``` + +Group dependencies by purpose (OpenAPI, Vapor, Middlewares, Apple-only), use descriptive variable names. + +### Product Definition + +```swift +// ---------- Products ---------- +let allProducts: [Product] = { + // Base products (all platforms) + let apiSharedProduct = Product.singleTargetLibrary("ApiShared") + let apiServerProduct = Product.singleTargetLibrary("ApiServer") + let apiClientProduct = Product.singleTargetLibrary("ApiClient") + let sharedModelsProduct = Product.singleTargetLibrary("SharedModels") + let cachingMiddlewareProduct = Product.singleTargetLibrary("OpenAPICachingMiddleware") + let serverAppProduct = Product.executable(name: "apiserverapp", targets: ["ApiServerApp"]) + + let baseProducts: [Product] = [ + apiSharedProduct, + apiServerProduct, + apiClientProduct, + sharedModelsProduct, + cachingMiddlewareProduct, + serverAppProduct, + ] + + // Apple-only products (iOS + macOS) + #if os(iOS) || os(macOS) + let appColorsProduct = Product.singleTargetLibrary("AppColors") + let appThemeProduct = Product.singleTargetLibrary("AppTheme") + let sharedViewsProduct = Product.singleTargetLibrary("SharedViews") + let authFeatureProduct = Product.singleTargetLibrary("AuthFeature") + let appFeatureProduct = Product.singleTargetLibrary("AppFeature") + let componentsProduct = Product.singleTargetLibrary("Components") + let appComponentsProduct = Product.singleTargetLibrary("AppComponents") + + let appleOnlyProducts: [Product] = [ + appColorsProduct, + appThemeProduct, + sharedViewsProduct, + authFeatureProduct, + appFeatureProduct, + componentsProduct, + appComponentsProduct, + ] + #else + let appleOnlyProducts: [Product] = [] + #endif + + // Always exposed (for Xcode scheme visibility) + let playbookProduct = Product.singleTargetLibrary("PlaybookFeature") + + return baseProducts + appleOnlyProducts + [playbookProduct] +}() + +// Helper extension +extension Product { + static func singleTargetLibrary(_ name: String) -> Product { + .library(name: name, targets: [name]) + } +} +``` + +Use local variables for each product, group by platform, use helper extensions for common patterns. + +### Target Organization + +Declare targets as individual variables inside a closure, group by layer, then return their concatenation. + +```swift +let targets: [Target] = { + // ---------- Shared Models ---------- + let sharedModelsTarget = Target.target( + name: "SharedModels", + dependencies: [] + ) + let sharedModelsTestsTarget = Target.testTarget( + name: "SharedModelsTests", + dependencies: ["SharedModels"] + ) + let modelTargets = [ + sharedModelsTarget, + sharedModelsTestsTarget, + ] + + // ---------- API Layer ---------- + let apiSharedTarget = Target.target( + name: "ApiShared", + dependencies: [ + .product(name: "OpenAPIRuntime", package: "swift-openapi-runtime"), + ], + plugins: [ + .plugin(name: "OpenAPIGenerator", package: "swift-openapi-generator"), + ] + ) + let apiSharedTestsTarget = Target.testTarget( + name: "ApiSharedTests", + dependencies: ["ApiShared"] + ) + let apiClientTarget = Target.target( + name: "ApiClient", + dependencies: [ + "ApiShared", + "SharedModels", + ] + ) + let apiClientTestsTarget = Target.testTarget( + name: "ApiClientTests", + dependencies: ["ApiClient"] + ) + let apiTargets = [ + apiSharedTarget, + apiSharedTestsTarget, + apiClientTarget, + apiClientTestsTarget, + ] + + // ---------- UI Components ---------- + let appThemeTarget = Target.target( + name: "AppTheme", + dependencies: [] + ) + let sharedViewsTarget = Target.target( + name: "SharedViews", + dependencies: [ + "AppTheme", + "AppFont", + ] + ) + let uiTargets = [ + appThemeTarget, + sharedViewsTarget, + ] + + // Return all targets grouped by layer + return modelTargets + apiTargets + uiTargets +}() +``` + +**Why this pattern:** +- ✅ Clear visual separation with comment headers (e.g., `// ---------- Shared Models ----------`) +- ✅ Each target has a descriptive variable name (`sharedModelsTarget`, not inline Target.target(...)) +- ✅ Easy to reference targets within Package.swift (can reuse variable names) +- ✅ Groups targets by layer/domain for better organization +- ✅ Trailing commas in arrays for cleaner diffs +- ✅ Makes Package.swift more maintainable as project grows + +Don't define targets inline in the array; use intermediate variables. + +## See also + +- [`core/folder-grouping.md`](../core/folder-grouping.md): when many single-file targets share a semantic kind (e.g. 27 importers, 5 renderers), they can colocate under a single parent folder using `path:` + disjoint `sources:` declarations. A `flatImporter(_:in:dependencies:)` helper is the worked example: 26 single-file importer targets share `Sources/Import/Importers//` paths while remaining independent SPM targets. The closure-with-local-variables pattern above still applies; the helper just returns a `Target.target(...)` with the path/sources baked in. + diff --git a/docs/rules-swift/exp/testing.md b/docs/rules-swift/exp/testing.md new file mode 100644 index 0000000..ba44b96 --- /dev/null +++ b/docs/rules-swift/exp/testing.md @@ -0,0 +1,27 @@ +# Testing Patterns (Architecture Angle) + +ExtremePackaging-specific testing concerns: per-package test target, cross-package test doubles. Full Swift Testing rules live in `testing.md` (the top-level rule). Both files state the per-package-test-target rule, by deliberate cross-context dual-homing. + +Full Swift Testing rules live in `testing.md`. Two ExtremePackaging-specific points: + +**Per-package test target.** Every source target ships with a matching test target. Run a single package in isolation: + +```bash +cd Packages +swift test --filter Tests +``` + +Folder layout: `Packages/Sources//` and `Packages/Tests/Tests/`. + +**Cross-package test doubles.** Mocks live in the test target, not the source package. Public protocols can be defined in the source package; their mock implementations stay in `Packages/Tests/Tests/Mocks/`. Never publish mocks from `Sources/` (they leak into production binaries). + +```swift +// Packages/Sources/ApiClient/APIClientProtocol.swift +public protocol APIClientProtocol { + func login(email: String, password: String) async throws -> User +} + +// Packages/Tests/ApiClientTests/Mocks/MockAPIClient.swift +public struct MockAPIClient: APIClientProtocol { /* ... */ } +``` + diff --git a/docs/rules-swift/exp/verification.md b/docs/rules-swift/exp/verification.md new file mode 100644 index 0000000..9e3867d --- /dev/null +++ b/docs/rules-swift/exp/verification.md @@ -0,0 +1,31 @@ +# Verification + +Final checklist before considering an ExtremePackaging change done. + +When applying these rules, always: +1. Check current package structure matches documented 20-package layout + - Foundation: SharedModels, AppColors, AppFont + - Design System: AppTheme +2. Verify new packages follow naming conventions +3. Ensure dependencies flow unidirectionally (Foundation → Infrastructure → Features → Apps) +4. Create test targets for all new packages +5. Update Package.swift using closure-with-local-variables pattern: + - Define `deps`, `allProducts`, and `targets` using `let variable: [Type] = { ... }()` pattern + - NEVER use inline array definitions + - Use descriptive variable names for each dependency, product, and target + - Group by purpose/layer with comment headers (`// ---------- Header ----------`) + - Separate platform-specific code with `#if os(iOS) || os(macOS)` +6. Run `swift build` to verify package integrity +7. Verify Package.swift follows all formatting rules: + - Trailing commas in arrays + - Grouped dependencies (OpenAPI, Vapor, Apple-only) + - Grouped products (base, appleOnly) + - Grouped targets (modelTargets, apiTargets, uiTargets) +8. Check platform separation in `Package.swift` uses `#if os()`, not `#if canImport()`. (Inside Swift source files, `#if canImport(UIKit)` / `#if canImport(AppKit)` is the correct idiom; the manifest is the only place `canImport` is forbidden.) +9. Verify `.when(platforms:)` used for platform-specific dependencies within targets +10. For font/resource packages: + - Confirm `.process()` used for resources (NOT `.copy()`) + - Verify `Bundle.module` used (NOT `Bundle.main`) + - Check CoreText registration implemented with error handling + - Ensure registration called in app init before UI renders + - Verify no Info.plist font registration used diff --git a/docs/rules-swift/exp/when-to-create.md b/docs/rules-swift/exp/when-to-create.md new file mode 100644 index 0000000..4af8c50 --- /dev/null +++ b/docs/rules-swift/exp/when-to-create.md @@ -0,0 +1,135 @@ +# When to Create a New Package + +Always-loaded core. Use the decision tree first. The DO/DON'T list and the checklist below cover the corner cases. + +## PACKAGE CREATION DECISION TREE + +``` +Need to add new code? +├─ Is it a reusable domain model? +│ └─ YES → Add to SharedModels +│ +├─ Is it UI-related? +│ ├─ Colors/semantic colors? → Add to AppColors +│ ├─ Fonts/typography? → Add to AppFont +│ ├─ Combined theme? → Add to AppTheme (uses AppColors + AppFont) +│ ├─ Component system infrastructure? → Add to Components (core system) +│ ├─ Hot reload support? → Add to SharedComponents +│ ├─ Reusable app component? → Add to AppComponents +│ └─ View helper/modifier? → Add to SharedViews +│ +├─ Is it a complete user-facing feature? +│ └─ YES → Create new *Feature package +│ Example: ProfileFeature, SettingsFeature, PaymentFeature +│ +├─ Is it API/networking related? +│ ├─ Client-side? → Add to ApiClient (or create new client package) +│ ├─ Server-side? → Add to ApiServer +│ ├─ Shared DTOs? → Add to ApiShared (OpenAPI generated) +│ └─ Middleware? → Create new *Middleware package +│ +├─ Is it shared infrastructure? +│ └─ YES → Create new Shared* package +│ Example: SharedUtilities, SharedNetworking +│ +└─ Still unsure? + └─ Ask: "Could this be reused in isolation?" + ├─ YES → Create new package + └─ NO → Add to most specific existing package +``` + +## WHEN TO CREATE A NEW PACKAGE + +### ✅ DO Create New Package When: + +1. **New Feature Module** + - Complete user-facing feature (login, profile, payments) + - Example: `ProfileFeature`, `PaymentFlowFeature` + +2. **Reusable Infrastructure** + - Can be tested in isolation + - Might be used by multiple features + - Example: `CachingMiddleware`, `LoggingUtility` + +3. **Third-Party Integration** + - Wraps external library + - Isolates external dependencies + - Example: `ApplePayIntegration`, `BiometricAuth` + +4. **Platform Separation** + - Platform-specific code (iOS vs macOS) + - Example: `IOSBiometrics`, `MacOSNotifications` + +5. **Build Optimization** + - Large, stable code that rarely changes + - Expensive compilation (generated code) + - Example: `ApiShared` (OpenAPI generated) + +### ❌ DON'T Create New Package When: + +1. **Single Use Case** + - Only used by one feature + - Tightly coupled to specific screen + - → Add to that feature's package + +2. **Trivial Helper** + - 1-2 small functions + - No external dependencies + - → Add to existing utility package + +3. **Temporary Code** + - Proof of concept + - Spike/experiment + - → Keep in feature until proven stable + +## DECISION CHECKLIST + +### Before Creating a New Package + +- [ ] Package has single, clear responsibility +- [ ] Package name follows conventions (*Feature, Shared*, Api*, *Components) +- [ ] Dependencies are minimal and explicit +- [ ] No circular dependencies introduced +- [ ] Can be built and tested in isolation +- [ ] Fits into architectural layer (Foundation/Infrastructure/Feature/App) +- [ ] Test target created alongside source target +- [ ] Product registered in Package.swift products array +- [ ] Dependencies only flow upward (no higher-layer dependencies) + +### Before Adding to Existing Package + +- [ ] New code shares responsibility with existing code +- [ ] No better-suited package exists +- [ ] Not creating a "God package" with mixed concerns +- [ ] Won't introduce unwanted dependencies to package consumers + +### Before Modifying Package.swift + +- [ ] Used closure-with-local-variables pattern for `deps` +- [ ] Used closure-with-local-variables pattern for `allProducts` +- [ ] Used closure-with-local-variables pattern for `targets` +- [ ] NEVER used inline array definitions +- [ ] Grouped dependencies by purpose (OpenAPI, Vapor, Apple-only, etc.) +- [ ] Grouped products by platform (base vs. appleOnly) +- [ ] Grouped targets by layer (Foundation, Infrastructure, Features) +- [ ] Used comment headers (e.g., `// ---------- Shared Models ----------`) +- [ ] In `Package.swift`: separated platform-specific code with `#if os(iOS) || os(macOS)` +- [ ] In `Package.swift` only: did NOT use `#if canImport(UIKit)` (manifest evaluates it lazily and breaks Linux CI; `#if os()` is manifest-parse-time and safe). In regular Swift source files, `#if canImport(UIKit)` / `#if canImport(AppKit)` is the correct idiom for platform-conditional code. +- [ ] Each target/product/dependency has descriptive variable name +- [ ] Used trailing commas in all arrays +- [ ] Applied `.when(platforms:)` for platform-specific dependencies within targets + +### Before Adding Font/Resource Package + +- [ ] Used `.process()` for resources, NEVER `.copy()` +- [ ] Created `FontRegistration.swift` with CoreText registration +- [ ] Used `Bundle.module` for resource access, NEVER `Bundle.main` +- [ ] Used `#if canImport(UIKit)` / `#if canImport(AppKit)` for platform imports +- [ ] Filtered font files by extension (.otf, .ttf) +- [ ] Used `CTFontManagerRegisterFontsForURL` with error handling +- [ ] Added console logging for registration success/failure +- [ ] Called `FontRegistration.registerFonts()` in app init BEFORE UI renders +- [ ] NEVER used Info.plist font registration (`UIAppFonts`, `ATSApplicationFontsPath`) +- [ ] Package has zero dependencies (fonts are Foundation layer) +- [ ] Resources organized in dedicated subdirectory (e.g., `Fonts/`) + diff --git a/docs/rules-swift/formatting-and-linting.md b/docs/rules-swift/formatting-and-linting.md new file mode 100644 index 0000000..715b3b5 --- /dev/null +++ b/docs/rules-swift/formatting-and-linting.md @@ -0,0 +1,26 @@ +# Formatting and Linting + +**Status: MANDATORY.** Two tools enforce a consistent surface: SwiftFormat applies a deterministic format, SwiftLint flags style and correctness smells. Both run before every commit through a pre-commit hook, and again in CI. The point is that no unformatted or unlinted code reaches the history, and no contributor has to argue about style in review: the config decides, the hook enforces. + +## The rules + +1. **Config is checked in and is the source of truth.** `.swiftformat` and `.swiftlint.yml` live at the repository root and are committed. Formatting and lint behavior come from those files, never from per-editor settings, so every clone and CI run produces the identical result. +2. **Run both before every commit.** + - Format: `swiftformat . --config .swiftformat` + - Lint: `swiftlint --config .swiftlint.yml --strict` + Run format first (it resolves some lint findings on its own), then lint. `--strict` makes warnings fail, so a warning cannot accumulate into noise that hides the next real one. +3. **A pre-commit hook runs both, on staged Swift files, every commit.** Install it per clone: hooks live in `.git/` and are not cloned, so either ship a tracked `.githooks/pre-commit` and set `git config core.hooksPath .githooks`, or use the pre-commit framework (https://pre-commit.com) with the committed `.pre-commit-config.yaml` (`pre-commit install`, plus `pre-commit install --hook-type commit-msg` for the message checks). The hook formats and lints the staged set and refuses the commit on any failure. +4. **CI is the backstop.** Because hooks are not cloned, CI re-runs both as a required gate: `swiftformat --lint . --config .swiftformat` (lint mode fails on unformatted code rather than rewriting it) and `swiftlint --config .swiftlint.yml --strict`. A clone that never installed the hook still cannot merge unformatted or unlinted code. +5. **No silent disables.** A `// swiftlint:disable` is a shortcut (see `core/no-shortcuts-first-principles.md`). Every disable carries a one-line reason and is scoped as tightly as possible (`// swiftlint:disable:next ` over a single line, not a file-wide disable). A disable with no justification is a violation. + +This is a different hook from the commit-message hook in `core/git-discipline.md`: the pre-commit hook checks file content (format and lint); the commit-msg hook checks the message text (no tool attribution, no em dashes). Install both. + +## Acceptance check + +A conforming repo has: (1) `.swiftformat` and `.swiftlint.yml` committed at the root; (2) a tracked pre-commit hook (a `.githooks/` entry plus `core.hooksPath`, or a committed pre-commit config) that runs SwiftFormat and SwiftLint on staged Swift files and blocks the commit on failure; (3) CI steps that run `swiftformat --lint` and `swiftlint --strict` as required checks; (4) no `// swiftlint:disable` without an adjacent reason, verified by a grep. A repo that relies on contributors remembering to format, with no hook and no CI gate, fails this rule. + +## Companion rules + +- `core/no-shortcuts-first-principles.md`: a blanket lint-disable to silence a warning is the shortcut this rule forbids. +- `core/git-discipline.md`: the commit-message hook (attribution and em-dash checks) is the other half of the commit-time enforcement. +- `code-style.md`: the conventions that the formatter and linter mechanize. diff --git a/docs/rules-swift/framework-policy.md b/docs/rules-swift/framework-policy.md new file mode 100644 index 0000000..a0e17f4 --- /dev/null +++ b/docs/rules-swift/framework-policy.md @@ -0,0 +1,55 @@ +# Framework Policy (MANDATORY, ZERO TOLERANCE) + +This codebase targets Apple platforms exclusively. Every suggestion, comparison, +recommendation, citation, and dependency stays inside Apple's own SDKs (SwiftUI, +UIKit, AppKit, Foundation, and the rest of Apple's frameworks) and the +Swift-native ecosystem. This is not a default to be argued out of. It is a hard +boundary that holds in code, comments, documentation, commit messages, reviews, +and conversation alike. + +## The mandate + +- **Apple frameworks only.** Suggest, compare against, recommend, or cite Apple's + own SDKs and the Swift / Apple-native ecosystem, and nothing else. Swift-native + community libraries are acceptable. Non-Apple product stacks are not. +- **Never name a non-Apple UI or cross-platform stack.** Do not introduce one as a + dependency, do not suggest one, do not compare against one, do not validate a + choice by pointing at one, and do not mention one as an aside or as "this is what + others do." This holds for every Android, web, mobile, or cross-platform UI + toolkit without exception. The prohibition is stated as a category on purpose: + do not enumerate the banned stacks, do not name them at all. "I only cited it as + neutral corroboration" is not a defence. +- **The only exception is Linux.** When the target is genuinely Linux, use native + **C or C++** only. No other language, runtime, or framework is acceptable on + Linux. + +## Concept versus rival framework: the exact line + +Ecosystem-neutral academic and HCI theory may be referenced to explain a concept: +the architecture and interaction-design literature, the separation-of-concerns and +testability patterns, the layering models, and the like. The line is exact: + +- **Allowed:** explaining a concept by way of vendor-neutral theory. +- **Forbidden:** naming a rival framework to adopt, to compare against, or to be + validated by. + +When an idea's most famous instance lives in a non-Apple stack, make the point +with an Apple-native or vendor-neutral example, or drop the example. Never reach +for the banned stack just because it is the canonical illustration. + +## Why this exists + +A single non-Apple reference, even an offhand one, reframes the codebase as +platform-agnostic and invites drift in dependencies, comparisons, and reviewer +expectations. Holding the boundary at the level of what we are even willing to +name keeps every decision anchored to Apple's frameworks and the Swift-native +ecosystem, which is the whole point of the project. + +## Acceptance check + +A change conforms when: (1) no non-Apple UI or cross-platform stack is named in +code, comments, docs, or commit text; (2) every new dependency is an Apple SDK or +a Swift-native library; (3) any non-Apple-targeted comparison or "what others do" +aside is absent; and (4) the only non-Swift code present targets Linux and is +native C or C++. A change that names a rival stack anywhere, even as corroboration, +fails this rule. diff --git a/docs/rules-swift/linux-server.md b/docs/rules-swift/linux-server.md new file mode 100644 index 0000000..d36ddac --- /dev/null +++ b/docs/rules-swift/linux-server.md @@ -0,0 +1,392 @@ +# Linux-server Swift + +Operational rules for Tiledown code that runs on Linux in production: HTTP client, logging, database, crypto, signals, resources, POSIX interop, file I/O, and Docker CI. + +Load on demand. Triggers: Linux, server, Vapor, Hummingbird, AsyncHTTPClient, swift-log, swift-crypto, FluentSQLite, FluentPostgres, NIO, SIGTERM, graceful shutdown, Docker, `swift:6.0-jammy`, `swift:6.0-noble`, Bundle.module, FoundationNetworking on a server, isatty, BoringSSL. + +If your target runs on Linux in production (server, CLI binary, container workload), this file is the operational rule. The higher-level topology rule and the `#if canImport(...)` patterns live in `docs/rules/cross-platform.md`; this file is the server-specific operational layer. + +## When this rule applies + +- Building a Vapor / Hummingbird / NIO server that ships to Linux (Docker, ECS, Kubernetes). +- Building a Swift CLI that supports Linux installation (Homebrew on macOS, package manager or `swift build` on Linux). +- Building a server that ships cross-platform. +- Any repo with a Linux-buildable product in its Package.swift (Topology 2 or 3 from `cross-platform.md`). + +If the answer is "Apple-only iOS/macOS app, no Linux," skip this file; load `cross-platform.md` for the Apple-cross-platform patterns instead. + +Note: the TileKit engine targets macOS and Linux. Both support subprocess and shell-out, so the engine may spawn subprocesses. As a design preference, keep server-only concerns (this file's HTTP/database/signal patterns) in dedicated CLI/server targets rather than the core, so the core stays focused; this is an organizational choice, not a platform limitation. + +## 1. HTTP: AsyncHTTPClient, not URLSession + +The canonical HTTP client on Linux server code is `AsyncHTTPClient` (swift-server). Use it on every platform for cross-platform parity, not just Linux. + +```swift +import AsyncHTTPClient +import NIOHTTP1 + +let httpClient = HTTPClient(eventLoopGroupProvider: .singleton) +let request = HTTPClientRequest(url: "https://example.com/api") +let response = try await httpClient.execute(request, timeout: .seconds(30)) +let body = try await response.body.collect(upTo: 1024 * 1024) +``` + +**Why not URLSession on Linux:** `FoundationNetworking`'s URLSession on Linux lacks `URLSessionWebSocketTask`, `URLSessionStreamTask`, and PAC (proxy auto-configuration). For any feature beyond plain `data(from:)`, you will hit gaps. + +**Why use AsyncHTTPClient on Apple too:** a single HTTP client across all platforms eliminates the entire `#if canImport(FoundationNetworking)` class of bug. The cost is one dependency. You can tune one `HTTPClient` config (HTTP/1.1 only, short idle timeout, POSIX sockets) and run the same code on macOS and Linux. + +**TLS:** AsyncHTTPClient ships bundled BoringSSL on Linux; on Apple it uses Security.framework transparently. No source-level branching needed. + +If you must use URLSession (existing library code, an Apple-specific feature), see `cross-platform.md` Pattern 10 for the `canImport(FoundationNetworking)` form. + +**URLSession lifetime on Linux (FoundationNetworking):** `URLSession.deinit` asserts that the session was invalidated before release. On Apple platforms this is advisory; on Linux it crashes with "Object deallocated with non-zero retain count" at process exit. Always invalidate before the session goes out of scope: + +```swift +// Wrong: session deallocated without invalidation, Linux crash +let session = URLSession(configuration: .ephemeral) +// ... use session ... +// session goes out of scope here → crash + +// Correct option 1: final class with deinit (preferred, automatic for all callers) +public final class TileDownloader: @unchecked Sendable { + private let session: URLSession + init() { session = URLSession(configuration: .ephemeral) } + deinit { session.invalidateAndCancel() } +} + +// Correct option 2: explicit invalidation at call site +defer { session.invalidateAndCancel() } +``` + +Use `invalidateAndCancel()` rather than `finishTasksAndInvalidate()` when all tasks are already done. `invalidateAndCancel` sets `invalidated = true` synchronously, whereas `finishTasksAndInvalidate` does so async on the work queue and may not run before process exit. Sessions created with a delegate (e.g., for TLS handling) are especially prone to this: the session retains the delegate, and the retain cycle is only broken on invalidation. + +## 2. Logging: swift-log on the server, `canImport(os)` only for local dev + +Two-tier strategy: + +**Server tier (Vapor / Hummingbird / NIO target):** use `swift-log` (the `Logging` package) exclusively. Bootstrap once at startup: + +```swift +import Logging + +LoggingSystem.bootstrap { label in + var handler = StreamLogHandler.standardError(label: label) + handler.logLevel = .info + return handler +} + +let logger = Logger(label: "com.example.tiledown") +logger.info("server starting") +``` + +In production, redirect stderr to journald / Docker logs / your hosting platform's log sink. + +**App tier (iOS / macOS UI):** wrap `os.log` with a `canImport(os)` conditional and a print fallback: + +```swift +#if canImport(os) +import os.log + +public struct TileLogger { + private let logger: Logger + public init(subsystem: String, category: String) { + self.logger = Logger(subsystem: subsystem, category: category) + } + public func info(_ message: String) { logger.info("\(message, privacy: .public)") } + public func error(_ message: String) { logger.error("\(message, privacy: .public)") } +} +#else +public struct TileLogger { + public init(subsystem: String, category: String) {} + public func info(_ message: String) { print("[INFO] \(message)") } + public func error(_ message: String) { print("[ERROR] \(message)") } +} +#endif +``` + +The Apple side uses unified logging (Console, Instruments). The Linux side uses print. Same call site, different sink. + +**Do not mix the two in the same file.** `Logger` (swift-log) and `Logger` (os.log) are different types with the same name. Trying to use both in one source file requires fully qualified names (`Logging.Logger` vs `os.Logger`) and is brittle. Pick one per target. + +Reference: SSWG `swift-log` is the standard for the server tier. + +## 3. Database: Fluent on Linux, avoid CoreData + +CoreData is Apple-only; do not reach for it on the server. Options on Linux: + +- **Vapor + Fluent + FluentSQLiteDriver** (local development) / **FluentPostgresDriver** (production), the dominant SSWG stack. +- **GRDB**, a cross-platform SQLite wrapper, single binary, no actor abstractions. +- **swift-sqlite** (the raw `sqlite3` C module imported via Swift), the lowest level, lowest overhead. A good fit for FTS5 search indexes. + +**CloudKit, iCloud Drive, NSPersistentCloudKitContainer:** Apple-only. For Linux backends that need sync, build it explicitly over your chosen database driver. + +## 4. Cryptography: swift-crypto cross-platform, CryptoKit Apple-only + +The standard conditional: + +```swift +#if canImport(CryptoKit) +import CryptoKit +#else +import Crypto +#endif +``` + +This imports correctly on both platforms. But the APIs are NOT fully source-compatible. Real gotchas: + +- **`SHA256.hash` vs `SHA256.digest`**: CryptoKit exposes `Digest` types differently. The hash methods return `Digest` on CryptoKit and `Digest` on Crypto, but their `description`, `Sequence` conformance, and bytewise iteration can differ at the protocol level. Test both sides. +- **`SecureEnclave`** does not exist in swift-crypto. Hardware-backed keys are Apple-only. +- **`SymmetricKey(data:)`** is identical on both. `ChaChaPoly`, `AES.GCM`, `HKDF`, `Curve25519` are all source-compatible. +- **`P256.Signing.PrivateKey.publicKey.derRepresentation`** is identical. + +When in doubt: write the code against `Crypto` first (smaller surface, cross-platform), only reach for `CryptoKit`-specific APIs (SecureEnclave, biometric-backed keys) at Apple-only call sites with explicit `#if canImport(CryptoKit)` guards. + +## 5. Graceful shutdown: SIGTERM grace then SIGKILL + +Server containers receive SIGTERM on shutdown (Docker stop, Kubernetes pod termination, systemd stop). The correct response: stop accepting new work, drain in-flight requests, exit cleanly. Vapor and Hummingbird handle the basic SIGTERM hook automatically; your server's `application.run()` returns when SIGTERM arrives. + +For multi-process supervisors (a parent that forks server children), the reaper pattern: + +```swift +#if canImport(Darwin) +import Darwin +#elseif canImport(Glibc) +import Glibc +#endif + +func reapChild(pid: pid_t, graceSeconds: Double = 2.0) { + _ = kill(pid, SIGTERM) + let deadline = Date().addingTimeInterval(graceSeconds) + while Date() < deadline { + var status: Int32 = 0 + if waitpid(pid, &status, WNOHANG) == pid { return } // exited cleanly + Thread.sleep(forTimeInterval: 0.1) + } + _ = kill(pid, SIGKILL) + _ = waitpid(pid, nil, 0) +} +``` + +POSIX signal semantics are identical on Linux and Darwin (sigaction, signal handlers, realtime signals). The wrapper is for the `Darwin` vs `Glibc` import only. + +**Gotcha:** `proc_pidpath()` is Darwin-only. To resolve a Linux child's binary path, read `/proc/$pid/exe` as a symlink. Do not assume `proc_pidpath` works cross-platform. + +**For Vapor servers specifically:** Vapor calls `application.shutdown()` on SIGTERM, which drains the event loop group. Do not reinvent. For Hummingbird, the equivalent is `application.shutdownGracefully()`. + +## 6. Resources: compile to Swift, do not rely on `Bundle.module` + +`Bundle.module` is Apple-only as a fully reliable API. On Linux SPM, `Bundle.module` either does not exist or returns paths that do not match expectations. On macOS with Homebrew installs, the binary symlink trap breaks `Bundle.module` lookups even on Apple. Both failure modes have the same fix: compile resources as Swift code. + +The pattern (in a `Resources` target): + +```swift +// Embedded/Static.swift (generated) +extension Embedded { + static let initialSeedJSON: String = """ + { + "key": "value", + ... + } + """ +} +``` + +A build-time generator script reads the resource file, escapes it as a Swift string literal, and writes the `.swift` file. At runtime, no bundle lookup, no FS access, no symlink resolution. Works identically on every platform. + +When this pays off: + +- Resource is small (< 1 MB total per file works fine in a string literal). +- Resource is read-only at runtime. +- Resource is needed at startup (no lazy disk access). +- You are shipping a binary to multiple package managers (Homebrew, apt, MSI) and do not want to debug each one's resource-path semantics. + +When to NOT use: + +- Large binary assets (images, sound, large CSV). Use SPM's `resources: [.process("...")]` and read at runtime; accept the platform-specific resource lookup. +- Resources that change per-installation (user data, downloaded content). + +## 7. POSIX C interop: Darwin / Glibc / Musl + +For raw POSIX calls (read, write, ioctl, signal, kill, waitpid, dup2, pipe), the canonical conditional: + +```swift +#if canImport(Darwin) +import Darwin +#elseif canImport(Glibc) +import Glibc +#elseif canImport(Musl) +import Musl +#endif +``` + +Musl is reserved for Alpine Linux containers; most production deployments use glibc (Ubuntu, Debian, Amazon Linux 2, RHEL). Test on Musl only if your container base is Alpine. + +**Modern alternative, `apple/swift-system`:** + +```swift +import SystemPackage + +let fd = try FileDescriptor.open("/path", .readOnly) +defer { try? fd.close() } +let bytesRead = try fd.read(into: buffer) +``` + +`swift-system` provides a unified Swift surface for POSIX file I/O, file paths, errno handling. Use it for new code instead of raw Darwin/Glibc imports. Reference: SYS-0008 (`CInterop.stat` back-deploy), SE-0063, SE-0208. + +**When to reach for raw imports:** legacy code, operations not covered by `swift-system` (signal handlers, exec / posix_spawn, terminal ioctls). + +## 8. FileHandle write API diverges + +`FileHandle.write(_:)` has different signatures across platforms: + +- macOS / iOS: `func write(_ data: Data)`, non-throwing, never actually fails +- Linux (swift-corelibs-foundation): `func write(contentsOf: Data) throws`, throws on error + +Code that compiles on one platform may not compile on the other. The wrapper: + +```swift +extension FileHandle { + func write(contentsOf data: Data) throws { + #if canImport(Darwin) + write(data) + #else + try write(contentsOf: data) + #endif + } +} +``` + +Use this wrapper anywhere FileHandle writes are needed in cross-platform code. Apple platforms swallow errors silently (consistent with their non-throwing API); Linux propagates. + +**Alternative:** switch to `swift-system`'s `FileDescriptor.write(_:)`, which has the same throwing signature on every platform. + +## 9. Piped stdio detection: `isatty()` + +When a CLI binary is piped (shell pipeline, log collector, host process), interactive prompts and TUI escape sequences must be suppressed. Standard POSIX check, works identically on Linux and Darwin: + +```swift +import Foundation + +let isPiped = isatty(STDOUT_FILENO) == 0 + +if isPiped { + // protocol mode: structured output only +} else { + // interactive mode: colours, prompts, redraws +} +``` + +Use this to disable terminal logging when the binary is invoked by another process rather than an interactive shell. + +## 10. Test framework on cross-platform server code + +XCTest works on Linux (auto-discovery since Swift 5.4; no `LinuxMain.swift` required). Swift Testing is platform-agnostic by design. For new tests in any Linux-buildable target, default to Swift Testing. + +```swift +import Testing + +@Test func serverHealthEndpointReturns200() async throws { + let response = try await client.execute(.health) + #expect(response.status == .ok) +} +``` + +Mixed XCTest / Swift Testing in the same target is fine; both runners coexist. See `concurrency.md` for the broader rule. + +References: ST-0009, ST-0016, ST-0017, ST-0022 (Swift Testing parity across Apple, Linux, Windows). + +## 11. CI for Linux-buildable server products + +Every Linux-buildable product runs a Linux build on every PR. Minimum GitHub Actions job: + +```yaml +linux-build: + runs-on: ubuntu-latest + container: swift:6.0-jammy + steps: + - uses: actions/checkout@v4 + - run: swift build -c release --product +``` + +For testing, add a parallel job that runs `swift test --filter ` against the Linux-buildable test suites. + +**Docker image choice:** +- `swift:6.0-jammy`, Ubuntu 22.04 LTS, the default for most production deploys. +- `swift:6.0-noble`, Ubuntu 24.04 LTS, newer. +- `swift:6.0-jammy-slim`, runtime-only base for the production stage. +- `swift:6.0-bookworm`, Debian 12, for some legacy deploys. +- `swift:6.0-alpine`, Alpine + Musl, smallest image. Test Musl conditionals if you use this. + +**Production deployment via Docker:** + +```dockerfile +FROM swift:6.0-jammy as build +WORKDIR /src +COPY . . +RUN swift build -c release --product tiledownserver + +FROM swift:6.0-jammy-slim +COPY --from=build /src/.build/release/tiledownserver /usr/local/bin/ +EXPOSE 8080 +CMD ["tiledownserver"] +``` + +Multi-stage builds keep the runtime image small. The Docker production path can catch catastrophic failures, but it is not a substitute for PR-time Linux CI; deploy-time discovery means broken builds reach main. + +## 12. Manifest discipline (Topology 2: Apple clients + Linux server) + +If your repo is the Apple-clients-plus-Linux-server split (`cross-platform.md` Topology 2), the manifest pattern from `cross-platform.md` Layer A applies: + +```swift +let baseProducts: [Product] = [ + .singleTargetLibrary("TileKit"), + .singleTargetLibrary("TileServer"), + .executable(name: "tiledownserver", targets: ["TileServerApp"]), + // ... unconditional, Linux-buildable +] + +#if os(iOS) || os(macOS) +let appleOnlyProducts: [Product] = [ + .singleTargetLibrary("TileDownUI"), + .singleTargetLibrary("TileDownComponents"), + // ... Apple-only UI +] +#else +let appleOnlyProducts: [Product] = [] +#endif + +let products = baseProducts + appleOnlyProducts +``` + +Same shape for targets. Server-side products and targets stay unconditional. UI-side wraps in the conditional array. + +Document the Linux-buildable surface in the repo's `README.md`: name the product (`tiledownserver`), name the build command (`swift build -c release --product tiledownserver`), name the supported Docker image. Otherwise new developers running plain `swift build` on Linux hit Apple-target compile errors. + +## 13. Cross-references + +- **`docs/rules/cross-platform.md`** is the higher-level topology rule and covers UI patterns + library patterns. This file is the server-specific operational layer; load both together for a server repo. +- **`concurrency.md`** is platform-agnostic; Sendable + async/await behave identically on Apple and Linux. + +## 14. Authoritative references + +- **SSWG packages:** `swift-server/async-http-client`, `apple/swift-log`, `apple/swift-crypto`, `apple/swift-system`, `apple/swift-nio`. +- **Vapor / Hummingbird:** their respective package docs. +- **System interop:** SE-0063, SE-0208 (System packages). SYS-0008 (`CInterop.stat` back-deploy). +- **Swift Testing platforms:** ST-0009, ST-0016, ST-0017, ST-0022. +- **swift-foundation architecture:** `swiftlang/swift-foundation` README for the three-tier model (Darwin / swift-foundation / swift-corelibs-foundation). + +## Quick reference table + +| Concern | Apple-only choice | Cross-platform / Linux server choice | +|---|---|---| +| HTTP client | URLSession | AsyncHTTPClient | +| Logging | `os.Logger` | swift-log (`Logging`) | +| Crypto | CryptoKit | swift-crypto (`Crypto`) | +| Database | CoreData | Fluent + SQLite/Postgres driver, GRDB, or raw `sqlite3` | +| C interop | `import Darwin` | `swift-system` preferred; `canImport(Darwin/Glibc/Musl)` for legacy | +| FileHandle write | `write(_:)` non-throwing | wrap with `canImport(Darwin)` (see § 8) | +| Resources | `Bundle.module` | compile to Swift code | +| Tests | XCTest or Swift Testing | Swift Testing (platform-agnostic) | +| Shutdown | OS handles UI lifecycle | SIGTERM grace + SIGKILL | +| CI | macOS only | macOS + Linux Docker on every PR | +| Production runtime | App Store / Mac App | Docker container (jammy-slim base) | diff --git a/docs/rules-swift/namespacing.md b/docs/rules-swift/namespacing.md new file mode 100644 index 0000000..1b2b650 --- /dev/null +++ b/docs/rules-swift/namespacing.md @@ -0,0 +1,244 @@ +# Swift namespaces via enums or structs, canonical rule + +Single-page rule for picking the namespace-anchor type in TileKit and the wider Tiledown codebase: when the anchor is `Foo`, should it be `enum Foo`, `struct Foo`, or `class Foo`? + +Companion to [code-style.md](code-style.md), which covers the broader namespacing discipline (folder mirroring, file-naming convention, anchor file placement, one-type-per-file). This file answers the narrower type-choice question. + +## TL;DR + +**Default: caseless `enum`.** Use `struct` only when the type also represents a value. Never `class`. + +```swift +// Pure namespace, caseless enum. +public enum Shared {} +extension Shared { + public enum Constants {} // nested namespace + public enum Configuration {} // nested namespace +} + +// Value type that also acts as a namespace for its own helpers, struct. +public struct URL: Sendable, Equatable { + public let absoluteString: String + public init(_ s: String) { self.absoluteString = s } + + public struct Components { /* nested type */ } + public static let placeholder = URL("about:blank") +} +``` + +## Why caseless `enum`? + +A namespace anchor exists to hold nested types and static members. It should be: + +1. **Uninstantiable.** `let x = Shared()` must not compile. A caseless enum can't be instantiated because there are no cases to construct it from. A `struct` *can* be instantiated (Swift synthesises `init()` for an empty struct, yielding a zero-sized instance); a `class` even more so. Only `enum` gives uninstantiability by construction, with no extra code required. +2. **Free of ARC and runtime overhead.** A `class` carries a refcount header even when empty. A `struct` carries no header but is still a value. A caseless `enum` has no runtime footprint. +3. **Cleanly extensible.** `extension Shared { ... }` adds nested types from any file. Standard Swift practice; works identically across enum, struct, and class. +4. **Idiom recognised by the Swift community since 2015.** Reading `enum Foo {}` tells the next reader "this is a namespace" immediately. Reading `struct Foo {}` is ambiguous: could be a namespace, could be an empty value. + +The Swift Standard Library uses `enum` for the same purpose. `enum Result` and `enum Optional` are case-bearing, but the pure-namespace pattern in user libraries follows this rule. + +## When to use `struct` + +Use `struct` for the namespace anchor **only** when the type: + +- Holds **value-typed state** the consumer cares about, AND +- Also serves as the container for nested types or static helpers. + +```swift +// The type represents a value (the resolved paths) AND holds nested helpers +// (Paths.live()). Struct is correct here. +public struct Paths: Sendable { + public let baseDirectory: URL + public let docsDirectory: URL + // ... derived URLs ... + + public static func live() -> Paths { /* ... */ } +} +``` + +If the namespace has no state and no instances, `enum` is correct. If it represents a value, `struct` is correct. Never both; split into a struct value plus an enum namespace if both roles are needed. + +## When to use `class` + +**Never**, for namespacing. Class adds: + +- Refcounted heap allocation (even for empty classes, Swift still emits an instance header). +- A deinit slot. +- Inheritance complications (`open` vs `final`). + +A namespace doesn't need any of that. If you're tempted to use a class for "shared mutable state in a namespace," you actually want an **actor** or a struct value injected through DI, not a namespace anchor. + +**When to reach for `actor` instead of `enum` namespace.** All three of these must hold: (a) the state is mutable, (b) the state is genuinely shared across concurrent contexts (multiple tasks read or write it), (c) the access pattern fits serial isolation. If any one is false, the right answer is a struct value injected through `init`, not a namespace anchor and not an actor. + +## What about protocols? + +A `protocol` is not a namespace anchor. Protocols name a contract; namespaces hold types and static members. The two roles don't overlap. + +A protocol MAY live inside a namespace: + +```swift +public enum Logging {} +extension Logging { + public protocol Recording: Sendable { ... } +} +``` + +The namespace is still `enum Logging`. The protocol is a nested type. For cross-target seams, the protocol lives in a foundation-only `*Models` target (cross-package coupling via protocols, not concrete imports). The composition root wires the concrete conformer. + +## Anti-patterns + +### `struct` for a pure namespace + +```swift +// Don't. Shared() is constructible even though it has no meaning. +public struct Shared {} + +let s = Shared() // compiles, allocates nothing, but is meaningless +``` + +Use `enum Shared {}` instead. + +### `enum` with a private initialiser (the pre-2015 workaround) + +```swift +// Don't. Caseless enum already prevents instantiation; no init needed. +public enum Shared { + private init() {} // unreachable; clutters the API +} +``` + +Just `public enum Shared {}`. No init. + +### Stateless `class` as namespace + +```swift +// Don't. Class adds ARC overhead for zero benefit. +public final class Shared { + public static let constants = Constants() +} +``` + +Use `enum Shared {}` plus extensions. + +### Nesting a `struct` that's only a namespace inside a value type + +```swift +// The outer struct is a value; the nested "namespace" struct adds no value +// but is constructible. +public struct Configuration { + public let renderer: Renderer + + public struct Constants { // nested but a namespace, should be enum + public static let fileName = "config.json" + } +} +``` + +Inner type should be `public enum Constants` since it carries no state. + +## Singular vs plural for kind-group namespaces + +When a namespace holds N sibling implementations of one concept (e.g. several format parsers, several content fetchers, multiple model types), either singular (`Parser.CSV`, `Fetcher.Remote`, `Model.Tile`) or plural (`Parsers.CSV`, `Fetchers.Remote`, `Models.Tile`) is correct. Pick one per project and stay with it. Apple's Combine uses plural (`Publishers.Map`, `Subscribers.Sink`); some projects pick singular to match folder-name conventions. The rule is intra-project consistency, not the choice itself. + +The one exception is `Protocol` (singular). Swift treats `T.Protocol` as a reserved metatype member on every type expression, so a namespace literally cannot be named `Protocol`. MUST be `Protocols` plural OR a semantic rename (`Wire`, `Spec`) regardless of the project's singular-vs-plural default. See `Reserved names` in [code-style.md](code-style.md). + +## When nesting + +Nest a namespace inside another namespace by extending: + +```swift +public enum Shared {} +extension Shared { + public enum Constants {} + public enum Configuration {} +} +extension Shared.Constants { + public enum FileName { + public static let renderDatabase = "render.db" + public static let metadata = "metadata.json" + } +} +``` + +Each level of nesting stays a caseless `enum`. File layout: one nested namespace per file, named `Shared.Constants.FileName.swift`, etc. Folder layout mirrors the namespace tree: + +```text +Sources/SharedConstants/ +├── Shared.swift # public enum Shared {} +├── Constants/ +│ ├── Shared.Constants.swift # public enum Shared.Constants {} +│ ├── Shared.Constants.FileName.swift +│ └── Shared.Constants.BaseURL.swift +``` + +## Why the file-rename convention matters + +When the namespace is `Shared.Constants.FileName`, the file should be `Shared.Constants.FileName.swift`. Reasons: + +1. `git grep "Shared.Constants.FileName"` finds both the declaration site (via filename) and usages (via type reference) in one search. +2. Re-namespacing the type (moving `FileName` under a different parent) becomes a single `git mv`. +3. Folder structure mirrors the namespace tree without a separate map file. + +See [code-style.md](code-style.md) for the full file-naming rule. + +## Anchor file convention + +Every public namespace gets a single declaration file: + +```swift +// Shared.swift +public enum Shared {} +``` + +```swift +// Shared.Constants.swift +extension Shared { + public enum Constants {} +} +``` + +```swift +// Shared.Constants.FileName.swift +extension Shared.Constants { + public enum FileName { + public static let renderDatabase = "render.db" + // ... + } +} +``` + +Anchor files contain ONLY the `extension X { public enum Y {} }` line, no implementation. Implementation lives in sibling files inside the same nested namespace. The anchor plus folder convention makes the namespace tree mechanically discoverable: `find Sources/SharedConstants -name "Shared.*.swift"` lists every public-anchor file. + +## Apple precedent + +- **Swift Standard Library:** `enum Never`, `enum CommandLine`, `enum FloatingPointRoundingRule`, `enum ProcessInfo.ThermalState`. Caseless enums for both true-namespace types (`CommandLine`) and case-bearing enums. +- **Foundation:** `struct URL`, `struct UUID`, `struct Data`. Structs because they carry value state. Their nested types (`URL.ResourceValues`) are also structs because they carry state. `URLSession` is a class because it owns resources. +- **SwiftUI:** `View` is a protocol; conforming types are structs because they're value-types describing UI. `enum Edge`, `enum Axis` are case-bearing enums. + +The cross-cutting rule: **type follows function.** Namespace, `enum`. Value, `struct`. Reference identity or shared mutable state, `class` or `actor`. + +## Migration tip + +If a codebase has `struct Shared {}` or `class Shared {}` as a namespace, the simple cases are mechanical: + +```bash +# Find every empty-body declaration +git grep -l 'struct Shared {}\|class Shared {}' + +# Replace +sed -i.bak 's/public struct Shared {}/public enum Shared {}/' +sed -i.bak 's/public class Shared {}/public enum Shared {}/' +``` + +**The regex handles the empty-body case only.** Declarations with conformances (`public struct Shared: Sendable {}`), inheritance clauses, or any body content will not match. Do those rewrites by hand, one at a time, after auditing the existing conformances and members. Don't sed-blast complex declarations; the result is too easy to corrupt silently (a missed conformance, a body line left behind referring to `self`, etc.). + +Build and tests verify the safe cases: anything that was constructing the empty struct or class will fail to compile, and those callsites were wrong anyway. + +## Reference + +- Swift API Design Guidelines: https://swift.org/documentation/api-design-guidelines/ +- "Caseless enums for namespaces" idiom: Erica Sadun, 2015 (widely adopted since). + +## Cross-references + +- [code-style.md](code-style.md) covers the broader namespacing discipline (folder mirroring, file-naming convention, anchor file placement, one-type-per-file, reserved names, namespace-vs-module collisions). Read this file first for the anchor-type choice, then code-style.md for the operational details. diff --git a/docs/rules-swift/openapi-generated.md b/docs/rules-swift/openapi-generated.md new file mode 100644 index 0000000..a87c8db --- /dev/null +++ b/docs/rules-swift/openapi-generated.md @@ -0,0 +1,195 @@ +--- +description: Guardrails for OpenAPI-generated code and spec files (Swift projects) +globs: [] +alwaysApply: true +--- +# OpenAPI Generated Code and Spec Guardrails + +Never hand-edit OpenAPI-generated Swift sources or OpenAPI specs. The spec is the +source of truth: generated code is a build artifact derived from it, and any hand +edit is lost on the next generation. When a change seems necessary, change the +spec, regenerate, and put your own code in hand-written modules, or propose a spec +change without touching the generated artifacts. + +## Core rules + +### Rule 1: Do-not-touch zones + +Do not edit files that are: + +- Generated by the OpenAPI generator. +- Located under typical generation paths: + - `Sources/**/Generated/**` + - `.build/**` + - `DerivedData/**` + - `**/OpenAPI/Generated/**` +- The OpenAPI spec(s): `openapi.yaml`, `openapi.yml`, `**/openapi/*.yaml`. + +### Rule 2: Analysis-only + +Analyse, do not change. If asked to "analyse only", do not offer diffs for the +spec or the generated files. Provide findings plus actionable recommendations for +where to apply changes safely. + +### Rule 3: Safe change targets + +Direct code changes to: + +- Hand-written modules (the API client, the API server, the shared model module). +- Adapters, mappers, and extensions in non-generated targets. +- Dependency-injected services and repositories. +- A separate notes or proposals file for spec-change suggestions. + +### Rule 4: Change requests to specs + +If a spec change is unavoidable: + +- Produce a proposal file (for example + `Docs/OpenAPI-Proposals/YYYY-MM-DD-what-why.md`). +- Include the motivation, example payloads, and migration steps. +- Do **not** modify the spec inline as part of that proposal. + +## Decision tree + +``` +Need to modify API behaviour? +├─ Is it in generated code? +│ ├─ YES → Do NOT modify +│ │ ├─ Create an extension in a hand-written module +│ │ ├─ Add an adapter / mapper layer +│ │ └─ Use dependency injection +│ └─ NO → Safe to modify (the API client, the API server, and so on) +├─ Is it the OpenAPI spec? +│ ├─ YES → Do NOT modify inline +│ │ ├─ Create a proposal under Docs/OpenAPI-Proposals/ +│ │ ├─ Include motivation and examples +│ │ └─ Wait for approval, then change the spec and regenerate +│ └─ NO → Safe to modify +└─ Is it a DTO mapping? + ├─ Generated DTO → Create a domain model plus a mapper + └─ Domain model → Safe to modify +``` + +## Implementation patterns + +### Pattern 1: Extension on generated types + +```swift +// CORRECT: extend in a hand-written module +// File: ApiClient/Extensions/User+Domain.swift +import ApiShared + +extension Components.Schemas.UserDTO { + func toDomain() -> User { + User( + id: UUID(uuidString: self.id) ?? UUID(), + name: self.name, + email: self.email + ) + } +} +``` + +### Pattern 2: Adapter layer + +```swift +// CORRECT: adapter in the API client package +// File: ApiClient/Adapters/UserAdapter.swift +import ApiShared +import SharedModels + +public struct UserAdapter { + public static func toDomain(_ dto: Components.Schemas.UserDTO) -> User { + User( + id: UUID(uuidString: dto.id) ?? UUID(), + name: dto.name, + email: dto.email + ) + } + + public static func toDTO(_ user: User) -> Components.Schemas.UserDTO { + Components.Schemas.UserDTO( + id: user.id.uuidString, + name: user.name, + email: user.email + ) + } +} +``` + +### Pattern 3: Spec change proposal + +````markdown +// File: Docs/OpenAPI-Proposals/2025-01-15-add-user-avatar.md + +# Proposal: add a user avatar field + +## Motivation +Users need profile pictures for the social features. + +## Proposed change +Add an `avatarUrl` field to the UserDTO schema. + +## Example payload +```yaml +UserDTO: + type: object + properties: + id: + type: string + name: + type: string + avatarUrl: # NEW + type: string + format: uri + nullable: true +``` + +## Migration steps +1. Update the OpenAPI spec. +2. Regenerate the Swift code. +3. Update the domain model. +4. Add a migration for existing users. +```` + +## Anti-patterns + +### Do NOT modify generated files + +```swift +// WRONG: editing Generated/Types.swift +// This file is overwritten on the next generation. +extension Components.Schemas.UserDTO { + var fullName: String { ... } // Lost on regenerate. +} +``` + +### Do NOT edit the spec directly without a proposal + +```yaml +# WRONG: a direct, undocumented edit to the spec +UserDTO: + properties: + newField: # Added without a proposal + type: string +``` + +## Checklist + +Before modifying API-related code: + +- [ ] Verified the target file is NOT in a `Generated/` folder. +- [ ] Verified the target file is NOT the OpenAPI spec. +- [ ] Changes go to hand-written modules (the API client, the API server). +- [ ] Extensions or adapters created for generated-type customisation. +- [ ] Spec changes documented in a proposal file first. +- [ ] Domain models separated from DTOs. + +## Verification + +When working with OpenAPI: + +1. Check the file path before any edit. +2. Never modify `.build/`, `DerivedData/`, or `Generated/` paths. +3. Create adapters for DTO-to-domain mapping. +4. Document spec changes in proposals first, then change the spec and regenerate. diff --git a/docs/rules-swift/package-architecture.md b/docs/rules-swift/package-architecture.md new file mode 100644 index 0000000..8cc65d8 --- /dev/null +++ b/docs/rules-swift/package-architecture.md @@ -0,0 +1,169 @@ +# Package Architecture + +How to decompose Tiledown into focused SPM targets within its single package. Tiledown is a monorepo from day one: one `Package.swift` under `Packages/`, many single-responsibility targets in it. The `TileKit` library and the `tile-down` executable are already two such targets, so the decomposition rules below apply now and guide every target that joins the manifest. + +The pattern: single-responsibility SPM targets with explicit, unidirectional dependencies, all in one package. Each target has one cohesive job and declares exactly what it depends on. This buys isolated compilation, parallel builds, a clear dependency graph, and targets you can test (and lift out) in isolation. + +## What this covers + +Tiledown already has more than one target (the `TileKit` library and the `tile-down` CLI). Add a new target to the single package when one of these is true: + +- A part of TileKit has become a clearly separable responsibility (a parser, a transport, a renderer) used in more than one place. +- Compilation is slow and a large stable chunk would benefit from being its own target. +- A second front-door appears (an app target, a server, a second CLI verb set). + +## Core rules + +### Rule 1: Single responsibility per package + +Create packages with one clear purpose: + +- One well-defined responsibility per package. +- Do not mix concerns (UI plus networking, models plus API client). +- The name communicates the purpose. +- Independently buildable and testable. + +### Rule 2: Explicit dependency declaration + +Declare dependencies explicitly in `Package.swift`: + +- List every dependency in the manifest. +- No reliance on implicit or transitive dependencies. +- Minimize cross-package dependencies. +- Prefer unidirectional dependency flow. + +### Rule 3: Package granularity + +Prefer smaller, focused packages over larger ones: + +- Single-file packages are fine when the unit is genuinely standalone (a color foundation, a single protocol, a single transport). +- Separate by role, not by topical bundling: foundation primitives, infrastructure, protocols, middleware, services, per-feature or per-verb operation packages, front-door binaries. +- Each cohesive responsibility (a feature, a CLI verb, a middleware, a transport, a service) gets its own package. + +### Rule 4: Naming conventions + +Use a consistent scheme. Patterns that travel across project shapes: + +- **Shared foundation:** `Shared*` for cross-target value types, models, utilities, configuration (for example `SharedModels`, `SharedProtocols`). +- **Core infrastructure:** `Core*` or a descriptive single-purpose name for the foundation domain (protocols, parsers, transports). +- **Per-feature or per-verb operation packages:** one package per user-facing flow (in a UI app) or one per CLI verb (in a CLI). Name after the responsibility, not after a role suffix. +- **Service packages:** `*Service`, or a `Services` aggregator for cross-layer read/write services consumed by multiple front-doors. +- **Aggregators:** umbrella packages only when the umbrella adds real value (a preview host, an all-features aggregator for app composition). Do not add one by default. + +For Tiledown specifically, keep the `TileKit` library name as the public anchor and grow new packages around it (for example `TileCore`, `TileParser`) rather than renaming it. + +### Rule 5: Layer architecture (unidirectional) + +Organize packages into layers with dependency flow strictly bottom to top. The exact number of layers varies per project; the constants are: + +- **Foundation** (bottom): shared value types, models, primitive utilities, logging. +- **Infrastructure:** protocols, persistence, networking, file I/O, parsers, transports. +- **Domain:** services, business logic, per-feature or per-verb operation packages. +- **Presentation** (UI projects only): component packages, design-system packages, screens. +- **Front-door** (top): binary targets, apps, CLIs, preview hosts, test harnesses. + +Every dependency edge points upward. No back-edges. The active layer instantiation lives in the project's own `Package.swift`. + +## When to create a new package + +### Decision tree + +``` +Need to add new code? +├─ Is it a reusable domain model or value type? +│ └─ YES → add to the shared foundation package (e.g. SharedModels) +│ +├─ Is it a cross-target protocol seam? +│ └─ YES → add to SharedProtocols (see shared-protocols.md) +│ +├─ Is it a complete user-facing feature or a distinct CLI verb? +│ └─ YES → create a new feature / verb package +│ +├─ Is it infrastructure (transport, parser, persistence, middleware)? +│ └─ YES → create or extend the matching infrastructure package +│ +└─ Still unsure? + └─ Ask: "Could this be reused or tested in isolation?" + ├─ YES → create a new package + └─ NO → add to the most specific existing package +``` + +### Create a new package when + +1. **It is a complete feature or distinct verb** (a self-contained user-facing flow, or one CLI command's logic). +2. **It is reusable infrastructure** that can be tested in isolation and used by more than one consumer (a caching middleware, a transport). +3. **It wraps a third-party integration**, isolating that external dependency behind a seam. +4. **It is platform-specific** code that is cleaner separated (iOS-only vs macOS-only). +5. **It is a build-optimization win**: large, stable, or expensive-to-compile code (for example generated code) that rarely changes. + +### Do NOT create a new package when + +1. **It is single use**, only used by one feature and tightly coupled to it. Add it to that feature's package. +2. **It is a trivial helper** of one or two functions with no external dependencies. Add it to an existing utility package. +3. **It is temporary** (a spike or proof of concept). Keep it in the feature until it proves stable. + +## Decision checklist + +Before creating a new package: + +- [ ] One clear responsibility +- [ ] Name follows the conventions above +- [ ] Dependencies are minimal and explicit +- [ ] No circular dependency introduced +- [ ] Can be built and tested in isolation +- [ ] Fits a single architectural layer +- [ ] Test target created alongside the source target +- [ ] Product registered in the `Package.swift` products array +- [ ] Dependencies only flow upward + +Before adding to an existing package: + +- [ ] The new code shares a responsibility with what is there +- [ ] No better-suited package exists +- [ ] You are not creating a "god package" with mixed concerns +- [ ] You are not introducing unwanted dependencies onto the package's consumers + +Before modifying `Package.swift`: + +- [ ] Used a closure-with-local-variables pattern for the `deps`, `allProducts`, and `targets` arrays rather than one giant inline array +- [ ] Grouped dependencies by purpose, products by platform, targets by layer, with comment headers +- [ ] Separated platform-specific code with `#if os(iOS) || os(macOS)` in the manifest +- [ ] Did NOT use `#if canImport(UIKit)` in `Package.swift` (the manifest evaluates it lazily and it breaks Linux builds; `#if os()` is parse-time and safe). In regular Swift source files, `#if canImport(UIKit)` / `#if canImport(AppKit)` is the correct idiom. +- [ ] Each target/product/dependency has a descriptive variable name +- [ ] Trailing commas in all arrays +- [ ] `.when(platforms:)` applied for platform-specific dependencies within targets + +If you add a font or resource package: + +- [ ] Used `.process()` for resources, never `.copy()` +- [ ] Accessed resources via `Bundle.module`, never `Bundle.main` +- [ ] Used `#if canImport(UIKit)` / `#if canImport(AppKit)` for platform imports +- [ ] The package has zero dependencies (resources are a foundation-layer concern) + +## Example layout (illustrative, not normative) + +As Tiledown grows targets within its single package, the layout might look like this. Your actual target list will differ. + +``` +Packages/Sources/ +├── Foundation Layer (0 internal dependencies) +│ ├── TileCore # core value types, no dependencies +│ └── SharedProtocols # cross-target protocol seams +│ +├── Infrastructure Layer +│ ├── TileParser # depends: TileCore +│ └── TileTransport # depends: SharedProtocols +│ +├── Domain Layer +│ └── TileKit # the main library; depends: TileCore, TileParser +│ +└── Front-door (not under Sources/) + └── tile-down # the CLI executable; wires the concretes +``` + +## Related rules + +- [package-structure.md](package-structure.md): repository layout and the `Package.swift` manifest shape +- [package-import-contract.md](package-import-contract.md): what each target may import +- [shared-protocols.md](shared-protocols.md): the cross-target protocol-seam package +- [dependency-injection.md](dependency-injection.md): the DI principles these layers serve diff --git a/docs/rules-swift/package-import-contract.md b/docs/rules-swift/package-import-contract.md new file mode 100644 index 0000000..2096e16 --- /dev/null +++ b/docs/rules-swift/package-import-contract.md @@ -0,0 +1,118 @@ +# Per-Package Import Contract + +The rule for what each SPM target may `import`. Tiledown is a monorepo from day one with many targets in one package, and the engine (`TileKit`) and CLI (`tile-down`) are already two targets, so this contract applies now and governs every target added to the manifest. + +This is the operational companion to [dependency-injection.md](dependency-injection.md) and [shared-protocols.md](shared-protocols.md). Read those first for the underlying principles (no singletons, constructor injection, protocol seams). + +## The goal + +Every target should be able to be lifted out of the monorepo and built on its own against only its declared external dependencies. To make that true, each target has a hard contract about what it may `import`. Anything outside that contract is a violation and must be fixed in the same change. + +The shape is: single-responsibility targets with unidirectional dependencies. A target never reaches sideways into a peer's concrete implementation; it talks to a protocol seam, and the composition root supplies the concrete. + +## The SPM target name is load-bearing + +Before any restructure that touches a target (folder move, file rename, type rename, namespace change), treat the SPM target name as the highest-stability anchor. Preserve it unless the rename IS the goal of the change. + +The target name is what every consumer writes as `import `. Changing it cascades through every file that imports it, every doc that names it, and every test target's `dependencies:` list. Treat it like the public API of the package. + +Order of operations for a refactor: + +1. Anchor on the target name. The import line stays the same. +2. Move and rename files inside the target freely. The compiler does not care. +3. Rename folders, including SPM `path:` overrides, freely. Consumers do not see this. +4. Rename public types behind a `public typealias OldName = NewName` so direct references still compile; migrate them later and drop the alias when uses hit zero. +5. Rename the target itself only as a dedicated change with explicit acceptance criteria (every consumer updated, every test green, every doc swept). Never as a side effect of a folder restructure. + +Priority order: target name > public type > file name > folder layout. + +## Stop rule (before adding any `import`) + +Before adding an `import X` line to a producer target, ask: + +1. Is `X` external (Foundation, a system framework, ArgumentParser, Testing, a vetted third-party dependency)? Allowed. +2. Is the target an `executableTarget` / composition root (for example the `tile-down` CLI)? Allowed; the composition root wires the universe. +3. Is `X` a foundation-tier target that is foundation-only by construction (shared constants, value types, read-only diagnostics)? Allowed. +4. Is `X` a protocol-seam package (foundation-only by contract, carrying only protocols and value types)? Allowed. +5. Otherwise: **STOP.** Surface the situation; do not proceed. Importing another producer's concrete target is forbidden. Route the dependency through a protocol seam; the composition root supplies the concrete via injection. + +If you proceed past the stop rule, you have coupled two producers, which is exactly the coupling this rule exists to prevent. Every package should keep full autonomy, every dependency injectable, so any package can be pulled out of the monorepo at any time. + +## The rules + +1. **External primitives are always allowed.** `Foundation`, `os`, `OSLog`, `Combine`, system frameworks, `ArgumentParser`, `Testing`, `XCTest`, and vetted third-party SPM products are ambient and never count as a violation. +2. **A producer target may import its own protocol-seam companion.** That is the foundation-only protocol and value-type seam the producer publishes. +3. **A producer target may import other protocol-seam targets.** They are foundation-only by construction; importing one carries no behavioural coupling. +4. **A producer target may not import another producer (feature) target.** Cross-feature coupling goes through protocols defined in a seam package; the concrete is supplied at the composition root. +5. **A producer target may not depend on a concrete writer.** Consumers hold the protocol type from the seam package; the binary supplies the live implementation. +6. **Read-only foundation infrastructure can be imported widely** (embedded resources, read-only diagnostics). Treat it like Foundation. +7. **Composition roots can import anything.** The CLI and any other app/binary wire the live concretes and pass them down. +8. **No singletons reachable from producer code.** No `static let shared = X()`, no static config accessors. Pass the value as a parameter. The composition root constructs once and threads it down. +9. **A "this singleton is sanctioned" argument is not a get-out.** Even where a singleton would be technically acceptable, the project rule is strict dependency injection. + +## What this rule does not say + +- It does not ban a per-category static `os.Logger` constant; that is the Apple idiom (an `os.Logger` is a value-type wrapper meant to be held statically). +- It does not ban a `static let cache = Cache()` internal detail that is not reachable from consumers (for example a memoization cache inside a static loader). That is not a singleton in the service-locator sense. +- It does not ban a composition root constructing one instance and treating it as a local `let`. That is just a value, not a singleton. + +## Per-project checked-in contract + +Keep a `docs/package-import-contract-status.md` with one table row per target listing: + +- **Target name** +- **Allowed imports** (the explicit list; anything else is a violation) +- **Current state** (matches / excess imports listed) + +This file is the single source of truth a reviewer can grep against. + +## CI enforcement + +Two checks back the contract: + +- A source-import audit: grep `^import` lines per target and fail if a consumer imports a forbidden producer. +- A portability check: copy a target plus its declared transitive deps into a temporary directory, generate a minimal `Package.swift`, and run `swift build`. Green means the target genuinely lifts standalone with only its declared deps. Sweep across every producer target in CI. + +## When auditing the packages + +1. List every target. +2. For each, run `grep -h '^import ' Sources//**/*.swift | sort -u` to see actual imports. +3. Compare against the contract. Producer importing another producer is a violation. Producer importing a concrete writer is a violation. A singleton reachable inside a producer is a violation. +4. Open an issue or PR for each violation, smallest first. + +## No closure typealiases at cross-target seams + +Closure typealiases like: + +```swift +public typealias TileLookup = @Sendable (String) async throws -> Tile? +``` + +are forbidden for cross-target seams. Use a named protocol instead: + +```swift +public protocol TileLookupStrategy: Sendable { + func lookup(id: String) async throws -> Tile? +} +``` + +Reasons: + +1. **Named.** A protocol shows up by name in tooling, error messages, documentation, and test stubs. A closure typealias is just a tuple-to-tuple mapping. +2. **Discoverable.** "Find all conformers" works; "find all closure literals matching this signature" does not. +3. **Forces a real type at the conformer side.** A protocol conformer is a named struct or actor with explicit captured state, not an inline literal at the binding site. +4. **The Factory Method and Strategy patterns are defined in terms of types, not function values.** Keeping the named-type shape keeps the design legible. + +This does not ban closures as method parameters or property values; `onProgress: (@Sendable (Progress) -> Void)?` is fine. It bans closure-typed named cross-target contracts. + +## Reference + +- Gamma, Helm, Johnson, Vlissides, *Design Patterns* (1994). Factory Method p. 107; Strategy p. 315; Singleton p. 127 (we do not use the third one). +- Mark Seemann, *Dependency Injection in .NET* (2011). Service Locator anti-pattern ch. 5; Composition Root ch. 4. + +## Related rules + +- [dependency-injection.md](dependency-injection.md): underlying DI principles +- [shared-protocols.md](shared-protocols.md): where the protocol seams live +- [package-architecture.md](package-architecture.md): package layers and granularity +- [package-structure.md](package-structure.md): repository and manifest layout diff --git a/docs/rules-swift/package-structure.md b/docs/rules-swift/package-structure.md new file mode 100644 index 0000000..279b1d6 --- /dev/null +++ b/docs/rules-swift/package-structure.md @@ -0,0 +1,275 @@ +# Package and Repository Structure + +How the repository is laid out. Tiledown is a monorepo from day one: a workspace, a single `Package.swift` under `Packages/`, many single-responsibility targets in that one package, and `Apps/` for app targets. Sources live in `Packages/Sources/`, tests in `Packages/Tests/`. The `TileKit` library and the `tile-down` executable are already two targets in that package, so the layout rules below apply now. + +## What this covers + +Tiledown ships as one SPM package with many targets, not one package per library. There is a single `Package.swift` under `Packages/`; the `TileKit` library and the `tile-down` CLI are two targets in it today, and new targets join the same manifest as responsibilities separate out. The workspace and `Apps/` directory are part of the structure from the start; `Apps/` holds app targets (such as the planned native macOS/iOS editor) while the CLI executable target stays in `Package.swift`. + +Reach for additional targets in the single package when one of these is true: + +- A part of TileKit becomes a clearly separable responsibility (a parser, a transport, a renderer) used in more than one place. +- A second app target appears (for example the GUI editor alongside the CLI). +- You want isolated compilation and parallel builds across several focused targets. + +## Core rules + +### Rule 1: Root structure + +Organize the repository with these top-level directories: + +- `Main.xcworkspace` containing all projects +- `Packages/` for the single SPM package with all library and CLI targets +- `Apps/` for app targets that ship a UI (such as the planned native editor) +- `docs/` for documentation + +The workspace hosts the `Packages/` SPM package and any `Apps/*/*.xcodeproj`. The CLI executable target stays inside the single `Package.swift`; `Apps/` is for app targets that need Xcode project settings a plain SPM executable target cannot express. + +### Rule 2: Single Package.swift + +Use ONE `Package.swift` for all targets: + +- It contains ALL library targets and products. +- It contains the CLI executable target. +- It contains ALL test targets. +- It uses `#if os()` for platform-specific targets. +- App projects reference the package via a local path dependency. + +Do not split the targets into one `Package.swift` per library. A single manifest keeps the dependency graph in one readable place. + +### Rule 3: Apps as separate projects + +App targets (such as the planned native macOS/iOS editor) are separate Xcode projects in `Apps/`: + +- Each app has its own `.xcodeproj`. +- Apps import the package as a local SPM dependency. +- This enables different app configurations (Debug, Release, alternate backends). +- It supports multiple platforms per app. + +The CLI executable target stays inside the single `Package.swift`. Use `Apps/` for app targets that need Xcode project settings a plain SPM executable target cannot express. + +### Rule 4: Workspace references + +Add every project to the workspace: the `Packages/` SPM package, each `Apps/*/*.xcodeproj`, and the docs/README files. + +### Rule 5: No storyboards or XIBs + +For any UI you add, do not use Interface Builder artifacts: + +- NO `.storyboard` files. +- NO `.xib` files. +- ALL UI is created in code (SwiftUI or programmatic UIKit/AppKit). +- Delete any auto-generated storyboards from Xcode templates. + +### Rule 6: UI code lives in packages + +Keep views and view controllers in packages, not in app targets: + +- SwiftUI views in feature packages. +- UIKit/AppKit views in a dedicated UI package. +- App targets contain ONLY entry points (`AppDelegate`, `SceneDelegate`, `@main`). + +## Directory structure + +``` +TileDownRoot/ +├── Main.xcworkspace/ # Hosts the package and any Apps/ projects +│ └── contents.xcworkspacedata +├── Packages/ # Single SPM package +│ ├── Package.swift # ALL targets defined here +│ ├── Package.resolved +│ ├── Sources/ +│ │ ├── TileKit/ # the engine library +│ │ ├── TileDownCLI/ # the tile-down CLI target +│ │ └── ... # additional focused libraries +│ └── Tests/ +│ ├── TileKitTests/ +│ └── ... +├── Apps/ # App targets (planned native editor) +│ └── TileDownApp/ +│ ├── TileDownApp.xcodeproj/ +│ └── TileDownApp/ +├── docs/ +└── README.md +``` + +## Package.swift structure (many targets in one package) + +Use helper-driven, grouped target declarations rather than one giant inline array. + +### Platform-specific products + +```swift +// swift-tools-version: 6.0 +import PackageDescription + +// ---------- Base Products (All Platforms) ---------- +let baseProducts: [Product] = [ + .singleTargetLibrary("TileKit"), + .singleTargetLibrary("TileCore"), + .executable(name: "tile-down", targets: ["TileDownCLI"]), +] + +// ---------- Apple-Only Products ---------- +#if os(iOS) || os(macOS) +let appleOnlyProducts: [Product] = [ + .singleTargetLibrary("TileUI"), +] +#else +let appleOnlyProducts: [Product] = [] +#endif + +let allProducts = baseProducts + appleOnlyProducts + +extension Product { + static func singleTargetLibrary(_ name: String) -> Product { + .library(name: name, targets: [name]) + } +} +``` + +### Target organization pattern + +```swift +let targets: [Target] = { + // ---------- Foundation Layer ---------- + let tileCoreTarget = Target.target( + name: "TileCore", + dependencies: [] + ) + let tileCoreTestsTarget = Target.testTarget( + name: "TileCoreTests", + dependencies: ["TileCore"] + ) + let foundationTargets = [tileCoreTarget, tileCoreTestsTarget] + + // ---------- Library Layer ---------- + let tileKitTarget = Target.target( + name: "TileKit", + dependencies: ["TileCore"] + ) + let libraryTargets = [tileKitTarget] + + return foundationTargets + libraryTargets +}() +``` + +## App configuration (only if a GUI app is added) + +### Removing storyboard references + +When you create a new Xcode app project, remove all storyboard configuration. + +iOS (UIKit): + +1. Delete `Main.storyboard` and `LaunchScreen.storyboard`. +2. Remove the `UIMainStoryboardFile` and `UILaunchStoryboardName` keys from `Info.plist`. +3. Configure the scene manifest with a `UISceneDelegateClassName` and no `UISceneStoryboardFile` key. +4. Provide a launch screen via the `UILaunchScreen` plist dictionary instead of a storyboard. +5. Clear the storyboard build-setting fields. + +macOS (AppKit): + +1. Delete `Main.storyboard` / `MainMenu.xib`. +2. Remove `NSMainStoryboardFile` and `NSMainNibFile` from `Info.plist`. +3. Set `NSPrincipalClass` to `NSApplication`. +4. Clear the Main Interface build setting. +5. Provide a `main.swift` (AppKit without a storyboard needs an explicit entry point, no `@main`): + +```swift +import Cocoa + +let delegate = AppDelegate() +NSApplication.shared.delegate = delegate +_ = NSApplicationMain(CommandLine.argc, CommandLine.unsafeArgv) +``` + +macOS (SwiftUI): + +1. Delete any `.storyboard` / `.xib` files. +2. Use a `@main` `App` struct. No storyboard keys are needed. + +### Build menus in code + +For AppKit apps, build the `NSMenu` programmatically in the app delegate rather than using `MainMenu.xib`. + +## Native UI patterns (AppKit / UIKit) + +If you add a UIKit/AppKit UI package, use platform typealiases and factory entry points so the app target stays minimal: + +```swift +#if os(macOS) +import AppKit +public typealias PlatformViewController = NSViewController +public typealias PlatformView = NSView +public typealias PlatformColor = NSColor +#elseif os(iOS) +import UIKit +public typealias PlatformViewController = UIViewController +public typealias PlatformView = UIView +public typealias PlatformColor = UIColor +#endif +``` + +```swift +public enum NativeUI { + #if os(macOS) + @MainActor + public static func createWindowController() -> NSWindowController { + NativeWindowController() + } + #endif +} +``` + +The app target then just wires the factory: + +```swift +// SceneDelegate (iOS) +window = NativeUI.createMainWindow(for: windowScene) // all UI from the package +``` + +## When to create a new app target + +Create a new app target when: + +- A different backend endpoint is needed (local, staging, production). +- A different platform is targeted (iOS, macOS). +- A different app variant ships (lite, pro). +- A different testing mode is needed (offline, mock data). + +Do NOT create a new app target when: + +- You only need Debug vs Release (use build configurations). +- You only need a different bundle ID (use build settings). +- You only have a feature-flag difference (use runtime flags). + +## Common mistakes + +- Do NOT put app executable targets for shipping GUI apps in `Package.swift` when they need real Xcode project settings. (A plain CLI executable target is fine in `Package.swift`.) +- Do NOT create multiple `Package.swift` files, one per library. Use a single manifest. +- Do NOT put business logic, view models, services, or models in an app target. They belong in targets under `Packages/Sources/`. +- Do NOT keep `.storyboard` or `.xib` files anywhere. +- Do NOT keep view controllers in the app target. Move them into a package. + +## Checklist + +Before changing repo structure: + +- [ ] Single SPM package manifest for all targets +- [ ] All library and CLI code under `Packages/Sources/` +- [ ] All test code under `Packages/Tests/` +- [ ] Each GUI app, if any, is a separate `.xcodeproj` in `Apps/` +- [ ] Apps import the package via a local dependency +- [ ] Platform-specific targets use `#if os()` +- [ ] App targets contain entry points only +- [ ] Business logic stays in packages, not apps +- [ ] No `.storyboard` or `.xib` files anywhere +- [ ] macOS AppKit apps have `main.swift` (not `@main`) +- [ ] SwiftUI apps use a `@main` `App` struct + +## Related rules + +- [package-architecture.md](package-architecture.md): single-responsibility packages, layers, and the when-to-create decision tree +- [package-import-contract.md](package-import-contract.md): what each target may import +- [shared-protocols.md](shared-protocols.md): the cross-target protocol-seam package diff --git a/docs/rules-swift/parsing-rules.md b/docs/rules-swift/parsing-rules.md new file mode 100644 index 0000000..6dff6f2 --- /dev/null +++ b/docs/rules-swift/parsing-rules.md @@ -0,0 +1,255 @@ +# Parsing Rules + +When parsing any strings, data files (CSV, XML, JSON, Excel), or structured text, follow the **OpenAPIKit idiom** by [Matt Polzin](https://github.com/mattpolzin/OpenAPIKit). + +## Reference Repository + +**https://github.com/mattpolzin/OpenAPIKit** + +Key files to study: +- `Document.swift` - Root document structure, Codable implementation +- `JSONSchema.swift` - Enum-based type representation +- `JSONSchemaContext.swift` - Protocol-driven context pattern + +## Core Principles + +### 1. Protocol-First Design + +Define shared behavior in protocols before concrete types: + +```swift +/// All parsed items share these properties +protocol ParsedItemContext { + var id: String { get } + var rawValue: String { get } +} + +/// Type-specific contexts extend the base +protocol ProductContext: ParsedItemContext { + var name: String { get } + var price: Decimal { get } +} +``` + +### 2. Codable-Based Parsing + +Use Swift's built-in `Codable` with custom `init(from decoder:)`: + +```swift +struct Product: Decodable { + let name: String + let price: Decimal + + enum CodingKeys: String, CodingKey { + case name = "NAZIV PROIZVODA" + case price = "MALOPRODAJNA CIJENA" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + name = try container.decode(String.self, forKey: .name) + + // Custom parsing for complex types + let priceString = try container.decode(String.self, forKey: .price) + price = try Self.parsePrice(priceString) + } +} +``` + +### 3. Enum with Associated Values + +Represent variants using enums with associated values: + +```swift +enum ParsedDocument: Sendable { + case csv(CSVDocument) + case xml(XMLDocument) + case json(JSONDocument) + case excel(ExcelDocument) + + var unified: UnifiedDocument { + switch self { + case .csv(let doc): return doc.toUnified() + case .xml(let doc): return doc.toUnified() + case .json(let doc): return doc.toUnified() + case .excel(let doc): return doc.toUnified() + } + } +} +``` + +### 4. Context Objects with CoreContext Pattern + +Separate core context from type-specific extensions: + +```swift +/// Core context all items have +struct CoreContext: ParsedItemContext { + let id: String + let rawValue: String + let format: Format + let warnings: [ParseWarning] +} + +/// Type-specific context +struct PriceContext { + let value: Decimal + let currency: Currency + let isSpecialOffer: Bool +} +``` + +### 5. Module Isolation per Format/Provider + +Separate modules for different data sources: + +``` +MyParser/ +├── Sources/ +│ ├── ParserCore/ # Unified types, protocols +│ ├── ParserCSV/ # CSV-specific parsing +│ ├── ParserXML/ # XML-specific parsing +│ ├── ParserJSON/ # JSON-specific parsing +│ └── ParserCompat/ # Convert between formats +``` + +### 6. Validation Separate from Parsing + +Parse first, validate second: + +```swift +struct ParsedItem { + // ... properties + + func validate() throws -> [ValidationWarning] { + var warnings: [ValidationWarning] = [] + + if id.isEmpty { + warnings.append(.missingId) + } + if !isValidFormat { + warnings.append(.invalidFormat(raw: rawValue)) + } + + return warnings + } +} +``` + +The snippet above is the minimal shape. The **full, MANDATORY validation idiom** +(composable `Validation` values, the combinator algebra, errors with a +coding-path context, the default-plus-blank `Validator`, and the test style) lives +in its own rule: see `universal/validation-rules.md`. A monolithic +`func validate()` that appends to a `var warnings` through a tree of `if` +statements is the anti-pattern that rule replaces; use it only as throwaway +pseudocode, never as the shipped validator. + +### 7. Builder Pattern for Transformations + +Immutable transformations with fluent API: + +```swift +extension CoreContext { + func with(id newId: String) -> Self { + var copy = self + copy.id = newId + return copy + } + + func addingWarning(_ warning: ParseWarning) -> Self { + var copy = self + copy.warnings.append(warning) + return copy + } +} +``` + +### 8. Type-Safe References + +Use typed references for lookups: + +```swift +struct ItemReference: Hashable, Codable { + let source: DataSource + let id: String +} + +extension Document { + subscript(item ref: ItemReference) -> ParsedItem? { + items.first { $0.source == ref.source && $0.id == ref.id } + } +} +``` + +## Price/Number Parsing Helper + +Common price parsing that handles European formats: + +```swift +extension Decimal { + /// Parse price string: "7,99", "7.99", "7,99€", "7.99 EUR" + static func parsePrice(_ string: String) throws -> Decimal { + var cleaned = string + .replacingOccurrences(of: "€", with: "") + .replacingOccurrences(of: "EUR", with: "") + .trimmingCharacters(in: .whitespaces) + + // Handle European format (comma as decimal) + if cleaned.contains(",") && cleaned.contains(".") { + // "1.234,56" -> "1234.56" + if cleaned.firstIndex(of: ",")! > cleaned.firstIndex(of: ".")! { + cleaned = cleaned.replacingOccurrences(of: ".", with: "") + } else { + cleaned = cleaned.replacingOccurrences(of: ",", with: "") + } + } + cleaned = cleaned.replacingOccurrences(of: ",", with: ".") + + guard let value = Decimal(string: cleaned) else { + throw ParseError.invalidPrice(string) + } + return value + } +} +``` + +## Text Encoding Handling + +Always handle multiple encodings: + +```swift +func decodeText(from data: Data) -> String? { + let encodings: [String.Encoding] = [ + .utf8, + .windowsCP1250, // Croatian/Central European + .isoLatin2, // ISO-8859-2 + .isoLatin1 // Fallback + ] + + for encoding in encodings { + if let text = String(data: data, encoding: encoding) { + return text + } + } + return nil +} +``` + +## DO + +- Define protocols for shared parsing behavior +- Use Codable with custom `init(from decoder:)` +- Separate parsing from validation +- Use enums with associated values for variants +- Create type-safe references +- Handle multiple text encodings +- Use builder pattern for transformations + +## DO NOT + +- Parse and validate in the same step +- Use raw string manipulation without protocols +- Ignore text encoding issues +- Create tightly-coupled parsers +- Skip the validation layer +- Use force unwrapping in parsers diff --git a/docs/rules-swift/scripts/leak-gate.sh b/docs/rules-swift/scripts/leak-gate.sh new file mode 100755 index 0000000..2119582 --- /dev/null +++ b/docs/rules-swift/scripts/leak-gate.sh @@ -0,0 +1,28 @@ +#!/bin/sh +# Public-safe leak gate for this repository. +# Runs on every commit (.githooks/pre-commit) and in CI (.github/workflows/leak-gate.yml). +# It enforces only what is safe in a PUBLIC repo: no AI/tool vendor names (outside the +# two attribution rules that discuss them), no machine-absolute paths, no em dashes. +# The private project-name scrub lives in a separate private repository, listing those +# names here would itself be the leak. +set -eu + +ROOT=$(cd "$(dirname "$0")/.." && pwd) +fail=0 + +# AI/tool vendor names are allowed ONLY in the attribution rules that discuss them. +VEND='\bclaude\b|anthropic|\bopenai\b|chatgpt|\bcopilot\b|\bgemini\b|antigravity' +v=$(grep -rniE "$VEND" "$ROOT" --include='*.md' 2>/dev/null | grep -vE '/\.git/' \ + | grep -vE '(^|[./])(commits|git-discipline)\.md:' || true) +[ -n "$v" ] && { echo "LEAK GATE FAIL (vendor name outside attribution rules):"; echo "$v" | head; fail=1; } + +# Machine-absolute paths leak a local filesystem layout. +p=$(grep -rnE '/Users/|/Volumes/' "$ROOT" --include='*.md' 2>/dev/null | grep -vE '/\.git/' || true) +[ -n "$p" ] && { echo "LEAK GATE FAIL (machine-absolute path):"; echo "$p" | head; fail=1; } + +# No em dashes in any tracked markdown. +e=$(grep -rlF "$(printf '\xe2\x80\x94')" "$ROOT" --include='*.md' 2>/dev/null | grep -vE '/\.git/' || true) +[ -n "$e" ] && { echo "LEAK GATE FAIL (em dash):"; echo "$e"; fail=1; } + +[ "$fail" = 0 ] || { echo "LEAK GATE FAILED for $ROOT" >&2; exit 1; } +echo "leak gate clean ($(find "$ROOT" -name '*.md' -not -path '*/.git/*' | wc -l | tr -d ' ') md files)" diff --git a/docs/rules-swift/shared-protocols.md b/docs/rules-swift/shared-protocols.md new file mode 100644 index 0000000..5d82597 --- /dev/null +++ b/docs/rules-swift/shared-protocols.md @@ -0,0 +1,178 @@ +# Shared Protocols Target + +The pattern for a single foundation-only target that holds every cross-target protocol seam. Tiledown is a monorepo from day one with many targets in one package, so the seam target applies as soon as one producer target needs to talk to another without importing its concretes. Add it when the first cross-target seam appears. + +Companion to [dependency-injection.md](dependency-injection.md) and [package-import-contract.md](package-import-contract.md). The target described here is conventionally named `SharedProtocols`. + +## Target regime + +Across the package's targets, every internal target falls into one of two categories: + +1. **Producer / feature targets** import only `SharedProtocols` plus external dependencies (Foundation, SwiftUI, Combine, third-party SPM). Their SPM `dependencies:` list is `["SharedProtocols", ...external products]` and nothing else internal. +2. **Composition root and live-writer targets** (the CLI, networking, storage, environment, and similar glue) import `SharedProtocols` plus the external framework they bridge. Their `dependencies:` list is `["SharedProtocols", ...the framework being wrapped]`. These are the only places where protocol-to-concrete glue exists. + +There is no third category. + +## Hard rules + +1. **`SharedProtocols` has zero internal dependencies.** Its SPM `dependencies:` list is `[]`. +2. **Allowed imports inside `SharedProtocols`:** any external framework (`Foundation`, `SwiftUI`, `Combine`, a logging library, etc.). **Forbidden:** any internal package. +3. **Declarations are top-level.** `public protocol Recording`, not nested under a namespace anchor like `extension SharedProtocols { protocol Recording }`. +4. **Naming follows the domain noun form.** `Recording`, `FileSystem`, `HTTPRequester`. The protocol is the noun; the conformer is `LiveRecording`. You do not have to suffix the protocol with `-able` or `-ing`. +5. **One concept per file**, filename equal to the type name. Group related files in subfolders (`Coordinators/`, `Providers/`) when there are three or more related declarations. +6. **DEBUG-only preview implementations live here too**, as a `Preview_*_Impl` no-op shape wrapped in `#if DEBUG`. + +## What goes in `SharedProtocols` + +- **Protocol seams:** every protocol that exists to decouple a producer from a concrete (`Recording`, `HTTPRequester`, `FileSystem`, `UUIDProvider`, `DateProvider`, and so on). +- **Coordinator protocols** for navigation/flow when the codebase uses them. +- **Value types** that travel across targets: enums (`HTTPMethod`, `LogLevel`), small structs (`HTTPResponse`, a domain model). +- **Null implementations** (`NullRecording`, `NullHTTPRequester`) so producers can default to a no-op without importing a live writer. +- **DEBUG-only `Preview_*_Impl`** no-op conformances for SwiftUI previews and tests. + +## What does NOT go in `SharedProtocols` + +- **Live writers and concrete services** (`LiveRecording`, `LiveHTTPClient`, `LiveFileSystem`). These live in their own glue targets. +- **Per-capability locator protocols invented just to satisfy a DI checklist.** If the composition root can probe the environment in three lines and decide whether to register a service, do not manufacture a one-method protocol for it. + +## Why + +Cross-package coupling through protocols (rather than concrete imports) only works if the protocol is reachable without dragging in the rest of the producer's universe. If a protocol lives in producer package `Logging`, then any consumer must `import Logging` to see it. Multiply that across every seam type and a producer ends up importing many internal packages. `SharedProtocols` collapses that to one import. + +Live writers import `SharedProtocols` for the protocol and the external framework for the concrete, never an intermediate package. + +## Example + +```swift +// Sources/SharedProtocols/Coordinators/AppCoordinator.swift +import SwiftUI +import Combine + +@MainActor +public protocol AppCoordinator: AnyObject { + func showWelcome() + func showMainApp() + func start() +} +``` + +```swift +// Sources/SharedProtocols/Coordinators/PreviewHelpers.swift +import SwiftUI + +#if DEBUG +public final class Preview_AppCoordinator_Impl: AppCoordinator { + public init() {} + public func showWelcome() {} + public func showMainApp() {} + public func start() {} +} +#endif +``` + +```swift +// Sources/SharedProtocols/TileSummary.swift +import Foundation + +public struct TileSummary: Identifiable { + public var id: UUID + public var title: String + public init(id: UUID = .init(), title: String) { + self.id = id + self.title = title + } +} +``` + +`Package.swift`: + +```swift +let sharedProtocolsTarget = Target.target( + name: "SharedProtocols", + dependencies: [] // zero internal deps +) +``` + +Every feature target depends on `SharedProtocols` and nothing else internal: + +```swift +let tileFeatureTarget = Target.target( + name: "TileFeature", + dependencies: ["SharedProtocols"] +) +``` + +## Anti-patterns + +### Per-capability locator protocols + +```swift +// Avoid: a protocol invented to satisfy a DI checklist +public protocol BinaryLocating: Sendable { + func locateBinary() -> String? +} +``` + +The composition root can probe the filesystem itself in three lines. The producer does not need a locator abstraction. + +### Protocol nested under a namespace anchor + +```swift +// Avoid in SharedProtocols +extension SharedProtocols { + public protocol Recording { /* ... */ } +} +``` + +Top-level (`public protocol Recording`) reduces noise at call sites and matches `import SharedProtocols` ergonomics. + +### Inline protocol declared in a producer file + +```swift +// Avoid: a cross-target seam declared in the producer that uses it +public protocol TileProviding: Sendable { + func validate() async throws -> Bool +} +``` + +A composition root would have to import the producer to see the protocol. The dependency arrow is reversed. + +## Stop rule + +Before adding a new `import` to a producer Swift file: + +1. Is it an external framework or SPM product? Fine. +2. Is it `SharedProtocols`? Fine. +3. Anything else internal? **STOP.** Either move the seam into `SharedProtocols`, or push the orchestration up to the composition root. + +## CI guard + +```bash +# SharedProtocols must have no internal-package imports. +# External imports (Foundation, SwiftUI, Combine, ...) are fine. +INTERNAL_TARGETS=$(swift package describe --type json | jq -r '.targets[].name') +for ln in $(grep -rn '^import ' Packages/Sources/SharedProtocols/); do + mod=$(echo "$ln" | awk '{print $2}') + if echo "$INTERNAL_TARGETS" | grep -qx "$mod"; then + echo "FAIL: SharedProtocols imports internal target $mod ($ln)" + exit 1 + fi +done +``` + +## Sequencing + +If you migrate existing scattered protocols into `SharedProtocols`, do it as one coordinated arc, not a per-package vote: + +1. Move every cross-target protocol and value type into `SharedProtocols`. +2. Update every internal target's deps: producers gain `["SharedProtocols"]` and drop intermediate packages; live writers gain `["SharedProtocols", ]`. +3. Update every old `import` to `import SharedProtocols`. +4. Delete the now-empty intermediate source directories and target declarations. +5. Build and test green; commit. + +## Related rules + +- [dependency-injection.md](dependency-injection.md): DI principles the seams serve +- [package-import-contract.md](package-import-contract.md): what each target may import +- [package-architecture.md](package-architecture.md): package layers and granularity +- [package-structure.md](package-structure.md): repository and manifest layout diff --git a/docs/rules-swift/testing.md b/docs/rules-swift/testing.md new file mode 100644 index 0000000..ff6f07e --- /dev/null +++ b/docs/rules-swift/testing.md @@ -0,0 +1,660 @@ +# Swift Testing Framework Rules + +How to write tests for Tiledown: focused, isolated, deterministic suites built on the Swift Testing framework. + +Write comprehensive tests using the Swift Testing framework (`@Test`). Tests must be focused, isolated, deterministic, and leverage modern Swift Testing features for maximum reliability and maintainability. The core engine (`TileKit`, the `Tile` / `TileType` primitives, the `TileDown` namespace) is non-UI and is exercised with plain unit and integration tests. The SwiftUI/ViewInspector/snapshot patterns below apply to the planned native macOS/iOS app, not to the engine. + +The `withDependencies` overrides shown in the patterns below assume the Point-Free Dependencies library. Where a project does not use that library, control collaborators through plain constructor injection instead; the test-isolation and determinism rules apply either way. + +## Core rules + +### Rule 1: Swift Testing framework usage + +Use modern Swift Testing: +- MUST use `@Test` attribute for test methods +- MUST use `#expect` macro for assertions +- MUST use parameterized tests for multiple scenarios +- MUST NOT use XCTest unless absolutely required + +### Rule 2: Dependency isolation + +Control dependencies in tests: +- MUST control collaborators in tests (via `withDependencies` if the Dependencies library is adopted, otherwise via constructor injection) +- MUST provide deterministic test values +- MUST isolate tests from external systems +- MUST NOT use live dependencies in tests + +### Rule 3: Test organization + +Structure tests clearly: +- MUST use `@Suite` for logical grouping +- MUST name tests descriptively +- MUST test one behavior per test +- MUST NOT create kitchen sink tests + +### Rule 4: Async testing + +Handle concurrency properly: +- MUST use async/await for async tests +- MUST avoid race conditions +- MUST use structured concurrency +- MUST NOT use arbitrary delays + +### Rule 5: Test pyramid + +Follow testing hierarchy: +- MUST have ~70% unit tests (fast, isolated) +- MUST have ~20% integration tests (component interaction) +- MUST have ~10% UI/E2E tests (critical paths) +- MUST NOT invert the pyramid + +## TEST TYPE DECISION TREE + +``` +What are you testing? +├─ Pure logic/calculations? +│ └─ Unit Test → Direct function tests +├─ State management? +│ └─ Unit Test → ViewModel/Model tests +├─ Component interaction? +│ └─ Integration Test → Multi-component tests +├─ User interface (planned native app)? +│ ├─ Visual correctness? → Snapshot Test +│ └─ User interaction? → ViewInspector Test +└─ Complete user flow? + └─ E2E Test → Full flow test +``` + +## TESTING PATTERNS + +### Pattern 1: Basic Test Structure + +```swift +// RULE: Each test file follows this structure +import Testing +import Dependencies +@testable import TileKit + +@Suite("Tile Rendering Tests") +struct TileRenderingTests { + // RULE: Group related tests in nested suites + @Suite("Markdown Tile") + struct MarkdownTileTests { + // RULE: Shared test data at suite level + let fixture = TileFixture() + + @Test("Clear description of what should happen") + func renderScenario() async throws { + // RULE: Arrange dependencies + let sut = withDependencies { + $0.fileClient.read = { _ in self.fixture.markdown } + } operation: { + TileRenderer() + } + + // RULE: Act on the system + let result = try await sut.render(.markdown) + + // RULE: Assert the outcome + #expect(result == expectedHTML) + } + } +} +``` + +### Pattern 2: Parameterized Testing + +```swift +// RULE: Use parameterized tests for similar scenarios +@Test("Validates different slug formats", arguments: [ + ("hello-world", true), + ("Hello World", false), + ("", false), + ("trailing-", false), + ("-leading", false) +]) +func slugValidation(slug: String, isValid: Bool) { + let validator = SlugValidator() + #expect(validator.isValid(slug) == isValid) +} + +// RULE: Use table-driven tests for complex scenarios +struct TestCase { + let input: String + let expectedOutput: String + let shouldThrow: Bool +} + +@Test("Processes various inputs correctly", arguments: [ + TestCase(input: "hello", expectedOutput: "HELLO", shouldThrow: false), + TestCase(input: "", expectedOutput: "", shouldThrow: false), + TestCase(input: "123", expectedOutput: "", shouldThrow: true) +]) +func processingScenarios(testCase: TestCase) async throws { + let processor = TextProcessor() + + if testCase.shouldThrow { + await #expect(throws: ProcessingError.self) { + try await processor.process(testCase.input) + } + } else { + let result = try await processor.process(testCase.input) + #expect(result == testCase.expectedOutput) + } +} +``` + +### Pattern 3: ViewModel Testing (planned native app) + +```swift +// RULE: Test ViewModels with controlled dependencies +@Suite("TilePreviewViewModel Tests") +struct TilePreviewViewModelTests { + @Test("Initial state is correct") + func initialState() { + let viewModel = TilePreviewViewModel() + + #expect(viewModel.tile == nil) + #expect(viewModel.isLoading == false) + #expect(viewModel.error == nil) + } + + @Test("Loading a tile shows loading states") + func loadingStateTransitions() async { + // RULE: Track state changes + var states: [LoadingState] = [] + + let viewModel = withDependencies { + $0.tileClient.fetch = { _ in + try await Task.sleep(for: .milliseconds(50)) + return .mock + } + } operation: { + TilePreviewViewModel() + } + + // RULE: Observe state changes + let observation = viewModel.observe(\.isLoading) { loading in + states.append(loading ? .loading : .idle) + } + + await viewModel.loadTile(id: UUID()) + + #expect(states == [.idle, .loading, .idle]) + #expect(viewModel.tile == .mock) + } + + @Test("Handles errors gracefully") + func errorHandling() async { + struct TestError: Error, Equatable {} + + let viewModel = withDependencies { + $0.tileClient.fetch = { _ in throw TestError() } + $0.logger.log = { _, _ in } // Silence logs in tests + } operation: { + TilePreviewViewModel() + } + + await viewModel.loadTile(id: UUID()) + + #expect(viewModel.error as? TestError == TestError()) + #expect(viewModel.tile == nil) + #expect(!viewModel.isLoading) + } +} +``` + +### Pattern 4: Async Stream Testing + +```swift +// RULE: Test async streams properly +@Suite("File Watcher Tests") +struct FileWatcherTests { + @Test("Processes a change stream") + func changeStream() async throws { + let changes = ["post-a.md", "post-b.md", "index.md"] + var received: [String] = [] + + let viewModel = withDependencies { + $0.watcherClient.changes = { + AsyncStream { continuation in + for change in changes { + continuation.yield(change) + } + continuation.finish() + } + } + } operation: { + RebuildViewModel() + } + + // RULE: Collect all changes + for await change in viewModel.changeStream { + received.append(change) + } + + #expect(received == changes) + } + + @Test("Handles stream errors") + func streamErrorHandling() async throws { + struct StreamError: Error {} + + let viewModel = withDependencies { + $0.watcherClient.changes = { + AsyncThrowingStream { continuation in + continuation.yield("first.md") + continuation.finish(throwing: StreamError()) + } + } + } operation: { + RebuildViewModel() + } + + var changes: [String] = [] + var errorCaught = false + + do { + for try await change in viewModel.changeStream { + changes.append(change) + } + } catch is StreamError { + errorCaught = true + } + + #expect(changes == ["first.md"]) + #expect(errorCaught) + } +} +``` + +### Pattern 5: View Testing with ViewInspector (planned native app) + +```swift +// RULE: Test SwiftUI views in isolation +import ViewInspector + +@Suite("TileCardView Tests") +struct TileCardViewTests { + @Test("Displays tile information") + func tileDisplay() throws { + let tile = Tile(title: "Hello World", type: .markdown) + let view = TileCardView(tile: tile) + + let sut = try view.inspect() + + // RULE: Find and verify UI elements + let titleText = try sut.find(text: "Hello World") + #expect(try titleText.string() == "Hello World") + + let typeText = try sut.find(text: "Markdown") + #expect(try typeText.string() == "Markdown") + } + + @Test("Interaction triggers callbacks") + func buttonInteraction() throws { + var tapped = false + let view = TileCardView( + tile: .mock, + onTap: { tapped = true } + ) + + let sut = try view.inspect() + let button = try sut.find(button: "Open") + try button.tap() + + #expect(tapped) + } +} +``` + +### Pattern 6: Snapshot Testing (planned native app) + +```swift +// RULE: Use snapshot tests for visual regression +import SnapshotTesting + +@Suite("Visual Regression Tests") +struct VisualRegressionTests { + @Test("Tile list appearance", arguments: [ + ("iPhone", ViewImageConfig.iPhone13Pro), + ("iPad", ViewImageConfig.iPadPro11), + ("SE", ViewImageConfig.iPhoneSe) + ]) + func deviceSnapshots(device: String, config: ViewImageConfig) { + let view = TileListView(tiles: .mockArray) + + assertSnapshot( + matching: view, + as: .image(layout: .device(config: config)), + named: device + ) + } + + @Test("Dark mode support") + func darkMode() { + let view = ContentView() + .preferredColorScheme(.dark) + + assertSnapshot( + matching: view, + as: .image(traits: .init(userInterfaceStyle: .dark)) + ) + } +} +``` + +## DEPENDENCY MOCKING PATTERNS + +### Pattern 1: Test Dependency Configuration + +```swift +// RULE: Create reusable test configurations +extension DependencyValues { + static func testValue( + configuring: (inout DependencyValues) -> Void = { _ in } + ) -> DependencyValues { + var dependencies = DependencyValues() + + // RULE: Set safe defaults for all dependencies + dependencies.tileClient = .noop + dependencies.date.now = Date(timeIntervalSince1970: 0) + dependencies.uuid = .incrementing + dependencies.mainQueue = .immediate + + // Apply custom configuration + configuring(&dependencies) + + return dependencies + } +} + +// RULE: Use in tests for consistency +@Test("Example usage") +func testWithConfiguration() async { + let viewModel = withDependencies { + $0 = .testValue { deps in + deps.tileClient.fetch = { _ in .mock } + } + } operation: { + TileViewModel() + } + + await viewModel.loadTile() + #expect(viewModel.tile == .mock) +} +``` + +### Pattern 2: Mock Implementations + +```swift +// RULE: Create predictable mock implementations +extension TileClient { + static let noop = TileClient( + fetch: { _ in throw CancellationError() }, + write: { _ in throw CancellationError() }, + delete: { _ in throw CancellationError() } + ) + + static func succeeding( + tile: Tile = .mock, + delay: Duration = .zero + ) -> TileClient { + TileClient( + fetch: { _ in + if delay > .zero { + try await Task.sleep(for: delay) + } + return tile + }, + write: { _ in + if delay > .zero { + try await Task.sleep(for: delay) + } + }, + delete: { _ in + if delay > .zero { + try await Task.sleep(for: delay) + } + } + ) + } + + static func failing( + error: Error = TestError() + ) -> TileClient { + TileClient( + fetch: { _ in throw error }, + write: { _ in throw error }, + delete: { _ in throw error } + ) + } +} +``` + +## TESTING ANTI-PATTERNS + +### DON'T: Test implementation details +```swift +// WRONG: Testing private state +@Test +func badTest() { + let viewModel = TileViewModel() + + // Don't access private properties + let mirror = Mirror(reflecting: viewModel) + // This is brittle and breaks encapsulation +} + +// RIGHT: Test behavior through public API +@Test +func goodTest() { + let viewModel = TileViewModel() + + viewModel.updateTitle("New Title") + #expect(viewModel.displayTitle == "New Title") +} +``` + +### DON'T: Use arbitrary delays +```swift +// WRONG: Race condition waiting +@Test +func badAsyncTest() async { + let viewModel = SearchViewModel() + viewModel.search("query") + + // Arbitrary delay - flaky! + try? await Task.sleep(for: .seconds(1)) + + #expect(!viewModel.results.isEmpty) +} + +// RIGHT: Proper synchronization +@Test +func goodAsyncTest() async { + let viewModel = withDependencies { + $0.tileClient.search = { _ in [.mock] } + } operation: { + SearchViewModel() + } + + await viewModel.search("query") + #expect(viewModel.results == [.mock]) +} +``` + +### DON'T: Share state between tests +```swift +// WRONG: Shared mutable state +class SharedCache { + static let shared = SharedCache() + var data: [String: Any] = [:] +} + +@Test +func badTest1() { + SharedCache.shared.data["key"] = "value" +} + +@Test +func badTest2() { + // This test depends on badTest1! + let value = SharedCache.shared.data["key"] +} + +// RIGHT: Isolated dependencies +@Test +func goodTest() { + let cache = withDependencies { + $0.cacheClient = .previewValue + } operation: { + CacheClient() + } + + // Test is completely isolated +} +``` + +## Test target placement + +### Rule: One test target per source target + +Declare a matching `Target.testTarget` for every `Target.target` in `Package.swift`: + +```swift +let tileKitTarget = Target.target( + name: "TileKit", + dependencies: ["TileDownModels", "FileClient"] +) +let tileKitTestsTarget = Target.testTarget( + name: "TileKitTests", + dependencies: ["TileKit"] +) +let tileKitTargets = [tileKitTarget, tileKitTestsTarget] +``` + +- MUST name the test target `Tests` (no other suffix) +- MUST list the source target as a dependency of the test target +- MUST group the source + test target as a pair in a named local sub-array (`let tileKitTargets = [...]`) so platform conditionals and grouping comments stay clean +- MUST NOT skip the test target, even for trivial packages +- MUST NOT define the test target inline in the top-level `targets:` array + +### Folder layout + +Tiledown is a monorepo from day one: sources live under `Packages/Sources//` and tests under `Packages/Tests/Tests/`, mirroring each other: + +``` +. +├── Packages/ +│ ├── Sources/ +│ │ └── TileKit/ +│ │ └── ...source files... +│ └── Tests/ +│ └── TileKitTests/ +│ ├── ...test files... +│ └── Mocks/ +│ └── ...mock implementations... +``` + +### Mocks live in the test target, never in the source package + +Public test doubles (mocks, fakes, stubs) are placed in `Packages/Tests/Tests/Mocks/`, NOT in `Packages/Sources//`. Mocks shipped from `Packages/Sources/` leak into production binaries. + +```swift +// Packages/Sources/FileClient/FileClientProtocol.swift +public protocol FileClientProtocol { + func read(path: String) async throws -> String +} + +// Packages/Tests/FileClientTests/Mocks/MockFileClient.swift +public struct MockFileClient: FileClientProtocol { + public var readResult: Result + public func read(path: String) async throws -> String { + try readResult.get() + } +} +``` + +The protocol MAY be public (so the mock can implement it). The mock MUST live in the test target. + +### Running a single target's tests + +```bash +swift test --filter Tests +``` + +## TEST ORGANIZATION CHECKLIST + +Before submitting tests, verify: + +- [ ] Tests use `@Test` attribute, not XCTest +- [ ] Each test has single, clear purpose +- [ ] Test names describe scenario and outcome +- [ ] Dependencies controlled with `withDependencies` +- [ ] No live network/database calls +- [ ] Async tests use proper concurrency +- [ ] No arbitrary delays or race conditions +- [ ] Test data is deterministic +- [ ] Error cases thoroughly tested +- [ ] Edge cases covered +- [ ] Tests run in isolation +- [ ] Follows test pyramid (70/20/10) +- [ ] Critical paths have E2E tests +- [ ] Visual changes have snapshot tests + +## Testing decision flowchart + +``` +Writing a new test: +├─ Can it be a unit test? +│ ├─ YES → Write focused unit test +│ └─ NO → Why not? +│ ├─ Needs multiple components? +│ │ └─ Write integration test +│ └─ Tests UI (planned native app)? +│ ├─ Visual? → Snapshot test +│ └─ Interaction? → ViewInspector +├─ Is it deterministic? +│ ├─ NO → Fix non-determinism +│ └─ YES → Continue +└─ Does it run fast (<100ms)? + ├─ NO → Can it be optimized? + │ ├─ YES → Optimize + │ └─ NO → Tag as slow + └─ YES → Good to go +``` + +## Troubleshooting: stale SwiftPM build artifacts + +Before spending time debugging an impossible-looking test failure, check if it's a stale build. Swift 6.2 on recent macOS has a known incremental-build bug where adding, moving, or renaming a method on an `actor` (or any type with concurrency surface) can leave downstream `.o` / `.swiftmodule` files with the old method-table layout. Async dispatch then lands in the wrong slot, reads garbage, and trips a Swift stdlib `_precondition` far from the real call site. + +### How to recognise it + +All of these together strongly suggest staleness, not a real bug: + +- Stack trace points at a stdlib `.swiftinterface` line: "Not enough bits to represent the passed value" or a similar stdlib integer-conversion trap +- Reported crash frame is inside a function that is **not** actually in the call path (linker ICF-folding + symbolizer ambiguity) +- Adding or removing a *trivial* method (even an empty one) toggles the crash +- The same test passes in isolation but fails when run alongside others, or vice versa +- Git bisect blames a commit whose diff has no logical relationship to the trap +- Parallelism toggles (`--parallel / --no-parallel`, `SWIFT_TESTING_PARALLELIZATION_ENABLED=false`) have no effect + +### Remedy + +```bash +swift package clean && rm -rf .build && swift test +``` + +Or, if the project has a `Makefile`, run the `test-clean` (or equivalent) target. + +CI is not affected; it always builds from scratch. This only bites local development after method-surface changes on actors. + +### Dead-end diagnostic paths (do NOT repeat) + +- Adding `print` / `FileHandle.standardError.write` to the apparent trap site: the prints never fire because the reported symbol is wrong +- Switching `Int32(x)` to `Int32(clamping: x)`: masks the UInt assertion as a stack canary trap (SIGTRAP to SIGABRT), does not fix it +- Per-commit bisection: will blame the commit that happened to add a method, not the real cause +- Debugging across a test binary rebuild: rebuild resets the staleness, making the bug intermittent + +If you've exhausted these paths, the answer is almost always a clean rebuild. diff --git a/docs/rules-swift/ui/appkit-views.md b/docs/rules-swift/ui/appkit-views.md new file mode 100644 index 0000000..7a05f23 --- /dev/null +++ b/docs/rules-swift/ui/appkit-views.md @@ -0,0 +1,222 @@ +# AppKit Views + +How a Tiledown-style native editor writes its AppKit renderer: `NSViewController` +and `NSView` subclasses that are purely presentational over the framework-free +model (see `pre-ui-layer.md`). The renderer reads Surface values, forwards every +user action as `perform(_ intent:)`, and re-renders on the model's change signal, +observed through `withObservationTracking`. It holds ZERO business logic and owns +ZERO state that is not view-local. + +## Core rules + +1. The renderer renders Surface state only: no business logic, no API calls, no domain decisions. +2. Every user action (target/action, menu item, delegate callback) becomes a `perform(_ intent:)` call; the renderer reports, it never decides. +3. Observe the model with `withObservationTracking` (the AppKit idiom from `pre-ui-layer.md`), re-registering after each change, then re-pull the dirty `Set`. +4. All layout and menus in code; no Storyboards, no XIBs, no `MainMenu.xib` (build `NSMenu` in the app delegate). +5. Use a diffable data source (`NSCollectionViewDiffableDataSource` / `NSTableViewDiffableDataSource`) keyed by stable Surface identifiers. +6. Every control sets an accessibility label (`setAccessibilityLabel(_:)`). +7. Appearance happens in one `color(for: token)` map returning `NSColor`; never hard-code inline. +8. AppKit views recycle (`makeView(withIdentifier:)`, `itemForRepresentedObjectAt:`); reconfigure fully, stash no per-row state. + +## Decision tree: code belongs in the AppKit renderer? + +``` +Visual presentation (views, layout, animation) → YES +View-local UI state (drag in progress, split position) → YES +Reading a Surface to populate views → YES +Translating an action/menu/delegate callback into an Intent → YES (perform(intent)) +Data transformation → NO (domain service, in the model) +Business logic, validation, enable/disable → NO (engine; reflect the surface flag) +Navigation / window routing decision → NO (engine sets a destination surface; the controller reflects it) +Network / persistence → NO (data layer, behind a seam) +``` + +## Patterns + +### Entry point and menus in code + +A code-only AppKit app has no storyboard and no `@main`, so the entry point is +`main.swift` and several steps the Xcode template normally hides must be written by +hand. This is the cumbersome part; you write it once per app. + +```swift +// main.swift: AppKit with no storyboard needs an explicit entry point (no @main). +import AppKit + +let app = NSApplication.shared +app.setActivationPolicy(.regular) // without this a tool-launched app has no dock icon or menu bar +app.mainMenu = buildMainMenu() // programmatic NSMenu; there is no MainMenu.xib + +let window = TileListWindow.make(model: model) +window.makeKeyAndOrderFront(nil) +model.perform(.appDidLaunch) // lifecycle is an intent, not entry-point logic + +app.activate() // bring the app to the front +app.run() +``` + +For lifecycle hooks (`applicationDidFinishLaunching`, termination) set an +`NSApplicationDelegate` via `app.delegate` before `run()`; keep it thin, it raises +intents and owns no behavior. + +### Binding the model with withObservationTracking + +```swift +final class TileListViewController: NSViewController { + private let model: TileListRendererModel + private lazy var dataSource = makeDataSource() + + init(model: TileListRendererModel) { + self.model = model + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) required init?(coder: NSCoder) { fatalError("code-only UI") } + + override func loadView() { view = NSView() } // all hierarchy built in code + + override func viewDidLoad() { + super.viewDidLoad() + configureHierarchy() + observe() + } + + // Re-register after every change: withObservationTracking fires once per registration. + private func observe() { + withObservationTracking { + render(model.listSurface, model.statusSurface) + } onChange: { [weak self] in + DispatchQueue.main.async { self?.observe() } + } + } +} +``` + +### Action / menu / delegate to Intent + +```swift +// CORRECT: target/action reports an intent +@objc private func reload(_ sender: NSToolbarItem) { + model.perform(.run(.reload)) +} + +// Menu validation reflects a surface flag the engine computed; it does not decide. +func validateMenuItem(_ item: NSMenuItem) -> Bool { + model.menuSurface.isEnabled(item.action) +} + +// NSTableView selection to intent +func tableViewSelectionDidChange(_ notification: Notification) { + guard let id = dataSource.itemIdentifier(for: tableView.selectedRow) else { return } + model.perform(.select(id)) +} +``` + +### Lists via a diffable data source + +```swift +private func makeDataSource() -> NSCollectionViewDiffableDataSource { + NSCollectionViewDiffableDataSource(collectionView: collectionView) { [model] cv, indexPath, id in + let item = cv.makeItem(withIdentifier: .tile, for: indexPath) as! TileItem + item.render(model.tileSurface(id)) // item reflects a surface, owns no state + return item + } +} + +private func applyListSnapshot(_ surface: ListSurface) { + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + snapshot.appendItems(surface.orderedIDs, toSection: .main) + dataSource.apply(snapshot, animatingDifferences: true) +} +``` + +### Navigation / windows reflect a destination surface + +```swift +private func render(_ list: ListSurface, _ status: StatusSurface) { + applyListSnapshot(list) + statusLabel.stringValue = status.message + switch model.navigationSurface.destination { + case .none: detailWindow?.close() + case .detail(let detail): presentDetail(detail) // reflect, do not decide + } +} +``` + +### Accessibility + +```swift +reloadButton.setAccessibilityLabel("Reload tiles") +reloadButton.setAccessibilityHelp("Fetches the latest tiles") +``` + +## App setup (no storyboard, no XIB, and the network entitlement) + +A code-only app drops the template scaffolding, so do by hand what the storyboard +did implicitly, and grant network access in the right place: + +- **No storyboard, no XIB, no `MainMenu.xib`.** Remove the storyboard from the + target and build both the window and the main menu in code + (`app.mainMenu = buildMainMenu()`). UI is created in code, always. +- **`main.swift`, not `@main`.** With no `@NSApplicationMain` / `@main`, the entry + point is `main.swift`: share the application, set the activation policy to + `.regular`, install the menu, make the window key, `activate()`, then `run()` + (see the entry-point pattern above). +- **Network access is an entitlement, not Info.plist.** If the app enables App + Sandbox, network is denied by default. Grant it in the app's `.entitlements` + file, which Xcode's "Signing & Capabilities → App Sandbox → Network" checkboxes + write, never in Info.plist: + + ```xml + com.apple.security.app-sandbox + + com.apple.security.network.client + + com.apple.security.network.server + + ``` + + `network.client` is outgoing, `network.server` is incoming; add only the + direction the app actually uses. A non-sandboxed Mac app needs neither key. + +## Responder chain (route, do not decide) + +AppKit's responder chain is plumbing, not logic. A first-responder handler +translates the action into an intent and forwards it; it does not implement the +behavior. + +- An `@objc` action reaching a controller via the responder chain calls `model.perform(...)` and returns; the engine owns what happens. +- Use `validateMenuItem` / `validateUserInterfaceItem` to reflect engine-computed enablement from a surface flag, never to compute it inline. +- Field-editor edits (`NSTextField` / `NSText`) forward each commit as an `.edit` intent over the document surface the model owns; the renderer keeps no separate copy of the text. + +## Identity and reuse (the AppKit trap) + +`NSTableView` / `NSCollectionView` recycle views and items; treat each as a pure +function of the surface it renders. + +- An item/cell MUST fully reconfigure from its surface in `render(_:)`; never assume residual state. +- Cancel in-flight async in the reuse hook before the view is handed out again. +- Drive identity from stable `SurfaceID`s in the diffable snapshot, never from row indices. +- Keep view-local state (a drag in progress, a split-view position) on the view, never in the model. + +## Validation checklist + +- [ ] Zero business logic; every decision reflects a surface the engine set +- [ ] Every action/menu/delegate callback forwarded as `perform(_ intent:)` +- [ ] Updates driven by `withObservationTracking` re-registration over the change signal +- [ ] All layout and menus in code; no Storyboards, XIBs, or MainMenu.xib +- [ ] `main.swift` entry point (not `@main`) with `setActivationPolicy(.regular)` and `activate()` +- [ ] If sandboxed, network granted via `.entitlements` (`network.client` / `network.server`), not Info.plist +- [ ] Lists use a diffable data source keyed by stable Surface IDs +- [ ] Items/cells reconfigure fully on reuse; async cancelled before reuse +- [ ] Accessibility labels on every control +- [ ] Appearance only through the `color(for:)` map (`NSColor`) +- [ ] This is one of several renderers over the same model (see `pre-ui-layer.md`) + +## Companion rules + +- `pre-ui-layer.md`: the framework-free model this renderer adapts; AppKit observes the change signal via `withObservationTracking`. +- `swiftui-views.md`, `uikit-views.md`: the sibling renderers over the same model; build all three and compare (see `../domain-first.md`). +- `view-models.md`: responsibilities of the presentation layer the renderer binds to. +- `colors.md`, `fonts.md`: the `color(for:)` and font sources the renderer reflects. diff --git a/docs/rules-swift/ui/colors.md b/docs/rules-swift/ui/colors.md new file mode 100644 index 0000000..b57a624 --- /dev/null +++ b/docs/rules-swift/ui/colors.md @@ -0,0 +1,469 @@ +# Colors + +How a Tiledown-style native editor builds its color system: an HSV-based colors package with dynamic light/dark support and semantic naming. The colors package is independent from the theme package, which combines colors and fonts. + +## Core rules + +### Rule 1: Package structure + +Structure the design system as separate packages: + +- **AppColors**: standalone color system (zero dependencies) +- **AppFont**: standalone font system (zero dependencies) +- **AppTheme**: combines AppColors + AppFont + +### Rule 2: HSV internal representation + +Use HSV (Hue, Saturation, Value) internally: + +- Store all colors as HSV components. +- Calculate dark/light variants by HSV manipulation. +- Convert to/from RGB only when handing a SwiftUI `Color` to the UI. + +### Rule 3: Semantic color names (Apple HIG) + +Use these exact semantic names: + +- `primary`: primary brand/action color (like systemBlue) +- `success`: success states (like systemGreen) +- `secondary`: secondary brand color (like systemPurple) +- `destructive`: destructive/error actions (like systemRed). Apple uses "destructive", NOT "danger". +- `label`: primary text color (like `UIColor.label`) +- `secondaryLabel`: secondary text color +- `onPrimary`: text on primary-colored backgrounds +- `background`: primary background (like systemBackground) +- `secondaryBackground`: secondary/elevated background + +Concrete Apple system-color hex for the brand/action semantics (iOS light-mode values; derive dark variants by HSV per Rule 2). These are the canonical fills, never web defaults or ad-hoc hexes: + +| Semantic | Apple system color | Hex | +|---|---|---| +| `primary` | systemBlue | `#007AFF` | +| `success` | systemGreen | `#34C759` | +| `secondary` | systemPurple | `#AF52DE` | +| `destructive` | systemRed | `#FF3B30` | + +Full iOS system palette: systemRed `#FF3B30` · systemOrange `#FF9500` · systemYellow `#FFCC00` · systemGreen `#34C759` · systemMint `#00C7BE` · systemTeal `#30B0C7` · systemCyan `#32ADE6` · systemBlue `#007AFF` · systemIndigo `#5856D6` · systemPurple `#AF52DE` · systemPink `#FF2D55` · systemGray `#8E8E93`. The same palette governs GitHub labels and roadmap-diagram status colors (`git-discipline.md` Rule 1.7). + +### Rule 4: Dynamic color support + +Create dynamic colors that adapt to appearance: + +- Use `UIColor` dynamic colors on iOS. +- Use `NSColor` dynamic colors on macOS. +- Fall back to the light variant on other platforms. + +### Rule 5: Initialization modes + +Support two initialization modes: + +1. **Explicit colors**: caller provides light and dark variants. +2. **System fallback**: use system colors as defaults. + +## Package hierarchy + +``` +AppColors (standalone - zero dependencies) + ↓ +AppFont (standalone - zero dependencies) + ↓ +AppTheme (combines AppColors + AppFont) +``` + +### AppColors package structure + +``` +Sources/AppColors/ +├── HSVColor.swift # HSV color representation +├── Color+Dynamic.swift # Dynamic color extension +├── Color+HSV.swift # HSV conversion utilities +├── AppColors.swift # Main semantic colors +└── SystemColorDefaults.swift # System color fallbacks +``` + +### Package.swift configuration + +```swift +let appColorsTarget = Target.target( + name: "AppColors", + dependencies: [] // Foundation layer; zero dependencies +) + +let appFontTarget = Target.target( + name: "AppFont", + dependencies: [], + resources: [.process("Fonts")] +) + +let appThemeTarget = Target.target( + name: "AppTheme", + dependencies: [ + "AppColors", + "AppFont", + ] +) +``` + +## Implementation patterns + +### 1. HSV color representation + +```swift +import SwiftUI + +/// Internal HSV color representation for manipulation. +public struct HSVColor: Equatable, Sendable { + public let hue: Double // 0.0 - 1.0 + public let saturation: Double // 0.0 - 1.0 + public let value: Double // 0.0 - 1.0 + public let alpha: Double // 0.0 - 1.0 + + public init(hue: Double, saturation: Double, value: Double, alpha: Double = 1.0) { + self.hue = hue + self.saturation = saturation + self.value = value + self.alpha = alpha + } + + public func toColor() -> Color { + Color(hue: hue, saturation: saturation, brightness: value, opacity: alpha) + } + + /// Dark variant: reduce brightness, slightly increase saturation. + public func darkVariant() -> HSVColor { + HSVColor( + hue: hue, + saturation: min(1.0, saturation * 1.1), + value: max(0.15, value * 0.6), + alpha: alpha + ) + } + + /// Light variant: increase brightness, slightly reduce saturation. + public func lightVariant() -> HSVColor { + HSVColor( + hue: hue, + saturation: max(0.0, saturation * 0.85), + value: min(1.0, value * 1.3), + alpha: alpha + ) + } + + public func adjustingValue(by factor: Double) -> HSVColor { + HSVColor(hue: hue, saturation: saturation, value: min(1.0, max(0.0, value * factor)), alpha: alpha) + } + + public func adjustingSaturation(by factor: Double) -> HSVColor { + HSVColor(hue: hue, saturation: min(1.0, max(0.0, saturation * factor)), value: value, alpha: alpha) + } +} +``` + +### 2. Color + HSV extension + +```swift +import SwiftUI + +#if canImport(UIKit) +import UIKit +#elseif canImport(AppKit) +import AppKit +#endif + +extension Color { + public init(hsv: HSVColor) { + self = hsv.toColor() + } + + public func toHSV() -> HSVColor { + #if canImport(UIKit) + let uiColor = UIColor(self) + var hue: CGFloat = 0, saturation: CGFloat = 0, brightness: CGFloat = 0, alpha: CGFloat = 0 + uiColor.getHue(&hue, saturation: &saturation, brightness: &brightness, alpha: &alpha) + return HSVColor(hue: Double(hue), saturation: Double(saturation), value: Double(brightness), alpha: Double(alpha)) + #elseif canImport(AppKit) + let nsColor = NSColor(self) + let converted = nsColor.usingColorSpace(.deviceRGB) ?? nsColor + var hue: CGFloat = 0, saturation: CGFloat = 0, brightness: CGFloat = 0, alpha: CGFloat = 0 + converted.getHue(&hue, saturation: &saturation, brightness: &brightness, alpha: &alpha) + return HSVColor(hue: Double(hue), saturation: Double(saturation), value: Double(brightness), alpha: Double(alpha)) + #else + return HSVColor(hue: 0, saturation: 0, value: 0.5, alpha: 1.0) + #endif + } +} +``` + +### 3. Dynamic color extension + +```swift +import SwiftUI + +#if canImport(UIKit) +import UIKit +#elseif canImport(AppKit) +import AppKit +#endif + +extension Color { + /// Creates a dynamic color that adapts to appearance. + public init(light: Color, dark: Color) { + #if os(iOS) + self = Color(UIColor { traitCollection in + traitCollection.userInterfaceStyle == .dark ? UIColor(dark) : UIColor(light) + }) + #elseif os(macOS) + self = Color(NSColor(name: nil) { appearance in + appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua ? NSColor(dark) : NSColor(light) + }) + #else + self = light + #endif + } + + /// Dynamic color from HSV, auto-calculating the dark variant. + public init(lightHSV: HSVColor) { + self.init(light: lightHSV.toColor(), dark: lightHSV.darkVariant().toColor()) + } + + /// Dynamic color from HSV, auto-calculating the light variant. + public init(darkHSV: HSVColor) { + self.init(light: darkHSV.lightVariant().toColor(), dark: darkHSV.toColor()) + } +} +``` + +### 4. System color defaults + +```swift +import SwiftUI + +/// System color defaults for fallback (Apple HIG inspired). +public enum SystemColorDefaults { + public static let primary = HSVColor(hue: 0.58, saturation: 0.8, value: 0.9) // Blue + public static let success = HSVColor(hue: 0.33, saturation: 0.7, value: 0.8) // Green + public static let secondary = HSVColor(hue: 0.75, saturation: 0.6, value: 0.85) // Purple + public static let destructive = HSVColor(hue: 0.0, saturation: 0.8, value: 0.9) // Red + public static let label = HSVColor(hue: 0.0, saturation: 0.0, value: 0.1) // Near-black + public static let secondaryLabel = HSVColor(hue: 0.0, saturation: 0.0, value: 0.5) // Medium gray + public static let onPrimary = HSVColor(hue: 0.0, saturation: 0.0, value: 1.0) // White + public static let background = HSVColor(hue: 0.0, saturation: 0.0, value: 1.0) // White + public static let secondaryBackground = HSVColor(hue: 0.0, saturation: 0.0, value: 0.95) // Light gray +} +``` + +### 5. AppColors main structure + +```swift +import SwiftUI + +/// Semantic color palette (Apple HIG naming). +public struct AppColors: Sendable { + // Semantic + public let primary: Color + public let success: Color + public let secondary: Color + public let destructive: Color + + // Text + public let label: Color + public let secondaryLabel: Color + public let onPrimary: Color + + // Background + public let background: Color + public let secondaryBackground: Color + + /// Initialize with explicit HSV light-mode colors. Dark variants are calculated automatically. + public init( + primaryHSV: HSVColor, + successHSV: HSVColor, + secondaryHSV: HSVColor, + destructiveHSV: HSVColor, + labelHSV: HSVColor, + secondaryLabelHSV: HSVColor, + onPrimaryHSV: HSVColor, + backgroundHSV: HSVColor, + secondaryBackgroundHSV: HSVColor + ) { + primary = Color(lightHSV: primaryHSV) + success = Color(lightHSV: successHSV) + secondary = Color(lightHSV: secondaryHSV) + destructive = Color(lightHSV: destructiveHSV) + label = Color(lightHSV: labelHSV) + secondaryLabel = Color(lightHSV: secondaryLabelHSV) + onPrimary = Color(lightHSV: onPrimaryHSV) + background = Color(lightHSV: backgroundHSV) + secondaryBackground = Color(lightHSV: secondaryBackgroundHSV) + } + + /// Initialize with explicit light/dark Color pairs. + public init( + primary: (light: Color, dark: Color), + success: (light: Color, dark: Color), + secondary: (light: Color, dark: Color), + destructive: (light: Color, dark: Color), + label: (light: Color, dark: Color), + secondaryLabel: (light: Color, dark: Color), + onPrimary: (light: Color, dark: Color), + background: (light: Color, dark: Color), + secondaryBackground: (light: Color, dark: Color) + ) { + self.primary = Color(light: primary.light, dark: primary.dark) + self.success = Color(light: success.light, dark: success.dark) + self.secondary = Color(light: secondary.light, dark: secondary.dark) + self.destructive = Color(light: destructive.light, dark: destructive.dark) + self.label = Color(light: label.light, dark: label.dark) + self.secondaryLabel = Color(light: secondaryLabel.light, dark: secondaryLabel.dark) + self.onPrimary = Color(light: onPrimary.light, dark: onPrimary.dark) + self.background = Color(light: background.light, dark: background.dark) + self.secondaryBackground = Color(light: secondaryBackground.light, dark: secondaryBackground.dark) + } + + /// Default palette using system colors. + public static let system = AppColors( + primaryHSV: SystemColorDefaults.primary, + successHSV: SystemColorDefaults.success, + secondaryHSV: SystemColorDefaults.secondary, + destructiveHSV: SystemColorDefaults.destructive, + labelHSV: SystemColorDefaults.label, + secondaryLabelHSV: SystemColorDefaults.secondaryLabel, + onPrimaryHSV: SystemColorDefaults.onPrimary, + backgroundHSV: SystemColorDefaults.background, + secondaryBackgroundHSV: SystemColorDefaults.secondaryBackground + ) +} + +// MARK: - Environment + +private struct AppColorsKey: EnvironmentKey { + static let defaultValue = AppColors.system +} + +extension EnvironmentValues { + public var appColors: AppColors { + get { self[AppColorsKey.self] } + set { self[AppColorsKey.self] = newValue } + } +} + +extension View { + public func appColors(_ colors: AppColors) -> some View { + environment(\.appColors, colors) + } +} +``` + +### 6. AppTheme package (combines colors + fonts) + +```swift +import SwiftUI +import AppColors +import AppFont + +/// Complete theme combining colors and typography. +public struct AppTheme: Sendable { + public let colors: AppColors + + public init(colors: AppColors = .system) { + self.colors = colors + } + + public static let system = AppTheme(colors: .system) +} + +private struct AppThemeKey: EnvironmentKey { + static let defaultValue = AppTheme.system +} + +extension EnvironmentValues { + public var appTheme: AppTheme { + get { self[AppThemeKey.self] } + set { self[AppThemeKey.self] = newValue } + } +} + +extension View { + public func appTheme(_ theme: AppTheme) -> some View { + environment(\.appTheme, theme) + } +} +``` + +## Usage patterns + +### Basic usage with system colors + +```swift +import SwiftUI +import AppColors + +struct MyView: View { + @Environment(\.appColors) var colors + + var body: some View { + VStack { + Text("Primary Text") + .foregroundColor(colors.label) + + Button("Action") {} + .foregroundColor(colors.onPrimary) + .background(colors.primary) + } + .background(colors.background) + } +} +``` + +### Custom brand colors + +```swift +let customColors = AppColors( + primaryHSV: HSVColor(hue: 0.6, saturation: 0.85, value: 0.95), + successHSV: HSVColor(hue: 0.33, saturation: 0.7, value: 0.8), + secondaryHSV: HSVColor(hue: 0.75, saturation: 0.6, value: 0.85), + destructiveHSV: HSVColor(hue: 0.0, saturation: 0.8, value: 0.9), + labelHSV: HSVColor(hue: 0, saturation: 0, value: 0.1), + secondaryLabelHSV: HSVColor(hue: 0, saturation: 0, value: 0.5), + onPrimaryHSV: HSVColor(hue: 0, saturation: 0, value: 1.0), + backgroundHSV: HSVColor(hue: 0, saturation: 0, value: 1.0), + secondaryBackgroundHSV: HSVColor(hue: 0, saturation: 0, value: 0.95) +) + +@main +struct TiledownApp: App { + var body: some Scene { + WindowGroup { + ContentView() + .appColors(customColors) + } + } +} +``` + +## Common mistakes + +- Making `AppColors` depend on `AppFont` (it MUST be standalone). +- Storing colors as RGB internally instead of HSV. +- Hardcoding dark-mode colors instead of calculating them from HSV. +- Static colors that do not adapt to dark mode. +- Non-semantic names like `blue`, `red`, `lightGray`. +- Material Design naming (`error`, `surface`, `onSurface`). Use Apple HIG: `destructive`, `background`, `label`. + +## Checklist + +- [ ] `AppColors` is a standalone package (zero dependencies) +- [ ] `AppFont` is a standalone package (zero dependencies) +- [ ] `AppTheme` combines `AppColors` + `AppFont` +- [ ] `HSVColor` defined with hue, saturation, value, alpha +- [ ] `HSVColor` has `darkVariant()` and `lightVariant()` +- [ ] `Color+Dynamic` implements `init(light:dark:)` +- [ ] `Color+HSV` converts between `Color` and `HSVColor` +- [ ] All 9 semantic colors present, Apple HIG naming +- [ ] Used `destructive` (not "danger"/"error") +- [ ] Used `label`/`secondaryLabel` (not "textPrimary") +- [ ] Used `background`/`secondaryBackground` (not "bgPrimary") +- [ ] `SystemColorDefaults` provides fallback values +- [ ] `AppColors.system` static property exists +- [ ] Environment keys for `appColors` and `appTheme` +- [ ] Platform-specific dynamic colors (`#if os(iOS)` / `os(macOS)`) diff --git a/docs/rules-swift/ui/components.md b/docs/rules-swift/ui/components.md new file mode 100644 index 0000000..5f81024 --- /dev/null +++ b/docs/rules-swift/ui/components.md @@ -0,0 +1,327 @@ +# Components + +How a Tiledown-style native editor builds its UI from reusable, registry-driven components. This file consolidates the component protocol, the package hierarchy, the registry, and the one-component-per-file discipline. + +## One component per file + +- Each UI component lives in its own component package, NEVER in feature packages or screens. +- ONE component per file; the file name equals the component name. +- Every component conforms to the `Component` protocol from the `Components` package. + +## Core rules + +### Rule 1: Three-package hierarchy + +Use this strict layering: + +``` +Components (core infrastructure - ZERO dependencies) + ↓ +SharedComponents (hot reload - depends on hot-reload + file-watch tools only) + ↓ +AppComponents (app-specific - depends on Components + theme + font packages) + ↓ +AllComponents (optional aggregator for previews - depends on Components + AppComponents) +``` + +### Rule 2: Components package foundation + +Create the `Components` package FIRST with the core infrastructure: + +- MUST contain: the `Component` protocol, `AnyComponent`, `ComponentsRegistry`, `ComponentFactory`. +- MUST contain: bundle management, registration interfaces, and the component-list model/view. +- MUST include a `components.json` configuration file, processed with `.process()`. +- MUST have ZERO dependencies (foundation layer). + +### Rule 3: SharedComponents dependencies + +Depend ONLY on hot-reload tooling: + +- A hot-reload injection library and a file-watching library. +- NEVER depend on UI packages (theme, font). Mixing them in breaks the hot-reload layer's independence from the UI foundation. + +### Rule 4: AppComponents dependencies + +Depend on the foundation packages: + +- `Components` (core system) +- The theme package (colors, styles) +- The font package (typography) + +### Rule 5: Component registration + +Register components via the registry: + +- All components conform to the `Component` protocol from the `Components` package. +- Registration happens in a per-package `*ComponentsRegistration.swift`. +- Register external components through the shared registry. + +## The Component protocol + +```swift +import Foundation +import SwiftUI + +public protocol ComponentData: Hashable, Decodable, Sendable {} +public typealias ComponentKind = String + +public protocol Component: Sendable { + associatedtype Data: ComponentData + associatedtype ViewBody: View + + var data: Data { get } + + /// Build the SwiftUI view for this component. + /// Runs on the main actor because SwiftUI view construction + /// and state are main-actor isolated. + @MainActor + func make() -> ViewBody + + static var kind: ComponentKind { get } + init(data: Data) +} + +public extension Component { + /// Auto-generates kind from the type name (e.g. "TilePreviewComponent" → "tilepreview"). + @inline(__always) + static var kind: ComponentKind { + let fullName = String(reflecting: Self.self) + let parts = fullName.split(separator: ".") + let typePath = parts.dropFirst().joined(separator: ".") + return typePath + .replacingOccurrences(of: "Component", with: "") + .lowercased() + } + + /// Register this component in the registry. + static func register(in registry: ComponentsRegistry) { + let componentType = Self.self + let dataType = Self.Data.self + + registry.decoders[kind] = { decoder in + let data = try decoder.decode(dataType, forKey: .payload) + return ComponentFactory(data: data, type: componentType) + } + } +} +``` + +### AnyComponent (type-erased wrapper) + +`AnyComponent` is a type-erased wrapper struct, not a protocol. Concrete components conform to `Component`; `AnyComponent` wraps any of them for storage and rendering. + +```swift +@MainActor +public struct AnyComponent: Identifiable { + private let component: any Component + public var id: UUID = .init() + public let kind: ComponentKind + + private let _make: () -> AnyView + + public init(_ component: ComponentType) where ComponentType: Component { + self.component = component + kind = ComponentType.kind + _make = { AnyView(component.make()) } + } + + public var contentView: AnyView { _make() } +} +``` + +### ComponentFactory (Sendable bridge) + +`ComponentFactory` is `Sendable` so component data can be decoded off the main thread, then rendered on the main actor. + +```swift +public struct ComponentFactory: Sendable { + public let kind: ComponentKind + + private let _makeRenderable: @MainActor () -> AnyComponent + + public init(data: C.Data, type: C.Type) { + kind = C.kind + _makeRenderable = { + let instance = C(data: data) + return AnyComponent(instance) + } + } + + @MainActor + public func makeRenderable() -> AnyComponent { + _makeRenderable() + } +} +``` + +### ComponentsRegistry + +The registry maps a component `kind` to a decoder that produces a `ComponentFactory`. JSON describing a component carries a `kind` plus a `payload`, and the registry decodes the right concrete data type. + +```swift +public final class ComponentsRegistry { + public typealias ComponentDecoder = + @Sendable (KeyedDecodingContainer) throws -> ComponentFactory + + public var decoders: [ComponentKind: ComponentDecoder] = [:] + + public struct Container: Decodable { + public enum CodingKeys: CodingKey { case kind, payload } + public struct ComponentNotFound: Error { public var kind: ComponentKind } + public struct DecodersNotFound: Error {} + + public static let decodersKey = CodingUserInfoKey(rawValue: "ComponentDecoders")! + public let factory: ComponentFactory + + public init(from decoder: Decoder) throws { + guard let decoders = decoder.userInfo[Self.decodersKey] as? [ComponentKind: ComponentDecoder] else { + throw DecodersNotFound() + } + let container = try decoder.container(keyedBy: CodingKeys.self) + let kind = try container.decode(ComponentKind.self, forKey: .kind) + guard let decodeForKind = decoders[kind] else { + throw ComponentNotFound(kind: kind) + } + factory = try decodeForKind(container) + } + } + + public init() {} + + /// Decode JSON into ComponentFactories (safe off the main thread). + public func decodeFactories(from data: Data) -> [ComponentFactory] { + do { + let decoder = JSONDecoder() + decoder.userInfo[Container.decodersKey] = decoders + let containers = try decoder.decode([Container].self, from: data) + return containers.map(\.factory) + } catch { + print("Unable to parse \(error)") + return [] + } + } +} +``` + +## Concrete component example + +One component per file. Define the data struct, store it, implement `make()`, and provide a preview. + +```swift +import Components +import SwiftUI + +public struct TilePreviewComponent: Component { + public struct Data: ComponentData { + public var title: String + public var subtitle: String + public var isActive: Bool = true + + public init(title: String, subtitle: String, isActive: Bool = true) { + self.title = title + self.subtitle = subtitle + self.isActive = isActive + } + } + + public var data: Data + + public init(data: Data) { + self.data = data + } + + public func make() -> some View { + TilePreviewContent(data: data) + } +} + +// Separate view struct for the actual UI. +struct TilePreviewContent: View { + var data: TilePreviewComponent.Data + @Environment(\.appTheme) private var theme + + var body: some View { + // card implementation using theme.colors and the font package + EmptyView() + } +} + +#Preview { + TilePreviewComponent(data: .init( + title: "Hero Tile", + subtitle: "Top of the canvas" + )).make() +} +``` + +## components.json + +Describes the components and sample payloads used by previews and the component gallery. Lives in the `Components` package, processed with `.process()`. + +```json +{ + "components": [ + { + "kind": "tile-preview", + "category": "Display", + "description": "Displays a tile preview with status", + "payload": { + "title": "Hero Tile", + "subtitle": "Top of the canvas", + "isActive": true + } + } + ] +} +``` + +## Registration + +```swift +import Components + +/// Register a package's components in the registry. +public func registerTileComponents(in registry: ComponentsRegistry) { + TilePreviewComponent.register(in: registry) + // additional components... +} + +// In app initialization: +let registry = ComponentsRegistry() +registerTileComponents(in: registry) +``` + +## AllComponents aggregator + +An optional umbrella package that re-exports `Components` and `AppComponents` for convenient imports. + +```swift +@_exported import Components +@_exported import AppComponents +``` + +- USE only in preview/demo apps and the component gallery. +- DO NOT use in production features or main app targets; be explicit with imports there. + +## Common mistakes + +- Creating `AppComponents` without the core `Components` package. +- Adding UI dependencies (theme, font) to `Components` (it MUST have zero dependencies). +- Adding UI dependencies to `SharedComponents` (hot reload only). +- Importing the broad `AllComponents` aggregator inside production features. +- Over-specific package naming. Keep app-specific components in `AppComponents`, not a narrowly named package. + +## Checklist + +- [ ] `Components` package exists with zero dependencies, created first +- [ ] `Components` contains the protocol, `AnyComponent`, registry, factory, list types +- [ ] `Components` includes `components.json`, processed with `.process()` +- [ ] `SharedComponents` depends ONLY on hot-reload + file-watch tooling +- [ ] `AppComponents` depends on Components + theme + font +- [ ] `AppComponents` holds production components only +- [ ] `AllComponents`, if present, used only in preview/demo apps +- [ ] All components conform to the `Component` protocol +- [ ] ONE component per file; file name equals component name +- [ ] Registration goes through the shared registry +- [ ] Resources use `.process()` not `.copy()` +- [ ] The component layer follows the strict hierarchy diff --git a/docs/rules-swift/ui/flowspec.md b/docs/rules-swift/ui/flowspec.md new file mode 100644 index 0000000..3a19087 --- /dev/null +++ b/docs/rules-swift/ui/flowspec.md @@ -0,0 +1,43 @@ +# FlowSpec (declarative UI scenarios) + +**Status: RECOMMENDED, and the expected form when a UI ships in more than one renderer.** A UI flow is described once, as a declarative scenario, and replayed against every UI implementation. The scenario is the single source of truth for what a flow does; the implementations cannot drift in their behavior because they all run the same steps. The engine is the public package [FlowSpec](https://github.com/mihaelamj/FlowSpec). + +## The model + +- A **scenario** is a JSON file: `{ id, title, actor?, preconditions[], tags[], steps[] }`. +- A **step** is `{ verb, target, arg? }`. Its key is `"verb:target"`. +- A **verb** comes from a small closed vocabulary (`open`, `tap`, `type`, `swipe`, `wait`, `assert`, `request`). The vocabulary is deliberately tiny so a scenario reads like a specification, not a script; adding a verb is a coordinated change across the schema, the enum, and every registry. +- A **step registry** is the seam: one per UI, a `@MainActor` type with a single `execute(_ step: Step) throws` that turns a step into a real action against that UI. The registry throws on an unknown `verb:target`, so a scenario can never reference machinery that is not wired up. FlowSpec ships a default identifier-driven XCUITest registry in its `FlowSpecXCUI` module (see `pom.md` for the page-object pattern it drives); the headless registry that drives the model layer you write yourself. The core `FlowSpec` engine stays `Foundation`-only. +- The **runner** walks a scenario's steps in order, stops on the first failure, and reports the scenario id and step index, so a log points at the exact step. + +## Why and when + +- **One flow, many UIs.** When a feature ships in more than one renderer (SwiftUI, AppKit, UIKit; see `pre-ui-layer.md`), write the flow once and bind a registry per renderer. The renderers cannot disagree about what the flow does, because the steps are shared. +- **The same corpus, headless and on-device.** The engine is `Foundation`-only and transport-agnostic. The same scenario corpus runs on-device through an XCUITest registry that drives page objects (`pom.md`), AND headless through a registry that drives the model layer directly (`pre-ui-layer.md`). This is the strongest form of the pre-UI-layer's headless-parity invariant: do not keep a separate set of hand-written headless tests that can drift from the device scenarios; replay the one corpus both ways. +- **A closed vocabulary keeps scenarios honest.** Because the verb set is small and the registry throws on an unwired step, a scenario stays a readable specification and cannot silently no-op. + +## DO + +- Keep the flow in a scenario file; keep the per-UI mechanics in a step registry, one per renderer. +- Drive the on-device registry through page objects (`pom.md`), not raw queries. +- Replay the same scenario corpus headless against the model layer; do not fork a separate headless test set. +- Let the runner stop at the first failure and report the scenario id and step index. +- Throw `unknownStep` on an unwired `verb:target` so a scenario cannot reference missing machinery. + +## DON'T + +- Do not embed UI-framework calls in the scenario or the engine; the engine is `Foundation`-only and the framework binding lives in the registry. +- Do not grow the verb vocabulary casually; a wide verb set turns a scenario back into a script. +- Do not maintain device scenarios and headless tests as two separate corpora that drift; share one. +- Do not let a step silently pass when its handler is missing; the registry throws. + +## Acceptance check + +A conforming setup has: (1) flows expressed as scenario files, not procedural test code; (2) one `StepRegistry` per renderer, with the UI-framework binding confined to it and the engine `Foundation`-only; (3) the on-device registry driving page objects, not raw element queries; (4) the same scenario corpus replayed headless against the model layer when the UI has a pre-UI layer; (5) an unwired `verb:target` throwing `unknownStep`, verified by a test; (6) failures carrying scenario id and step index. A project that keeps its device scenarios and its headless coverage as two unrelated corpora fails the headless-parity intent of this rule. + +## Companion rules + +- `pre-ui-layer.md`: the headless registry drives the model layer; one corpus replayed headless and on-device is the strongest headless-parity form. +- `pom.md`: the on-device registry maps each step to a page-object action. +- `../core/testing-discipline.md`: real tests, on every change. +- `../core/no-shortcuts-first-principles.md`: an unwired step throws rather than silently passing. diff --git a/docs/rules-swift/ui/fonts.md b/docs/rules-swift/ui/fonts.md new file mode 100644 index 0000000..c4e9772 --- /dev/null +++ b/docs/rules-swift/ui/fonts.md @@ -0,0 +1,221 @@ +# Fonts + +How to register custom fonts in a Tiledown-style native editor: CoreText registration inside SPM packages, never the Info.plist approaches. Resources must use `.process()` in Package.swift, and resource access always goes through `Bundle.module`. + +## Core rules + +### Rule 1: CoreText registration + +Use `CTFontManagerRegisterFontsForURL` for font registration: + +- MUST import CoreText and CoreGraphics. +- MUST use `Bundle.module` (NOT `Bundle.main`). +- MUST handle errors with `Unmanaged?`. +- MUST provide console logging for success/failure. +- MUST filter font files by extension (.otf, .ttf). + +### Rule 2: Package.swift configuration + +Use `.process()` for font resources: + +- NEVER use `.copy()`. It will not work with `Bundle.module`. +- MUST organize fonts in a dedicated subdirectory (e.g. `Fonts/`). +- The package MUST have zero dependencies (foundation layer). + +### Rule 3: Platform imports + +Use platform-specific imports: + +- `#if canImport(UIKit)` for iOS. +- `#elseif canImport(AppKit)` for macOS. +- Import UIKit/AppKit for platform types. + +### Rule 4: App initialization + +Register fonts in app init **before** UI renders: + +- Call in `App.init()` or `AppDelegate.applicationDidFinishLaunching`. +- Registration is synchronous and must complete before SwiftUI renders. + +### Rule 5: Never use Info.plist + +Do not use Info.plist font registration in SPM packages: + +- `UIAppFonts` only works in app bundles, NOT packages. +- `ATSApplicationFontsPath` only works in app bundles. +- CoreText is the ONLY approach that works in SPM packages. + +## Implementation pattern + +### Package.swift configuration + +```swift +let appFontTarget = Target.target( + name: "AppFont", + dependencies: [], // Foundation layer; zero dependencies + resources: [ + .process("Fonts"), // MUST be .process(); .copy() silently fails to register fonts + ] +) +``` + +Why `.process()` not `.copy()`: + +- `.process()`: resources processed and accessible via `Bundle.module`. +- `.copy()`: resources copied verbatim, may not work correctly. + +### Font registration implementation + +```swift +import CoreGraphics +import CoreText +import Foundation + +#if canImport(UIKit) +import UIKit +#elseif canImport(AppKit) +import AppKit +#endif + +public enum FontRegistration { + /// Register custom fonts shipped with the AppFont package. + public static func registerFonts() { + // Use Bundle.module for SPM packages. + guard let resourceURLs = Bundle.module.urls( + forResourcesWithExtension: nil, + subdirectory: nil + ) else { + print("No resources found in AppFont bundle") + return + } + + // Filter by font extension (.otf, .ttf). + let fontURLs = resourceURLs.filter { url in + let ext = url.pathExtension.lowercased() + return ext == "otf" || ext == "ttf" + } + + guard !fontURLs.isEmpty else { + print("No font files found in AppFont bundle") + return + } + + // Register each font with error handling. + for url in fontURLs { + var errorRef: Unmanaged? + let success = CTFontManagerRegisterFontsForURL( + url as CFURL, + .process, // Register for the current process + &errorRef + ) + + if !success { + print("Failed to register font: \(url.lastPathComponent)") + if let error = errorRef?.takeRetainedValue() { + print(" Error: \(error)") + } + } else { + print("Registered font: \(url.lastPathComponent)") + } + } + } +} +``` + +### Package directory structure + +``` +Sources/AppFont/ +├── FontRegistration.swift # CoreText registration (this file) +├── ScaledFont.swift # Font modifiers (e.g. .appFont()) +├── FontStyles.swift # Font style definitions +└── Fonts/ # Font resources + ├── EditorSans-Regular.otf + ├── EditorSans-Bold.otf + ├── EditorSans-Light.otf + └── EditorSans-Italic.otf +``` + +### App initialization + +```swift +import SwiftUI +import AppFont + +@main +struct TiledownApp: App { + init() { + // Register fonts before any UI renders. + FontRegistration.registerFonts() + } + + var body: some Scene { + WindowGroup { + ContentView() + } + } +} +``` + +### Font usage in SwiftUI + +```swift +import SwiftUI +import AppFont + +struct MyView: View { + var body: some View { + VStack { + Text("Headline") + .appFont(.headline) // Custom font with Dynamic Type + + Text("Body text") + .appFont(.body) + } + } +} +``` + +## Why this pattern? + +- **Works in SPM packages.** Info.plist approaches only work in app bundles; CoreText registration works in packages, frameworks, and apps. +- **Explicit error reporting.** Console logs show which fonts loaded and why a failure happened. +- **Cross-platform.** Works on iOS and macOS with conditional imports, same code path. +- **Uses `Bundle.module`.** SPM manages the bundle; no manual path handling. +- **Multiple formats.** `.otf` (recommended) and `.ttf`, filtered by extension. + +### CoreText vs Info.plist + +| Approach | Works in app bundle | Works in SPM package | +|---|---|---| +| Info.plist (`UIAppFonts`) | Yes | No | +| Info.plist (`ATSApplicationFontsPath`) | Yes | No | +| CoreText (`CTFontManagerRegisterFontsForURL`) | Yes | Yes | + +CoreText is the only approach that works universally. + +## Common mistakes + +- Using Info.plist (`UIAppFonts` / `ATSApplicationFontsPath`) in a package: does not work. +- Using `.copy()` for resources: will not work with `Bundle.module`. +- Using `Bundle.main` in package code: `Bundle.main` is for apps, not packages. +- Skipping error handling on `CTFontManagerRegisterFontsForURL`. +- Registering fonts in `.onAppear` instead of app init: too late, UI already rendered with system fonts. + +## Checklist + +- [ ] Used `.process()` for resources in Package.swift +- [ ] Created `FontRegistration.swift` with CoreText registration +- [ ] Imported CoreText and CoreGraphics +- [ ] Used platform-specific imports (`#if canImport(UIKit)` / `#if canImport(AppKit)`) +- [ ] Used `Bundle.module` for resource access +- [ ] Filtered font files by extension (.otf, .ttf) +- [ ] Used `CTFontManagerRegisterFontsForURL` with `.process` scope +- [ ] Implemented error handling with `Unmanaged?` +- [ ] Added console logging for success/failure +- [ ] Called registration in app init BEFORE UI renders +- [ ] Fonts organized in a `Fonts/` subdirectory +- [ ] Package has zero dependencies +- [ ] NEVER used Info.plist (`UIAppFonts`, `ATSApplicationFontsPath`) +- [ ] NEVER used `Bundle.main` in package code +- [ ] NEVER used `.copy()` for font resources diff --git a/docs/rules-swift/ui/pom.md b/docs/rules-swift/ui/pom.md new file mode 100644 index 0000000..448483b --- /dev/null +++ b/docs/rules-swift/ui/pom.md @@ -0,0 +1,43 @@ +# Page Object Model (for UI tests) + +**Status: MANDATORY for UI tests.** A UI test interacts with a screen only through a page object, never through raw element queries. Each screen has one page object that owns its locators and its actions; the test body names pages and calls their methods, and never touches a `XCUIElement` or a raw identifier string. This keeps tests readable, keeps a UI change confined to one page object, and makes invalid navigation a compile error. + +## The pieces + +1. **A base page owns the mechanics.** A single shared base class is constructed with the `XCUIApplication` injected (`init(app:)`) and holds every reusable interaction: waiting for an element to appear or disappear, tapping, asserting existence, scrolling to an element, taking a screenshot. Every method threads `file: StaticString = #filePath, line: UInt = #line` so a failure blames the test's call site, not the base page. Methods are `open` so a page can override a wait without re-implementing it. + + A default identifier-driven XCUITest registry ships in `FlowSpecXCUI`, part of the [FlowSpec](https://github.com/mihaelamj/FlowSpec) package, so an app whose views set accessibility identifiers can run scenarios with no custom registry code; the page objects themselves are per-app. See `flowspec.md`. + +2. **One page object per screen.** A concrete page subclasses the base class, file name equals page name. Its locators are computed `XCUIElement` properties that query the app by a shared identifier constant and the typed element kind (`app.buttons[ID.Catalog.search]`, `app.staticTexts[ID.Item.title]`), never a raw string. Its methods split into verifications (return `Self`) and actions (return the next page). + +3. **A single source of truth for accessibility identifiers.** Identifiers live in one shared enum (one nested namespace per screen, `public static let` constants) in a package that BOTH the app and the UI tests depend on. The app sets them (`.accessibilityIdentifier(ID.Catalog.search)`); the page objects query the same constants. Renaming an identifier is one edit the compiler propagates to producer and consumer, so source and tests cannot drift into a string mismatch. + +4. **Fluent, type-safe navigation.** An action returns the page it navigates to (`openItem() -> ItemPage`, `goBack() -> CatalogPage`); a verification returns `Self`. A test reads as a chain (`CatalogPage(app:).verifyShown().openItem().verifyShown()`), and the compiler rejects an impossible navigation path because the return types do not line up. + +## DO + +- Construct the first page with the app, then chain; let action methods return the next page. +- Put every locator behind a shared accessibility-identifier constant; the app sets it, the page queries it. +- Keep `XCUIElement` properties private on the page; expose screen actions, not elements. +- Wait explicitly (`waitForExistence(timeout:)` and predicate waits); never `sleep()`. See `../core/no-shortcuts-first-principles.md`. +- Thread `#filePath` / `#line` through waits and asserts so failures point at the test. +- One page object per screen; recurring mechanics live once in the base page. + +## DON'T + +- Do not query `app.buttons["raw string"]` in a test body; that is the coupling page objects exist to remove. +- Do not return `Void` from a navigation action; return the destination page so the type system checks the flow. +- Do not phrase a locator as a literal; route it through the shared identifier enum. +- Do not `sleep()` to dodge a race; wait for the element. A sleep is a silenced symptom. +- Do not duplicate wait or tap logic per page; it belongs in the base page. + +## Acceptance check + +A conforming UI test suite has: (1) a shared base class (constructed with the app) that owns waits, taps, and asserts, with `#filePath`/`#line` threaded through; (2) one page object per screen, file name equals page name, locators private and routed through a shared identifier enum; (3) a single accessibility-identifier source of truth in a package both the app and tests depend on, with the app setting and the tests querying the same constants; (4) action methods that return the next page and verification methods that return `Self`; (5) no raw element-query strings and no `XCUIElement` in any test body (grep the test target); (6) no `sleep()` anywhere in the page objects or tests. A suite that queries raw strings, or returns `Void` from navigation, or sleeps to avoid a race, fails this rule even if it currently passes. + +## Companion rules + +- `flowspec.md`: drive page objects from a declarative scenario corpus; the scenario step registry calls page-object actions. +- `pre-ui-layer.md`: the accessibility identifiers a page object queries should line up with the surface identities the model exposes, so a scenario can target the same element across renderers. +- `../core/no-shortcuts-first-principles.md`: the no-sleep, wait-explicitly discipline. +- `../core/testing-discipline.md`: real tests, on every change. diff --git a/docs/rules-swift/ui/pre-ui-layer.md b/docs/rules-swift/ui/pre-ui-layer.md new file mode 100644 index 0000000..105b899 --- /dev/null +++ b/docs/rules-swift/ui/pre-ui-layer.md @@ -0,0 +1,114 @@ +# Pre-UI Layer (display-less model) + +**Status: MANDATORY for any code that has UI.** All display state and interaction behavior live in a UI-framework-agnostic, value-typed model. The UI consumes it through a narrow framework-agnostic contract. Renderers translate the model to the target's native presentation and are the only code that imports a UI framework. The result is renderer-agnostic AND platform-portable: one model drives SwiftUI, AppKit, and UIKit on Apple platforms, and because it imports only Foundation the same model builds and runs on Linux, Windows, and WASM, where a different front end (a text or terminal UI, a WASM front end, a platform-native one) renders the same surfaces. The model is blind to which renderer is attached, and it is testable with no display at all. + +## The premise + +The only fundamental split in UI code is *what to present* (state and behavior, framework-free) versus *how to render it* (pixels, framework-bound). Everything below is derived from that one cut. This is the Humble Object and Presentation Model pattern: the framework-bound part is made so thin it needs no test, and all the logic sits in a plain object beneath it. + +An app does not begin at its UI. It begins at the domain and the engine: what the entities are and what the behavior does. The UI is the last layer attached and the most replaceable one. Start with it and you bake renderer assumptions into decisions that should have stayed framework-free, and you cannot answer "does this behave correctly" until a screen exists. Build the model that runs with no display first, prove it headless, then attach renderers to it. The order is domain and engine first, UI last, which is also why one model can later carry SwiftUI, AppKit, and UIKit at once: nothing about it was ever decided by a renderer. + +## Below the renderer: two value tiers, three roles + +What sits below the renderer is the inner rings of the classic layered architecture, with the renderer standing in for the UI layer. It is *not* a tall new stack of layers. It is two kinds of *value* plus the roles that act on them, all framework-free. The reason it reads as "two tiers" in one place and "more" in another is that those are different axes: the tiers are the nouns (what the model *is*), the roles are the verbs (what reads and writes them). Both are below the renderer; neither is an extra layer of the other. + +**Two value tiers** (pure, `Sendable`, value-typed, no UI import): + +1. **Domain.** What the entities *are*: `Codable` value types, reusable beyond any UI. No display state. +2. **Surface.** Display state *over* the domain: selection, expansion, caret position, load state, and the surface organs (the tree, grid, form, document, status, dialog the UI shows). This is the Presentation Model: framework-free presentation state the renderer reflects. The renderer sees surfaces, never raw domain types. + +**Three roles that operate on those tiers** (the behavior, also framework-free): + +- **Engine** (the interactor, the application role): receives every intent and runs the use case. It owns the surfaces and is the one place behavior is orchestrated. +- **Data layer** (I/O behind a port): all loading and persistence. The engine reaches it only through injected, interface-segregated protocol seams (a repository), never directly. +- **Domain services**: pure, deterministic computation over the domain (check syntax, validate, transform). The engine calls them and puts the result in a surface. + +Style is named by *meaning*, never appearance (a token, not a color), so the value tiers contain no appearance vocabulary at all. + +This is not five peer layers competing for a count. The two value tiers are the nouns; the three roles are the verbs that read and write them; the renderer is the only thing above. In a small app the roles collapse into one module (the engine is a couple of functions, the data layer is one protocol with one conformer, domain services may be inline), but the **seams are not optional**: one intent channel in, a data-layer port for every I/O, and no UI import below the renderer. Those seams are what keep the model renderable by any front end, testable with no display, and portable off Apple. Collapse the modules; never collapse the seams. + +## The consumption contract + +The UI touches the model through three narrow seams and nothing else: + +- **Pull** (read): interface-segregated capability protocols, composed per consumer, not one wide protocol. A feature injects only the slices it uses, so its fakes are tiny. +- **Intent** (write): a single `perform(_ intent: Intent)`, the one inbound door into the engine, where `Intent` is a payload-carrying value (a Command: `.edit(Edit)`, `.select(SurfaceID)`, `.toggle(SurfaceID)`, `.run(CommandID)`, `.ambient(Ambient)`). **Every trigger that reaches the engine is one enumerable case**, whether a user raises it or the system does, and the model defines and handles all of it: opening or activating the app, a menu-bar or toolbar command, selecting a menu item, a pull-to-refresh, a tap, a swipe, a scroll, a focus change, and every text edit (each keystroke or the committed string), *and equally* the automatic, no-user events: a timer firing, a notification arriving, a background refresh or a network request completing, an app lifecycle transition, an observed value changing. Those automatic events are `.ambient(...)` cases on the same channel: the system raises them, but they enter the engine through the one door, never a side path. The caller (renderer or system) reports the intent and re-pulls; it never interprets, decides, or owns behavior. +- **Notify**: a typed change signal, a `Set` of what changed. The model emits it; the UI re-pulls the dirty areas. How a renderer observes the signal (Observation, a tracking closure, a re-pull loop) is a renderer detail, not part of the contract. + +## Completeness: every trigger is represented, in order + +The intent vocabulary is **exhaustive** over everything that can drive the app, from both directions: every action a user can take *and* every event the system can raise on its own. If a user can perform an action, there is an `Intent` case for it and the model handles it; nothing is decided in the renderer. A button press is sending a command. A text field is an `.edit` over a document the model owns (with its own undo and cursor). A pull-to-refresh is a `.run` of a reload command. A menu-bar or context-menu item is a `.run` of its command id. Opening or activating the app is an intent the model receives, not lifecycle logic the renderer keeps. **And equally for automatic events**: if a timer fires, a notification arrives, a background task or network request completes, the app moves to background or foreground, or an observed value changes, there is an `.ambient(...)` case for it and the engine handles it; that behavior is never left in a timer closure, a notification handler, or a completion block the renderer or the system owns. The caller (a finger or the system) raises the event; the model is the only place it means anything. + +This is the same coverage law as `../validation-rules.md`, applied to triggers: keep a registry of every trigger the app can receive, the user affordances **and** the ambient/system events, and a meta-test asserting each one maps to an `Intent` (or is an explicitly recorded, reasoned gap). Adding a new affordance or a new system event without an intent MUST fail that meta-test. A trigger handled directly outside the engine (an `if` in the renderer that decides, state the renderer keeps, a network call a button makes, a timer or notification handler that mutates state itself) is the exact violation this rule exists to prevent: it is behavior the headless tests can never reach and the other renderers will not share. + +**Order is part of the contract.** A run of the app is a *sequence* of these triggers, user and ambient interleaved as they actually occur, and the engine's outcome depends on that order. So the test corpus is not a set of isolated triggers; it is the **ordered sequence** of intents a real session produces, ambient events recorded in their true positions (the timer fires *after* the load completes, the notification arrives *while* the editor is focused). The same ordered corpus is what runs on-device and headless (see `flowspec.md`): the device UI test and the headless replay drive the identical sequence, so the order that matters in real usage is the order the tests assert. A test corpus that exercises each trigger once but never in a realistic, ordered run, or that omits the ambient events that interleave with user actions, is incomplete by this rule even if every individual intent is covered. + +### Every intent has a documented response + +The half of the model that receives intents and runs behavior is the **engine** (the interactor): given an intent, it runs the use case. For any loading or persistence it calls the **data layer**, the repositories and data sources behind injected, interface-segregated protocol seams; the engine orchestrates and owns the surfaces, but it does not do I/O itself, the data layer does. It maps the result into the named surfaces, sets load and failure state, and emits the change signal. So an intent's round trip is the two value tiers acted on by the three roles: the engine (behavior) maps Domain entities, fetched through the data-layer port and refined by domain services, into Surface display state, all framework-free. Every intent's effect is documented as data, what it does, which data it touches, which surface areas it mutates, and its loading and failure states, so each interaction is a recorded round trip: *affordance to intent to engine effect*. A pull-to-refresh is `.run(reload)`, whose effect is: the engine asks the data layer to load, sets the list's load state to loading then loaded or failed, maps the returned entities into the list surface, and notifies the list area. Typing is `.edit`, whose effect is: the document applies the edit, the cursor moves, the editor surface is notified. Selecting a menu item is `.run` of its command id, whose effect is that command's behavior. An arriving notification is `.ambient(.notification(payload))`, whose effect is documented the same way: the engine routes the payload, maybe asks the data layer to fetch, maps the result into a surface, sets a badge or banner surface flag, and notifies those areas, exactly as a user-raised intent would, with no behavior left in the notification handler. Because the engine reaches data only through the protocol seams it is given, a headless test injects a fake data source and the same intent produces the same surfaces with no network. The coverage meta-test covers BOTH halves: every affordance has an intent, and every intent has a defined, documented effect (its work, its mutated surfaces, its loading/failure states). An intent whose effect is undocumented, or that the engine performs ad hoc without recording what it touches, fails this rule as surely as an unrepresented affordance. The point of writing both halves down is that the round trip is then testable headless and identical under every renderer: the same pull-to-refresh runs the same reload and produces the same surfaces, on SwiftUI, on AppKit, and off-Apple. + +The engine is the caller; what it calls lives by kind. Anything with I/O (load, save, a network request) is the **data layer**, behind an injected seam. Any pure analysis of the domain (check syntax, validate, parse, transform) is a **domain service**, a deterministic, framework-free function the engine calls and whose result it puts in a surface (the diagnostics, say). Whether an action is enabled is neither a call nor the renderer's question: the engine derives it from current state and bakes it into the surface as a flag the renderer only reflects. So *load data* is the data layer, *check syntax* is a domain service, *enable actions* is engine-computed surface state, and the engine is the one place that orchestrates all three in response to an intent. + +## The renderer + +Renderers are thin adapters: they translate surface values into the target's native presentation (SwiftUI, AppKit, or UIKit views on Apple platforms; a text grid, a WASM tree, or another platform's widgets elsewhere) and are the **only** code permitted to import a UI framework. There can be several at once, one per renderer technology, all binding the same model. On Apple platforms the standing aspiration, pursued whenever bandwidth allows, is to ship all three native renderers (SwiftUI, AppKit, and UIKit) against the one model: three idioms, one behavior, proven identical by the same headless corpus. That triple render is the architecture's payoff, not an afterthought, and a model that only ever drives one renderer is leaving the guarantee unused. The triple render is also a comparison instrument: with one model and identical behavior underneath, building the same surface in each SDK isolates the renderer as the only variable, so which framework behaves best in a given scenario (animation smoothness, accessibility, gesture handling, layout under load, large-list performance) becomes an apples-to-apples test rather than a guess. Reach for it whenever a scenario's UI behavior is uncertain. Each adapts the change signal its own way: SwiftUI through Observation, AppKit through `withObservationTracking`, a non-reactive or off-Apple front end through a re-pull loop. The set of renderer packages is named explicitly (the UI boundary). A renderer holds no behavior worth testing; the single place appearance happens is one `color(for: token)` map (or the target's equivalent). + +## Make impossible states unrepresentable, then validate the residue + +Push every total invariant into the types: a non-empty selection wrapper, a smart constructor that rejects the bad value at `init`, so the bad state cannot be built. Validate only what the type system cannot carry, with a default validator per public surface type (see `../validation-rules.md`: every public type is validated or excluded with a reason). The strongest coverage is the rule you delete by making the bug unwritable. + +## Enforcement + +The no-UI-import invariant earns mechanical enforcement, because it is brutal to retrofit once a UI type leaks inward. Ship a **build-failing** check (a script in CI) that forbids any UI-framework import outside the named renderer boundary set, and fails naming the offending file. A documented manual audit is not enough. + +## Proof: testable headless + +Because all behavior is in value-typed models behind a framework-free contract, the real models run with no display. The strongest form runs the **same ordered scenario corpus** the device UI tests use, replayed headless against the models (see `flowspec.md`); only pixel-geometry steps are skipped. The corpus drives the real ordered sequence of triggers, user actions and ambient events interleaved as a real session produces them (a `.run(reload)` then, mid-load, an `.ambient(.notification(...))`, then an `.edit`), so the order that decides behavior in production is the order the tests assert. Do not keep a separate hand-written headless test set that can drift from the device scenarios, and do not reduce the corpus to each trigger fired once in isolation. + +### Run the real async; control only the environment + +The replay must exercise the model exactly as it runs in production: its real concurrency, never a synchronous reduction of it. The engine's actual `async`/`await`, its actor isolation, its `Task` scheduling, and the real interleaving of concurrent operations all run during the test, the same code paths that ship. Determinism comes from controlling the *environment*, not from erasing the asynchrony: + +- **Time is injected, not real.** The engine takes its time source as a `Clock` (the Swift standard library `Clock` protocol; a test conforms its own clock and advances it by hand). A timer, a delay, or a debounce then fires at a controlled instant, deterministic and immediate in wall-clock terms, while the suspension points themselves stay real. Never sleep against the real wall clock in a test. +- **Ambient sources are injected behind their seams.** Notifications, network responses, and background-task completions arrive through the same interface-segregated seams the engine already reads, fed by the test as an `AsyncStream` or a resumed continuation. The test decides *when* each completes and *in what order*, so two operations in flight finish in the order the scenario pins, and a race that matters in production becomes a reproducible step rather than luck. +- **Outcomes are awaited, not slept for.** Assert that the asynchronous effect actually happened, the expected number of times, with Swift Testing's `confirmation`, not by sleeping and hoping; the test proceeds when the real work completes. + +The result is the real program under deterministic conditions: the actual async behavior and the actual ordering, reproduced exactly, with only the wall clock and the external deliveries under the test's control. A replay that makes the engine synchronous, drops the actor hops, or swaps `await`s for immediate returns is testing a different program than the one that ships, and exactly the failures this discipline exists to catch, ordering, re-entrancy, and races, never surface. + +## DO + +- Split the model into Domain (pure data) and Surface (display state over it); both framework-free, value-typed, `Sendable`. +- Expose reads as interface-segregated capability slices; expose writes as one `perform(_ intent: Intent)` with a payload-carrying `Intent` value, and route automatic, no-user events (timer, notification, background or network completion, lifecycle, observed change) through that same door as `.ambient(...)` cases. +- Emit change as a typed `Set`; let the renderer choose how to observe it. +- Keep renderers thin: surface-to-native translation only, the one place appearance happens, the only UI-importing packages. +- Make total invariants unrepresentable; validate the residue with a default validator per public surface type. +- Gate the no-UI-import rule with a build-failing check naming the renderer boundary. +- Replay one ordered scenario corpus both headless and on-device, with user and ambient triggers interleaved in their real order. +- Run the headless replay through the model's real concurrency (real `async`/`await`, actors, `Task` scheduling); get determinism from an injected `Clock` and injected ambient seams, not from making the engine synchronous; await async effects with `confirmation`, never a sleep. + +## DON'T + +- Do not put display state or behavior in the renderer; the renderer reports intents and re-pulls, nothing more. +- Do not import a UI framework below the renderer layer, and do not name a color, font, or size in the model (style names meaning). +- Do not grow an open-ended delegate of many methods; one `Intent` channel for user and ambient triggers alike. +- Do not leave behavior in a timer closure, a notification handler, or a completion block; raise an `.ambient(...)` intent and let the engine own it. +- Do not depend on the model from a renderer and the renderer from the model; the dependency is one way, renderer to model. +- Do not rely on a manual audit to keep UI imports out; gate it in the build. +- Do not keep device scenarios and headless tests as two corpora that drift; share one. +- Do not test triggers only in isolation; the corpus is the real ordered run, ambient events in their true positions. +- Do not make the engine synchronous or stub the async away to test it; run the real concurrency and control the `Clock` and the seams instead, so races and ordering bugs actually surface. + +## Acceptance check + +A conforming UI codebase has: (1) a framework-free, value-typed model in two tiers (Domain pure data, Surface display state), grep-clean of any UI-framework import (`SwiftUI`/`UIKit`/`AppKit`, or the equivalent UI layer on another target platform) outside the renderer set; (2) reads as interface-segregated slices and writes as a single `perform(_ intent: Intent)` over a payload value; (3) a typed `Set` change signal, with observation left to the renderer; (4) renderers that only translate surface to native and are the sole UI-importing packages, named as a boundary set; (5) a build-failing import-confinement check (not a manual audit); (6) total invariants made unrepresentable, with a default validator per remaining public surface type; (7) the real models tested headless by replaying the same ordered scenario corpus the device tests use, user and ambient triggers interleaved in their real order, exercising the model's real concurrency (real `async`/`await`, actors, `Task` scheduling) with time injected as a `Clock` and ambient sources injected behind their seams and async outcomes awaited via `confirmation`, not a synchronous reduction of the program; and (8) every trigger the app can receive, user affordance **and** ambient/system event, maps to an `Intent` (a `.ambient(...)` case for the automatic ones), and every `Intent` has a documented engine effect (its work, its mutated surfaces, its loading and failure states), both asserted by a coverage meta-test, with no trigger or effect handled outside the engine. A codebase that lets a UI type leak into a feature or model, or that owns behavior in the renderer, or that handles any trigger (a menu command, a refresh, a keystroke, a timer, an arriving notification, a completion callback) outside the model, fails this rule even if it runs. + +## How it composes (the pattern is not invented) + +This is the Humble Object and Presentation Model, realized as a composition of established patterns: Domain and Surface are the two value tiers of the layered model; the engine is the interactor or use-case role (a Facade over the services it orchestrates); the data layer is a Repository behind a port; `Intent` is Command; the read slices are Interface Segregation; the change signal is Observer; a renderer is an Adapter; and there is deliberately no Singleton (collaborators are injected, see `../dependency-injection.md`). It is the abstraction-versus-presentation separation that user-interface architecture theory has described for decades: the renderer is the layered architecture's UI layer, the engine its application layer, the data layer its infrastructure reached only through a port, and dependencies point one way, inward. + +## Companion rules + +- `pom.md` and `flowspec.md`: how the UI is tested (page objects driven from a shared scenario corpus, replayed headless against these models). +- `../validation-rules.md`: every public surface type is validated or excluded with a reason. +- `../package-import-contract.md`: the per-target import contract the renderer-boundary gate enforces. +- `../dependency-injection.md` and `../core/no-shortcuts-first-principles.md`: no Singleton, constructor injection, make impossible states unrepresentable. diff --git a/docs/rules-swift/ui/swiftui-views.md b/docs/rules-swift/ui/swiftui-views.md new file mode 100644 index 0000000..6a573b0 --- /dev/null +++ b/docs/rules-swift/ui/swiftui-views.md @@ -0,0 +1,283 @@ +# SwiftUI Views + +How a Tiledown-style native editor writes its SwiftUI renderer: purely presentational, performant, accessible, reusable. Views observe view models but contain ZERO business logic. This is one of three Apple renderers over the same framework-free model (see `pre-ui-layer.md`); its siblings are `uikit-views.md` and `appkit-views.md`. + +## Core rules + +1. Views render ViewModel state only: no business logic, no API calls +2. Use `@State` for view-local UI state, `@Bindable` for ViewModel bindings +3. Use lazy containers for lists >50 items +4. Every interactive element requires an accessibility label +5. Extract components used 1+ times +6. Import a hot-reload helper and add the hot-reload observation property for live previews during development +7. Use case-pathable enums for navigation state + +## Decision tree: code belongs in View? + +``` +Visual presentation → YES +UI animation → YES +Local UI state → YES (@State) +Data transformation → NO (ViewModel) +Business logic → NO (ViewModel/Service) +Navigation → View binds to ViewModel destination +``` + +## Patterns + +### Pure presentation + +```swift +// CORRECT +struct TilePreviewView: View { + let tile: Tile + let onSelect: () -> Void + + var body: some View { + Button(action: onSelect) { + VStack { + Text(tile.name) + Text(tile.sizeDescription) + } + } + } +} + +// INCORRECT: View with business logic +struct BadTileView: View { + let tile: Tile + + var body: some View { + Button("Render") { + // Wrong: direct service call in view + Task { + try? await RenderService.shared.render(tile) + } + } + } +} +``` + +### ViewModel integration + +```swift +// Container gets injected ViewModel +struct TileListScreen: View { + @Bindable var viewModel: TileListViewModel + + var body: some View { + TileListView(viewModel: viewModel) + .task { await viewModel.onViewAppear() } + } +} + +// Inner view receives ViewModel +struct TileListView: View { + let viewModel: TileListViewModel + + var body: some View { + List(viewModel.tiles) { tile in + Button { viewModel.onUserSelected(tile) } label: { + Text(tile.name) + } + } + } +} +``` + +### Reusable component + +```swift +// ViewBuilder slot +struct AsyncButton: View { + let action: () async -> Void + @ViewBuilder let label: () -> Label + @State private var isLoading = false + + var body: some View { + Button { + Task { + isLoading = true + await action() + isLoading = false + } + } label: { + isLoading ? ProgressView() : label() + } + .disabled(isLoading) + } +} + +// ViewModifier +struct CardStyle: ViewModifier { + func body(content: Content) -> some View { + content + .padding() + .background(Color(.systemBackground)) + .cornerRadius(12) + .shadow(radius: 2) + } +} +``` + +### Accessibility + +```swift +TextField("Name", text: $name) + .accessibilityLabel("Your name") + .accessibilityHint("Required field") + +Button("Submit", action: onSubmit) + .accessibilityHint("Double tap to save") +``` + +### Navigation + +```swift +// Define destinations using @CasePathable +@CasePathable +enum Destination { + case detail(DetailFeature) + case settings(SettingsFeature) + case alert(AlertState) +} + +@Observable @MainActor +final class AppViewModel { + var destination: Destination? + + func onTappedItem(id: String) { + destination = .detail(DetailFeature(id: id)) + } + + func onTappedSettings() { + destination = .settings(SettingsFeature()) + } +} + +struct AppView: View { + @Bindable var viewModel: AppViewModel + + var body: some View { + List { + Button("Show Detail") { viewModel.onTappedItem(id: "123") } + Button("Settings") { viewModel.onTappedSettings() } + } + .navigationDestination(item: $viewModel.destination.detail) { feature in + DetailView(viewModel: feature) + } + .navigationDestination(item: $viewModel.destination.settings) { feature in + SettingsView(viewModel: feature) + } + .alert($viewModel.destination.alert) { action in + viewModel.onAlertAction(action) + } + } +} +``` + +### Sheets and covers + +```swift +@CasePathable +enum Destination { + case editSheet(EditFeature) + case detailCover(DetailFeature) + case confirmationAlert(AlertState) +} + +@Observable @MainActor +final class FeatureViewModel { + var destination: Destination? + + func onTappedEdit() { + destination = .editSheet(EditFeature()) + } + + func onTappedDetail() { + destination = .detailCover(DetailFeature()) + } +} + +struct FeatureView: View { + @Bindable var viewModel: FeatureViewModel + + var body: some View { + List { + Button("Edit") { viewModel.onTappedEdit() } + Button("Detail") { viewModel.onTappedDetail() } + } + .sheet(item: $viewModel.destination.editSheet) { feature in + EditView(viewModel: feature) + } + .fullScreenCover(item: $viewModel.destination.detailCover) { feature in + DetailView(viewModel: feature) + } + .alert($viewModel.destination.confirmationAlert) { action in + viewModel.onConfirmAction(action) + } + } +} +``` + +## Validation checklist + +- [ ] Zero business logic +- [ ] Actions forwarded to ViewModel +- [ ] Accessibility labels provided +- [ ] Performance optimized (lazy loading) +- [ ] Components extracted for reuse +- [ ] Navigation uses case-pathable destinations +- [ ] Error/loading/empty states handled + +## Identity and conditional views + +SwiftUI view identity is fragile. Whenever you switch entire view branches with `if/else`, you create different view identities and risk losing any `@State` attached to them. + +### Prefer null views and stable containers + +Bad (two different branches = two identities): + +```swift +if hasSelection { + DetailView(item: selected) +} else { + ContentUnavailableView { + Label("No Selection", systemImage: "sidebar.left") + } description: { + Text("Select an item from the sidebar to view its details") + } +} +``` + +Instead, prefer a stable container with conditional content: + +```swift +ZStack { + if let item = selected { + DetailView(item: item) + } else { + ContentUnavailableView { + Label("No Selection", systemImage: "sidebar.left") + } description: { + Text("Select an item from the sidebar to view its details") + } + } +} +``` + +Or keep the same view and vary styling with a null/no-op modifier: + +```swift +ViewWithState() + .background(flag ? .red : .clear) + .animation(.easeInOut(duration: 2), value: flag) +``` + +Closures must never participate in identity. + +Rules of thumb: + +- Views with local `@State` MUST keep a stable identity. +- Containers without state MAY switch child content. +- When only styling changes, use parameters or no-op modifiers (like `.background(.clear)`), instead of duplicating view branches. diff --git a/docs/rules-swift/ui/three-renderers.md b/docs/rules-swift/ui/three-renderers.md new file mode 100644 index 0000000..cc3c02f --- /dev/null +++ b/docs/rules-swift/ui/three-renderers.md @@ -0,0 +1,62 @@ +# Three Renderers (one model, all three Apple SDKs) + +**Status: the standing UI aspiration, pursued whenever bandwidth allows.** Build the +same UI in all three Apple SDKs, SwiftUI, UIKit, and AppKit, over the one +framework-free model (see `pre-ui-layer.md`). The model is renderer-agnostic by +construction, so attaching a second and a third renderer is additive, not a +rewrite. A model that only ever drives one renderer is leaving the architecture's +central guarantee unused. + +## Why build all three + +1. **It is the payoff, not an afterthought.** The pre-UI layer exists so behavior + lives in a value-typed model and the renderer is a thin adapter. Once that holds, + three idioms over one behavior, proven identical by the same headless corpus, is + exactly what the discipline was for. +2. **It is a comparison instrument.** With one model and identical behavior + underneath, the renderer is the only variable. Building the same surface in each + SDK turns "which framework behaves best here" into an apples-to-apples test + rather than a guess: animation smoothness, accessibility, gesture handling, + layout under load, large-list performance. Reach for it whenever a scenario's UI + behavior is uncertain. +3. **The cost has collapsed.** Machine pattern-matching at scale now makes + developing several renderers affordable, where hand-writing each from scratch was + once too expensive to justify. + +## What this requires + +- One framework-free model: Domain plus Surface, a single `perform(_ intent:)` + write channel, and a `Set` change signal (see `pre-ui-layer.md`). +- Each renderer a thin adapter that reads surfaces, forwards intents, and + re-renders on the signal: SwiftUI via Observation, AppKit via + `withObservationTracking`, UIKit via a re-pull loop. The renderers hold no + behavior worth testing. +- One shared ordered scenario corpus replayed headless and on-device, so the three + renderers cannot disagree about what the UI does (see `flowspec.md`). + +## Three is the aspiration, not a tax on every screen + +Build the renderer your product ships first; add the others when bandwidth allows, +or when a scenario's behavior is genuinely in question and you want the comparison. +Never let "only one renderer for now" leak behavior into that renderer: the model +stays complete and renderer-agnostic no matter how many adapters exist today, which +is exactly what keeps the second and third cheap to add later. The decision is +deferred and reversible (see `domain-first.md`), never baked into the model. + +## Acceptance check + +A codebase honoring this rule has: (1) a framework-free model that builds and tests +headless with no renderer attached; (2) at least one renderer as a thin adapter +over that model, with a clear, additive path to a second and third because no +behavior lives in the renderer; and (3) where more than one renderer exists, a +single shared scenario corpus they are all proven against. A codebase whose single +renderer owns behavior, so a second renderer would mean reimplementing logic rather +than adding an adapter, fails this rule even if it ships. + +## Companion rules + +- `pre-ui-layer.md`: the framework-free model the renderers share; its renderer + section states this triple-render aspiration in architectural context. +- `domain-first.md`: defer the renderer choice, then develop more than one and pick + by observed behavior. +- `flowspec.md`: one flow, many renderers, replayed headless and on-device. diff --git a/docs/rules-swift/ui/uikit-views.md b/docs/rules-swift/ui/uikit-views.md new file mode 100644 index 0000000..fe473f8 --- /dev/null +++ b/docs/rules-swift/ui/uikit-views.md @@ -0,0 +1,190 @@ +# UIKit Views + +How a Tiledown-style native editor writes its UIKit renderer: `UIViewController` and +`UIView` subclasses that are purely presentational over the framework-free model +(see `pre-ui-layer.md`). The renderer reads Surface values, forwards every user +action as `perform(_ intent:)`, and re-renders on the model's change signal. It +holds ZERO business logic and owns ZERO state that is not view-local. + +## Core rules + +1. The renderer renders Surface state only: no business logic, no API calls, no domain decisions. +2. Every user action becomes a `perform(_ intent:)` call; the renderer reports, it never decides. +3. Observe the model's `Set` change signal and re-pull the dirty areas; UIKit is not reactive, so drive updates from the signal, never from ad hoc mutation. +4. All layout in code (Auto Layout or manual `layoutSubviews`); no Storyboards, no XIBs. +5. Use a diffable data source for any list or grid, keyed by stable Surface identifiers. +6. Every interactive element has an `accessibilityLabel` (and a hint where the action is non-obvious). +7. The one place appearance happens is a single `color(for: token)` map; never hard-code a `UIColor` inline. +8. Reuse cells; never stash per-row state on a cell, it will be recycled. + +## Decision tree: code belongs in the UIKit renderer? + +``` +Visual presentation (views, layout, animation) → YES +View-local UI state (in-flight gesture, scroll offset) → YES +Reading a Surface to populate views → YES +Translating a tap/swipe/edit into an Intent → YES (perform(intent)) +Data transformation → NO (domain service, in the model) +Business logic, validation, enable/disable decision → NO (engine; reflect the surface flag) +Navigation decision → NO (engine sets a destination surface; the controller reflects it) +Network / persistence → NO (data layer, behind a seam) +``` + +## Patterns + +### Binding the model and re-pulling on the signal + +```swift +final class TileListViewController: UIViewController { + private let model: TileListRendererModel // pull slices + perform(_:) + change signal + private lazy var dataSource = makeDataSource() + + init(model: TileListRendererModel) { + self.model = model + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) required init?(coder: NSCoder) { fatalError("code-only UI") } + + override func viewDidLoad() { + super.viewDidLoad() + configureHierarchy() // all layout in code + model.onChange = { [weak self] dirty in + self?.apply(dirty) // re-pull only the dirty surface areas + } + model.perform(.appWillAppear) // lifecycle is an intent, not controller logic + } + + private func apply(_ dirty: Set) { + if dirty.contains(.list) { applyListSnapshot(model.listSurface) } + if dirty.contains(.status) { statusLabel.text = model.statusSurface.message } + } +} +``` + +### User action to Intent (never a decision in the controller) + +```swift +// CORRECT: report the intent, the re-pull happens via the change signal +@objc private func didTapReload() { + model.perform(.run(.reload)) +} + +func collectionView(_ cv: UICollectionView, didSelectItemAt indexPath: IndexPath) { + guard let id = dataSource.itemIdentifier(for: indexPath) else { return } + model.perform(.select(id)) +} + +// INCORRECT: business logic in the renderer +@objc private func badReload() { + Task { try? await RenderService.shared.reload() } // wrong layer, wrong everything +} +``` + +### Lists via a diffable data source, keyed by Surface IDs + +```swift +private func makeDataSource() -> UICollectionViewDiffableDataSource { + let cell = UICollectionView.CellRegistration { [model] cell, _, id in + cell.render(model.tileSurface(id)) // cell reflects a surface, owns no state + } + return .init(collectionView: collectionView) { cv, indexPath, id in + cv.dequeueConfiguredReusableCell(using: cell, for: indexPath, item: id) + } +} + +private func applyListSnapshot(_ surface: ListSurface) { + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + snapshot.appendItems(surface.orderedIDs, toSection: .main) + dataSource.apply(snapshot, animatingDifferences: true) +} +``` + +### Enable/disable reflects a surface flag (the engine decides) + +```swift +// The engine computes whether the action is allowed; the renderer only reflects it. +reloadButton.isEnabled = model.toolbarSurface.isReloadEnabled +``` + +### Navigation reflects a destination surface + +```swift +private func apply(_ dirty: Set) { + guard dirty.contains(.navigation) else { return } + switch model.navigationSurface.destination { + case .none: + if presentedViewController != nil { dismiss(animated: true) } + case .detail(let detail): + present(TileDetailViewController(model: detail), animated: true) + } +} +``` + +### Accessibility + +```swift +reloadButton.accessibilityLabel = "Reload tiles" +reloadButton.accessibilityHint = "Fetches the latest tiles" +``` + +## Observing the change signal + +UIKit has no built-in reactivity, so the renderer is driven by the model's `Notify` +signal, never by mutating views from wherever an event lands: + +- **Re-pull loop (default).** The model calls back with a `Set`; the + controller re-pulls only those areas and updates the corresponding views. Coalesce + rapid signals to the next runloop tick to avoid redundant layout. +- **Observation bridge (optional).** If the model exposes `@Observable` surfaces, + wrap reads in `withObservationTracking`, re-registering after each callback, and + funnel the callback into `setNeedsUpdate`-style invalidation. It is still a + re-pull; Observation only schedules it. + +Mutation flows one way: intent in, surface out, re-pull. Never mutate a view from a +place that also mutates the model. + +## Identity and reuse (the UIKit trap) + +Cells and reusable views are recycled; treat each as a pure function of the surface +it renders. + +- A cell MUST fully reconfigure from its surface in `render(_:)`; never assume residual state from a prior row. +- Cancel any in-flight async (an image load) in `prepareForReuse`. +- Drive list identity from stable `SurfaceID`s in the diffable snapshot, never from index paths. +- Keep view-local values (a half-finished gesture) on the view, never in the model, and never let them survive reuse. + +## App setup (no storyboard, no XIB) + +UI is created in code, always. A code-only UIKit app removes the storyboard +scaffolding the template ships with: + +- Delete the **Main storyboard** entry: clear "Main storyboard file base name" + (`UIMainStoryboardFile`) and, under the scene manifest, the + `UISceneStoryboardFile` reference in Info.plist; remove `Main.storyboard` and any + `.xib`. +- Create the window and root view controller in code, in the scene delegate + (`scene(_:willConnectTo:)` builds the `UIWindow`, sets `rootViewController`, and + calls `makeKeyAndVisible()`), or in the app delegate for a single-scene app. +- Set `UILaunchScreen` as an empty dictionary in Info.plist (a code-only launch + screen) rather than pointing at a launch storyboard. + +## Validation checklist + +- [ ] Zero business logic; every decision reflects a surface the engine set +- [ ] Every user action forwarded as `perform(_ intent:)` +- [ ] Updates driven by the `Set` change signal, not ad hoc mutation +- [ ] All layout in code; no Storyboards or XIBs +- [ ] Lists use a diffable data source keyed by stable Surface IDs +- [ ] Cells reconfigure fully on reuse; async cancelled in `prepareForReuse` +- [ ] Accessibility labels on every interactive element +- [ ] Appearance only through the `color(for:)` map +- [ ] This is one of several renderers over the same model (see `pre-ui-layer.md`) + +## Companion rules + +- `pre-ui-layer.md`: the framework-free model this renderer adapts; renderers are the only UI import, gated mechanically. +- `swiftui-views.md`, `appkit-views.md`: the sibling renderers over the same model; build all three and compare (see `../domain-first.md`). +- `view-models.md`: responsibilities of the presentation layer the renderer binds to. +- `colors.md`, `fonts.md`: the `color(for:)` and font sources the renderer reflects. diff --git a/docs/rules-swift/ui/view-models.md b/docs/rules-swift/ui/view-models.md new file mode 100644 index 0000000..fe25b76 --- /dev/null +++ b/docs/rules-swift/ui/view-models.md @@ -0,0 +1,290 @@ +# View Models + +How a Tiledown-style native editor structures its view models: thin coordination layers between views and services. They orchestrate business logic without containing it, maintain reactive UI state via `@Observable`, and stay testable through dependency injection. + +## Mental model + +ViewModel = Coordinator (NOT a business-logic container) + +- Services: business logic +- ViewModel: coordination + UI transformation +- View: pure presentation + +## Core rules + +### Rule 1: ViewModel as coordinator + +- MUST delegate business logic to services +- MUST NOT contain business rules or external access +- Coordinate between layers only + +### Rule 2: State management + +- MUST use `private(set)` for mutable state +- MUST use enums for loading/async states (idle, loading, loaded, failed) +- MUST derive computed state (no duplication) +- MUST use `@MainActor` on the class definition +- State machines for complex flows (prefer enum over multiple booleans) + +### Rule 3: Dependency injection + +- MUST inject collaborators (see docs/rules/dependency-injection.md) +- SHOULD prefer struct-based dependencies over protocols where practical +- NO global singletons + +### Rule 4: Task management + +- MUST support cancellation +- MUST track loading states +- MUST handle errors gracefully +- Clean up in deinit + +### Rule 5: Method naming + +- View-triggered: `on` prefix (e.g., `onTappedItem`) +- Describe the user action, not the implementation +- Internal methods: standard naming + +### Rule 6: Testing design + +- MUST be testable without UI +- MUST verify state transitions +- NO timing dependencies + +## Decision tree: code belongs in ViewModel? + +``` +Code belongs in ViewModel? +├─ UI state management? → YES +├─ Coordinating services? → YES +├─ Transforming for UI? → YES +├─ Business logic? → NO (Service) +├─ Data persistence? → NO (Repository) +└─ Complex computation? → NO (Domain Service) +``` + +## Patterns + +### Basic structure + +```swift +enum LoadingState: Equatable { + case idle + case loading + case loaded(T) + case failed(Error) + + static func == (lhs: LoadingState, rhs: LoadingState) -> Bool { + switch (lhs, rhs) { + case (.idle, .idle), (.loading, .loading): return true + case let (.loaded(l), .loaded(r)): return l == r + case let (.failed(l), .failed(r)): return l.localizedDescription == r.localizedDescription + default: return false + } + } +} + +@Observable @MainActor +final class TileListViewModel { + // State (always private(set)) + private(set) var state: LoadingState<[Tile]> = .idle + + // Computed (derive, don't duplicate) + var tiles: [Tile] { + if case .loaded(let tiles) = state { return tiles } + return [] + } + + var isLoading: Bool { + if case .loading = state { return true } + return false + } + + var error: Error? { + if case .failed(let error) = state { return error } + return nil + } + + // Injected dependencies + private let service: TileService + private var loadTask: Task? + + init(service: TileService) { + self.service = service + } + + deinit { loadTask?.cancel() } + + // User actions (on prefix) + func onAppeared() { + loadTask = Task { await loadTiles() } + } + + // Private implementation + private func loadTiles() async { + state = .loading + do { + let tiles = try await service.fetchTiles() + state = .loaded(tiles) + } catch { + state = .failed(error) + } + } +} +``` + +### State machine + +```swift +enum SessionState: Equatable { + case signedOut, signingIn, signedIn(User), failed(Error) +} + +@Observable @MainActor +final class SessionViewModel { + private(set) var state: SessionState = .signedOut + + private let sessionService: SessionService + + init(sessionService: SessionService) { + self.sessionService = sessionService + } + + var isAuthenticated: Bool { + if case .signedIn = state { true } else { false } + } + + func onSubmittedSignIn(email: String, password: String) async { + guard case .signedOut = state else { return } + state = .signingIn + + do { + let user = try await sessionService.signIn(email, password) + state = .signedIn(user) + } catch { + state = .failed(error) + } + } +} +``` + +### Search debounce + +```swift +@Observable @MainActor +final class SearchViewModel { + private(set) var searchText = "" + private(set) var results: [SearchResult] = [] + private var searchTask: Task? + + private let service: SearchService + + init(service: SearchService) { + self.service = service + } + + func onChangedSearchText(_ text: String) { + searchText = text + searchTask?.cancel() + + guard !text.isEmpty else { + results = [] + return + } + + searchTask = Task { + try? await Task.sleep(for: .milliseconds(300)) + guard !Task.isCancelled else { return } + + results = try await service.search(text) + } + } +} +``` + +## Anti-patterns + +### Business logic in ViewModel + +```swift +// Wrong: business logic in ViewModel +func computeLayout(for tile: Tile) -> Layout { + // Belongs in a service +} + +// Right: delegate to service +func onRequestedLayout(for tile: Tile) async -> Layout { + await layoutService.layout(for: tile) +} +``` + +### Public mutable state + +```swift +// Wrong: anyone can mutate +var tiles: [Tile] = [] + +// Right: private setter +private(set) var tiles: [Tile] = [] +``` + +### Scattered state + +```swift +// Wrong: scattered state makes invalid states possible +private(set) var tiles: [Tile] = [] +private(set) var isLoading = false +private(set) var error: Error? +// Can be: loading=true AND error != nil (invalid) + +// Right: single source of truth with enum +enum LoadingState: Equatable { + case idle, loading, loaded(T), failed(Error) +} +private(set) var state: LoadingState<[Tile]> = .idle +``` + +### View reference in ViewModel + +```swift +// Wrong: ViewModel knows about View +weak var view: MyView? + +// Right: ViewModel exposes state +private(set) var errorMessage: String? +``` + +## Testing + +```swift +@Test("Search updates correctly") +func searchUpdate() async { + let service = SearchService(search: { _ in [.init(id: "1")] }) + let vm = SearchViewModel(service: service) + + // Initial state + #expect(vm.results.isEmpty) + + // Trigger search + await vm.onChangedSearchText("query") + + // Wait for debounce + try? await Task.sleep(for: .milliseconds(400)) + + // Verify + #expect(vm.results.count == 1) +} +``` + +## Checklist + +- [ ] All state uses `private(set)` +- [ ] Dependencies injected through `init` +- [ ] Business logic in services +- [ ] User actions prefixed with `on` +- [ ] Tasks cancellable +- [ ] Errors transformed for UI +- [ ] No force unwrapping +- [ ] No view references +- [ ] Computed properties for derived state +- [ ] Task cleanup in deinit diff --git a/docs/rules-swift/validation-rules.md b/docs/rules-swift/validation-rules.md new file mode 100644 index 0000000..24b49fd --- /dev/null +++ b/docs/rules-swift/validation-rules.md @@ -0,0 +1,441 @@ +# Validation Rules (MANDATORY) + +When validating any parsed model (a config, a document, a manifest, a feature +model, any structured value), you **MUST** follow the **OpenAPIKit validation +idiom** by [Matt Polzin](https://github.com/mattpolzin/OpenAPIKit). This is the +companion to `parsing-rules.md`: parse first, validate second, and write the +validation layer the way OpenAPIKit does. This is not a suggestion. Validations +are composable values, never imperative if-trees. + +## Reference repository + +**https://github.com/mattpolzin/OpenAPIKit** (local mirror: +`PureYAMLResearch/References/OpenAPIKit`). Study `Sources/OpenAPIKit/Validator/`: +`Validation.swift`, `Validator.swift`, `Validator+Convenience.swift`, +`Validation+Builtins.swift`, `ReferenceValidations.swift`, and the tests under +`Tests/OpenAPIKitTests/Validator/`. Last re-analysed against upstream main +`1d42ea6477` (2026-06-11); when in doubt, the live source wins over this +summary. + +## The philosophy (the eleven principles you must honor) + +1. **Parse, then validate, as two passes.** The types make many illegal states + unrepresentable; the validator catches only what the type system cannot or + should not enforce. Validation runs over an already parsed, well typed value. +2. **Validations are composable values, not if-trees.** A validation is a plain + struct of closures (a description, a check, an apply-predicate). You combine + them with an algebra (`&&`, `||`, `all`, `lift`, `unwrap`, `lookup`). You + declare what correct looks like; you do not hand write traversal or + accumulation loops. +3. **The description states the CORRECT state, never the failure.** Phrase every + description positively ("Operations contain at least one response"). The + framework derives the negative message by prefixing `"Failed to satisfy: "`. + One source of truth, two readings. +4. **Errors carry a context path.** An error is a reason plus a location (the + coding path). The path is filled in by the traversal and rendered + consistently ("at root of document", "at path: .a.b['c']"). Failures are + locatable without manual bookkeeping. +5. **Application is predicate gated.** Every validation has a `when:` predicate + (default true) that runs before the check, with the full context, so a rule + scopes itself to a subset of values, a coding-path position, or a document + version without conditionals inside the check body. +6. **Dispatch is type directed.** A `Validation` applies only to values + of type `Subject`, anywhere they appear in the tree, because the erased + wrapper filters by runtime type. You pick the subject type; the framework + finds every instance. +7. **Default set plus custom extension.** Ship a curated default validator and a + blank one. Builders are fluent and chainable (`.validating(...)`, + `.withoutValidating(...)`), returning Self. Users compose policy from defaults + plus or minus their own rules. Builtins live as `public static var` members of + a dedicated caseless namespace enum (`BuiltinValidation`), referenceable by + KeyPath rooted at that namespace so they can be added or removed by identity, + and the identity key IS the description string (`withoutValidating` removes by + matching descriptions across every list), so descriptions must be unique and + stable. Where strictness comes in grades, model the grades as whole-set + replacement methods on the validator (OpenAPIKit's lenient reference checks + by default, `validatingAllReferencesFoundInComponents()` swapping in the + strict set, `skippingReferenceValidations()` clearing it), not as flags + threaded through individual rules. +8. **Single-error and multi-error forms.** The Bool form (frontload the + description, return false if invalid) is for a rule that fails one way and auto + formats the message. The array-of-errors form is for a rule that can fail in + several places at once (one error per missing variable, per undefined + reference), each with its own reason and path. Choose by whether a single value + can fail in more than one way. +9. **Two error channels with a strict switch.** Validation errors always throw; + parse warnings are separate and elevated to errors only under `strict: true` + (the default), otherwise returned for inspection. Keep "spec violation" and + "lossy but parseable" distinct. +10. **One framework, parameterized over the document.** When more than one layer + validates (an authoring linter over a hand-written manifest, a semantic + validator over a resolved model), do NOT copy the machinery per layer. + OpenAPIKit fixes the document type to `OpenAPI.Document`; you generalize it. + Factor the error type, `Validation`, the erased wrapper, the `Validator`, and + the combinators into one framework in the lowest layer, parameterized over both + the `Subject` and the `Document` (the cross-cutting context: a library, a scope, + or `Void` when a rule needs nothing beyond its subject). Each layer then defines + only its own document, its rules, and its traversal. Two copies of + `ValidationError` is the same duplication this rule exists to prevent, one level + up. + +11. **Each rule lives in exactly one authoritative layer; lower layers do not + re-implement it.** A rule belongs to the layer that holds the data it judges. A + *semantic* rule, one that needs a resolved model (referential closure, scope, + inheritance, readiness), lives in the semantic layer and nowhere else. A lower + layer, a syntax or source-structural validator, checks only what is meaningful at + its own level (an empty identifier, a duplicate authored name) and MUST NOT copy + the semantic rules down, even when it could approximate them. The reason is the + dependency arrow: if the semantic layer depends on the syntax layer (parse then + resolve), the syntax layer cannot call back into the semantic one without a cycle, + so the temptation is to re-derive the semantic rule in the lower layer, and that + is the duplication to refuse. The correct direction is the other way: the + authoritative (higher) layer *consumes* the lower one (it parses, then validates), + so the full "source to semantic verdict" path lives at the top, not split into two + drifting rule sets. When you find the same check in two layers, delete the copy in + the layer that does not own the data, and document the boundary on both sides. + +## The core types (build these, with these exact roles) + +- **`ValidationContext`**: the read-only bundle every check receives. + Carries `document` (the whole structure, for cross-cutting checks), `subject` + (the current value of the specialized type), and `codingPath` (where in the + tree). Adapt `document` to your root model name. +- **`Validation`**: the atomic value. Stores `description: String`, a + multi-error `validate: (ValidationContext) -> [ValidationError]`, and a + `predicate: (ValidationContext) -> Bool`. Has `apply(to:at:in:)` that + gates on the predicate then runs validate. Two inits: + - multi-error: `init(description: String? = nil, check: @escaping (Context) -> [ValidationError], when: @escaping (Context) -> Bool = { _ in true })`. + - single-error Bool: `init(description: String, check: @escaping (Context) -> Bool, when: ... = { _ in true })`, where false yields one error `"Failed to satisfy: \(description)"`. Description is REQUIRED here because it is the message. +- **`ValidationError`**: `reason: String` plus `codingPath: [CodingKey]`. Its + `description` strips a trailing period from the reason, renders an empty path as + `" at root of document"` and a non-empty path as + `" at path: "`. Match this formatting. +- **`ValidationErrorCollection`**: the one value thrown at the end, with `.values: + [ValidationError]`. Tests inspect `.values`. +- **`Validatable`**: a pure marker protocol constraining `Subject`. Conform your + model types (and the primitives/containers you traverse) to it. +- **`AnyValidation`** (type erasure, internal): wraps `Validation` so a + heterogeneous list lives together, and filters by runtime type. It MUST guard + against an optional matching its wrapped type (a `T?` must not satisfy a + `Validation`): cast with `as? T`, then also check `type(of: subject) == type(of: input)`. +- **`Validator`**: a `final class` (reference semantics; the fluent builders + mutate self and return it, `@discardableResult`). Holds the default rules in + TWO tiers (`nonReferenceDefaultValidations` + `referenceDefaultValidations`) + plus `customValidations`; the effective order is fixed and tested: + non-reference defaults, then reference defaults, then custom. Exposes `init()` + (defaults on), a `blank` with none, fluent `validating(...)` / + `withoutValidating(...)`, and a `validationDescriptions` accessor listing the + active rules. The builtin add/remove overloads take VARIADIC KeyPaths via + parameter packs (`validating(_ validations: repeat + KeyPath>)`), so one call can mix + builtins specialised on different subject types. +- **`BuiltinValidation`**: the caseless namespace enum carrying every shipped + rule as a `public static var ...: Validation`. Rule FAMILIES that + differ only by type are generated by an internal factory (OpenAPIKit's + `References.referencesAreValid(ofType:named:mustBeInternal:mustPointToComponents:)` + computes graded descriptions and checks per component type) rather than + copy-pasted per type. + +## How validation runs + +Traverse the parsed model and offer every value to every validation; the type +filter in the erased wrapper makes each `Validation` fire only on `T` values, +at every path where one occurs. OpenAPIKit does this by walking an `Encodable` +through a fake `Encoder` that, at each node, applies validations and extends the +coding path by the key or index. Dictionary keys are themselves validated: the +keyed container's child encoder recovers the original key value (`AnyCodingKey +.originalValue as? Validatable`) and offers it to the validations too. You may +reuse that Encoder trick when your model is `Encodable`, or write an explicit +recursive walk that builds the path and dispatches by subject type. Either way: +predicate gates before check, errors accumulate with their path, and the whole +run throws a single `ValidationErrorCollection` if non-empty. + +The warning channel rides the same traversal: types declare their parse-time +warnings via small protocols (`HasWarnings`, and `HasConditionalWarnings` for +warnings that depend on the whole document), the walker collects them at each +node and contextualises each with the coding path where it was found. The +entrypoint is `validate(using:strict:) -> [Warning]` (`@discardableResult`): +under `strict: true` (the default) warnings are converted into +`ValidationError`s and merged into the thrown collection; under `strict: false` +they are returned for inspection instead. + +## Authoring a validation (every surface) + +- **Multi-error init**: return your own `[ValidationError]`, one per problem, each + with a custom reason and `context.codingPath`. Use when a value can fail several + ways at once. +- **Single-error Bool init**: pass a positive `description` and a Bool check; + failure auto-produces `"Failed to satisfy: "`. The dominant form. +- **`.validating(...)` on the Validator**: add a rule inline without constructing + a `Validation` (closure form, Bool form, prebuilt form, and a KeyPath form that + adds named builtins). Mirror these overloads. +- **`lift(\.child, into: validationsOnChild...)`**: run child-typed validations + against a lifted value while keeping the parent's path and document. The key + compositional move. + +Real exemplar (single-error, KeyPath check): + +```swift +public static var operationsContainResponses: Validation { + .init( + description: "Operations contain at least one response", + check: \.responses.count > 0 + ) +} +``` + +Real exemplar (multi-error, one error per offender): + +```swift +public static var serverVariablesAreDefined: Validation { + .init( + description: "All server template variables are defined", + check: { context in + context.subject.urlTemplate.variables + .filter { !context.subject.variables.contains(key: $0) } + .map { name in + ValidationError( + reason: "Server Object does not define the variable '\(name)' ...", + at: context.codingPath + ) + } + } + ) +} +``` + +## The combinator algebra (the declarative core) + +Provide these as free functions returning closures, so they compose by value: + +- **`&&` / `||`** on error-array checks (`&&` concatenates both error lists; `||` + short-circuits, errors only if both fail) and on Bool predicates (the obvious + way). +- **`==`, `!=`, `>`, `>=`, `<`, `<=`** lifting a `KeyPath, U>` + or a `KeyPath` (rooted through `context.subject`) and a literal into a + predicate. The dual rooting lets an author write `\.subject.foo`, `\.document.bar`, + or `\SomeType.foo` interchangeably and thereby tell the type system which type the + validation specializes on. +- **`take(\.path) { value in Bool }`**: dig to a value and run arbitrary logic + beyond equality. +- **`lift(\.child, into:)`**: as above. +- **`unwrap(\.optional, into:description:)`**: unwrap an optional, error if nil, + else run child validations. +- **`lookup` / `unwrapAndLookup`**: resolve a reference against the document's + component store, error if not found, else validate the resolved value. +- **`all(validations...)`**: apply many `Validation` to the same context + (equivalent to `lift` with `\.self`). + +Usage reads declaratively: `check: \.responseOutcomes.count >= 1 && { $0.subject.responseOutcomes.allSatisfy { $0.status == 200 } }`, +or `check: take(\.tags) { tags in tags.map(\.name).count == tags?.count }`. + +One type-inference contract to honour between the two clauses: the subject type +must be pinned in at least one of `check:` / `when:`. When one clause only +touches `\.document` or `\.codingPath` (which say nothing about the subject), +the other clause must name the subject type, for example via a fully rooted +KeyPath (`take(\OpenAPI.Document.servers) { ... }`), so the compiler knows what +the validation specialises on. Both `take` and every comparison operator come +in two rootings for exactly this reason (`KeyPath` and +`KeyPath, U>`). + +## The test style (MANDATORY, copy it exactly) + +Tests mirror the philosophy. The recipe: + +1. **Build the subject** (the document / model), seeding the values under test. +2. **Build a validator**: `Validator.blank.validating(...)` to test ONE rule in + isolation, `Validator()` to test the default set, or `Validator().validating(...)` + for a custom rule on top of defaults. +3. **Run**: `try model.validate(using: validator)` (success tests simply run; a + throw fails the test). +4. **Assert failures** with `XCTAssertThrowsError`, cast to + `ValidationErrorCollection`, then check `.values.count`, each + `.values[i].reason` (including the `"Failed to satisfy: "` prefix), and + `.codingPath.map { $0.stringValue }`. For message rendering, assert + `String(describing: error)` equals `" at path: "`. +5. **Isolate the combinators**: test each operator/`lift`/`take` as a pure + function against a hand-built dummy context, asserting `.isEmpty` / `.count` of + the resulting closure, with no full traversal. Test the erased wrapper directly + (`apply(to:at:in:)`), including that an optional and a wrong type both yield no + errors, and that the same value of the same type twice yields two errors. + +Representative failure test to copy: + +```swift +func test_unconditionalServerCountCheckFails() { + let document = ... // seed two server arrays + let validator = Validator.blank + .validating("All server arrays have more than 1 server", check: \[Server].count > 1) + XCTAssertThrowsError(try document.validate(using: validator)) { error in + let error = error as? ValidationErrorCollection + XCTAssertEqual(error?.values.count, 1) + XCTAssertEqual(error?.values.first?.reason, "Failed to satisfy: All server arrays have more than 1 server") + XCTAssertEqual(error?.values.first?.codingPath.map { $0.stringValue }, ["paths", "/hello/world", "get", "servers"]) + } +} +``` + +(Use Swift Testing `#expect` where the project uses Swift Testing, but keep the +same shape: run the validator, assert the collection's count, each reason, each +path.) + +## Exhaustive testing (MANDATORY, the upstream bar) + +The recipe above applied to a SAMPLE of rules does not satisfy this rule. The +upstream suite is exhaustive in five specific ways, and a conforming repo +reproduces all five: + +1. **Every rule ships a failing AND a succeeding test**, the upstream + `test_X_fails` / `test_X_succeeds` pairing (BuiltinValidationTests covers + every builtin both ways). The succeeding fixture is a NEAR MISS, the boundary + the rule almost trips (the value at the exact limit, the predicate one step + from firing), not merely an unrelated clean document. A rule with only its + failing direction tested is untested: nothing proves it stays quiet on valid + input. +2. **A configuration-pin test** asserts the EXACT ordered + `validationDescriptions` list (and count) of every validator configuration: + `blank` is empty, each fluent variant lists precisely its rules, the full + default set is pinned description by description (upstream + `test_variousConfigurationsHaveExpectedValidationCounts`). Adding, removing, + or rewording a rule MUST fail a test before the change is deliberate. +3. **The machinery has its own negative tests** (upstream ValidationTests): + the erased wrapper applied to an Optional subject yields no errors; applied + to a wrong type yields no errors; a false `when` predicate yields no errors; + a conditional rule gets a dedicated does-not-run test + (`test_validationNeverRunsAndSucceeds`); and the same value of the same type + occurring twice yields two errors (the positive control proving the + negatives are not vacuous). +4. **Many-error documents assert the COMPLETE error list**: exact + `.values.count`, then every reason and every coding path, in traversal order + (upstream asserts all nine reference errors with all nine paths in one + test). `contains` on a single element hides regressions in every other + error and in the ordering. +5. **Coverage is itself a tested law.** Keep a registry of every finding the + default set can produce (stable codes, or the descriptions list where codes + do not exist) and a meta-test that one failing fixture and one near-miss + fixture exist per entry, asserting set equality and naming any gap in the + failure message. Plant a fake entry once to prove the meta-test fails, then + remove it. Upstream pins this via the exact descriptions list; a registry + plus meta-test is the equivalent for code-bearing findings. + +## Full validation coverage (MANDATORY) + +The exhaustive-testing section proves every *rule* is tested. This proves the +rule *set* is complete over the input model: no field goes unexamined. The +OpenAPIKit idiom validates the whole document structure, not a chosen subset; a +conforming repo does the same. + +1. **Every field is modeled or reported, never silently ignored.** For each + field or construct the input can carry, the parsed model either handles it (it + has a typed home and the validations that constrain it) or the validator emits + an explicit "unsupported" / "ignored, with reason" finding. A field that is + neither handled nor reported is a silent gap and a violation, even when the + output looks right. This is the `…FieldsAreModeledOrReported` discipline: layer, + transform, shape, style, mask, matte, and silent-risk fields each get a rule + that accounts for every key. +2. **Unknown and unexpected fields are detected, not dropped.** A rule asserts + that every key present is either known (modeled) or explicitly permitted + metadata, and reports an unrecognized key with its path. The parser must not + `try?`-swallow or otherwise discard a field the validator should have seen + (see `core/no-shortcuts-first-principles.md`: silent drop is forbidden). +3. **Behavior-affecting fields get priority and a silent-risk rule.** Any field + that can change output but is easy to miss (a compositing mode, a matte source, + a time remap) carries a dedicated rule so its presence is never accepted + unhandled. +4. **Coverage over the model is a tested law.** Keep a registry of every field or + construct the input model defines, and a meta-test asserting each maps to a + validation or an explicit unsupported/ignored classification. Adding a field to + the model without classifying it MUST fail that meta-test. This is the + input-side twin of the finding-coverage meta-test above: that one proves every + rule is exercised; this one proves every field has a rule. + +A validator can pass every test it ships and still be incomplete if a field of +the input was never given a rule. Full coverage closes that gap: the validation +set is complete with respect to the input model, and incompleteness is caught +mechanically, not by review. + +## Every public type is validated or excluded (MANDATORY) + +This is the part it is most important to stick to. Validation is not applied to a +chosen subset of types. It is applied to EVERY public type. A package's public +surface is the set of values other code can construct and hand in, and each one +is a place malformed or untrusted state can enter, so each public type is exactly +one of two things: + +- **validated**: it has at least one `Validation` that constrains it, or +- **excluded**: it is listed in a checked-in exclusions file with a one-line + reason (a pure data-transfer type with no invariants, or a type whose + invariants are already made unrepresentable by its initializer). + +There is no third option. A public type that is neither validated nor excluded +with a reason is a silent gap, and a silent gap is a violation even when every +shipped test passes. + +**Enforce it mechanically, per repo, not by review.** Ship a coverage gate (a +script run by the pre-commit hook and in CI) that: (1) enumerates every public +type in the package, (2) checks each against the validator's subject types and +the exclusions file, and (3) fails the build, naming the type, if any public type +is neither covered nor excluded. Adding a new public type without a validation or +an exclusion entry MUST fail the gate. That is what "stick to it" means: the +discipline is enforced by the build, every time, for every public type. + +**Prefer make-unrepresentable over validate.** The strongest coverage is deleting +the rule. If a type can carry its own invariant (a non-empty collection type, a +smart constructor that rejects the bad value at init), the bad state cannot be +constructed and needs no runtime validation. Excluding such a type because its +invariants are unrepresentable is the win, not a loophole. Reach for a +`Validation` only when the type system cannot or should not carry the invariant. + +## DO + +- Separate parsing from validation; validate an already typed value. +- Write each rule as a `Validation` value with a positive description. +- Build errors as reason plus context path; let the traversal fill the path. +- Use the single-error Bool form by default; the multi-error form when a value can + fail several ways at once. +- Gate scope with the `when:` predicate, not with `if` inside the check. +- Compose with the operator algebra (`&&`, `lift`, `take`, `unwrap`, `lookup`, + `all`) instead of nesting conditionals. +- Ship a default validator plus a blank one; make rules addable and removable by + identity. +- In tests, isolate one rule with `.blank.validating(...)`, assert the error + collection's count, reasons, and coding paths, and unit test the combinators as + pure functions. +- When a second layer needs validation, reuse one framework parameterized over the + document (principle 10); never copy the error type, `Validation`, or `Validator`. + +## DON'T + +- Do not fold validation into parsing. +- Do not write a monolithic `validate()` that appends to a mutable error array + through a tree of `if` statements. That is the anti-pattern this rule exists to + replace. +- Do not phrase descriptions as failures; phrase the correct state. +- Do not drop the location; every error carries its path. +- Do not let an optional satisfy a non-optional validation (guard the erasure). + +## Acceptance check + +A repo whose validation conforms to this rule satisfies all of: (1) validations +are declared as `Validation` values (grep finds the type, not a single +imperative `func validate()` accumulating into a `var errors`); (2) descriptions +read as the correct state, are unique (they are the removal identity), and +failures render `"Failed to satisfy: ..."`; (3) errors carry a coding path and +render `"... at path: ..."`; (4) a `Validator` with a default set and a `blank`, +plus fluent `validating` / `withoutValidating`; (5) tests build the subject, run +`validate(using:)`, and assert the thrown collection's `.values` count, reasons, +and coding paths, with the combinators unit tested as pure functions; (6) every +public entry point that consumes untrusted input gates on the validator before +doing work, and a non-throwing recovery accessor (a `validationOutcome` style +API returning valid-or-errors) exists beside the throwing gate for callers that +inspect rather than abort; (7) the test suite is exhaustive per the section +above: a failing plus near-miss succeeding pair for every rule, a +configuration-pin test over exact validation descriptions, machinery negatives +(optional, wrong type, false predicate, does-not-run), complete error-list +assertions on many-error documents, and a coverage meta-test (or descriptions +pin) that names any rule shipped without its fixtures; (8) the validation set is +complete over the input model per the full-coverage section: every field is +modeled or explicitly reported, unknown keys are detected, and a coverage +meta-test fails if any field of the model lacks a rule. A validator that is one +big if-tree fails this rule even if it produces correct results, and so does a +validator that quietly ignores fields the input can carry. diff --git a/scripts/check-style.sh b/scripts/check-style.sh index 99ef97f..194c237 100755 --- a/scripts/check-style.sh +++ b/scripts/check-style.sh @@ -24,6 +24,11 @@ is_enforcement_file() { is_vendored_fixture() { case "$1" in Tests/Fixtures/acme/*|Tests/Fixtures/real-yaml/*) return 0 ;; + # Vendored upstream rules canon (mirror of the rules-swift repo). Its own + # enforcement docs legitimately quote the forbidden attribution phrases, so + # the whole vendored tree is excluded from the style scan, exactly like the + # repo's own enforcement files. Provenance: docs/rules-swift/PROVENANCE.md. + docs/rules-swift/*) return 0 ;; *) return 1 ;; esac }