Imported from berendkleinhaneveld/npo-light (
AGENTS.md). Install upstream withnpx skills add berendkleinhaneveld/npo-light. Copyright stays with the author.
AGENTS.md
Guidance for coding agents (and humans) working in this repository. Read this before changing anything. If a rule here conflicts with a habit from another project, the rule here wins.
The project
NPO light is a tvOS app built with SwiftUI and SwiftData.
| Xcode project | NPO light.xcodeproj (shared scheme: NPO light) |
| Platform | tvOS, deployment target 26.2 |
| Language | Swift 5 language mode |
| App target | NPO light/ — module name NPO_light |
| Unit tests | NPO lightTests/ — Swift Testing (import Testing, @Test) |
| UI tests | NPO lightUITests/ — XCTest (XCUIApplication) |
| Decisions | docs/adr/ |
| Requirements | docs/requirements/ |
| Wireframe | wireframe/ — published to GitHub Pages (ADR 0010) |
The app and test targets use Xcode's synchronised file groups: a .swift file
placed in one of those directories is part of the target automatically, and
project.pbxproj does not need to be touched to add or rename a file.
The checks
Run these before you push. CI runs the same scripts, so a green run locally means a green run on GitHub.
./scripts/lint.sh # SwiftLint, strict; also blocks lint exceptions
./scripts/build.sh # xcodebuild, warnings are errors
./scripts/test.sh # builds, then runs the unit + UI tests on a simulator
./scripts/requirements-coverage.sh # requirements and the tests that prove them
CI runs lint.sh, test.sh and requirements-coverage.sh in three parallel
jobs. It does not run
build.sh: the test action already compiles the app and the test targets with
the same warnings-as-errors settings, so a separate build job would only repeat
that work. Locally build.sh is still the quicker check while iterating —
it fails on a warning without waiting for the simulator.
A second workflow, wireframe.yml, assembles the interactive wireframe with
./scripts/build-wireframe.sh and publishes it to GitHub Pages from master.
It is not a check on the app. The wireframe follows the requirements, never the
other way round: when a change alters behaviour it illustrates, update it in the
same pull request if you can, and say so if you cannot.
build.sh and test.sh need macOS with Xcode. lint.sh and
requirements-coverage.sh do not: run them from a Linux container too.
Linting on Linux
There is no excuse for pushing unlinted code from a Linux container. SwiftLint publishes a statically linked Linux binary that needs no Swift toolchain:
./scripts/install-swiftlint-linux.sh # installs into ~/.local/bin
./scripts/lint.sh # picks up swiftlint-static from PATH
It runs every rule except the few that need SourceKit
(literal_expression_end_indentation, statement_position,
vertical_whitespace_closing_braces, vertical_whitespace_opening_braces),
and it names each one it skips on stderr. So a clean local run is strong
evidence, not proof: CI on macOS is the authority.
Building and testing still need Xcode. When you cannot run them, say so explicitly in your summary rather than implying the change compiles.
Rule 1: no lint exceptions
SwiftLint runs with --strict, so every violation, including one reported as
a warning, fails the build.
Not allowed, in code or in configuration:
// swiftlint:disable(ordisable:next,disable:this) anywhere;disabled_rules,only_rulesorexcludedin.swiftlint.yml;- relaxing a rule's thresholds to make a violation go away.
scripts/check-lint-exceptions.sh enforces this and runs first in CI.
Fix the code, not the rule. If a rule genuinely does not fit this project, do not disable it on your own initiative: say so in the pull request, wait for the repository owner (@berendkleinhaneveld) to confirm in writing, and record the approved exception in an ADR. An agent must never introduce an exception unprompted, and never as a way of getting a red pipeline green.
The same applies to compiler diagnostics: do not silence a warning with
@available juggling, a cast, _ = or #warning suppression when the honest
fix is to change the code.
Rule 2: builds are warning free
SWIFT_TREAT_WARNINGS_AS_ERRORS and GCC_TREAT_WARNINGS_AS_ERRORS are YES in
the project and are passed again on the CI command line, for the app and the
test targets. A new warning is a failed build. Deprecations count: when an API
is deprecated in tvOS 26, migrate to the replacement.
Rule 3: keep an ADR for important decisions
Architecturally significant decisions are recorded as Architecture Decision
Records in docs/adr/, one numbered Markdown file per
decision, following Michael Nygard's format (Context, Decision, Alternatives,
Consequences). See ADR 0001
for why.
Write an ADR when the decision is hard to reverse, or when a future reader would ask "why is it done this way?":
- adding, replacing or removing a dependency or an Apple framework;
- module, layer or navigation structure, and how state flows through the app;
- persistence, migration and caching (this app uses SwiftData — changing that is an ADR, as is a schema migration strategy);
- networking, authentication, error handling and logging approaches;
- testing strategy, or changes to the CI pipeline itself;
- any approved exception to the rules above.
Do not write one for a bug fix, a rename, or a view that follows the patterns already in the code base.
How: copy docs/adr/template.md to docs/adr/NNNN-short-title.md with the
next free number, fill it in, add the row to the index in
docs/adr/README.md, and link it from the pull request. ADRs are append-only:
supersede an outdated record with a new one — do not rewrite history. An agent
that is unsure whether a decision is significant should write the ADR and let
the reviewer decide; a decision that turns out to be routine costs one small
file, an undocumented one costs an afternoon later.
Rule 4: build what the requirements ask for
The app's specification lives in docs/requirements/,
one file per area, every requirement carrying a permanent identifier such as
FR-SEARCH-03 — see ADR 0003.
Work test-first, one coherent change at a time:
- Pick the smallest coherent piece of work — usually one
Acceptedrequirement, sometimes a few that share a screen or a data model. - Write the failing tests, each naming the identifier in its display name:
@Test("FR-SEARCH-03: typing is not blocked by a slow backend"). - Make them pass, lint-clean and warning-free.
- Set the status of every requirement the change implements to
Implementedin the same pull request, and name them in its description.
Take as many requirements as one reviewable change honestly covers, and no
more. Some only make sense together — a store and the four requirements that
describe it — and the cross-cutting ones, accessibility and localisation, are
conditions every screen meets rather than work of their own: name them in the
tests of the feature you are building, and leave them Accepted until they
hold everywhere they apply.
./scripts/requirements-coverage.sh fails when a test names an identifier no
requirement defines, or when an Implemented requirement is named by no test.
Do not invent behaviour. If a task needs something no requirement asks for, add or amend the requirement first and say so in the pull request — do not quietly widen the app. If a requirement is ambiguous, ask (@berendkleinhaneveld) rather than guessing; the answer belongs in the document either way.
Amend an unbuilt requirement; supersede an implemented one. A requirement
no test names yet is a draft: when it changes meaning, rewrite it in place,
keep its identifier, and let git carry the history. Retiring a number nobody
refers to only makes the next reader work through a decision that was never
made. The same goes for an ADR the same pull request added. Once a requirement
is Implemented, tests and commits point at it: then retire it (Superseded,
with a pointer) and add a new number. A retired number is never reused.
Code style
SwiftLint settles formatting; these are the conventions it cannot check.
- Naming. Types are
UpperCamelCasewith no underscores — the module isNPO_light, but a type isNPOLightApp. Follow the Swift API Design Guidelines: methods read as phrases at the call site, booleans as assertions (isEmpty,hasLoaded). - SwiftUI. Keep
bodysmall; extract a subview or a computed property instead of nesting a large view tree. Views hold no business logic beyond presentation. Every view gets a#Preview. - SwiftData.
@Modeltypes live inNPO light/, one type per file. As decided in ADR 0011, actor-isolated stores own theirModelContext, perform persistence off the main actor, and returnSendablevalue types. Store methods take the mode explicitly. Views use injected screen models, never@Queryor@Environment(\.modelContext). Compose dependencies inNPOLightApp; do not reach for a shared container from a view. - Concurrency. UI state is
@MainActor. Do not add@unchecked Sendableornonisolated(unsafe)to silence the compiler — model the isolation properly. - Errors. No
try!, no force unwraps (force_unwrappingis enforced), no implicitly unwrapped optionals.fatalErroris for genuinely unrecoverable programmer error only, and always with a message. - Comments explain why. Delete Xcode's generated placeholder comments instead of shipping them.
Tests
- New behaviour comes with a test. A test without an assertion is not a test —
do not commit an empty
@Test func example() {}placeholder. - Unit tests use Swift Testing:
@Test,#expect,#require. Name the function after the behaviour (itemKeepsItsTimestamp), and keep it under 40 characters —identifier_nameis enforced in test code too. - UI tests are slow; add one only for a flow that unit tests cannot cover.
- Never make a test pass by weakening or skipping it. A failing test on your branch is a bug in your branch until proven otherwise.
Working with git
- Branch off
master; never commit tomasterdirectly. - Keep commits focused, with a message that explains the why in the body.
- Fill in the pull request template (
.github/pull_request_template.md). - Do not hard-wrap the pull request description. One paragraph is one long line. GitHub reflows a description to the width of whoever is reading it, and a body wrapped at 80 columns keeps those breaks instead — a narrow ragged column on every window wider than the one it was written in. Commit messages are the opposite: wrap those, as git expects. The two are different formats and the habit does not carry across.
- Do not commit
xcuserdata/,build/or.xcresultbundles (see.gitignore). Do commit changes to the shared scheme.
What to do when you are stuck
State the problem and stop, rather than working around a rule. Specifically: if the only way you can see to get CI green is to disable a rule, exclude a file, skip a test, or suppress a warning, that is a signal to ask the repository owner — not to proceed.
