Coast Standards Enforcement — Developer Guide¶
Last updated: 2026-10-24
The technical reference for developers who adopt or maintain the enforcement layer: the scanner, the hooks and the installer. The current release number is in the root CHECKS-VERSION. The design and build plan are in README.md.
For installing the standards into a product, start with the public docs in docs/. Every installer flag and settings key is listed in docs/options.md.
Contents¶
- What it is
- Concepts
- Quick start
- The rule documents and the check tag
- The scanner —
check_rules.py - The doc-comment check —
check_doc_comments.py - The verifier —
verify_rules.py - The git hooks
- The Claude Code session hooks
- The installer —
adopt.py - The linter configs
- Versioning
- Troubleshooting
- Extending
- Tests
- Limits, stated plainly
1. What it is¶
Every rule in rules/ names the check that enforces it. The enforcement layer runs those checks in any git repository.
| Part | File | What it does |
|---|---|---|
| Scanner | check_rules.py |
Runs regex patterns over added lines, scoped by file type. |
| Doc-comment check | check_doc_comments.py |
Finds public declarations with no doc comment. |
| Verifier | verify_rules.py |
Confirms every rule names a real check, and prints "enforced by a check N of M". |
| Linter configs | lint/ |
Linter settings with the rules the documents cite switched on. |
| Git hooks | hooks/ |
pre-commit, commit-msg, pre-push. |
| Session hooks | hooks/claude-hook.py |
Claude Code hooks that refuse actions an agent must not take. |
| Installer | adopt.py |
Installs everything into a project, and writes baselines so existing code can adopt. |
It is Python 3 (standard library only) and POSIX sh, with no network and no AI model. It also needs the platform's toolchain and jscpd.
2. Concepts¶
| Term | Meaning |
|---|---|
| Rule | A bold bullet or a leaf prose section in a rule document, ending in a […; check: …] tag. |
| Check tag | The last square bracket in a rule that contains check: (§4). |
| Bin | How a rule is enforced: machine, partly, advisory, review, process, or open (a gap). Only machine counts in the number. |
| Signature | One row in rules_signatures.json: an id, a severity, the path classes it covers, a message, and a regex per platform. |
| Path class | A named set of globs in paths.json, such as theme, ui, tests, governing. |
| Severity | block fails on any hit. ratchet fails if the count rises. advisory only prints. |
| Ratchet | A count of existing problems that may only go down. |
| Baseline | The committed count for a ratchet, with a deadline. |
| Seat | One check in the pre-push hook: build, tests, lint, format, rules-scan, doc-comments, jscpd, gh-ruleset. |
| Scope | What a push changed: none (prose only), files (code), or all (the checks themselves). |
| Governing file | A file that defines the checks. The installer replaces it, and agents may not edit it. |
| Seed | A file the installer writes once; the owner controls it afterwards. |
| Exception | A person's entry in .coast/rules-exceptions.json that excuses a signature on a path, or a seat until a date. |
| The number | "Rules enforced by a check N of M", shown in CLAUDE.md, the verifier output, the session-start context and Coast's Rules tab. |
The number's wording lives in enforcement/checks/vocabulary.json. To rename it, change label, add the old wording to retired, and run the tests. test_vocabulary.py lists every page still using the old wording.
3. Quick start¶
3.1 Requirements¶
| Platform | Needs |
|---|---|
| all | python3 3.10+, git, jscpd 5.1.2 (npm install -g jscpd@5.1.2); gh optional |
| ios, macos | Xcode command line tools, swiftlint, swift-format |
| android | a Gradle wrapper, ktlint; detekt through the Gradle plugin |
| react-native, web | node, npm; typescript, eslint, prettier in node_modules |
| python | ruff, mypy, pytest (or unittest) |
Pinned versions are in TOOLCHAIN.md.
3.2 Adopt a project¶
- Preview the changes (nothing is written):
python3 enforcement/adopt.py /path/to/project --platform ios --dry-run
- Run it. Leave out
--platformif the manifest makes it obvious (§10.2). Add--yesto skip the on/off questions.
python3 enforcement/adopt.py /path/to/project --platform ios
- Commit what it wrote, and push. The first push runs every check.
A first adoption:
- Copies the checks to
.coast/checks/and the git hooks to.githooks/. - Installs the session hook and its keys in
.claude/settings.json. - Writes
.coast/config.jsonand.coast/layout.sh. - Writes linter configs and
docs/domain-rules.mdif missing. - Records the theme file in
.coast/paths.json. - Measures existing problems and writes
.coast/ratchet-baseline.json, with a deadlineratchet_days(default 90) away. - Writes
.coast/jscpd-baseline.json. - Rewrites the marked block in
CLAUDE.md. - Sets
core.hooksPathto.githooks, remembering any previous hooks path. - Scans tracked files for secrets, and exits 1 if it finds any.
The scanner starts from the adoption commit. Older problems are counted in the baselines.
3.3 Take a newer version¶
python3 <copy>/enforcement/adopt.py /path/to/project --release 1.1.0 # or: latest
A re-run replaces governing files, keeps seeds you edited, lowers baselines whose count fell, and changes nothing otherwise.
| Flag | Use |
|---|---|
--measure-tools [format,lint] |
Re-measure the tool baselines (a list without build skips the build). |
--lower-baselines |
Write only the two baselines, lowering counts that fell. Installs nothing; an agent may run it. |
3.4 Run the checks by hand¶
From the project root:
python3 .coast/checks/check_rules.py --tree --platform ios
python3 .coast/checks/check_rules.py --staged --platform ios
python3 .coast/checks/check_rules.py origin/main HEAD --platform ios
python3 .coast/checks/check_rules.py --files Sources/App/HomeView.swift --platform ios
sh .githooks/pre-push --seat lint
sh .githooks/pre-push --seat build,tests
The number, from the standards repo, with the project's switches:
python3 enforcement/checks/verify_rules.py --document /path/to/project/docs/domain-rules.md --platform ios --config /path/to/project/.coast/config.json
4. The rule documents and the check tag¶
4.1 What is a rule¶
- A column-0 bullet that opens in bold, such as
- **L-1 — No hardcoded user-facing text** … [check: …]. The id is the leadingPREFIX-nin the bold, or else a slug of the title. Indented lines belong to it; a blank or unindented line ends it. - A leaf prose section: a heading with no bullet rules and no sub-headings. If it has several tags, the last one counts.
The H1 and any "Sources" section are not rules.
4.2 The tag grammar¶
[Coast standard; check: scan:ui-string-literal]
[Swift practice; check: swiftlint:force_unwrapping, swiftlint:force_try]
[SRP; check: advisory:type-size, review]
| Reference | Meaning | Bin |
|---|---|---|
scan:<id> |
a block signature |
machine |
ratchet:<id> |
a ratchet signature |
machine |
advisory:<id> |
a signature that never fails | advisory |
<linter>:<rule> |
a rule the shipped config enables, such as swiftlint:force_unwrapping, eslint:react-hooks/rules-of-hooks, detekt:UnsafeCallOnNullableType, androidlint:HardcodedText, tsc:strict, ruff:E501, mypy:strict |
machine |
<linter> |
a whole linter: prettier, ktlint, swiftformat |
machine |
tool:<name> |
a tool the pre-push hook runs: tool:jscpd, tool:gh-ruleset, tool:warnings-as-errors, tool:tests-deadline |
machine |
session:<id> |
a Claude Code or git hook, such as session:chained-cd |
machine |
review |
a reviewer judges it | review |
process |
the pipeline or a person enforces it | process |
context |
explains a rule; not counted, and cannot be combined | — |
A rule whose references are all machine checks is machine. A machine check combined with review, process or advisory is partly.
4.3 How a reference resolves¶
| Reference | Present when |
|---|---|
scan, ratchet, advisory |
The id is in the signatures for every platform the document covers, with the claimed severity. |
<linter>:<rule> |
battery.json lists the linter, its config exists, and the config names the rule. Linter defaults do not count. |
tool:<name> |
The manifest's runner file mentions the tool. |
session:<id> |
A session-hook file in the manifest mentions the id. |
A signature that no rule names is also a gap.
4.4 Which documents apply to which platform¶
battery.json lists each document's platforms. rules/0x-*.md apply to all (04 and 05 to app platforms only). rules/platform/domain-rules-<p>.md applies to its platform. rules/platform/ai-features.md applies to all. A project carries its platform document as docs/domain-rules.md, and its number comes from that file.
5. The scanner: check_rules.py¶
5.1 Modes¶
check_rules.py <base> <head> --platform <p> lines added between two commits
check_rules.py <base> WORKTREE --platform <p> tracked changes since base + untracked files
check_rules.py --staged --platform <p> the index (pre-commit); no ratchets
check_rules.py --files a b c --platform <p> whole files; every line counts as added
check_rules.py --tree --platform <p> tree-scope signatures and ratchets only
check_rules.py --ratchet <id> --count <n> judge a tool's count (§5.5)
check_rules.py --ratchet <id> --log <log> [--measured <listing>]
count a tool's log and judge it
check_rules.py --measure <id> --log <log> print MEASURE / MEASURE-FILE lines
check_rules.py --has-baseline <id> [--per-file] exit 0 if the baseline has an entry
| Option | What it does |
|---|---|
--only id[,id] |
Runs only these signatures (used by the secret scan). |
--paths-override <file> |
The project's path-class file; default .coast/paths.json. |
COAST_PLATFORM |
Sets the platform instead of --platform. |
Run it from the repository root. It applies the project's config on every run, and refuses a config that raises a severity.
Only --tree counts ratchets, and a push runs it once. Other modes check only the change, so they take seconds.
- Renames are detected, so a
git mvadds nothing. - A new file named like a secret (
.env,.p12,.sqlite,.jks, …) hitssecret-filewithout its content being read. - Built-ins read the file version the diff saw, so partial staging cannot hide a hit.
- A diff that touches only governing or git files passes.
5.2 Output¶
FAIL <check> <path>:<line>:<id>: <words> [<rule>]
ADVISORY <check> <path>:<line>:<id>: <words> [<rule>]
OK ratchet <id>: <n> in the tree, equal to the baseline; deadline <date> [<rule>]
PASS rules
{"result": "pass", "advisories": 0}
Exit 0 on pass, 1 on any FAIL. The last line is a JSON summary. Hooks, CI and Coast parse the FAIL line, so keep its format stable.
5.3 The signature table: rules_signatures.json¶
Shared fields sit on the row. Each platform the signature runs on has an entry under platforms with its own globs, regex and any overrides.
{
"signatures": [
{
"id": "money-float",
"rule": "PAY-1",
"severity": "advisory",
"scope": "added",
"applies_to": ["*"],
"excludes": ["tests", "docs"],
"words": "a money field typed as binary floating point — money is a decimal",
"platforms": {
"ios": {
"files": ["**/*.swift"],
"pattern": "(price|amount|total)\\w*\\s*:\\s*(Double|Float)\\b"
},
"python": {
"rule": "DATA-3",
"files": ["**/*.py"],
"pattern": "(price|amount|total)\\w*\\s*:\\s*float\\b"
}
}
}
]
}
check_rules.flatten_signatures merges each platform entry over the row. A platform value equal to the row's is refused by test_check_rules.py.
| Field | Required | Meaning |
|---|---|---|
id |
yes | The name a scan:<id> tag uses. |
rule |
yes | The rule id printed on each hit; a platform may override it. |
severity |
yes | block, ratchet or advisory. |
scope |
yes | added (added lines) or tree (every file, at push). |
applies_to |
for regex | Path classes it runs on; ["*"] for all. |
excludes |
no | Path classes it skips. |
kind |
no | builtin for a check written in code (§5.4). |
group |
no | A label grouping related ids. |
once |
no | true to report a file once, not per line. |
words |
yes | The message: what is wrong and where it belongs. |
files |
no | Platform file globs that narrow it. |
files_excluded |
no | Platform file globs it skips. |
pattern |
for regex | A Python re pattern matched per line. |
paired |
no | A second pattern that must match in the same window of lines. |
Each signature needs fixtures in checks/tests/plants/<platform>/: <id>.fail.<ext> must hit and <id>.pass.<ext> must not.
5.4 Built-ins¶
| Built-in | Module | Finds |
|---|---|---|
ui-string-literal |
literals.py |
User-facing text written inline (Swift, Kotlin/XML, TSX/JSX, Python). |
doc-comments |
check_doc_comments.py |
Public declarations with no doc comment (§6). |
import-matrix |
import_matrix.py |
Imports that break the app / feature / shared layers in .coast/module-kinds.json. |
blocking-call (python) |
async_blocking.py |
Blocking calls inside async def. |
test-criterion-tag |
test_criteria.py |
Tests with no AC-<n> or "criterion" in the name, the three lines above, or the first body line. |
type-size |
type_size.py |
Types over 300 lines or files over 400 (advisory). |
view-model-in-presentation (ios, macos) |
view_model_ownership.py |
An observable view model built inside a .sheet, .fullScreenCover, .popover, .navigationDestination or NavigationLink. |
view-model-in-let (ios, macos) |
view_model_ownership.py |
A SwiftUI view that builds its own observable view model into a let or plain var instead of @State. |
unreached-view |
unreached_views.py |
Public views or components no route constructs. Previews, galleries and tests do not count as routes. A not-mounted-yet: <task> comment above the declaration passes. |
secret-file |
check_rules.py |
New, renamed or copied files named like secrets. |
plaintext-http |
check_rules.py |
http:// URLs, usesCleartextTraffic="true", and NSAllowsArbitraryLoads set to <true/>. |
exported-component (android) |
check_rules.py |
Manifest components exported with no permission and no LAUNCHER category. |
Added-scope built-ins read the whole file but report only added lines.
5.5 Ratchets and baselines¶
.coast/ratchet-baseline.json:
{
"baselines": [
{"id": "spacing-literal", "count": 839, "deadline": "2026-12-03",
"written": "2026-09-04", "by": "the owner", "moves": []},
{"id": "build-warnings", "count": 22, "deadline": "2026-12-03",
"written": "2026-09-04", "by": "the owner", "moves": []}
]
}
| Scanner ratchets | Tool ratchets | |
|---|---|---|
| Ids | spacing-literal, inline-comment, pii-in-log |
build-warnings, format-findings, lint-findings, tests-missing |
| Count rises | fails | fails |
| Count falls | fails until the baseline is lowered in the same commit | passes with a note |
| No entry | baseline is 0 | the check runs strict |
Per-file map. adopt.py --measure-tools also stores per-file counts for tool ratchets. A scoped push then judges only the changed files, and a rise names the file. Without the map, the check runs on the whole tree and prints the command to add it.
Deadlines.
- After the deadline, the ratchet blocks any count above zero.
- An entry with no
deadlineis refused. - Lowering a count never moves the deadline.
- Only a person moves a deadline, recorded under
moves. - Nothing raises a count.
5.6 Path classes: paths.json and .coast/paths.json¶
Each platform has extensions (source file types), order (which class wins) and classes (name → globs). A file belongs to the first matching class in order.
Shipped classes: git, governing, plans, generated, schema, theme, ui_lib, strings, manifest, docs, design_bundle, tests, gallery, ui, config_home. Anything else is product source.
galleryholds previews and component catalogues. They are not routes, and the string-literal check ignores them.stringsis the only allowed home for string catalogs (one-catalog-per-locale).
A project overrides classes in .coast/paths.json. A named class replaces the shipped globs for that class:
{
"platforms": {
"ios": {
"classes": {
"theme": ["App/DesignSystem/Theme.swift"],
"ui_lib": ["App/DesignSystem/**"],
"plans": ["plans/**", "docs/handoff/**"]
}
}
}
}
The installer writes it first when it finds a theme file outside the default path. After that it is governing.
5.7 Exceptions¶
.coast/rules-exceptions.json excuses one signature on one path (exact path or dir/**):
{
"exceptions": [
{"id": "secret-literal", "path": "Tests/AuthTests/FixtureKeys.swift",
"reason": "a fake sk-ant key read by the code under test",
"who": "the owner", "when": "2026-09-04"}
]
}
The file is governing, so agents cannot write it. It also holds seat exceptions (§8.6). Coast projects also honour approved_native_deviations in plans/<feature>/proof.json.
6. The doc-comment check: check_doc_comments.py¶
check_doc_comments.py --files a b c --platform <p> named files
check_doc_comments.py --all --platform <p> the whole tree
It prints FAIL doc-comments <path>:<line>:<name>: … for each undocumented public declaration.
| Language | Public | Doc comment |
|---|---|---|
| Swift | public/open, and members of public extensions, protocols and enums; extensions fold into their type per module |
/// or /** */ |
| Kotlin / Java | Kotlin by default; Java when declared, plus interface members | /** */ |
| TypeScript | exports, and non-private members of exported classes and interfaces | /** */ directly above; decorators and // may sit between, a blank line may not |
| Python | PEP 257 public functions, classes, methods and __init__ (read with ast); @overload and property setters are exempt |
a docstring |
A Python file that does not parse reports nothing. The scanner also runs this as the doc-comments built-in on added lines. The pre-push hook prints the whole-tree count as advisory.
7. The verifier: verify_rules.py¶
verify_rules.py the whole corpus, from the standards repo root
verify_rules.py --summary per-document lines and totals
verify_rules.py --document <file> --platform <p> one project's copy (repeatable)
verify_rules.py --json machine-readable report
verify_rules.py --baseline <file> pass only while the gap count equals the file's
verify_rules.py --config <file> apply a project's switches
It prints a line per document, a FAIL verify-rules line per gap, and totals, and exits 1 on any gap. The standards repo's pre-commit hook runs it against checks/verify-baseline.json (0 gaps), then runs the test suite.
The verifier and battery.json are not installed into projects. The installer runs the verifier at adoption to write the number into CLAUDE.md.
8. The git hooks¶
The three hooks are POSIX sh in .githooks/. Each one:
- reads
.coast/layout.shand the config (a switched-off check printsgate: <name> OFF (config)); - prints refusals as
FAILlines on stderr; - clears git's
GIT_DIRvariables and setsPYTHONDONTWRITEBYTECODE=1.
8.1 pre-commit¶
Runs the scanner on the staged changes, including the doc-comment check. Takes seconds.
8.2 commit-msg¶
Refuses a missing subject, or one over 100 characters.
Git's own merge and revert messages are exempt, and the hook prints a gate: line saying so.
8.3 pre-push: the battery¶
First, scope.py decides the scope from the pushed ranges and prints gate: scope <kind> — <reason>:
| Scope | When | What runs |
|---|---|---|
none |
Only docs, plans, design, generated, git or prose files changed. | The diff scan, gh-ruleset, and the project's own hooks. |
files |
Code changed. | lint and format on changed files; build and tests on the affected targets and their dependants; everything else whole. |
all |
Governing or manifest files changed, a code file has no target, --measure was passed, or COAST_SCOPE=all is set. |
Everything. |
Affected targets come from swift package describe, Gradle project(':x') edges, npm workspaces (with --findRelatedTests / vitest related for jest or vitest), or Python imports. Xcode builds, tsc, mypy, npm run build and detekt always run whole.
Then the checks run in this order, and any failure stops the push:
| Check | ios / macos | android | react-native / web | python |
|---|---|---|---|---|
build |
swift build -Xswiftc -warnings-as-errors, or xcodebuild build with SWIFT_TREAT_WARNINGS_AS_ERRORS=YES (or the warnings ratchet) |
requires allWarningsAsErrors; gradlew build test lint |
requires strict: true; tsc --noEmit; npm run build if present |
— |
tests |
swift test, or xcodebuild test on the newest iPhone simulator; or the tests-missing ratchet |
part of build |
npm test |
pytest -q with PYTHONWARNINGS=error |
lint |
swiftlint --strict (or ratchet) |
gradlew detekt |
eslint . --max-warnings 0 |
ruff check ., mypy . |
format |
swift-format lint --strict (or ratchet) |
ktlint |
prettier --check . |
ruff format --check . |
rules-scan |
the scanner on the pushed lines, then --tree once |
same | same | same |
doc-comments |
whole-tree count, advisory | same | same | same |
jscpd |
jscpd . --min-tokens 50 --baseline .coast/jscpd-baseline.json --fail-on-new-clones |
same | same | same |
gh-ruleset |
read-only gh api check that the default branch has an active ruleset with pull_request, deletion, non_fast_forward and no bypass |
same | same | same |
- Time limit. Test runs are stopped after
tests_deadline_seconds(default 900), and the push is refused with a message saying a test is hanging. - A missing tool is a refusal with its install command.
- Xcode uses the scheme in
.coast/xcode-scheme, or the first one listed. Builds run withCODE_SIGNING_ALLOWED=NO.
8.4 Running seats by hand¶
sh .githooks/pre-push --seat lint,format run the named checks
sh .githooks/pre-push --measure build,format print MEASURE and MEASURE-FILE counts
Git never passes these flags. Such runs take no lock and skip previous hooks. With no ref lines piped in, --seat compares HEAD to its upstream (or to the empty tree). --measure always covers the whole tree.
8.5 One push at a time¶
A lock directory in the git directory lets one push run per checkout. A lock older than an hour is removed. After waiting, the hook fetches again and stops quickly if the remote moved ahead of this branch.
8.6 Excusing a seat¶
A person can skip one check until a date in .coast/rules-exceptions.json:
{"exceptions": [
{"seat": "build", "reason": "the build needs env vars this checkout has not got",
"who": "the owner", "when": "2026-09-05", "until": "2026-09-19"}
]}
seatis one ofbuild,tests,lint,format,rules-scan,doc-comments,jscpd,gh-ruleset.untilis required; an entry without it is ignored.- Every push prints who excused it and why. The check returns the day after
until. --no-verifystays forbidden.
8.7 A project's own hooks keep running¶
The installer records the previous hooks path (core.hooksPath, or git's hooks directory if it has executable hooks) in .coast/previous-hooks-path. Each shipped hook runs the previous hook of the same name after its own checks pass. The installer warns if a package.json prepare script would reset the path.
9. The Claude Code session hooks¶
The hooks are wired in the hooks, disableAllHooks and permissions.deny keys of .claude/settings.json. The entry point is .coast/hooks/claude-hook.py <hook-id>. It refuses by exiting 2 with the reason on stderr. A hook in session_hooks.off exits 0 with a note.
Behaviour follows Anthropic's hooks reference (https://code.claude.com/docs/en/hooks).
| Hook id | Event | Refuses |
|---|---|---|
governing-edit |
PreToolUse (edit tools) | editing a governing file, .claude/settings.json or .claude/settings.local.json |
chained-cd |
PreToolUse (Bash) | cd followed by another command |
infra-command |
PreToolUse (Bash) | mutating infrastructure commands (below) |
no-verify |
PreToolUse (Bash) | --no-verify, git commit -n, -c core.hooksPath= |
force-push |
PreToolUse (Bash) | git push with --force, -f, --force-with-lease, --force-if-includes, or +refspec |
scan-at-commit |
PreToolUse (Bash) | a commit whose staged scan fails |
scan-on-edit |
PostToolUse (edit tools) | nothing; shows the scanner's findings to the model |
unpushed-at-stop |
Stop | ending a turn with uncommitted source changes, or the first turn after a commit that is not on the remote (later turns get a note until the next commit) |
rules-at-start |
SessionStart | nothing; prints the platform, ratchet counts and what will be refused |
infra-command refuses fly/flyctl, wrangler, cloudflared, terraform/tofu, pulumi, doctl, aws, gcloud, az and nsupdate, unless the command is read-only (for example fly status, aws s3 ls, terraform plan). It also refuses gh repo delete, gh secret|variable|ruleset, and mutating gh api calls on repo settings endpoints.
Bash hooks split a command on &&, ||, ;, pipes and newlines, and look inside $(…), sh -c, eval and find -exec. They skip VAR=value, wrappers such as sudo, env and npx, and shell keywords. test_claude_hooks.py (BashBypasses) covers these routes.
Permission layer. permissions.deny blocks the mutating forms (Bash(git push --force*), Bash(git * --no-verify *), Bash(fly deploy*), Bash(terraform apply*) and others) and edits to governing paths. It holds even where a hook cannot see the command.
Switching hooks off. The project's "disableAllHooks": false overrides a user setting. Only claude --settings '{"disableAllHooks": true}' turns them off, for one run; the git hooks still apply.
10. The installer: adopt.py¶
10.1 Usage¶
adopt.py <project> [--platform ios|macos|android|react-native|web|python]
[--dry-run] [--by <name>] [--secret-scan]
[--jscpd-bin <path>] [--measure-tools [build,format,lint,tests]]
[--release <version>|latest] [--lower-baselines]
[--init | --yes] [--owner <name>] [--product <name>] [--org <name>]
[--checks-dir <path>]
Every flag is described in docs/options.md, and adopt.py --help prints them.
| Environment variable | Use |
|---|---|
COAST_STANDARDS_CACHE |
Release cache (default ~/.cache/coast-standards/). |
COAST_STANDARDS_RELEASES |
Mirror address for release tarballs. |
--release is the only network use. A downloaded release whose CHECKS-VERSION does not match is refused. The report prints wrote, replaced, unchanged, kept (founder-edited) or would write for each file.
10.2 Platform detection¶
Without --platform, the installer reads .coast/platform, or else:
| Found | Platform |
|---|---|
Package.swift with only .iOS( / only .macOS( |
ios / macos |
.xcodeproj whose SDKROOT / SUPPORTED_PLATFORMS name only iphoneos / only macosx |
ios / macos (both or neither: asks) |
| Gradle files | android |
package.json depending on react-native |
react-native |
other package.json |
web |
pyproject.toml, requirements.txt, setup.py |
python |
10.3 What lands where¶
| Path | Owner | Contents |
|---|---|---|
.coast/checks/ |
governing | The scanner, its modules and tables, layout, config (not the verifier). |
.coast/hooks/claude-hook.py |
governing | The session hook. |
.coast/config.json |
governing; edited by a person | Switches and names (§10.5). |
.coast/layout.sh |
governing | The layout table for the sh hooks. |
.githooks/ |
governing | The three git hooks. |
.claude/settings.json |
governing keys only | hooks and disableAllHooks are replaced; permissions.deny is merged; other keys are kept. |
.coast/platform, .coast/standards-version |
governing | The platform, and the adopted release. |
.coast/seeds.json, .coast/installed.json |
governing | Seed hashes, and the files the last run installed (so later runs remove stale ones). |
.coast/paths.json |
governing; written once | Path-class overrides. |
.coast/ratchet-baseline.json, .coast/jscpd-baseline.json |
governing | Baselines. |
.coast/previous-hooks-path |
governing | The previous hooks path. |
.coast/rules-exceptions.json, .coast/module-kinds.json |
governing; written by a person | Exceptions, and module layers. |
.coast/xcode-scheme |
optional; written by a person | The Xcode scheme to build. |
| Linter configs | seed | Written when missing; updated while unedited; kept once edited. |
.claude/skills/<guide>/SKILL.md |
seed | The writing guides in skills/ that are not in writing_guides.off. A guide switched off is removed while unedited and kept once edited. |
docs/domain-rules.md |
seed | Replaced when the shipped copy has a newer release stamp. An edited copy is kept as docs/domain-rules.v<release>.md. An unstamped copy is replaced only while it is still the one the installer wrote. |
CLAUDE.md |
the marked block only | Rewritten from TEMPLATE-PROJECT-CLAUDE.md between <!-- coast-standards: begin --> and end -->. Broken markers stop the run before anything is written. |
10.4 Idempotency¶
A second run changes nothing. The tests check this, along with: --dry-run writes nothing, text outside the CLAUDE.md block survives, and edited seeds are kept.
10.5 Configuration: .coast/config.json¶
Every key, default and layout path is listed in docs/options.md. What a maintainer needs:
- One loader,
config.py, reads the file for every part of the layer.config.py --shprints it as shell variables for the hooks. - Defaults are in
config.default.json. The project file merges over them (objects by key; lists replace), then the run's flags. - The loader refuses a raised severity, a
layout.state_dir, a non-listoff, and a non-integerratchet_daysortests_deadline_seconds. - An
offswitch also counts in the number.verify_rules.py --configshows it, for exampleenforced by a check 21 of 74 (3 switched off). writing_guides.offnames guides fromWRITING_GUIDESinadopt.py. TheCLAUDE.mdblock lists only the guides that are on, through the{{WRITING_GUIDES}}placeholder.--initasks one screen of on/off questions per group. An answer that is missing (input ended) keeps that group's defaults.--yeswritesconfig.default.jsonbyte for byte.layout.jsonis the only place that names the layer's paths. Values may refer to other keys as{key}.test_config.pyfails if any shipped file hard-codes an old path.
11. The linter configs¶
The configs in lint/ switch on the rules the documents cite. The verifier fails if a cited rule is missing.
| File | Installed as | Notable settings |
|---|---|---|
swiftlint.yml |
.swiftlint.yml |
force_unwrapping, force_cast, force_try at error; type 300 and file 400 lines as warnings |
swift-format.json |
.swift-format |
Apple swift-format config |
eslint.config.mjs |
same | type-checked rules, no-floating-promises, react-hooks rules when the plugin is installed |
.prettierrc.json |
same | |
tsconfig.seed.json |
tsconfig.json |
a strict starter, written only if the project has none |
detekt.yml |
same | UnsafeCallOnNullableType, LargeClass 300 |
lint.xml |
same | HardcodedText at error |
.editorconfig |
same | ktlint settings |
ruff.toml |
same | B (bugbear) |
mypy.ini |
same | strict = True |
12. Versioning¶
Every merge to main is a new version, and every version is published as a GitHub release.
| What | Where |
|---|---|
| Version | CHECKS-VERSION (semantic version, no v) |
| Tag and release | v<version>, added by CI to every version on main, with a GitHub release whose notes are the version's changelog section (tools/release_notes.py) |
| Notes | CHANGELOG.md, one ## <version> — <date> section per version |
| A project's version | .coast/standards-version: 1.0.0 from a tarball or tagged clone, 1.0.0+<commit> otherwise |
| Rules release | First line of each platform rules file: <!-- coast-standards-release: X.Y.Z -->, the version in which its text last changed. Old coast-rules-version: N stamps count as older than any version. |
A project can install any tagged version with adopt.py <project> --release <version>. --release latest installs the newest version. A rules document from an older version is upgraded at adoption. A copy from the current version that differs is the owner's edit, and is left alone.
12.1 Every merge raises the version¶
While you work, add changelog entries under ## Unreleased. Before you merge to main:
- Decide which part of the version the change raises.
| Change | Raise |
|---|---|
| Fixes, wording, docs, internal changes | patch |
| A new check, rule, switch, option or refusal | minor |
| A change that makes an adopted project's setup stop working | major |
- Raise it. The tool updates
CHECKS-VERSION, moves the## Unreleasedentries into a new## <version> — <YYYY-MM-DD>section with your summary sentence, and stamps the platform rule files that changed:
python3 tools/version.py bump patch "One sentence summarising the change."
- Commit the changed files and push to
main.
What holds this:
- The pre-push hook (
.githooks/pre-push) refuses a push tomainwhose version is not higher than the one onmain, or whose changelog has no section for it. - The pre-commit hook's tests refuse a rules file still marked
unreleasedonce the version has moved. - After the checks pass on
main, CI tags the commitv<version>and publishes the release. It fails if that tag already marks another commit. - An edit made on the GitHub website skips the pre-push hook. If it did not raise the version, CI fails; raise the version in a follow-up commit.
Do not re-adopt the owner's projects as part of a merge. Each project takes a new version when its owner asks.
12.2 Publishing a missing release¶
CI publishes every version. If a version is missing from the releases page, publish it by hand:
- Open Actions, choose release, and click Run workflow.
- Enter the version, for example
1.9.1. Its tag must already exist.
From a terminal:
gh workflow run release.yml -R Up-Coast/coast-standards -f version=<version>
Publish missing versions oldest first, so the newest one is marked Latest.
How to write release notes¶
Add entries to ## Unreleased in this format as each change lands. Then a release only moves them.
Format:
- After the heading, one sentence that sums up the release.
- Entries grouped under
### Added,### Changed,### Fixedand### Removed. Include only the groups that have entries. - A final
### Upgradingsection: what an adopting project must do, or "Re-runadopt.py <project>. No other changes are needed."
Each bullet:
- Is one line. Do not wrap it. GitHub release pages show every line break, so a wrapped bullet shows as broken lines.
- Starts with a bold phrase that says what changed, in plain words.
- Follows with one or two sentences: the problem it solves, and what the reader needs to do or set, if anything.
- Names things the way a developer using the tool sees them. Write "the test step of the pre-push hook", not "the tests seat". Write "time limit", not "wall-clock deadline".
- Leaves out history: no incident stories, no dates other than the heading, no "born of", no rule numbers, no task ids, no internal decision numbers.
Example of a good bullet:
- **The pre-push hook now stops test runs that take longer than 15 minutes.** A hanging test used to block the push forever with no message; now the push is refused with a message saying a test is hanging. Set `tests_deadline_seconds` in `.coast/config.json` to allow longer runs.
13. Troubleshooting¶
Refusals a developer meets day to day (a count went up or down, a hanging test, a build warning, another push running, no simulator, jscpd missing, a refused file, a wrong check) are covered in docs/when-a-check-stops-you.md. The cases below are the ones a maintainer meets.
A ratchet count fell and the push was refused.
- What it means: a scanner count is below its baseline.
- What to do: run
adopt.py <project> --lower-baselinesand commit the baseline with the change. An agent may do this, because it only lowers counts.
"the rules scanner is not installed."
- What it means: the project is not adopted, or its checks are missing.
- What to do: run
adopt.pyon the project.
The session hooks never fire.
- What it means: the session did not start at the repository root, or the settings are missing.
- What to do: start at the root, and check
.claude/settings.jsonhashooksand"disableAllHooks": false, and that.coast/hooks/claude-hook.pyexists.
The secret scan flagged a test fixture.
- What it means: the value looks like a real credential (
sk-ant-…,ghp_…,AKIA…, a PEM header). Values startingtest-,fake_,dummy.,sample-orstub-are already excused. - What to do: excuse the path in
.coast/rules-exceptions.json(§5.7).
A signature is wrong.
- What it means: the pattern flags good code.
- What to do: fix it in the standards repo with fixtures that prove both directions (§14.1), then re-adopt. Until then, the owner can excuse it (§5.7, §8.6).
CLAUDE.md markers stop the run.
- What it means: the
coast-standardsbegin/end markers are out of order, unmatched or duplicated. - What to do: fix the markers so there is exactly one block, then re-run.
14. Extending¶
14.1 Add a signature¶
- Add a row to
checks/rules_signatures.json, with aplatformsentry for each platform. - Add its id to
EXPECTED_IDSintest_check_rules.py. - Add
<id>.fail.<ext>and<id>.pass.<ext>underchecks/tests/plants/<platform>/. - Tag the rule:
[…; check: scan:<id>](orratchet:/advisory:). - Run the tests and the verifier. The verifier's gap count must not rise.
- Re-adopt the projects that need it.
14.2 Add a built-in¶
- Write a module beside the scanner.
- Register its id in
check_rules.builtin_hits. - Set
"kind": "builtin"on the signature, and add fixtures.
The installer ships every .py and .json in checks/ except the verifier's files.
14.3 Add a seat¶
- In
hooks/pre-push, add the case behindwants <name>, printgate: <name>, and report failures asFAILlines. - List it under
toolsinbattery.jsonfor each platform. - Tag the rule
tool:<name>. - Add a passing and a failing test in
test_hooks_and_adopt.py.
Seat exceptions work for the new name automatically.
14.4 Add a session hook¶
- Add a function and dispatch entry in
claude-hook.py. - Wire it in
claude-settings.jsonwith astatusMessagenaming the id. - Tag the rule
session:<id>. - Add a test in
test_claude_hooks.py.
14.5 Add a platform¶
- Add it to
paths.json,rules_signatures.jsonandbattery.json. - Write
rules/platform/domain-rules-<p>.md. - Add the
pre-pushcase and the linter configs. - Add it to
PLATFORMS,LINT_DESTINATIONSand platform detection in the installer.
15. Tests¶
python3 -m unittest discover -s enforcement/checks/tests -p 'test_*.py'
Run from the standards repo root. It takes about two minutes. jscpd and node must be installed, or their tests fail.
| File | Covers |
|---|---|
test_check_rules.py |
Scanner modes, exceptions, ratchets, and every fixture. |
test_verify_rules.py |
The parser, config readers, bins, and the real corpus. |
test_corpus_sections.py |
Required sections in every app document. |
test_lint_configs.py |
Every cited linter rule is enabled; the ESLint config loads. |
test_claude_hooks.py |
Every session hook. |
test_hooks_and_adopt.py |
Installer idempotency, seeds, secret scan, git hooks, exceptions, previous hooks, layout moves. |
test_config.py |
The layout table, every switch, --config, the questions, and --yes. |
16. Limits, stated plainly¶
- The scanner reads lines, not program structure. Rules that need a syntax tree are deferred.
- Kotlin/Java nested members and accessors are not checked for doc comments.
- Warning, lint and format ratchets exist only for Swift. Other platforms run strict.
- The number is computed at adoption. The session-start hook does not recompute it.
- About half the rules are reviewer rules, with no machine check yet.
- Session hooks need a session started at the repository root, and a command-line
--settingscan turn them off for one run. infra-commandreads verbs, not each tool's grammar. A read-only command with several option values before its verb is refused; reorder it.