diff --git a/.github/workflows/rules-swift.yml b/.github/workflows/rules-swift.yml new file mode 100644 index 0000000..f8f5989 --- /dev/null +++ b/.github/workflows/rules-swift.yml @@ -0,0 +1,27 @@ +name: rules-swift gate + +# Runs the codified rules-swift gates on every pull request. The corpus-pin +# integrity check must stay green (it fails if the vendored canon drifts from its +# pinned commit). The full strict gate runs in report mode (continue-on-error) +# because it is intentionally red on a repo's pre-existing gaps, recorded in +# docs/rules-swift-audit.md; flip it to required (remove continue-on-error, or add +# branch protection) once the repo reaches conformance. +on: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + rules-swift: + runs-on: macos-14 + steps: + - uses: actions/checkout@v4 + + - name: Corpus pin integrity (required) + run: sh scripts/check-rules-corpus-coverage.sh + + - name: Full rules gate (reports pre-existing gaps; non-blocking) + run: sh scripts/check-rules-swift.sh + continue-on-error: true diff --git a/AGENTS.md b/AGENTS.md index af05234..48cf1de 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,30 +8,42 @@ Guidance for anyone writing code in PureXML. ## Rule Loading -At the start of a session, read this file and the rules under `docs/rules/` that -match the task. Confirm by replying with `rules-loaded` and name the files you -loaded. +At the start of a session, read this file and the applicable rules under +`third_party/rules-swift/` for the area being touched. Confirm by replying with +`rules-loaded` and name the files you loaded. The canonical upstream Swift rule set is vendored at -`docs/rules/rules-swift/` and mirrored from -`/Volumes/Code/DeveloperExt/public/rules-swift`. Use it when a local -`docs/rules/` copy is missing or has drifted, but keep the PureXML-specific -constraints in this file and `docs/rules/README.md` as stronger project -overrides. +`third_party/rules-swift/`, a pinned snapshot recorded in +`docs/rules-swift-corpus.sha256` and audited in `docs/rules-swift-audit.md`. +That checkout is the canon this repo is measured against; keep the +PureXML-specific constraints in this file as stronger project overrides. For code changes, load at minimum: -- `docs/rules/code-style.md` -- `docs/rules/namespacing.md` -- `docs/rules/cross-platform.md` -- `docs/rules/testing.md` -- `docs/rules/verification.md` -- `docs/rules/commits.md` -- `docs/rules/research-first.md` (when stuck: read the research repo and the literature before guessing) -- `docs/rules/rules-swift/core/no-shortcuts-first-principles.md` -- `docs/rules/rules-swift/core/proof-discipline.md` (when making correctness or conformance claims) -- `docs/rules/rules-swift/parsing-rules.md` (when changing parser behavior) -- `docs/rules/rules-swift/validation-rules.md` (when changing validation behavior) +- `third_party/rules-swift/code-style.md` +- `third_party/rules-swift/namespacing.md` +- `third_party/rules-swift/cross-platform.md` +- `third_party/rules-swift/testing.md` +- `third_party/rules-swift/core/verification.md` +- `third_party/rules-swift/core/commits.md` +- `docs/research-first.md` (when stuck: read the research repo and the literature before guessing) +- `third_party/rules-swift/core/no-shortcuts-first-principles.md` +- `third_party/rules-swift/core/proof-discipline.md` (when making correctness or conformance claims) +- `third_party/rules-swift/parsing-rules.md` (when changing parser behavior) +- `third_party/rules-swift/validation-rules.md` (when changing validation behavior) + +Run the local gates before hand-off: + +```sh +scripts/check-rules-swift.sh +scripts/check-local-gate.sh +``` + +Install hooks once per clone: + +```sh +scripts/install-hooks.sh +``` ## What PureXML Is diff --git a/CLAUDE.md b/CLAUDE.md index 7e9e426..1eef5c6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,12 +18,12 @@ Non-negotiables: ## Read First - `AGENTS.md` -- `docs/rules/code-style.md` -- `docs/rules/namespacing.md` -- `docs/rules/cross-platform.md` -- `docs/rules/testing.md` -- `docs/rules/verification.md` -- `docs/rules/research-first.md` (when stuck: read `PureXML-research` sources and the literature before guessing; clean-room only) +- `third_party/rules-swift/code-style.md` +- `third_party/rules-swift/namespacing.md` +- `third_party/rules-swift/cross-platform.md` +- `third_party/rules-swift/testing.md` +- `third_party/rules-swift/core/verification.md` +- `docs/research-first.md` (when stuck: read `PureXML-research` sources and the literature before guessing; clean-room only) - `docs/production-readiness.md` (the bar for shipping this in an IDE; read every conformance iteration) Confirm rule loading with `rules-loaded`. diff --git a/docs/rules/engineering.md b/docs/engineering.md similarity index 100% rename from docs/rules/engineering.md rename to docs/engineering.md diff --git a/docs/rules/mermaid.md b/docs/mermaid.md similarity index 100% rename from docs/rules/mermaid.md rename to docs/mermaid.md diff --git a/docs/rules/research-first.md b/docs/research-first.md similarity index 100% rename from docs/rules/research-first.md rename to docs/research-first.md diff --git a/docs/rules-swift-audit.md b/docs/rules-swift-audit.md new file mode 100644 index 0000000..787eacc --- /dev/null +++ b/docs/rules-swift-audit.md @@ -0,0 +1,155 @@ +# rules-swift Audit + +Date: 2026-06-30 + +This audit checks PureXML against the Swift rule set at +. It separates mechanical proof from +architecture findings. Fill every row before claiming conformance. A row left at +`Gap` or `Partial` keeps `scripts/check-rules-swift.sh` red, which is the +intended behavior until the repo actually conforms. + +## Current Verdict + +| Area | Status | Evidence | +|---|---|---| +| Codified mechanical rules | Pass | `scripts/check-rules-swift.sh` wires leak, corpus-coverage, namespacing, validation-coverage, target pairing, and anti-pattern greps | +| Local mechanical gates | Pass | `scripts/check-local-gate.sh` runs the rules gate, style gate, build, and tests | +| Hook installation | Pass | `.githooks/commit-msg` and `.githooks/pre-commit` present; `scripts/install-hooks.sh` verifies rejection | +| Formatting and linting | Pass | `.swiftformat` and `.swiftlint.yml` committed; SwiftFormat runs before SwiftLint | +| Tests | Pass | `Tests/` and `PublicAPITests/` carry the parser, emitter, schema, and conformance suites | +| Validation coverage gate | Pass | `scripts/check-validation-coverage.sh` over `docs/validation-coverage-registry.txt` | +| Public CI backstop | Pass | `.github/workflows/rules-swift.yml` plus existing style and per-OS build workflows | +| Pre-UI layer | Not applicable | Headless library; no UI target | +| DocC documentation | Gap | DocC coverage not yet audited against `documentation.md` | +| Multi-renderer UI proof | Not applicable | No UI or renderer layer in this package | + +## Public Corpus Coverage + +Rules corpus last read: 2026-06-30 (pinned snapshot `97dc70f`). + +Rules corpus file count: 68. + + + +The local gate verifies that every Markdown or shell file in the public +`rules-swift` checkout selected by `RULES_SWIFT_DIR` is named in this section and +that the pinned snapshot digest matches `docs/rules-swift-corpus.sha256`. That +does not mean every rule is fully satisfied; it means no public rule source can +be silently omitted. + +| Public rule source | Coverage | +|---|---| +| `.github/pull_request_template.md` | Vendored, pinned | +| `business-rules-and-constraints.md` | Vendored, pinned | +| `CHANGELOG.md` | Vendored, pinned | +| `code-style.md` | Vendored, pinned | +| `concurrency.md` | Vendored, pinned | +| `CONTRIBUTING.md` | Vendored, pinned | +| `CONVENTIONS.md` | Vendored, pinned | +| `core/brainstorming.md` | Vendored, pinned | +| `core/commits.md` | Vendored, pinned | +| `core/file-naming.md` | Vendored, pinned | +| `core/first-principles-analysis.md` | Vendored, pinned | +| `core/folder-grouping.md` | Vendored, pinned | +| `core/git-discipline.md` | Vendored, pinned | +| `core/no-shortcuts-first-principles.md` | Vendored, pinned | +| `core/oracle-first.md` | Vendored, pinned | +| `core/proof-discipline.md` | Vendored, pinned | +| `core/README.md` | Vendored, pinned | +| `core/round-trip-transformation.md` | Vendored, pinned | +| `core/rules.md` | Vendored, pinned | +| `core/self-improve.md` | Vendored, pinned | +| `core/systematic-debugging.md` | Vendored, pinned | +| `core/testing-discipline.md` | Vendored, pinned | +| `core/verification.md` | Vendored, pinned | +| `core/writing-plans.md` | Vendored, pinned | +| `cross-platform.md` | Vendored, pinned | +| `dependency-injection.md` | Vendored, pinned | +| `documentation-search.md` | Vendored, pinned | +| `documentation.md` | Vendored, pinned | +| `domain-first.md` | Vendored, pinned | +| `exp/app-target.md` | Vendored, pinned | +| `exp/build-performance.md` | Vendored, pinned | +| `exp/common-mistakes.md` | Vendored, pinned | +| `exp/critical-rules.md` | Vendored, pinned | +| `exp/dependency-management.md` | Vendored, pinned | +| `exp/implementation-patterns.md` | Vendored, pinned | +| `exp/migration.md` | Vendored, pinned | +| `exp/package-swift.md` | Vendored, pinned | +| `exp/README.md` | Vendored, pinned | +| `exp/testing.md` | Vendored, pinned | +| `exp/verification.md` | Vendored, pinned | +| `exp/when-to-create.md` | Vendored, pinned | +| `formatting-and-linting.md` | Vendored, pinned | +| `framework-policy.md` | Vendored, pinned | +| `linux-server.md` | Vendored, pinned | +| `namespacing.md` | Vendored, pinned | +| `openapi-generated.md` | Vendored, pinned | +| `package-architecture.md` | Vendored, pinned | +| `package-import-contract.md` | Vendored, pinned | +| `package-structure.md` | Vendored, pinned | +| `parsing-rules.md` | Vendored, pinned | +| `README.md` | Vendored, pinned | +| `scripts/check-namespacing.sh` | Vendored, pinned | +| `scripts/leak-gate.sh` | Vendored, pinned | +| `shared-protocols.md` | Vendored, pinned | +| `testing.md` | Vendored, pinned | +| `ui/appkit-views.md` | Vendored, pinned | +| `ui/colors.md` | Vendored, pinned | +| `ui/components.md` | Vendored, pinned | +| `ui/flowspec.md` | Vendored, pinned | +| `ui/fonts.md` | Vendored, pinned | +| `ui/native-per-platform.md` | Vendored, pinned | +| `ui/pom.md` | Vendored, pinned | +| `ui/pre-ui-layer.md` | Vendored, pinned | +| `ui/swiftui-views.md` | Vendored, pinned | +| `ui/three-renderers.md` | Vendored, pinned | +| `ui/uikit-views.md` | Vendored, pinned | +| `ui/view-models.md` | Vendored, pinned | +| `validation-rules.md` | Vendored, pinned | + +## Rule Matrix + + + +| Rule file | Applies | Status | Evidence or gap | +|---|---:|---|---| +| `core/no-shortcuts-first-principles.md` | yes | Pass | `scripts/check-rules-swift.sh` greps `Sources/` and `Tests/` for shortcut patterns | +| `namespacing.md` | yes | Pass | `scripts/check-namespacing.sh`: one public type per file under `PureXML` | +| `package-architecture.md` | yes | Pass | target/test-target pairing enforced in `scripts/check-rules-swift.sh` | +| `package-structure.md` | yes | Pass | root `Package.swift`, root `Sources/`, root `Tests/` | +| `cross-platform.md` | yes | Pass | macOS, Linux, Windows, WASI build workflows under `.github/workflows/` | +| `validation-rules.md` | yes | Pass | `scripts/check-validation-coverage.sh` over the coverage registry | +| `framework-policy.md` | yes | Pass | banned-stack octal scan in `scripts/check-rules-swift.sh` | +| `testing.md` | yes | Pass | parser, emitter, schema, and W3C conformance suites under `Tests/` | +| `documentation.md` | yes | Gap | DocC coverage not yet audited | +| `ui/*` | no | Not applicable | headless library, no UI layer | +| `exp/package-swift.md` | no | Not applicable | single-target package, no exp closure manifest | + +## Required Remediation + +1. Audit DocC coverage against `documentation.md` and resolve the `documentation` + row. +2. The public leak gate (`scripts/leak-gate.sh`) flags the repo's pre-existing + Antigravity onboarding content (`GEMINI.md`, `docs/onboarding-antigravity.md`, + and the Antigravity references in `AGENTS.md`). These are owner-managed and + out of scope for this additive adoption; resolve with the maintainer whether + to whitelist them in the gate or relocate the content. +3. Repoint the two remaining `docs/rules/` links in + `docs/onboarding-antigravity.md` (owner-managed) to `third_party/rules-swift/` + and `docs/research-first.md`. + +Until those items are complete, state the honest status: PureXML has strict rule +enforcement wired in, and that enforcement correctly fails while the repo does +not yet fully conform to the entire `rules-swift` corpus. diff --git a/docs/rules-swift-corpus.sha256 b/docs/rules-swift-corpus.sha256 new file mode 100644 index 0000000..68c9e94 --- /dev/null +++ b/docs/rules-swift-corpus.sha256 @@ -0,0 +1,68 @@ +13ffe73c313954b379e91f15746f4f7c1b621f062710a2d64c8b3fd6d3ed8aeb .github/pull_request_template.md +2f2b00ede2a0a0b8ea3ae5d40528b9ddb65f9855e676d2516f6782b413125cbe business-rules-and-constraints.md +4d45adc8beff3e6ac00231f43b523ada84f8d9d992db4b27f5011fe3d32ca993 CHANGELOG.md +a0fa0338cbf60c819c0643dcf81ac36445497e28d964b22b943bf68fb3f44c10 code-style.md +2a76443b5671254deb77d715e7903e26dc2e0cb723bf7d4092c5678787d7cdf9 concurrency.md +16e0b09c64b19aff717eaa49ee0a22393d7b35d54829ec8ff9fa618c51efe9fb CONTRIBUTING.md +4596e07dcaa390a213d9e5e56c25c28181aaccc0afa3d814fc8d4c5935ffc931 CONVENTIONS.md +f9049c8df726b33b6adf01724a8f13a5af2c2d9cef456dd7d742c3f5937f22f5 core/brainstorming.md +de956b34d58a19b6852bf916ee528b43756169ade1b76478c4fd0b364e1a806a core/commits.md +d0bc7467ec6674d47ef1dd59b6ac62b9ba2a3f5eaaf4c5e452f814ea4192acd6 core/file-naming.md +9a58f0cb17d3a566fa27c8466a9e440950d2884c9d35e5df4cffd992009b1e24 core/first-principles-analysis.md +91cb75a203d84ef9aa767979a7196395f9f3bbc9f20224a85b28c1ad849f954f core/folder-grouping.md +71dcb7a8df43a9cf4e801efe015a6c4adb71023ee0bb0badbecee199497745af core/git-discipline.md +c5062717b1bbfd9f8e6e363526d4e77ec313a968453aaa2cae099ce828e1349b core/no-shortcuts-first-principles.md +48315f7839d1747e932c6a106b5859f0aab2a095fba1fb08640ee614a7706c0a core/oracle-first.md +7319934dd32b6a0c6744a980c1d95267ef1996ad8d8a4e8a973664033d497eb3 core/proof-discipline.md +632c3fabcd46a2ff789e841d9112b06c5a3de1ca2154fbeaa797042750e62d0a core/README.md +6b76e9bfa52169190da54224f9a2018fa00ba34f8dee75fa1c718010de434f06 core/round-trip-transformation.md +98a85b72d52bb179ed0f9712dd9131114be8a8550e62dedcf9715e4bb6eac61f core/rules.md +7237ffe85b06b210f6a7d6154496958ba78906169132be9e63574f627284c529 core/self-improve.md +e7e4616c100f864a8bf65f371f3e90359835eac60b952e44dbde5016f5c7d1ff core/systematic-debugging.md +dfbd528fdf9ad898149c44731bc6f871cfea34e358e60d503a5b88addccd1284 core/testing-discipline.md +5444efca6c70596c7dde95106ffcdd4246872734f0a5cfe11ffba464db8d02a7 core/verification.md +f8ee59a8313278e0507f562965d6e71a4a11aa744eade89103c23d5bdac15f02 core/writing-plans.md +ca424e5cf141c82ea86be9a4c07275ff84f7408c581aa702f354cdb11754d6a7 cross-platform.md +d6cb6a5d66b49e4da46b8f4eece8ef93abe4758d733e18ae058cd27eda714b07 dependency-injection.md +8bfc0e52532d86779689d079822d88648727f2f3f667ab5a183a669f876de31e documentation-search.md +efa2cabce23f0ed77875cc5bbca61df5b1426a6835599b6b34167103ce738085 documentation.md +75bef7008f232274d06cadd1a09d6493243857737e879829b498f25453eac50b domain-first.md +4b7ece49fac6eac1cce269615eddfc64e3aa05f14deac821aeef86b244870f25 exp/app-target.md +ffe5f8830b24e2547a6e843a1b1da928d2cb484dd4f31d35a131609a3ccc42a4 exp/build-performance.md +ee5572c1d50d0d977374352d7dbfb1f09b55ec2e57bd3712443553f4444e455e exp/common-mistakes.md +dffe98c4b476542c1fa2abdf37e3fe6898ef86d4aa782fc3b961b1166cb85360 exp/critical-rules.md +2ec3b9f0bd9013b12aa77e4cfdbb5e058d6a900488ebcb28b231a74aa900c7b4 exp/dependency-management.md +18bb0b25733540837c10b57d2de59212664a595fc8b6811a6b8cb2aa3edf5864 exp/implementation-patterns.md +f1f02a9d94d0ae2ae935288a0ffa0226d28f338ef0491efdaf8420262199a47d exp/migration.md +52302f2f5791d38f7e8370bb72a711c17684f6a917b99011b1198e2d0af76810 exp/package-swift.md +1e8d08a75ff7f1669866bfbd7faa11c955c5ae5625f0af536a68ba8b3b06c679 exp/README.md +6854b78671b25ad8bcac568f5021be4b712b9503843eb44adf8952e388ce4ddf exp/testing.md +7dec307eb969dcbac7c0c28ea7a9d57f11353f676a0422a4898660be2cefc0ad exp/verification.md +f7f182deb386f03e955f7bdeb3ea7dd583e0b1c1a482267d0432801bc89595ec exp/when-to-create.md +8c9bb26f56cb849c6164778ae09df434ba5ea71b35192b62fe2df4f72e8317f9 formatting-and-linting.md +e0579e48bec9fc1670efa0f596c2458207b19b701b6bfd725b8647a30090863a framework-policy.md +25eb598a7c75cd2631dd72cb81afe98cacab3f844ca7f14d38f9eb0abd5d2c43 linux-server.md +f6459775422fda6d9359e9914b88a08af7673e1e02ce0c970dd97a01660d60a0 namespacing.md +4f9db39aa365e26f2a18c54229e9abcd53fef6153566510453f4a2dcebf07f7d openapi-generated.md +685999f2b40c2edffc348103fdbddf022cc3fbb8eb11fc3530945c75514d8c0b package-architecture.md +4a287a84c891f82827d1923fae946fcdaba8feae4d31c0f698fad0e73ac82c1a package-import-contract.md +15173db2a547db6e4db53a4c0abe4eee259a0c4cc11ef609bc9acff89993d9cb package-structure.md +7b32c5386455160991779162917a51ae4f7a62765f05dd812f36bcd2b5d6ee19 parsing-rules.md +91d16b50114366f98e9ceb40be51176d1ee85485f5bd88e0ba454798345ba418 README.md +a4e1973f58b815099c2acd28c673d00df5a93ce5cfc935f2b2ebaf150b00db43 scripts/check-namespacing.sh +c77663d648a4d7fc67012e009a36ce9d16fb0f4b4f5cc5a251f6e9433947177b scripts/leak-gate.sh +7d06771b0d9be1d5d2a73bec1d50a6d94be2799e8046be5645af638155d3edee shared-protocols.md +62a3886ca75e4dd8aabec389b1c56317e5fff1ebde58f1b65bbbf6cfe6e09bcd testing.md +99038cc15eeb9988a31ee4478bd83c804e7dedc99a7eb22686508e2b0ec312ea ui/appkit-views.md +91dceefc09e8082b09f891ff8675728a92cb34a178f105644e7052a4c3329671 ui/colors.md +ca2d5d6a70eef00fefa56acbb7ba8df75d5bab7d6dd44a1bae85f89adc482827 ui/components.md +6545df89b59a22be605cabf1e0e0bef97dc30331baabee7779ac8d5f2e1bc496 ui/flowspec.md +fafddaa4d87e8f92e430e5aa494bbb049c50f6834a1024bc8cf52faf257605f4 ui/fonts.md +bb2442a17edcc39404388f4f2202a146f3e396dcca8da437621518db639d0fc0 ui/native-per-platform.md +755d1fc079aab90dede44e45ca9aff7bcbf60e9daef1dc0487e0fe1f1bf27108 ui/pom.md +bd3eefcede20de3098b4acb781b53122e185faa129553f394d5cb7504f4a34b0 ui/pre-ui-layer.md +a29bd9531f6aac15f5e56633abf09aa057ce0a45908b20063b83cc5a1ca854c3 ui/swiftui-views.md +7c83f83e0e5672f877984a3637e88a2cf82f8e8019202676ce64a78b74b95208 ui/three-renderers.md +20f5610e20dff5ff1833ceab06186d31db915eeee43f1d6393e70fbab4d72041 ui/uikit-views.md +edc82febe0138dad789541d3b05f9ef16da5dc907479304b8cc8b900ac1e7fd1 ui/view-models.md +35d3ac448344544b8b73b6c0be81e7d5afdce1220122127a7b2cb9547ae5b19d validation-rules.md diff --git a/docs/rules/README.md b/docs/rules/README.md deleted file mode 100644 index 0eab173..0000000 --- a/docs/rules/README.md +++ /dev/null @@ -1,38 +0,0 @@ -# PureXML Rules - -Load these rules before changing code: - -- `code-style.md` -- `namespacing.md` -- `cross-platform.md` -- `testing.md` -- `verification.md` -- `commits.md` -- `research-first.md` - -Project-specific overrides: - -- PureXML uses a root `Package.swift`. -- Sources live directly under `Sources`. -- Tests live in `Tests` (test target `PureXMLTests`). -- `Package.swift` must keep `dependencies: []`. -- Public API must live under the `PureXML` namespace tree. -- The package must build on macOS, Linux, Windows, and WASI. -- Validation coverage gates: `docs/validation-coverage-registry.txt` (public types), - `docs/validation-field-registry.txt` (document/subject fields), enforced by - `scripts/check-validation-coverage.sh` and `scripts/check-validation-fields.sh`. - -The broader rule files are retained for detailed guidance. When they include -generic examples, apply the PureXML-specific overrides above. - -Canonical Swift rules are vendored at `docs/rules/rules-swift/` and mirrored -from `/Volumes/Code/DeveloperExt/public/rules-swift` (especially -`validation-rules.md`, `parsing-rules.md`, and the `core/` spine). Prefer that -vendored source when root `docs/rules/` copies are missing or have drifted, but -do not let its generic Tiledown examples weaken PureXML's root-package, -dependency-free, Windows, and WASI requirements. PureXML's committed -`.swiftlint.yml` and `.swiftformat` are the coherent project tuning of that -canon: they adopt its opt-in rule set and shared formatter directives, keep -stricter safety (`force_unwrapping`/`force_try`/`force_cast` = error), and -resolve the canon's `--commas always` vs default-`trailing_comma` mismatch by -mandating trailing commas in both tools. diff --git a/docs/rules/code-style.md b/docs/rules/code-style.md deleted file mode 100644 index f3cfabe..0000000 --- a/docs/rules/code-style.md +++ /dev/null @@ -1,362 +0,0 @@ -# Code style: namespacing and structure - -The operational namespacing discipline for PureXML: 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 PureXML-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 -PureXML.Parser.Model.Token // from PureXMLCore -PureXML.Generator.Command // from PureXMLCLI -PureXML.Parser.Scanning.Scanner // from PureXMLTools -``` - -Targets MUST NOT redefine conflicting namespace trees, and the root namespace meaning MUST be identical across all modules. - -## Root + sub-namespace pattern (PureXML 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 PureXML types across all modules -public enum PureXML { - /// 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. `PureXML.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 PureXML.Parser.Model { - /// Represents a parsed piece of tile content - public enum Token: Equatable, Sendable { - case text(String) - case variable(VariableToken) - } -} - -extension PureXML.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.: - - `PureXML.Model` - - `PureXML.Parser.Model` - - `PureXML.Parser.Metadata` - - `PureXML.Generator` -- Conformances (`Codable`, `Sendable`, `CustomStringConvertible`, etc.) MAY be separate extensions - -### File layout for PureXML-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 -PureXML/ - PureXML.swift // namespace anchor: public enum PureXML {} - Parser/Model/Token.swift // extension PureXML.Parser.Model { public enum Token {} } - Parser/Scanning/Scanner.swift // extension PureXML.Parser.Scanning { public struct Scanner {} } - Generator/Writer.swift // extension PureXML.Generator { public struct Writer {} } -``` - -**`..swift` approach**: flat folder, file name encodes the namespace path with dots: - -```text -PureXML.swift // namespace anchor: public enum PureXML {} -PureXML.Parser.Model.Token.swift // extension PureXML.Parser.Model { public enum Token {} } -PureXML.Parser.Metadata.Header.swift // extension PureXML.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**: - -- `PureXML.swift` declaring `public enum PureXML { /* 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 `PureXML` namespace umbrella: - -```text -Sources/PureXML/ -├── PureXML.swift // namespace anchor: extension PureXML { ... } <-- AT ROOT, not Core/PureXML.swift -├── Client/ // PureXMLClient SPM target -├── Core/ // PureXMLCore SPM target (path includes ../PureXML.swift) -│ ├── Protocol/ -│ ├── Server/ -│ └── Transport/ -├── SharedTools/ // PureXMLSharedTools SPM target -└── Support/ // PureXMLSupport SPM target -``` - -The PureXMLCore target's `Package.swift` entry uses `path: "Sources/PureXML"` with `exclude: ["Client", "SharedTools", "Support"]` so it picks up the anchor file plus the `Core/` subtree. Every sibling target (Client, SharedTools, Support) depends on PureXMLCore in production, so the `PureXML` namespace anchor reaches them through that dep. - -Why this matters: - -- Reading the file listing immediately tells you "PureXML is the umbrella here": the anchor sits visually at the top. -- Moving an anchor into `Core/PureXML.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 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/commits.md b/docs/rules/commits.md deleted file mode 100644 index 150804c..0000000 --- a/docs/rules/commits.md +++ /dev/null @@ -1,381 +0,0 @@ -# Git Commit Framework - -The commit-message format for PureXML: Conventional Commits, imperative mood, one logical change per commit. - -Create meaningful, consistent git commits following the Conventional Commits specification. Every commit must clearly communicate the what, why, and impact of changes for collaboration and automated tooling. - -## Core rules - -### Rule 1: Commit format - -Use this exact format: -``` -(): - -[optional body] - -[optional footer(s)] -``` -- MUST use lowercase for type -- MUST use present tense, imperative mood -- MUST NOT end description with period -- MUST limit 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 type/scope for breaking changes -- MUST include `BREAKING CHANGE:` in footer -- MUST explain migration path - -### Rule 4: Commit scope - -Include relevant scope: -- Use component/module name -- Use feature area -- Keep consistent across project -- Omit only if 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 - -```bash -# RULE: New features require clear user benefit -feat(auth): add OAuth2 authentication support - -# RULE: Include implementation details in body -feat(api): implement rate limiting for API endpoints - -Adds configurable rate limiting using Redis: -- 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 - -```bash -# RULE: Describe what was broken and now works -fix(parser): handle empty input without crashing - -# RULE: Reference issue numbers -fix(ui): correct button alignment on mobile devices - -The submit button was overlapping with the cancel button -on screens smaller than 375px. Added proper flex spacing. - -Fixes #456 -``` - -### Pattern 3: Breaking change commits - -```bash -# RULE: Use ! and BREAKING CHANGE footer -feat(api)!: change authentication from cookies to JWT tokens - -BREAKING CHANGE: API now requires Bearer token authentication. -Cookie-based auth has been removed. Update clients to send -Authorization header with JWT token. - -Migration guide: https://docs.example.com/migration/v2 -``` - -### Pattern 4: Refactoring commits - -```bash -# RULE: Explain why, not just what -refactor(database): extract query builder into separate class - -Improves testability and reduces coupling between -repository and database layers. No functional changes. - -# RULE: Keep behavior identical -refactor(utils): use native Array methods instead of lodash - -Removes lodash dependency for array operations. -All tests pass without modification. -``` - -### Pattern 5: Documentation commits - -```bash -# RULE: Be specific about what was documented -docs(readme): add installation instructions for Windows - -# RULE: Include scope for API docs -docs(api): document rate limiting headers and status codes -``` - -### Pattern 6: Performance commits - -```bash -# RULE: Include metrics when possible -perf(search): optimize full-text search query - -Reduces search time from ~500ms to ~50ms by adding -compound index on (title, content, created_at). - -Benchmark results: -- Before: 487ms avg (n=1000) -- After: 52ms avg (n=1000) -``` - -## Commit scope guidelines - -### Scope selection strategy - -```markdown -# RULE: Use consistent, meaningful scopes -Common scope patterns: -- Component name: (Button), (Modal), (Form) -- Feature area: (auth), (payment), (search) -- Layer: (api), (db), (ui), (service) -- File type: (config), (types), (tests) - -# RULE: Omit scope only for truly global changes -Examples when to omit: -- Project-wide dependency updates -- Global configuration changes -- Cross-cutting refactors -``` - -### Multi-file changes - -```bash -# RULE: Use most specific common scope -# Changed: auth/login.ts, auth/logout.ts, auth/session.ts -feat(auth): add session timeout handling - -# RULE: Use general scope for many areas -# Changed: multiple unrelated files -refactor: update imports to use path aliases -``` - -## Commit body guidelines - -### When to include a body - -```markdown -Include body when: -- Change requires explanation -- Multiple issues addressed -- Breaking changes introduced -- Performance metrics available -- Complex implementation details -- External references needed -``` - -### Body format rules - -```bash -# RULE: Wrap at 72 characters -# RULE: Separate body from subject with blank line -# RULE: Use bullet points for multiple items - -fix(cache): prevent memory leak in LRU cache implementation - -- Add proper cleanup in cache eviction -- Implement WeakMap for object references -- Add memory limit configuration option - -The previous implementation held strong references to evicted -items, preventing garbage collection. This change ensures -proper memory management while maintaining O(1) operations. - -Fixes #789 -``` - -## Commit footer patterns - -### Issue references - -```bash -# RULE: Use correct keywords -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 - -```bash -# RULE: Credit all human contributors -feat(ui): implement dark mode toggle - -Pair programmed the CSS architecture and state management. - -Co-authored-by: Jane Doe -Co-authored-by: Bob Smith -``` - -### Review references - -```bash -# RULE: Link to code review discussions -refactor(engine): simplify state machine logic - -See detailed discussion in PR #234 about the -approach and alternatives considered. - -Reviewed-by: Alice Johnson -``` - -## Commit validation checklist - -Before committing, verify: - -- [ ] Type is correct (feat/fix/docs/etc.) -- [ ] Scope reflects affected area -- [ ] Description in imperative mood -- [ ] Description under 72 characters -- [ ] No period at end of description -- [ ] Breaking changes marked with ! -- [ ] BREAKING CHANGE footer if needed -- [ ] Body explains why (not just what) -- [ ] Body wrapped at 72 characters -- [ ] Issue references use correct keywords -- [ ] Co-authors credited properly -- [ ] Commit is atomic (one logical change) -- [ ] All tests pass -- [ ] No debug code or stray logging - -## Common mistakes to avoid - -### DON'T: Use past tense -```bash -# WRONG -feat(auth): added login functionality - -# RIGHT -feat(auth): add login functionality -``` - -### DON'T: Be vague -```bash -# WRONG -fix: fix bug -chore: update stuff -refactor: changes - -# RIGHT -fix(parser): handle null input in JSON parser -chore(deps): update React from 17.0.2 to 18.2.0 -refactor(auth): extract token validation to middleware -``` - -### DON'T: Combine unrelated changes -```bash -# 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 React and TypeScript -``` - -### DON'T: Forget breaking change notation -```bash -# WRONG -feat(api): change response format - -# RIGHT -feat(api)!: change response format to follow JSON:API spec - -BREAKING CHANGE: API responses now use JSON:API format. -Old format: { data: [...] } -New format: { data: [...], meta: {}, links: {} } -``` - -## Integration with tools - -### Commitlint configuration - -```javascript -// RULE: Enforce rules automatically -module.exports = { - extends: ['@commitlint/config-conventional'], - rules: { - 'scope-enum': [2, 'always', [ - 'api', 'auth', 'build', 'ci', 'core', - 'db', 'deps', 'docs', 'ui', 'utils' - ]], - 'body-max-line-length': [2, 'always', 72], - 'header-max-length': [2, 'always', 72] - } -}; -``` - -## Branch naming rules - -### Branch name format - -```bash -# RULE: Use consistent branch naming -/- - -# Examples: -feat/123-add-oauth-support -fix/456-resolve-memory-leak -chore/update-dependencies -release/v2.0.0 -hotfix/critical-security-patch -``` - -### Branch type mapping - -```markdown -Branch types should match commit types: -- feat/* -> Feature branches -- fix/* -> Bug fix branches -- docs/* -> Documentation updates -- refactor/* -> Code refactoring -- test/* -> Test additions -- chore/* -> Maintenance tasks -``` - -See docs/rules/git-discipline.md for branch, PR, label, and remote conventions that build on this format. diff --git a/docs/rules/concurrency.md b/docs/rules/concurrency.md deleted file mode 100644 index 2c0a5a4..0000000 --- a/docs/rules/concurrency.md +++ /dev/null @@ -1,235 +0,0 @@ -# Swift 6 Strict Concurrency - -Concurrency posture for PureXML and PureXML: 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. -- `../decisions/point-free-dependencies.md`: open decision on the within-module Point-Free Dependencies pattern (closure-based, `@Sendable` closures). Undecided, not a rule. - -## 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/cross-platform.md b/docs/rules/cross-platform.md deleted file mode 100644 index 79d7c19..0000000 --- a/docs/rules/cross-platform.md +++ /dev/null @@ -1,565 +0,0 @@ -# Cross-platform Swift (Apple + Linux + Windows + WASI) - -How to structure PureXML's Swift so the same sources build and run on Apple -platforms, Linux, Windows, and WASI without silent breakage. - -Load on demand. Triggers: `canImport`, `Linux`, `Windows`, `WASI`, -`FoundationNetworking`, `Darwin`, `Glibc`, `WinSDK`, `swift-system`, -`swift-foundation`, cross-platform, manifest, `.when(platforms:)`, `os(Linux)`, -`os(Windows)`, `os(WASI)`, `os(iOS)`, `os(macOS)`, `os(visionOS)`, `os(tvOS)`. - -Grounded in Swift Evolution and `swiftlang/swift-foundation`. The PureXML -engine targets macOS, Linux, Windows, and WASI, so it falls under Topology 3 -below. The library target must not require platform-specific subprocesses, -shell-outs, C sources, generated parsers, JavaScript tooling, or Foundation. - -## Cross-platform portability and platform seams (mandatory) - -The engine targets macOS, Linux, Windows, and WASI. Three obligations follow: - -1. Guard every platform-divergent line, preferring `#if canImport()` over `#if os(...)` so future platforms inherit the right branch. -2. Check Linux, Windows, and WASI availability before adding any dependency, - framework import, filesystem behavior, or runtime assumption. -3. Keep the core library dependency-free. If a platform-specific feature ever - becomes necessary, put it behind a protocol seam outside the parser core. - -When the same functionality needs a different implementation per platform, -abstract it behind a protocol seam; do not branch at call sites. Define a -dependency-free protocol (see [shared-protocols.md](shared-protocols.md)), -implement it once per platform, 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 PlatformNameService: Sendable { var name: String { get } } -#if canImport(Darwin) -let platform: any PlatformNameService = DarwinPlatformNameService() -#elseif os(Windows) -let platform: any PlatformNameService = WindowsPlatformNameService() -#else -let platform: any PlatformNameService = PortablePlatformNameService() -#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/WinSDK conditionals, swift-system. The PureXML engine lives - here, with the stricter rule that the library target must also build for - WASI and therefore cannot rely on process spawning or platform filesystem - behavior. - -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("PureXMLUI"), - .singleTargetLibrary("PureXMLComponents"), - // ... -] -#else -let appleOnlyProducts: [Product] = [] -#endif -``` - -Same pattern for `appleOnlyTargets`. Core engine products (e.g. `PureXML`) 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 PureXML engine. The engine targets macOS, Linux, Windows, and WASI, 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 -// PureXMLUI/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@v6 - - 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@v6 - - 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, - Windows, or WASI entry in the SPM platforms enum; non-Apple support is - implicit when the package builds on those toolchains. For the engine, avoid - Apple-only platform declarations unless an Apple API genuinely requires a - minimum OS. 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 "PureXML/iOS/\(version)" -#elseif os(macOS) -return "PureXML/macOS/\(version)" -#elseif os(visionOS) -return "PureXML/visionOS/\(version)" -#else -return "PureXML/unknown/\(version)" // Linux falls into "unknown" -#endif -``` - -Good: - -```swift -#if os(iOS) -return "PureXML/iOS/\(version)" -#elseif os(macOS) -return "PureXML/macOS/\(version)" -#elseif os(visionOS) -return "PureXML/visionOS/\(version)" -#elseif os(Linux) -return "PureXML/Linux/\(version)" -#else -return "PureXML/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 + WASI | -| Linux UI | n/a | server-side: HTTP responses, not native widgets | n/a | diff --git a/docs/rules/dependency-injection.md b/docs/rules/dependency-injection.md deleted file mode 100644 index 982acee..0000000 --- a/docs/rules/dependency-injection.md +++ /dev/null @@ -1,144 +0,0 @@ -# Dependency Injection Rules - -Canonical dependency-injection principles for PureXML and the wider PureXML 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 PureXML: - -```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 PureXML 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 PureXML 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 PureXML 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 PureXML 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/documentation.md b/docs/rules/documentation.md deleted file mode 100644 index 4041ffe..0000000 --- a/docs/rules/documentation.md +++ /dev/null @@ -1,268 +0,0 @@ -# Documentation Rules (DocC) - -How PureXML 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 `PureXMLDocumentation`, display name `PureXML`). -- 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 (`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 -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 paths like `/Users/*` or `/Volumes/*`. -- 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 (`/Users/`, `/Volumes/`, 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/file-naming.md b/docs/rules/file-naming.md deleted file mode 100644 index de7e721..0000000 --- a/docs/rules/file-naming.md +++ /dev/null @@ -1,75 +0,0 @@ -# File Naming Rules - -Conventions for naming committed files in the PureXML repo: lowercase, dash-separated, ASCII, ISO dates. - -Every file committed must follow these conventions. - -## 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 dots before the file extension. -- **ASCII only**: filenames must be plain ASCII. - -## Date Format in Filenames - -Always use 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 (sips) -Photo DD-MM-YYYY.heic -> photo-YYYY-MM-DD.jpg (convert + rename) -``` - -### Dated documents -``` ---YYYY-MM-DD.pdf -``` -Use a date suffix only when the document is date-specific; omit it otherwise. - -## Renaming Existing Files - -When renaming, always 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/*" -``` - -## Skip List - -Some asset paths legitimately carry vendor-supplied names and are exempt from the audit. Document the skip paths per repo (for example `*/webAccessibleResources/*`, generated id-document folders, vendor photo dumps). Keep the list short and justified. diff --git a/docs/rules/folder-grouping.md b/docs/rules/folder-grouping.md deleted file mode 100644 index 9f596b8..0000000 --- a/docs/rules/folder-grouping.md +++ /dev/null @@ -1,216 +0,0 @@ -# Folder Grouping Rule - -How to lay out source trees in PureXML: one folder per SPM target, with file-grouping rules for the files inside a target. - -Cross-cutting layout rule. Most concrete in Swift / SPM packages where one-target-per-folder is the default; the same intent applies to Python packages, web project trees, docs trees, anywhere a tree of files exists. - -## SPM targets: one folder per target is the MANDATE (not just the default) - -For SwiftPM packages the standing rule is: -**every target maps to exactly one top-level source folder, and the folder is -the target's home** (`Sources//`, `Tests/Tests/`). No -`path:` pointing many targets into a shared parent, no disjoint `sources:` / -`exclude:` slicing to carve multiple targets out of one folder. `ls Sources/` -must enumerate every target; the filesystem boundary IS the target boundary. - -This supersedes the older "family-parent folder" guidance below for the -target-grouping case. The file-grouping rules (grouping FILES *within* a single -target's folder) still apply unchanged. The two earned-exception patterns -(same-kind single-file clusters; sub-targets sharing an umbrella) are now -discouraged for new code; flatten unless there is a compelling, documented reason. - -A migration that flattened a deep `Sources/{Core,Executables,Retailers}/...` -tree to strict one-folder-per-target 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 target) - -1. **Group related files in a shared parent folder.** When you have several files that belong to 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 click / 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 that parent: are there sub-clusters that share a finer semantic? Pull those into their own subfolders. After that pass, look UP one level: is the parent kind folder a peer of other related kind folders (`Importer/` seam + `ImporterUtilities/` + `Importers/` 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/EnrichmentRunner.swift`) and matches the Swift 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. - -## SPM default: every target is its own top-level folder - -The Swift Package Manager default, `Sources//` with no `path:` override, is the safer baseline for packages with many SPM targets. The filesystem location mirrors the target identity one-to-one, which means: - -- `ls Sources/` enumerates every target without reading `Package.swift`. -- `grep -r ... Sources/Foo/` answers "what's in target Foo" without ambiguity. -- Lift-out is trivial: copy the folder, write a 5-line standalone `Package.swift`, build. -- New contributors do not have to learn an additional layer of conventions before navigating the tree. - -Deviate from SPM 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 targets - -A family parent folder with `path:` + `sources:` overrides earns its keep when you have **5 or more peer targets of the same semantic kind**, each a single-file standalone target. The worked example below is the canonical case: 27 importers under `Sources/Importers/.swift`. The kind folder collapses 27 single-file siblings into one scannable list, and SPM's `path:` + `sources:` keeps each importer a separate target. - -The rule of thumb: if you can name the kind in one word and you have many peers, group them. `Importers/`, `Renderers/`, `ViewModels/` qualify. Two targets do not. - -## When family-parent folders are *not* worth it: heterogeneous-kind families - -A family folder with **kind-named subfolders** (`Sources//{Core, Model, ...}/`, where each subfolder holds a different kind of target: live concrete vs foundation seam vs WebKit adapter) is an anti-pattern. It looks symmetric but: - -- The filesystem path no longer maps 1:1 to a target. `Sources/Enrichment/Core/` is the target `Enrichment`; `Sources/Enrichment/Model/` is the target `EnrichmentModels`. The reader needs `Package.swift` to make the connection. -- Overloads the word "Core" across multiple meanings (family root, target's content, kind subfolder). -- Reads worse: `Sources/Source/SampleCode/SampleCodeSource.swift` triples the same word. -- Forces every new contributor to learn a project-specific taxonomy before navigating. - -If a `Foo` target ships alongside `FooModels`, leave them as `Sources/Foo/` + `Sources/FooModels/` (SPM 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 SPM default after a single review pass: "this is confusing." The lesson: filesystem === target boundary is load-bearing for navigability; family-parent folders that obscure it are not worth the cohesion gain. - -## Target name vs folder name decoupling - -SPM target identity is declared via `path:` in `Package.swift`. The filesystem location and the target name can diverge, but **do not make them diverge for cosmetic reasons**. Use `path:` only when: - -1. **Clustering same-kind single-file targets** (the Importers case). -2. **Embedding a sub-target inside a parent target's folder** (e.g. `Sources/MCP/Core/`, `Sources/MCP/Client/`: each is its own SPM target but they share the umbrella name because they ship together as one conceptual framework). -3. **Test targets pre-organised under a domain folder** (`Tests/MCP/CoreTests/`, `Tests/CLICommandTests/DoctorTests/`) when the source side mirrors that shape. - -Anywhere else: keep SPM 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. **SPM target name**, *highest stability*. This is the string consumers write as `import `. Changing it cascades to every consumer's source file, every test file, every doc that names the import, every script that greps for it. Default position: do not rename. A target rename is a separate, dedicated PR 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 `public typealias OldName = NewName` for back-compat, but every direct reference still needs updating eventually. Stage the rename: introduce the new name + alias in one PR, migrate consumers in follow-ups, drop the alias when uses hit zero. - -3. **File name**, *low stability*. The Swift compiler does not care what file a type lives in. Rename freely. Common patterns: `..swift` for namespace-anchored types. `git mv` 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. SPM `path:` overrides decouple folder from target name when needed (sparingly, see the section above). Reorganise folders without touching target names: every consumer keeps building unchanged. - -**The load-bearing principle**: when renaming, ask first *"can I do this without touching the SPM target name?"* If yes, the change is local. Only the codebase author sees the file moves. If no, the change is API-breaking and needs the heavy-PR treatment. - -**Worked example**: a family-folder restructure attempted to deepen a codebase by moving source files into family-parent + kind-subfolder shapes. The instinct was right (organize better) but the execution prioritised the wrong surface (folder layout over target name). After a single review pass on the result ("this is confusing") the right move was to keep target names + file names exactly as they were and revert the folder layout to SPM default. The same PR also landed type-name deepening (rank 2 above) with back-compat typealiases, which DID stick because it respected the priority order: target 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 multiple companion files of its own (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 `.swift` / `.py` / `.md` / etc. file is a candidate to flatten. -- Flatten by `git mv`-ing the file up one level and `rmdir`-ing the now-empty folder. -- Preserve the unit-with-multiple-files rule: do not flatten a 2-file folder. - -## How this composes with SPM (Swift 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 `Package.swift`. Multiple targets MAY share the same `path:` as long as each declares disjoint `sources:` lists. Example: - -```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 `.swift` files under `Sources/Importers/` while remaining independent SPM targets. A multi-file target (e.g. one with a protocol + a CSV 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 target at `Tests/Importers/CsvImporterTests/`. - -Use the same `flatImporterTests(...)` helper pattern in `Package.swift`. - -## 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 target. Brevity wins. -- **Mental overhead during edits.** When all importers are siblings, "show me what every importer does" is `ls Sources/Importers`. When each importer hides one level down, the same question becomes `find Sources/*Importer -name '*.swift'` plus a mental merge. - -The cost the rule preserves: SPM-level target independence (every package lifts out of the package set cleanly). Folder-grouping is a *filesystem* layout decision; SPM-target boundaries stay sharp via `path:` + `sources:`. - -## Lift-out preservation (non-negotiable) - -Every package must lift out as a working standalone Swift package: copy the target + its declared transitive deps to a tmp directory, generate a minimal `Package.swift`, `swift build`, green. Folder-grouping must not regress this. - -It does not, because: - -- Each target still owns a definite set of `.swift` files (declared via `path:` + `sources:` in `Package.swift`). The filesystem locations are colocated, but the file-to-target mapping is unambiguous. -- A lifted target re-roots its sources under the default SPM convention (`Sources//`); the shared-path trick was a *package-set* layout choice, not part of the target's identity. The lifted-package `Package.swift` goes back to plain `.target(name: "X", dependencies: [...])` with no `path:` override. -- Tests follow the same rule: each test target's `.swift` files belong to exactly one test target, and lifting copies just those files. - -**Mechanical verification (do this when in doubt):** copy the importer + its declared transitive deps to a tmp `Sources//` (one folder per target, default layout), generate a one-screen `Package.swift`, `swift build`. Green = lift-out works. - -**Per-target import audit must still work.** A side-effect of shared `path:` is that simple `for d in Sources/*; do audit "$d"; done` scripts collapse a cluster of single-file targets into one bucket and lose per-target granularity. Fix: walk to leaf `.swift` files inside designated cluster directories. Add an `is_cluster_path` predicate to the audit so each clustered target is still audited individually; update that predicate when adding a new cluster bucket. - -If the audit script cannot distinguish your targets, 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.swift`, `Sources/FollowersImporter/FollowersImporter.swift`, ... 27 one-file folders at the `Sources/` level. After: `Sources/Importers/.swift` 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.** `Sources/` 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/`. Final import-side layout: - -``` -Sources/Import/ -|-- Importer/ (seam, multi-file) -|-- ImporterUtilities/ (helpers, multi-file) -+-- Importers/ (concretes) - |-- PlatformA/ (multiple PlatformA targets) - |-- PlatformB/ (multiple PlatformB targets) - |-- AnalysesImporter.swift (flat, no platform peer) - +-- SnapshotImporter.swift -``` - -Tests mirror the same shape under `Tests/Import/`. Build + tests + the live CLI pipeline 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.swift` triple where the inner folder holds one file. Flatten. -- Hoisting a multi-file unit (e.g. an importer's protocol + CSV 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.swift` 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.swift`). Same anti-pattern as `Renderers/Web/Web.swift`: the inner layer is empty. - -## Enforcement - -No automated linter today. Verify by eyeballing the tree before declaring a layout change done: - -```bash -find Sources -mindepth 1 -maxdepth 1 -type d | while read d; do - count=$(find "$d" -maxdepth 1 -name "*.swift" | 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). - -## See also - -- docs/rules/file-naming.md: filename conventions this layout sits on top of. diff --git a/docs/rules/git-discipline.md b/docs/rules/git-discipline.md deleted file mode 100644 index db4e1ad..0000000 --- a/docs/rules/git-discipline.md +++ /dev/null @@ -1,304 +0,0 @@ -# GitHub and Git Discipline - -Conventions for issues, labels, pull requests, branches, commits, and remotes in the PureXML repo. Commit-message format lives in docs/rules/commits.md; this file covers everything else. - -The repo ships issue forms, a PR template, a label set, and git hooks that enforce most of these rules mechanically. The rules below are the durable conventions those mechanisms encode. - -## 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 status blocks 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 PR; 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 like `SearchIndex.swift:2409` go dead within one PR 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 on this 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 an issue 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 the referenced issue closes, edit the citing issue's body: either remove the cross-reference, or rewrite the surrounding 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 GitHub forms, not markdown - -Use `.yml` form templates, not `.github/ISSUE_TEMPLATE/feature.md` style markdown. Forms enforce structure mechanically at filing time; markdown templates only suggest it. - -Required fields: status date (input), priority (dropdown), complexity (dropdown). Required structured textareas per template kind (goal + acceptance for features; symptom + expected + reproduce + acceptance for bugs). - -**GitHub 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 `issues.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. - -## 2. Labels - -### Rule 2.1: Brutal-minimum 5-label set - -The canonical label set is **5 labels**. Labels exist only to mechanically partition the issue space; everything else lives in the issue body or in native GitHub primitives (issue state, Milestones, Project boards). - -| Label | Color | Use | -|---|---|---| -| `bug` | Red `#FF3B30` | Something ships incorrectly | -| `enhancement` | Blue `#007AFF` | New feature, refactor, or design proposal | -| `epic` | Purple `#AF52DE` | Aggregation parent of related issues; mechanically excluded from "missing kind" check | -| `priority: high` | Red `#FF3B30` | Critical / release-gating / actively-blocking. Absence means "do when you can." | -| `good first issue` | Green `#34C759` | Newcomer-friendly; GitHub renders specially in the contributor chooser | - -Kind is determined at filing time (feature template applies `enhancement`, bug template applies `bug`). Maintainers add `epic`, `priority: high`, `good first issue` 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` and `priority: low` collapse to "absence of `priority: high`." If the gradient does not change behaviour, the gradient is decoration. -- **Topical categorisation** (search-quality, source-expansion, refactor, area: distribution, etc.). These are composition, not type. Encode in the body or in a milestone. -- **Status / lifecycle** (`wishlist`, `transitional`, `blocker`, `big-win`, `fixed: awaiting release`, `released-in: v`). Status belongs to GitHub-native primitives (issue state, Milestones, Project boards), not to labels. - -**Why this size**: each surviving label has at least one mechanical reason it cannot fold into the body. Form templates apply kind at filing time; a staleness script partitions on `epic`; `priority: high` is the only triage tier (absence is implicit "later"); `good first issue` is GitHub-native. Three lenses converge on this set: - -- **Cut mercilessly**: cut even fine things that do not earn their place. A larger set still carries comfort-tags. -- **Classify by type, not status**: every classification must justify its existence; status / lifecycle / release-tracking are not types, route them 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: 3-carrier threshold for adding a new label - -If you feel the urge to add a sixth label, ask: does this label have at least 3 expected open carriers (today or within the next planning cycle)? AND does it survive each of the three lenses above? - -- Would you ship this label, or delete it? -- Is this a type/axis, or is it status/lifecycle/composition pretending to be a type? -- Does this label mechanically partition the issue space in a way grep / filter / a CI script must use, or is it commentary that belongs in the body? - -If any lens fails, fold the categorisation into the issue body. A one-line note in the body 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: Apple system palette - -The 5 canonical labels use 4 hues from Apple's published system colour palette: - -| Hex | Color | Use | -|---|---|---| -| `#FF3B30` | Red | Urgent / blocking: `bug`, `priority: high` | -| `#007AFF` | Blue | Active work: `enhancement` | -| `#AF52DE` | Purple | Aggregation / epic: `epic` | -| `#34C759` | Green | Newcomer-friendly / shipped: `good first issue` | - -If you add a sixth label under Rule 2.2, draw from Apple's other system colours by semantic grouping: - -| Hex | Color | Reserved for | -|---|---|---| -| `#FF9500` | Orange | Medium urgency | -| `#FFCC00` | Yellow | Awaiting / pending | -| `#00C7BE` | Mint | Low complexity (if you ever bring complexity back) | -| `#30B0C7` | Teal | Topical / categorical | -| `#32ADE6` | Cyan | Help / outreach | -| `#5856D6` | Indigo | High complexity / structural | -| `#FF2D55` | Pink | Celebration / high-impact | -| `#A2845E` | Brown | Maintainer / internal | -| `#8E8E93` | Gray | Speculative / wishlist | - -**Why Apple's palette**: 13 distinct hues with strong semantic associations. Picking from this set means labels render coherently in any view that supports colour (GitHub UI, project boards, third-party trackers reading from GitHub). - -### Rule 2.4: Label deletion is destructive on closed issues - -`gh label delete ` removes the label from every issue that ever bore it, including closed issues. Use `gh label edit --name ` to rename 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 `phase-1` removes that context. Renaming preserves it. - -When in doubt: 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 via 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; CHANGELOG lives in the release. Without enforcement, source ships ahead of its description. A mechanical gate prevents the batch of PRs that merge without entries. - -### 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 git history shows the iteration as separate commits; the PR diff shows the converged result. - -**Why**: mechanical edits without re-reading the surrounding paragraph create new bugs. A `sed` that injects a reference inside a sentence saying "this is independent" creates a contradiction. Critic-fix loops catch the self-introduced bugs that 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 `main` (or `develop` into `main`, etc.), the PR head is a dedicated `release/v` branch, not the canonical working branch. Auto-delete-on-merge would kill the canonical branch otherwise. - -**Why**: the GitHub 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/-`; bug fixes (e.g. `fix/284-error-page-filter`) -- `feat/`; features -- `chore/`; tooling, infrastructure, non-functional cleanup -- `docs/`; documentation-only changes -- `refactor/`; structural code reorganisation -- `release/v`; release-prep branches (PR head for canonical-branch merges per Rule 3.4) - -Issue-anchored prefixes (`fix/-...`) 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). For Swift projects this is usually `main` or `develop` depending on the project's branching model. - -`git fetch origin && git checkout -b origin/` is the safe form. Branching from a stale local copy of the base creates merge conflicts later. - -### Rule 4.3: Issue-first workflow - -For every bug fix: file the GitHub issue first, then branch. The branch name carries the issue number; the PR auto-links via `Closes #N` in the description. - -For features: an issue is optional but recommended when scope is non-trivial or when the change crosses two or more bug-list cycles. - -**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 data but does not survive squash-merge as well as an issue does. - -## 5. Commits - -See docs/rules/commits.md for commit message format. The following are additional GitHub-related conventions. - -### Rule 5.1: No AI attribution - -Generated content carries no AI attribution in any committed text. Specifically forbidden: - -- Any AI co-author line in commit messages -- Tool-attribution lines in PR descriptions, code comments, or documentation -- The names of AI tools or vendors, or the words "AI", "LLM", "agent", "assistant", "automated", "machine-generated", or any synonym, in commits / PRs / branch names / file names that could end up in front of a colleague -- AI-tell stylistic patterns: emoji signatures, "Here's a quick summary:" preludes, gratuitous emoji-prefixed bullets, "I'll now..." / "Let me..." preambles in committed text -- Watermarks: zero-width characters, hidden HTML comments naming a tool, branch suffixes like `-ai` / `-bot` - -The work must read as entirely human-authored. The standard is "would a colleague reading this guess a tool wrote it?" If yes, remove it. - -For mechanical enforcement at the commit boundary, install the commit-message style hook described in Section 7. Rule 5.1 plus Rule 5.2 plus 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 (the U+2014 character) is a recognisable writing tell. Replace with commas, periods, colons, semicolons, or restructure the sentence. Apply to commit messages, PR descriptions, code comments, issue bodies, documentation, README content; everything that lands in the repo or in front of a colleague. - -This is a stylistic rule, not a syntactic one. En dashes (U+2013) and hyphens (U+002D) are fine. Only the em dash is forbidden. - -Enforced mechanically by the commit-message style hook in Section 7 for commit messages. For file content, run the same byte check in a `pre-commit` hook over staged diffs. - -### Rule 5.3: Verify after `git commit --amend --no-edit` - -`--no-edit` keeps the previous commit's message verbatim. After any amend with `--no-edit`, run `git log -1` to confirm the message still describes the new tree accurately. If the staged change made the previous message wrong, drop `--no-edit` and rewrite. - -**Why**: silent message staleness is the second-easiest commit-history bug to introduce (after force-pushing the wrong branch). The previous message gets anchored by `--no-edit` and lies about content that has since changed. - -## 6. Remotes - -### Rule 6.1: Push conventions - -Pushes follow the usual conventions: short-lived feature branches push freely; canonical branches (`main` / `develop`) only land via merge of an approved PR. - -## 7. Mechanical enforcement - -A repo that adopts these rules should ship a mechanical backstop: - -- **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 that gets 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**: GitHub form templates for new issues + a workflow on `issues.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 (BSD-grep compatible, no PCRE dependency). Install per repo by symlink or copy into `.git/hooks/commit-msg`. Strip comment lines and the diff scissors block before checking so notes in `# ...` lines are not penalised. - -**Why mechanical enforcement matters.** 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. A 3-line `commit-msg` hook prevents this entire failure class at write time, after which `git push` cannot carry a violation regardless of who or what wrote the message. Discipline scales with attention; hooks do not. - -## 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 (`ci.yml`) with several shields.io `?label=...` badges that all point at the same workflow. The `actions/workflow/status` endpoint reports the whole-workflow status; the `label=` is cosmetic, so every such badge renders the same result. A failure in any one job turns all of them red, and none of them tracks its named platform. That is a lying badge. - -Canonical layout (one file each, names are the badge labels): - -``` -.github/workflows/style.yml # name: Style and namespacing -.github/workflows/swift-macos.yml # name: Swift (macOS) -.github/workflows/swift-linux.yml # name: Swift (Linux) -.github/workflows/swift-windows.yml # name: Swift (Windows) -.github/workflows/swift-wasm.yml # name: Swift (WASM) -``` - -### Rule 8.2: Native per-workflow badge markdown, plus a license badge - -Use GitHub's native per-workflow badge (it reflects the real status of that one workflow), not a shields.io workflow-status badge with a cosmetic label: - -```markdown -[![Style and namespacing](https://github.com///actions/workflows/style.yml/badge.svg)](https://github.com///actions/workflows/style.yml) -[![Swift macOS](https://github.com///actions/workflows/swift-macos.yml/badge.svg)](https://github.com///actions/workflows/swift-macos.yml) -[![Swift Linux](https://github.com///actions/workflows/swift-linux.yml/badge.svg)](https://github.com///actions/workflows/swift-linux.yml) -[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) -``` - -The license badge is static and links to the repo's `LICENSE`; match the label to the actual license (`MIT`, `AGPL--3.0`, etc.). - -### 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. Never ship a workflow or badge for a platform the package does not support; an absent badge is honest, a red-or-fictional badge is not. - -Decide possibility from the repo's topology (see `cross-platform.md`): - -- **Apple platforms (iOS, visionOS, tvOS, watchOS) and any UI build or test run on macOS runners only.** There is no "iOS CI on Linux" and no Windows/Linux UI. An iOS/visionOS app or a SwiftUI/UIKit/AppKit target is exercised by a macOS-hosted job (simulator or `xcodebuild -destination`), reported under the macOS badge or a dedicated `swift-ios.yml` that still `runs-on: macos`. Do not add a Linux or Windows badge for UI code. -- **Linux** badge only when the product builds on a Linux toolchain (libraries, servers, CLIs). A pure Apple-UI repo has no Linux badge. -- **Windows** badge only when the targets compile on Windows. If the test tooling is POSIX-only, Windows is a build-only gate: build the portable library targets (debug and release), skip the suite, and say so in a header comment. The badge then truthfully means "compiles on Windows." -- **WASM/WASI** badge only when the package builds for `wasm32-unknown-wasip1`. Code that needs Foundation networking, threads, or other WASI-absent surface does not get a WASM badge. WASM is typically a build gate; running wasm tests needs a separate runtime and is usually out of scope. - -When a platform can build but not run the full suite, the workflow is a build gate, not a test gate. The header comment states the scope 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: `on: push: branches: [main]` plus `pull_request:` plus `workflow_dispatch:`. Keep `permissions:` least-privilege (`contents: read`, plus `issues: read` only where a gate reads issues, e.g. a roadmap check). The commit-message attribution gate (Rule 5.1) lives in the style workflow and needs `fetch-depth: 0` on checkout plus `BASE_SHA` / `HEAD_SHA` from the PR event to scan the commit range. - -## Triggers (when to load this rule) - -Load this rule when: - -- Filing a new GitHub issue (Rule 1.1, Rule 1.2, Rule 1.3, Rule 1.6) -- Editing an open issue body (Rule 1.1, Rule 1.4, Rule 1.5) -- Adding or deleting a GitHub label (Rule 2.1, Rule 2.2, Rule 2.3, Rule 2.4) -- Opening a PR (Rule 3.1, Rule 3.2, Rule 3.3, Rule 3.4) -- Creating a branch (Rule 4.1, Rule 4.2, Rule 4.3) -- Writing a commit message that will be pushed (Rule 5.1, Rule 5.2) -- Running `git commit --amend` (Rule 5.3) -- Running `git push` (Rule 6.1, Rule 6.2) -- Designing a tracker-discipline mechanism for a new repo (Rule 7) -- Setting up CI workflows or README status badges (Rule 8.1, Rule 8.2, Rule 8.3, Rule 8.4) - -Load on demand per the trigger; do not auto-load. diff --git a/docs/rules/namespacing.md b/docs/rules/namespacing.md deleted file mode 100644 index b505cd8..0000000 --- a/docs/rules/namespacing.md +++ /dev/null @@ -1,244 +0,0 @@ -# Swift namespaces via enums or structs, canonical rule - -Single-page rule for picking the namespace-anchor type in PureXML code: 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/package-architecture.md b/docs/rules/package-architecture.md deleted file mode 100644 index 71c857a..0000000 --- a/docs/rules/package-architecture.md +++ /dev/null @@ -1,37 +0,0 @@ -# Package Architecture - -PureXML starts as one root Swift package with one library target: - -- product: `PureXML` -- target: `PureXML` -- tests: `PureXMLTests` - -## Rules - -- Keep dependencies explicit and minimal. -- Keep external dependencies at zero unless the maintainer explicitly changes the - project contract. -- Add a new target only when a responsibility needs independent compilation, - testing, or platform boundaries. -- Keep dependencies unidirectional if new targets are added. - -## Current Layers - -```text -PureXML -├── Model ordered YAML value tree -├── Parsing parser implementation -└── Emitting dumper implementation -``` - -The public namespace tree mirrors this layout. - -## When to Split a Target - -Do not split for aesthetics. Split when one of these becomes true: - -- the parser and emitter need independent platform gates -- a reusable scanner/lexer becomes large enough to test independently -- a compatibility layer for Yams-style decoding needs isolation from the core - model -- a CLI or research tool appears and should not ship as library code diff --git a/docs/rules/package-structure.md b/docs/rules/package-structure.md deleted file mode 100644 index 736872e..0000000 --- a/docs/rules/package-structure.md +++ /dev/null @@ -1,38 +0,0 @@ -# Package and Repository Structure - -PureXML is a root Swift package. - -## Required Layout - -```text -PureXML/ -├── Package.swift -├── Sources/ -│ ├── PureXML.swift -│ ├── Model/ -│ ├── Parsing/ -│ └── Emitting/ -├── Tests/ -│ └── PureXMLTests/ -├── docs/ -└── scripts/ -``` - -## Rules - -- Keep `Package.swift` at repository root. -- Do not create a `Packages/` folder. -- Keep production code directly under `Sources`. -- Keep tests in `Tests/PureXMLTests`. -- Keep `Package.swift` dependency-free. -- Add new targets only when a responsibility genuinely needs isolation. - -## Namespace Mapping - -The namespace tree mirrors the source tree: - -- `Sources/Model/Value.swift` -> `PureXML.Model.Value` -- `Sources/Parsing/Parser.swift` -> `PureXML.Parsing.Parser` -- `Sources/Emitting/Dumper.swift` -> `PureXML.Emitting.Dumper` - -The root namespace file, `Sources/PureXML.swift`, is a map of namespaces only. diff --git a/docs/rules/rules-swift/CHANGELOG.md b/docs/rules/rules-swift/CHANGELOG.md deleted file mode 100644 index 6961eb4..0000000 --- a/docs/rules/rules-swift/CHANGELOG.md +++ /dev/null @@ -1,18 +0,0 @@ -# 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/rules-swift/core/file-naming.md b/docs/rules/rules-swift/core/file-naming.md deleted file mode 100644 index b105e90..0000000 --- a/docs/rules/rules-swift/core/file-naming.md +++ /dev/null @@ -1,95 +0,0 @@ -# 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/rules-swift/exp/build-performance.md b/docs/rules/rules-swift/exp/build-performance.md deleted file mode 100644 index f685d2a..0000000 --- a/docs/rules/rules-swift/exp/build-performance.md +++ /dev/null @@ -1,45 +0,0 @@ -# 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/shared-protocols.md b/docs/rules/shared-protocols.md deleted file mode 100644 index 50adff7..0000000 --- a/docs/rules/shared-protocols.md +++ /dev/null @@ -1,178 +0,0 @@ -# Shared Protocols Target - -The pattern for a single foundation-only target that holds every cross-target protocol seam. PureXML 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 ' 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/systematic-debugging.md b/docs/rules/systematic-debugging.md deleted file mode 100644 index a5bded7..0000000 --- a/docs/rules/systematic-debugging.md +++ /dev/null @@ -1,75 +0,0 @@ -# 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. - -## Core rules - -### Rule 1: Reproduce first, investigate second, fix third - -Complete the phases in order. -- Get a deterministic reproduction (test, sample input, repro script) before reading code -- MUST identify the smallest change that flips the outcome before naming it the cause -- MUST NOT propose a fix until you can describe the cause in one sentence - -### Rule 2: The four phases - -``` -1. REPRODUCE - - capture exact failing command (swift test --filter X, app run with input Y) - - confirm it fails consistently - - if flaky, treat the flake as the primary bug - -2. ISOLATE - - bisect: which target, 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 assumption that was wrong - - if you cannot explain, 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 - - run the verification gate (see docs/rules/verification.md) -``` - -### Rule 3: Common Swift pitfalls to check first - -Consider these before deeper investigation: - -- **Force unwrap on optional**: search for `!` near the crash site; trace where the optional became nil -- **Concurrency**: was the failing code on the expected actor or thread? Check `@MainActor`, `Sendable` violations, structured concurrency hops -- **Dependency injection**: did a test forget `withDependencies`? Did a default `liveValue` leak into the test? -- **Strict concurrency**: Swift 6 errors often surface as "data race" warnings in Swift 5 mode. Check the migration mode of the affected target. -- **Generic type inference**: a fix that "looks right" but the compiler flags is often hiding an inference loop -- **Cache or stale build**: try `swift package clean && swift build` if behavior contradicts the visible source - -### Rule 4: Banned behaviors during debugging - -**MUST NOT**: -- Apply a fix to make the test pass without understanding why it was failing -- Wrap the failing call in `try?` or `if let` to silence the symptom -- Add `@MainActor` to "fix" a concurrency error without confirming the call site needed it -- Disable the failing test -- Skip the test with `.disabled` and move on -- 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 + 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) - -## 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 diff --git a/docs/rules/testing-discipline.md b/docs/rules/testing-discipline.md deleted file mode 100644 index ff0b034..0000000 --- a/docs/rules/testing-discipline.md +++ /dev/null @@ -1,88 +0,0 @@ -# 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. - -When the change touches code, the change is not complete until tests have run. 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: - -- Swift Package: `swift test` -- Swift app target: project test scheme via `xcodebuild test` -- Python: `pytest`, `python -m unittest`, or the project's chosen runner -- JavaScript / TypeScript: `npm test`, `pnpm test`, `yarn test`, or the project's chosen runner -- 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 `pyproject.toml` `[tool.*.scripts]`. - -The required output is the actual runner producing pass/fail counts, not a successful compile. `swift build` is not a test run. `tsc --noEmit` is not a test run. The test runner must execute and report. - -Failures block the commit. Investigate and fix before shipping. Skipping with `.skip` / `xfail` / `@Disabled` 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 SQL view, a database 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 (real SQLite file, real temp directory, real fixture markdown). 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 Swift project using Swift Testing (`@Test`, `@Suite`, `#expect`) does not get XCTest stubs added to it. A Python project using `pytest` does not get a `unittest.TestCase` class added. 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 `return nil` or `return 0` or `return 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 `swift test`: 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. - -## Exceptions - -The rule does not require tests for: - -- Documentation-only changes (`docs/`, `README.md`, code comments). -- Renames where the compiler proves equivalence (Swift `swift build` passes, TypeScript `tsc` passes, Python `mypy` passes). -- 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 (GitHub Actions, or any CI) that runs tests on every PR 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. - -## 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/testing.md b/docs/rules/testing.md deleted file mode 100644 index dfa1acb..0000000 --- a/docs/rules/testing.md +++ /dev/null @@ -1,681 +0,0 @@ -# Swift Testing Framework Rules - -How to write tests for PureXML: 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 (`PureXML`, the `Tile` / `TileType` primitives, the `PureXML` 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, whose adoption is an open question; see [../decisions/point-free-dependencies.md](../decisions/point-free-dependencies.md). Where PureXML does not use that library, control collaborators through plain constructor injection instead; the test-isolation and determinism rules apply either way. - -## Core rules - -### Rule 0: Exact Diagnostics Discipline - -PureXML tests must use Swift Testing and pin exact behavior: - -- Use focused `test_...` method names inside `@Suite` groups. -- Use direct equality checks for concrete values, rendered output, token/event - descriptions, paths, and error values. -- Use helper functions for repeated assertions and round-trip/error checks. -- Cover success and expected-failure behavior for every supported surface. -- For parser/scanner/emitter/model tests, assert all relevant dimensions: - what output must be present, what output must be absent, what must succeed, - what must fail, and the exact value or error returned. -- Error tests must pin concrete error cases and descriptions where the error is - user-facing; do not stop at "throws". -- Keep table-driven/argument tests for families of related cases. -- Keep tests fixture-like and realistic enough that a stub implementation cannot - satisfy them accidentally. -- Do not add XCTest-style `XCTestCase`, `XCTAssert...`, or mixed XCTest helpers - to new PureXML tests. - -### 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 test methods with the `test_...` convention -- MUST keep `@Test("...")` descriptions clear and user-readable -- 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 PureXML - -@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: "PureXML", - dependencies: ["PureXMLModels", "FileClient"] -) -let tileKitTestsTarget = Target.testTarget( - name: "PureXMLTests", - dependencies: ["PureXML"] -) -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 - -PureXML is a monorepo from day one: sources live under `Sources//` and tests under `Tests/Tests/`, mirroring each other: - -``` -. -├── -│ ├── Sources/ -│ │ └── PureXML/ -│ │ └── ...source files... -│ └── Tests/ -│ └── PureXMLTests/ -│ ├── ...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 `Tests/Tests/Mocks/`, NOT in `Sources//`. Mocks shipped from `Sources/` leak into production binaries. - -```swift -// Sources/FileClient/FileClientProtocol.swift -public protocol FileClientProtocol { - func read(path: String) async throws -> String -} - -// 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/verification.md b/docs/rules/verification.md deleted file mode 100644 index ec80b4a..0000000 --- a/docs/rules/verification.md +++ /dev/null @@ -1,40 +0,0 @@ -# Verification Before Completion - -Never claim a PureXML change is complete without fresh command output from the -relevant gates. - -## Required Gate - -Run this local gate before committing or reporting completion: - -```sh -bash scripts/check-style.sh -bash scripts/check-namespacing.sh -bash scripts/check-forbidden-patterns.sh -bash scripts/check-changelog-touched.sh -swiftformat . --config .swiftformat --lint -swiftlint --config .swiftlint.yml --strict -swift build -swift test -bash scripts/check-linux.sh -bash scripts/check-wasm.sh -``` - -Hosted CI must also pass the Windows job, which runs `scripts/check-windows.ps1`. - -## Claims and Evidence - -| Claim | Required evidence | -|---|---| -| Style clean | `scripts/check-style.sh` exits 0 | -| Namespace clean | `scripts/check-namespacing.sh` exits 0 | -| Forbidden-pattern clean | `scripts/check-forbidden-patterns.sh` exits 0 | -| Format clean | `swiftformat . --config .swiftformat --lint` exits 0 | -| Lint clean | `swiftlint --config .swiftlint.yml --strict` exits 0 | -| Builds on macOS | `swift build` exits 0 | -| Tests pass | `swift test` exits 0 and reports zero failures | -| Linux compatible | `bash scripts/check-linux.sh` exits 0 | -| Windows compatible | hosted `scripts/check-windows.ps1` exits 0 | -| WASI compatible | `bash scripts/check-wasm.sh` exits 0 | - -If a tool is unavailable, say so explicitly and do not claim that gate passed. diff --git a/scripts/check-changelog-touched.sh b/scripts/check-changelog-touched.sh index f74795e..2b9beda 100755 --- a/scripts/check-changelog-touched.sh +++ b/scripts/check-changelog-touched.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # Changelog currency gate. When a change set modifies library sources under # Sources/, it must also update CHANGELOG.md, so the Keep a Changelog -# "Unreleased" section stays current. See docs/rules/verification.md. +# "Unreleased" section stays current. See third_party/rules-swift/core/verification.md. # # The change set is the union of (a) commits on this branch since it forked # from the base branch (origin/main, else main) and (b) the staged and unstaged diff --git a/scripts/check-local-gate.sh b/scripts/check-local-gate.sh new file mode 100755 index 0000000..0b8e55b --- /dev/null +++ b/scripts/check-local-gate.sh @@ -0,0 +1,25 @@ +#!/bin/sh +# Full local gate. GitHub Actions are not assumed for this repo family. +set -eu + +ROOT=$(cd "$(dirname "$0")/.." && pwd) +cd "$ROOT" + +# The pinned corpus must be checked out, or the rules gate cannot verify anything. +if [ ! -e third_party/rules-swift/README.md ]; then + echo "check-local-gate: rules-swift submodule not initialized; run: git submodule update --init --recursive" >&2 + exit 1 +fi + +# Hooks are a real part of the gate. A clone that skipped install must fail loud, +# not silently run with no commit-msg/attribution enforcement. +hooks_path=$(git config --get core.hooksPath || true) +if [ "$hooks_path" != ".githooks" ]; then + echo "check-local-gate: git hooks not installed (core.hooksPath='$hooks_path'); run: scripts/install-hooks.sh" >&2 + exit 1 +fi + +scripts/check-rules-swift.sh +scripts/check-style.sh +swift build +swift test diff --git a/scripts/check-namespacing.sh b/scripts/check-namespacing.sh index e95b083..872fbc9 100755 --- a/scripts/check-namespacing.sh +++ b/scripts/check-namespacing.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Namespacing gate (docs/rules/code-style.md): one non-private top-level type per +# Namespacing gate (third_party/rules-swift/code-style.md): one non-private top-level type per # file. A file with more than one file-scope type declaration is a violation: # split it, or mark helper types private/fileprivate. # @@ -35,6 +35,6 @@ while IFS= read -r f; do done < <(find "$SRC" -name "*.swift") if [ "$FAIL" -ne 0 ]; then - echo "namespacing: gate failed. Rule: docs/rules/code-style.md" >&2 + echo "namespacing: gate failed. Rule: third_party/rules-swift/code-style.md" >&2 fi exit "$FAIL" diff --git a/scripts/check-roadmap.sh b/scripts/check-roadmap.sh index 71df4d4..e7b50f8 100755 --- a/scripts/check-roadmap.sh +++ b/scripts/check-roadmap.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # Mermaid roadmap gate. Keeps README roadmap diagrams vertical, legend-first, # TileDown-palette colored, and covered by live epic issues. -# See docs/rules/mermaid.md. +# See docs/mermaid.md. set -u diff --git a/scripts/check-rules-corpus-coverage.sh b/scripts/check-rules-corpus-coverage.sh new file mode 100755 index 0000000..ca6824d --- /dev/null +++ b/scripts/check-rules-corpus-coverage.sh @@ -0,0 +1,87 @@ +#!/bin/sh +# Ensures the pinned rules-swift corpus is present at its recorded version and that +# the audit names every public rules-swift source file. +# +# Two pin mechanisms are supported (auto-detected): +# submodule mode: the corpus is a git submodule at third_party/rules-swift; the +# submodule commit IS the pin (preferred for tool repos). +# snapshot mode: the corpus is a plain vendored copy at third_party/rules-swift, +# pinned by docs/rules-swift-corpus.sha256 (preferred for source-published repos +# that must avoid `--recursive` clones). +# Override RULES_SWIFT_DIR to audit against a live local checkout during dev. +set -eu + +ROOT=$(cd "$(dirname "$0")/.." && pwd) +cd "$ROOT" + +RULES_SWIFT_DIR=${RULES_SWIFT_DIR:-"$ROOT/third_party/rules-swift"} +AUDIT=docs/rules-swift-audit.md +MANIFEST=docs/rules-swift-corpus.sha256 +fail=0 +file_count=0 +pin_mode="none" + +fail_check() { + echo "RULE FAIL: $1" >&2 + fail=1 +} + +if [ ! -f "$AUDIT" ]; then + fail_check "missing rules-swift audit: $AUDIT" +elif [ ! -d "$RULES_SWIFT_DIR" ] || [ -z "$(ls -A "$RULES_SWIFT_DIR" 2>/dev/null)" ]; then + fail_check "rules-swift corpus absent at $RULES_SWIFT_DIR (run: git submodule update --init --recursive, or restore the vendored snapshot)" +else + # --- Pin verification: submodule status, else snapshot digest. --- + if [ -f .gitmodules ] && grep -q 'third_party/rules-swift' .gitmodules; then + pin_mode="submodule" + status=$(git submodule status third_party/rules-swift 2>/dev/null || echo "?unknown") + # A '+' or '-' prefix means the working submodule drifted off the recorded commit. + case "$status" in + [-+]*) fail_check "rules-swift submodule not at pinned commit ($status); run: git submodule update --init --recursive" ;; + esac + elif [ -f "$MANIFEST" ]; then + pin_mode="snapshot" + actual=$(mktemp "${TMPDIR:-/tmp}/rules-swift-corpus.XXXXXX") + ( + cd "$RULES_SWIFT_DIR" + find . -type f \( -name '*.md' -o -name '*.sh' \) -not -path '*/.git/*' -print0 \ + | sort -z \ + | xargs -0 shasum -a 256 \ + | sed 's| ./| |' + ) > "$actual" + if ! cmp -s "$actual" "$MANIFEST"; then + echo "RULE FAIL: rules-swift snapshot digest changed; update $MANIFEST and $AUDIT together" >&2 + diff -u "$MANIFEST" "$actual" >&2 || true + fail=1 + fi + rm -f "$actual" + else + fail_check "no corpus pin found: add a git submodule (third_party/rules-swift) or a snapshot digest ($MANIFEST)" + fi + + file_count=$( + find "$RULES_SWIFT_DIR" -type f \( -name '*.md' -o -name '*.sh' \) \ + -not -path '*/.git/*' | wc -l | tr -d ' ' + ) + + if ! grep -Fq "Rules corpus file count: $file_count" "$AUDIT"; then + fail_check "rules-swift audit must record current corpus file count: $file_count" + fi + + missing=$( + find "$RULES_SWIFT_DIR" -type f \( -name '*.md' -o -name '*.sh' \) -not -path '*/.git/*' \ + | sed "s|^$RULES_SWIFT_DIR/||" \ + | sort \ + | while IFS= read -r rel; do + grep -Fq "\`$rel\`" "$AUDIT" || echo "$rel" + done + ) + if [ -n "$missing" ]; then + echo "RULE FAIL: rules-swift audit missing corpus files:" >&2 + echo "$missing" | sed 's/^/ /' >&2 + fail=1 + fi +fi + +[ "$fail" = 0 ] || exit 1 +echo "rules-swift corpus coverage clean ($file_count files, pinned via $pin_mode)" diff --git a/scripts/check-rules-swift.sh b/scripts/check-rules-swift.sh new file mode 100755 index 0000000..234cc91 --- /dev/null +++ b/scripts/check-rules-swift.sh @@ -0,0 +1,155 @@ +#!/bin/sh +# Mechanical rules-swift gate for PureXML. +# Tailored to this repo: a dependency-free, pure-Swift XML package with a root +# Package.swift, root Sources/ and Tests/, a validation-coverage layer, and no UI +# target. Only the rules that apply here are enforced; rules that do not apply (UI +# renderers, the ExtremePackaging monorepo manifest pattern, exp package closures) +# are intentionally omitted. +set -eu + +ROOT=$(cd "$(dirname "$0")/.." && pwd) +cd "$ROOT" + +fail=0 + +fail_check() { + echo "RULE FAIL: $1" >&2 + fail=1 +} + +run_check() { + if ! "$@"; then + fail=1 + fi +} + +require_file() { + [ -f "$1" ] || fail_check "missing required file: $1" +} + +require_dir() { + [ -d "$1" ] || fail_check "missing required directory: $1" +} + +require_executable() { + [ -x "$1" ] || fail_check "missing executable bit: $1" +} + +empty_output() { + label=$1 + output=$2 + if [ -n "$output" ]; then + echo "RULE FAIL ($label):" >&2 + echo "$output" >&2 + fail=1 + fi +} + +# --- Required repository files (only files that genuinely apply here) --- +require_file AGENTS.md +require_file Package.swift +require_file README.md +require_file .swiftformat +require_file .swiftlint.yml +require_file docs/rules-swift-audit.md +require_file docs/rules-swift-corpus.sha256 +require_file third_party/rules-swift/README.md +require_file Sources/PureXML.swift +require_dir Sources +require_dir Tests +require_dir scripts +require_dir .githooks +require_dir third_party/rules-swift + +require_executable scripts/check-local-gate.sh +require_executable scripts/check-rules-corpus-coverage.sh +require_executable scripts/check-rules-swift.sh +require_executable scripts/check-namespacing.sh +require_executable scripts/check-style.sh +require_executable scripts/check-validation-coverage.sh +require_executable scripts/install-hooks.sh +require_executable scripts/leak-gate.sh +require_executable .githooks/commit-msg +require_executable .githooks/pre-commit + +if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then + hooks_path=$(git config --get core.hooksPath || true) + [ "$hooks_path" = ".githooks" ] || fail_check "core.hooksPath must be .githooks; run scripts/install-hooks.sh" +fi + +# --- Sub-gates that this repo carries --- +run_check scripts/leak-gate.sh +run_check scripts/check-rules-corpus-coverage.sh +run_check scripts/check-namespacing.sh +run_check scripts/check-validation-coverage.sh + +# --- Audit must carry no unresolved blocker rows --- +audit_blockers=$( + awk -F '|' ' + /^\|/ { + for (i = 1; i <= NF; i++) { + gsub(/^[ \t]+|[ \t]+$/, "", $i) + } + if ($3 == "Gap" || $3 == "Partial" || $4 == "Gap" || $4 == "Partial") { + print $0 + } + } + ' docs/rules-swift-audit.md +) +empty_output "rules-swift audit blockers" "$audit_blockers" + +# --- package-architecture.md: every source target has a matching test target --- +source_targets=$( + grep -A2 -E '\.(executableTarget|target)\(' Package.swift \ + | sed -n 's/.*name: "\([^"]*\)".*/\1/p' \ + | sort -u +) +test_targets=$( + grep -A2 -E '\.testTarget\(' Package.swift \ + | sed -n 's/.*name: "\([^"]*\)".*/\1/p' \ + | sort -u +) +for target in $source_targets; do + case "$target" in + *Tests) continue ;; + esac + if ! printf '%s\n' "$test_targets" | grep -qx "${target}Tests"; then + fail_check "source target ${target} has no matching test target ${target}Tests" + fi +done + +# --- concurrency.md and no-shortcuts-first-principles.md: anti-patterns in our code only --- +empty_output "Swift concurrency escape hatches" "$(grep -rnE '@unchecked Sendable|nonisolated\(unsafe\)' Sources/ Tests/ || true)" +empty_output "threading and test-wait anti-patterns" "$(grep -rnE 'DispatchQueue\.main\.async|NSLock|pthread_mutex|XCTestExpectation' Sources/ Tests/ || true)" +empty_output "shortcut patterns" "$(grep -rnE 'try\?|catch \{\}|sleep\(|XCTSkip|swiftlint:disable' Sources/ Tests/ || true)" + +# --- framework-policy.md: no non-Apple UI or cross-platform stack names in our sources/docs --- +# Terms are octal-encoded so this script does not itself trip the gate. Case-sensitive +# whole-word, and the vendored corpus and build products are out of scope. +blocked_terms=$( + for term in \ + "$(printf '\106\154\165\164\164\145\162')" \ + "$(printf '\122\145\141\143\164\040\116\141\164\151\166\145')" \ + "$(printf '\105\154\145\143\164\162\157\156')" \ + "$(printf '\124\141\165\162\151')" \ + "$(printf '\113\157\164\154\151\156')" \ + "$(printf '\103\157\155\160\157\163\145')" \ + "$(printf '\125\156\151\164\171')" \ + "$(printf '\125\156\162\145\141\154')" \ + "$(printf '\130\141\155\141\162\151\156')" \ + "$(printf '\103\157\162\144\157\166\141')" \ + "$(printf '\103\141\160\141\143\151\164\157\162')" + do + find . \( -name '*.swift' -o -name '*.md' -o -name 'Package.swift' \) \ + -not -path './.git/*' \ + -not -path './.build/*' \ + -not -path '*/.build/*' \ + -not -path './third_party/*' \ + -not -path './Package.resolved' \ + -exec grep -nH -w -F "$term" {} + 2>/dev/null || true + done +) +empty_output "non-Apple UI or cross-platform stack mentions" "$blocked_terms" + +[ "$fail" = 0 ] || exit 1 +echo "rules-swift mechanical gate clean" diff --git a/scripts/check-style.sh b/scripts/check-style.sh index 1bc310a..75bf309 100755 --- a/scripts/check-style.sh +++ b/scripts/check-style.sh @@ -16,7 +16,7 @@ EMDASH=$(printf '\xe2\x80\x94') # detect them, so exclude them from the phrase scan. is_enforcement_file() { case "$1" in - .githooks/*|scripts/check-style.sh|scripts/check-commit-attribution.sh|docs/rules/rules-swift/core/commits.md|docs/rules/rules-swift/core/git-discipline.md) return 0 ;; + .githooks/*|scripts/check-style.sh|scripts/check-commit-attribution.sh|third_party/rules-swift/core/commits.md|third_party/rules-swift/core/git-discipline.md) return 0 ;; *) return 1 ;; esac } @@ -40,6 +40,8 @@ AI_PREFIXES='Co-Authored-By:|Co-authored-by:|Generated with|Generated by|with he while IFS= read -r f; do [ -f "$f" ] || continue + # The vendored rules-swift corpus is pinned upstream content, not authored here. + case "$f" in third_party/*) continue ;; esac if LC_ALL=C grep -qF -- "$EMDASH" "$f" 2>/dev/null; then echo "style: em dash (U+2014) in $f" >&2 FAIL=1 @@ -62,7 +64,7 @@ while IFS= read -r f; do done < <(git ls-files) if [ "$FAIL" -ne 0 ]; then - echo "style: gate failed. Rules: docs/rules/git-discipline.md" >&2 + echo "style: gate failed. Rules: third_party/rules-swift/core/git-discipline.md" >&2 fi # Roadmap gate (github-discipline: the roadmap check is wired into check-style). diff --git a/scripts/install-hooks.sh b/scripts/install-hooks.sh new file mode 100755 index 0000000..fe64107 --- /dev/null +++ b/scripts/install-hooks.sh @@ -0,0 +1,21 @@ +#!/bin/sh +# Install and verify tracked git hooks for this clone. +set -eu + +ROOT=$(cd "$(dirname "$0")/.." && pwd) +cd "$ROOT" + +git config core.hooksPath .githooks + +tmp=$(mktemp) +trap 'rm -f "$tmp"' EXIT +# Plant a message the commit-msg hook MUST reject: an em dash (U+2014), which every +# commit-msg variant in this repo family rejects under github-discipline Rule 5.2. +printf 'test\n\nbad em dash \342\200\224 here\n' > "$tmp" + +if .githooks/commit-msg "$tmp" >/dev/null 2>&1; then + echo "install-hooks: commit-msg hook did not reject the planted style violation." >&2 + exit 1 +fi + +echo "hooks installed: $(git config --get core.hooksPath)" diff --git a/scripts/leak-gate.sh b/scripts/leak-gate.sh new file mode 100755 index 0000000..c21234a --- /dev/null +++ b/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/|/\.build/|/third_party/' \ + | grep -vE '(^|[./])(commits|git-discipline|rules-swift-audit)\.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/|/\.build/|/third_party/' || 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/|/\.build/|/third_party/' || 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/*' -not -path '*/.build/*' -not -path '*/third_party/*' | wc -l | tr -d ' ') md files)" diff --git a/docs/rules/rules-swift/.githooks/commit-msg b/third_party/rules-swift/.githooks/commit-msg similarity index 100% rename from docs/rules/rules-swift/.githooks/commit-msg rename to third_party/rules-swift/.githooks/commit-msg diff --git a/docs/rules/rules-swift/.githooks/pre-commit b/third_party/rules-swift/.githooks/pre-commit similarity index 84% rename from docs/rules/rules-swift/.githooks/pre-commit rename to third_party/rules-swift/.githooks/pre-commit index 3a2cebf..779bfed 100755 --- a/docs/rules/rules-swift/.githooks/pre-commit +++ b/third_party/rules-swift/.githooks/pre-commit @@ -9,6 +9,10 @@ set -e # not be gated behind staged Swift files). See scripts/leak-gate.sh. sh "$(dirname "$0")/../scripts/leak-gate.sh" +# Namespacing gate (one non-private top-level type per file) runs whole-tree on every +# commit, matching code-style.md. Cheap, and vacuous in a repo with no Swift sources. +sh "$(dirname "$0")/../scripts/check-namespacing.sh" + # Lint only the Swift this commit touches. A commit with no Swift files (docs, config, # this rules repo) has nothing to format or lint, so stop here. swift_files=$(git diff --cached --name-only --diff-filter=ACM -- '*.swift') diff --git a/third_party/rules-swift/.github/ISSUE_TEMPLATE/bug.yml b/third_party/rules-swift/.github/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 0000000..e461fc1 --- /dev/null +++ b/third_party/rules-swift/.github/ISSUE_TEMPLATE/bug.yml @@ -0,0 +1,66 @@ +name: Bug +description: A defect in the rules, a contradiction, a broken check, a phantom path, or a wrong claim. +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + Report a defect in the rule set: a rule that contradicts another, an acceptance check that flags conforming code, a broken link, or a claim that does not match the repo. + - type: textarea + id: status + attributes: + label: Status (YYYY-MM-DD) + description: First line is today's date. Then one line on the current state. Edit this block in place as state changes; the rest of the body keeps its original framing. + placeholder: | + 2026-06-26: filed, not started. + validations: + required: true + - type: dropdown + id: priority + attributes: + label: Priority + description: The value high means critical, release-gating, or actively blocking. Otherwise pick normal, since absence of high is the default. + options: + - high + - normal + validations: + required: true + - type: dropdown + id: complexity + attributes: + label: Complexity + description: A reader's estimate. This stays in the body; it is not a label. + options: + - small + - medium + - large + validations: + required: true + - type: textarea + id: symptom + attributes: + label: Symptom + description: What goes wrong. Reference files by symbol or name, never by line number. Every backtick-quoted path must exist in the repo. + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected + description: What the rules should say or do instead. + validations: + required: true + - type: textarea + id: reproduce + attributes: + label: Reproduce + description: The exact command or steps that surface the defect, with the output they produce. + validations: + required: true + - type: textarea + id: acceptance + attributes: + label: Acceptance + description: The observable conditions, ideally a runnable command with a stated expected result, that prove the defect is fixed. + validations: + required: true diff --git a/third_party/rules-swift/.github/ISSUE_TEMPLATE/config.yml b/third_party/rules-swift/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..a265afc --- /dev/null +++ b/third_party/rules-swift/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Contributing guide + url: https://github.com/mihaelamj/rules-swift/blob/main/CONTRIBUTING.md + about: How a change is proposed, where it belongs, and the house conventions this repo follows. diff --git a/third_party/rules-swift/.github/ISSUE_TEMPLATE/feature.yml b/third_party/rules-swift/.github/ISSUE_TEMPLATE/feature.yml new file mode 100644 index 0000000..e84d509 --- /dev/null +++ b/third_party/rules-swift/.github/ISSUE_TEMPLATE/feature.yml @@ -0,0 +1,52 @@ +name: Feature or enhancement +description: A new rule, a refactor of an existing one, or a design proposal. +labels: ["enhancement"] +body: + - type: markdown + attributes: + value: | + File a rule change that generalizes beyond a single project. See `core/self-improve.md` for the threshold a new rule must clear. + - type: textarea + id: status + attributes: + label: Status (YYYY-MM-DD) + description: First line is today's date. Then one line on the current state. Edit this block in place as state changes; the rest of the body keeps its original framing. + placeholder: | + 2026-06-26: filed, not started. + validations: + required: true + - type: dropdown + id: priority + attributes: + label: Priority + description: The value high means critical, release-gating, or actively blocking. Otherwise pick normal, since absence of high is the default. + options: + - high + - normal + validations: + required: true + - type: dropdown + id: complexity + attributes: + label: Complexity + description: A reader's estimate. This stays in the body; it is not a label. + options: + - small + - medium + - large + validations: + required: true + - type: textarea + id: goal + attributes: + label: Goal + description: What the rule should make true, and why it generalizes. Reference files by symbol or name, never by line number. + validations: + required: true + - type: textarea + id: acceptance + attributes: + label: Acceptance + description: The observable conditions, ideally a runnable command with a stated expected result, that prove the change is done. + validations: + required: true diff --git a/third_party/rules-swift/.github/pull_request_template.md b/third_party/rules-swift/.github/pull_request_template.md new file mode 100644 index 0000000..122fccf --- /dev/null +++ b/third_party/rules-swift/.github/pull_request_template.md @@ -0,0 +1,18 @@ + + +## What this changes + + + +## Linked issue + + +Closes # + +## Checklist + +- [ ] One cohesive change; unrelated concerns are in separate PRs (Rule 3.1). +- [ ] `CHANGELOG.md` updated if production source changed; otherwise `[no-changelog]` is justified (Rule 3.2). +- [ ] Self-critic pass done: I read my own diff as a reviewer and fixed what it surfaced (Rule 3.3). +- [ ] No AI/tool attribution and no em dashes in any committed text (Rules 5.1, 5.2). +- [ ] Gates pass locally: `sh scripts/leak-gate.sh` and `sh scripts/check-namespacing.sh` are green. diff --git a/third_party/rules-swift/.github/workflows/labeler.yml b/third_party/rules-swift/.github/workflows/labeler.yml new file mode 100644 index 0000000..c2ceaf8 --- /dev/null +++ b/third_party/rules-swift/.github/workflows/labeler.yml @@ -0,0 +1,29 @@ +name: issue-priority-labeler +# git-discipline.md Rule 1.6 "Forms gotcha": dropdown selections are TEXT in the body, +# they do not auto-apply as labels. This translates the Priority dropdown into the +# `priority: high` label when, and only when, the chosen value is "high". +on: + issues: + types: [opened, edited] +permissions: + issues: write +jobs: + apply-priority-label: + runs-on: ubuntu-latest + steps: + - uses: actions/github-script@v7 + with: + script: | + const body = context.payload.issue.body || ""; + // The form renders the dropdown under a "### Priority" heading; read the next non-empty line. + const m = body.match(/^###\s+Priority\s*$\n+([^\n]+)/m); + const value = m ? m[1].trim().toLowerCase() : ""; + const label = "priority: high"; + const has = (context.payload.issue.labels || []).some(l => l.name === label); + const owner = context.repo.owner, repo = context.repo.repo; + const issue_number = context.payload.issue.number; + if (value === "high" && !has) { + await github.rest.issues.addLabels({ owner, repo, issue_number, labels: [label] }); + } else if (value !== "high" && has) { + await github.rest.issues.removeLabel({ owner, repo, issue_number, name: label }).catch(() => {}); + } diff --git a/docs/rules/rules-swift/.github/workflows/leak-gate.yml b/third_party/rules-swift/.github/workflows/leak-gate.yml similarity index 62% rename from docs/rules/rules-swift/.github/workflows/leak-gate.yml rename to third_party/rules-swift/.github/workflows/leak-gate.yml index e3b658f..3f2e72f 100644 --- a/docs/rules/rules-swift/.github/workflows/leak-gate.yml +++ b/third_party/rules-swift/.github/workflows/leak-gate.yml @@ -1,12 +1,15 @@ -name: leak-gate +name: gates on: push: branches: [main] pull_request: + workflow_dispatch: jobs: - leak-gate: + gates: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Run the public-safe leak gate run: sh scripts/leak-gate.sh + - name: Run the namespacing gate + run: sh scripts/check-namespacing.sh diff --git a/docs/rules/rules-swift/.gitignore b/third_party/rules-swift/.gitignore similarity index 100% rename from docs/rules/rules-swift/.gitignore rename to third_party/rules-swift/.gitignore diff --git a/docs/rules/rules-swift/.pre-commit-config.yaml b/third_party/rules-swift/.pre-commit-config.yaml similarity index 100% rename from docs/rules/rules-swift/.pre-commit-config.yaml rename to third_party/rules-swift/.pre-commit-config.yaml diff --git a/docs/rules/rules-swift/.swiftformat b/third_party/rules-swift/.swiftformat similarity index 100% rename from docs/rules/rules-swift/.swiftformat rename to third_party/rules-swift/.swiftformat diff --git a/docs/rules/rules-swift/.swiftlint.yml b/third_party/rules-swift/.swiftlint.yml similarity index 100% rename from docs/rules/rules-swift/.swiftlint.yml rename to third_party/rules-swift/.swiftlint.yml diff --git a/third_party/rules-swift/CHANGELOG.md b/third_party/rules-swift/CHANGELOG.md new file mode 100644 index 0000000..5c79c33 --- /dev/null +++ b/third_party/rules-swift/CHANGELOG.md @@ -0,0 +1,39 @@ +# Changelog + +All notable changes to this rule set are documented here. This project follows Semantic Versioning. + +## [Unreleased] + +A conformance pass bringing the repository into agreement with its own rules. + +### Added + +- Vendored `core/oracle-first.md` from the engineering-discipline upstream: build the answer key (Oracle, comparison law, failure report, unsupported-case classification, parity gate) before any system or new correctness surface, including the "a codified expectation is a witness, not a verdict" guidance. Indexed in `core/README.md` and the README core-spine list. +- Acceptance check sections for the rule files that lacked one (per `core/rules.md`): `core/first-principles-analysis`, `core/systematic-debugging`, `concurrency`, `linux-server`, `ui/native-per-platform`, and the ExtremePackaging set (`exp/build-performance`, `exp/common-mistakes`, `exp/critical-rules`, `exp/dependency-management`, `exp/implementation-patterns`, `exp/package-swift`). +- Namespacing gate `scripts/check-namespacing.sh` (one non-private top-level type per file, per `code-style.md`), wired into the `pre-commit` hook and the gates CI workflow. +- GitHub issue forms (feature, bug) with required status/priority/complexity fields, a pull-request template, and an issue-priority labeler workflow, per `core/git-discipline.md`. +- README CI and license badges, and a License section. + +### Fixed + +- Broken cross-reference links in `CONVENTIONS.md` and `code-style.md`. + +### Changed + +- Label set trimmed to the brutal-minimum per `core/git-discipline.md` (added `epic` and `priority: high`; removed the decorative stock labels), with the Rule 2.3 semantic color palette. +- Synced the vendored core spine to the engineering-discipline upstream: `core/systematic-debugging.md` gains "Rule 6: Debugging against a differential or parity oracle" and two anti-patterns; `core/verification.md` and `core/git-discipline.md` gain the GitHub-hosted vs self-hosted CI runner escalation rule; `core/testing-discipline.md` gains the hosted-first runner note; `core/proof-discipline.md` and `core/no-shortcuts-first-principles.md` gain `oracle-first.md` cross-references. The local acceptance-check sections in `core/first-principles-analysis.md` and `core/systematic-debugging.md` are preserved. + +## [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/rules-swift/CONTRIBUTING.md b/third_party/rules-swift/CONTRIBUTING.md similarity index 100% rename from docs/rules/rules-swift/CONTRIBUTING.md rename to third_party/rules-swift/CONTRIBUTING.md diff --git a/docs/rules/rules-swift/CONVENTIONS.md b/third_party/rules-swift/CONVENTIONS.md similarity index 92% rename from docs/rules/rules-swift/CONVENTIONS.md rename to third_party/rules-swift/CONVENTIONS.md index b14c1d9..f74ab07 100644 --- a/docs/rules/rules-swift/CONVENTIONS.md +++ b/third_party/rules-swift/CONVENTIONS.md @@ -9,12 +9,12 @@ 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 +[`core/`](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/). +This page is the overview. The full per-area Swift rules live at the repository +root (index at [`README.md`](README.md)), and the cross-cutting rules in +[`core/`](core/). ## Language @@ -100,7 +100,7 @@ Tiledown builds and runs on Apple platforms and Linux, with Windows where explic ## Verification before "done" -The discipline lives in [`core/verification.md`](rules/core/verification.md): no +The discipline lives in [`core/verification.md`](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: @@ -114,8 +114,8 @@ For Swift, match the command to the claim: ## 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 +See [`core/commits.md`](core/commits.md) and +[`core/git-discipline.md`](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/rules-swift/LICENSE b/third_party/rules-swift/LICENSE similarity index 100% rename from docs/rules/rules-swift/LICENSE rename to third_party/rules-swift/LICENSE diff --git a/docs/rules/rules-swift/README.md b/third_party/rules-swift/README.md similarity index 87% rename from docs/rules/rules-swift/README.md rename to third_party/rules-swift/README.md index b6f1629..bb240ed 100644 --- a/docs/rules/rules-swift/README.md +++ b/third_party/rules-swift/README.md @@ -1,5 +1,8 @@ # Swift coding rules (canonical) +[![gates](https://github.com/mihaelamj/rules-swift/actions/workflows/leak-gate.yml/badge.svg)](https://github.com/mihaelamj/rules-swift/actions/workflows/leak-gate.yml) +[![license: CC BY 4.0 (prose), MIT (code)](https://img.shields.io/badge/license-CC%20BY%204.0%20prose%2C%20MIT%20code-blue)](LICENSE) + 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 @@ -19,6 +22,8 @@ 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/oracle-first.md` - build the answer key before the system: the Oracle, + comparison law, failure report, unsupported-case classification, and parity gate. - `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, @@ -78,7 +83,8 @@ domain that enforces them. ## Packages - [package-structure.md](package-structure.md) - workspace and package layout: one - `Package.swift` under `Packages/`, many targets, `Apps/` for app targets. + active `Package.swift`; root SPM packages use root `Sources/` and `Tests/`, + project monorepos use `Packages/` plus `Apps/`. - [package-architecture.md](package-architecture.md) - single-responsibility targets with unidirectional dependencies. - [package-import-contract.md](package-import-contract.md) - per-target allowed @@ -132,7 +138,8 @@ core files are loaded on every Swift session; the rest load on demand. 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/native-per-platform.md](ui/native-per-platform.md) - MANDATORY: optimize for the user via genuinely native UI per platform; per-device divergence is welcome; never trade the native experience for developer or test convenience or a write-once lowest-common-denominator. Governs and is superior to three-renderers. +- [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. Subordinate to native-per-platform: applies only where the same UI is also the best native UI. - [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. @@ -142,3 +149,7 @@ The UI rules live under [`ui/`](ui/). Any code with UI keeps the renderer thin o - [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. + +## License + +Dual-licensed by content type, see [LICENSE](LICENSE): the prose (every `.md` rule document) under CC BY 4.0, any code under MIT. diff --git a/docs/rules/rules-swift/business-rules-and-constraints.md b/third_party/rules-swift/business-rules-and-constraints.md similarity index 100% rename from docs/rules/rules-swift/business-rules-and-constraints.md rename to third_party/rules-swift/business-rules-and-constraints.md diff --git a/docs/rules/rules-swift/code-style.md b/third_party/rules-swift/code-style.md similarity index 98% rename from docs/rules/rules-swift/code-style.md rename to third_party/rules-swift/code-style.md index 5a6ba5b..53ee110 100644 --- a/docs/rules/rules-swift/code-style.md +++ b/third_party/rules-swift/code-style.md @@ -306,7 +306,7 @@ For cross-cutting namespaces (e.g. a `Tile` namespace that touches multiple SPM #### 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). +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-commit) and in CI; see [core/verification.md](core/verification.md). Strict reading: @@ -349,7 +349,10 @@ extension Core.Parser { Acceptance check: ```bash -for f in $(find Packages/Sources -name "*.swift"); do +roots=() +[ -d Sources ] && roots+=(Sources) +[ -d Packages/Sources ] && roots+=(Packages/Sources) +for f in $(find "${roots[@]}" -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 diff --git a/docs/rules/rules-swift/concurrency.md b/third_party/rules-swift/concurrency.md similarity index 92% rename from docs/rules/rules-swift/concurrency.md rename to third_party/rules-swift/concurrency.md index e945946..6567682 100644 --- a/docs/rules/rules-swift/concurrency.md +++ b/third_party/rules-swift/concurrency.md @@ -210,6 +210,28 @@ Avoid in new Swift 6 code: - `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. +## Acceptance check + +Conforms when each binding clause above is shown to hold: + +- **Every escape hatch is justified.** Every `@unchecked Sendable` and every `nonisolated(unsafe)` carries a one-line comment beside or above it explaining why the race is acceptable. List them and read the neighbouring line: + +```bash +grep -rnE '@unchecked Sendable|nonisolated\(unsafe\)' Sources/ Tests/ +``` + + Every hit MUST have an adjacent justifying comment; an unjustified one is a violation. + +- **The anti-patterns are absent from new code.** The following finds no lines added by the change under review: + +```bash +grep -rnE 'DispatchQueue\.main\.async|NSLock|pthread_mutex|XCTestExpectation' Sources/ Tests/ +``` + + Each surviving hit must be a deliberate legacy-bridging exception named in a comment (the `withCheckedContinuation` / `XCTestExpectation` "only when wrapping legacy callback APIs" carve-out), not a default reach. + +- **Observable checklist** for the clauses no grep settles, each phrased so two reviewers agree: public value types on the API surface declare `: Sendable` explicitly and error enums always do; `actor` appears only for cross-task shared mutable state, not for data that could be a copied struct; `@MainActor` does not appear in a non-UI CLI/library target to "make it compile"; `withTaskGroup` calls are bounded, never unbounded; and no new type pairs a hand-rolled lock with `@unchecked Sendable` (the "use `actor`" clause). + ## 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. diff --git a/docs/rules/rules-swift/core/README.md b/third_party/rules-swift/core/README.md similarity index 82% rename from docs/rules/rules-swift/core/README.md rename to third_party/rules-swift/core/README.md index cf3a27b..993da3c 100644 --- a/docs/rules/rules-swift/core/README.md +++ b/third_party/rules-swift/core/README.md @@ -9,6 +9,7 @@ The premise is simple and demanding: **choose the optimal path, not the fastest | 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. | +| `oracle-first.md` | Build the answer key before the system: define the Oracle, comparison law, failure report, unsupported cases, and parity gate before production work. | | `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). | @@ -26,7 +27,7 @@ The premise is simple and demanding: **choose the optimal path, not the fastest ## 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. +Point your agent (or yourself) at this directory as always-loaded context. The ethic rules (`no-shortcuts-first-principles`, `oracle-first`, `proof-discipline`, `verification`, `testing-discipline`) apply on every task that makes or evaluates a correctness claim. The process rules (`brainstorming`, `writing-plans`, `commits`, `file-naming`) apply when their topic comes up. ## License diff --git a/docs/rules/rules-swift/core/brainstorming.md b/third_party/rules-swift/core/brainstorming.md similarity index 100% rename from docs/rules/rules-swift/core/brainstorming.md rename to third_party/rules-swift/core/brainstorming.md diff --git a/docs/rules/rules-swift/core/commits.md b/third_party/rules-swift/core/commits.md similarity index 98% rename from docs/rules/rules-swift/core/commits.md rename to third_party/rules-swift/core/commits.md index 6de8394..92ca0bc 100644 --- a/docs/rules/rules-swift/core/commits.md +++ b/third_party/rules-swift/core/commits.md @@ -252,12 +252,12 @@ Committed text is authored by the people who made the change. Commit messages, P 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. # style-check: allow -- 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. # style-check: allow +- 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. # style-check: allow -- 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). # style-check: allow +- 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. diff --git a/third_party/rules-swift/core/file-naming.md b/third_party/rules-swift/core/file-naming.md new file mode 100644 index 0000000..361421e --- /dev/null +++ b/third_party/rules-swift/core/file-naming.md @@ -0,0 +1,112 @@ +# File Naming Rules + +**Status: house convention for committed filenames.** Every committed document or asset 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. + +## Scope + +This rule governs committed **documents and assets**: scanned documents, photos, exports, dated records, and the other non-code files a repository accumulates. Two classes are out of scope and are never flagged by the audit below, because a different authority already names them: + +- **Source code.** A source file follows its language's own naming convention. A Swift type file is `PascalCase`, named for the single type it holds, and a framework-style internal type may carry a leading underscore (`_ConditionalContent.swift`); a Go file is lowercase; and so on. That language convention is the authority for those files, and the code-style rule of each language domain owns it. This rule does not override it, so source trees are excluded from the audit. +- **Conventional and tool-mandated names.** The uppercase names a tool or ecosystem expects verbatim: the repository-root files `LICENSE`, `CHANGELOG`, `CONTRIBUTING`, `CODEOWNERS`, `NOTICE`, `AUTHORS`, and `README`; the platform infrastructure under `.github/` (`ISSUE_TEMPLATE/`, `PULL_REQUEST_TEMPLATE`, `FUNDING.yml`); and the build and package manifests a toolchain loads by an exact name (`Package.swift`, `Package.resolved`, `Package@swift-5.9.swift`, `Dockerfile`, `Makefile`, `Gemfile`, `Rakefile`, `Podfile`, `Cartfile`, `Fastfile`). Renaming any of them would break the tool that looks for them. + +## 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: + +Point `` at the document and asset directories the rule governs, or add a `-not -path "*//*"` for each source tree (`Sources`, `Tests`, `src`), so the queries do not flag source files that follow their language convention (see Scope). + +```bash +# Files with spaces +find -name "* *" -not -path "*/.git/*" -not -name "*.md" + +# Files with uppercase (documents and assets only; source files and the +# conventional or tool-mandated names of the Scope section are out of scope) +find -regex ".*/[^/]*[A-Z][^/]*" -not -path "*/.git/*" -not -path "*/.github/*" \ + -not -name "*.md" \ + -not -name "README*" -not -name "LICENSE" -not -name "CHANGELOG" \ + -not -name "CONTRIBUTING" -not -name "CODEOWNERS" -not -name "NOTICE" \ + -not -name "AUTHORS" \ + -not -name "Package.swift" -not -name "Package.resolved" -not -name "Package@*.swift" \ + -not -name "Dockerfile" -not -name "Makefile" -not -name "Gemfile" \ + -not -name "Rakefile" -not -name "Podfile" -not -name "Cartfile" -not -name "Fastfile" + +# Files with underscores (excluding .git and the tool-mandated .github tree) +find -name "*_*" -not -path "*/.git/*" -not -path "*/.github/*" -not -name "*.md" + +# HEIC files +find \( -name "*.heic" -o -name "*.HEIC" \) -not -path "*/.git/*" +``` + +Run over the documents and assets in scope, a clean repo returns nothing from the first three queries: the `.md`, `README`, and conventional-root-file exclusions cover the in-scope exceptions, and the source-tree exclusion keeps the audit off code, which the language's own code-style rule governs (Scope). + +## 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/rules-swift/core/first-principles-analysis.md b/third_party/rules-swift/core/first-principles-analysis.md similarity index 91% rename from docs/rules/rules-swift/core/first-principles-analysis.md rename to third_party/rules-swift/core/first-principles-analysis.md index 0e3047e..c8b9dfa 100644 --- a/docs/rules/rules-swift/core/first-principles-analysis.md +++ b/third_party/rules-swift/core/first-principles-analysis.md @@ -205,6 +205,26 @@ When in doubt, default to first-principles. Lighter docs are easy to retro-fit; - `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. +## Acceptance check + +A first-principles analysis artifact conforms when it is auditable from the text itself: + +1. It has a prerequisites or glossary section for the language, libraries, protocols, formats, data model, and domain terms the rest of the document uses. +2. Each non-trivial algorithm, parser, transformation, or workflow has at least one concrete worked input and output. +3. It includes an end-to-end worked example that follows one real input through every relevant component to the final output. +4. It closes with a reimplementation roadmap or equivalent porting plan that names phase order and the hard parts. +5. Every numeric or factual claim is tagged as `MEASURED`, `DERIVED`, `RANGED`, `STRUCTURAL`, or `DOCUMENTED`, with the cited command, formula, file path, or source beside the claim. +6. None of the forbidden softeners appear next to an empirical claim unless the claim is tagged with reproducible evidence. + +Runnable spot checks: + +```sh +rg -n 'typically|usually|approximately|roughly|~[0-9]|tens of|a few hundred|around [0-9]|on the order of' docs/ +rg -n '[0-9]' docs/ +``` + +The first command should return no untagged empirical claims. The second command is a review queue: every surviving numeric or factual claim in the touched artifact must carry one of the five claim tags or be an identifier, code literal, date, version, or other non-empirical token. + ## 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/rules-swift/core/folder-grouping.md b/third_party/rules-swift/core/folder-grouping.md similarity index 100% rename from docs/rules/rules-swift/core/folder-grouping.md rename to third_party/rules-swift/core/folder-grouping.md diff --git a/docs/rules/rules-swift/core/git-discipline.md b/third_party/rules-swift/core/git-discipline.md similarity index 91% rename from docs/rules/rules-swift/core/git-discipline.md rename to third_party/rules-swift/core/git-discipline.md index a6087e0..5b8cd06 100644 --- a/docs/rules/rules-swift/core/git-discipline.md +++ b/third_party/rules-swift/core/git-discipline.md @@ -146,15 +146,15 @@ For every bug fix: file the issue first, then branch. The branch name carries th 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. # style-check: allow -- 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. # style-check: allow +- 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"). # style-check: allow -- 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. # style-check: allow +- 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. # style-check: allow +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. @@ -209,9 +209,19 @@ A badge is a claim; only claim a platform the repo can actually build or test on 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. +### Rule 8.5: Separate GitHub-hosted and self-hosted Actions explicitly + +Self-hosted runners are not a different CI system; they are GitHub Actions jobs running on owned hardware. Name the runner tier explicitly so readers know whether a green check came from GitHub-hosted infrastructure or from a machine such as the self-hosted Mac mini / Claw runner. + +- GitHub-hosted jobs use labels such as `ubuntu-latest`, `macos-latest`, or `windows-latest` and SHOULD be the default for portable gates. +- Self-hosted jobs use labels such as `[self-hosted, macos, claw]` and MUST have a job name, workflow name, or header comment that identifies the self-hosted tier. +- A failing GitHub-hosted job MAY move to self-hosted only after the failure report names a hosted-runner limitation. Examples: missing SDK or Xcode, Apple Silicon requirement, private dependency access, local network dependency, Docker/Swift SDK setup, runner quota, cache size, or timeout. +- A failing GitHub-hosted job MUST NOT move to self-hosted when the failure is a real test failure, compiler error, lint error, missing dependency declaration, or flaky harness. Fix the product or harness first. +- If both tiers exist for the same concern, keep them in separate workflows or clearly separate jobs and badges. Do not let one badge imply both tiers are green unless the badge endpoint actually covers both. + ## 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. +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; (7) each CI badge points at exactly one named workflow and claims only a genuinely-possible platform; and (8) every self-hosted workflow is explicitly named or commented as self-hosted, with a diagnosed hosted-runner limitation when it exists as a fallback from a hosted job. Mechanical backstops (body-drift scan, changelog gate, labeler, style hook) are installed, and the hooks are installed per clone. ## Companion rules diff --git a/docs/rules/rules-swift/core/no-shortcuts-first-principles.md b/third_party/rules-swift/core/no-shortcuts-first-principles.md similarity index 98% rename from docs/rules/rules-swift/core/no-shortcuts-first-principles.md rename to third_party/rules-swift/core/no-shortcuts-first-principles.md index 50ca250..bc55b29 100644 --- a/docs/rules/rules-swift/core/no-shortcuts-first-principles.md +++ b/third_party/rules-swift/core/no-shortcuts-first-principles.md @@ -53,6 +53,7 @@ A shortcut that no test catches is still a shortcut. The standard is internal, n ## Companion rules - `systematic-debugging.md`: Reproduce, Isolate, Explain, Fix. The anti-shortcut procedure for bugs. +- `oracle-first.md`: build the answer key before the system. The anti-shortcut procedure for new correctness surfaces. - `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. diff --git a/third_party/rules-swift/core/oracle-first.md b/third_party/rules-swift/core/oracle-first.md new file mode 100644 index 0000000..e0f35f3 --- /dev/null +++ b/third_party/rules-swift/core/oracle-first.md @@ -0,0 +1,206 @@ +# Oracle First (MANDATORY) + +**Status: MANDATORY when designing, building, rebuilding, or substantially extending any system or correctness surface.** Before production architecture, before performance work, and before "does it work" tests, build the Oracle: the independent answer key that says what correct output is, how it is compared, and which cases are outside the current claim. For a tiny system, the Oracle may be tiny: one invariant, one closed-form example, one round-trip law, or one executable contract. It still comes first. + +An Oracle is not just a test suite and not necessarily an external implementation. It is the correctness instrument for the system: the thing that can answer, for a given input, **what should have happened?** It may be a closed-form equation, a slow reference implementation, a live platform witness, a differential path, a curated corpus with a tolerance ledger, or a single canonical definition that prevents two paths from drifting. The implementation is not done until it passes the Oracle gate or the remaining frontier is named. + +## The core rule + +The first design artifact for a system MUST be its Oracle plan. The first runnable slice SHOULD be the smallest Oracle-backed slice. A production implementation without an Oracle is not "untested"; it is ungrounded. + +Before writing the production path, state: + +- **Correctness question.** What observable output must be right: bytes, pixels, layout frames, glyph ids, diagnostics, database rows, state transitions, network messages, numeric values, or something else. +- **Input domain.** Which inputs are in scope now, which are malformed, which are intentionally unsupported, and which are blocked. +- **Authority.** What makes the expected answer correct: specification clause, closed form, public formula, simpler reference implementation, live platform behavior, independent backend, curated fixture, or canonical shared definition. +- **Equivalence relation.** What counts as equal: byte-identical, structurally equal, same sorted set, same pixels, same floats within a derived bound, same diagnostics at the same source location. +- **Failure report.** What evidence a failing case must produce: fixture name, seed, input, expected, actual, comparator, tolerance id if any, and the unsupported/divergence status if it did not compare. + +If those five things are not known, the system is not ready for production implementation. + +## Choose the strongest Oracle available + +Prefer the Oracle with the strongest claim. Do not use a weaker witness when a stronger one is available. + +1. **Analytic / closed-form Oracle.** Best when the domain has math: geometry, transforms, physics, interpolation, codecs, color operations, parsers with formal grammars. The expected answer is derived from the formula or spec, not from another implementation. +2. **Slow literate reference implementation.** Best when the production system will be optimized, parallel, cached, GPU-backed, distributed, or otherwise hard to inspect. The reference is simple, direct, correctness-first, and allowed to be slow. +3. **Differential Oracle.** Best when the same source can travel through two independent paths: two lowerers, two backends, encode/decode, compiler/interpreter, software/GPU, direct engine/compiled engine. A disagreement localizes a defect. +4. **Live platform or external reference witness.** Use when the project explicitly claims behavioral parity with a platform, library, vendor renderer, browser, database, or OS API. Isolate it in an oracle/test target. Treat it as a fallible witness unless behavioral parity with that implementation is itself the spec. +5. **Evidence-ledger Oracle.** Use when correctness depends on a corpus, tolerance policy, or external toolchain. Pin the tool version, curate the corpus, keep a machine-readable tolerance ledger, keep a divergence ledger, and require every threshold to carry evidence. +6. **Single canonical definition.** Use when drift can be designed out. Generate both sides or route both paths through one definition, then test the independent invariants around it. This is stronger than hand-writing twin definitions and hoping they agree. +7. **Runtime fallback Oracle.** Use when fast paths are optional. A slow software path or reference backend remains always available; optimized paths must prove parity or fall back. + +Stored golden files are a backstop, not the primary Oracle, unless the artifact is itself the product. A golden image or snapshot rots when the engine changes; a live differential or closed-form Oracle explains why the new output is right or wrong. + +## Build the Oracle before the system + +The Oracle must exist early enough to shape the implementation. + +- The first feature slice MUST include the Oracle harness or a documented Oracle plan with the exact missing blocker. Do not build the production engine first and promise to "add conformance later." +- The Oracle SHOULD be smaller and simpler than the system it checks. If the checker is as complex as the implementation, it needs its own Oracle or a narrower claim. +- Production code MUST NOT import a test-only Oracle. Keep the dependency one-way: tests and conformance tools may depend on production; production must not depend on the test oracle. +- If the Oracle is also a runtime fallback, name that explicitly and keep the contract sharp: the fallback is the correctness path; optimized paths are optional accelerators. +- Every new feature MUST co-land with its Oracle case, fixture, or stated unsupported report. Backfilled correctness is a debt, not a plan. + +## Define comparison before implementation + +The comparator is part of the Oracle. It must be designed before a failure forces negotiation. + +- Use exact equality by default. +- Use tolerance only when exact equality is impossible for a named reason: antialiasing, floating-point order, platform font hinting, nondeterministic scheduling, lossy compression, clock resolution. +- Every tolerance MUST be derived or measured. A round number with no derivation is an invented number. +- Every tolerance SHOULD have an id and a ledger entry naming: feature, unit, comparator, threshold, reason, derivation status, arithmetic model, evidence, and at least one counterexample just outside the bound. +- Do not scatter numeric thresholds through tests. Put them behind named comparators or a ledger so the policy is reviewable. +- A comparison report MUST show enough data to reproduce and explain the mismatch: input id, seed if any, expected, actual, delta, tolerance id, and status. + +If a test says `<= 0.05` and no artifact explains why `0.05` is correct, the Oracle is incomplete. + +## Preserve independence + +An Oracle catches bugs only to the extent that it does not share the same bug. + +- Do not copy production logic into the Oracle. Re-derive from the spec, use closed form, use a simpler algorithm, or run an independent path. +- If both sides intentionally share a canonical primitive, say so. Then the Oracle no longer proves that primitive; it proves that all consumers use the same primitive and that the primitive satisfies independent invariants. +- External implementations are witnesses, not automatically truth. Specs and closed forms outrank vendor behavior unless the product's explicit promise is vendor parity. +- When the Oracle and implementation might share a derivation error, audit the Oracle with a second witness: a live platform, a closed-form case, a fault injection, or a hand-computed counterexample. +- Add negative controls. The Oracle must fail when the implementation is deliberately wrong in a representative way. + +A passing implementation and a passing Oracle can still both be wrong if they share the same mistaken assumption. The rule requires an audit path for that possibility. + +## A codified expectation is a witness, not a verdict + +Once a system has tests, property invariants, "laws", golden values, and issue write-ups, those become **codified expectations**, and a codified expectation can encode the wrong behavior exactly as a reference implementation can be a fallible witness. When a pinned test, a property law, a golden value, or an issue's stated root cause disagrees with the live Oracle or the authority (a specification clause, the source data, the strongest available witness), the expectation is a **suspect**, not the answer. Re-derive the correct result from the authority before assuming the production code is the wrong side. + +- When the Oracle and a codified expectation disagree, derive the answer from the **authority**: the spec clause, the source data, the strongest available Oracle. Then fix whichever of {code, test, law, issue} is wrong. Often it is the expectation, not the code. +- A property or invariant that asserts **more than the spec guarantees** is itself a defect. Restate it to its true, provable form with the clause cited; do not strengthen the code to satisfy a false law, and do not weaken a law to silence a real failure. Tell the two apart by deriving the spec's actual guarantee, not by which change makes the bar go green. +- Treat an issue's or a prior note's stated root cause as a **hypothesis to verify against the authority**, not a fact to implement against. A confident, wrong diagnosis sends the fix to the wrong layer. +- A golden value or fixture inherits no authority from being checked in. When it conflicts with a fresh re-derivation from the spec, the golden is the suspect. + +WRONG: + +```text +A property test fails after a fix. Weaken the assertion until it passes. +An issue says "the bundled table is stale, regenerate it." Regenerate it without checking the source. +``` + +RIGHT: + +```text +Re-derive the invariant from the spec. If the test encoded behavior stricter than the spec guarantees, restate the test to the spec's true invariant (clause cited) and keep the fix; if the code violated a real invariant, fix the code. If the issue's stated cause contradicts the source data, correct the issue and fix the layer that actually owns the defect. +``` + +## Classify every case + +Every input must land in exactly one bucket: + +- **handled**: compared against the Oracle and passed; +- **failed**: compared against the Oracle and did not pass; +- **unsupported**: reported as unsupported before producing a misleading result; +- **intentionally divergent**: different by design, with the reason named; +- **ineligible**: not compared because a prerequisite was absent, with the missing prerequisite named; +- **blocked**: not currently decidable, with the blocker and next instrument named. + +Silent skip is forbidden. A missing font, absent GPU, unavailable reference tool, unsupported syntax node, or invalid fixture must not make the suite pass vacuously. The harness must emit a non-vacuity check: at least one real eligible case exercised each claimed surface, or the run reports that the surface was not proven. + +## Use generated and randomized trips carefully + +Generated cases and randomized differential trips are powerful only when reproducible. + +- Seed every randomized run and print the seed on failure. +- Minimize or save failing inputs when possible. +- Generate by coverage design, not blind Cartesian explosion: boundaries, degenerates, pairwise interactions, order-sensitive permutations, and malformed cases. +- Keep curated fixtures separate from discovery corpora. Discovery finds bugs; curated fixtures prevent regressions. +- Do not let random passing replace named boundary cases. Random tests supplement the Oracle; they do not define it. + +## Optimized paths must prove parity or fall back + +For systems with multiple backends, caches, accelerators, or approximations: + +- A slow exact path SHOULD exist first. +- A fast path MUST declare what it supports. +- The caller MUST route unsupported cases to the Oracle/fallback path or report unsupported; it must not approximate silently. +- Parity tests MUST assert that the fast path actually claimed support before comparing output. A skipped fast path is not a parity pass. +- Whole-output averages are insufficient when they can hide local errors. Compare at the unit where a defect would be visible: per byte, per pixel region, per glyph, per row, per event, per source span. + +Speed is allowed only after correctness is established. The optimized path inherits the right to exist from parity with the Oracle. + +## WRONG / RIGHT + +WRONG: + +```text +Build the renderer, inspect a few screenshots, save one PNG as a golden, and call it done. +``` + +RIGHT: + +```text +Build a slow software/reference renderer first, define pixel equality and antialiasing tolerance, add analytic shape cases, add negative controls, then require the renderer to match the reference or report the unsupported feature. +``` + +WRONG: + +```text +Use a platform framework as "the oracle" for every case, even when the public spec gives a closed-form answer. +``` + +RIGHT: + +```text +Use the spec or closed form as ground truth. Use the platform framework as a witness. If the product promise is exact platform parity, isolate the platform harness and state that behavioral parity is the spec for that surface. +``` + +WRONG: + +```text +Skip the conformance test when the reference tool, GPU, font, fixture, or network service is missing. +``` + +RIGHT: + +```text +Report the case as ineligible with the missing prerequisite, and require a non-vacuity assertion for every surface claimed proven by the run. +``` + +WRONG: + +```text +Let the GPU backend render unsupported blend modes "close enough." +``` + +RIGHT: + +```text +Have the GPU backend declare unsupported blend modes, route them to the software Oracle, and add a parity test for every blend mode it does claim. +``` + +## Acceptance check + +A new or substantially changed system conforms when: + +1. An Oracle plan exists before the production implementation claim, naming the correctness question, input domain, authority, equivalence relation, and failure report. +2. A runnable Oracle harness, reference implementation, closed-form check, differential path, generated canonical definition, or explicitly blocked Oracle plan exists in the repo. +3. The Oracle is independent from production logic, or the shared canonical definition is named and independent invariants cover the shared primitive. +4. The comparator uses exact equality or a named, derived/measured tolerance. Grep finds no unexplained magic tolerance added by the change. +5. The harness has at least one negative control, counterexample, fault injection, or deliberately wrong case proving the Oracle can fail. +6. Unsupported, intentionally divergent, ineligible, and blocked cases are reported explicitly. No case disappears through a silent skip. +7. Random or generated cases are reproducible by seed or fixture id. +8. Optimized paths either pass parity against the Oracle or fall back/report unsupported. +9. The completion report states which Oracle gate ran and what it proved. If the gate did not run, the report names the blocker and the unproven frontier. +10. When the change resolved a conflict between the implementation and a codified expectation (a test, a property law, a golden value, an issue's stated cause), the resolution cites the authority (spec clause, source data, or stronger Oracle) that decided which side was wrong. It did not weaken a real invariant to pass, nor strengthen the code to satisfy a false one. + +A system that ships production behavior first and adds an answer key later fails this rule even if conventional tests pass. A system with a beautiful test suite but no independent way to know the expected answer also fails this rule. + +## Companion rules + +- `no-shortcuts-first-principles.md`: the ethic this specializes. Building without an answer key is partial coverage dressed as correctness. +- `proof-discipline.md`: how to label what the Oracle proves, witnesses, samples, assumes, or leaves blocked. +- `testing-discipline.md`: how the Oracle becomes executable tests and gates. +- `verification.md`: no completion claim without fresh Oracle/test evidence. +- `round-trip-transformation.md`: the special case where the Oracle is the round-trip law and the invertible construction. +- `first-principles-analysis.md`: how to derive and record measured bounds, counts, and tolerance evidence. +- `systematic-debugging.md`: when the Oracle fails, reproduce, isolate, explain, then fix. + +## Why this exists + +Most systems are built backward: implementation first, tests second, correctness story last. That creates polished machinery with no independent answer to "is this right?" The Oracle-first rule reverses the order. It forces the team to define truth before optimizing toward it, exposes unsupported cases before they become silent wrong output, and makes correctness a designed artifact rather than an after-the-fact argument. diff --git a/docs/rules/rules-swift/core/proof-discipline.md b/third_party/rules-swift/core/proof-discipline.md similarity index 97% rename from docs/rules/rules-swift/core/proof-discipline.md rename to third_party/rules-swift/core/proof-discipline.md index d525772..f50ea89 100644 --- a/docs/rules/rules-swift/core/proof-discipline.md +++ b/third_party/rules-swift/core/proof-discipline.md @@ -7,7 +7,8 @@ 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 +`oracle-first.md` (build the answer key before the system), +`no-shortcuts-first-principles.md` (disclosed limits, never hidden debt), and `round-trip-transformation.md`, which governs the special case of an invertible transform. @@ -141,6 +142,9 @@ even if its suite is green. - `no-shortcuts-first-principles.md`: the ethic this specializes. Disclosed limits are fine, hidden debt is the violation; total correctness over enumerated cases. +- `oracle-first.md`: the upstream design discipline. It defines the answer key, + comparison law, and failure report this rule later labels as theorem, witnessed, + sampled, assumed, or blocked. - `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). diff --git a/docs/rules/rules-swift/core/round-trip-transformation.md b/third_party/rules-swift/core/round-trip-transformation.md similarity index 100% rename from docs/rules/rules-swift/core/round-trip-transformation.md rename to third_party/rules-swift/core/round-trip-transformation.md diff --git a/docs/rules/rules-swift/core/rules.md b/third_party/rules-swift/core/rules.md similarity index 100% rename from docs/rules/rules-swift/core/rules.md rename to third_party/rules-swift/core/rules.md diff --git a/docs/rules/rules-swift/core/self-improve.md b/third_party/rules-swift/core/self-improve.md similarity index 100% rename from docs/rules/rules-swift/core/self-improve.md rename to third_party/rules-swift/core/self-improve.md diff --git a/docs/rules/rules-swift/core/systematic-debugging.md b/third_party/rules-swift/core/systematic-debugging.md similarity index 57% rename from docs/rules/rules-swift/core/systematic-debugging.md rename to third_party/rules-swift/core/systematic-debugging.md index 3daeb78..e41eb28 100644 --- a/docs/rules/rules-swift/core/systematic-debugging.md +++ b/third_party/rules-swift/core/systematic-debugging.md @@ -71,6 +71,15 @@ State, in this order, when reporting on a debugging session: 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). +### Rule 6: Debugging against a differential or parity oracle + +When the bug is a disagreement with a reference (parity with a platform, two paths that must agree, a conformance corpus), the four phases still apply, but the FIX phase is iterative and the regression set is part of the instrument. + +- **A regression from your fix is a measurement, not a defeat.** A fix that closes the target case but breaks another has just told you a case the rule must also satisfy. Read the new failure to refine the rule toward the exact behavior the authority exhibits, then re-run the whole parity corpus. Successive regressions converge the rule on the spec; do not abandon a correct direction at the first one. +- **Run the full parity set after every change.** The regression corpus is the specification you are reverse-engineering; a green target case with an unrun corpus proves nothing. +- **Revert a net-negative fix.** If the change trades a *benign* divergence, one that does not alter the observable output, for a *real* one that does, it is wrong even though it closes the original case. Revert it, pin the cases that were already correct, and disclose the gap with its evidence rather than shipping a regression. (See `oracle-first.md`: classify divergences; the comparison law decides what is observable.) +- **Classify the divergence by the comparator's visible unit before chasing it.** A difference that does not change the observable output, a caret index, an internal array order, a sub-threshold position, is not worth destabilizing a delicate, heavily-tested path to fix, and never worth trading for a real one. + ## Anti-patterns - "I think it might be X, let me try" applied to multiple guesses in series. @@ -78,9 +87,30 @@ State, in this order, when reporting on a debugging session: - 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. +- Abandoning a correct fix at its first regression instead of reading the regression as the case the rule has not yet satisfied. +- Trading a cosmetic parity diff for a real one to close the original ticket. ## 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. + +## Acceptance check + +A debugging change conforms when the issue, PR, commit message, or final report contains these observable items: + +1. **Reproduction:** the exact failing command, input, or user action is named, with expected and actual behavior. +2. **Isolation:** the smallest implicated component, file, function, or invariant is named. If the failure was flaky, the race or nondeterministic condition is treated as the bug. +3. **Root cause:** one sentence explains why the symptom happened, including the wrong assumption or violated invariant. +4. **Fix:** the change addresses that cause directly. It does not swallow the error, skip the failing check, add a sleep, add a retry, broaden a catch, or apply unrelated guesses. +5. **Regression proof:** a test, fixture, assertion, or documented manual check that would have caught the bug is added or updated, unless the report states why no such check is possible. +6. **Verification:** the relevant suite is re-run and the command plus result counts are cited. + +Runnable spot check: + +```sh +rg -n 'try\\?|catch \\{|sleep\\(|Task\\.sleep|XCTSkip|\\.skip\\(|disabled|TODO.*test|swiftlint:disable' Sources/ Tests/ +``` + +Every hit introduced by the debugging change must be justified in adjacent text as part of the root-cause fix. An unjustified hit fails this rule even if the immediate failing test now passes. diff --git a/docs/rules/rules-swift/core/testing-discipline.md b/third_party/rules-swift/core/testing-discipline.md similarity index 94% rename from docs/rules/rules-swift/core/testing-discipline.md rename to third_party/rules-swift/core/testing-discipline.md index 3677d73..26b5eeb 100644 --- a/docs/rules/rules-swift/core/testing-discipline.md +++ b/third_party/rules-swift/core/testing-discipline.md @@ -79,6 +79,8 @@ Where the project supports it, add the test run to: 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. +Start CI tests on GitHub-hosted runners when the suite can run there. Escalate to a self-hosted runner only after the hosted failure is diagnosed as a runner limitation: missing SDK/toolchain, required hardware or architecture, private network or secret access, Docker/toolchain setup that hosted runners cannot support, runner minutes, cache size, or timeout. A real product failure on a hosted runner must be fixed, not hidden by moving the job. + ## 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. diff --git a/docs/rules/rules-swift/core/verification.md b/third_party/rules-swift/core/verification.md similarity index 71% rename from docs/rules/rules-swift/core/verification.md rename to third_party/rules-swift/core/verification.md index d4d0fa0..dba806f 100644 --- a/docs/rules/rules-swift/core/verification.md +++ b/third_party/rules-swift/core/verification.md @@ -65,6 +65,22 @@ A typical split: 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. +### CI runner escalation + +GitHub-hosted Actions are the default runner tier for portable gates. A self-hosted runner is still GitHub Actions, but it is a separate escalation tier that runs the workflow on owned hardware. Do not blur the two in workflow names, badges, or failure reports. + +- MUST start with GitHub-hosted runners for gates that can run on `ubuntu-latest`, `macos-latest`, or `windows-latest`. +- MUST NOT move a failing gate to a self-hosted runner until the failure is diagnosed as a hosted-runner limitation, not a product bug, test failure, missing dependency declaration, or flaky harness. +- MAY use self-hosted runners when the gate needs hardware, SDKs, architecture, secrets, local network access, Docker/toolchain setup, cache size, runner minutes, or wall-clock limits that hosted runners cannot provide. +- MUST name or comment the self-hosted reason in the workflow, especially for jobs using labels such as `runs-on: [self-hosted, macos, claw]`. +- MUST keep the same verification contract on self-hosted runners: run the real format, lint, build, test, oracle, or parity gate and report the same pass/fail evidence a hosted runner would. + +WRONG: `swift test` fails on `macos-latest`, so change the job to `runs-on: [self-hosted, macos, claw]` and call the build fixed. + +RIGHT: reproduce the hosted failure, identify the hosted-runner blocker (for example missing Swift SDK, Xcode version, Apple Silicon requirement, private dependency access, Docker/Swift SDK setup, or timeout), then add or move to a self-hosted job with a workflow comment naming that blocker. + +Acceptance check: every self-hosted workflow has an explicit `self-hosted` runner label and a nearby comment, job name, or README/badge text that says why hosted runners are insufficient. A self-hosted job that only makes a red hosted test disappear without a diagnosis fails this rule. + ## Anti-patterns - "All set" with no command output in the response diff --git a/docs/rules/rules-swift/core/writing-plans.md b/third_party/rules-swift/core/writing-plans.md similarity index 100% rename from docs/rules/rules-swift/core/writing-plans.md rename to third_party/rules-swift/core/writing-plans.md diff --git a/docs/rules/rules-swift/cross-platform.md b/third_party/rules-swift/cross-platform.md similarity index 99% rename from docs/rules/rules-swift/cross-platform.md rename to third_party/rules-swift/cross-platform.md index 6477eda..daf9bac 100644 --- a/docs/rules/rules-swift/cross-platform.md +++ b/third_party/rules-swift/cross-platform.md @@ -331,7 +331,7 @@ No `#if canImport(FoundationNetworking)` needed. AsyncHTTPClient is the canonica ### 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). +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.2-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. @@ -436,7 +436,7 @@ Linux CI runs on every PR. Minimum job: ```yaml linux-build: runs-on: ubuntu-latest - container: swift:6.0-jammy + container: swift:6.2-jammy steps: - uses: actions/checkout@v4 - run: swift build -c release --product @@ -453,7 +453,7 @@ macOS CI is sufficient. Optional iOS Simulator + visionOS simulator on tagged PR ```yaml linux-test: runs-on: ubuntu-latest - container: swift:6.0-jammy + container: swift:6.2-jammy steps: - uses: actions/checkout@v4 - run: swift test --filter diff --git a/docs/rules/rules-swift/dependency-injection.md b/third_party/rules-swift/dependency-injection.md similarity index 100% rename from docs/rules/rules-swift/dependency-injection.md rename to third_party/rules-swift/dependency-injection.md diff --git a/docs/rules/rules-swift/documentation-search.md b/third_party/rules-swift/documentation-search.md similarity index 100% rename from docs/rules/rules-swift/documentation-search.md rename to third_party/rules-swift/documentation-search.md diff --git a/docs/rules/rules-swift/documentation.md b/third_party/rules-swift/documentation.md similarity index 98% rename from docs/rules/rules-swift/documentation.md rename to third_party/rules-swift/documentation.md index 885aadf..d833815 100644 --- a/docs/rules/rules-swift/documentation.md +++ b/third_party/rules-swift/documentation.md @@ -85,7 +85,7 @@ Content with real code listings, tables, lists. ### 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 use plain markdown paths (`Sources/Foo/README.md` or `Packages/Sources/Foo/README.md`, matching repository shape) 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. @@ -137,7 +137,7 @@ Reviewers MUST block approval when code drifts from docs. If the column "Update" Build from CLI: ```bash -cd Packages +cd swift package generate-documentation --target {Name}Documentation ``` diff --git a/docs/rules/rules-swift/domain-first.md b/third_party/rules-swift/domain-first.md similarity index 100% rename from docs/rules/rules-swift/domain-first.md rename to third_party/rules-swift/domain-first.md diff --git a/docs/rules/rules-swift/exp/README.md b/third_party/rules-swift/exp/README.md similarity index 84% rename from docs/rules/rules-swift/exp/README.md rename to third_party/rules-swift/exp/README.md index 93d3c19..8a083b9 100644 --- a/docs/rules/rules-swift/exp/README.md +++ b/third_party/rules-swift/exp/README.md @@ -2,7 +2,7 @@ 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. +Follow the "ExtremePackaging" architecture pattern: maximum granular modularization into distinct SwiftPM targets inside the repository's active package. In a package repo the manifest is at the root; in a project monorepo it is under `Packages/`. Each target represents a single cohesive responsibility with explicit dependencies. This enables isolated compilation, parallel builds, clear dependency graphs, and superior testability. ## Always-load core @@ -29,10 +29,10 @@ These two files are loaded in every Swift session. Together they cover the rule ## 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. +The canonical Swift monorepo this rule was derived from has the following target layout. Treat as illustrative, not normative; your target list will differ. Package repos use the same tree under root `Sources/`; project monorepos use it under `Packages/Sources/`. ``` -Packages/Sources/ +Sources/ # or Packages/Sources/ in a project monorepo ├── Foundation Layer (0 dependencies) │ ├── SharedModels # Domain models, no dependencies │ ├── AppColors # Color system (HSV, semantic colors) diff --git a/docs/rules/rules-swift/exp/app-target.md b/third_party/rules-swift/exp/app-target.md similarity index 100% rename from docs/rules/rules-swift/exp/app-target.md rename to third_party/rules-swift/exp/app-target.md diff --git a/third_party/rules-swift/exp/build-performance.md b/third_party/rules-swift/exp/build-performance.md new file mode 100644 index 0000000..467f05f --- /dev/null +++ b/third_party/rules-swift/exp/build-performance.md @@ -0,0 +1,69 @@ +# 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 +``` + +## Acceptance check + +This is a rationale doc, not a binding rule, so it ships an observable checklist a reviewer applies to +any change that touches the package graph, to confirm the build-time benefits above were preserved. +Each item is phrased so two reviewers agree: + +- **Granularity preserved.** The change does not merge two single-responsibility packages into one, so + a touch to one concern still rebuilds only that package and its dependents, not a wider blast radius. + FAIL if files from two distinct responsibilities are folded into a single target. +- **No new wide-fan-in edge.** The change adds no dependency that makes a low-level, frequently-edited + package gain many new dependents (which would force a large rebuild on every edit to it). FAIL if a + foundation package gains a new upward dependency, or a widely-depended-on package starts depending on + something volatile. +- **Parallelism intact.** Independent packages stay independent: the change introduces no edge that + serialises two previously-parallel build branches without a real reason. PASS when the dependency + graph remains a DAG whose independent branches SPM can still build concurrently. +- **Per-package CI caching still valid.** The change keeps the foundation, infrastructure, features, + apps layering, so a CI runner can still cache and skip a layer that did not change. FAIL if a + back-edge (a lower layer depending on a higher one) defeats per-layer caching. + +A change that collapses granularity or adds a back-edge fails this checklist even if the build still +succeeds, because it forfeits the incremental, parallel, and cache-friendly build this document exists +to protect. + diff --git a/docs/rules/rules-swift/exp/common-mistakes.md b/third_party/rules-swift/exp/common-mistakes.md similarity index 73% rename from docs/rules/rules-swift/exp/common-mistakes.md rename to third_party/rules-swift/exp/common-mistakes.md index 57ed86c..3d30c6a 100644 --- a/docs/rules/rules-swift/exp/common-mistakes.md +++ b/third_party/rules-swift/exp/common-mistakes.md @@ -148,3 +148,33 @@ Three anti-patterns, all covered by `implementation-patterns.md > Pattern 6 (Fon 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`. +## Acceptance check + +Each anti-pattern above is mechanically checkable; the fixes are conforming when: + +- **No inline top-level arrays.** The three top-level manifest arrays use the closure-with-local-variables form, never a bare array literal: + +```bash +grep -nE 'let (deps|allProducts|targets)[^=]*=[[:space:]]*\[' Packages/Package.swift Package.swift +``` + + Output MUST be empty (each should read `let deps: [...] = {`), per the closure-with-locals fix. + +- **No Info.plist font registration in packages.** Packages register fonts via CoreText, never the plist keys or `.copy("Fonts")`: + +```bash +grep -rnE 'UIAppFonts|ATSApplicationFontsPath|\.copy\("Fonts"\)' Packages/ +``` + + Output MUST be empty (use `.process("Fonts")` + `CTFontManagerRegisterFontsForURL` against `Bundle.module`, Pattern 6). + +- **Every source target has a matching test target.** For each `Target.target(name: "X")` there is a `Target.testTarget(name: "XTests")`. List the names and confirm no source target lacks its pair: + +```bash +grep -oE 'Target\.(target|testTarget)\(name: "[^"]+"' Packages/Package.swift +``` + + Every non-test target name MUST have a corresponding `Tests`. + +- **Observable checklist** for the structural anti-patterns no grep settles, each a clear pass/fail: no "God package" mixing unrelated concerns (Models, Views, Networking, Database under one target); no circular dependency between two packages (the graph is a DAG); no feature target lists another feature target as a dependency (coordination goes through a parent/protocol package); and every cross-package symbol use has an explicit `import` of the package that declares it, never a reliance on a transitive import. + diff --git a/docs/rules/rules-swift/exp/critical-rules.md b/third_party/rules-swift/exp/critical-rules.md similarity index 69% rename from docs/rules/rules-swift/exp/critical-rules.md rename to third_party/rules-swift/exp/critical-rules.md index 2687fe6..637a595 100644 --- a/docs/rules/rules-swift/exp/critical-rules.md +++ b/third_party/rules-swift/exp/critical-rules.md @@ -49,5 +49,20 @@ Organize packages into clear architectural layers with unidirectional dependency - **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`. +Every dependency edge points upward. No back-edges. For the active project's actual layer instantiation, read the active manifest: root `Package.swift` in a package repo, `Packages/Package.swift` in a project monorepo. +## Acceptance check + +The five rules are conforming when each is shown to hold for the active package graph: + +- **The graph is acyclic and resolves.** SPM rejects a dependency cycle at resolution, so a clean resolve proves Rule 2's "no circular dependencies" floor: + +```bash +swift package resolve && swift build +``` + + Both MUST exit 0. A cycle makes resolution fail. + +- **Dependencies are declared, not transitive.** Spot-check that every package a target's sources `import` appears in that target's `dependencies:` list (Rule 2). No target relies on a symbol reaching it only through another package's import. + +- **Observable checklist** for the rules SPM does not enforce, each a clear pass/fail: every package has one cohesive responsibility, not a grab-bag of unrelated concerns (Rule 1); a package is split rather than grown when a second responsibility appears, and single-file packages are allowed when genuinely standalone (Rule 3); package names follow the scheme (`Shared*` foundation, `Core*` or descriptive infrastructure, per-feature/per-verb operation packages, `*Service` services, umbrella aggregators only when they add value) (Rule 4); and every dependency edge points upward through the foundation, infrastructure, domain, presentation, front-door layering with no back-edge (Rule 5). A back-edge passes `swift build` but fails this checklist. diff --git a/docs/rules/rules-swift/exp/dependency-management.md b/third_party/rules-swift/exp/dependency-management.md similarity index 80% rename from docs/rules/rules-swift/exp/dependency-management.md rename to third_party/rules-swift/exp/dependency-management.md index 71347fa..cbe0eec 100644 --- a/docs/rules/rules-swift/exp/dependency-management.md +++ b/third_party/rules-swift/exp/dependency-management.md @@ -173,3 +173,25 @@ let targets: [Target] = { **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. +## Acceptance check + +Conforms when: + +- **Manifest platform detection uses `#if os()`, never `#if canImport`.** This is the named SPM landmine: `#if canImport(UIKit)` silently breaks the manifest on Linux CI. + +```bash +grep -n '#if canImport' Packages/Package.swift Package.swift +``` + + Output MUST be empty; platform splits in the manifest use `#if os(iOS) || os(macOS)`. + +- **No dependency cycle.** Resolution and build succeed, proving the circular-dependency-prevention rule's floor: + +```bash +swift package resolve && swift build +``` + + Both MUST exit 0. + +- **Observable checklist** for the flow rules, each a clear pass/fail: dependencies flow only downward through the foundation, design-system, infrastructure, components, features, apps layering, with no import from a higher layer; when two packages would depend on each other, the shared code is extracted to a new package rather than creating a cycle; Apple-only products and targets sit behind `#if os(iOS) || os(macOS)` while shared models, API contracts, and server code stay unconditional (base); and a platform-specific dependency is attached with `condition: .when(platforms:)` rather than duplicated. A server target that builds on Linux CI without Apple SDKs is the observable signal that the separation holds. + diff --git a/docs/rules/rules-swift/exp/implementation-patterns.md b/third_party/rules-swift/exp/implementation-patterns.md similarity index 87% rename from docs/rules/rules-swift/exp/implementation-patterns.md rename to third_party/rules-swift/exp/implementation-patterns.md index d1dc242..2957fd1 100644 --- a/docs/rules/rules-swift/exp/implementation-patterns.md +++ b/third_party/rules-swift/exp/implementation-patterns.md @@ -434,3 +434,28 @@ Rules: 5. ALWAYS call `FontRegistration.registerFonts()` in app init BEFORE any UI renders 6. NEVER use Info.plist `UIAppFonts` / `ATSApplicationFontsPath` in packages +## Acceptance check + +The patterns are conforming when their checkable invariants hold: + +- **Foundation and core-infrastructure packages carry zero dependencies.** The `SharedModels` (Pattern 1) and `Components` (Pattern 5) targets declare `dependencies: []`. Confirm by reading those target definitions in `Packages/Package.swift`; a non-empty list is a violation. + +- **Fonts register via CoreText against `Bundle.module`, never Info.plist or `Bundle.main`** (Pattern 6): + +```bash +grep -rnE 'UIAppFonts|ATSApplicationFontsPath|\.copy\("Fonts"\)|Bundle\.main' Packages/Sources/AppFont/ +grep -rn 'CTFontManagerRegisterFontsForURL' Packages/Sources/AppFont/ +``` + + The first MUST be empty; the second MUST be non-empty. Font resources use `.process("Fonts")` and `FontRegistration.registerFonts()` is called in app init before any UI renders. + +- **The aggregator is confined to preview/demo.** An `@_exported import` umbrella (Pattern 5, AllComponents) is imported only from preview/demo apps and component libraries, never from a production feature path: + +```bash +grep -rln '@_exported import' Packages/Sources/ +``` + + Each hit MUST be an aggregator package used only off the production path. + +- **Observable checklist** for the structural shape, each a clear pass/fail: the API layer keeps its four-package split (Shared contract, Client, Server, ServerApp executable, Pattern 4); features depend on foundations and infrastructure but never on sibling features except a declared parent/child (Pattern 2); middleware packages stay single-purpose with minimal dependencies (Pattern 3); and the component layer is created in order Components, SharedComponents, AppComponents, AllComponents with the Components package never skipped (Pattern 5). + diff --git a/docs/rules/rules-swift/exp/migration.md b/third_party/rules-swift/exp/migration.md similarity index 86% rename from docs/rules/rules-swift/exp/migration.md rename to third_party/rules-swift/exp/migration.md index e306380..0f07534 100644 --- a/docs/rules/rules-swift/exp/migration.md +++ b/third_party/rules-swift/exp/migration.md @@ -6,8 +6,8 @@ Extracting code into a new package; splitting a large package. ```bash # 1. Create new package structure -mkdir -p Packages/Sources/NewPackage -mkdir -p Packages/Tests/NewPackageTests +mkdir -p Sources/NewPackage +mkdir -p Tests/NewPackageTests # 2. Move files git mv OldPackage/SomeFeature.swift NewPackage/ @@ -20,7 +20,7 @@ git mv OldPackage/SomeFeature.swift NewPackage/ # To: import NewPackage # 5. Rebuild -cd Packages && swift build +swift build # 6. Run tests swift test --filter NewPackageTests @@ -40,4 +40,3 @@ AuthFeature/ ProfileFeature/ SettingsFeature/ ``` - diff --git a/docs/rules/rules-swift/exp/package-swift.md b/third_party/rules-swift/exp/package-swift.md similarity index 85% rename from docs/rules/rules-swift/exp/package-swift.md rename to third_party/rules-swift/exp/package-swift.md index 0c5e7d7..046f993 100644 --- a/docs/rules/rules-swift/exp/package-swift.md +++ b/third_party/rules-swift/exp/package-swift.md @@ -224,6 +224,22 @@ let targets: [Target] = { Don't define targets inline in the array; use intermediate variables. +## Acceptance check + +Conforms when the manifest's three top-level arrays are built the closure-with-local-variables way, not as inline literals: + +- **Each array opens a closure, not a bracket.** `deps`, `allProducts`, and `targets` read `let : [...] = {` and return their grouped concatenation: + +```bash +grep -nE 'let (deps|allProducts|targets)[^=]*=[[:space:]]*\[' Packages/Package.swift +``` + + Output MUST be empty. A hit means an array was written as an inline `[...]` literal, the anti-pattern this file replaces. + +- **No targets defined inline in the returned array.** Each target is a named intermediate variable (`let sharedModelsTarget = Target.target(...)`), grouped by layer, then concatenated in the `return`. Confirm by reading the `targets` closure: the `return` line is a sum of group arrays (`return modelTargets + apiTargets + ...`), not a literal full of `Target.target(...)` calls. + +- **Observable checklist**, each a clear pass/fail: comment headers separate the groups (`// ---------- Shared Models ----------`); every variable has a descriptive name rather than an inline expression; and arrays use trailing commas for clean diffs. A manifest whose `deps`, `allProducts`, or `targets` is a bare inline array fails this rule even if it compiles. + ## 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/rules-swift/exp/testing.md b/third_party/rules-swift/exp/testing.md similarity index 62% rename from docs/rules/rules-swift/exp/testing.md rename to third_party/rules-swift/exp/testing.md index ba44b96..515a8d0 100644 --- a/docs/rules/rules-swift/exp/testing.md +++ b/third_party/rules-swift/exp/testing.md @@ -7,21 +7,20 @@ Full Swift Testing rules live in `testing.md`. Two ExtremePackaging-specific poi **Per-package test target.** Every source target ships with a matching test target. Run a single package in isolation: ```bash -cd Packages +cd swift test --filter Tests ``` -Folder layout: `Packages/Sources//` and `Packages/Tests/Tests/`. +Folder layout: `Sources//` and `Tests/Tests/` in package repos; `Packages/Sources//` and `Packages/Tests/Tests/` in project monorepos. -**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). +**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 `Tests/Tests/Mocks/` or `Packages/Tests/Tests/Mocks/`, matching repository shape. Never publish mocks from `Sources/` (they leak into production binaries). ```swift -// Packages/Sources/ApiClient/APIClientProtocol.swift +// Sources/ApiClient/APIClientProtocol.swift public protocol APIClientProtocol { func login(email: String, password: String) async throws -> User } -// Packages/Tests/ApiClientTests/Mocks/MockAPIClient.swift +// Tests/ApiClientTests/Mocks/MockAPIClient.swift public struct MockAPIClient: APIClientProtocol { /* ... */ } ``` - diff --git a/docs/rules/rules-swift/exp/verification.md b/third_party/rules-swift/exp/verification.md similarity index 100% rename from docs/rules/rules-swift/exp/verification.md rename to third_party/rules-swift/exp/verification.md diff --git a/docs/rules/rules-swift/exp/when-to-create.md b/third_party/rules-swift/exp/when-to-create.md similarity index 100% rename from docs/rules/rules-swift/exp/when-to-create.md rename to third_party/rules-swift/exp/when-to-create.md diff --git a/docs/rules/rules-swift/formatting-and-linting.md b/third_party/rules-swift/formatting-and-linting.md similarity index 100% rename from docs/rules/rules-swift/formatting-and-linting.md rename to third_party/rules-swift/formatting-and-linting.md diff --git a/docs/rules/rules-swift/framework-policy.md b/third_party/rules-swift/framework-policy.md similarity index 100% rename from docs/rules/rules-swift/framework-policy.md rename to third_party/rules-swift/framework-policy.md diff --git a/docs/rules/rules-swift/linux-server.md b/third_party/rules-swift/linux-server.md similarity index 88% rename from docs/rules/rules-swift/linux-server.md rename to third_party/rules-swift/linux-server.md index d36ddac..b6336dd 100644 --- a/docs/rules/rules-swift/linux-server.md +++ b/third_party/rules-swift/linux-server.md @@ -2,7 +2,7 @@ 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. +Load on demand. Triggers: Linux, server, Vapor, Hummingbird, AsyncHTTPClient, swift-log, swift-crypto, FluentSQLite, FluentPostgres, NIO, SIGTERM, graceful shutdown, Docker, `swift:6.2-jammy`, `swift:6.2-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. @@ -302,7 +302,7 @@ Every Linux-buildable product runs a Linux build on every PR. Minimum GitHub Act ```yaml linux-build: runs-on: ubuntu-latest - container: swift:6.0-jammy + container: swift:6.2-jammy steps: - uses: actions/checkout@v4 - run: swift build -c release --product @@ -311,21 +311,21 @@ linux-build: 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. +- `swift:6.2-jammy`, Ubuntu 22.04 LTS, the default for most production deploys. +- `swift:6.2-noble`, Ubuntu 24.04 LTS, newer. +- `swift:6.2-jammy-slim`, runtime-only base for the production stage. +- `swift:6.2-bookworm`, Debian 12, for some legacy deploys. +- `swift:6.2-alpine`, Alpine + Musl, smallest image. Test Musl conditionals if you use this. **Production deployment via Docker:** ```dockerfile -FROM swift:6.0-jammy as build +FROM swift:6.2-jammy as build WORKDIR /src COPY . . RUN swift build -c release --product tiledownserver -FROM swift:6.0-jammy-slim +FROM swift:6.2-jammy-slim COPY --from=build /src/.build/release/tiledownserver /usr/local/bin/ EXPOSE 8080 CMD ["tiledownserver"] @@ -362,6 +362,36 @@ Same shape for targets. Server-side products and targets stay unconditional. UI- 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. +## Acceptance check + +A Linux-buildable repo conforms when each rule above is shown to hold: + +- **No Apple-only frameworks in server code.** The server/CLI targets import none of CoreData, an unguarded CryptoKit, or os.log on the server tier: + +```bash +grep -rnE 'import CoreData|import CryptoKit|import os(\.log)?' Sources// +``` + + Output MUST be empty for the server target, save a `CryptoKit` import that sits inside a `#if canImport(CryptoKit)` guard (§4). + +- **Every URLSession is invalidated before release.** If any survives, each is paired with `invalidateAndCancel()` in a `deinit` or a `defer` (§1): + +```bash +grep -rn 'URLSession(' Sources/ +``` + + Every hit MUST have a matching `invalidateAndCancel()` in scope; an unpaired session is the Linux deinit crash this rule prevents. + +- **A Linux build runs on every PR.** A CI job builds the Linux product in a `swift:6.2-*` container (§11): + +```bash +grep -rnE 'swift:6\.0-(jammy|noble|bookworm|alpine)' .github/workflows/ +``` + + Output MUST be non-empty, and the job MUST run `swift build` for the named Linux product. + +- **Observable checklist** for the patterns no single grep settles, each phrased so two reviewers agree: POSIX interop uses the `canImport(Darwin)/Glibc/Musl` conditional (or `swift-system`), never a bare `import Glibc`; startup resources are compiled to Swift rather than read through `Bundle.module` (§6); `swift-log`'s `Logger` and `os.log`'s `Logger` never appear in the same source file (§2); cross-platform `FileHandle` writes go through the §8 wrapper or `swift-system`; and the manifest splits Apple-only products/targets behind `#if os(iOS) || os(macOS)` while server products stay unconditional (§12). + ## 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. diff --git a/docs/rules/rules-swift/namespacing.md b/third_party/rules-swift/namespacing.md similarity index 100% rename from docs/rules/rules-swift/namespacing.md rename to third_party/rules-swift/namespacing.md diff --git a/docs/rules/rules-swift/openapi-generated.md b/third_party/rules-swift/openapi-generated.md similarity index 100% rename from docs/rules/rules-swift/openapi-generated.md rename to third_party/rules-swift/openapi-generated.md diff --git a/docs/rules/rules-swift/package-architecture.md b/third_party/rules-swift/package-architecture.md similarity index 66% rename from docs/rules/rules-swift/package-architecture.md rename to third_party/rules-swift/package-architecture.md index 8cc65d8..15b566a 100644 --- a/docs/rules/rules-swift/package-architecture.md +++ b/third_party/rules-swift/package-architecture.md @@ -1,12 +1,19 @@ # 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. +How to decompose Swift code into focused SwiftPM targets within the repository's +single active package manifest. In a package repository that manifest is +`Package.swift` at the repository root. In a project monorepo that manifest is +`Packages/Package.swift`. -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. +The pattern: single-responsibility SwiftPM targets with explicit, +unidirectional dependencies, all in one active manifest. 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: +Add a new target to the active package manifest 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. @@ -14,11 +21,11 @@ Tiledown already has more than one target (the `TileKit` library and the `tile-d ## Core rules -### Rule 1: Single responsibility per package +### Rule 1: Single responsibility per target -Create packages with one clear purpose: +Create targets with one clear purpose: -- One well-defined responsibility per package. +- One well-defined responsibility per target. - Do not mix concerns (UI plus networking, models plus API client). - The name communicates the purpose. - Independently buildable and testable. @@ -32,13 +39,13 @@ Declare dependencies explicitly in `Package.swift`: - Minimize cross-package dependencies. - Prefer unidirectional dependency flow. -### Rule 3: Package granularity +### Rule 3: Target granularity -Prefer smaller, focused packages over larger ones: +Prefer smaller, focused targets 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. +- Single-file targets 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 targets, front-door binaries. +- Each cohesive responsibility (a feature, a CLI verb, a middleware, a transport, a service) gets its own target. ### Rule 4: Naming conventions @@ -46,15 +53,16 @@ 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. +- **Per-feature or per-verb operation targets:** one target 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 targets:** `*Service`, or a `Services` aggregator for cross-layer read/write services consumed by multiple front-doors. +- **Aggregators:** umbrella targets 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. +Keep the established public library name as the public anchor and grow new +targets around it 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: +Organize targets 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. @@ -62,33 +70,33 @@ Organize packages into layers with dependency flow strictly bottom to top. The e - **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`. +Every dependency edge points upward. No back-edges. The active layer instantiation lives in the repository's active `Package.swift`. -## When to create a new package +## When to create a new target ### 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) +│ └─ YES → add to the shared foundation target (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 +│ └─ YES → create a new feature / verb target │ ├─ Is it infrastructure (transport, parser, persistence, middleware)? -│ └─ YES → create or extend the matching infrastructure package +│ └─ YES → create or extend the matching infrastructure target │ └─ Still unsure? └─ Ask: "Could this be reused or tested in isolation?" - ├─ YES → create a new package - └─ NO → add to the most specific existing package + ├─ YES → create a new target + └─ NO → add to the most specific existing target ``` -### Create a new package when +### Create a new target 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). @@ -96,15 +104,15 @@ Need to add new code? 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 +### Do NOT create a new target 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. +1. **It is single use**, only used by one feature and tightly coupled to it. Add it to that feature's target. +2. **It is a trivial helper** of one or two functions with no external dependencies. Add it to an existing utility target. 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: +Before creating a new target: - [ ] One clear responsibility - [ ] Name follows the conventions above @@ -113,15 +121,15 @@ Before creating a new package: - [ ] 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 +- [ ] Product registered in the `Package.swift` products array when the target is public or executable - [ ] Dependencies only flow upward -Before adding to an existing package: +Before adding to an existing target: - [ ] 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 +- [ ] No better-suited target exists +- [ ] You are not creating a "god target" with mixed concerns +- [ ] You are not introducing unwanted dependencies onto the target's consumers Before modifying `Package.swift`: @@ -145,7 +153,7 @@ If you add a font or resource package: As Tiledown grows targets within its single package, the layout might look like this. Your actual target list will differ. ``` -Packages/Sources/ +Sources/ # or Packages/Sources/ in a project monorepo ├── Foundation Layer (0 internal dependencies) │ ├── TileCore # core value types, no dependencies │ └── SharedProtocols # cross-target protocol seams diff --git a/docs/rules/rules-swift/package-import-contract.md b/third_party/rules-swift/package-import-contract.md similarity index 100% rename from docs/rules/rules-swift/package-import-contract.md rename to third_party/rules-swift/package-import-contract.md diff --git a/docs/rules/rules-swift/package-structure.md b/third_party/rules-swift/package-structure.md similarity index 53% rename from docs/rules/rules-swift/package-structure.md rename to third_party/rules-swift/package-structure.md index 279b1d6..cc82890 100644 --- a/docs/rules/rules-swift/package-structure.md +++ b/third_party/rules-swift/package-structure.md @@ -1,58 +1,104 @@ # 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. +Choose the repository shape before adding targets. A Swift package repository is +an SPM-consumable package at the repository root. A project repository is a +monorepo that hosts one package plus one or more app projects. Do not mix the two +shapes casually. ## 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`. +This rule covers two allowed shapes: -Reach for additional targets in the single package when one of these is true: +- **Package repository:** a reusable package or CLI that another project can add + as a SwiftPM dependency by pointing at the repository URL. It has a root + `Package.swift`, root `Sources/`, root `Tests/`, and no `Apps/` or + `.xcworkspace`. +- **Project monorepo:** an app/product repository that needs Xcode app projects, + workspace settings, signing, entitlements, or multiple app targets. It has + `Main.xcworkspace`, `Packages/Package.swift`, `Packages/Sources/`, + `Packages/Tests/`, and `Apps/`. -- 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. +Both shapes use one manifest for all library, executable, and test targets in +that repository. Add targets when a responsibility becomes separable, reuse +appears, isolated compilation matters, or a second front door appears. -## Core rules +## Core Rules -### Rule 1: Root structure +### Rule 1: Pick exactly one repository shape -Organize the repository with these top-level directories: +Use package repository shape for reusable libraries, reusable CLIs, compilers, +parsers, semantic engines, validators, and other code products meant to be +consumed by SwiftPM. + +Use project monorepo shape for shipping GUI apps or product repositories that +need Xcode project settings a plain SwiftPM executable target cannot express. + +Do not leave a package repo half-converted: no root `Package.swift` plus nested +`Packages/Package.swift`, no orphaned `Main.xcworkspace`, and no empty `Apps/`. + +### Rule 2: Package repository root structure + +Package repositories use this top-level structure: + +- `Package.swift` for all products and targets +- `Sources/` for all library and executable targets +- `Tests/` for all test targets +- `docs/` for documentation +- `scripts/` for local validation helpers, when needed + +Forbidden in package repositories: + +- `Main.xcworkspace` +- `Apps/` +- `Packages/Package.swift` +- `Packages/Sources/` or `Packages/Tests/` as the primary source tree + +### Rule 3: Project monorepo root structure + +Project monorepos use this top-level structure: - `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) +- `Apps/` for app targets that ship a UI - `docs/` for documentation +- `scripts/` for local validation helpers, when needed -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. +The workspace hosts `Packages/Package.swift` and any `Apps/*/*.xcodeproj`. CLI +executable targets stay inside the single `Package.swift`; `Apps/` is for app +targets that need Xcode project settings. -### Rule 2: Single Package.swift +### Rule 4: Single Package.swift -Use ONE `Package.swift` for all targets: +Use one active `Package.swift` for all Swift targets in the repository: -- 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. +- In a package repo, it is `Package.swift` at the repository root. +- In a project monorepo, it is `Packages/Package.swift`. +- It contains all library targets and products. +- It contains CLI executable targets. +- It contains all test targets. +- It uses `#if os()` for platform-specific manifest branches. -Do not split the targets into one `Package.swift` per library. A single manifest keeps the dependency graph in one readable place. +Do not split the targets into one manifest per library. A single manifest keeps +the dependency graph in one readable place. -### Rule 3: Apps as separate projects +### Rule 5: Apps as separate projects in project monorepos -App targets (such as the planned native macOS/iOS editor) are separate Xcode projects in `Apps/`: +App targets 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). +- Apps import the package as a local SwiftPM dependency. +- This enables signing, entitlements, asset catalogs, and app configurations. - 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. +The CLI executable target stays inside the single `Package.swift`. Use `Apps/` +only for app targets that need Xcode project settings. -### Rule 4: Workspace references +### Rule 6: Workspace references for project monorepos -Add every project to the workspace: the `Packages/` SPM package, each `Apps/*/*.xcodeproj`, and the docs/README files. +Add every project to the workspace: the `Packages/` SPM package, each +`Apps/*/*.xcodeproj`, and the docs/README files. -### Rule 5: No storyboards or XIBs +### Rule 7: No storyboards or XIBs For any UI you add, do not use Interface Builder artifacts: @@ -61,35 +107,54 @@ For any UI you add, do not use Interface Builder artifacts: - 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 +### Rule 8: UI code lives in packages -Keep views and view controllers in packages, not in app targets: +Keep views and view controllers in package targets, 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 +## Package Repository Layout ``` -TileDownRoot/ -├── Main.xcworkspace/ # Hosts the package and any Apps/ projects +PackageRepo/ +├── Package.swift # All products and targets +├── Package.resolved # Tracked when dependencies are resolved +├── Sources/ +│ ├── PackageCore/ +│ ├── PackageCLI/ +│ └── ... +├── Tests/ +│ ├── PackageCoreTests/ +│ └── ... +├── docs/ +├── scripts/ +└── README.md +``` + +## Project Monorepo Layout + +``` +ProjectRoot/ +├── Main.xcworkspace/ # Hosts the package and Apps/ projects │ └── contents.xcworkspacedata -├── Packages/ # Single SPM package -│ ├── Package.swift # ALL targets defined here +├── Packages/ +│ ├── Package.swift # All package products and targets │ ├── Package.resolved │ ├── Sources/ -│ │ ├── TileKit/ # the engine library -│ │ ├── TileDownCLI/ # the tile-down CLI target -│ │ └── ... # additional focused libraries +│ │ ├── ProjectCore/ +│ │ ├── ProjectCLI/ +│ │ └── ... │ └── Tests/ -│ ├── TileKitTests/ +│ ├── ProjectCoreTests/ │ └── ... -├── Apps/ # App targets (planned native editor) -│ └── TileDownApp/ -│ ├── TileDownApp.xcodeproj/ -│ └── TileDownApp/ +├── Apps/ +│ └── ProjectApp/ +│ ├── ProjectApp.xcodeproj/ +│ └── ProjectApp/ ├── docs/ +├── scripts/ └── README.md ``` @@ -100,7 +165,7 @@ Use helper-driven, grouped target declarations rather than one giant inline arra ### Platform-specific products ```swift -// swift-tools-version: 6.0 +// swift-tools-version: 6.2 import PackageDescription // ---------- Base Products (All Platforms) ---------- @@ -246,9 +311,11 @@ Do NOT create a new app target when: ## 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 leave `Packages/Package.swift` in a package repository. The manifest lives at the repository root. +- Do NOT leave `Main.xcworkspace` or `Apps/` in a package repository. Those are project monorepo artifacts. +- 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 active `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 package targets under the active source tree. - Do NOT keep `.storyboard` or `.xib` files anywhere. - Do NOT keep view controllers in the app target. Move them into a package. @@ -256,11 +323,13 @@ Do NOT create a new app target when: 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/` +- [ ] Repository shape is explicitly package repo or project monorepo +- [ ] Package repo has root `Package.swift`, root `Sources/`, root `Tests/` +- [ ] Package repo has no `Main.xcworkspace`, no `Apps/`, no nested `Packages/Package.swift` +- [ ] Project monorepo has `Main.xcworkspace`, `Packages/Package.swift`, `Packages/Sources/`, `Packages/Tests/` +- [ ] Project monorepo GUI apps, if any, are separate `.xcodeproj` projects in `Apps/` - [ ] Apps import the package via a local dependency +- [ ] Single active SPM package manifest for all targets - [ ] Platform-specific targets use `#if os()` - [ ] App targets contain entry points only - [ ] Business logic stays in packages, not apps diff --git a/docs/rules/rules-swift/parsing-rules.md b/third_party/rules-swift/parsing-rules.md similarity index 100% rename from docs/rules/rules-swift/parsing-rules.md rename to third_party/rules-swift/parsing-rules.md diff --git a/third_party/rules-swift/scripts/check-namespacing.sh b/third_party/rules-swift/scripts/check-namespacing.sh new file mode 100755 index 0000000..1faee76 --- /dev/null +++ b/third_party/rules-swift/scripts/check-namespacing.sh @@ -0,0 +1,29 @@ +#!/bin/sh +# Namespacing gate: one non-private top-level type per file. +# Enforces the "One non-private type per file (mandatory)" rule in code-style.md. +# Runs on every commit (.githooks/pre-commit) and in CI (.github/workflows/leak-gate.yml). +# A file declaring more than one public/package/internal top-level type is a violation; +# mark helper types private/fileprivate so they drop out, or split the file. +set -eu + +ROOT=$(cd "$(dirname "$0")/.." && pwd) +SCAN="$ROOT/Packages/Sources" +[ -d "$SCAN" ] || SCAN="$ROOT" + +violations=$( + find "$SCAN" -name '*.swift' -not -path '*/.build/*' -not -path '*/.git/*' -print | while IFS= read -r f; do + count=$(grep -cE '^(public |package |internal )?(actor|struct|enum|protocol|class|final class) [A-Z]' "$f" || true) + if [ "$count" -gt 1 ]; then + printf '%s %s\n' "$count" "$f" + fi + done +) + +if [ -n "$violations" ]; then + echo "NAMESPACING FAIL (more than one non-private top-level type per file):" >&2 + echo "$violations" >&2 + echo "Fix: split the file, or mark helper types private/fileprivate so they drop out of the count." >&2 + exit 1 +fi + +echo "namespacing clean ($(find "$SCAN" -name '*.swift' -not -path '*/.build/*' | wc -l | tr -d ' ') swift files)" diff --git a/docs/rules/rules-swift/scripts/leak-gate.sh b/third_party/rules-swift/scripts/leak-gate.sh similarity index 100% rename from docs/rules/rules-swift/scripts/leak-gate.sh rename to third_party/rules-swift/scripts/leak-gate.sh diff --git a/docs/rules/rules-swift/shared-protocols.md b/third_party/rules-swift/shared-protocols.md similarity index 97% rename from docs/rules/rules-swift/shared-protocols.md rename to third_party/rules-swift/shared-protocols.md index 5d82597..ce6f1cd 100644 --- a/docs/rules/rules-swift/shared-protocols.md +++ b/third_party/rules-swift/shared-protocols.md @@ -151,7 +151,9 @@ Before adding a new `import` to a producer Swift file: # 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 +SHARED_PROTOCOLS_PATH="Sources/SharedProtocols" +[ -d "$SHARED_PROTOCOLS_PATH" ] || SHARED_PROTOCOLS_PATH="Packages/Sources/SharedProtocols" +for ln in $(grep -rn '^import ' "$SHARED_PROTOCOLS_PATH"/); do mod=$(echo "$ln" | awk '{print $2}') if echo "$INTERNAL_TARGETS" | grep -qx "$mod"; then echo "FAIL: SharedProtocols imports internal target $mod ($ln)" diff --git a/docs/rules/rules-swift/testing.md b/third_party/rules-swift/testing.md similarity index 94% rename from docs/rules/rules-swift/testing.md rename to third_party/rules-swift/testing.md index ff6f07e..7553cbc 100644 --- a/docs/rules/rules-swift/testing.md +++ b/third_party/rules-swift/testing.md @@ -542,32 +542,49 @@ let tileKitTargets = [tileKitTarget, tileKitTestsTarget] ### Folder layout -Tiledown is a monorepo from day one: sources live under `Packages/Sources//` and tests under `Packages/Tests/Tests/`, mirroring each other: +Source and test targets mirror each other under the active package root. +Package repositories use root `Sources/` and `Tests/`; project monorepos use +`Packages/Sources/` and `Packages/Tests/`. ``` -. -├── Packages/ -│ ├── Sources/ -│ │ └── TileKit/ -│ │ └── ...source files... -│ └── Tests/ -│ └── TileKitTests/ -│ ├── ...test files... -│ └── Mocks/ -│ └── ...mock implementations... +PackageRepo/ +├── Sources/ +│ └── TileKit/ +│ └── ...source files... +└── Tests/ + └── TileKitTests/ + ├── ...test files... + └── Mocks/ + └── ...mock implementations... + +ProjectMonorepo/ +└── 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. +Public test doubles (mocks, fakes, stubs) are placed in +`Tests/Tests/Mocks/` or +`Packages/Tests/Tests/Mocks/`, matching the repository shape. +They are never placed in `Sources//` or +`Packages/Sources//`. Mocks shipped from source targets leak into +production binaries. ```swift -// Packages/Sources/FileClient/FileClientProtocol.swift +// Sources/FileClient/FileClientProtocol.swift public protocol FileClientProtocol { func read(path: String) async throws -> String } -// Packages/Tests/FileClientTests/Mocks/MockFileClient.swift +// Tests/FileClientTests/Mocks/MockFileClient.swift public struct MockFileClient: FileClientProtocol { public var readResult: Result public func read(path: String) async throws -> String { diff --git a/docs/rules/rules-swift/ui/appkit-views.md b/third_party/rules-swift/ui/appkit-views.md similarity index 100% rename from docs/rules/rules-swift/ui/appkit-views.md rename to third_party/rules-swift/ui/appkit-views.md diff --git a/docs/rules/rules-swift/ui/colors.md b/third_party/rules-swift/ui/colors.md similarity index 100% rename from docs/rules/rules-swift/ui/colors.md rename to third_party/rules-swift/ui/colors.md diff --git a/docs/rules/rules-swift/ui/components.md b/third_party/rules-swift/ui/components.md similarity index 100% rename from docs/rules/rules-swift/ui/components.md rename to third_party/rules-swift/ui/components.md diff --git a/docs/rules/rules-swift/ui/flowspec.md b/third_party/rules-swift/ui/flowspec.md similarity index 100% rename from docs/rules/rules-swift/ui/flowspec.md rename to third_party/rules-swift/ui/flowspec.md diff --git a/docs/rules/rules-swift/ui/fonts.md b/third_party/rules-swift/ui/fonts.md similarity index 100% rename from docs/rules/rules-swift/ui/fonts.md rename to third_party/rules-swift/ui/fonts.md diff --git a/third_party/rules-swift/ui/native-per-platform.md b/third_party/rules-swift/ui/native-per-platform.md new file mode 100644 index 0000000..270cd7c --- /dev/null +++ b/third_party/rules-swift/ui/native-per-platform.md @@ -0,0 +1,100 @@ +# Native Per Platform (optimize for the user, not the developer) + +**Status: MANDATORY for any code that has UI.** The UI is optimized for the *user*, through genuinely +native presentation on each platform, never for the developer's convenience of a single shared +layout, easy testing, or a write-once cross-platform lowest-common-denominator. Each platform's UI +may be drastically different, and that is correct. Sameness across platforms is never a goal in +itself; the best native experience on the device in the user's hands is. + +## The premise + +There are two distinct optimization targets, and they are often in tension: + +- **Optimizing for the developer**: one layout that runs everywhere, identical across devices, cheap + to build, easy to test, easy to keep "in sync". A write-once cross-platform stack is the extreme + of this: the user gets the intersection of what every platform can do, rendered by a foreign + toolkit that is no platform's native idiom. +- **Optimizing for the user**: the interface that fits *this* device, its input model, its + conventions, its ergonomics. A Mac with a menu bar, resizable split columns, multiple windows, a + pointer and a full keyboard is a different machine from an iPhone held in one hand at arm's reach, + and its best UI looks and behaves nothing alike. + +We choose the user. When the two conflict, the native user experience wins, every time. + +## The rule + +1. **Build native, per platform.** Use each platform's own SDK and idioms (SwiftUI, UIKit, AppKit on + Apple platforms) to their fullest. Do not flatten an iPhone, an iPad, and a Mac into one layout + because it is less code. The Mac build is a Mac app; the iPhone build is an iPhone app. + +2. **Divergence is welcome, not a smell.** If the iPhone and the Mac UIs end up drastically + different, that is success, not drift. Different navigation, different chrome, different + affordances, different information density per the device is the point. + +3. **Never justify a UI choice with developer or test convenience.** "Easier to build", "keeps the + renderers identical", "simpler to test", or "matches the other platform" is not a reason to give + the user a worse native experience. Justify UI decisions with what is best for the user on that + device. + +4. **A shared layout policy is advisory, not a constraint.** A framework-free decision aid (a + resolved layout plan, a size-class mapping, a common vocabulary) is useful so a renderer starts + from a tested answer instead of guessing. But each platform may follow it, refine it, or replace + it entirely when its own idioms serve the user better. The plan informs; it does not bind. + +## What still stays shared (because it is correctness, not look and feel) + +This rule frees the *presentation*. It does not loosen the architecture. Two things remain uniform: + +- **The pre-UI layer** (`pre-ui-layer.md`): every renderer drives change through the one + `perform(Intent)` channel and reflects read-only surface state; the engine is the single source of + truth. A drastically different Mac UI still goes through the same intents and surfaces. The model + is shared *precisely so* the presentation can diverge freely without duplicating behavior. +- **Output correctness**: where multiple backends produce the same artifact (for example a GPU and a + CPU renderer producing pixels), they must agree. That is backend correctness, unrelated to + per-platform UI structure. + +## Reconciliation with `three-renderers.md` + +`three-renderers.md` calls for building the same UI in all three Apple SDKs over one model, as the +architecture's payoff and a comparison instrument for which framework behaves best. That holds *for +a given device class*, and it is genuinely valuable. But it is a developer and architecture goal, +and it is **subordinate to this rule**: the "same UI across renderers" aspiration applies only where +the same UI is also the best native UI for the device. Where a platform's best native experience +calls for a drastically different shape, the user wins and the renderers diverge. The shared model +makes that cheap; it never makes it mandatory to look identical. + +## Acceptance check + +This rule is judgment-based; conformance is an observable checklist, each item phrased so two +reviewers reach the same verdict on a given change: + +- **Native SDK per platform.** Each platform's UI is built with that platform's own SDK and idioms + (SwiftUI / UIKit / AppKit), not a single foreign cross-platform layout rendered everywhere. PASS: + the Mac target uses a menu bar, resizable split columns, multiple windows where they serve the user; + the iPhone target uses one-handed navigation. FAIL: one layout is forced identical across devices + because it is less code. +- **No developer-convenience justification.** No UI decision in the change is defended with "easier to + build", "keeps the renderers identical", "simpler to test", or "matches the other platform". FAIL if + any commit message, comment, or PR note carries such a reason for a user-facing layout choice; PASS + if every UI choice is justified by what is best for the user on that device. +- **Divergence is not treated as drift.** Where the iPhone and Mac UIs differ in navigation, chrome, + density, or affordances, that difference is left in place, not "fixed" toward sameness. FAIL if a + change collapses a justified per-platform divergence purely to make the platforms look alike. +- **The shared layer is untouched by the divergence.** Every renderer still drives change through the + one `perform(Intent)` channel and reads read-only surface state (`pre-ui-layer.md`); the divergence + lives only in presentation. FAIL if a platform duplicates behavior into its renderer instead of + routing through the shared model. +- **Backend output still agrees.** Where two backends produce the same artifact (a GPU and a CPU + renderer producing pixels, say), they still match; this rule frees presentation, not output + correctness. + +A change passes only when every item passes; a UI made uniform across platforms for build or test +convenience fails this rule even if it ships and looks tidy. + +## Why + +Users do not run architectures; they run the app in front of them, on one device, judged against the +native apps beside it. A write-once, lowest-common-denominator UI reads as foreign on every platform +and best on none. The pre-UI layer exists to make native-per-platform *affordable* (one model, many +thin renderers), not to make every platform look the same. Build the model once; build the +experience natively, for the user, on each device. diff --git a/docs/rules/rules-swift/ui/pom.md b/third_party/rules-swift/ui/pom.md similarity index 100% rename from docs/rules/rules-swift/ui/pom.md rename to third_party/rules-swift/ui/pom.md diff --git a/docs/rules/rules-swift/ui/pre-ui-layer.md b/third_party/rules-swift/ui/pre-ui-layer.md similarity index 100% rename from docs/rules/rules-swift/ui/pre-ui-layer.md rename to third_party/rules-swift/ui/pre-ui-layer.md diff --git a/docs/rules/rules-swift/ui/swiftui-views.md b/third_party/rules-swift/ui/swiftui-views.md similarity index 100% rename from docs/rules/rules-swift/ui/swiftui-views.md rename to third_party/rules-swift/ui/swiftui-views.md diff --git a/docs/rules/rules-swift/ui/three-renderers.md b/third_party/rules-swift/ui/three-renderers.md similarity index 86% rename from docs/rules/rules-swift/ui/three-renderers.md rename to third_party/rules-swift/ui/three-renderers.md index cc3c02f..da37afa 100644 --- a/docs/rules/rules-swift/ui/three-renderers.md +++ b/third_party/rules-swift/ui/three-renderers.md @@ -1,12 +1,19 @@ # Three Renderers (one model, all three Apple SDKs) -**Status: the standing UI aspiration, pursued whenever bandwidth allows.** Build the +**Status: the standing UI aspiration, pursued whenever bandwidth allows, and subordinate to +[`native-per-platform.md`](native-per-platform.md).** 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. +This rule applies only where the same UI is also the best native UI on each platform. +Where building the same surface in all three SDKs would trade away the native experience for +write-once convenience, `native-per-platform.md` governs: optimize for the user, and let the +per-platform UIs diverge. "All three renderers" is the goal for the shared-and-still-native case +and a comparison instrument, never a mandate to flatten the platforms into one layout. + ## Why build all three 1. **It is the payoff, not an afterthought.** The pre-UI layer exists so behavior diff --git a/docs/rules/rules-swift/ui/uikit-views.md b/third_party/rules-swift/ui/uikit-views.md similarity index 100% rename from docs/rules/rules-swift/ui/uikit-views.md rename to third_party/rules-swift/ui/uikit-views.md diff --git a/docs/rules/rules-swift/ui/view-models.md b/third_party/rules-swift/ui/view-models.md similarity index 100% rename from docs/rules/rules-swift/ui/view-models.md rename to third_party/rules-swift/ui/view-models.md diff --git a/docs/rules/rules-swift/validation-rules.md b/third_party/rules-swift/validation-rules.md similarity index 100% rename from docs/rules/rules-swift/validation-rules.md rename to third_party/rules-swift/validation-rules.md