chore: adopt the project template and add the Kotlin/Compose skeleton
Period was a bare directory holding one 2,527-line specification, with no git
repository, no tracker and no documentation convention. This is the adoption
from Projects/Template/START-HERE-New-Project.md, plus a project that compiles
so the hooks and future guards have something real to run against.
Documents. scaffold.sh created 19 paths, 0 skipped. The specification moved to
docs/planning/PRODUCT_PLAN.md unchanged in substance, with a status header; the
capitalised Docs/ is gone. Every scaffolded document was filled in for Period.
docs/OPERATIONS.md deleted — an offline app is not a deployed service.
DOC_TRUST_MAP.md written last, describing what is actually here, including what
this project deliberately does not have.
Code. Four Gradle modules. domain/cycle and domain/prediction are kotlin("jvm")
and cannot see the Android SDK, so the engine is testable without an emulator —
17 tests pass, 12 of them the acceptance cases from PRODUCT_PLAN.md §51.
BaselinePredictionEngine is a robust-median prototype and explicitly not the
product; it exists so Batch 02's replacement can be shown to be better rather
than merely different.
Versions verified against their official sources today rather than inherited
from the specification's own numbers, which that document asks for: Kotlin
2.4.10, AGP 9.3.1, Gradle 9.7.0, Compose BOM 2026.08.00, Room 2.8.4, Hilt
2.60.1. AGP 9 ships Kotlin built in, so org.jetbrains.kotlin.android is no
longer applied. compileSdk is 37 because current AndroidX requires it; targetSdk
stays 36, Play's floor from 2026-08-31, and the difference is deliberate.
Six scripts taken into scripts/; the rest declined and named in docs/TOOLS.md.
Three hooks in .githooks/, with pre-commit adapted to Gradle.
closes #1
closes #2
This commit is contained in:
commit
96dd878ac5
|
|
@ -0,0 +1,110 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Every commit says what kind of change it is, before it says anything else.
|
||||||
|
#
|
||||||
|
# ## Why
|
||||||
|
#
|
||||||
|
# Ninety-nine of this repository's commits already carry a conventional type —
|
||||||
|
# `feat:`, `fix:`, `docs:`, `chore:` — and ninety-six do not, including a run of
|
||||||
|
# recent ones written as bare sentences. That split is the problem: `git log
|
||||||
|
# --grep '^fix'` answers "what have we fixed" for half the history and quietly
|
||||||
|
# omits the other half, which is worse than having no convention at all, because
|
||||||
|
# the answer looks complete.
|
||||||
|
#
|
||||||
|
# So the type is required, and the vocabulary is closed. A closed list is the
|
||||||
|
# point — `feat`, `feature` and `feat!` as three spellings of one idea is how a
|
||||||
|
# convention stops being searchable.
|
||||||
|
#
|
||||||
|
# ## The vocabulary
|
||||||
|
#
|
||||||
|
# feat a new capability somebody can use
|
||||||
|
# fix a defect. The thing behaved wrongly and now does not
|
||||||
|
# ui appearance, layout, copy, or interaction, with no change in what
|
||||||
|
# the software knows or decides
|
||||||
|
# docs documentation only
|
||||||
|
# test tests only, with no change to what they test
|
||||||
|
# refactor same behaviour, different shape. If behaviour changed it is not this
|
||||||
|
# security hardening, boundaries, secret handling. Kept separate from `fix`
|
||||||
|
# on purpose: "what have we hardened" is a question worth being able
|
||||||
|
# to ask on its own, and it is the one an auditor asks first
|
||||||
|
# perf faster or lighter, same answers
|
||||||
|
# chore tooling, dependencies, releases. The bucket for work that is not
|
||||||
|
# about the product
|
||||||
|
#
|
||||||
|
# `harden`, `style` and `content` each appear once or twice in the history and
|
||||||
|
# are deliberately not here — they are `security`, `ui` and `docs` under other
|
||||||
|
# names, and a synonym is a hole in a closed list.
|
||||||
|
#
|
||||||
|
# ## Scope is optional, and lowercase
|
||||||
|
#
|
||||||
|
# fix(admin): ... the fifteen existing `admin` scopes, and `release`,
|
||||||
|
# `integrations`, `db` and the rest, all keep working.
|
||||||
|
#
|
||||||
|
# ## What it deliberately does not enforce
|
||||||
|
#
|
||||||
|
# Subject length. Thirty-five existing subjects run past 72 characters, several
|
||||||
|
# of them deliberately, and rejecting a commit for a well-written 80-character
|
||||||
|
# sentence would teach people to use `--no-verify` — which switches off the
|
||||||
|
# checks that actually matter. The type is the part a tool reads; the length is
|
||||||
|
# a matter of taste and stays that way.
|
||||||
|
#
|
||||||
|
# ## Escape hatch
|
||||||
|
#
|
||||||
|
# SKIP_GUARDS=1 git commit ... skips this too, and says so
|
||||||
|
#
|
||||||
|
# The same variable the pre-commit hook uses, because two switches for "I know
|
||||||
|
# what I am doing" is one more than anybody will remember.
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
say() { printf '\033[1mcommit-msg:\033[0m %s\n' "$*" >&2; }
|
||||||
|
|
||||||
|
message_file="$1"
|
||||||
|
subject=$(head -1 "$message_file")
|
||||||
|
|
||||||
|
if [ -n "${SKIP_GUARDS:-}" ]; then
|
||||||
|
say "SKIP_GUARDS set — the commit type was NOT checked."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Git writes these itself, or writes them on a human's behalf during a rebase.
|
||||||
|
# Rejecting them would break `git merge`, `git revert` and autosquash for a
|
||||||
|
# convention none of them ever agreed to.
|
||||||
|
case "$subject" in
|
||||||
|
"Merge "*|"Revert "*|"fixup!"*|"squash!"*|"amend!"*)
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# A comment-only file is an aborted commit; git handles that itself.
|
||||||
|
if [ -z "${subject// /}" ]; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
TYPES="feat|fix|ui|docs|test|refactor|security|perf|chore"
|
||||||
|
|
||||||
|
if printf '%s' "$subject" | grep -qE "^(${TYPES})(\([a-z0-9._-]+\))?!?: .+"; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
say "the subject line needs a type."
|
||||||
|
say ""
|
||||||
|
say " got: ${subject}"
|
||||||
|
say ""
|
||||||
|
say " expected: <type>: <subject> e.g. fix: stop counting milestones as records"
|
||||||
|
say " <type>(<scope>): <subject> ui(admin): mark cloud models on the picker"
|
||||||
|
say ""
|
||||||
|
say " types: feat a new capability"
|
||||||
|
say " fix a defect, now not"
|
||||||
|
say " ui appearance, layout, copy — no change to what it decides"
|
||||||
|
say " docs documentation only"
|
||||||
|
say " test tests only"
|
||||||
|
say " refactor same behaviour, different shape"
|
||||||
|
say " security hardening, boundaries, secrets"
|
||||||
|
say " perf faster or lighter, same answers"
|
||||||
|
say " chore tooling, dependencies, releases"
|
||||||
|
say ""
|
||||||
|
say " Your message is kept. Run 'git commit' again to edit it, or"
|
||||||
|
say " SKIP_GUARDS=1 git commit ... to bypass this loudly."
|
||||||
|
|
||||||
|
exit 1
|
||||||
|
|
@ -0,0 +1,91 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Every commit goes to Forgejo, without anybody having to remember the push.
|
||||||
|
#
|
||||||
|
# ## Why a hook rather than a habit
|
||||||
|
#
|
||||||
|
# The commit that added `pre-commit` sat unpushed for a day. Nothing was wrong
|
||||||
|
# with it; it just never got the second command. That is the whole failure this
|
||||||
|
# closes — work that exists on one laptop and nowhere else is work that is one
|
||||||
|
# disk away from gone, and it is invisible to anybody reading the tracker.
|
||||||
|
#
|
||||||
|
# ## It runs after the commit, and cannot undo one
|
||||||
|
#
|
||||||
|
# git ignores this hook's exit code, which is the right shape for the job: a
|
||||||
|
# network that is down must not cost somebody a commit they already made. So a
|
||||||
|
# failed push is **reported loudly and left in place** — the commit stands, the
|
||||||
|
# branch is simply still ahead, and the next commit tries again.
|
||||||
|
#
|
||||||
|
# What it will never do is force. A rejected push means the remote has something
|
||||||
|
# this checkout has not seen, and the fix for that is a human running a pull, not
|
||||||
|
# a hook overwriting the difference.
|
||||||
|
#
|
||||||
|
# ## When it deliberately stays out of the way
|
||||||
|
#
|
||||||
|
# - mid-rebase, mid-cherry-pick, mid-am: every step of a rebase fires this
|
||||||
|
# hook, and pushing an intermediate commit publishes a history that is about
|
||||||
|
# to be rewritten. Wait for the rebase to finish.
|
||||||
|
# - detached HEAD: there is no branch to push, and guessing one is worse than
|
||||||
|
# doing nothing.
|
||||||
|
# - no `origin`: a clone with no remote is a legitimate state, not an error.
|
||||||
|
#
|
||||||
|
# SKIP_PUSH=1 git commit ... commits without publishing, loudly
|
||||||
|
#
|
||||||
|
# The counterpart to `SKIP_GUARDS` in the pre-commit hook, and loud for the same
|
||||||
|
# reason: an exception that leaves no trace becomes a habit.
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
cd "$(git rev-parse --show-toplevel)" || exit 0
|
||||||
|
|
||||||
|
say() { printf '\033[1mpost-commit:\033[0m %s\n' "$*" >&2; }
|
||||||
|
|
||||||
|
if [ -n "${SKIP_PUSH:-}" ]; then
|
||||||
|
say "SKIP_PUSH set — this commit was NOT pushed."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
git_dir=$(git rev-parse --git-dir)
|
||||||
|
|
||||||
|
# A rebase, cherry-pick or `git am` fires this hook once per replayed commit.
|
||||||
|
# Those commits are provisional by definition.
|
||||||
|
for marker in rebase-merge rebase-apply CHERRY_PICK_HEAD; do
|
||||||
|
if [ -e "$git_dir/$marker" ]; then
|
||||||
|
say "a rebase or cherry-pick is in progress — not pushing until it finishes."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
branch=$(git symbolic-ref --quiet --short HEAD) || {
|
||||||
|
say "detached HEAD — no branch to push."
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if ! git remote get-url origin >/dev/null 2>&1; then
|
||||||
|
say "no 'origin' remote — nothing to push to."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
say "pushing $branch to origin…"
|
||||||
|
|
||||||
|
# --porcelain keeps the output to one parseable line per ref rather than the
|
||||||
|
# usual banner, and the timeout is here because an unreachable SSH host
|
||||||
|
# otherwise hangs the terminal well past the point of being useful.
|
||||||
|
if timeout 60 git push --porcelain origin "$branch"; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
status=$?
|
||||||
|
|
||||||
|
if [ "$status" -eq 124 ]; then
|
||||||
|
say "push timed out after 60s. The commit is safe locally; push when the"
|
||||||
|
say " remote is reachable."
|
||||||
|
else
|
||||||
|
say "push was refused. The commit is safe locally and the branch is now ahead."
|
||||||
|
say " If this is a non-fast-forward, pull and reconcile — this hook will"
|
||||||
|
say " not force, and should not."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Deliberately zero. git ignores it either way, and returning non-zero here reads
|
||||||
|
# as though the commit failed when it did not.
|
||||||
|
exit 0
|
||||||
|
|
@ -0,0 +1,115 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# The repo's own guards, before a commit rather than after it.
|
||||||
|
#
|
||||||
|
# ## Why this lives in the repository and not in .git/hooks
|
||||||
|
#
|
||||||
|
# `.git/hooks` is not versioned, so a hook living there protects exactly one
|
||||||
|
# checkout and silently protects nothing anywhere else. This directory is
|
||||||
|
# committed, and `core.hooksPath` points at it:
|
||||||
|
#
|
||||||
|
# git config core.hooksPath .githooks
|
||||||
|
#
|
||||||
|
# That one line is the only setup, and it is per clone. See the README.
|
||||||
|
#
|
||||||
|
# ## Adapted from the template's version
|
||||||
|
#
|
||||||
|
# The template's pre-commit runs `npx tsc --noEmit` and `npx vitest`. Period is
|
||||||
|
# a Kotlin/Android project with no npm anywhere, so the two guard commands are
|
||||||
|
# Gradle instead. Everything else — the ordering, the secret scan, the
|
||||||
|
# unstaged-changes warning, SKIP_GUARDS — is unchanged, because the arguments
|
||||||
|
# for those are not language-specific.
|
||||||
|
#
|
||||||
|
# ## What it checks, and why in this order
|
||||||
|
#
|
||||||
|
# 1. The secret scan, first and cheapest: it reads the staged diff only. A
|
||||||
|
# keystore caught here costs a `git reset`; the same keystore caught after a
|
||||||
|
# push cannot be undone at all — an upload key cannot be rotated once the app
|
||||||
|
# is published.
|
||||||
|
# 2. The JVM unit tests, when Kotlin is staged. They are the prediction
|
||||||
|
# acceptance cases from PRODUCT_PLAN.md §51 and they run on the JVM in about
|
||||||
|
# a second, which is the whole reason :domain:* is not an Android module.
|
||||||
|
#
|
||||||
|
# Compilation of the Android modules is deliberately NOT run here. It needs the
|
||||||
|
# SDK and takes half a minute, and a pre-commit hook people disable is worse
|
||||||
|
# than one that checks less. `./gradlew assembleRelease` belongs to the release
|
||||||
|
# checklist — docs/security/SECURITY_CHECKLIST.md — which is where it is.
|
||||||
|
#
|
||||||
|
# ## It warns about unstaged changes rather than failing on them
|
||||||
|
#
|
||||||
|
# Both commands run against the **working tree**, not the index. A clean run
|
||||||
|
# proves the working tree is good, which is only the same thing as the commit
|
||||||
|
# being good when nothing is left unstaged. Saying so is more honest than
|
||||||
|
# implying a guarantee that was not made.
|
||||||
|
#
|
||||||
|
# ## Escape hatch
|
||||||
|
#
|
||||||
|
# SKIP_GUARDS=1 git commit ... skips both, loudly
|
||||||
|
# git commit --no-verify ... skips the hook entirely, silently
|
||||||
|
#
|
||||||
|
# The first is preferred: it leaves a line in the terminal saying the guards did
|
||||||
|
# not run, which is the difference between a deliberate exception and a habit.
|
||||||
|
#
|
||||||
|
# ## Exit codes
|
||||||
|
#
|
||||||
|
# 0 the guards that applied ran and passed
|
||||||
|
# 1 a guard failed, or refused — the commit is stopped
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
cd "$(git rev-parse --show-toplevel)" || exit 1
|
||||||
|
|
||||||
|
say() { printf '\033[1mpre-commit:\033[0m %s\n' "$*" >&2; }
|
||||||
|
|
||||||
|
if [ -n "${SKIP_GUARDS:-}" ]; then
|
||||||
|
say "SKIP_GUARDS set — the secret scan and tests did NOT run for this commit."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Nothing staged is not this hook's problem; git will refuse on its own.
|
||||||
|
if git diff --cached --quiet; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Credentials, before the commit exists.
|
||||||
|
if [ -f scripts/secrets.sh ]; then
|
||||||
|
if ! bash scripts/secrets.sh; then
|
||||||
|
say "possible credential in the staged changes — commit refused."
|
||||||
|
say "If it is real, ROTATE IT. Deleting the line does not remove it from a"
|
||||||
|
say "commit that already exists. If it is not, --allow the path or adjust"
|
||||||
|
say "the patterns; do not silence the check."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
# Said out loud rather than passed over: a missing scanner reads exactly like
|
||||||
|
# a scanner that found nothing.
|
||||||
|
say "note: scripts/secrets.sh is not here, so nothing scanned the staged diff."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Only worth running when Kotlin or the build changed. A docs-only commit does
|
||||||
|
# not need the suite; a change to a build file does, because that is how a
|
||||||
|
# module boundary gets widened without anyone reading a Kotlin line.
|
||||||
|
staged=$(git diff --cached --name-only)
|
||||||
|
touches_code=$(printf '%s\n' "$staged" \
|
||||||
|
| grep -cE '\.(kt|kts)$|^gradle/libs\.versions\.toml$|^gradle\.properties$' || true)
|
||||||
|
|
||||||
|
if [ "$touches_code" -gt 0 ]; then
|
||||||
|
say "JVM unit tests…"
|
||||||
|
|
||||||
|
# :domain:* only. These are pure-JVM modules, so this needs no Android SDK and
|
||||||
|
# no emulator, and it is the suite that guards the product's core claim.
|
||||||
|
if ! ./gradlew --quiet --console=plain :domain:cycle:test :domain:prediction:test; then
|
||||||
|
say "tests failed — commit refused."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
say "no .kt/.kts or build file staged — skipping the suite."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Said last so it is the thing still on screen when the editor opens.
|
||||||
|
if ! git diff --quiet; then
|
||||||
|
say "NOTE: unstaged changes are present. The guards ran against the working"
|
||||||
|
say " tree, so they did not verify this commit in isolation."
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit 0
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Secrets. docs/security/SECURITY.md is the rule; this is the safety net, and
|
||||||
|
# it is not a substitute for reading that file.
|
||||||
|
#
|
||||||
|
# The upload keystore is the one secret here whose loss is permanent in BOTH
|
||||||
|
# directions: lost means this app can never be updated again, leaked means
|
||||||
|
# somebody else can sign as us. It cannot be rotated once the app is published.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
local.properties
|
||||||
|
*.jks
|
||||||
|
*.keystore
|
||||||
|
keystore.properties
|
||||||
|
play-service-account*.json
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
|
||||||
|
# Gradle and Android build output
|
||||||
|
.gradle/
|
||||||
|
build/
|
||||||
|
captures/
|
||||||
|
.cxx/
|
||||||
|
*.apk
|
||||||
|
*.aab
|
||||||
|
*.ap_
|
||||||
|
*.dex
|
||||||
|
output.json
|
||||||
|
|
||||||
|
# Python bytecode from the scripts in scripts/
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
|
||||||
|
# Editor and OS cruft
|
||||||
|
.DS_Store
|
||||||
|
.idea/
|
||||||
|
*.iml
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*~
|
||||||
|
|
@ -0,0 +1,169 @@
|
||||||
|
# Period
|
||||||
|
|
||||||
|
```text
|
||||||
|
Status: Draft
|
||||||
|
Owner: _null
|
||||||
|
Last reviewed: 2026-08-18
|
||||||
|
Governs: README.md as the project-facing overview for Period
|
||||||
|
Review trigger: The first buildable feature release; any change to the stack, the
|
||||||
|
privacy promise, or how the project is built and run
|
||||||
|
```
|
||||||
|
|
||||||
|
A private Android period tracker that learns **your** cycle rather than the
|
||||||
|
average person's — and never sells your data.
|
||||||
|
|
||||||
|
[What it is](#what-it-is) | [Status](#status) | [Build and run](#build-and-run) |
|
||||||
|
[Repository map](#repository-map) | [Project docs](#project-docs) |
|
||||||
|
[Agent notes](#agent-notes)
|
||||||
|
|
||||||
|
_{ Android / Google Play | Kotlin + Jetpack Compose | Room, offline-first |
|
||||||
|
Personalized prediction with an honest window | Discreet notifications |
|
||||||
|
No account, no server, no data sale }_
|
||||||
|
|
||||||
|
The core loop is:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Track → Learn → Predict → Remind → Learn again
|
||||||
|
```
|
||||||
|
|
||||||
|
## What it is
|
||||||
|
|
||||||
|
A focused tracker that answers one question well: **when is my next period
|
||||||
|
likely to start?** It records confirmed period dates, learns the individual's
|
||||||
|
pattern from them, and produces a most-likely date with a window and a
|
||||||
|
confidence label — never a bare date presented as fact. From that it estimates
|
||||||
|
ovulation and the fertile window, and it asks discreetly whether the period
|
||||||
|
started, using both *yes* and *not yet* to improve the next forecast.
|
||||||
|
|
||||||
|
Two promises hold the product up:
|
||||||
|
|
||||||
|
> **Your period. Better predicted.** — once there is enough personal history,
|
||||||
|
> the app must not fall back to a generic 28-day cycle. A user recording 34, 35,
|
||||||
|
> 36, 34, 35 who is predicted 28 is a core product defect, not a tuning issue.
|
||||||
|
|
||||||
|
> **Your cycle belongs to you.** We will never sell period history, fertility
|
||||||
|
> information, ovulation estimates, cycle predictions or personally identifiable
|
||||||
|
> information — to advertisers, data brokers or anyone else.
|
||||||
|
|
||||||
|
The second is enforced structurally, not remembered: health data cannot reach
|
||||||
|
the advertising subsystem, because the module that will hold ad code declares no
|
||||||
|
dependency on the cycle database or the prediction domain, and a guard proves it.
|
||||||
|
|
||||||
|
What it deliberately is **not** — no community, no pregnancy mode, no chatbot,
|
||||||
|
no article feed, no symptom encyclopedia, and not contraception — is in
|
||||||
|
[docs/planning/PROJECT_PLAN.md](docs/planning/PROJECT_PLAN.md).
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
**Skeleton.** There is no usable app yet, and this section says so rather than
|
||||||
|
listing features that do not exist.
|
||||||
|
|
||||||
|
| Surface | Status | Evidence |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Documentation tree and tracker convention | Adopted | this tree; milestones and issues in the tracker |
|
||||||
|
| Gradle / Kotlin / Compose project | Builds | `./gradlew assembleDebug` and `assembleRelease` both pass |
|
||||||
|
| Material 3 theme and tokens | Built | `core/designsystem` |
|
||||||
|
| Four-tab navigation shell | Built, placeholder screens | `app/src/main/kotlin/dev/privacyllc/period/navigation/PeriodApp.kt` |
|
||||||
|
| Cycle model and interval derivation | Built | `domain/cycle`, 5 tests |
|
||||||
|
| Prediction engine | **Baseline only** | `domain/prediction`, 12 acceptance tests from PRODUCT_PLAN §51 |
|
||||||
|
| Room persistence, DataStore | Not built | Batch 01 |
|
||||||
|
| Onboarding, Today, Calendar, Insights | Not built | Batch 03 |
|
||||||
|
| Fertility, notifications, privacy features, monetization | Not built | Batches 04–07 |
|
||||||
|
| QA | No round run | [docs/qa/ClaudeReport.md](docs/qa/ClaudeReport.md) |
|
||||||
|
|
||||||
|
`BaselinePredictionEngine` is a robust-median prototype and is **not the
|
||||||
|
product**. PRODUCT_PLAN §11 names a plain average as an acceptable prototype and
|
||||||
|
an unacceptable final engine; Batch 02 replaces it with the recency-weighted,
|
||||||
|
trend-aware, "not yet"-conditioned engine §12 specifies. The acceptance tests
|
||||||
|
exist now so that replacement can be shown to be better rather than merely
|
||||||
|
different.
|
||||||
|
|
||||||
|
## Build and run
|
||||||
|
|
||||||
|
Prerequisites: a JDK 21 and the Android SDK with **platform 37** and
|
||||||
|
**build-tools 37.0.0** installed, `ANDROID_HOME` set.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git config core.hooksPath .githooks # per clone, every clone — see below
|
||||||
|
|
||||||
|
./gradlew test # JVM suites, no emulator, ~1s
|
||||||
|
./gradlew assembleDebug # app/build/outputs/apk/debug/
|
||||||
|
./gradlew assembleRelease # minified, unsigned
|
||||||
|
```
|
||||||
|
|
||||||
|
`compileSdk` is 37 because the current AndroidX libraries require it;
|
||||||
|
`targetSdk` is 36, Google Play's floor for new apps from 2026-08-31. They are
|
||||||
|
deliberately different — raising `targetSdk` opts the app into runtime behaviour
|
||||||
|
changes and is a tested decision, not a build fix.
|
||||||
|
|
||||||
|
**`git config core.hooksPath .githooks` is per clone.** Without it the hooks are
|
||||||
|
not installed and fail silently, which is the failure mode they exist to
|
||||||
|
prevent. `post-commit` pushes; see
|
||||||
|
[docs/architecture/githooks/README.md](docs/architecture/githooks/README.md).
|
||||||
|
|
||||||
|
## Repository map
|
||||||
|
|
||||||
|
```text
|
||||||
|
app/ application module, MainActivity, navigation shell
|
||||||
|
core/designsystem/ Material 3 theme and colour tokens
|
||||||
|
domain/cycle/ pure Kotlin — period records, cycle derivation
|
||||||
|
domain/prediction/ pure Kotlin — the forecast, window and confidence
|
||||||
|
scripts/ the six template scripts this project adopted
|
||||||
|
.githooks/ pre-commit, commit-msg, post-commit
|
||||||
|
docs/ product, architecture, design, QA, security, history
|
||||||
|
```
|
||||||
|
|
||||||
|
`domain/*` are `kotlin("jvm")` modules and cannot see the Android SDK. That is
|
||||||
|
on purpose: the prediction engine is the product, so it needs the most tests,
|
||||||
|
and tests that need an emulator are tests that do not get run.
|
||||||
|
|
||||||
|
## Project docs
|
||||||
|
|
||||||
|
Detailed procedures belong in docs; **open work belongs in the tracker**, not in
|
||||||
|
this README.
|
||||||
|
|
||||||
|
| Doc | Purpose |
|
||||||
|
| --- | --- |
|
||||||
|
| [docs/DOC_TRUST_MAP.md](docs/DOC_TRUST_MAP.md) | Which document owns which answer, and which source wins when records disagree. **Read this first.** |
|
||||||
|
| [docs/planning/PRODUCT_PLAN.md](docs/planning/PRODUCT_PLAN.md) | The full V1 specification — prediction requirements, screens, copy, compliance |
|
||||||
|
| [docs/planning/PROJECT_PLAN.md](docs/planning/PROJECT_PLAN.md) | The short vision, the stack and its reasons, and what this is deliberately not |
|
||||||
|
| [docs/WORK_CYCLE.md](docs/WORK_CYCLE.md) | What happens at the end of a piece of work |
|
||||||
|
| [docs/architecture/README.md](docs/architecture/README.md) | Modules, boundaries, data shapes, migrations |
|
||||||
|
| [docs/design/README.md](docs/design/README.md) | Tone, and the four rules that settle design arguments |
|
||||||
|
| [docs/security/SECURITY.md](docs/security/SECURITY.md) | Threat model, the advertising boundary, logging rules |
|
||||||
|
| [docs/TOOLS.md](docs/TOOLS.md) | Which script to run, and which ones can stop you |
|
||||||
|
|
||||||
|
## Where the tracker is
|
||||||
|
|
||||||
|
Milestones are batches, issues are deliverables, and severity labels are exactly
|
||||||
|
`P0`, `P1`, `P2` and `release-blocker`. Credentials come from the environment,
|
||||||
|
never from this repository:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
set -a; . ~/.openclaw/docker-registry.env; set +a
|
||||||
|
python3 scripts/forgejo-issue.py list
|
||||||
|
```
|
||||||
|
|
||||||
|
Two traps that cost an hour each otherwise: Cloudflare 1010-blocks clients that
|
||||||
|
do not look like a browser or curl, so every request needs
|
||||||
|
`User-Agent: curl/8.5.0` — `forgejo-issue.py` sends it and anything new must
|
||||||
|
too. And `/issues` returns pull requests unless `type=issues` is passed.
|
||||||
|
|
||||||
|
## Agent notes
|
||||||
|
|
||||||
|
- **Product truth comes from the code and the tracker before prose.**
|
||||||
|
- Do not keep a work list in this README or anywhere else in `docs/`.
|
||||||
|
- Finish with [docs/WORK_CYCLE.md](docs/WORK_CYCLE.md), every time: close what
|
||||||
|
you finished with the evidence, close the milestone if the batch landed,
|
||||||
|
update the documents the change triggered **in the same commit**, push, log
|
||||||
|
the entry, then reconcile and write the summary and next action.
|
||||||
|
- Nothing on privacyllc.dev writes itself except the tracker counts and the
|
||||||
|
pushed docs.
|
||||||
|
- **Do not claim a feature is built** unless you can cite the file, test or
|
||||||
|
screenshot that proves it. The status table above is the standard.
|
||||||
|
- **Verify current stable versions before changing the toolchain.** The ones in
|
||||||
|
`gradle/libs.versions.toml` were checked against the official sources on
|
||||||
|
2026-08-18; PRODUCT_PLAN.md asks for that check rather than for its own
|
||||||
|
numbers to be trusted.
|
||||||
|
- Never put a cycle date, a prediction or a fertility state into a log, an
|
||||||
|
analytics event, or an ad request.
|
||||||
|
|
@ -0,0 +1,80 @@
|
||||||
|
plugins {
|
||||||
|
alias(libs.plugins.android.application)
|
||||||
|
alias(libs.plugins.kotlin.compose)
|
||||||
|
alias(libs.plugins.ksp)
|
||||||
|
alias(libs.plugins.hilt)
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "dev.privacyllc.period"
|
||||||
|
// compileSdk and targetSdk are deliberately different. compileSdk 37 is what
|
||||||
|
// the current AndroidX libraries require to compile against; targetSdk 36 is
|
||||||
|
// Google Play's floor for new apps from 2026-08-31 and is what the app opts
|
||||||
|
// into at runtime. Raising targetSdk opts in to behaviour changes and is a
|
||||||
|
// tested decision, not a build fix.
|
||||||
|
compileSdk = 37
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId = "dev.privacyllc.period"
|
||||||
|
minSdk = 26
|
||||||
|
// Google Play requires API 36 for new apps and updates from 2026-08-31.
|
||||||
|
// Confirm the current requirement at submission time rather than here.
|
||||||
|
targetSdk = 36
|
||||||
|
versionCode = 1
|
||||||
|
versionName = "0.1.0"
|
||||||
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
isMinifyEnabled = true
|
||||||
|
isShrinkResources = true
|
||||||
|
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||||
|
}
|
||||||
|
debug {
|
||||||
|
applicationIdSuffix = ".debug"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buildFeatures {
|
||||||
|
compose = true
|
||||||
|
}
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
|
||||||
|
packaging {
|
||||||
|
resources.excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(project(":core:designsystem"))
|
||||||
|
implementation(project(":domain:cycle"))
|
||||||
|
implementation(project(":domain:prediction"))
|
||||||
|
|
||||||
|
implementation(libs.androidx.core.ktx)
|
||||||
|
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||||
|
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||||
|
implementation(libs.androidx.activity.compose)
|
||||||
|
implementation(libs.androidx.navigation.compose)
|
||||||
|
implementation(libs.kotlinx.coroutines.core)
|
||||||
|
|
||||||
|
implementation(platform(libs.compose.bom))
|
||||||
|
implementation(libs.compose.ui)
|
||||||
|
implementation(libs.compose.ui.graphics)
|
||||||
|
implementation(libs.compose.ui.tooling.preview)
|
||||||
|
implementation(libs.compose.material3)
|
||||||
|
implementation(libs.compose.material.icons.extended)
|
||||||
|
debugImplementation(libs.compose.ui.tooling)
|
||||||
|
|
||||||
|
implementation(libs.hilt.android)
|
||||||
|
implementation(libs.hilt.navigation.compose)
|
||||||
|
ksp(libs.hilt.compiler)
|
||||||
|
|
||||||
|
testImplementation(libs.junit)
|
||||||
|
androidTestImplementation(libs.androidx.test.junit)
|
||||||
|
androidTestImplementation(libs.androidx.espresso.core)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
# Release builds are minified. Nothing project-specific is needed yet.
|
||||||
|
#
|
||||||
|
# When a rule IS added here it must say what breaks without it — a proguard file
|
||||||
|
# of unexplained -keep lines is a file nobody can ever safely shrink again.
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
No INTERNET permission. The core tracker is offline by design
|
||||||
|
(PRODUCT_PLAN.md §44, §49) and it stays that way until Batch 07 brings ads
|
||||||
|
and billing, at which point adding it is a deliberate, reviewable change
|
||||||
|
rather than something that was always there.
|
||||||
|
|
||||||
|
No SCHEDULE_EXACT_ALARM either: reminders use WorkManager, because a period
|
||||||
|
reminder does not need alarm-clock precision (§31).
|
||||||
|
-->
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:name=".PeriodApplication"
|
||||||
|
android:allowBackup="false"
|
||||||
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
|
android:fullBackupContent="false"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@style/Theme.Period">
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:theme="@style/Theme.Period">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
</application>
|
||||||
|
|
||||||
|
</manifest>
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
package dev.privacyllc.period
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import dev.privacyllc.period.designsystem.PeriodTheme
|
||||||
|
import dev.privacyllc.period.navigation.PeriodApp
|
||||||
|
|
||||||
|
@AndroidEntryPoint
|
||||||
|
class MainActivity : ComponentActivity() {
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
enableEdgeToEdge()
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
setContent {
|
||||||
|
PeriodTheme {
|
||||||
|
PeriodApp()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
package dev.privacyllc.period
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
|
|
||||||
|
@HiltAndroidApp
|
||||||
|
class PeriodApplication : Application()
|
||||||
|
|
@ -0,0 +1,130 @@
|
||||||
|
package dev.privacyllc.period.navigation
|
||||||
|
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.CalendarMonth
|
||||||
|
import androidx.compose.material.icons.filled.Insights
|
||||||
|
import androidx.compose.material.icons.filled.Settings
|
||||||
|
import androidx.compose.material.icons.filled.Today
|
||||||
|
import androidx.compose.material.icons.filled.Circle
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.NavigationBar
|
||||||
|
import androidx.compose.material3.NavigationBarItem
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.navigation.NavDestination.Companion.hierarchy
|
||||||
|
import androidx.navigation.NavGraph.Companion.findStartDestination
|
||||||
|
import androidx.navigation.compose.NavHost
|
||||||
|
import androidx.navigation.compose.composable
|
||||||
|
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||||
|
import androidx.navigation.compose.rememberNavController
|
||||||
|
import dev.privacyllc.period.R
|
||||||
|
import dev.privacyllc.period.designsystem.PeriodTheme
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The four tabs from docs/planning/PRODUCT_PLAN.md §20.
|
||||||
|
*
|
||||||
|
* Four and only four. Every proposal to add a fifth is answered by
|
||||||
|
* docs/planning/PROJECT_PLAN.md's "deliberately not" list.
|
||||||
|
*/
|
||||||
|
enum class PeriodDestination(
|
||||||
|
val route: String,
|
||||||
|
@param:StringRes val labelRes: Int,
|
||||||
|
val icon: ImageVector,
|
||||||
|
) {
|
||||||
|
TODAY("today", R.string.tab_today, Icons.Filled.Today),
|
||||||
|
CALENDAR("calendar", R.string.tab_calendar, Icons.Filled.CalendarMonth),
|
||||||
|
INSIGHTS("insights", R.string.tab_insights, Icons.Filled.Insights),
|
||||||
|
SETTINGS("settings", R.string.tab_settings, Icons.Filled.Settings),
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PeriodApp() {
|
||||||
|
val navController = rememberNavController()
|
||||||
|
val backStackEntry by navController.currentBackStackEntryAsState()
|
||||||
|
val currentDestination = backStackEntry?.destination
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
bottomBar = {
|
||||||
|
NavigationBar {
|
||||||
|
PeriodDestination.entries.forEach { destination ->
|
||||||
|
val selected = currentDestination?.hierarchy?.any { it.route == destination.route } == true
|
||||||
|
NavigationBarItem(
|
||||||
|
selected = selected,
|
||||||
|
onClick = {
|
||||||
|
navController.navigate(destination.route) {
|
||||||
|
popUpTo(navController.graph.findStartDestination().id) { saveState = true }
|
||||||
|
launchSingleTop = true
|
||||||
|
restoreState = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
icon = { Icon(destination.icon, contentDescription = null) },
|
||||||
|
label = { Text(stringResource(destination.labelRes)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) { innerPadding ->
|
||||||
|
NavHost(
|
||||||
|
navController = navController,
|
||||||
|
startDestination = PeriodDestination.TODAY.route,
|
||||||
|
modifier = Modifier.padding(innerPadding),
|
||||||
|
) {
|
||||||
|
PeriodDestination.entries.forEach { destination ->
|
||||||
|
composable(destination.route) {
|
||||||
|
PlaceholderScreen(stringResource(destination.labelRes))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skeleton only. Each of these is replaced by its real screen in Batch 03, and
|
||||||
|
* saying "not built yet" on the screen is deliberate — a convincing mock is how
|
||||||
|
* a screen comes to be believed finished.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun PlaceholderScreen(title: String) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.Circle,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primaryContainer,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Not built yet",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Composable
|
||||||
|
private fun PeriodAppPreview() {
|
||||||
|
PeriodTheme { PeriodApp() }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
Abstract cycle: an open ring with one offset dot marking progression.
|
||||||
|
|
||||||
|
PRODUCT_PLAN.md §42 forbids blood drops, tampers, pads, uterus imagery, gender
|
||||||
|
symbols and anatomical graphics. This is a placeholder behind a replaceable
|
||||||
|
resource name — the real mark is tracked as an issue, not assumed done.
|
||||||
|
-->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
|
||||||
|
<path
|
||||||
|
android:pathData="M54,30 m-18,0 a18,18 0 1,1 36,0 a18,18 0 1,1 -36,0"
|
||||||
|
android:strokeColor="#FBF7F3"
|
||||||
|
android:strokeWidth="5"
|
||||||
|
android:strokeLineCap="round"
|
||||||
|
android:fillColor="#00000000" />
|
||||||
|
|
||||||
|
<path
|
||||||
|
android:pathData="M54,30 m0,-18 a4.5,4.5 0 1,1 0,9 a4.5,4.5 0 1,1 0,-9"
|
||||||
|
android:fillColor="#C9B8D8" />
|
||||||
|
</vector>
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/ic_launcher_background" />
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
</adaptive-icon>
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/ic_launcher_background" />
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
</adaptive-icon>
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="ic_launcher_background">#4A2545</color>
|
||||||
|
</resources>
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">Period</string>
|
||||||
|
|
||||||
|
<string name="tab_today">Today</string>
|
||||||
|
<string name="tab_calendar">Calendar</string>
|
||||||
|
<string name="tab_insights">Insights</string>
|
||||||
|
<string name="tab_settings">Settings</string>
|
||||||
|
</resources>
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<!-- Compose owns the real theme; this is the launch/system theme only. -->
|
||||||
|
<style name="Theme.Period" parent="android:Theme.Material.NoActionBar" />
|
||||||
|
</resources>
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
Backup is off at the application level (android:allowBackup="false") and this
|
||||||
|
file is the belt to that brace.
|
||||||
|
|
||||||
|
docs/security/SECURITY.md: the health database is excluded from platform
|
||||||
|
backup until a decision to include it has been made and recorded. Android
|
||||||
|
auto-backup is on by default, and a default that silently ships a cycle
|
||||||
|
history to a cloud account defeats the entire local-first argument.
|
||||||
|
-->
|
||||||
|
<data-extraction-rules>
|
||||||
|
<cloud-backup>
|
||||||
|
<exclude domain="root" />
|
||||||
|
<exclude domain="database" />
|
||||||
|
<exclude domain="sharedpref" />
|
||||||
|
<exclude domain="file" />
|
||||||
|
</cloud-backup>
|
||||||
|
<device-transfer>
|
||||||
|
<exclude domain="root" />
|
||||||
|
<exclude domain="database" />
|
||||||
|
<exclude domain="sharedpref" />
|
||||||
|
<exclude domain="file" />
|
||||||
|
</device-transfer>
|
||||||
|
</data-extraction-rules>
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
// AGP 9 has built-in Kotlin support, so there is no org.jetbrains.kotlin.android
|
||||||
|
// plugin here — applying it is now an error rather than a redundancy.
|
||||||
|
// The Compose compiler plugin is still applied separately.
|
||||||
|
// See https://developer.android.com/build/migrate-to-built-in-kotlin
|
||||||
|
plugins {
|
||||||
|
alias(libs.plugins.android.application) apply false
|
||||||
|
alias(libs.plugins.android.library) apply false
|
||||||
|
alias(libs.plugins.kotlin.jvm) apply false
|
||||||
|
alias(libs.plugins.kotlin.compose) apply false
|
||||||
|
alias(libs.plugins.ksp) apply false
|
||||||
|
alias(libs.plugins.hilt) apply false
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
plugins {
|
||||||
|
alias(libs.plugins.android.library)
|
||||||
|
alias(libs.plugins.kotlin.compose)
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "dev.privacyllc.period.designsystem"
|
||||||
|
compileSdk = 37
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
minSdk = 26
|
||||||
|
}
|
||||||
|
|
||||||
|
buildFeatures {
|
||||||
|
compose = true
|
||||||
|
}
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(platform(libs.compose.bom))
|
||||||
|
api(libs.compose.material3)
|
||||||
|
api(libs.compose.ui)
|
||||||
|
api(libs.compose.ui.graphics)
|
||||||
|
api(libs.compose.ui.tooling.preview)
|
||||||
|
debugImplementation(libs.compose.ui.tooling)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
package dev.privacyllc.period.designsystem
|
||||||
|
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The palette from docs/planning/PRODUCT_PLAN.md §39, as tokens.
|
||||||
|
*
|
||||||
|
* Two rules live here rather than in a reviewer's memory:
|
||||||
|
*
|
||||||
|
* - the period state is a **berry/plum**, never a graphic blood-red;
|
||||||
|
* - the fertile window is a **muted teal/sage**, never a bright green, because
|
||||||
|
* green reads as "safe" and this app must never say that about fertility.
|
||||||
|
*
|
||||||
|
* Nothing outside this file declares a colour literal. A `Color(0xFF...)` in a
|
||||||
|
* Composable is a review failure — see docs/design/README.md.
|
||||||
|
*/
|
||||||
|
internal object PeriodPalette {
|
||||||
|
val DeepPlum = Color(0xFF4A2545)
|
||||||
|
val MutedBerry = Color(0xFF8C3A5C)
|
||||||
|
val BerryLight = Color(0xFFB86A87)
|
||||||
|
val SoftLavender = Color(0xFFC9B8D8)
|
||||||
|
val LavenderDeep = Color(0xFF6E5A82)
|
||||||
|
|
||||||
|
val MutedSage = Color(0xFF5F8A80)
|
||||||
|
val SageLight = Color(0xFF9BC0B6)
|
||||||
|
val SageDeep = Color(0xFF2F4A44)
|
||||||
|
|
||||||
|
val WarmCream = Color(0xFFFBF7F3)
|
||||||
|
val OffWhite = Color(0xFFFFFBFF)
|
||||||
|
val Charcoal = Color(0xFF1C1A1D)
|
||||||
|
val CharcoalRaised = Color(0xFF272429)
|
||||||
|
|
||||||
|
val OnDark = Color(0xFFF2EAF0)
|
||||||
|
val OnLight = Color(0xFF241F26)
|
||||||
|
val Error = Color(0xFF8F2B2B)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,115 @@
|
||||||
|
package dev.privacyllc.period.designsystem
|
||||||
|
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Typography
|
||||||
|
import androidx.compose.material3.darkColorScheme
|
||||||
|
import androidx.compose.material3.lightColorScheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.ReadOnlyComposable
|
||||||
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
|
||||||
|
private val LightColors = lightColorScheme(
|
||||||
|
primary = PeriodPalette.MutedBerry,
|
||||||
|
onPrimary = Color.White,
|
||||||
|
primaryContainer = PeriodPalette.SoftLavender,
|
||||||
|
onPrimaryContainer = PeriodPalette.DeepPlum,
|
||||||
|
secondary = PeriodPalette.LavenderDeep,
|
||||||
|
onSecondary = Color.White,
|
||||||
|
tertiary = PeriodPalette.MutedSage,
|
||||||
|
onTertiary = Color.White,
|
||||||
|
background = PeriodPalette.WarmCream,
|
||||||
|
onBackground = PeriodPalette.OnLight,
|
||||||
|
surface = PeriodPalette.OffWhite,
|
||||||
|
onSurface = PeriodPalette.OnLight,
|
||||||
|
error = PeriodPalette.Error,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val DarkColors = darkColorScheme(
|
||||||
|
primary = PeriodPalette.BerryLight,
|
||||||
|
onPrimary = PeriodPalette.DeepPlum,
|
||||||
|
primaryContainer = PeriodPalette.DeepPlum,
|
||||||
|
onPrimaryContainer = PeriodPalette.SoftLavender,
|
||||||
|
secondary = PeriodPalette.SoftLavender,
|
||||||
|
onSecondary = PeriodPalette.DeepPlum,
|
||||||
|
tertiary = PeriodPalette.SageLight,
|
||||||
|
onTertiary = PeriodPalette.SageDeep,
|
||||||
|
background = PeriodPalette.Charcoal,
|
||||||
|
onBackground = PeriodPalette.OnDark,
|
||||||
|
surface = PeriodPalette.CharcoalRaised,
|
||||||
|
onSurface = PeriodPalette.OnDark,
|
||||||
|
error = Color(0xFFEC9A9A),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Semantic cycle colours.
|
||||||
|
*
|
||||||
|
* Deliberately not squeezed into Material's roles: "the colour a predicted
|
||||||
|
* period is drawn in" is not `secondary`, and mapping it onto one means the next
|
||||||
|
* person to adjust `secondary` silently changes the calendar. They travel as
|
||||||
|
* their own token set instead.
|
||||||
|
*
|
||||||
|
* **Colour is never the only signal.** These pair with shape — solid fill,
|
||||||
|
* dotted outline, ring, marker — per docs/design/README.md rule 3.
|
||||||
|
*/
|
||||||
|
data class CycleColors(
|
||||||
|
val periodConfirmed: Color,
|
||||||
|
val periodPredicted: Color,
|
||||||
|
val fertileWindow: Color,
|
||||||
|
val ovulation: Color,
|
||||||
|
)
|
||||||
|
|
||||||
|
val LocalCycleColors = staticCompositionLocalOf {
|
||||||
|
CycleColors(
|
||||||
|
periodConfirmed = PeriodPalette.MutedBerry,
|
||||||
|
periodPredicted = PeriodPalette.BerryLight,
|
||||||
|
fertileWindow = PeriodPalette.MutedSage,
|
||||||
|
ovulation = PeriodPalette.LavenderDeep,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val PeriodTypography = Typography().let { base ->
|
||||||
|
base.copy(
|
||||||
|
// The forecast number is the visual hero — PRODUCT_PLAN.md §38.
|
||||||
|
displayLarge = base.displayLarge.copy(fontWeight = FontWeight.Bold, fontSize = 72.sp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PeriodTheme(
|
||||||
|
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||||
|
content: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
val cycleColors = if (darkTheme) {
|
||||||
|
CycleColors(
|
||||||
|
periodConfirmed = PeriodPalette.BerryLight,
|
||||||
|
periodPredicted = PeriodPalette.SoftLavender,
|
||||||
|
fertileWindow = PeriodPalette.SageLight,
|
||||||
|
ovulation = PeriodPalette.SoftLavender,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
CycleColors(
|
||||||
|
periodConfirmed = PeriodPalette.MutedBerry,
|
||||||
|
periodPredicted = PeriodPalette.BerryLight,
|
||||||
|
fertileWindow = PeriodPalette.MutedSage,
|
||||||
|
ovulation = PeriodPalette.LavenderDeep,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
CompositionLocalProvider(LocalCycleColors provides cycleColors) {
|
||||||
|
MaterialTheme(
|
||||||
|
colorScheme = if (darkTheme) DarkColors else LightColors,
|
||||||
|
typography = PeriodTypography,
|
||||||
|
content = content,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
object PeriodThemeDefaults {
|
||||||
|
val cycleColors: CycleColors
|
||||||
|
@Composable @ReadOnlyComposable get() = LocalCycleColors.current
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,192 @@
|
||||||
|
# Doc Trust Map — which document to believe
|
||||||
|
|
||||||
|
```
|
||||||
|
Status: Current
|
||||||
|
Owner: _null
|
||||||
|
Last reviewed: 2026-08-18
|
||||||
|
Governs: docs/**
|
||||||
|
Review trigger: Any doc added, deleted or moved; any change to which doc owns a subject
|
||||||
|
Fires on: added, deleted, moved
|
||||||
|
```
|
||||||
|
|
||||||
|
> Written last, and describing what is **actually here** rather than what the
|
||||||
|
> template said should be. Its whole value is being accurate about the others.
|
||||||
|
|
||||||
|
## Work items are not in this tree
|
||||||
|
|
||||||
|
The sequence of work, the open defects, and the things blocking a release all
|
||||||
|
live in this repository's **issue tracker** at
|
||||||
|
[dream.scheller.ltd/null/Period](https://dream.scheller.ltd/null/Period), not in
|
||||||
|
`docs/`. Milestones are batches — eight of them, `Batch 01 — Foundation` through
|
||||||
|
`Batch 08 — Polish` — issues are deliverables, and severity labels are `P0`,
|
||||||
|
`P1`, `P2` and `release-blocker`.
|
||||||
|
|
||||||
|
This section exists to stop the next contributor starting a fresh markdown
|
||||||
|
to-do list. A list of things to do in two places is two records that will
|
||||||
|
disagree, and nothing will say which one is right.
|
||||||
|
|
||||||
|
| Question | Answer lives in |
|
||||||
|
| --- | --- |
|
||||||
|
| What are we building, and for whom? | [`planning/PROJECT_PLAN.md`](planning/PROJECT_PLAN.md) |
|
||||||
|
| What exactly does V1 do — prediction rules, screens, copy, compliance? | [`planning/PRODUCT_PLAN.md`](planning/PRODUCT_PLAN.md) |
|
||||||
|
| What is the sequence of work? | milestones in the tracker |
|
||||||
|
| What is left in this batch? | open issues under that milestone |
|
||||||
|
| What is broken right now? | issues labelled `P0` / `P1` / `P2` |
|
||||||
|
| What makes a release wrong? | issues labelled `release-blocker` |
|
||||||
|
| What is the next action? | the `nextAction` field on the project at privacyllc.dev — the newest entry in [`history/DEVELOPMENT_LOG.md`](history/DEVELOPMENT_LOG.md) says what it was *then* |
|
||||||
|
| What is blocking us? | the tracker, for the work; the blockers table at privacyllc.dev, for the stakeholder-facing version |
|
||||||
|
| What do I do when a piece of work is finished? | [`WORK_CYCLE.md`](WORK_CYCLE.md) |
|
||||||
|
| What happened, and when? | [`history/DEVELOPMENT_LOG.md`](history/DEVELOPMENT_LOG.md) |
|
||||||
|
| Why is it built this way, and what was rejected? | [`history/HISTORY.md`](history/HISTORY.md) |
|
||||||
|
| Did QA pass, and what does the tester think? | [`qa/ClaudeReport.md`](qa/ClaudeReport.md) |
|
||||||
|
| What did QA actually reach? | [`qa/ClaudeQACoverage.md`](qa/ClaudeQACoverage.md) |
|
||||||
|
| What is a QA round? | [`qa/ClaudeQAPlan.md`](qa/ClaudeQAPlan.md) |
|
||||||
|
| How is it built — modules, boundaries, data shapes, migrations? | [`architecture/README.md`](architecture/README.md) |
|
||||||
|
| What should it feel like, and what words does it use? | [`design/README.md`](design/README.md) |
|
||||||
|
| What is protected, from whom, and what must never be logged? | [`security/SECURITY.md`](security/SECURITY.md) |
|
||||||
|
| What is checked before a Play release? | [`security/SECURITY_CHECKLIST.md`](security/SECURITY_CHECKLIST.md) |
|
||||||
|
| Which script do I run, and can it stop me? | [`TOOLS.md`](TOOLS.md) — the signpost; [`architecture/README.md`](architecture/README.md) has the table |
|
||||||
|
| What runs before a commit? | [`architecture/githooks/README.md`](architecture/githooks/README.md), and the hooks themselves in `.githooks/` |
|
||||||
|
| How do I write a check that will actually catch something? | [`architecture/GUARDS.md`](architecture/GUARDS.md) |
|
||||||
|
|
||||||
|
**Next action and blockers are recorded at the end of every piece of work, not
|
||||||
|
when somebody asks.** [`WORK_CYCLE.md`](WORK_CYCLE.md) holds that procedure and
|
||||||
|
the reason each step is in it.
|
||||||
|
|
||||||
|
## The two planning documents, and which owns what
|
||||||
|
|
||||||
|
Unusually, this project has two — worth stating plainly, because a reader who
|
||||||
|
does not know which is which will pick the wrong one.
|
||||||
|
|
||||||
|
| | Owns | Length |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| [`planning/PRODUCT_PLAN.md`](planning/PRODUCT_PLAN.md) | the **V1 specification**: prediction requirements and their acceptance cases, the data model, every screen and its copy, notification modes, monetization, artwork, Play compliance | ~2,500 lines |
|
||||||
|
| [`planning/PROJECT_PLAN.md`](planning/PROJECT_PLAN.md) | the **argument**: what this is, who for, what it deliberately is not, the stack and the reason for each choice, what success looks like, known risks | ~110 lines |
|
||||||
|
|
||||||
|
`PRODUCT_PLAN.md` is the document this project started as — it existed before
|
||||||
|
the repository did. Everything else in `docs/` points **into** it rather than
|
||||||
|
copying out of it, and that is deliberate: a second copy of a fertility
|
||||||
|
disclaimer or a notification string is how two versions of a promise come to
|
||||||
|
exist. `architecture/`, `design/` and `qa/` each name the sections of it they
|
||||||
|
own.
|
||||||
|
|
||||||
|
Where the two disagree, `PRODUCT_PLAN.md` is the specification and
|
||||||
|
`PROJECT_PLAN.md` is the reasoning — resolve it rather than letting both stand.
|
||||||
|
|
||||||
|
## Folder layout
|
||||||
|
|
||||||
|
| Folder | Contents |
|
||||||
|
| --- | --- |
|
||||||
|
| `docs/planning/` | `PRODUCT_PLAN` — the V1 specification. `PROJECT_PLAN` — the vision. Neither is the schedule; that is the tracker. |
|
||||||
|
| `docs/qa/` | `ClaudeQAPlan` (playbook, passes A–H), `ClaudeQACoverage` (what each pass reached), `ClaudeReport` (the verdict) |
|
||||||
|
| `docs/architecture/` | modules, boundaries, data shapes, the migration table; `GUARDS.md`; `githooks/README.md` |
|
||||||
|
| `docs/design/` | tone, the four rules that settle arguments, and which specification sections own each surface |
|
||||||
|
| `docs/security/` | `SECURITY` — threat model, the advertising boundary, logging rules. `SECURITY_CHECKLIST` — the pre-release list |
|
||||||
|
| `docs/history/` | `DEVELOPMENT_LOG` (dated, append-only), `HISTORY` (decisions and their reasons), `BATCH_LEDGER` (archived) |
|
||||||
|
| `docs/data/` | the three branding marks privacyllc.dev renders — **currently absent, tracked as issue #8** |
|
||||||
|
| `docs/` root | this map; `WORK_CYCLE`; `TOOLS` |
|
||||||
|
|
||||||
|
`README.md` stays at the repository root; it is the landing page and moving it
|
||||||
|
breaks that. Everything else lives under `docs/`.
|
||||||
|
|
||||||
|
## What this project deliberately does not have
|
||||||
|
|
||||||
|
Recorded here because an absence somebody chose and an absence nobody noticed
|
||||||
|
look identical from outside, and only one of them is fine.
|
||||||
|
|
||||||
|
- **`docs/OPERATIONS.md` — deleted.** Period is an offline-first Android app
|
||||||
|
distributed through Google Play. There is no host, no container, no uptime and
|
||||||
|
no restore path of ours. An empty runbook reads as one nobody wrote rather
|
||||||
|
than one that never applied.
|
||||||
|
- **`docs/planning/FUTURE.md` — never created.** The Command Center's docs
|
||||||
|
report looks for a batch ledger there and reports it **missing** for every
|
||||||
|
tracker-first project. That is the expected state; creating the file to turn
|
||||||
|
the line green would rebuild the second record
|
||||||
|
[`history/BATCH_LEDGER.md`](history/BATCH_LEDGER.md) was archived for.
|
||||||
|
- **Most of the template's scripts.** Six were taken; the rest assume a deployed
|
||||||
|
Node or Postgres service. [`TOOLS.md`](TOOLS.md) names each one and why.
|
||||||
|
`doc-claims.sh` noting a document that mentions a script this project does not
|
||||||
|
have is expected, not a failure.
|
||||||
|
- **A master copy of the hooks under `docs/architecture/githooks/`.** The
|
||||||
|
template keeps one and installs copies; Period keeps only `.githooks/`,
|
||||||
|
because `pre-commit` here is adapted for Gradle and a second copy would drift.
|
||||||
|
- **No `Exempt:` declarations.** Every document this convention asks for is
|
||||||
|
either present in git or listed above as deliberately deleted.
|
||||||
|
|
||||||
|
## Source-of-truth ladder
|
||||||
|
|
||||||
|
When two sources disagree, believe them in this order:
|
||||||
|
|
||||||
|
1. **Verified code behaviour** — read the source, run `./gradlew test`
|
||||||
|
2. **The issue tracker** — for anything about state of work: what is open, what
|
||||||
|
closed, when, and by which commit
|
||||||
|
3. **Specialist docs** — authoritative for their own subject only
|
||||||
|
4. **History and logs** — a record of *then*, not a description of *now*
|
||||||
|
|
||||||
|
A document that describes work as done is describing what was true when it was
|
||||||
|
written. The tracker is describing now. The `Status:` table in
|
||||||
|
[`../README.md`](../README.md) is the one place a claim about what is built is
|
||||||
|
allowed to live, and every row of it cites its evidence.
|
||||||
|
|
||||||
|
## The status header
|
||||||
|
|
||||||
|
Every document in this tree opens with one, immediately after its H1 — and so
|
||||||
|
do the documents at the repository root, which `scripts/doc-triggers.py` reads
|
||||||
|
alongside this tree.
|
||||||
|
|
||||||
|
```
|
||||||
|
Status: Current | Draft | Superseded | Archived
|
||||||
|
Owner: <who maintains this>
|
||||||
|
Last reviewed: <YYYY-MM-DD>
|
||||||
|
Governs: <paths or subject this document is authoritative for>
|
||||||
|
Review trigger: <the change that should send someone back to this file>
|
||||||
|
Fires on: <optional — added, deleted, moved, changed>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Exactly those four status words.** A checker reads them, and a document whose
|
||||||
|
status falls outside the list is reported as having an unknown one rather than
|
||||||
|
being quietly accepted.
|
||||||
|
|
||||||
|
**Review trigger** is the line that matters. "Last reviewed" ages on its own and
|
||||||
|
a reader cannot tell a current document from an abandoned one by looking at it;
|
||||||
|
a trigger names the event that should bring somebody back. It is also checked: a
|
||||||
|
header carrying `Status` without `Review trigger` is reported as incomplete —
|
||||||
|
that combination looks finished and is not.
|
||||||
|
|
||||||
|
**Governs** is a comma-separated list, and an entry may explain itself after the
|
||||||
|
glob with a **spaced dash** — `docs/data/** — the assets privacyllc.dev renders`.
|
||||||
|
`scripts/doc-triggers.py` cuts the entry there and reads the globs from the left.
|
||||||
|
**Use that form and no other.** A gloss in parentheses, or after a colon, is not
|
||||||
|
recognised: the whole entry becomes the glob, matches nothing, and the document
|
||||||
|
is silently never fired — not reported as skipped either, because it still looks
|
||||||
|
like a path.
|
||||||
|
|
||||||
|
**Fires on** is optional and only for the case where `Governs:` is much broader
|
||||||
|
than the trigger. This file is the extreme of that gap: it governs `docs/**`,
|
||||||
|
the broadest glob here, while its trigger is one of the narrowest. Omit the line
|
||||||
|
unless it is genuinely needed — absent means fire on every kind, which is what
|
||||||
|
almost every document wants.
|
||||||
|
|
||||||
|
## Declaring a document deliberately absent
|
||||||
|
|
||||||
|
A repository may decide it will not keep one of these documents in git. Say so
|
||||||
|
**here**, one line per path, anywhere in this file:
|
||||||
|
|
||||||
|
```
|
||||||
|
Exempt: <the path> — <why, in a few words>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Write the real path only when you mean it.** Outside a fenced block, an
|
||||||
|
`Exempt:` line naming a real document is not an example — it is a declaration,
|
||||||
|
and the checker will report that document as deliberately absent. Keep
|
||||||
|
illustrations fenced, and use placeholders anyway, as the form above does.
|
||||||
|
|
||||||
|
Period currently declares none. Where a document is genuinely not applicable it
|
||||||
|
has been **deleted** and recorded above instead, which is the honest form: an
|
||||||
|
exemption says "kept elsewhere", and `OPERATIONS.md` is not kept anywhere.
|
||||||
|
|
||||||
|
**`docs/data/` and `docs/data/img/` cannot be exempted.** A declaration naming
|
||||||
|
either is refused rather than honoured, because the Command Center renders what
|
||||||
|
is in them — an exemption would produce a project card with no icon and nothing
|
||||||
|
explaining why. Their absence here is a filed issue, which is the mechanism that
|
||||||
|
gets them drawn.
|
||||||
|
|
@ -0,0 +1,96 @@
|
||||||
|
# Tools — where the scripts are, and which ones can stop you
|
||||||
|
|
||||||
|
```
|
||||||
|
Status: Current
|
||||||
|
Owner: _null
|
||||||
|
Last reviewed: 2026-08-18
|
||||||
|
Governs: scripts/**, .githooks/**
|
||||||
|
Review trigger: Any script added to, removed from or repurposed in scripts/; any
|
||||||
|
change to which of them gates a commit or a release
|
||||||
|
```
|
||||||
|
|
||||||
|
> A signpost, deliberately. Every project that adopts this template has a
|
||||||
|
> `docs/TOOLS.md`, so "read `docs/TOOLS.md` first" is an instruction that works
|
||||||
|
> without knowing anything about the project — which is the whole reason this
|
||||||
|
> file exists at a fixed path.
|
||||||
|
|
||||||
|
## The list is not here
|
||||||
|
|
||||||
|
**[`architecture/README.md`](architecture/README.md)** holds the table of what
|
||||||
|
this project has and what each script is. That is the one copy.
|
||||||
|
|
||||||
|
A second table here would be two records of one fact, and the other one would
|
||||||
|
never hear that a script was renamed. So this file answers the questions that
|
||||||
|
table does not, and points at it for everything else.
|
||||||
|
|
||||||
|
## Period has six of them, and that is deliberate
|
||||||
|
|
||||||
|
The template this repository adopted ships around twenty scripts. Period took
|
||||||
|
six. `scaffold.sh` copies none of them on purpose — *"an unconfigured
|
||||||
|
`release.sh` landing in every new repository is a loaded gun, not a head
|
||||||
|
start"* — so each one is taken having been read and configured.
|
||||||
|
|
||||||
|
**The ones not taken were not forgotten.** `release.sh`, `deploy.py`,
|
||||||
|
`backup.sh`, `restore-check.sh`, `preflight.sh`, `healthcheck.sh`, `migrate.sh`,
|
||||||
|
`status.sh`, `dev.sh`, `audit-gate.mjs`, `release-notes.mjs`, `duplication.py`
|
||||||
|
and `dead-code.py` all assume a deployed Node or Postgres service with an npm
|
||||||
|
dependency tree and a URL. Period is an Android app that ships through Google
|
||||||
|
Play and has no server at all. A release here is an AAB and a Play Console
|
||||||
|
submission, so [`security/SECURITY_CHECKLIST.md`](security/SECURITY_CHECKLIST.md)
|
||||||
|
carries what `release.sh` would have gated.
|
||||||
|
|
||||||
|
Two were deferred rather than declined:
|
||||||
|
|
||||||
|
- **`verify.sh`** — worth having once there is a real check suite to aggregate.
|
||||||
|
Today `./gradlew check` is the whole answer and wrapping it would add a layer
|
||||||
|
that could only be wrong.
|
||||||
|
- **`check-env.sh`** — its SPEC ships empty and exits `2` until it has entries,
|
||||||
|
and Period has no environment variables yet. It gets taken with the first
|
||||||
|
signing or Play credential, which is exactly the moment it becomes worth
|
||||||
|
running.
|
||||||
|
|
||||||
|
If this project later grows a script the template already has, take that one
|
||||||
|
rather than writing a new one — the arguments in its header are the part that
|
||||||
|
took the longest.
|
||||||
|
|
||||||
|
## Which ones can stop you
|
||||||
|
|
||||||
|
Not in a table, because the honest answer lives in each script's own header and
|
||||||
|
would go stale here. The rule that matters:
|
||||||
|
|
||||||
|
**Exit code `2` is never a pass.** These scripts distinguish "the check ran and
|
||||||
|
found nothing" from "the check did not run", because those look identical from
|
||||||
|
the outside and only one of them is evidence. A hook or a CI step that treats a
|
||||||
|
`2` as success has quietly turned the check off. Each script states its codes at
|
||||||
|
the top; read them there.
|
||||||
|
|
||||||
|
The hooks are the other place work gets stopped:
|
||||||
|
[`architecture/githooks/README.md`](architecture/githooks/README.md) has the one
|
||||||
|
install command and the table of what each hook runs. Note that **`post-commit`
|
||||||
|
pushes**, and that `pre-commit` here runs Gradle rather than the template's
|
||||||
|
TypeScript typecheck.
|
||||||
|
|
||||||
|
## Where to start in a fresh clone
|
||||||
|
|
||||||
|
1. `git config core.hooksPath .githooks` — per clone, every time, and an
|
||||||
|
uninstalled hook fails silently.
|
||||||
|
2. `bash scripts/secrets.sh --tracked` — the one-time audit of what is already
|
||||||
|
committed. The staged-diff mode is for the hook; this mode is for the day you
|
||||||
|
clone.
|
||||||
|
3. `./gradlew test` — the prediction acceptance tests run on the JVM, so this
|
||||||
|
needs no emulator and should be fast.
|
||||||
|
4. [`architecture/GUARDS.md`](architecture/GUARDS.md) — how to write a check that
|
||||||
|
can actually fail, before you write one. `scripts/prove-guard.sh` performs its
|
||||||
|
first rule.
|
||||||
|
|
||||||
|
## Adding one
|
||||||
|
|
||||||
|
Put it in `scripts/`, give it a header saying what it does and **which incident
|
||||||
|
motivated it**, state its exit codes, and add a row to
|
||||||
|
[`architecture/README.md`](architecture/README.md)'s table — this file's
|
||||||
|
`Review trigger` fires on exactly that.
|
||||||
|
|
||||||
|
The bar, from the scripts that are already here: **done by hand three times, or
|
||||||
|
once with a consequence.** A script written before either of those has no
|
||||||
|
failure to describe in its header, which is the part that stops the next person
|
||||||
|
deleting it.
|
||||||
|
|
@ -0,0 +1,270 @@
|
||||||
|
# The work cycle — what happens at the end of a piece of work
|
||||||
|
|
||||||
|
```
|
||||||
|
Status: Current
|
||||||
|
Owner: _null
|
||||||
|
Last reviewed: 2026-08-18
|
||||||
|
Governs: what must be true before a piece of work counts as finished
|
||||||
|
Review trigger: Any change to what the Command Center reads, or to which of
|
||||||
|
those channels a person rather than an agent has to write
|
||||||
|
```
|
||||||
|
|
||||||
|
> **This is a procedure, not a status board.** Nothing here records what is open,
|
||||||
|
> what is next, or what is blocked — those live in the tracker and on
|
||||||
|
> privacyllc.dev, and a copy of them in this file would be the second disagreeing
|
||||||
|
> record that `DOC_TRUST_MAP.md` exists to prevent.
|
||||||
|
|
||||||
|
## Why this file exists
|
||||||
|
|
||||||
|
A piece of work ends in more than one place. The code is committed; the issue
|
||||||
|
that asked for it is still open; the document the change contradicts still says
|
||||||
|
the old thing; and the project screen at
|
||||||
|
[privacyllc.dev](https://privacyllc.dev) still shows last week's next action to
|
||||||
|
whoever opens it.
|
||||||
|
|
||||||
|
None of those catch up on their own. Two of them cannot be caught up later by
|
||||||
|
anybody but the person who did the work, because by then nobody knows what the
|
||||||
|
next action was meant to be.
|
||||||
|
|
||||||
|
## The cycle
|
||||||
|
|
||||||
|
Run all of it, in this order, every time. It is short on purpose.
|
||||||
|
|
||||||
|
1. **Close what you finished.** `closes #N` in the commit that does the work, so
|
||||||
|
the record comes from the thing that happened rather than a date typed
|
||||||
|
afterwards. If no single commit finished it, close it by hand with the
|
||||||
|
evidence — a path, a symbol, a test name, or the command that proves it.
|
||||||
|
*"Done" is not a close.*
|
||||||
|
2. **File what you found.** A defect noticed on the way past is an issue with a
|
||||||
|
severity label and the build SHA it was seen at, not a memory. Filing it costs
|
||||||
|
a minute; the alternative is finding it again from scratch, or shipping it.
|
||||||
|
3. **Close the milestone if the batch landed.** A milestone with every issue
|
||||||
|
closed and itself still open reads as a batch still in progress — see
|
||||||
|
[Open and closed are not bookkeeping](#open-and-closed-are-not-bookkeeping).
|
||||||
|
4. **Update the documents this change triggered.** Read the `Review trigger`
|
||||||
|
lines: a new module, a changed data shape, a new migration, a new boundary
|
||||||
|
something crosses. Those edits go in **the same commit as the code**, for the
|
||||||
|
reason in [Docs travel with the push](#docs-travel-with-the-push).
|
||||||
|
5. **Push.** Nothing above is visible off this machine until you do, and step 7
|
||||||
|
reports on what was pushed.
|
||||||
|
6. **Write the log entry** in `docs/history/DEVELOPMENT_LOG.md`: what changed,
|
||||||
|
what it proved, **Next action**, and **Blockers**. Dated, append-only, newest
|
||||||
|
first.
|
||||||
|
7. **Tell the Command Center**, which is three calls: reconcile, so it re-reads
|
||||||
|
the tracker and the pushed docs; `PATCH` the project's summary and next
|
||||||
|
action, which nothing else writes; and a check-in if what changed is worth a
|
||||||
|
timestamped note. Commands are [at the bottom](#the-commands).
|
||||||
|
|
||||||
|
Steps 6 and 7 are the two that get skipped, and they are the two nobody else can
|
||||||
|
do afterwards.
|
||||||
|
|
||||||
|
## Next action — where it lives
|
||||||
|
|
||||||
|
| Copy | Where | What it is |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| The current one | `nextAction` on the project, privacyllc.dev | the live answer to "what happens next", shown to whoever opens the project screen |
|
||||||
|
| The dated one | the newest entry in `DEVELOPMENT_LOG.md` | what the next action was **at that point** — history, not status |
|
||||||
|
|
||||||
|
These are not two records of the same thing, and the distinction is worth
|
||||||
|
holding on to. The field is overwritten every time and always describes now. The
|
||||||
|
log entry is never edited and describes a moment — which is what makes it safe
|
||||||
|
to keep, and why an old entry naming a next action that has since been done is
|
||||||
|
not stale, it is a receipt.
|
||||||
|
|
||||||
|
Write the next action as an **action**: the thing a person would start on
|
||||||
|
Monday, specific enough to begin without asking a question. "Continue the work"
|
||||||
|
is not one. If the honest answer is that you do not know, that is a real answer —
|
||||||
|
say what has to be decided and by whom.
|
||||||
|
|
||||||
|
**Filing an issue can change what the project card says next, without anyone
|
||||||
|
choosing that.** The dashboard's next action is the *newest open issue in the
|
||||||
|
current milestone* — not the most severe one; severity labels have no influence
|
||||||
|
on it at all. So a routine `P2` filed into the batch you are working in replaces
|
||||||
|
whatever the card was showing, and it will keep showing that until something
|
||||||
|
newer arrives. The `nextAction` field is the only way to say something different
|
||||||
|
on purpose, which is most of why step 7 exists.
|
||||||
|
|
||||||
|
`currentSummary`, `nextAction` and `description` are **write-only**: they are
|
||||||
|
deliberately absent from every API response, because free text can name a
|
||||||
|
customer or an unannounced product. Only the admin screen shows them back. Never
|
||||||
|
report them as empty because a `GET` did not return them.
|
||||||
|
|
||||||
|
## Blockers — where they live
|
||||||
|
|
||||||
|
A blocker is recorded in up to three places, and **an agent can write only the
|
||||||
|
first and the third**:
|
||||||
|
|
||||||
|
| Where | What it holds | Who writes it |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| The tracker | the work itself — an issue labelled `release-blocker`, or `P0` when it ships broken | anyone, including an agent |
|
||||||
|
| The blockers table on privacyllc.dev | the business-facing blocker, with a severity, an owner and a resolution note | **a human, in the admin UI** |
|
||||||
|
| A check-in's `blockers` field | narrative: what is stuck, said in a timestamped note | anyone, including an agent |
|
||||||
|
|
||||||
|
The agent API has no route that creates, edits or resolves a blocker on the
|
||||||
|
site. Posting a check-in whose `blockers` field says something is stuck records
|
||||||
|
*narrative* — it does not touch the blockers table, and it does not clear
|
||||||
|
anything. An agent that reports "blocker filed" after a check-in has told the
|
||||||
|
truth about the note and a falsehood about the table.
|
||||||
|
|
||||||
|
So: **file the issue** — that is the copy the work is actually tracked in — and
|
||||||
|
when it belongs in front of a stakeholder, say so plainly and let a human enter
|
||||||
|
it. Before treating a site blocker as resolved, restate its title and status and
|
||||||
|
get explicit confirmation; the admin UI requires a resolution note that the API
|
||||||
|
cannot supply.
|
||||||
|
|
||||||
|
If the work is blocked and nothing is filed anywhere, the project simply looks
|
||||||
|
slow.
|
||||||
|
|
||||||
|
## Open and closed are not bookkeeping
|
||||||
|
|
||||||
|
Every open issue is a denominator, and not in the abstract: the percentage on
|
||||||
|
the project screen **is** closed issues over all issues in this tracker. Nothing
|
||||||
|
else produces it. That has three consequences worth stating in full:
|
||||||
|
|
||||||
|
- **An issue left open after the work is done** understates the project
|
||||||
|
permanently, and the understatement compounds — a fortnight of finished work
|
||||||
|
with unclosed issues reads as a fortnight of no progress.
|
||||||
|
- **An issue closed without evidence** cannot be reopened with confidence,
|
||||||
|
because nothing in it says what "fixed" meant. That is why the close comment
|
||||||
|
carries the path, symbol, test or command.
|
||||||
|
- **Invented future work** makes every percentage wrong, permanently and in one
|
||||||
|
direction. Do not pad the tracker. If the real answer is one milestone and
|
||||||
|
three issues, file exactly that.
|
||||||
|
|
||||||
|
Milestones are the same argument at batch scale, and they are counted the same
|
||||||
|
way: milestones closed over milestones total is the second figure on the project
|
||||||
|
screen. Closing the last issue under a milestone does not close the milestone,
|
||||||
|
so a tracker full of complete-but-open batches reports a project as less
|
||||||
|
finished than it is — and cannot answer "what shipped".
|
||||||
|
|
||||||
|
**File every issue into a milestone.** One filed outside still counts against
|
||||||
|
the headline percentage while being invisible to the milestone figure, which is
|
||||||
|
how two readings of the same project come to describe different amounts of work.
|
||||||
|
The site measures the gap rather than ignoring it.
|
||||||
|
|
||||||
|
One trap that costs an afternoon, and it is about a *card* rather than a figure:
|
||||||
|
the Milestones list on privacyllc.dev reads the Command Center's own table,
|
||||||
|
which only an admin can write. A repository whose milestones are being counted
|
||||||
|
in the figure above can still show *"No milestones have been added yet"* in that
|
||||||
|
list. It is not a sync failure and no amount of reconciling changes it.
|
||||||
|
|
||||||
|
## Docs travel with the push
|
||||||
|
|
||||||
|
The Command Center reads this repository's documents at a commit, and stores the
|
||||||
|
SHA it read them from. When that SHA falls behind the repository's newest
|
||||||
|
commit, the docs report is marked **stale** — not wrong, not missing, *stale*,
|
||||||
|
which is the honest description of a document that was accurate at a commit
|
||||||
|
nobody is running any more.
|
||||||
|
|
||||||
|
Two ways to produce it, and both are ordinary carelessness rather than bad luck:
|
||||||
|
|
||||||
|
- **Code pushed, documents not updated.** The report is recomputed at the new
|
||||||
|
SHA against prose describing the old behaviour. Nothing flags this; the
|
||||||
|
document is simply confidently wrong now, and its `Last reviewed` line still
|
||||||
|
looks recent.
|
||||||
|
- **Documents updated, not pushed.** The site keeps reporting the old ones. A
|
||||||
|
correction that exists only on your machine has not been made.
|
||||||
|
|
||||||
|
Hence step 4's insistence that documentation edits ride in the same commit as
|
||||||
|
the change that caused them. It is not tidiness — a doc commit that comes "after
|
||||||
|
this next thing" is the commit that does not get written, and the manual's
|
||||||
|
migration table in the project this template came from sat six migrations behind
|
||||||
|
before anybody noticed. Every reader in between trusted it.
|
||||||
|
|
||||||
|
## What the Command Center reads, and who writes it
|
||||||
|
|
||||||
|
Four independent channels, and **only two of them are automatic**:
|
||||||
|
|
||||||
|
| On the project screen | Source | How it gets there |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Headline % — issues closed / all issues | the repository's tracker | automatic — on reconcile, and immediately on a webhook delivery |
|
||||||
|
| Second figure — milestones closed / all milestones | the repository's **milestones** | same read, same moment |
|
||||||
|
| Milestone coverage — issues that sit in no milestone | the repository's tracker | same read |
|
||||||
|
| QA verdict — round, build SHA, overall sentence | `docs/qa/ClaudeReport.md` | push the repository, then reconcile |
|
||||||
|
| Current summary / Next action | manual fields on the project | `PATCH /agent/projects/<slug>` — nothing else writes them |
|
||||||
|
| The Milestones card, and a typed weighted plan | the Command Center's **own** milestone table | admin UI only |
|
||||||
|
| Blockers | the Command Center's **own** blockers table | admin UI only |
|
||||||
|
|
||||||
|
The first three are why steps 1 and 3 are steps: **closing an issue and closing
|
||||||
|
a milestone each move a figure a stakeholder can see, that day.** The last two
|
||||||
|
are lists rather than figures, and they are the ones an agent cannot write — a
|
||||||
|
repository whose milestones are counted in the second figure can still show
|
||||||
|
*"No milestones have been added yet"* on the card, because that card reads a
|
||||||
|
table only an admin fills in.
|
||||||
|
|
||||||
|
A repository that has not adopted the four label names is reported as *not
|
||||||
|
adopted* rather than as zero defects, and one with its tracker switched off is
|
||||||
|
reported as switched off rather than as an empty backlog. Absence is never
|
||||||
|
rendered as a measurement — which is exactly why a real backlog nobody filed
|
||||||
|
looks like nothing at all.
|
||||||
|
|
||||||
|
## The commands
|
||||||
|
|
||||||
|
The tracker is the Forgejo instance at
|
||||||
|
**[dream.scheller.ltd](https://dream.scheller.ltd)**, and steps 1 to 3 happen
|
||||||
|
there. Its credentials, the Cloudflare trap and the reason it has to be that
|
||||||
|
instance rather than any tracker are in the project README's *Where the tracker
|
||||||
|
is* — one copy, named once:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
set -a; . ~/.openclaw/docker-registry.env; set +a
|
||||||
|
python3 docs/architecture/scripts/forgejo-issue.py list
|
||||||
|
python3 docs/architecture/scripts/forgejo-issue.py close 42 "Fixed in a1b2c3d; tests/foo.test.js covers it."
|
||||||
|
```
|
||||||
|
|
||||||
|
Step 7 talks to the Command Center instead:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# The token lives in this machine's credential store, never in the repository.
|
||||||
|
TOKEN="$PRIVACY_LLC_TOKEN"
|
||||||
|
BASE="https://privacyllc.dev/api/internal/v1"
|
||||||
|
AUTH="Authorization: Bearer $TOKEN"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Reconcile** — re-read the tracker and the pushed documents. Do this after the
|
||||||
|
push, not before:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sk -X POST -H "$AUTH" -H "Content-Type: application/json" \
|
||||||
|
-d '{"kind":"reconcile_all"}' "$BASE/agent/jobs"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Summary and next action** — read the project first for its `version`, then
|
||||||
|
send a flat body carrying that version. A stale version is rejected rather than
|
||||||
|
silently overwriting somebody else's edit:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sk -H "$AUTH" "$BASE/agent/projects/<slug>" # for version + state
|
||||||
|
|
||||||
|
curl -sk -X PATCH -H "$AUTH" -H "Content-Type: application/json" \
|
||||||
|
-d '{"version":<n>,"health":"on_track",
|
||||||
|
"currentSummary":"<where the project stands, in a sentence or two>",
|
||||||
|
"nextAction":"<the next concrete thing, specific enough to start>"}' \
|
||||||
|
"$BASE/agent/projects/<slug>"
|
||||||
|
```
|
||||||
|
|
||||||
|
**A check-in** — a timestamped "what changed", when there is something real to
|
||||||
|
report. `summary` is required and must say something; the API refuses an empty
|
||||||
|
"no change" update, deliberately:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sk -X POST -H "$AUTH" -H "Content-Type: application/json" \
|
||||||
|
-d '{"projectId":"<slug>","health":"on_track",
|
||||||
|
"summary":"<what changed>","accomplishments":"<what landed>",
|
||||||
|
"blockers":"<what is stuck, or omit>","nextActions":"<what is next>"}' \
|
||||||
|
"$BASE/agent/updates"
|
||||||
|
```
|
||||||
|
|
||||||
|
Health is one of `on_track`, `caution`, `off_track`, `unknown`. Never invent
|
||||||
|
one, and never report progress the API did not return.
|
||||||
|
|
||||||
|
## What this file is not
|
||||||
|
|
||||||
|
- **Not the work list.** That is the tracker: milestones are batches, issues are
|
||||||
|
deliverables.
|
||||||
|
- **Not the release procedure.** That is `scripts/release.sh`
|
||||||
|
and `docs/security/SECURITY_CHECKLIST.md`.
|
||||||
|
- **Not the QA procedure.** That is `docs/qa/ClaudeQAPlan.md`, which ends in its
|
||||||
|
own version of step 7.
|
||||||
|
- **Not a place to record status.** If you are about to add "current state" or a
|
||||||
|
list of outstanding items below this line, the tracker is where it goes.
|
||||||
|
|
@ -0,0 +1,142 @@
|
||||||
|
# Guards — how to write a check that actually checks
|
||||||
|
|
||||||
|
```
|
||||||
|
Status: Current
|
||||||
|
Owner: _null
|
||||||
|
Last reviewed: 2026-08-18
|
||||||
|
Governs: structural tests, source-grep assertions, probes, and any check whose
|
||||||
|
passing is taken as evidence
|
||||||
|
Review trigger: A guard is found to have been passing while the thing it guards
|
||||||
|
was broken; a new class of check is added to the suite.
|
||||||
|
```
|
||||||
|
|
||||||
|
A guard that cannot fail is worse than no guard, because it is trusted. Every
|
||||||
|
rule here was learned by finding one that had been green for months over
|
||||||
|
something broken.
|
||||||
|
|
||||||
|
## 1. Prove the guard fails before you believe it passes
|
||||||
|
|
||||||
|
The one discipline that matters most, and it takes thirty seconds:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp src/lib/thing.ts /tmp/thing.bak
|
||||||
|
# break exactly the thing the test protects
|
||||||
|
sed -i 's/if (body.error)/if (false)/' src/lib/thing.ts
|
||||||
|
npx vitest run tests/thing.test.ts # expect: exactly one failure
|
||||||
|
cp /tmp/thing.bak src/lib/thing.ts
|
||||||
|
npx vitest run tests/thing.test.ts # expect: green again
|
||||||
|
```
|
||||||
|
|
||||||
|
**Exactly one** is the part people skip. If breaking the guard's target fails
|
||||||
|
three tests, two of them are coincidental and will mask a real regression later.
|
||||||
|
If it fails none, the guard is decoration — and you have just learned that for
|
||||||
|
the price of one `sed`.
|
||||||
|
|
||||||
|
`scripts/prove-guard.sh` performs exactly this, which removes the two ways it
|
||||||
|
gets skipped: the restore is a `trap`, so an interrupted run cannot leave the
|
||||||
|
code broken, and the failure count comes from the runner's own summary rather
|
||||||
|
than from eyeballing red — one failing test is routinely reported on half a
|
||||||
|
dozen lines, and counting those calls a clean result six coincidental
|
||||||
|
failures.
|
||||||
|
|
||||||
|
Do this when you write a guard, and again when you change what it guards. A
|
||||||
|
test written alongside the code it tests has never been observed failing.
|
||||||
|
|
||||||
|
## 2. A source-grep guard must tell code from the comment about code
|
||||||
|
|
||||||
|
Structural tests that assert a file does *not* contain some pattern will match
|
||||||
|
the docblock explaining why that pattern is forbidden. So the clearest possible
|
||||||
|
comment breaks the test, and the obvious fix is to delete the explanation.
|
||||||
|
|
||||||
|
Strip comments first:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const codeOf = (path: string) =>
|
||||||
|
readFileSync(path, "utf8")
|
||||||
|
.split("\n")
|
||||||
|
.filter((line) => !/^\s*(\*|\/\/|\{\/\*)/.test(line))
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
expect(codeOf("src/lib/thing.ts")).not.toContain("dangerouslySetInnerHTML");
|
||||||
|
```
|
||||||
|
|
||||||
|
Otherwise the guard quietly punishes documenting the rule it exists to enforce —
|
||||||
|
which is exactly backwards, because the comment is how the next person learns
|
||||||
|
the rule at all.
|
||||||
|
|
||||||
|
## 3. Pin the behaviour, not the spelling
|
||||||
|
|
||||||
|
A guard should fail when the protected behaviour breaks and stay quiet
|
||||||
|
otherwise. One that asserts on a variable name fails on a rename that changed
|
||||||
|
nothing.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Brittle: breaks when the variable is renamed, while the fallback it protects
|
||||||
|
// is untouched.
|
||||||
|
expect(route).toContain("readAsset(project.forgejoRepo");
|
||||||
|
|
||||||
|
// Pins the behaviour: the route fetches through the wrapper that tries both
|
||||||
|
// spellings, and never through the raw reader.
|
||||||
|
expect(route).toMatch(/readAsset\(\s*\w+,\s*ASSETS\[which\]\s*\)/);
|
||||||
|
expect(body).not.toContain("readFileBytes(");
|
||||||
|
```
|
||||||
|
|
||||||
|
A guard that fails on changes it does not care about is one people learn to edit
|
||||||
|
rather than heed, and the edit is usually deletion.
|
||||||
|
|
||||||
|
## 4. A negative result is only as good as the probe that produced it
|
||||||
|
|
||||||
|
"The check found nothing" and "the check did not run" are different facts, and
|
||||||
|
they look identical from the outside. Before reporting an absence, prove the
|
||||||
|
instrument worked:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Not this alone — an unreadable file produces the same silence as an unset key
|
||||||
|
grep -c '^WANTED=' /proc/$PID/environ
|
||||||
|
|
||||||
|
# Establish the read succeeded first
|
||||||
|
tr '\0' '\n' < /proc/$PID/environ | grep -c . # 0 here means "could not read"
|
||||||
|
```
|
||||||
|
|
||||||
|
This is the confident-absence failure one level up: the same trap as a screen
|
||||||
|
rendering a failed query as a count of zero, applied to your own diagnosis.
|
||||||
|
|
||||||
|
## 5. A guard that is often wrong is worse than none
|
||||||
|
|
||||||
|
A check with a high false-positive rate trains everybody to skip its output,
|
||||||
|
including on the day it is right.
|
||||||
|
|
||||||
|
One written for this template flagged **684 of 1142** candidates on its first
|
||||||
|
run. That was not 684 findings, it was a broken heuristic — and shipping it
|
||||||
|
would have taught its readers that the check is noise. Two rounds of narrowing
|
||||||
|
brought it to 17 of 363, all of them real.
|
||||||
|
|
||||||
|
If a new guard's first run is loud, tune it until it is quiet before anybody
|
||||||
|
relies on it. Report the false-positive rate you settled at, so the next person
|
||||||
|
knows what silence is worth.
|
||||||
|
|
||||||
|
## 6. Guards belong before the artifact exists
|
||||||
|
|
||||||
|
A check that runs after publication catches the problem once it is somewhere it
|
||||||
|
cannot be taken back from: the tag is in the registry, and refusing the commit
|
||||||
|
afterwards leaves git with no record of it.
|
||||||
|
|
||||||
|
Order the gates so the expensive, irreversible step is last — preconditions,
|
||||||
|
guards, build, verify the built thing is what was asked for, publish, and record
|
||||||
|
it last of all.
|
||||||
|
|
||||||
|
## 7. When the gate finds something that invalidates the operation, stop
|
||||||
|
|
||||||
|
Printing a warning and continuing produces the worst outcome available: the bad
|
||||||
|
thing happens *and* a reassuring summary appears above it.
|
||||||
|
|
||||||
|
The question is not how bad the finding is. It is **whether it invalidates what
|
||||||
|
the operation claims**:
|
||||||
|
|
||||||
|
- A release whose test gate skipped half the suite — a release claims to be
|
||||||
|
tested. **Refuse.**
|
||||||
|
- A backup written to a group-readable directory — the backup is still a
|
||||||
|
backup. **Warn.**
|
||||||
|
|
||||||
|
Escape hatches are fine, and they have to be asked for by name, never be the
|
||||||
|
default, and say plainly what is being given up.
|
||||||
|
|
@ -0,0 +1,150 @@
|
||||||
|
# Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Status: Current
|
||||||
|
Owner: _null
|
||||||
|
Last reviewed: 2026-08-18
|
||||||
|
Governs: docs/architecture/**, the Gradle module graph, and the data shapes that
|
||||||
|
outlive a function
|
||||||
|
Review trigger: Any new Gradle module, any change to a module boundary, any change
|
||||||
|
to a Room entity or a DAO, any new Room migration, any dependency
|
||||||
|
added to a domain/* module
|
||||||
|
```
|
||||||
|
|
||||||
|
## The shape
|
||||||
|
|
||||||
|
```text
|
||||||
|
Compose UI (app, feature/*)
|
||||||
|
↓
|
||||||
|
ViewModel — immutable StateFlow of screen state
|
||||||
|
↓
|
||||||
|
Use case / prediction engine (domain/*)
|
||||||
|
↓
|
||||||
|
Repository (core/data)
|
||||||
|
↓
|
||||||
|
Room + DataStore (core/database, core/datastore)
|
||||||
|
```
|
||||||
|
|
||||||
|
Unidirectional: state flows down as an immutable `UiState`, events flow up as
|
||||||
|
function calls. Nothing below the ViewModel knows Compose exists.
|
||||||
|
|
||||||
|
## Modules
|
||||||
|
|
||||||
|
Four today. `core/database` and `core/datastore` are Batch 01 issues #3 and #4
|
||||||
|
and **do not exist yet** — a module created before it has contents is a place
|
||||||
|
for things to be put by accident. The wider layout sketched in
|
||||||
|
[`../planning/PRODUCT_PLAN.md` §9](../planning/PRODUCT_PLAN.md) arrives the same
|
||||||
|
way, with the batch that needs it.
|
||||||
|
|
||||||
|
| Module | Plugin | Owns | May depend on |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `app` | Android application | `MainActivity`, the four-tab navigation shell, DI wiring | everything below |
|
||||||
|
| `core/designsystem` | Android library | Material 3 theme, colour and type tokens | nothing in this project |
|
||||||
|
| `domain/cycle` | **Kotlin JVM** | `PeriodRecord`, `SpottingRecord`, `CycleRecord` and the rules over them | nothing |
|
||||||
|
| `domain/prediction` | **Kotlin JVM** | the forecast, the window, confidence, `NotYetObservation` | `domain/cycle` |
|
||||||
|
|
||||||
|
Planned, with the issue that brings each one:
|
||||||
|
|
||||||
|
| Module | Plugin | Owns | May depend on | Issue |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| `core/database` | Android library | Room entities, DAOs, migrations | `domain/cycle` | #3 |
|
||||||
|
| `core/datastore` | Android library | `UserPreferences` | `domain/cycle` | #4 |
|
||||||
|
| `core/data` | Android library | the repositories — the only things that touch a DAO | `core/database`, `core/datastore`, `domain/cycle` | #5 |
|
||||||
|
| `core/ads` | Android library | the `AdProvider` implementation | **neither `core/database` nor `domain/*`** | Batch 07 |
|
||||||
|
|
||||||
|
### Why `domain/*` is `kotlin("jvm")` and not an Android library
|
||||||
|
|
||||||
|
[`PRODUCT_PLAN.md` §57.10](../planning/PRODUCT_PLAN.md) asks for the prediction
|
||||||
|
engine to be unit-testable without Android. A convention saying "do not import
|
||||||
|
`android.*` here" is a convention somebody breaks at 11pm; a module that
|
||||||
|
**cannot see the Android SDK at all** is a compile error instead.
|
||||||
|
|
||||||
|
It buys the thing §50 depends on: the acceptance tests in §51 — stable 35-day
|
||||||
|
user, variable user, 45-day outlier, "not yet" — run on the JVM in under a
|
||||||
|
second, so they run on every commit rather than on an emulator when someone
|
||||||
|
remembers.
|
||||||
|
|
||||||
|
### The boundary that is not negotiable
|
||||||
|
|
||||||
|
> The advertising subsystem must never receive menstrual dates, cycle length,
|
||||||
|
> period duration, fertility status, ovulation estimates, prediction confidence,
|
||||||
|
> prediction history, spotting records, or any other health-derived attribute.
|
||||||
|
> — [`PRODUCT_PLAN.md` §34](../planning/PRODUCT_PLAN.md)
|
||||||
|
|
||||||
|
Expressed structurally rather than as a rule people remember: when `core/ads`
|
||||||
|
exists it will declare no dependency on `core/database` or `domain/*`, and a
|
||||||
|
Gradle check enforces the whole table above by enumerating each module's allowed
|
||||||
|
dependencies. Ads reach the UI through an `AdProvider` interface owned by `app`.
|
||||||
|
|
||||||
|
Per [`GUARDS.md`](GUARDS.md) §1, that check is proved to fail — a deliberate
|
||||||
|
forbidden dependency added, the guard watched going red, the file restored —
|
||||||
|
before it is treated as evidence. `scripts/prove-guard.sh` performs it.
|
||||||
|
|
||||||
|
## Data shapes
|
||||||
|
|
||||||
|
Defined in `domain/cycle` as plain Kotlin, mirrored by Room entities in
|
||||||
|
`core/database`. The full field lists are
|
||||||
|
[`PRODUCT_PLAN.md` §10](../planning/PRODUCT_PLAN.md); what matters here is why
|
||||||
|
each exists and what must not happen to it.
|
||||||
|
|
||||||
|
| Type | Why it exists | The rule that goes with it |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `PeriodRecord` | a confirmed period, with its source and whether it is confirmed | a record's `source` is kept; edits are recorded, never silent |
|
||||||
|
| `SpottingRecord` | spotting, tracked separately | **must not** start a cycle or reset one |
|
||||||
|
| `CycleRecord` | derived interval between two confirmed starts | derived, never stored as truth — recomputed from period records |
|
||||||
|
| `PredictionRecord` | a snapshot taken *before* the outcome is known | this is what makes accuracy measurable at all; never overwritten in place |
|
||||||
|
| `NotYetObservation` | the user said the period had not started by a date | a censoring observation — the forecast is re-conditioned on it, not shifted by +1 day |
|
||||||
|
| `UserPreferences` | notification privacy, reminder time, lock, theme, ads entitlement | lives in DataStore, never in the cycle database |
|
||||||
|
|
||||||
|
**Never secretly modify health history.** A gap that looks like a missing entry
|
||||||
|
([§14](../planning/PRODUCT_PLAN.md)) produces a question, not a correction. That
|
||||||
|
is an architectural constraint as much as a UX one: nothing in the data layer
|
||||||
|
may write a `PeriodRecord` the user did not confirm.
|
||||||
|
|
||||||
|
## Migrations
|
||||||
|
|
||||||
|
Room migrations are numbered, tested, and **listed in this document** — one row
|
||||||
|
per migration, added in the same commit as the migration itself. The template
|
||||||
|
this repository came from records why: a manual's migration table sat six
|
||||||
|
behind, and every reader in between trusted it.
|
||||||
|
|
||||||
|
| Version | What changed | Migration test |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 1 | initial schema | — |
|
||||||
|
|
||||||
|
Room's exported schemas are committed, so a migration can be tested against the
|
||||||
|
real previous schema rather than a remembered one.
|
||||||
|
|
||||||
|
## Documents here
|
||||||
|
|
||||||
|
- **[`GUARDS.md`](GUARDS.md)** — how to write a check that actually checks. Read
|
||||||
|
it before adding a structural test or a probe.
|
||||||
|
|
||||||
|
## What ships in this folder
|
||||||
|
|
||||||
|
Nothing. This project took six scripts from the template into `scripts/`, and
|
||||||
|
[`../TOOLS.md`](../TOOLS.md) explains why the rest are absent and where the
|
||||||
|
menu is.
|
||||||
|
|
||||||
|
| Path | What it is |
|
||||||
|
| --- | --- |
|
||||||
|
| `scripts/secrets.sh` | credential shapes in a staged diff — the one that stops a keystore reaching a commit |
|
||||||
|
| `scripts/doc-claims.sh` | every file a document names must exist; `--covers` asks the inverse |
|
||||||
|
| `scripts/doc-triggers.py` | which documents a pending change fires, read from the `Governs:` headers |
|
||||||
|
| `scripts/commit-mine.sh` | commits only the paths you name, by pathspec, after the secret scan |
|
||||||
|
| `scripts/forgejo-issue.py` | files and closes issues in the tracker convention, with every rule of it as a check |
|
||||||
|
| `scripts/prove-guard.sh` | breaks what a guard protects and requires the guard to go red |
|
||||||
|
| `.githooks/` | pre-commit, commit-msg, post-commit — see [githooks/README.md](githooks/README.md) |
|
||||||
|
|
||||||
|
## What does not belong here
|
||||||
|
|
||||||
|
- Product intent — that is [`../planning/PROJECT_PLAN.md`](../planning/PROJECT_PLAN.md)
|
||||||
|
- What it should feel like — that is [`../design/README.md`](../design/README.md)
|
||||||
|
- What happened while building it — that is [`../history/DEVELOPMENT_LOG.md`](../history/DEVELOPMENT_LOG.md)
|
||||||
|
|
||||||
|
## A note on drift
|
||||||
|
|
||||||
|
Architecture docs go stale faster than any other kind, because code changes
|
||||||
|
under them silently. That is what the **Review trigger** above is for, and why
|
||||||
|
it names a new Gradle module and a new Room migration specifically: those are
|
||||||
|
the two changes here that make this document wrong without touching it.
|
||||||
|
|
@ -0,0 +1,89 @@
|
||||||
|
# Git hooks
|
||||||
|
|
||||||
|
```
|
||||||
|
Status: Current
|
||||||
|
Owner: _null
|
||||||
|
Last reviewed: 2026-08-18
|
||||||
|
Governs: .githooks/ — what runs before and after a commit
|
||||||
|
Review trigger: A new guard the repository wants run before a commit; any change
|
||||||
|
to what a commit message must contain; any change to which
|
||||||
|
Gradle tasks pre-commit runs
|
||||||
|
```
|
||||||
|
|
||||||
|
Three hooks, and the reason they live in the repository rather than in
|
||||||
|
`.git/hooks`: that directory is not versioned, so a hook living there protects
|
||||||
|
exactly one clone on exactly one machine.
|
||||||
|
|
||||||
|
## The hooks are in `.githooks/`, and only there
|
||||||
|
|
||||||
|
The template this repository adopted keeps a master copy under
|
||||||
|
`docs/architecture/githooks/` and installs copies into `.githooks/`. **Period
|
||||||
|
does not**, deliberately: `pre-commit` here is adapted for Gradle rather than
|
||||||
|
npm, so a second copy would be a second version of a file somebody edits once
|
||||||
|
and forgets — the exact failure `../../DOC_TRUST_MAP.md` exists to prevent.
|
||||||
|
|
||||||
|
So [`.githooks/`](../../../.githooks) holds the hooks and this document
|
||||||
|
describes them. One copy of the code, one copy of the explanation.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git config core.hooksPath .githooks
|
||||||
|
```
|
||||||
|
|
||||||
|
That is the whole setup, and it is **per clone** — every checkout runs it once,
|
||||||
|
including a fresh clone on the same machine. An uninstalled hook fails silently,
|
||||||
|
which is the same class of problem the hooks exist to prevent.
|
||||||
|
|
||||||
|
## What each one does
|
||||||
|
|
||||||
|
| Hook | Guard |
|
||||||
|
| --- | --- |
|
||||||
|
| `pre-commit` | the staged-diff secret scan, then `:domain:cycle:test` and `:domain:prediction:test` when `.kt`/`.kts` or a build file is staged |
|
||||||
|
| `commit-msg` | refuses a message with no conventional type — the closed vocabulary is in the hook's own header |
|
||||||
|
| `post-commit` | pushes to `origin`, so a guarded commit does not sit unpushed |
|
||||||
|
|
||||||
|
## Two things worth knowing before you rely on them
|
||||||
|
|
||||||
|
**`pre-commit` does not compile the Android modules.** It runs the two pure-JVM
|
||||||
|
suites, which need no SDK and take about a second. Compiling `:app` needs the
|
||||||
|
Android SDK and half a minute, and a hook people reach for `--no-verify` to
|
||||||
|
avoid is worse than one that checks less. `./gradlew assembleRelease` belongs to
|
||||||
|
[`../../security/SECURITY_CHECKLIST.md`](../../security/SECURITY_CHECKLIST.md),
|
||||||
|
which is where it is.
|
||||||
|
|
||||||
|
The suite it *does* run is not an arbitrary subset: it is the prediction
|
||||||
|
acceptance cases from
|
||||||
|
[`../../planning/PRODUCT_PLAN.md` §51](../../planning/PRODUCT_PLAN.md), which
|
||||||
|
guard the one claim this product is built on.
|
||||||
|
|
||||||
|
**`post-commit` pushes.** That is the intent — the commit that first added a
|
||||||
|
pre-commit hook to the project this came from sat unpushed for a day, guarded
|
||||||
|
and invisible — but it is a surprise if you were not expecting it. It never
|
||||||
|
forces, stays out of the way mid-rebase, and `SKIP_PUSH=1` opts out loudly.
|
||||||
|
|
||||||
|
It has a second consequence worth knowing about: the push is what the Command
|
||||||
|
Center reads, so **whatever documentation was not in that commit is now behind
|
||||||
|
the code by one push**. That is the mechanical reason
|
||||||
|
[`../../WORK_CYCLE.md`](../../WORK_CYCLE.md) asks for doc edits in the same
|
||||||
|
commit as the change rather than in a tidy-up afterwards — with this hook
|
||||||
|
installed, "I will document it next commit" means the site has already published
|
||||||
|
the version without it.
|
||||||
|
|
||||||
|
## Escape hatches, and why they are loud
|
||||||
|
|
||||||
|
```bash
|
||||||
|
SKIP_GUARDS=1 git commit ... # skips the scan and the tests, and says so
|
||||||
|
SKIP_PUSH=1 git commit ... # commits without publishing, and says so
|
||||||
|
git commit --no-verify ... # skips the hooks entirely, silently
|
||||||
|
```
|
||||||
|
|
||||||
|
Prefer the first two. They leave a line in the terminal saying the guard did not
|
||||||
|
run, which is the difference between a deliberate exception and a habit.
|
||||||
|
|
||||||
|
## Why a hook and not CI
|
||||||
|
|
||||||
|
Both, eventually. These are the guards that must run before the artifact exists:
|
||||||
|
a check that fires after a push, or after a Play upload, catches the problem once
|
||||||
|
it is already somewhere it cannot be taken back from. CI is the second opinion;
|
||||||
|
this is the one that runs first.
|
||||||
|
|
@ -0,0 +1,80 @@
|
||||||
|
# Data — Period
|
||||||
|
|
||||||
|
```
|
||||||
|
Status: Current
|
||||||
|
Owner: _null
|
||||||
|
Last reviewed: 2026-08-18
|
||||||
|
Governs: docs/data/** — the assets privacyllc.dev renders for this project
|
||||||
|
Review trigger: A rebrand, or any change to the icon, logo or banner
|
||||||
|
```
|
||||||
|
|
||||||
|
## What goes here
|
||||||
|
|
||||||
|
Three files, in `img/`, at exactly these names:
|
||||||
|
|
||||||
|
```text
|
||||||
|
docs/data/img/icon.webp the square mark, used wherever the project is listed
|
||||||
|
docs/data/img/logo.webp the full lockup, used on the project page
|
||||||
|
docs/data/img/banner.webp the wide image, used across the project header
|
||||||
|
```
|
||||||
|
|
||||||
|
All **webp**. All **required**. Only `img/` is checked — an asset left in
|
||||||
|
`docs/data/` instead of `docs/data/img/` is not found.
|
||||||
|
|
||||||
|
**Dimensions, weights and how to generate them are in
|
||||||
|
[`img/README.md`](img/README.md)**, beside the files they describe. This
|
||||||
|
document owns the rule; that one owns the spec. Stating both in both places is
|
||||||
|
how two copies of one convention start disagreeing.
|
||||||
|
|
||||||
|
**No placeholders ship with this template, deliberately.** It carried a 0-byte
|
||||||
|
`logo.webp` once, and an empty file is the worst of the three states: a check
|
||||||
|
that asks "does the path exist" calls it present, and anything that reads the
|
||||||
|
bytes rejects it — a consumer verifying the webp signature answers 415, which
|
||||||
|
reads as a corrupt asset rather than a missing one. Absent is honest and the
|
||||||
|
conformance check reports it as absent, which is what gets it filled in.
|
||||||
|
|
||||||
|
Extra sizes and variants are welcome beside them — `icon-512.webp`,
|
||||||
|
`logo-dark.webp` — and are not treated as clutter. Only the three exact names
|
||||||
|
are checked for.
|
||||||
|
|
||||||
|
## If an asset is missing, open an issue — do not invent one
|
||||||
|
|
||||||
|
An agent cannot draw a logo, and this is the one gap in the whole convention
|
||||||
|
that cannot be closed by writing a file.
|
||||||
|
|
||||||
|
So when an asset is absent, **file an issue** rather than producing something:
|
||||||
|
title it for the asset, label it `P2`, and end the body with its `Verify:` line
|
||||||
|
— `Verify: docs/data/img/logo.webp exists and the project card renders it.`
|
||||||
|
|
||||||
|
**Do not generate a placeholder.** A placeholder that looks deliberate outlives
|
||||||
|
the issue that would have replaced it: nobody files a ticket against an image
|
||||||
|
that appears to be finished. An obviously absent asset keeps asking.
|
||||||
|
|
||||||
|
## Why this folder is different from every other one here
|
||||||
|
|
||||||
|
The Command Center *consumes* these. Every other document in this tree is written
|
||||||
|
for a person to read; these are fetched and rendered on privacyllc.dev's project
|
||||||
|
page.
|
||||||
|
|
||||||
|
That has one consequence worth stating plainly: **this folder cannot be declared
|
||||||
|
exempt.** A repository may tell the conformance check that a required document is
|
||||||
|
deliberately absent — kept out of git on purpose, say — and the check will
|
||||||
|
believe it. It will not accept that declaration for `docs/data/`, because the
|
||||||
|
result would be a project card with nothing to show and nothing explaining why,
|
||||||
|
which is the exact failure the check exists to prevent.
|
||||||
|
|
||||||
|
## Why webp and not PNG
|
||||||
|
|
||||||
|
One format, checked by its magic bytes rather than its file extension, so the
|
||||||
|
site can serve it inline with confidence. A file whose first bytes are RIFF/WEBP
|
||||||
|
cannot be an HTML document or an SVG carrying script, which is what makes it safe
|
||||||
|
to render directly rather than forcing a download.
|
||||||
|
|
||||||
|
Renaming a PNG to `.webp` will not work, and is meant not to.
|
||||||
|
|
||||||
|
## This is not an asset library
|
||||||
|
|
||||||
|
Screenshots, mockups, reference art and exported source files do not belong here.
|
||||||
|
They belong wherever the project already keeps them. This folder holds the three
|
||||||
|
marks that identify the project elsewhere, and stays small enough that its
|
||||||
|
contents are obvious at a glance.
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
# Project images
|
||||||
|
|
||||||
|
```
|
||||||
|
Status: Current
|
||||||
|
Owner: _null
|
||||||
|
Last reviewed: 2026-08-18
|
||||||
|
Governs: docs/data/img/** — the three files and their sizes
|
||||||
|
Review trigger: A rebrand; any change to a required name, dimension or ceiling;
|
||||||
|
any change to what the consumer accepts.
|
||||||
|
```
|
||||||
|
|
||||||
|
Three files, all **webp**, all **required**, at exactly these names:
|
||||||
|
|
||||||
|
| File | Dimensions | Aspect | Typical weight |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `icon.webp` | **512 × 512** | 1:1 | 8–60 KB |
|
||||||
|
| `logo.webp` | **1024** on the long edge | whatever the lockup is | 20–190 KB |
|
||||||
|
| `banner.webp` | **2176 × 725** | 3:1 | 30–130 KB |
|
||||||
|
|
||||||
|
**Not `icon.ico`.** The consumer checks the file's magic bytes, not its name: a
|
||||||
|
`.ico`, or a PNG renamed to `.webp`, is refused with a 415 and the project falls
|
||||||
|
back to an initials tile. That signature check is what makes it safe to render
|
||||||
|
these inline, so it is not going to be relaxed.
|
||||||
|
|
||||||
|
**512 KB is a hard ceiling per file**, enforced in code — the size is read from
|
||||||
|
the listing before the bytes are fetched, so an oversized asset is never
|
||||||
|
downloaded and simply never appears. Nothing enforces the dimensions, which is
|
||||||
|
why they are written down.
|
||||||
|
|
||||||
|
## Making them
|
||||||
|
|
||||||
|
```bash
|
||||||
|
magick logo-source.png -resize 512x512 -quality 82 icon.webp
|
||||||
|
identify -format '%f %wx%h %b\n' *.webp # check before committing
|
||||||
|
```
|
||||||
|
|
||||||
|
Quality 80–85 suits a flat mark. If a file lands over ~200 KB it is usually a
|
||||||
|
photographic banner that wants a lower quality rather than fewer pixels.
|
||||||
|
|
||||||
|
## Why these numbers
|
||||||
|
|
||||||
|
The icon renders small — a 44 px tile in a list, 58 px on a project header — so
|
||||||
|
512 covers the densest display several times over; the reference project
|
||||||
|
deliberately halved it from 1024. The banner spans a card about 760 px wide, so
|
||||||
|
~2176 covers it at 2×, and its 3:1 shape matters more than its width because the
|
||||||
|
header crops to fill. The logo has no fixed frame, so only its long edge is
|
||||||
|
specified.
|
||||||
|
|
||||||
|
Extra sizes and variants are welcome beside these — `icon-512.webp`,
|
||||||
|
`logo-dark.webp`. Only the three exact names are checked for.
|
||||||
|
|
||||||
|
No placeholders ship with this template. An empty file is the worst of the three
|
||||||
|
states: a check that asks whether the path exists calls it present, and anything
|
||||||
|
reading the bytes rejects it. Absent is honest, and the conformance check reports
|
||||||
|
it as absent — which is what gets it filled in.
|
||||||
|
|
||||||
|
Why the requirement exists, and what reads it: [`../README.md`](../README.md).
|
||||||
|
|
@ -0,0 +1,104 @@
|
||||||
|
# Design
|
||||||
|
|
||||||
|
```
|
||||||
|
Status: Current
|
||||||
|
Owner: _null
|
||||||
|
Last reviewed: 2026-08-18
|
||||||
|
Governs: docs/design/**, the design tokens in core/designsystem, and the product's
|
||||||
|
tone and interface copy
|
||||||
|
Review trigger: Any new user-facing screen or state; any change to the colour or
|
||||||
|
type tokens; any change to notification copy or to a privacy or
|
||||||
|
fertility disclaimer
|
||||||
|
```
|
||||||
|
|
||||||
|
## Where the detail is
|
||||||
|
|
||||||
|
The screen-by-screen specification and the actual words are
|
||||||
|
[`../planning/PRODUCT_PLAN.md`](../planning/PRODUCT_PLAN.md), and they are not
|
||||||
|
repeated here — a second copy of interface copy is how two versions of a
|
||||||
|
disclaimer come to exist.
|
||||||
|
|
||||||
|
| Subject | Section it owns |
|
||||||
|
| --- | --- |
|
||||||
|
| Onboarding, seven screens, with copy | §19, §56 |
|
||||||
|
| Navigation — four tabs | §20 |
|
||||||
|
| Today screen and its six dynamic states | §21, §22 |
|
||||||
|
| Period logging, period end, spotting | §23, §24, §25 |
|
||||||
|
| Calendar states and markers | §26 |
|
||||||
|
| Insights | §27 |
|
||||||
|
| Notification modes, types and flow | §28, §29, §30 |
|
||||||
|
| Incognito launcher | §32 |
|
||||||
|
| Look and feel, visual direction, colour, typography, motion | §37–§41 |
|
||||||
|
| Artwork | §42 |
|
||||||
|
| Accessibility | §43 |
|
||||||
|
|
||||||
|
This document holds what governs those: the tone, and the rules that decide an
|
||||||
|
argument the specification did not anticipate.
|
||||||
|
|
||||||
|
## Tone
|
||||||
|
|
||||||
|
Calm, private, intelligent, adult. The app is a good utility with warmth — it is
|
||||||
|
not clinical, not childish, not gamified, and not stereotypically feminine as an
|
||||||
|
identity. It never celebrates a period and never alarms about one.
|
||||||
|
|
||||||
|
One paragraph, and it settles most small arguments: **the app speaks like
|
||||||
|
someone competent who is not making a fuss.** "Your period is late!" is out.
|
||||||
|
"Not yet?" with an updated forecast is in.
|
||||||
|
|
||||||
|
## Four rules that decide the arguments
|
||||||
|
|
||||||
|
**1. The number is the hero.** The forecast dominates the Today screen —
|
||||||
|
[§38](../planning/PRODUCT_PLAN.md). Anything competing with it for attention is
|
||||||
|
wrong, including anything of ours.
|
||||||
|
|
||||||
|
**2. Nothing asserts certainty the model does not have.** A window and a
|
||||||
|
confidence label, never a bare exact date presented as fact. "Estimated
|
||||||
|
ovulation", never "you are ovulating today". Fertility copy carries the
|
||||||
|
not-contraception line wherever it appears.
|
||||||
|
|
||||||
|
**3. State is never colour alone.** Confirmed period is a solid fill, predicted
|
||||||
|
is dotted or outlined, fertile window is a ring, ovulation is its own small
|
||||||
|
marker — distinguishable in greyscale, because that is also what makes them
|
||||||
|
distinguishable to a colourblind user and to a screenshot in a bug report.
|
||||||
|
Predicted and confirmed days must never look identical.
|
||||||
|
|
||||||
|
**4. Ads never touch a health action.** No banner in onboarding, in the
|
||||||
|
period-start confirmation, in the period-end confirmation, or between steps of a
|
||||||
|
health workflow — and never an interstitial after logging
|
||||||
|
([§33](../planning/PRODUCT_PLAN.md)). Banner space is reserved in the layout so a
|
||||||
|
failed ad does not move the content.
|
||||||
|
|
||||||
|
## Colour, in one line each
|
||||||
|
|
||||||
|
The palette is [§39](../planning/PRODUCT_PLAN.md); the constraints on it are:
|
||||||
|
|
||||||
|
- **Not pink as the whole identity.** Deep plum, muted berry, soft lavender,
|
||||||
|
warm cream, charcoal, muted sage/teal.
|
||||||
|
- **Period state** is a sophisticated berry or plum — never graphic blood-red.
|
||||||
|
- **Fertile window** is muted teal or sage — never bright green, which reads as
|
||||||
|
*safe* and this app must never say that.
|
||||||
|
- Everything goes through Material 3 colour roles and centralized tokens in
|
||||||
|
`core/designsystem`. **No hard-coded colours in a Composable** — the guard is
|
||||||
|
that a colour literal outside the token file is a review failure.
|
||||||
|
|
||||||
|
## The states most often left undesigned
|
||||||
|
|
||||||
|
Designed here on purpose, because they are the two most people meet first:
|
||||||
|
|
||||||
|
- **Empty** — no periods logged yet. It has to make the next action obvious
|
||||||
|
rather than apologise.
|
||||||
|
- **Learning** — one or two cycles recorded. The app says it is still learning
|
||||||
|
rather than showing a confident forecast it has not earned. "Getting to know
|
||||||
|
your pattern", not a percentage.
|
||||||
|
|
||||||
|
## Include the rejected version
|
||||||
|
|
||||||
|
For any decision that was genuinely close, record what was not chosen and why.
|
||||||
|
Without it the same option is proposed again every few months and re-argued from
|
||||||
|
nothing. The first entries belong to whoever makes those calls; the
|
||||||
|
specification's `Avoid:` lists are already a partial record of them.
|
||||||
|
|
||||||
|
## What does not belong here
|
||||||
|
|
||||||
|
- How it is built — [`../architecture/README.md`](../architecture/README.md)
|
||||||
|
- Scope and audience — [`../planning/PROJECT_PLAN.md`](../planning/PROJECT_PLAN.md)
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
# Batch ledger — Period
|
||||||
|
|
||||||
|
```
|
||||||
|
Status: Archived
|
||||||
|
Owner: _null
|
||||||
|
Last reviewed: 2026-08-18
|
||||||
|
Governs: what the batches were, before the tracker held them
|
||||||
|
Review trigger: Nothing. Superseded by the tracker; kept for the record.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Why this file is archived rather than deleted
|
||||||
|
|
||||||
|
Planning used to live in markdown as a numbered batch list. It now lives in the
|
||||||
|
tracker: **milestones are batches, issues are deliverables**, and severity is
|
||||||
|
`P0` / `P1` / `P2` / `release-blocker`.
|
||||||
|
|
||||||
|
This file is what that list *was*. It is kept because the reasoning in it is
|
||||||
|
still worth reading, and deleted files are not readable.
|
||||||
|
|
||||||
|
**It must not be updated.** A batch list beside the tracker is a second answer to
|
||||||
|
"what is open", and two records of the same thing will disagree without saying
|
||||||
|
which is right. That is precisely why the work moved. If you are tempted to add a
|
||||||
|
batch here, add a milestone instead.
|
||||||
|
|
||||||
|
Its `Status: Archived` is therefore not a nicety — it is the machine-readable
|
||||||
|
form of "do not treat this as current".
|
||||||
|
|
||||||
|
## "Ledger: missing" on the project screen is the correct answer
|
||||||
|
|
||||||
|
The Command Center's docs report still looks for a batch ledger — at
|
||||||
|
docs/planning/FUTURE.md, named here without backticks deliberately, because
|
||||||
|
`doc-claims.sh` treats a backticked path as a claim that the file exists and
|
||||||
|
this one must not — and reports it as **missing** for every repository that has
|
||||||
|
moved planning into the tracker. That is the expected state and not a gap to
|
||||||
|
close: the report's own contract is that a tracker-first project shows a missing
|
||||||
|
ledger and no batch percentages.
|
||||||
|
|
||||||
|
Creating that file to turn the line green would rebuild, in a new name, the
|
||||||
|
exact second record this one was archived for.
|
||||||
|
|
||||||
|
## Period never had one
|
||||||
|
|
||||||
|
This repository adopted the tracker convention in its **first commit**, so there
|
||||||
|
was never a markdown batch list here to migrate. Nothing was lost and nothing was
|
||||||
|
archived; the milestones in the tracker are the only record this project has ever
|
||||||
|
had of what a batch is.
|
||||||
|
|
||||||
|
The file is kept anyway, and only for the section above it: the explanation of
|
||||||
|
why "Ledger: missing" is the correct reading on the project screen is worth
|
||||||
|
having in every repository that will show it. Recreating a ledger to turn that
|
||||||
|
line green would build, in a new name, the exact second record this file exists
|
||||||
|
to warn about.
|
||||||
|
|
||||||
|
Written into `Status: Archived` rather than deleted so the reason survives
|
||||||
|
somebody wondering, in a year, whether a batch list ought to be added.
|
||||||
|
|
@ -0,0 +1,87 @@
|
||||||
|
# Development log — Period
|
||||||
|
|
||||||
|
```
|
||||||
|
Status: Current
|
||||||
|
Owner: _null
|
||||||
|
Last reviewed: 2026-08-18
|
||||||
|
Governs: the dated record of what happened
|
||||||
|
Review trigger: Nothing. This file is appended to, never revised.
|
||||||
|
```
|
||||||
|
|
||||||
|
## How to use this
|
||||||
|
|
||||||
|
Newest first. **One entry per work session**, written before you stop — that is
|
||||||
|
step 6 of `docs/WORK_CYCLE.md`, and the two lines it insists on are `Next
|
||||||
|
action` and `Blockers`.
|
||||||
|
|
||||||
|
Those two are not decoration. The next session starts by reading the top of this
|
||||||
|
file, and a session that ended without saying what came next hands the one after
|
||||||
|
it a re-derivation instead of a starting point — which is where drift enters.
|
||||||
|
Neither line competes with anything: the live next action is the field on the
|
||||||
|
project at privacyllc.dev and the live blockers are issues in the tracker, while
|
||||||
|
these say what both were **at this date**. A record of then never disagrees with
|
||||||
|
a record of now.
|
||||||
|
|
||||||
|
**Append-only by convention.** Correcting an old entry rewrites the record of
|
||||||
|
what was known at the time, which is the one thing this file is for. If an entry
|
||||||
|
turns out to be wrong, add a later entry saying so; do not edit the first.
|
||||||
|
|
||||||
|
Note the Review trigger above says "nothing", deliberately. A dated log cannot
|
||||||
|
rot the way a description of current state can — the entries were true when
|
||||||
|
written and stay true. It is exempt from review for the same reason a receipt is.
|
||||||
|
|
||||||
|
## Entries
|
||||||
|
|
||||||
|
### 2026-08-18 — Template adopted; Kotlin/Compose skeleton builds
|
||||||
|
|
||||||
|
Period went from a bare directory holding one specification file to a git
|
||||||
|
repository with the standard documentation tree, a tracker, and a project that
|
||||||
|
compiles. Adoption followed `Projects/Template/START-HERE-New-Project.md`.
|
||||||
|
|
||||||
|
**Documents.** `scaffold.sh` created 19 paths, 0 skipped. The specification moved
|
||||||
|
from `Docs/period_tracker_product_plan.md` to `docs/planning/PRODUCT_PLAN.md`
|
||||||
|
unchanged in substance, with a status header added; the capitalised `Docs/` is
|
||||||
|
gone, since every script and the Command Center expect the lowercase tree. Every
|
||||||
|
scaffolded document was filled in for Period rather than left with placeholders.
|
||||||
|
`docs/OPERATIONS.md` was deleted — an offline app is not a deployed service.
|
||||||
|
`docs/DOC_TRUST_MAP.md` was written last and describes what is actually here,
|
||||||
|
including a section naming what this project deliberately does **not** have.
|
||||||
|
|
||||||
|
**Code.** Four Gradle modules: `app`, `core/designsystem`, and `domain/cycle`
|
||||||
|
and `domain/prediction` as `kotlin("jvm")` so the engine is testable without an
|
||||||
|
emulator. 17 tests pass, 12 of them the acceptance cases from `PRODUCT_PLAN.md`
|
||||||
|
§51. `BaselinePredictionEngine` is a robust-median prototype and is explicitly
|
||||||
|
not the product — it exists so Batch 02's replacement can be shown to be better
|
||||||
|
rather than merely different.
|
||||||
|
|
||||||
|
**Three things that cost time and are worth knowing next session:**
|
||||||
|
|
||||||
|
- **AGP 9 ships Kotlin built in.** Applying `org.jetbrains.kotlin.android` is now
|
||||||
|
a hard error, not a redundancy. The Compose compiler plugin is still separate.
|
||||||
|
- **Current AndroidX requires `compileSdk 37`.** Only up to 36 was installed;
|
||||||
|
`platforms;android-37.0` and `build-tools;37.0.0` were installed into
|
||||||
|
`~/Android/Sdk`. `targetSdk` stays at 36 — Play's floor from 2026-08-31 — and
|
||||||
|
the two being different is deliberate, not an oversight to tidy up.
|
||||||
|
- **Versions were verified, not inherited.** Kotlin 2.4.10, AGP 9.3.1, Gradle
|
||||||
|
9.7.0, Compose BOM 2026.08.00, Room 2.8.4, Hilt 2.60.1 — each checked against
|
||||||
|
its official source today, which `PRODUCT_PLAN.md` asks for rather than
|
||||||
|
trusting its own numbers.
|
||||||
|
|
||||||
|
**Tracker.** Eight milestones opened, `Batch 01 — Foundation` through
|
||||||
|
`Batch 08 — Polish`, and nine issues filed under Batch 01 only. Seven milestones
|
||||||
|
are deliberately empty: the roadmap is genuinely known and worth being visible,
|
||||||
|
but the work items under it are not, and inventing them would make every tracker
|
||||||
|
percentage permanently wrong. `forgejo-issue.py check` warns about this, and the
|
||||||
|
warning is correct about the mechanism and expected here.
|
||||||
|
|
||||||
|
- **Closed:** #1, #2
|
||||||
|
- **Next action:** Start issue #3 — `core/database` with Room entities for
|
||||||
|
`PeriodRecord`, `SpottingRecord`, `PredictionRecord` and `NotYetObservation`,
|
||||||
|
DAOs returning `Flow`, schema export committed, and a version-1 migration test
|
||||||
|
that proves the harness works before there is a migration that matters. Its row
|
||||||
|
goes in `docs/architecture/README.md`'s migration table in the same commit.
|
||||||
|
- **Blockers:** None for the code. Two things need a person rather than an agent:
|
||||||
|
the three branding marks (#8), which cannot be drawn here and must not be
|
||||||
|
faked, and the Command Center webhook (#9), whose URL and secret are not in any
|
||||||
|
credential file readable from this machine. Without the webhook an opened `P0`
|
||||||
|
raises no alert at all — it waits for the next reconcile.
|
||||||
|
|
@ -0,0 +1,71 @@
|
||||||
|
# History — Period
|
||||||
|
|
||||||
|
```
|
||||||
|
Status: Current
|
||||||
|
Owner: _null
|
||||||
|
Last reviewed: 2026-08-18
|
||||||
|
Governs: the narrative of how this project got to where it is
|
||||||
|
Review trigger: A decision reversed, a direction abandoned, or a rewrite
|
||||||
|
```
|
||||||
|
|
||||||
|
## What this is for
|
||||||
|
|
||||||
|
The story, in prose: what was tried, what was abandoned, and why. A reader
|
||||||
|
arriving in six months wants to know which walls have already been walked into,
|
||||||
|
and that is not something a commit log tells them.
|
||||||
|
|
||||||
|
**This is a record of *then*, never a description of *now*.** That distinction is
|
||||||
|
what makes it safe to leave alone as the project changes — a history document
|
||||||
|
edited to stay current is not a history, it is a second and competing
|
||||||
|
description of the present.
|
||||||
|
|
||||||
|
If you find yourself updating a sentence here because the code changed, the
|
||||||
|
sentence belongs somewhere else.
|
||||||
|
|
||||||
|
## Where this project started
|
||||||
|
|
||||||
|
Period began as a single document. Before any repository existed there was one
|
||||||
|
2,500-line specification — product, privacy, prediction algorithm, screen copy,
|
||||||
|
monetization and compliance — written to be handed to a coding agent whole. It
|
||||||
|
is still here, unchanged in substance, as
|
||||||
|
[`../planning/PRODUCT_PLAN.md`](../planning/PRODUCT_PLAN.md).
|
||||||
|
|
||||||
|
That is worth recording because it explains a shape a newcomer would otherwise
|
||||||
|
find odd: this repository has an unusually complete specification and no code.
|
||||||
|
Most projects have the reverse problem.
|
||||||
|
|
||||||
|
## Decisions and their reasons
|
||||||
|
|
||||||
|
One entry per decision that would otherwise look arbitrary later. The reason
|
||||||
|
matters more than the decision: "we chose X" ages badly, "we chose X because Y
|
||||||
|
was true at the time" survives Y stopping being true.
|
||||||
|
|
||||||
|
| When | What was decided | Why, at the time |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 2026-08-18 | Kotlin and Jetpack Compose, not Java or XML | Android is Kotlin-first and Compose-first in Google's own current documentation; the alternative was legacy on arrival |
|
||||||
|
| 2026-08-18 | Room, not files or SharedPreferences, for cycle history | history is structured, must survive app updates, and needs migrations that can be tested — the other options make a migration a hand-written parser |
|
||||||
|
| 2026-08-18 | The prediction engine is a **pure Kotlin JVM module** | it is the product, so it needs the most tests; a module that cannot see the Android SDK gets tests that run in a second instead of tests that need an emulator |
|
||||||
|
| 2026-08-18 | A robust personalized statistical model for V1, not a learned model | it is explainable, offline, fast, deterministic and testable, and the product promise is *personal*, not *neural*. A local ML model is a post-V1 option and only if it demonstrably beats this |
|
||||||
|
| 2026-08-18 | WorkManager for reminders, and **no exact-alarm permission** | a period reminder does not need alarm-clock precision, and requesting that permission is a Play scrutiny cost with no user benefit |
|
||||||
|
| 2026-08-18 | Ads stay behind an abstraction in a module that cannot reach cycle data | the privacy promise is the product; a rule people remember is not a control, a compile error is |
|
||||||
|
| 2026-08-18 | One-time purchase to remove ads, not a subscription | it keeps prediction quality free for everyone and avoids an entire class of entitlement defect — nothing to claw back on cancellation |
|
||||||
|
| 2026-08-18 | Six Gradle modules at the skeleton, not the seventeen the specification sketches | a module created before it has contents is a place for things to be put by accident; the rest arrive with the batch that needs them |
|
||||||
|
| 2026-08-18 | Eight milestones opened at once, issues filed only under Batch 01 | the roadmap is genuinely known and worth being visible; the *work items* are not, and inventing them would make every tracker percentage permanently wrong |
|
||||||
|
| 2026-08-18 | `OPERATIONS.md` deleted rather than kept empty | this is an offline app, not a deployed service; an empty runbook reads as one nobody wrote |
|
||||||
|
|
||||||
|
## What was tried and dropped
|
||||||
|
|
||||||
|
Nothing yet. This is the most useful section in the file and the one most often
|
||||||
|
missing — an approach abandoned for a good reason will be proposed again by
|
||||||
|
somebody who does not know it was tried, including you, in a year.
|
||||||
|
|
||||||
|
The specification already carries a partial version of it: every `Avoid:` and
|
||||||
|
`Do not:` list in [`../planning/PRODUCT_PLAN.md`](../planning/PRODUCT_PLAN.md)
|
||||||
|
is a decision made in advance rather than a lesson learned. When one of them
|
||||||
|
turns out to be wrong, that reversal belongs here.
|
||||||
|
|
||||||
|
## What this file is not
|
||||||
|
|
||||||
|
- **Not the plan.** That is [`../planning/PROJECT_PLAN.md`](../planning/PROJECT_PLAN.md).
|
||||||
|
- **Not the work list.** Open work lives in the tracker as milestones and issues.
|
||||||
|
- **Not a changelog.** Dated entries go in [`DEVELOPMENT_LOG.md`](DEVELOPMENT_LOG.md).
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,112 @@
|
||||||
|
# Period — Project Plan
|
||||||
|
|
||||||
|
```
|
||||||
|
Status: Current
|
||||||
|
Owner: _null
|
||||||
|
Last reviewed: 2026-08-18
|
||||||
|
Governs: scope, audience, and what this project deliberately is not
|
||||||
|
Review trigger: Any change of scope, audience, or platform; anything added to or
|
||||||
|
removed from the "deliberately not" list below
|
||||||
|
```
|
||||||
|
|
||||||
|
> The vision. The **milestones in this repository's issue tracker** hold the
|
||||||
|
> sequence of work; this holds what the work is *for*.
|
||||||
|
>
|
||||||
|
> The full specification — prediction requirements, screen-by-screen UX, copy,
|
||||||
|
> data model, monetization, compliance — is
|
||||||
|
> [`PRODUCT_PLAN.md`](PRODUCT_PLAN.md) beside this file. This document is the
|
||||||
|
> short argument; that one is the detail, and it is not summarised here because
|
||||||
|
> two copies of one specification disagree the first time either is edited.
|
||||||
|
|
||||||
|
## What this is
|
||||||
|
|
||||||
|
A private Android period tracker whose one job is to predict a user's next
|
||||||
|
period well. It learns the individual's cycle from confirmed dates, produces a
|
||||||
|
most-likely date with an honest window and a confidence label, estimates
|
||||||
|
ovulation and the fertile window from that, and asks discreetly whether the
|
||||||
|
period started — using both the *yes* and the *not yet* to improve the next
|
||||||
|
forecast. Everything core works offline, without an account.
|
||||||
|
|
||||||
|
## Who it is for
|
||||||
|
|
||||||
|
Someone who wants to know when their period is coming and does not want a
|
||||||
|
wellness platform. They have probably used a tracker that predicted a generic
|
||||||
|
28-day cycle at them for a year, and one that put their fertility data somewhere
|
||||||
|
they could not see. The product's two promises answer exactly those two
|
||||||
|
experiences: **it learns your cycle**, and **we never sell your data**.
|
||||||
|
|
||||||
|
Not "everyone who menstruates". A person who wants community, articles,
|
||||||
|
pregnancy mode or a symptom encyclopedia is better served elsewhere, and
|
||||||
|
building for them is what turns this into the app it exists to not be.
|
||||||
|
|
||||||
|
## What it is deliberately not
|
||||||
|
|
||||||
|
Every "not" here is a decision that stops being re-litigated. From
|
||||||
|
[`PRODUCT_PLAN.md` §5](PRODUCT_PLAN.md), and it is the most useful list in this
|
||||||
|
repository:
|
||||||
|
|
||||||
|
- not a social network, community or forum
|
||||||
|
- not a pregnancy tracker — no pregnancy mode in V1
|
||||||
|
- not an AI chatbot
|
||||||
|
- not a wellness article feed
|
||||||
|
- not a diet or exercise tracker
|
||||||
|
- not a horoscope or lunar-phase app
|
||||||
|
- not a shop
|
||||||
|
- not a partner-account or sex-diary product
|
||||||
|
- not a large mood/symptom library
|
||||||
|
- not a supplements funnel
|
||||||
|
- not a diagnostic tool, and **not contraception** — fertility output is an
|
||||||
|
estimate and says so wherever it appears
|
||||||
|
- not an app that makes free users worse at the thing it is for
|
||||||
|
|
||||||
|
Two of those are harder rules than the rest. **Prediction quality is never a
|
||||||
|
paid feature**, and **health data never reaches the advertising subsystem** —
|
||||||
|
see [`PRODUCT_PLAN.md` §34](PRODUCT_PLAN.md) and the boundary guard in
|
||||||
|
[`../architecture/README.md`](../architecture/README.md).
|
||||||
|
|
||||||
|
## Stack and platform
|
||||||
|
|
||||||
|
Verified against the official sources on 2026-08-18, not inherited from the
|
||||||
|
specification's own version numbers — which the specification itself asks for.
|
||||||
|
|
||||||
|
| Concern | Choice | Why |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Language | Kotlin 2.4.10 | Android is Kotlin-first; null safety and data classes suit a model built out of dates |
|
||||||
|
| UI | Jetpack Compose, Compose BOM 2026.08.00 | Android is Compose-first; XML layouts are the legacy path |
|
||||||
|
| Design system | Material 3 (1.4.0) | dynamic colour, dark mode and accessible contrast without hand-rolling any of it |
|
||||||
|
| Build | Gradle 9.7.0, AGP 9.3.1, Kotlin DSL | version catalog in `gradle/libs.versions.toml` |
|
||||||
|
| Local structured data | Room 2.8.4 | cycle history is structured, must survive updates, and needs migrations that can be tested |
|
||||||
|
| Preferences | DataStore 1.2.1 | typed, async, no SharedPreferences main-thread surprises |
|
||||||
|
| Background work | WorkManager 2.11.2 | reminders must survive process death; **no exact-alarm permission** — a period reminder does not need alarm-clock precision |
|
||||||
|
| DI | Hilt 2.60.1 | wired at the skeleton stage; retrofitting DI across modules later is the expensive order |
|
||||||
|
| Prediction engine | pure Kotlin JVM module | testable without an emulator, which is the only way it gets the test coverage §50 asks for |
|
||||||
|
| Target | `compileSdk`/`targetSdk` 36, `minSdk` 26 | Play requires API 36 for new apps and updates from **2026-08-31** |
|
||||||
|
|
||||||
|
## Success looks like
|
||||||
|
|
||||||
|
Observable, from [`PRODUCT_PLAN.md` §60](PRODUCT_PLAN.md) — the primary metrics
|
||||||
|
are about the forecast, not about engagement:
|
||||||
|
|
||||||
|
- mean absolute next-period prediction error, falling as confirmed cycles accrue
|
||||||
|
- share of predictions within ±1 and ±2 days
|
||||||
|
- measurable accuracy improvement at 3, 6 and 12 confirmed cycles
|
||||||
|
- a user with a stable 35-day cycle is never predicted at 28
|
||||||
|
|
||||||
|
And one qualitative test that decides the rest: **a first-time user enters their
|
||||||
|
last period and immediately sees a forecast they believe.**
|
||||||
|
|
||||||
|
Deliberately not optimised for: time in app, ads viewed, feed engagement. A good
|
||||||
|
period tracker helps in seconds and gets out of the way.
|
||||||
|
|
||||||
|
## Known risks
|
||||||
|
|
||||||
|
Written now, while it is still cheap to be honest.
|
||||||
|
|
||||||
|
| Risk | What it would look like | What is done about it |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| The engine falls back to a global average | a 35-day user predicted at 28 — the specification calls this a core product defect | the acceptance tests in §51 are written before the engine, and run in a module with no Android dependency so they always run |
|
||||||
|
| Confidence becomes decoration | "High" shown because there is a lot of data rather than because the data agrees | confidence is derived from variability and historical error, and §15's two worked examples are test cases |
|
||||||
|
| Health data leaks into ad requests | a cycle-state string in an ad extra, or an ads SDK initialised with user properties | a module-boundary guard: `ads` may not depend on the cycle database or the prediction domain, and the guard is proved to fail before it is trusted |
|
||||||
|
| A notification exposes cycle state on a lock screen | Discreet mode showing the private text publicly | notification privacy modes are a QA pass of their own, tested at every mode |
|
||||||
|
| Play health-app policy changes before launch | a submission rejected on Data Safety or a medical-claim reading | policy verified immediately before submission, never from memory; the target API deadline is already in the table above |
|
||||||
|
| A keystore or Play service-account JSON gets committed | a signing key in the history, which deleting the file does not undo | `scripts/secrets.sh` runs before every commit; `local.properties`, `*.jks` and `*.keystore` are ignored from the first commit |
|
||||||
|
|
@ -0,0 +1,56 @@
|
||||||
|
# Claude QA Coverage — Period
|
||||||
|
|
||||||
|
```
|
||||||
|
Status: Current
|
||||||
|
Owner: _null
|
||||||
|
Last reviewed: 2026-08-18
|
||||||
|
Governs: what each QA pass actually reached
|
||||||
|
Review trigger: Any QA round run
|
||||||
|
```
|
||||||
|
|
||||||
|
> Pass by pass, what was reached and what was not. The point of this file is the
|
||||||
|
> **Blocked** and **Not run** rows: a pass left out of a report reads exactly
|
||||||
|
> like a pass that succeeded, and that is how untested code ships believing it
|
||||||
|
> was tested.
|
||||||
|
|
||||||
|
## No round has been run
|
||||||
|
|
||||||
|
There is nothing to report yet, and this section says so rather than leaving the
|
||||||
|
file looking like a round that found nothing. The first round can only happen
|
||||||
|
once there is an app to run — the passes in
|
||||||
|
[`ClaudeQAPlan.md`](ClaudeQAPlan.md) all require a build, and Batch 01 is what
|
||||||
|
produces one.
|
||||||
|
|
||||||
|
The table below is the shape each round fills in. It is deliberately left with
|
||||||
|
no rows rather than pre-filled with "Not run", because a round that never
|
||||||
|
happened and a pass that was skipped are different facts.
|
||||||
|
|
||||||
|
## Round N — YYYY-MM-DD at `sha`
|
||||||
|
|
||||||
|
| Pass | Result | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| A — First run | | |
|
||||||
|
| B — Core loop | | |
|
||||||
|
| C — Failure paths | | |
|
||||||
|
| D — Persistence and migration | | |
|
||||||
|
| E — Forecast under hard histories | | |
|
||||||
|
| F — Notification privacy on a lock screen | | |
|
||||||
|
| G — Accessibility | | |
|
||||||
|
| H — Data ownership and leakage | | |
|
||||||
|
|
||||||
|
Results are `Pass`, `Partial`, `Blocked` or `Not run` — and the last three carry
|
||||||
|
what stopped them and the issue number, never a blank.
|
||||||
|
|
||||||
|
## Standing gaps
|
||||||
|
|
||||||
|
Things no round has ever covered, carried forward until they are. This list
|
||||||
|
existing is not a failure; it not existing while the gaps do is.
|
||||||
|
|
||||||
|
- **Everything.** No build exists yet.
|
||||||
|
- **Physical-device coverage is undecided.** Passes F and G need a real device
|
||||||
|
with a lock screen and TalkBack; which device that is has not been chosen, and
|
||||||
|
an emulator is not a substitute for either.
|
||||||
|
- **Long-horizon accuracy** — whether predictions measurably improve at 3, 6 and
|
||||||
|
12 confirmed cycles — cannot be reached by a QA round at all. It needs either
|
||||||
|
a simulated history harness or real elapsed time, and until one exists the
|
||||||
|
product's headline claim is tested only at the unit level.
|
||||||
|
|
@ -0,0 +1,117 @@
|
||||||
|
# Claude QA Plan — Period
|
||||||
|
|
||||||
|
```
|
||||||
|
Status: Current
|
||||||
|
Owner: _null
|
||||||
|
Last reviewed: 2026-08-18
|
||||||
|
Governs: what a QA round consists of
|
||||||
|
Review trigger: Any new user-facing surface, or a defect class that got through
|
||||||
|
```
|
||||||
|
|
||||||
|
> The playbook. What a round *is*, so two rounds are comparable and a gap is
|
||||||
|
> visible rather than assumed covered.
|
||||||
|
|
||||||
|
## Before a round
|
||||||
|
|
||||||
|
- Build from a clean checkout at a known SHA, and **record that SHA**. A finding
|
||||||
|
without one cannot be re-tested, and a finding that cannot be re-tested cannot
|
||||||
|
be closed.
|
||||||
|
- Build from a detached worktree if other work is in flight, so uncommitted
|
||||||
|
changes cannot contaminate what is under test.
|
||||||
|
- Note the environment: device or emulator, Android version and API level,
|
||||||
|
display size, font scale, and whether the device has a lock screen set. The
|
||||||
|
last one matters more here than anywhere — half of pass F depends on it.
|
||||||
|
- Seed the cycle history deliberately. A round run against three cycles and a
|
||||||
|
round run against twelve are not comparable, and the forecast is the product.
|
||||||
|
|
||||||
|
## The passes
|
||||||
|
|
||||||
|
Each pass gets a letter, so `ClaudeQACoverage.md` can report per pass and a
|
||||||
|
skipped one is visible.
|
||||||
|
|
||||||
|
| Pass | What it covers |
|
||||||
|
| --- | --- |
|
||||||
|
| A | First run: install on a clean profile, onboarding end to end, notification permission prompt, empty states |
|
||||||
|
| B | The core loop as a real user: log a period, see the forecast update, log the next one |
|
||||||
|
| C | Things going wrong: airplane mode, notification permission denied, invalid or duplicate dates, period logged in the future, app killed mid-entry |
|
||||||
|
| D | Persistence: force-stop and relaunch, reboot, background for days, app update over an existing install with a Room migration |
|
||||||
|
| E | **The forecast under the histories that break naive engines** — the §51 acceptance cases exercised through the UI rather than only in unit tests: stable 35-day user, highly variable user, 45-day outlier, repeated "Not yet" |
|
||||||
|
| F | **Notification privacy at every mode**, on a real lock screen: Discreet, Maximum privacy, Direct. What is visible without unlocking is the finding |
|
||||||
|
| G | Accessibility: TalkBack through the core loop, largest font scale, calendar states distinguishable in greyscale, touch targets, focus order, reduced motion |
|
||||||
|
| H | Data ownership and leakage: export, Delete My Data, biometric/PIN gate, incognito launcher, and the built artifact inspected for anything health-derived reaching the ads or analytics path |
|
||||||
|
|
||||||
|
<Add, remove and rename to fit. A pass that never applies is noise; a pass that
|
||||||
|
is always skipped is a lie.>
|
||||||
|
|
||||||
|
### Why E is separate from B
|
||||||
|
|
||||||
|
Pass B walks the loop as a satisfied user — a plausible cycle history, a
|
||||||
|
forecast that looks right. It cannot see the defect the product is most likely
|
||||||
|
to actually ship, because that defect only appears with a history B would never
|
||||||
|
generate.
|
||||||
|
|
||||||
|
[`../planning/PRODUCT_PLAN.md`](../planning/PRODUCT_PLAN.md) names it as a
|
||||||
|
**core product defect**: a user recording 34, 35, 36, 34, 35 who is predicted a
|
||||||
|
28- or 29-day cycle. No amount of walking the happy path finds that. E exists to
|
||||||
|
run the histories in §51 and check the *window and the confidence*, not just the
|
||||||
|
date — a right date with a wrong window is still wrong.
|
||||||
|
|
||||||
|
### Why F is a pass and not a checkbox
|
||||||
|
|
||||||
|
The most likely real breach in this product is not a database compromise. It is
|
||||||
|
a lock screen in a shared room.
|
||||||
|
|
||||||
|
Discreet is the default and Maximum privacy exists for people who need it, which
|
||||||
|
means both are load-bearing and both are only testable by looking at an actual
|
||||||
|
locked device. A unit test can assert the string; it cannot tell you Android
|
||||||
|
expanded the notification, or that a heads-up popup showed the private text on
|
||||||
|
its way past. Run F on hardware with a lock screen set, at every mode, for every
|
||||||
|
notification type in §29.
|
||||||
|
|
||||||
|
### What is deliberately not here
|
||||||
|
|
||||||
|
**No pass for money flowing backwards.** The template carries one, and it is
|
||||||
|
deleted rather than carried as permanently skipped: the only money here is a
|
||||||
|
one-time Play purchase, refunds are handled by Google, and there is no
|
||||||
|
entitlement of ours to claw back beyond what Play reports. If a subscription is
|
||||||
|
ever added, this pass comes back with it.
|
||||||
|
|
||||||
|
**No pass for authorisation.** There are no accounts and no server, so there is
|
||||||
|
no entitlement to confuse with authentication. That is a property of the
|
||||||
|
architecture, and if it changes this section is the trigger to re-add the pass.
|
||||||
|
|
||||||
|
## What counts as a finding
|
||||||
|
|
||||||
|
A finding needs: what was done, what happened, what should have happened, and
|
||||||
|
the build SHA. Without the SHA it cannot be re-tested, and a finding that cannot
|
||||||
|
be re-tested cannot be closed.
|
||||||
|
|
||||||
|
## Severity
|
||||||
|
|
||||||
|
Findings are filed as issues, labelled:
|
||||||
|
|
||||||
|
- **P0** — ships broken, or loses data
|
||||||
|
- **P1** — materially wrong, but shippable
|
||||||
|
- **P2** — cosmetic or low impact
|
||||||
|
- **release-blocker** — a release built today would be wrong rather than merely
|
||||||
|
incomplete
|
||||||
|
|
||||||
|
Exactly these label names: the Command Center queries them by name, and a
|
||||||
|
repository that spells them differently has its defects reported as *not
|
||||||
|
adopted* rather than counted wrongly.
|
||||||
|
|
||||||
|
Severity is what it costs, not how annoying it is to fix.
|
||||||
|
|
||||||
|
## After a round
|
||||||
|
|
||||||
|
File each finding as a labelled issue. Update `ClaudeReport.md`'s run-state
|
||||||
|
block and its overall sentence, and `ClaudeQACoverage.md` with what each pass
|
||||||
|
actually reached. A pass that could
|
||||||
|
not be run is recorded as blocked, with what blocks it — never quietly left out,
|
||||||
|
which reads identically to "passed".
|
||||||
|
|
||||||
|
Then **push, and reconcile**. The verdict on the project screen at
|
||||||
|
privacyllc.dev is read out of `ClaudeReport.md` in the pushed repository, so a
|
||||||
|
round whose report is committed but not pushed — or pushed but not reconciled —
|
||||||
|
leaves a stakeholder reading the previous round's judgment with no indication
|
||||||
|
that a newer one exists. The rest of the cycle is in `docs/WORK_CYCLE.md`.
|
||||||
|
|
@ -0,0 +1,56 @@
|
||||||
|
# Claude QA Report — Period
|
||||||
|
|
||||||
|
```
|
||||||
|
Status: Current
|
||||||
|
Owner: _null
|
||||||
|
Last reviewed: 2026-08-18
|
||||||
|
Governs: the QA verdict — build SHAs, round summaries, the overall judgment
|
||||||
|
Review trigger: Any QA round run
|
||||||
|
```
|
||||||
|
|
||||||
|
> The QA verdict. Companion to [`ClaudeQACoverage.md`](ClaudeQACoverage.md)
|
||||||
|
> (what each pass reached) and [`ClaudeQAPlan.md`](ClaudeQAPlan.md) (the
|
||||||
|
> playbook).
|
||||||
|
>
|
||||||
|
> **Defects are issues, not entries here.** A defect found in a round is filed
|
||||||
|
> in the tracker with a severity label, where it can be assigned, closed by a
|
||||||
|
> commit, and counted. This file keeps the part a tracker is bad at: a judgment
|
||||||
|
> about whether the thing is fit to ship.
|
||||||
|
|
||||||
|
## Current run-state
|
||||||
|
|
||||||
|
- **Last QA round:** None — no round has been run
|
||||||
|
- **Last verified build SHA:** none
|
||||||
|
- **Last tested device / environment:** none
|
||||||
|
- **Overall status:** No QA round has been run, because there is nothing to run
|
||||||
|
one against yet — this repository currently holds the product specification,
|
||||||
|
the documentation tree and a Kotlin project skeleton, and the first buildable
|
||||||
|
version of the app is what Batch 01 produces. The two things that will decide
|
||||||
|
whether this product is trustworthy are already known and already testable in
|
||||||
|
principle: whether the forecast stays personal for a user whose cycle is not
|
||||||
|
28 days, and whether the lock screen keeps quiet in Discreet mode. Neither has
|
||||||
|
been measured.
|
||||||
|
|
||||||
|
## Open defects
|
||||||
|
|
||||||
|
**Do not list them here, and do not read a defect count out of this file.** The
|
||||||
|
Command Center's docs report parses this document for open `P0` / `P1` / `P2`
|
||||||
|
counts, and under this convention they are always zero — the defects are in the
|
||||||
|
tracker, which is the whole point. The zeros here mean *this file does not hold
|
||||||
|
them*, never *there are none*.
|
||||||
|
|
||||||
|
Filed as issues in this repository's tracker, labelled by what they cost:
|
||||||
|
|
||||||
|
- **P0** — ships broken, or loses data
|
||||||
|
- **P1** — materially wrong, but shippable
|
||||||
|
- **P2** — cosmetic or low impact
|
||||||
|
- **release-blocker** — a release built today would be *wrong*, not merely
|
||||||
|
incomplete
|
||||||
|
|
||||||
|
Severity is what it costs, not how annoying it is to fix. Every defect needs the
|
||||||
|
build SHA it was found at — a finding that cannot be re-tested cannot be closed
|
||||||
|
— so put it in the issue body.
|
||||||
|
|
||||||
|
## Round notes
|
||||||
|
|
||||||
|
No rounds yet.
|
||||||
|
|
@ -0,0 +1,160 @@
|
||||||
|
# Security — Period
|
||||||
|
|
||||||
|
```
|
||||||
|
Status: Current
|
||||||
|
Owner: _null
|
||||||
|
Last reviewed: 2026-08-18
|
||||||
|
Governs: what this app protects, secret handling, data at rest, and what leaves
|
||||||
|
the device
|
||||||
|
Review trigger: Any new SDK or external service; any new secret; any change to
|
||||||
|
what is stored, exported, backed up or logged; any change to what
|
||||||
|
the ads or billing subsystems can see
|
||||||
|
```
|
||||||
|
|
||||||
|
## What this protects, and from whom
|
||||||
|
|
||||||
|
The asset is a menstrual and fertility history. It is not valuable to a
|
||||||
|
generic attacker and it is extremely costly to its owner, which makes the threat
|
||||||
|
model unusual: **the adversaries are mostly people with physical access to the
|
||||||
|
phone, and organisations that would like to buy the data.**
|
||||||
|
|
||||||
|
| Asset | Where it lives | What it would cost to lose |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Confirmed period dates, spotting, cycle history | Room, app-private storage on the device | the thing the user came here to keep private — inferable pregnancy, contraception use, health conditions |
|
||||||
|
| Predictions, ovulation and fertility estimates | Room, derived from the above | same class: fertility state is health data even though the app computed it |
|
||||||
|
| Notification content on the lock screen | Android's notification surface | disclosure to anyone who can see the screen, without unlocking it — the most likely real breach here |
|
||||||
|
| Play Billing entitlement (`remove_ads_forever`) | Google Play, mirrored in DataStore | low: a wrongly granted ad removal costs money, not privacy |
|
||||||
|
| Upload keystore and Play service-account JSON | the developer machine, **never this repository** | permanent — a signing key cannot be rotated for an existing app listing |
|
||||||
|
|
||||||
|
**The adversary list, plainly:** someone who picks up an unlocked phone; someone
|
||||||
|
who can see a lock screen; a person with a shared device; an ad or analytics SDK
|
||||||
|
that collects more than it declares; and a data broker offering money. Not a
|
||||||
|
nation state — controls sized for one would come at the cost of the offline,
|
||||||
|
accountless design that makes the rest of this true.
|
||||||
|
|
||||||
|
## The promise this document has to hold up
|
||||||
|
|
||||||
|
> **We will never sell your personal or health data.**
|
||||||
|
|
||||||
|
And the wording that is deliberately *not* promised
|
||||||
|
([§4](../planning/PRODUCT_PLAN.md)): "no third party ever processes any data."
|
||||||
|
An ads SDK, Play Billing and the store itself process limited technical
|
||||||
|
information. The architecture minimises that, declares it accurately in Play's
|
||||||
|
Data Safety section, and never lets health data reach any of them.
|
||||||
|
|
||||||
|
## Data at rest, and what never leaves
|
||||||
|
|
||||||
|
- Cycle history is in **app-private storage** — never external or shared
|
||||||
|
storage, never a world-readable path.
|
||||||
|
- The app works fully offline for logging, editing, prediction, fertility
|
||||||
|
estimates, calendar, insights, notification scheduling and app lock. **No
|
||||||
|
server is involved in producing a prediction**, which is the strongest privacy
|
||||||
|
control here: data that never leaves cannot be sold, subpoenaed from us, or
|
||||||
|
breached from a server we do not run.
|
||||||
|
- **No account is required** for core tracking, so there is no identity to
|
||||||
|
correlate the history with.
|
||||||
|
- Biometric/PIN gating protects app launch. Where a secret is needed to back
|
||||||
|
that, it is Android Keystore-backed — never a value in DataStore.
|
||||||
|
- **Platform backup is reviewed before the health database is allowed into it.**
|
||||||
|
An Android auto-backup that silently ships the cycle database to a cloud
|
||||||
|
account defeats the entire local-first argument, and it is on by default.
|
||||||
|
Until that review has been done and recorded here, the health database is
|
||||||
|
excluded from backup.
|
||||||
|
- **Delete My Data is irreversible after confirmation** and actually deletes —
|
||||||
|
not a soft flag.
|
||||||
|
|
||||||
|
## Third parties
|
||||||
|
|
||||||
|
Every row is a decision to send someone else's data somewhere. An empty table is
|
||||||
|
a good table, and this one gets filled in as each subsystem lands.
|
||||||
|
|
||||||
|
| Service | What it receives | Why that is acceptable |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Google Play Billing | purchase token, product id, device Play identity | required to sell anything on Play; carries no cycle data, and none may be put in billing metadata |
|
||||||
|
| Ads provider *(Batch 07, not yet integrated)* | non-personalized ad request, region consent signal | **no health-derived attribute, ever** — see the boundary below |
|
||||||
|
| Crash reporting *(not yet decided)* | stack traces | only if raw cycle dates cannot appear in them; otherwise not adopted |
|
||||||
|
|
||||||
|
## The advertising boundary
|
||||||
|
|
||||||
|
This is the one non-negotiable technical rule in the product
|
||||||
|
([§34](../planning/PRODUCT_PLAN.md)), so it is stated here as a security control
|
||||||
|
rather than only as an architecture note:
|
||||||
|
|
||||||
|
- Ad code sits behind an `AdProvider` abstraction.
|
||||||
|
- No health-derived property is ever placed in ad request extras, user
|
||||||
|
properties, or a callback log line.
|
||||||
|
- Cycle state is never used for targeting. Non-personalized/contextual is the
|
||||||
|
default.
|
||||||
|
- The ads provider is not initialised at all for users who bought Remove Ads,
|
||||||
|
where practical.
|
||||||
|
- Every SDK's data collection is audited before each release, against what Data
|
||||||
|
Safety declares.
|
||||||
|
|
||||||
|
Enforced structurally, not remembered: the `ads` module declares no dependency
|
||||||
|
on `core/database` or `domain/*`, and a Gradle guard proves it — see
|
||||||
|
[`../architecture/README.md`](../architecture/README.md).
|
||||||
|
|
||||||
|
## Logging
|
||||||
|
|
||||||
|
**Never write a cycle date, a prediction, or a fertility state to a log.**
|
||||||
|
|
||||||
|
```text
|
||||||
|
BAD User period started: 2026-08-18
|
||||||
|
BAD Predicted ovulation: 2026-09-02
|
||||||
|
OK period_record_created
|
||||||
|
OK prediction_recalculated
|
||||||
|
```
|
||||||
|
|
||||||
|
Event names without values. Verbose logging is off in release builds, and no
|
||||||
|
raw cycle date may appear in a crash report. An error's *name* is almost always
|
||||||
|
enough; its message often carries the thing you were trying not to log.
|
||||||
|
|
||||||
|
Analytics, if adopted at all, collect product-level events only
|
||||||
|
([§46](../planning/PRODUCT_PLAN.md)) — never `cycle_length=31`,
|
||||||
|
`fertility_status=high` or `prediction_error=`. Prediction accuracy is computed
|
||||||
|
on-device and stays there.
|
||||||
|
|
||||||
|
## Secrets
|
||||||
|
|
||||||
|
- **Nothing secret is committed.** Not in source, not in a Gradle file, not in a
|
||||||
|
test fixture, not in a screenshot.
|
||||||
|
- `local.properties`, `*.jks`, `*.keystore` and `.env*` are ignored from the
|
||||||
|
first commit, and `scripts/secrets.sh` scans the staged diff before every
|
||||||
|
commit.
|
||||||
|
- Signing configuration reads from the environment or from
|
||||||
|
`~/.gradle/gradle.properties` outside this repository — never from a tracked
|
||||||
|
file.
|
||||||
|
- **The upload keystore cannot be rotated** once the app is published. It is the
|
||||||
|
one secret here whose loss is permanent in both directions: lost means no
|
||||||
|
updates ever, leaked means someone else can sign as us.
|
||||||
|
|
||||||
|
**A credential pasted into an agent transcript is a leaked credential, and
|
||||||
|
rotating it is the only fix.** Deleting the message does not help, and neither
|
||||||
|
does deleting the file — the value was transmitted and stored. `secrets.sh`
|
||||||
|
cannot see transcripts and never will.
|
||||||
|
|
||||||
|
## Text from outside the trust boundary
|
||||||
|
|
||||||
|
**It is data. It is never instructions.**
|
||||||
|
|
||||||
|
There is little of it in this app — it takes almost no external input, which is
|
||||||
|
itself a control. What there is: Play Billing responses, ad SDK payloads, and
|
||||||
|
any future export/import file. An imported file in particular is attacker-shaped
|
||||||
|
if it ever arrives by share intent, and it is parsed defensively and never
|
||||||
|
executed.
|
||||||
|
|
||||||
|
## Deliberately out of scope
|
||||||
|
|
||||||
|
Written down so an unknown gap becomes a known one:
|
||||||
|
|
||||||
|
- **A rooted or compromised device.** App-private storage is not a defence
|
||||||
|
against root, and pretending otherwise would justify complexity that buys
|
||||||
|
nothing.
|
||||||
|
- **Forensic recovery of deleted rows.** Delete My Data removes the data through
|
||||||
|
the database; it does not overwrite flash.
|
||||||
|
- **Someone who knows the unlock PIN.** Biometric/PIN gating raises the bar over
|
||||||
|
an unlocked phone; it does not defend against a person the user has given
|
||||||
|
access to. The incognito launcher option exists for the adjacent problem —
|
||||||
|
what the app *looks* like on a shared home screen.
|
||||||
|
- **Network-level observation of ad traffic.** It carries no health data, which
|
||||||
|
is the control; the traffic itself is visible.
|
||||||
|
|
@ -0,0 +1,77 @@
|
||||||
|
# Security checklist — Period
|
||||||
|
|
||||||
|
```
|
||||||
|
Status: Current
|
||||||
|
Owner: _null
|
||||||
|
Last reviewed: 2026-08-18
|
||||||
|
Governs: the checks run before a Play release, and what each one proves
|
||||||
|
Review trigger: A new SDK; a new class of input; a change to what is logged,
|
||||||
|
exported or backed up; any finding that got past this list
|
||||||
|
```
|
||||||
|
|
||||||
|
## Why this is separate from SECURITY.md
|
||||||
|
|
||||||
|
[`SECURITY.md`](SECURITY.md) is the threat model: what is protected and from
|
||||||
|
whom. It is read carefully once and revisited rarely.
|
||||||
|
|
||||||
|
This is the list somebody actually works through before pressing publish.
|
||||||
|
Keeping them apart means the model can stay stable while the checks change, and
|
||||||
|
it means the release list stays short enough to finish rather than skim.
|
||||||
|
|
||||||
|
## Before a release
|
||||||
|
|
||||||
|
- [ ] `bash scripts/secrets.sh --tracked` is clean — proves no credential shape is in the tracked tree
|
||||||
|
- [ ] `git log -p` searched for keystore, `.jks`, service-account JSON and `local.properties` — proves nothing secret is in the **history**, which a directory listing cannot tell you
|
||||||
|
- [ ] The release APK/AAB is signed by the upload key from outside the repository — proves signing config never needed a tracked secret
|
||||||
|
- [ ] `./gradlew test` passes with the prediction acceptance tests included — proves the forecast still behaves at the boundaries §51 names
|
||||||
|
- [ ] Play **Data Safety** declaration re-read against what the app actually sends — proves the declaration is a description rather than an aspiration
|
||||||
|
- [ ] Play **Health apps** declaration completed as applicable — proves the category rules were checked, not assumed
|
||||||
|
- [ ] Store listing carries no medical or contraceptive claim — proves the copy did not drift past what the app can support
|
||||||
|
- [ ] Current Play target-API requirement confirmed **at submission time**, not from this document — proves the deadline was checked rather than remembered
|
||||||
|
|
||||||
|
## Standing checks
|
||||||
|
|
||||||
|
- [ ] No secret in the repository, in a log line, or in an error message
|
||||||
|
- [ ] Every input that reaches a query or a filesystem path is validated at the boundary
|
||||||
|
- [ ] Dependencies reviewed, and any accepted advisory recorded with a reason
|
||||||
|
- [ ] Every third-party SDK in the build is in [`SECURITY.md`](SECURITY.md)'s third-parties table — proves a dependency did not arrive without a decision
|
||||||
|
|
||||||
|
### Health data never reaches advertising
|
||||||
|
|
||||||
|
The one group that is not generic. Every item proves part of
|
||||||
|
[`../planning/PRODUCT_PLAN.md` §34](../planning/PRODUCT_PLAN.md).
|
||||||
|
|
||||||
|
- [ ] `ads` declares no Gradle dependency on `core/database` or `domain/*`, and the boundary guard was **proved to fail** this release — proves the check is evidence rather than decoration
|
||||||
|
- [ ] No health-derived value appears in an ad request extra, user property, or callback log — proves targeting cannot happen by accident
|
||||||
|
- [ ] Ad requests are non-personalized/contextual, with region-appropriate consent — proves the default is the private one
|
||||||
|
- [ ] The ads provider is not initialised for entitled (ad-free) users where practical — proves the purchase removes the SDK, not just the view
|
||||||
|
- [ ] The built AAB inspected for the ad SDK's declared collection — proves the scan reached the artifact users receive, not only the source
|
||||||
|
|
||||||
|
### What the device and the lock screen expose
|
||||||
|
|
||||||
|
- [ ] Cycle history is in app-private storage only — proves nothing landed in shared or external storage
|
||||||
|
- [ ] Lock-screen text in **Discreet** and **Maximum privacy** modes contains no menstrual detail, checked on a real lock screen at every mode — proves the notification privacy feature actually works, which is the breach most likely to happen
|
||||||
|
- [ ] The health database's inclusion in platform auto-backup is a **recorded decision**, not a default — proves the local-first promise is not undone by the OS
|
||||||
|
- [ ] Release build has verbose logging off and no raw cycle date in any crash payload — proves §45's logging rule survived the build type
|
||||||
|
- [ ] Delete My Data removes the rows and is irreversible after confirmation — proves the promise in Settings is true
|
||||||
|
- [ ] Export produces only the user's own data, to a location they chose — proves export is not an accidental leak path
|
||||||
|
|
||||||
|
### The compliance bar, which is not the launch bar
|
||||||
|
|
||||||
|
- [ ] The privacy policy is published, reachable from the app, and matches what the app does
|
||||||
|
- [ ] The privacy promise appears in onboarding, in Settings → Privacy & Security, and on the public privacy page — proves the promise is where §4 says it must be
|
||||||
|
|
||||||
|
*(Not applicable here, and deleted rather than carried as permanently skipped:
|
||||||
|
authorisation and IDOR checks — there are no accounts and no server; browser
|
||||||
|
bundle and cookie checks — there is no browser; rate limiting and spend
|
||||||
|
ceilings — there is no endpoint of ours to exhaust.)*
|
||||||
|
|
||||||
|
## What got past this list
|
||||||
|
|
||||||
|
Add an entry whenever a real finding was not caught here, and then add the check
|
||||||
|
that would have caught it. A checklist that never grows is one nobody is honest
|
||||||
|
with.
|
||||||
|
|
||||||
|
| When | What was missed | The check now added |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| — | — | — |
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
plugins {
|
||||||
|
alias(libs.plugins.kotlin.jvm)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deliberately kotlin("jvm") and not an Android library. The prediction domain
|
||||||
|
// must be testable without an emulator, and a module that CANNOT see the
|
||||||
|
// Android SDK is a compile error rather than a convention somebody breaks at
|
||||||
|
// 11pm. See docs/architecture/README.md.
|
||||||
|
kotlin {
|
||||||
|
jvmToolchain(21)
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
testImplementation(libs.junit)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,69 @@
|
||||||
|
package dev.privacyllc.period.domain.cycle
|
||||||
|
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
|
/** How a period record came to exist. Kept on the record: edits are recorded, never silent. */
|
||||||
|
enum class PeriodRecordSource { MANUAL, NOTIFICATION_CONFIRMATION, HISTORICAL_ENTRY, EDITED }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A period the user confirmed.
|
||||||
|
*
|
||||||
|
* [endDate] is null while the period is still going or was never closed out —
|
||||||
|
* those are different states to the user and the same to the cycle maths, which
|
||||||
|
* only reads [startDate].
|
||||||
|
*/
|
||||||
|
data class PeriodRecord(
|
||||||
|
val id: Long,
|
||||||
|
val startDate: LocalDate,
|
||||||
|
val endDate: LocalDate? = null,
|
||||||
|
val source: PeriodRecordSource = PeriodRecordSource.MANUAL,
|
||||||
|
val isConfirmed: Boolean = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spotting. Tracked separately and **never** treated as a period start.
|
||||||
|
*
|
||||||
|
* Spotting resetting the cycle is the defect this separate type exists to make
|
||||||
|
* impossible — see docs/planning/PRODUCT_PLAN.md §25.
|
||||||
|
*/
|
||||||
|
data class SpottingRecord(
|
||||||
|
val id: Long,
|
||||||
|
val date: LocalDate,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An interval between two confirmed period starts.
|
||||||
|
*
|
||||||
|
* Derived, never stored as truth: recomputed from the period records so an edit
|
||||||
|
* to a record cannot leave a stale cycle behind it.
|
||||||
|
*/
|
||||||
|
data class CycleRecord(
|
||||||
|
val previousPeriodStart: LocalDate,
|
||||||
|
val currentPeriodStart: LocalDate,
|
||||||
|
val cycleLengthDays: Int,
|
||||||
|
val periodDurationDays: Int? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive the cycle intervals from confirmed period records.
|
||||||
|
*
|
||||||
|
* Unconfirmed records are ignored, records are ordered by start date, and two
|
||||||
|
* records sharing a start date contribute one cycle rather than a zero-length
|
||||||
|
* one — a duplicate entry is a data problem, not a zero-day cycle.
|
||||||
|
*/
|
||||||
|
fun List<PeriodRecord>.toCycles(): List<CycleRecord> =
|
||||||
|
asSequence()
|
||||||
|
.filter { it.isConfirmed }
|
||||||
|
.sortedBy { it.startDate }
|
||||||
|
.distinctBy { it.startDate }
|
||||||
|
.zipWithNext { previous, current ->
|
||||||
|
CycleRecord(
|
||||||
|
previousPeriodStart = previous.startDate,
|
||||||
|
currentPeriodStart = current.startDate,
|
||||||
|
cycleLengthDays = (current.startDate.toEpochDay() - previous.startDate.toEpochDay()).toInt(),
|
||||||
|
periodDurationDays = previous.endDate?.let {
|
||||||
|
(it.toEpochDay() - previous.startDate.toEpochDay()).toInt() + 1
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.toList()
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
package dev.privacyllc.period.domain.cycle
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
|
class CycleTest {
|
||||||
|
|
||||||
|
private fun starts(vararg dates: String) = dates.mapIndexed { i, d ->
|
||||||
|
PeriodRecord(id = i.toLong(), startDate = LocalDate.parse(d))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `intervals are the gaps between consecutive starts`() {
|
||||||
|
val cycles = starts("2026-01-01", "2026-01-30", "2026-02-27").toCycles()
|
||||||
|
assertEquals(listOf(29, 28), cycles.map { it.cycleLengthDays })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `records out of order still produce ascending cycles`() {
|
||||||
|
val cycles = starts("2026-02-27", "2026-01-01", "2026-01-30").toCycles()
|
||||||
|
assertEquals(listOf(29, 28), cycles.map { it.cycleLengthDays })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a duplicate start date does not produce a zero-length cycle`() {
|
||||||
|
val cycles = starts("2026-01-01", "2026-01-01", "2026-01-30").toCycles()
|
||||||
|
assertEquals(listOf(29), cycles.map { it.cycleLengthDays })
|
||||||
|
assertTrue(cycles.none { it.cycleLengthDays == 0 })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `unconfirmed records are not cycle boundaries`() {
|
||||||
|
val records = starts("2026-01-01", "2026-01-15", "2026-01-30")
|
||||||
|
.mapIndexed { i, r -> if (i == 1) r.copy(isConfirmed = false) else r }
|
||||||
|
assertEquals(listOf(29), records.toCycles().map { it.cycleLengthDays })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `fewer than two starts is no cycles rather than an error`() {
|
||||||
|
assertTrue(starts("2026-01-01").toCycles().isEmpty())
|
||||||
|
assertTrue(emptyList<PeriodRecord>().toCycles().isEmpty())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
plugins {
|
||||||
|
alias(libs.plugins.kotlin.jvm)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pure JVM, like :domain:cycle. This module is the product, so it needs the
|
||||||
|
// most tests, and tests that need an emulator are tests that do not get run.
|
||||||
|
kotlin {
|
||||||
|
jvmToolchain(21)
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(project(":domain:cycle"))
|
||||||
|
testImplementation(project(":domain:cycle"))
|
||||||
|
testImplementation(libs.junit)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,120 @@
|
||||||
|
package dev.privacyllc.period.domain.prediction
|
||||||
|
|
||||||
|
import java.time.LocalDate
|
||||||
|
import kotlin.math.abs
|
||||||
|
import kotlin.math.roundToLong
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A deliberately simple baseline, and it is NOT the product.
|
||||||
|
*
|
||||||
|
* docs/planning/PRODUCT_PLAN.md §11 names `mean(all cycles)` as an acceptable
|
||||||
|
* prototype baseline and an unacceptable final engine. This is that prototype:
|
||||||
|
* a median rather than a mean so a single outlier cannot drag it, a window from
|
||||||
|
* the median absolute deviation, and confidence from agreement rather than
|
||||||
|
* volume.
|
||||||
|
*
|
||||||
|
* It exists so the skeleton has something honest to render and something to
|
||||||
|
* measure the real engine against. Batch 02 replaces it with the recency
|
||||||
|
* weighted, trend-aware, "not yet"-conditioned engine §12 specifies — at which
|
||||||
|
* point these tests become the regression suite that says the replacement is
|
||||||
|
* better rather than merely different.
|
||||||
|
*/
|
||||||
|
class BaselinePredictionEngine : PredictionEngine {
|
||||||
|
|
||||||
|
override val modelVersion: String = "baseline-1"
|
||||||
|
|
||||||
|
override fun predict(
|
||||||
|
confirmedStarts: List<LocalDate>,
|
||||||
|
today: LocalDate,
|
||||||
|
notYet: List<NotYetObservation>,
|
||||||
|
): Prediction? {
|
||||||
|
val starts = confirmedStarts.distinct().sorted()
|
||||||
|
if (starts.isEmpty()) return null
|
||||||
|
|
||||||
|
val intervals = starts.zipWithNext { a, b -> b.toEpochDay() - a.toEpochDay() }
|
||||||
|
.filter { it > 0 }
|
||||||
|
|
||||||
|
// No history at all: the population default, said with the lowest
|
||||||
|
// confidence the type can express. Never presented as knowledge.
|
||||||
|
val centre = if (intervals.isEmpty()) DEFAULT_CYCLE_DAYS else median(intervals)
|
||||||
|
val spread = if (intervals.size < 2) DEFAULT_SPREAD_DAYS else medianAbsoluteDeviation(intervals, centre)
|
||||||
|
|
||||||
|
val lastStart = starts.last()
|
||||||
|
var likely = lastStart.plusDays(centre.roundToLong())
|
||||||
|
val halfWidth = spread.roundToLong().coerceIn(MIN_HALF_WIDTH_DAYS, MAX_HALF_WIDTH_DAYS)
|
||||||
|
var windowStart = likely.minusDays(halfWidth)
|
||||||
|
var windowEnd = likely.plusDays(halfWidth)
|
||||||
|
|
||||||
|
// "Not yet" removes dates from the front of the window. A date the user
|
||||||
|
// has told us was not the start cannot remain a future start candidate,
|
||||||
|
// which is the specific requirement in §51's "Not yet" acceptance case.
|
||||||
|
val latestRuledOut = notYet.map { it.date }.maxOrNull()
|
||||||
|
val floor = listOfNotNull(latestRuledOut, today.minusDays(1)).maxOrNull()
|
||||||
|
if (floor != null && !windowStart.isAfter(floor)) {
|
||||||
|
windowStart = floor.plusDays(1)
|
||||||
|
if (windowEnd.isBefore(windowStart)) windowEnd = windowStart
|
||||||
|
if (likely.isBefore(windowStart)) likely = windowStart
|
||||||
|
}
|
||||||
|
|
||||||
|
val confidence = confidenceFor(intervals.size, spread, notYet.size)
|
||||||
|
|
||||||
|
return Prediction(
|
||||||
|
mostLikelyStartDate = likely,
|
||||||
|
windowStart = windowStart,
|
||||||
|
windowEnd = windowEnd,
|
||||||
|
confidenceScore = confidence,
|
||||||
|
confidenceLabel = labelFor(confidence, intervals.size),
|
||||||
|
modelVersion = modelVersion,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confidence is agreement, not volume.
|
||||||
|
*
|
||||||
|
* §15 is explicit that a large number of recorded cycles must not on its own
|
||||||
|
* produce "High" — a user whose cycles run 25, 33, 28, 37, 26, 32 has plenty
|
||||||
|
* of data and an unpredictable cycle, and telling them otherwise is the
|
||||||
|
* failure. So spread dominates, count only caps.
|
||||||
|
*/
|
||||||
|
private fun confidenceFor(cycleCount: Int, spread: Double, notYetCount: Int): Double {
|
||||||
|
if (cycleCount == 0) return 0.1
|
||||||
|
val agreement = 1.0 / (1.0 + spread / 2.0)
|
||||||
|
val evidence = (cycleCount.toDouble() / SATURATION_CYCLES).coerceAtMost(1.0)
|
||||||
|
val uncertainty = notYetCount * NOT_YET_PENALTY
|
||||||
|
return (agreement * evidence - uncertainty).coerceIn(0.0, 1.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun labelFor(confidence: Double, cycleCount: Int): ConfidenceLabel = when {
|
||||||
|
cycleCount < 2 -> ConfidenceLabel.LOW
|
||||||
|
confidence >= HIGH_THRESHOLD -> ConfidenceLabel.HIGH
|
||||||
|
confidence >= MEDIUM_THRESHOLD -> ConfidenceLabel.MEDIUM
|
||||||
|
else -> ConfidenceLabel.LOW
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun median(values: List<Long>): Double {
|
||||||
|
val sorted = values.sorted()
|
||||||
|
val mid = sorted.size / 2
|
||||||
|
return if (sorted.size % 2 == 1) sorted[mid].toDouble()
|
||||||
|
else (sorted[mid - 1] + sorted[mid]) / 2.0
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun medianAbsoluteDeviation(values: List<Long>, centre: Double): Double {
|
||||||
|
val deviations = values.map { abs(it - centre) }.sorted()
|
||||||
|
val mid = deviations.size / 2
|
||||||
|
val mad = if (deviations.size % 2 == 1) deviations[mid]
|
||||||
|
else (deviations[mid - 1] + deviations[mid]) / 2.0
|
||||||
|
return maxOf(mad, MIN_SPREAD_DAYS)
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val DEFAULT_CYCLE_DAYS = 28.0
|
||||||
|
const val DEFAULT_SPREAD_DAYS = 4.0
|
||||||
|
const val MIN_SPREAD_DAYS = 0.5
|
||||||
|
const val MIN_HALF_WIDTH_DAYS = 1L
|
||||||
|
const val MAX_HALF_WIDTH_DAYS = 10L
|
||||||
|
const val SATURATION_CYCLES = 6.0
|
||||||
|
const val NOT_YET_PENALTY = 0.08
|
||||||
|
const val HIGH_THRESHOLD = 0.6
|
||||||
|
const val MEDIUM_THRESHOLD = 0.4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,56 @@
|
||||||
|
package dev.privacyllc.period.domain.prediction
|
||||||
|
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
|
enum class ConfidenceLabel { LOW, MEDIUM, HIGH }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A forecast, taken before the outcome is known.
|
||||||
|
*
|
||||||
|
* The window is not decoration. docs/planning/PRODUCT_PLAN.md §8 and §15 require
|
||||||
|
* a range and a confidence rather than a single date presented as fact, so this
|
||||||
|
* type has no way to express a bare certain date.
|
||||||
|
*/
|
||||||
|
data class Prediction(
|
||||||
|
val mostLikelyStartDate: LocalDate,
|
||||||
|
val windowStart: LocalDate,
|
||||||
|
val windowEnd: LocalDate,
|
||||||
|
val confidenceScore: Double,
|
||||||
|
val confidenceLabel: ConfidenceLabel,
|
||||||
|
val modelVersion: String,
|
||||||
|
) {
|
||||||
|
init {
|
||||||
|
require(!windowStart.isAfter(windowEnd)) { "window start $windowStart is after window end $windowEnd" }
|
||||||
|
require(mostLikelyStartDate in windowStart..windowEnd) {
|
||||||
|
"most likely $mostLikelyStartDate falls outside the window $windowStart..$windowEnd"
|
||||||
|
}
|
||||||
|
require(confidenceScore in 0.0..1.0) { "confidence $confidenceScore is outside 0..1" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The user told us the period had not started by [date].
|
||||||
|
*
|
||||||
|
* A censoring observation, not a nudge: the next forecast is re-conditioned on
|
||||||
|
* it rather than shifted by a day. See PRODUCT_PLAN.md §13.
|
||||||
|
*/
|
||||||
|
data class NotYetObservation(
|
||||||
|
val date: LocalDate,
|
||||||
|
val predictionId: Long? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The contract every version of the engine satisfies.
|
||||||
|
*
|
||||||
|
* Deterministic for the same inputs and model version — PRODUCT_PLAN.md §11 —
|
||||||
|
* which is what makes the acceptance cases in §51 testable at all.
|
||||||
|
*/
|
||||||
|
interface PredictionEngine {
|
||||||
|
val modelVersion: String
|
||||||
|
|
||||||
|
fun predict(
|
||||||
|
confirmedStarts: List<LocalDate>,
|
||||||
|
today: LocalDate,
|
||||||
|
notYet: List<NotYetObservation> = emptyList(),
|
||||||
|
): Prediction?
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,203 @@
|
||||||
|
package dev.privacyllc.period.domain.prediction
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The acceptance cases from docs/planning/PRODUCT_PLAN.md §51, written against
|
||||||
|
* the interface rather than the implementation so they survive Batch 02
|
||||||
|
* replacing [BaselinePredictionEngine] with the real engine.
|
||||||
|
*
|
||||||
|
* These are the tests the product's headline claim rests on. A change that
|
||||||
|
* makes any of them fail is a core product defect, not a tuning regression:
|
||||||
|
* §3 names a 35-day user being predicted at 28 in exactly those words.
|
||||||
|
*/
|
||||||
|
class PredictionAcceptanceTest {
|
||||||
|
|
||||||
|
private val engine: PredictionEngine = BaselinePredictionEngine()
|
||||||
|
|
||||||
|
/** Build confirmed start dates from a run of cycle lengths, ending [ending]. */
|
||||||
|
private fun startsFromIntervals(vararg intervals: Long, ending: LocalDate): List<LocalDate> {
|
||||||
|
val dates = ArrayDeque<LocalDate>()
|
||||||
|
var cursor = ending
|
||||||
|
dates.addFirst(cursor)
|
||||||
|
for (gap in intervals.reversed()) {
|
||||||
|
cursor = cursor.minusDays(gap)
|
||||||
|
dates.addFirst(cursor)
|
||||||
|
}
|
||||||
|
return dates.toList()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun windowWidth(p: Prediction) =
|
||||||
|
p.windowEnd.toEpochDay() - p.windowStart.toEpochDay()
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// §51 — Stable longer-cycle user
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a stable 35-day user is not predicted at 28 days`() {
|
||||||
|
val lastStart = LocalDate.of(2026, 8, 1)
|
||||||
|
val starts = startsFromIntervals(35, 35, 34, 36, 35, ending = lastStart)
|
||||||
|
|
||||||
|
val p = engine.predict(starts, today = lastStart.plusDays(1))
|
||||||
|
assertNotNull(p)
|
||||||
|
val predictedInterval = p!!.mostLikelyStartDate.toEpochDay() - lastStart.toEpochDay()
|
||||||
|
|
||||||
|
assertTrue(
|
||||||
|
"forecast interval was $predictedInterval days; a 35-day user must not be predicted near 28",
|
||||||
|
predictedInterval in 34..36,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a stable user gets high confidence and a tight window`() {
|
||||||
|
val lastStart = LocalDate.of(2026, 8, 1)
|
||||||
|
val starts = startsFromIntervals(35, 35, 34, 36, 35, ending = lastStart)
|
||||||
|
|
||||||
|
val p = engine.predict(starts, today = lastStart.plusDays(1))!!
|
||||||
|
assertEquals(ConfidenceLabel.HIGH, p.confidenceLabel)
|
||||||
|
assertTrue("window was ${windowWidth(p)} days wide", windowWidth(p) <= 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// §51 — Variable user
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a variable user gets a wider window and lower confidence than a stable one`() {
|
||||||
|
val lastStart = LocalDate.of(2026, 8, 1)
|
||||||
|
val today = lastStart.plusDays(1)
|
||||||
|
|
||||||
|
val stable = engine.predict(startsFromIntervals(35, 35, 34, 36, 35, ending = lastStart), today)!!
|
||||||
|
val variable = engine.predict(startsFromIntervals(25, 34, 29, 37, 26, 32, ending = lastStart), today)!!
|
||||||
|
|
||||||
|
assertTrue(
|
||||||
|
"variable window ${windowWidth(variable)} was not wider than stable ${windowWidth(stable)}",
|
||||||
|
windowWidth(variable) > windowWidth(stable),
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
"variable confidence ${variable.confidenceScore} was not below stable ${stable.confidenceScore}",
|
||||||
|
variable.confidenceScore < stable.confidenceScore,
|
||||||
|
)
|
||||||
|
assertEquals(ConfidenceLabel.LOW, variable.confidenceLabel)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `confidence is agreement rather than volume`() {
|
||||||
|
// Six recorded cycles, all disagreeing. §15: do not assign High purely
|
||||||
|
// because the user has entered a large number of cycles.
|
||||||
|
val lastStart = LocalDate.of(2026, 8, 1)
|
||||||
|
val p = engine.predict(startsFromIntervals(25, 34, 29, 37, 26, 32, ending = lastStart), lastStart.plusDays(1))!!
|
||||||
|
assertFalse("six disagreeing cycles must not read as High", p.confidenceLabel == ConfidenceLabel.HIGH)
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// §51 — Outlier
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a single 45-day outlier does not dominate the forecast`() {
|
||||||
|
val lastStart = LocalDate.of(2026, 8, 1)
|
||||||
|
val starts = startsFromIntervals(29, 29, 28, 30, 45, 29, ending = lastStart)
|
||||||
|
|
||||||
|
val p = engine.predict(starts, today = lastStart.plusDays(1))!!
|
||||||
|
val predictedInterval = p.mostLikelyStartDate.toEpochDay() - lastStart.toEpochDay()
|
||||||
|
|
||||||
|
// The arithmetic mean of that history is ~31.7. A robust centre stays near 29.
|
||||||
|
assertTrue(
|
||||||
|
"forecast interval was $predictedInterval days; the 45-day observation dominated",
|
||||||
|
predictedInterval in 28..30,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// §51 — Not yet
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a not-yet date can no longer be a future start candidate`() {
|
||||||
|
val starts = listOf(
|
||||||
|
LocalDate.of(2026, 5, 28),
|
||||||
|
LocalDate.of(2026, 6, 26),
|
||||||
|
LocalDate.of(2026, 7, 25),
|
||||||
|
)
|
||||||
|
val today = LocalDate.of(2026, 8, 22)
|
||||||
|
|
||||||
|
val before = engine.predict(starts, today)!!
|
||||||
|
assertEquals(LocalDate.of(2026, 8, 22), before.windowStart)
|
||||||
|
|
||||||
|
val after = engine.predict(starts, today, listOf(NotYetObservation(date = today)))!!
|
||||||
|
assertTrue(
|
||||||
|
"Aug 22 was ruled out and is still in the window ${after.windowStart}..${after.windowEnd}",
|
||||||
|
after.windowStart.isAfter(today),
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
"the window must stay valid after re-conditioning",
|
||||||
|
!after.windowStart.isAfter(after.windowEnd),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a not-yet observation lowers confidence rather than only shifting the date`() {
|
||||||
|
val starts = listOf(
|
||||||
|
LocalDate.of(2026, 5, 28),
|
||||||
|
LocalDate.of(2026, 6, 26),
|
||||||
|
LocalDate.of(2026, 7, 25),
|
||||||
|
)
|
||||||
|
val today = LocalDate.of(2026, 8, 22)
|
||||||
|
|
||||||
|
val before = engine.predict(starts, today)!!
|
||||||
|
val after = engine.predict(starts, today, listOf(NotYetObservation(date = today)))!!
|
||||||
|
|
||||||
|
assertTrue(
|
||||||
|
"confidence did not fall: ${before.confidenceScore} -> ${after.confidenceScore}",
|
||||||
|
after.confidenceScore < before.confidenceScore,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Boundaries the UI will actually hit
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `no history produces no prediction rather than a confident guess`() {
|
||||||
|
assertNull(engine.predict(emptyList(), LocalDate.of(2026, 8, 18)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a single confirmed period predicts with the lowest confidence`() {
|
||||||
|
val p = engine.predict(listOf(LocalDate.of(2026, 8, 1)), LocalDate.of(2026, 8, 2))!!
|
||||||
|
assertEquals(ConfidenceLabel.LOW, p.confidenceLabel)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a window never opens in the past`() {
|
||||||
|
// A user who stopped logging months ago still gets a usable answer.
|
||||||
|
val starts = listOf(LocalDate.of(2026, 1, 1), LocalDate.of(2026, 1, 30))
|
||||||
|
val today = LocalDate.of(2026, 8, 18)
|
||||||
|
|
||||||
|
val p = engine.predict(starts, today)!!
|
||||||
|
assertFalse("window opened at ${p.windowStart}, before today", p.windowStart.isBefore(today))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the same input and model version give the same answer`() {
|
||||||
|
val starts = startsFromIntervals(29, 28, 30, 29, ending = LocalDate.of(2026, 8, 1))
|
||||||
|
val today = LocalDate.of(2026, 8, 18)
|
||||||
|
assertEquals(engine.predict(starts, today), engine.predict(starts, today))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `duplicate and unordered starts do not change the answer`() {
|
||||||
|
val ordered = startsFromIntervals(29, 28, 30, ending = LocalDate.of(2026, 8, 1))
|
||||||
|
val messy = (ordered + ordered.first() + ordered.last()).shuffled(kotlin.random.Random(7))
|
||||||
|
val today = LocalDate.of(2026, 8, 18)
|
||||||
|
assertEquals(engine.predict(ordered, today), engine.predict(messy, today))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||||
|
org.gradle.parallel=true
|
||||||
|
org.gradle.caching=true
|
||||||
|
org.gradle.configuration-cache=true
|
||||||
|
|
||||||
|
android.useAndroidX=true
|
||||||
|
android.nonTransitiveRClass=true
|
||||||
|
|
||||||
|
kotlin.code.style=official
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
# Versions verified against the official sources on 2026-08-18, not inherited
|
||||||
|
# from docs/planning/PRODUCT_PLAN.md's own numbers — which that document asks
|
||||||
|
# for explicitly. See docs/history/DEVELOPMENT_LOG.md for what was checked.
|
||||||
|
[versions]
|
||||||
|
agp = "9.3.1"
|
||||||
|
kotlin = "2.4.10"
|
||||||
|
ksp = "2.3.11"
|
||||||
|
composeBom = "2026.08.00"
|
||||||
|
coreKtx = "1.19.0"
|
||||||
|
activityCompose = "1.13.0"
|
||||||
|
lifecycle = "2.11.0"
|
||||||
|
navigationCompose = "2.9.8"
|
||||||
|
coroutines = "1.11.0"
|
||||||
|
hilt = "2.60.1"
|
||||||
|
hiltNavigationCompose = "1.4.0"
|
||||||
|
junit = "4.13.2"
|
||||||
|
androidxTestJunit = "1.3.0"
|
||||||
|
espresso = "3.7.0"
|
||||||
|
|
||||||
|
[libraries]
|
||||||
|
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||||
|
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
||||||
|
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" }
|
||||||
|
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
|
||||||
|
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
|
||||||
|
|
||||||
|
compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
|
||||||
|
compose-ui = { group = "androidx.compose.ui", name = "ui" }
|
||||||
|
compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
|
||||||
|
compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
|
||||||
|
compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
|
||||||
|
compose-material3 = { group = "androidx.compose.material3", name = "material3" }
|
||||||
|
compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
|
||||||
|
|
||||||
|
kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "coroutines" }
|
||||||
|
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" }
|
||||||
|
|
||||||
|
hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
|
||||||
|
hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" }
|
||||||
|
hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigationCompose" }
|
||||||
|
|
||||||
|
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
||||||
|
androidx-test-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxTestJunit" }
|
||||||
|
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso" }
|
||||||
|
|
||||||
|
[plugins]
|
||||||
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
|
android-library = { id = "com.android.library", version.ref = "agp" }
|
||||||
|
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
|
||||||
|
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||||
|
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
|
||||||
|
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
|
||||||
Binary file not shown.
|
|
@ -0,0 +1,10 @@
|
||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.0-bin.zip
|
||||||
|
networkTimeout=10000
|
||||||
|
retries=0
|
||||||
|
retryBackOffMs=500
|
||||||
|
validateDistributionUrl=true
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
|
distributionSha256Sum=84fbba45c7f4c64abc77460e1c00f541e9f960e3c7ed2538f1ede19eacd873ae
|
||||||
|
|
@ -0,0 +1,248 @@
|
||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
#
|
||||||
|
# Copyright © 2015 the original authors.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
|
#
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
#
|
||||||
|
# gradlew start up script for POSIX generated by Gradle.
|
||||||
|
#
|
||||||
|
# Important for running:
|
||||||
|
#
|
||||||
|
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||||
|
# noncompliant, but you have some other compliant shell such as ksh or
|
||||||
|
# bash, then to run this script, type that shell name before the whole
|
||||||
|
# command line, like:
|
||||||
|
#
|
||||||
|
# ksh gradlew
|
||||||
|
#
|
||||||
|
# Busybox and similar reduced shells will NOT work, because this script
|
||||||
|
# requires all of these POSIX shell features:
|
||||||
|
# * functions;
|
||||||
|
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||||
|
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||||
|
# * compound commands having a testable exit status, especially «case»;
|
||||||
|
# * various built-in commands including «command», «set», and «ulimit».
|
||||||
|
#
|
||||||
|
# Important for patching:
|
||||||
|
#
|
||||||
|
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||||
|
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||||
|
#
|
||||||
|
# The "traditional" practice of packing multiple parameters into a
|
||||||
|
# space-separated string is a well documented source of bugs and security
|
||||||
|
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||||
|
# options in "$@", and eventually passing that to Java.
|
||||||
|
#
|
||||||
|
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||||
|
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||||
|
# see the in-line comments for details.
|
||||||
|
#
|
||||||
|
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||||
|
# Darwin, MinGW, and NonStop.
|
||||||
|
#
|
||||||
|
# (3) This script is generated from the Groovy template
|
||||||
|
# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||||
|
# within the Gradle project.
|
||||||
|
#
|
||||||
|
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||||
|
#
|
||||||
|
##############################################################################
|
||||||
|
|
||||||
|
# Attempt to set APP_HOME
|
||||||
|
|
||||||
|
# Resolve links: $0 may be a link
|
||||||
|
app_path=$0
|
||||||
|
|
||||||
|
# Need this for daisy-chained symlinks.
|
||||||
|
while
|
||||||
|
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||||
|
[ -h "$app_path" ]
|
||||||
|
do
|
||||||
|
ls=$( ls -ld "$app_path" )
|
||||||
|
link=${ls#*' -> '}
|
||||||
|
case $link in #(
|
||||||
|
/*) app_path=$link ;; #(
|
||||||
|
*) app_path=$APP_HOME$link ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# This is normally unused
|
||||||
|
# shellcheck disable=SC2034
|
||||||
|
APP_BASE_NAME=${0##*/}
|
||||||
|
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||||
|
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||||
|
|
||||||
|
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||||
|
MAX_FD=maximum
|
||||||
|
|
||||||
|
warn () {
|
||||||
|
echo "$*"
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
die () {
|
||||||
|
echo
|
||||||
|
echo "$*"
|
||||||
|
echo
|
||||||
|
exit 1
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
# OS specific support (must be 'true' or 'false').
|
||||||
|
cygwin=false
|
||||||
|
msys=false
|
||||||
|
darwin=false
|
||||||
|
nonstop=false
|
||||||
|
case "$( uname )" in #(
|
||||||
|
CYGWIN* ) cygwin=true ;; #(
|
||||||
|
Darwin* ) darwin=true ;; #(
|
||||||
|
MSYS* | MINGW* ) msys=true ;; #(
|
||||||
|
NONSTOP* ) nonstop=true ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Determine the Java command to use to start the JVM.
|
||||||
|
if [ -n "$JAVA_HOME" ] ; then
|
||||||
|
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||||
|
# IBM's JDK on AIX uses strange locations for the executables
|
||||||
|
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||||
|
else
|
||||||
|
JAVACMD=$JAVA_HOME/bin/java
|
||||||
|
fi
|
||||||
|
if [ ! -x "$JAVACMD" ] ; then
|
||||||
|
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
JAVACMD=java
|
||||||
|
if ! command -v java >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Increase the maximum file descriptors if we can.
|
||||||
|
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||||
|
case $MAX_FD in #(
|
||||||
|
max*)
|
||||||
|
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
MAX_FD=$( ulimit -H -n ) ||
|
||||||
|
warn "Could not query maximum file descriptor limit"
|
||||||
|
esac
|
||||||
|
case $MAX_FD in #(
|
||||||
|
'' | soft) :;; #(
|
||||||
|
*)
|
||||||
|
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
ulimit -n "$MAX_FD" ||
|
||||||
|
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Collect all arguments for the java command, stacking in reverse order:
|
||||||
|
# * args from the command line
|
||||||
|
# * the main class name
|
||||||
|
# * -classpath
|
||||||
|
# * -D...appname settings
|
||||||
|
# * --module-path (only if needed)
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||||
|
|
||||||
|
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||||
|
if "$cygwin" || "$msys" ; then
|
||||||
|
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||||
|
|
||||||
|
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||||
|
|
||||||
|
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||||
|
for arg do
|
||||||
|
if
|
||||||
|
case $arg in #(
|
||||||
|
-*) false ;; # don't mess with options #(
|
||||||
|
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||||
|
[ -e "$t" ] ;; #(
|
||||||
|
*) false ;;
|
||||||
|
esac
|
||||||
|
then
|
||||||
|
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||||
|
fi
|
||||||
|
# Roll the args list around exactly as many times as the number of
|
||||||
|
# args, so each arg winds up back in the position where it started, but
|
||||||
|
# possibly modified.
|
||||||
|
#
|
||||||
|
# NB: a `for` loop captures its iteration list before it begins, so
|
||||||
|
# changing the positional parameters here affects neither the number of
|
||||||
|
# iterations, nor the values presented in `arg`.
|
||||||
|
shift # remove old arg
|
||||||
|
set -- "$@" "$arg" # push replacement arg
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||||
|
|
||||||
|
# Collect all arguments for the java command:
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||||
|
# and any embedded shellness will be escaped.
|
||||||
|
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||||
|
# treated as '${Hostname}' itself on the command line.
|
||||||
|
|
||||||
|
set -- \
|
||||||
|
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||||
|
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||||
|
"$@"
|
||||||
|
|
||||||
|
# Stop when "xargs" is not available.
|
||||||
|
if ! command -v xargs >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "xargs is not available"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Use "xargs" to parse quoted args.
|
||||||
|
#
|
||||||
|
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||||
|
#
|
||||||
|
# In Bash we could simply go:
|
||||||
|
#
|
||||||
|
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||||
|
# set -- "${ARGS[@]}" "$@"
|
||||||
|
#
|
||||||
|
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||||
|
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||||
|
# character that might be a shell metacharacter, then use eval to reverse
|
||||||
|
# that process (while maintaining the separation between arguments), and wrap
|
||||||
|
# the whole thing up as a single "set" statement.
|
||||||
|
#
|
||||||
|
# This will of course break if any of these variables contains a newline or
|
||||||
|
# an unmatched quote.
|
||||||
|
#
|
||||||
|
|
||||||
|
eval "set -- $(
|
||||||
|
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||||
|
xargs -n1 |
|
||||||
|
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||||
|
tr '\n' ' '
|
||||||
|
)" '"$@"'
|
||||||
|
|
||||||
|
exec "$JAVACMD" "$@"
|
||||||
|
|
@ -0,0 +1,82 @@
|
||||||
|
@rem
|
||||||
|
@rem Copyright 2015 the original author or authors.
|
||||||
|
@rem
|
||||||
|
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
@rem you may not use this file except in compliance with the License.
|
||||||
|
@rem You may obtain a copy of the License at
|
||||||
|
@rem
|
||||||
|
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
@rem
|
||||||
|
@rem Unless required by applicable law or agreed to in writing, software
|
||||||
|
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
@rem See the License for the specific language governing permissions and
|
||||||
|
@rem limitations under the License.
|
||||||
|
@rem
|
||||||
|
@rem SPDX-License-Identifier: Apache-2.0
|
||||||
|
@rem
|
||||||
|
|
||||||
|
@if "%DEBUG%"=="" @echo off
|
||||||
|
@rem ##########################################################################
|
||||||
|
@rem
|
||||||
|
@rem gradlew startup script for Windows
|
||||||
|
@rem
|
||||||
|
@rem ##########################################################################
|
||||||
|
|
||||||
|
@rem Set local scope for the variables, and ensure extensions are enabled
|
||||||
|
setlocal EnableExtensions
|
||||||
|
|
||||||
|
set DIRNAME=%~dp0
|
||||||
|
if "%DIRNAME%"=="" set DIRNAME=.
|
||||||
|
@rem This is normally unused
|
||||||
|
set APP_BASE_NAME=%~n0
|
||||||
|
set APP_HOME=%DIRNAME%
|
||||||
|
|
||||||
|
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||||
|
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||||
|
|
||||||
|
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||||
|
|
||||||
|
@rem Find java.exe
|
||||||
|
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||||
|
|
||||||
|
set JAVA_EXE=java.exe
|
||||||
|
%JAVA_EXE% -version >NUL 2>&1
|
||||||
|
if %ERRORLEVEL% equ 0 goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
"%COMSPEC%" /c exit 1
|
||||||
|
|
||||||
|
:findJavaFromJavaHome
|
||||||
|
set JAVA_HOME=%JAVA_HOME:"=%
|
||||||
|
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||||
|
|
||||||
|
if exist "%JAVA_EXE%" goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
"%COMSPEC%" /c exit 1
|
||||||
|
|
||||||
|
:execute
|
||||||
|
@rem Setup the command line
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@rem Execute gradlew
|
||||||
|
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
|
||||||
|
@rem which allows us to clear the local environment before executing the java command
|
||||||
|
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
|
||||||
|
|
||||||
|
:exitWithErrorLevel
|
||||||
|
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
|
||||||
|
"%COMSPEC%" /c exit %ERRORLEVEL%
|
||||||
|
|
@ -0,0 +1,120 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Commit only the paths you name, when something else is also writing the tree.
|
||||||
|
#
|
||||||
|
# ## The failure this catches
|
||||||
|
#
|
||||||
|
# Two agents, or an agent and a person, sharing one checkout share one **git
|
||||||
|
# index**. Staging is global state, and the window between staging a change and
|
||||||
|
# committing it is however long it takes to write the commit message. Anything
|
||||||
|
# that runs `git add -A` inside that window takes your files with it.
|
||||||
|
#
|
||||||
|
# Two real instances, one afternoon, one repository — both while the rule
|
||||||
|
# "stage by explicit path, and re-check the index immediately before committing"
|
||||||
|
# was being followed to the letter:
|
||||||
|
#
|
||||||
|
# - A commit about reviving a game carried two unrelated documentation edits.
|
||||||
|
# - A commit about art carried an entire renderer fix, a new class, its test
|
||||||
|
# and two documents. Its message had no `closes #N`, so the issue that work
|
||||||
|
# finished stayed open and had to be closed by hand afterwards.
|
||||||
|
#
|
||||||
|
# Nothing was lost either time. The attribution was wrong, and once the tracker
|
||||||
|
# was wrong with it. **The rule is not the fix, because the danger is the
|
||||||
|
# window** — so this closes the window instead: the message is written first and
|
||||||
|
# passed in, staging and scanning and committing happen back to back, and the
|
||||||
|
# commit itself names its paths.
|
||||||
|
#
|
||||||
|
# ## Why a pathspec commit rather than unstaging theirs
|
||||||
|
#
|
||||||
|
# `git commit -- <paths>` takes the working-tree content of exactly those paths
|
||||||
|
# and ignores the rest of the index. The other writer's staged work is neither
|
||||||
|
# swept into your commit nor removed from their index, so there is no step here
|
||||||
|
# that can break *their* commit either. What they have staged is reported, so you
|
||||||
|
# know somebody else is mid-flight before you add to the race.
|
||||||
|
#
|
||||||
|
# ## Usage
|
||||||
|
#
|
||||||
|
# bash scripts/commit-mine.sh <message-file> <path> [path…]
|
||||||
|
# bash scripts/commit-mine.sh --push <message-file> <path> [path…]
|
||||||
|
#
|
||||||
|
# ## Exit codes
|
||||||
|
#
|
||||||
|
# 0 committed (and pushed, with `--push`)
|
||||||
|
# 1 the secret scan objected, or a push left something unpushed
|
||||||
|
# 2 nothing was committed: bad arguments, or a path that does not exist.
|
||||||
|
# **Two is not a pass**
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
SECRETS="$HERE/secrets.sh"
|
||||||
|
|
||||||
|
PUSH="no"
|
||||||
|
if [ "${1:-}" = "--push" ]; then PUSH="yes"; shift; fi
|
||||||
|
|
||||||
|
if [ "$#" -lt 2 ]; then
|
||||||
|
sed -n '2,45p' "$0" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
MESSAGE_FILE="$1"; shift
|
||||||
|
[ -f "$MESSAGE_FILE" ] || { echo "commit-mine: no message file: $MESSAGE_FILE" >&2; exit 2; }
|
||||||
|
|
||||||
|
git rev-parse --is-inside-work-tree >/dev/null 2>&1 || {
|
||||||
|
echo "commit-mine: not inside a git repository" >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
|
||||||
|
for path in "$@"; do
|
||||||
|
[ -e "$path" ] || { echo "commit-mine: no such path: $path" >&2; exit 2; }
|
||||||
|
done
|
||||||
|
|
||||||
|
# Whatever anyone else has in flight. Not an error and not touched — but you
|
||||||
|
# should see it before committing into the same index.
|
||||||
|
OTHERS="$(git diff --cached --name-only | grep -vxF -f <(printf '%s\n' "$@") || true)"
|
||||||
|
if [ -n "$OTHERS" ]; then
|
||||||
|
echo "commit-mine: NOTE — the index also holds work that is not yours:"
|
||||||
|
printf ' %s\n' $OTHERS
|
||||||
|
echo " (left staged, and left out of this commit)"
|
||||||
|
echo
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Staged only so the scanner sees exactly these paths, and only for as long as
|
||||||
|
# the scan takes. A credential is the one mistake here that cannot be undone by
|
||||||
|
# a later commit.
|
||||||
|
git add -- "$@"
|
||||||
|
if [ -x "$SECRETS" ] || [ -f "$SECRETS" ]; then
|
||||||
|
set +e
|
||||||
|
bash "$SECRETS"
|
||||||
|
SCAN=$?
|
||||||
|
set -e
|
||||||
|
# 2 means it scanned nothing, which the convention in this template treats as
|
||||||
|
# a failure rather than a pass — a scanner that did not run has not cleared
|
||||||
|
# anything.
|
||||||
|
if [ "$SCAN" -ne 0 ]; then
|
||||||
|
git restore --staged -- "$@"
|
||||||
|
echo "commit-mine: secret scan exited $SCAN; nothing committed" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "commit-mine: WARNING — $SECRETS not found, committing unscanned" >&2
|
||||||
|
fi
|
||||||
|
|
||||||
|
git commit -F "$MESSAGE_FILE" -- "$@"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "commit-mine: committed"
|
||||||
|
git show --stat --oneline HEAD | head -20
|
||||||
|
|
||||||
|
if [ "$PUSH" = "yes" ]; then
|
||||||
|
BRANCH="$(git rev-parse --abbrev-ref HEAD)"
|
||||||
|
git push origin "$BRANCH"
|
||||||
|
# Finished work is pushed work. A local commit is invisible to everything that
|
||||||
|
# reports on the project, so the push is verified rather than assumed.
|
||||||
|
UNPUSHED="$(git log --oneline "origin/$BRANCH..HEAD" 2>/dev/null || true)"
|
||||||
|
if [ -n "$UNPUSHED" ]; then
|
||||||
|
echo "commit-mine: WARNING — still unpushed after push:" >&2
|
||||||
|
echo "$UNPUSHED" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "commit-mine: pushed; origin/$BRANCH..HEAD is empty"
|
||||||
|
fi
|
||||||
|
|
@ -0,0 +1,303 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Every file a document names must exist.
|
||||||
|
#
|
||||||
|
# ## The failure this catches
|
||||||
|
#
|
||||||
|
# Documentation makes claims about code, and the claims rot silently because
|
||||||
|
# nothing executes them. Three real instances, all found by hand in one
|
||||||
|
# afternoon on one repository:
|
||||||
|
#
|
||||||
|
# - A source comment stating that `tests/notice-security.test.ts` pinned a
|
||||||
|
# security rule. **That file had never existed.** The rule was real and
|
||||||
|
# enforced by nothing, and the sentence had been reassuring every reader who
|
||||||
|
# checked for two batches.
|
||||||
|
# - A reference manual whose migration table stopped at 0050 while the
|
||||||
|
# repository was at 0056. Six migrations behind, and every reader in between
|
||||||
|
# trusted it.
|
||||||
|
# - A manual promising a watchdog the code structurally could not fire.
|
||||||
|
#
|
||||||
|
# The first two are mechanically checkable and this checks them. The third is
|
||||||
|
# not — a claim about behaviour needs a person or a test — which is worth
|
||||||
|
# knowing about this script's limits: **it proves a path exists, never that the
|
||||||
|
# sentence around it is true.**
|
||||||
|
#
|
||||||
|
# ## What it looks at
|
||||||
|
#
|
||||||
|
# Anything that looks like a repository path inside backticks or a markdown
|
||||||
|
# link, in the files you point it at. A path is checked when it looks like one:
|
||||||
|
# it contains a slash or a known source extension, and it is not a URL, not a
|
||||||
|
# glob, and not obviously prose.
|
||||||
|
#
|
||||||
|
# bash scripts/doc-claims.sh # every tracked .md
|
||||||
|
# bash scripts/doc-claims.sh docs/ # one tree
|
||||||
|
# bash scripts/doc-claims.sh README.md # one file
|
||||||
|
# DOC_CLAIMS_ALSO_SRC=1 bash scripts/doc-claims.sh # also scan source comments
|
||||||
|
# DOC_CLAIMS_EXCLUDE='' bash scripts/doc-claims.sh # include forward-looking specs
|
||||||
|
#
|
||||||
|
# ## The inverse: is everything that exists written down?
|
||||||
|
#
|
||||||
|
# bash scripts/doc-claims.sh --covers src/db/migrations --in docs/MANUAL.md
|
||||||
|
#
|
||||||
|
# The check above asks whether every path a document *names* exists. This asks
|
||||||
|
# whether every file that exists is *named* — and it is the one that actually
|
||||||
|
# bit. A reference manual's migration table stopped at 0050 while the repository
|
||||||
|
# was at 0056: six rows missing, every path in the document perfectly valid, and
|
||||||
|
# no existence check can see an absent row.
|
||||||
|
#
|
||||||
|
# `docs/proposed/` is skipped by default: a specification naming the files it
|
||||||
|
# would create is not a claim that they exist.
|
||||||
|
#
|
||||||
|
# Exit codes: 0 every named path exists. 1 at least one does not. 2 nothing was
|
||||||
|
# scanned, which is not a pass — an empty run and a clean run must not look the
|
||||||
|
# same, for the same reason `verify.sh` refuses to report zero checks as green.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
# --covers <dir> --in <doc>: every file in <dir> must be mentioned in <doc>.
|
||||||
|
COVERS=""
|
||||||
|
COVERS_IN=""
|
||||||
|
ARGS=()
|
||||||
|
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--covers) COVERS="${2:-}"; shift 2 ;;
|
||||||
|
--in) COVERS_IN="${2:-}"; shift 2 ;;
|
||||||
|
*) ARGS+=("$1"); shift ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
set -- "${ARGS[@]+"${ARGS[@]}"}"
|
||||||
|
|
||||||
|
cd "$(git rev-parse --show-toplevel 2>/dev/null)" || {
|
||||||
|
printf 'doc-claims: not a git repository.\n' >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
|
||||||
|
say() { printf 'doc-claims: %s\n' "$*" >&2; }
|
||||||
|
|
||||||
|
if [ -n "$COVERS" ]; then
|
||||||
|
[ -n "$COVERS_IN" ] || { say "--covers needs --in <document>"; exit 2; }
|
||||||
|
[ -d "$COVERS" ] || { say "--covers: $COVERS is not a directory"; exit 2; }
|
||||||
|
[ -f "$COVERS_IN" ] || { say "--in: $COVERS_IN does not exist"; exit 2; }
|
||||||
|
|
||||||
|
unmentioned=0
|
||||||
|
total=0
|
||||||
|
doc_body="$(cat "$COVERS_IN")"
|
||||||
|
|
||||||
|
for entry in "$COVERS"/*; do
|
||||||
|
[ -e "$entry" ] || continue
|
||||||
|
|
||||||
|
total=$((total + 1))
|
||||||
|
name="$(basename "$entry")"
|
||||||
|
stem="${name%.*}"
|
||||||
|
prefix="${stem%%_*}"
|
||||||
|
|
||||||
|
# Three spellings, because documents legitimately use all of them and
|
||||||
|
# demanding the longest reports a perfectly correct document as broken.
|
||||||
|
# The first draft of this checked only the full name and the stem, and
|
||||||
|
# reported 57 of 58 migrations missing from a table that lists every one —
|
||||||
|
# because that table writes `0057`, not `0057_digest_recipients.sql`.
|
||||||
|
#
|
||||||
|
# The prefix is only accepted when it is distinctive: a bare `0057` is, a
|
||||||
|
# bare `route` would not be, and matching on the latter would let a document
|
||||||
|
# pass by coincidence.
|
||||||
|
case "$doc_body" in
|
||||||
|
*"$name"*|*"$stem"*) continue ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
case "$prefix" in
|
||||||
|
"$stem") ;; # no underscore; nothing new to try
|
||||||
|
[0-9][0-9][0-9]*|v[0-9]*)
|
||||||
|
case "$doc_body" in *"$prefix"*) continue ;; esac ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
printf '%s: %s is not mentioned in %s\n' "$COVERS" "$name" "$COVERS_IN"
|
||||||
|
unmentioned=$((unmentioned + 1))
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$total" -eq 0 ]; then
|
||||||
|
say "$COVERS is empty; nothing to cover."
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$unmentioned" -gt 0 ]; then
|
||||||
|
say "$unmentioned of $total entr(ies) in $COVERS are absent from $COVERS_IN."
|
||||||
|
say "A list that is missing rows reads as complete — that is the whole"
|
||||||
|
say "problem with it."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
say "all $total entr(ies) in $COVERS are mentioned in $COVERS_IN."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
TARGETS=("$@")
|
||||||
|
|
||||||
|
if [ ${#TARGETS[@]} -eq 0 ]; then
|
||||||
|
# Tracked files only. An untracked scratch document is not a claim this
|
||||||
|
# repository is making.
|
||||||
|
mapfile -t FILES < <(git ls-files '*.md')
|
||||||
|
else
|
||||||
|
mapfile -t FILES < <(git ls-files "${TARGETS[@]}" | grep -E '\.md$')
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "${DOC_CLAIMS_ALSO_SRC:-}" ]; then
|
||||||
|
# Source comments make the same claims and rot the same way — the missing
|
||||||
|
# test file above was named in a docblock, not in a document.
|
||||||
|
mapfile -t -O "${#FILES[@]}" FILES < <(git ls-files '*.ts' '*.tsx' '*.mjs' '*.js' '*.sh')
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Forward-looking documents are excluded, and this is the difference between a
|
||||||
|
# useful run and a noisy one. A specification naming the files it *would* create
|
||||||
|
# is not a rotted claim — it is the whole point of a specification. On the
|
||||||
|
# repository this was written against, every finding under `docs/proposed/` was
|
||||||
|
# of that kind and they outnumbered the real ones four to one.
|
||||||
|
# Two kinds of tree are excluded by default, and both for the same reason: the
|
||||||
|
# paths in them do not resolve against *this* repository.
|
||||||
|
#
|
||||||
|
# docs/proposed/ a specification naming the files it would create
|
||||||
|
# project-template/ a vendored copy of another project's docs, whose paths
|
||||||
|
# vendor/ resolve against whatever scaffolds from it
|
||||||
|
#
|
||||||
|
# The template copy alone accounted for 14 of 33 findings on the repository this
|
||||||
|
# was written against — every one of them a README correctly describing scripts
|
||||||
|
# that live in the template folder rather than here.
|
||||||
|
EXCLUDE="${DOC_CLAIMS_EXCLUDE:-docs/proposed/|project-template/|vendor/}"
|
||||||
|
|
||||||
|
if [ -n "$EXCLUDE" ]; then
|
||||||
|
mapfile -t FILES < <(printf '%s\n' "${FILES[@]}" | grep -vE "$EXCLUDE" || true)
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ${#FILES[@]} -eq 0 ]; then
|
||||||
|
say "no files to scan."
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
# A token worth checking, and the filter matters more than the check.
|
||||||
|
#
|
||||||
|
# The first draft flagged 684 of 1142 tokens on a real repository — routes like
|
||||||
|
# `/agent/notices`, absolute paths like `~/.config/thing`, and bare filenames.
|
||||||
|
# A guard that is wrong six times in ten is one people learn to skip, so the
|
||||||
|
# rule is now deliberately narrow: **a token is only checked when its first
|
||||||
|
# segment is something that actually exists at the top of this repository.**
|
||||||
|
#
|
||||||
|
# That excludes URL routes (their first segment is empty), home-relative paths,
|
||||||
|
# and prose, and it means a genuinely missing path is reported against a
|
||||||
|
# background of near-silence.
|
||||||
|
#
|
||||||
|
# Bare filenames with a source extension — `api-handler.ts` — are resolved by
|
||||||
|
# basename anywhere in the tree, which is what a reader would do.
|
||||||
|
mapfile -t TOPLEVEL < <(git ls-tree --name-only HEAD)
|
||||||
|
|
||||||
|
is_toplevel() {
|
||||||
|
local first="${1%%/*}"
|
||||||
|
local entry
|
||||||
|
for entry in "${TOPLEVEL[@]}"; do
|
||||||
|
[ "$first" = "$entry" ] && return 0
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
worth_checking() {
|
||||||
|
case "$1" in
|
||||||
|
http://*|https://*|*@*) return 1 ;; # links and addresses
|
||||||
|
/*|~*|.*) return 1 ;; # routes, home paths, relative noise
|
||||||
|
*\**|*\?*|*'<'*|*'>'*|*' '*) return 1 ;; # globs, placeholders, prose
|
||||||
|
"") return 1 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# A path into this repository.
|
||||||
|
is_toplevel "$1" && return 0
|
||||||
|
|
||||||
|
# Or a bare source filename, resolved by basename below.
|
||||||
|
case "$1" in
|
||||||
|
*/*) return 1 ;;
|
||||||
|
*.ts|*.tsx|*.mjs|*.js|*.sh|*.py|*.sql|*.md) return 0 ;;
|
||||||
|
*) return 1 ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
resolves() {
|
||||||
|
local token="$1" doc="$2"
|
||||||
|
|
||||||
|
[ -e "$token" ] && return 0
|
||||||
|
[ -e "$(dirname "$doc")/$token" ] && return 0
|
||||||
|
|
||||||
|
# Bare filename: does anything in the repository carry that basename?
|
||||||
|
case "$token" in
|
||||||
|
*/*) return 1 ;;
|
||||||
|
*) git ls-files "*/$token" "$token" | grep -q . && return 0 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
missing=0
|
||||||
|
checked=0
|
||||||
|
notes=0
|
||||||
|
|
||||||
|
for file in "${FILES[@]}"; do
|
||||||
|
[ -f "$file" ] || continue
|
||||||
|
|
||||||
|
# Backticked spans and markdown link targets. Line numbers and anchors are
|
||||||
|
# trimmed: `src/lib/foo.ts:42` and `foo.md#heading` name a real file.
|
||||||
|
while IFS= read -r raw; do
|
||||||
|
token="${raw%%:*}"
|
||||||
|
token="${token%%#*}"
|
||||||
|
token="${token%/}"
|
||||||
|
|
||||||
|
worth_checking "$token" || continue
|
||||||
|
|
||||||
|
checked=$((checked + 1))
|
||||||
|
|
||||||
|
resolves "$token" "$file" && continue
|
||||||
|
|
||||||
|
# A bare filename is a weaker claim than a path, and is reported without
|
||||||
|
# failing the run.
|
||||||
|
#
|
||||||
|
# `docs/qa/ClaudeReport.md` asserts something about THIS repository. But
|
||||||
|
# `release.sh` in prose is usually a reference to a script the template
|
||||||
|
# offers and this project may not have adopted yet -- scaffold.sh
|
||||||
|
# deliberately ships no scripts, and TOOLS.md says so: "the table is a menu
|
||||||
|
# rather than an inventory here". Treating those as failures made every
|
||||||
|
# freshly scaffolded project start with a red guard, over documents that
|
||||||
|
# were correct, and a gate that is red from day one is one nobody reads.
|
||||||
|
#
|
||||||
|
# The strictness that matters is untouched. The finding this script was
|
||||||
|
# written for -- a comment claiming `tests/notice-security.test.ts` pinned a
|
||||||
|
# security rule, for a file that had never existed -- is a path, and paths
|
||||||
|
# still fail.
|
||||||
|
case "$token" in
|
||||||
|
*/*) ;;
|
||||||
|
*) printf '%s: mentions %s, which is not in this repository (yet)\n' "$file" "$token"
|
||||||
|
notes=$((notes + 1))
|
||||||
|
continue ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
printf '%s: names %s, which does not exist\n' "$file" "$token"
|
||||||
|
missing=$((missing + 1))
|
||||||
|
done < <(grep -oE '`[^`]+`|\]\([^)]+\)' "$file" 2>/dev/null \
|
||||||
|
| sed -E 's/^`//; s/`$//; s/^\]\(//; s/\)$//')
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$checked" -eq 0 ]; then
|
||||||
|
say "scanned ${#FILES[@]} file(s) and found no paths to check."
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$notes" -gt 0 ]; then
|
||||||
|
say "$notes bare filename(s) above are mentioned but not present. Not a"
|
||||||
|
say "failure: a project adopts the scripts it needs one at a time, and the"
|
||||||
|
say "documents naming them are a menu rather than an inventory."
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$missing" -gt 0 ]; then
|
||||||
|
say "$missing claimed path(s) do not exist, of $checked checked."
|
||||||
|
say "A document naming a file that is not there is worse than one saying"
|
||||||
|
say "nothing: somebody checked, and was reassured."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
say "$checked claimed path(s), all present, across ${#FILES[@]} file(s)."
|
||||||
|
|
@ -0,0 +1,470 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Which documents does a change fire, while there is still time to update them?
|
||||||
|
|
||||||
|
python3 scripts/doc-triggers.py # everything dirty right now
|
||||||
|
python3 scripts/doc-triggers.py --staged # what is staged
|
||||||
|
python3 scripts/doc-triggers.py <path> [path…] # specific paths
|
||||||
|
python3 scripts/doc-triggers.py --range HEAD~3..HEAD
|
||||||
|
|
||||||
|
## The failure this catches
|
||||||
|
|
||||||
|
Every document in this tree carries a `Review trigger:` — the change that should
|
||||||
|
send somebody back to it — and `WORK_CYCLE.md` requires the triggered documents
|
||||||
|
to be updated *in the same commit as the code*. Deciding which fired means
|
||||||
|
reading every `Governs:` line and matching globs in your head, once per commit.
|
||||||
|
It is a check with no output of its own, so it is the one that gets skipped when
|
||||||
|
the code is already green, and the cost is invisible until a reader trusts a
|
||||||
|
document that stopped being true: a reference manual six migrations behind, and
|
||||||
|
every reader in between believing it.
|
||||||
|
|
||||||
|
**This is not the doc-review check.** That one asks which baselined documents are
|
||||||
|
*overdue*, from committed history — a governed path with a commit newer than the
|
||||||
|
document's `Last reviewed` date. It is a different question and it can only be
|
||||||
|
asked after the fact. By the time a change is committed without its document, the
|
||||||
|
thing this catches has already happened.
|
||||||
|
|
||||||
|
Exit status is always 0. This is a prompt, not a gate: a trigger asks a human
|
||||||
|
whether the prose is still true, and a check that failed the build for that would
|
||||||
|
be bumped past rather than read.
|
||||||
|
|
||||||
|
## Matching the trigger's verb, not only its glob
|
||||||
|
|
||||||
|
A document is fired when a changed path matches its `Governs:` **and** the kind
|
||||||
|
of change matches its `Fires on:`. That second field is optional and almost
|
||||||
|
never needed; it exists for the case where `Governs:` is far broader than the
|
||||||
|
trigger. `DOC_TRUST_MAP.md` is the extreme — it governs `docs/**` while its
|
||||||
|
trigger is *any doc added, deleted or moved* — so on the glob alone it fired on
|
||||||
|
every edit to every document, forever, and correctly by the only rule there was.
|
||||||
|
A prompt that always fires is one people stop reading, and this one exits 0 by
|
||||||
|
design, so nothing forces the reading.
|
||||||
|
|
||||||
|
The kinds are `added`, `deleted`, `moved` and `changed`, read from git's own
|
||||||
|
status letter. Absent, empty or unparseable means every kind, which is the
|
||||||
|
behaviour every document without the line still has.
|
||||||
|
|
||||||
|
## Two things it deliberately does not do
|
||||||
|
|
||||||
|
**It does not read `Exempt:` declarations.** Those mark a *required* document as
|
||||||
|
deliberately absent from a repository, and a document that does not exist cannot
|
||||||
|
govern a path. There is nothing here for them to change.
|
||||||
|
|
||||||
|
**It cannot fire a document whose `Governs:` is prose.** Several in this template
|
||||||
|
govern a subject rather than a set of paths — `GUARDS.md` governs "structural
|
||||||
|
tests, source-grep assertions, probes, and any check whose passing is taken as
|
||||||
|
evidence", which is the honest description and matches no glob. Those documents
|
||||||
|
are listed separately at the end rather than silently ignored, because a reader
|
||||||
|
who sees only the matched list would reasonably conclude the others were checked
|
||||||
|
and cleared.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import fnmatch
|
||||||
|
import pathlib
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
def _find_root() -> pathlib.Path:
|
||||||
|
"""The repository root, found rather than assumed.
|
||||||
|
|
||||||
|
This was `parents[3]`, which is correct only while the script sits at
|
||||||
|
`docs/architecture/scripts/` — its home in the template. The moment a
|
||||||
|
project copies it to `scripts/`, as the template's own adoption
|
||||||
|
instructions say to, `parents[3]` climbs out of the repository entirely: in
|
||||||
|
a checkout at `~/Projects/thing/scripts/`, it resolves to `~/`, and the
|
||||||
|
script reports "no docs/ directory" about a directory two levels above the
|
||||||
|
project it was run in.
|
||||||
|
|
||||||
|
So: walk up from the script looking for a directory that has both `docs/`
|
||||||
|
and `.git`, then fall back to either alone, then to git's own answer.
|
||||||
|
"""
|
||||||
|
here = pathlib.Path(__file__).resolve()
|
||||||
|
|
||||||
|
for parent in here.parents:
|
||||||
|
if (parent / "docs").is_dir() and (parent / ".git").exists():
|
||||||
|
return parent
|
||||||
|
|
||||||
|
for parent in here.parents:
|
||||||
|
if (parent / "docs").is_dir():
|
||||||
|
return parent
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "rev-parse", "--show-toplevel"],
|
||||||
|
cwd=here.parent,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode == 0 and result.stdout.strip():
|
||||||
|
return pathlib.Path(result.stdout.strip())
|
||||||
|
|
||||||
|
return here.parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = _find_root()
|
||||||
|
DOCS = ROOT / "docs"
|
||||||
|
|
||||||
|
# The status header is a fenced block immediately after the H1, and a long value
|
||||||
|
# wraps onto continuation lines indented by two spaces:
|
||||||
|
#
|
||||||
|
# Governs: structural tests, source-grep assertions, probes, and any check whose
|
||||||
|
# passing is taken as evidence
|
||||||
|
#
|
||||||
|
# A regex that reads one line per field — the obvious implementation — truncates
|
||||||
|
# at the wrap and silently under-reports, which for this tool means quietly
|
||||||
|
# failing to name a document that should have been updated. So fields are
|
||||||
|
# assembled line by line instead.
|
||||||
|
FIELD_START = re.compile(
|
||||||
|
r"^(Status|Owner|Last reviewed|Governs|Review trigger|Fires on):\s*(.*)$"
|
||||||
|
)
|
||||||
|
HEADER_LINES = 16
|
||||||
|
|
||||||
|
|
||||||
|
def header_of(doc: pathlib.Path) -> dict[str, str]:
|
||||||
|
"""The status header, with wrapped values joined."""
|
||||||
|
try:
|
||||||
|
lines = doc.read_text(encoding="utf-8").splitlines()[:HEADER_LINES]
|
||||||
|
except (OSError, UnicodeDecodeError):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
fields: dict[str, str] = {}
|
||||||
|
current: str | None = None
|
||||||
|
for line in lines:
|
||||||
|
match = FIELD_START.match(line)
|
||||||
|
if match:
|
||||||
|
current = match.group(1)
|
||||||
|
fields[current] = match.group(2).strip()
|
||||||
|
elif current and line.startswith((" ", "\t")) and line.strip():
|
||||||
|
fields[current] = f"{fields[current]} {line.strip()}".strip()
|
||||||
|
elif line.strip().startswith("```") and fields:
|
||||||
|
break
|
||||||
|
return fields
|
||||||
|
|
||||||
|
|
||||||
|
# `Status` must be one of these four. `DOC_TRUST_MAP.md` states it as a rule with
|
||||||
|
# a checker behind it, and it is the one field that distinguishes a document from
|
||||||
|
# a template for one: `project-readme-template.md` carries
|
||||||
|
# `Status: <Current | Draft | Superseded | Archived>`, and its `Governs` describes
|
||||||
|
# the README of the project that copies it, not anything in this repository.
|
||||||
|
STATUS_WORDS = {"Current", "Draft", "Superseded", "Archived"}
|
||||||
|
|
||||||
|
|
||||||
|
def governing_documents() -> list[pathlib.Path]:
|
||||||
|
"""Every document that can fire, root ones included.
|
||||||
|
|
||||||
|
The walk used to start at `docs/`, so the documents at the repository root
|
||||||
|
were not read at all — `README.md` and the two `START-HERE-*.md` carry a full
|
||||||
|
status header, govern real paths, and fired nothing ever, while the output
|
||||||
|
said "No document's Governs matched these paths". True of the tool and false
|
||||||
|
of the repository, which is the same shape as the gloss bug one level out.
|
||||||
|
|
||||||
|
The root is read **non-recursively**: `ROOT.glob`, not `rglob`. A vendored
|
||||||
|
copy of this template, a scratch checkout, or somebody's directory of notes
|
||||||
|
would otherwise enrol its documents as governing this repository.
|
||||||
|
"""
|
||||||
|
docs = sorted(ROOT.glob("*.md"))
|
||||||
|
if DOCS.is_dir():
|
||||||
|
docs += sorted(DOCS.rglob("*.md"))
|
||||||
|
return docs
|
||||||
|
|
||||||
|
|
||||||
|
def looks_like_path(glob: str) -> bool:
|
||||||
|
"""Whether a `Governs:` entry is a path pattern rather than a subject.
|
||||||
|
|
||||||
|
The same test `doc-claims.sh` uses: a slash, a wildcard, or a file
|
||||||
|
extension. Prose about what a document is authoritative for will have none
|
||||||
|
of them, and must not be treated as a glob that simply never matches.
|
||||||
|
"""
|
||||||
|
glob = glob.strip()
|
||||||
|
if not glob or " " in glob and "/" not in glob and "*" not in glob:
|
||||||
|
return False
|
||||||
|
return "/" in glob or "*" in glob or re.search(r"\.\w{1,5}$", glob) is not None
|
||||||
|
|
||||||
|
|
||||||
|
# A `Governs:` entry is split on commas, so an entry that explains itself after
|
||||||
|
# the glob arrives whole: `docs/data/** — the assets privacyllc.dev renders for
|
||||||
|
# this project`. Used as a glob that matches nothing, ever, and because it
|
||||||
|
# contains a slash `looks_like_path` calls it a path — so the document was
|
||||||
|
# neither fired nor listed among the ones no change can fire mechanically. It was
|
||||||
|
# simply absent, which is the one outcome a reader cannot notice.
|
||||||
|
#
|
||||||
|
# Three of the seven path-governing documents here were in that state from the
|
||||||
|
# first commit, `docs/data/img/README.md` among them: editing the branding assets
|
||||||
|
# had never once prompted the document that specifies their names and sizes.
|
||||||
|
GLOSS = re.compile(r"\s+(?:—|–|--)\s+")
|
||||||
|
|
||||||
|
|
||||||
|
def globs_in(entry: str) -> list[str]:
|
||||||
|
"""The globs inside one `Governs:` entry, with any trailing gloss removed.
|
||||||
|
|
||||||
|
The cut requires whitespace on both sides of the dash: `source-grep` and
|
||||||
|
`doc-claims` appear in these headers and a bare `-` would halve them. Tokens
|
||||||
|
are taken from the left of the gloss rather than from the whole entry,
|
||||||
|
because prose on the right can itself look like a path — `privacyllc.dev`
|
||||||
|
passes the extension test and would become a glob that fires on a file
|
||||||
|
nobody has.
|
||||||
|
|
||||||
|
An entry yielding no token falls back to itself, so a shape not foreseen here
|
||||||
|
behaves exactly as it did before.
|
||||||
|
"""
|
||||||
|
head = GLOSS.split(entry, 1)[0]
|
||||||
|
return [tok for tok in head.split() if looks_like_path(tok)] or [entry]
|
||||||
|
|
||||||
|
|
||||||
|
def matches(path: str, glob: str) -> bool:
|
||||||
|
"""Whether `path` is governed by `glob`.
|
||||||
|
|
||||||
|
`**` means "and everything below", which `fnmatch` does not implement: its
|
||||||
|
`*` already crosses separators, so `a/**` never matches `a/b/c`. The two
|
||||||
|
forms these headers actually use are reduced to prefix tests.
|
||||||
|
"""
|
||||||
|
glob = glob.strip()
|
||||||
|
if not glob:
|
||||||
|
return False
|
||||||
|
if glob.endswith("/**"):
|
||||||
|
return path.startswith(glob[:-2]) or path == glob[:-3]
|
||||||
|
if glob.endswith("/"):
|
||||||
|
# A bare directory, as `githooks/README.md` governs ".githooks/". fnmatch
|
||||||
|
# would not match a file inside it.
|
||||||
|
return path.startswith(glob)
|
||||||
|
if "/**/" in glob:
|
||||||
|
head, tail = glob.split("/**/", 1)
|
||||||
|
return path.startswith(head + "/") and fnmatch.fnmatch(path, "*" + tail)
|
||||||
|
return fnmatch.fnmatch(path, glob)
|
||||||
|
|
||||||
|
|
||||||
|
# `Governs:` says *where* a document is authoritative; `Fires on:` says which
|
||||||
|
# kinds of change to that place its `Review trigger` actually names. The two come
|
||||||
|
# apart badly at the extreme: `DOC_TRUST_MAP.md` governs `docs/**`, the broadest
|
||||||
|
# glob in the tree, while its trigger is one of the narrowest — *any doc added,
|
||||||
|
# deleted or moved*. Matching on the glob alone fires it on every edit to every
|
||||||
|
# document forever, and a prompt that always fires is one people stop reading,
|
||||||
|
# which takes the true positives with it.
|
||||||
|
#
|
||||||
|
# Why a declared field rather than reading the trigger prose. The obvious first
|
||||||
|
# cut — look for `added`/`deleted`/`moved` and no `changed`/`change to` — was
|
||||||
|
# tried against the seven path-governing documents here and misclassified the one
|
||||||
|
# it exists to fix. `DOC_TRUST_MAP.md`'s trigger ends "any change to which doc
|
||||||
|
# owns a subject", so it reads as a change-verb; the clause is about which
|
||||||
|
# document owns a subject, not about a file being edited. Nothing lexical
|
||||||
|
# separates it from `architecture/README.md`'s "any change to a module boundary
|
||||||
|
# or a data shape", which genuinely does mean modification. Guessing at English
|
||||||
|
# and getting it wrong here is silent in the expensive direction: the document
|
||||||
|
# stops being prompted for and goes quietly stale.
|
||||||
|
#
|
||||||
|
# So the narrowing is declared or it does not happen. Absent, unparseable, or
|
||||||
|
# empty means fire on everything, which is the old behaviour — a document is only
|
||||||
|
# ever quietened by someone writing the line deliberately.
|
||||||
|
KIND_LETTERS = {
|
||||||
|
"added": {"A", "C"},
|
||||||
|
"deleted": {"D"},
|
||||||
|
"moved": {"R"},
|
||||||
|
"changed": {"M", "T"},
|
||||||
|
}
|
||||||
|
ALL_KINDS = {letter for letters in KIND_LETTERS.values() for letter in letters}
|
||||||
|
KIND_OF = {letter: word for word, letters in KIND_LETTERS.items() for letter in letters}
|
||||||
|
|
||||||
|
|
||||||
|
def fires_on(header: dict[str, str]) -> tuple[set[str], list[str]]:
|
||||||
|
"""The status letters a document accepts, and any words not understood.
|
||||||
|
|
||||||
|
Returns every letter when nothing is declared or the declaration cannot be
|
||||||
|
read, so the failure mode of a typo is a document that is prompted for too
|
||||||
|
often rather than one that is silently dropped.
|
||||||
|
"""
|
||||||
|
raw = header.get("Fires on", "").strip()
|
||||||
|
if not raw:
|
||||||
|
return ALL_KINDS, []
|
||||||
|
|
||||||
|
words = [w.strip().lower().rstrip(".") for w in re.split(r"[,;]|\band\b", raw)]
|
||||||
|
words = [w for w in words if w]
|
||||||
|
|
||||||
|
letters: set[str] = set()
|
||||||
|
unknown: list[str] = []
|
||||||
|
for word in words:
|
||||||
|
if word in KIND_LETTERS:
|
||||||
|
letters |= KIND_LETTERS[word]
|
||||||
|
else:
|
||||||
|
unknown.append(word)
|
||||||
|
|
||||||
|
if unknown or not letters:
|
||||||
|
return ALL_KINDS, unknown or ["(empty)"]
|
||||||
|
return letters, []
|
||||||
|
|
||||||
|
|
||||||
|
def _git(*args: str) -> str:
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", *args], cwd=ROOT, capture_output=True, text=True, check=False
|
||||||
|
)
|
||||||
|
return result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def _name_status(*args: str) -> list[tuple[str, str]]:
|
||||||
|
"""(letter, path) from a `--name-status` listing.
|
||||||
|
|
||||||
|
A rename arrives as `R100<TAB>old<TAB>new`, so the path is taken from the
|
||||||
|
last field: the new name governs, as it did when only names were read.
|
||||||
|
"""
|
||||||
|
pairs = []
|
||||||
|
for line in _git(*args, "--name-status").splitlines():
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
fields = line.split("\t")
|
||||||
|
if len(fields) < 2 or not fields[0].strip():
|
||||||
|
continue
|
||||||
|
pairs.append((fields[0].strip()[0].upper(), fields[-1].strip()))
|
||||||
|
return pairs
|
||||||
|
|
||||||
|
|
||||||
|
def _status_of_named(path: str) -> str:
|
||||||
|
"""The kind of change a path named on the command line represents.
|
||||||
|
|
||||||
|
There is no diff to read here, so it is inferred: gone from disk is a
|
||||||
|
deletion, present but untracked is an addition, and anything else is a
|
||||||
|
modification — the usual reason to ask about a path by name.
|
||||||
|
"""
|
||||||
|
if not (ROOT / path).exists():
|
||||||
|
return "D"
|
||||||
|
return "M" if _git("ls-files", "--", path).strip() else "A"
|
||||||
|
|
||||||
|
|
||||||
|
def changed_paths(argv: list[str]) -> tuple[list[tuple[str, str]], str]:
|
||||||
|
if argv and argv[0] == "--staged":
|
||||||
|
return _name_status("diff", "--cached"), "staged"
|
||||||
|
if argv and argv[0] == "--range":
|
||||||
|
if len(argv) < 2:
|
||||||
|
sys.exit("doc-triggers: --range needs a revision range")
|
||||||
|
return _name_status("diff", argv[1]), f"range {argv[1]}"
|
||||||
|
if argv:
|
||||||
|
return [(_status_of_named(a), a) for a in argv], "named paths"
|
||||||
|
|
||||||
|
# Untracked files are included on purpose: a brand-new module is the case
|
||||||
|
# most likely to need a document and least likely to be remembered, and it
|
||||||
|
# is invisible to `git diff`.
|
||||||
|
pairs = []
|
||||||
|
for line in _git("status", "--porcelain").splitlines():
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
index, worktree = line[0], line[1]
|
||||||
|
path = line[3:].strip()
|
||||||
|
if " -> " in path: # a rename; the new name governs
|
||||||
|
path = path.split(" -> ", 1)[1]
|
||||||
|
if "?" in (index, worktree):
|
||||||
|
letter = "A" # untracked: a file that is new
|
||||||
|
else:
|
||||||
|
letter = (index if index != " " else worktree).upper()
|
||||||
|
pairs.append((letter, path.strip('"')))
|
||||||
|
return pairs, "working tree"
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
if not DOCS.is_dir():
|
||||||
|
print(f"doc-triggers: no docs/ directory at {DOCS}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
paths, source = changed_paths(sys.argv[1:])
|
||||||
|
if not paths:
|
||||||
|
print(f"doc-triggers: nothing changed in the {source}.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
print(f"doc-triggers: {len(paths)} path(s) from the {source}\n")
|
||||||
|
|
||||||
|
fired: list[tuple[str, list[tuple[str, str]], str]] = []
|
||||||
|
subject_only: list[str] = []
|
||||||
|
wrong_kind: list[tuple[str, str]] = []
|
||||||
|
unfilled: list[tuple[str, str]] = []
|
||||||
|
|
||||||
|
for doc in governing_documents():
|
||||||
|
rel = str(doc.relative_to(ROOT))
|
||||||
|
header = header_of(doc)
|
||||||
|
governs = header.get("Governs", "")
|
||||||
|
if not governs:
|
||||||
|
continue
|
||||||
|
|
||||||
|
filled = header.get("Status", "") in STATUS_WORDS
|
||||||
|
|
||||||
|
entries = [g.strip() for g in governs.split(",") if g.strip()]
|
||||||
|
# Classification reads the whole entry and extraction reads inside it:
|
||||||
|
# deciding "path or subject?" on a token would move documents between the
|
||||||
|
# two lists as a side effect of this fix.
|
||||||
|
path_globs = [g for e in entries if looks_like_path(e) for g in globs_in(e)]
|
||||||
|
if not path_globs:
|
||||||
|
if filled:
|
||||||
|
subject_only.append(rel)
|
||||||
|
continue
|
||||||
|
|
||||||
|
letters, unknown = fires_on(header)
|
||||||
|
if unknown:
|
||||||
|
print(
|
||||||
|
f"doc-triggers: {rel} declares 'Fires on: "
|
||||||
|
f"{header.get('Fires on', '')}' — {', '.join(unknown)} not "
|
||||||
|
f"understood, so it fires on everything.\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
matched = {(s, p) for s, p in paths for g in path_globs if matches(p, g)}
|
||||||
|
|
||||||
|
if not filled:
|
||||||
|
# A template for a document rather than a document. Named only when it
|
||||||
|
# would otherwise have fired: a line on every run, about a file that is
|
||||||
|
# supposed to look like this, is the noise this tool keeps being fixed
|
||||||
|
# for.
|
||||||
|
if matched:
|
||||||
|
unfilled.append((rel, header.get("Status", "") or "(none)"))
|
||||||
|
continue
|
||||||
|
|
||||||
|
hits = sorted({(s, p) for s, p in matched if s in letters}, key=lambda x: x[1])
|
||||||
|
if hits:
|
||||||
|
fired.append((rel, hits, header.get("Review trigger", "(none stated)")))
|
||||||
|
elif matched:
|
||||||
|
# Governed, and deliberately not prompted for: the paths changed in a
|
||||||
|
# way this document's trigger does not name. Said out loud, because a
|
||||||
|
# reader who saw nothing would have to guess whether it was checked.
|
||||||
|
wrong_kind.append((rel, header.get("Fires on", "").strip()))
|
||||||
|
|
||||||
|
for rel, hits, trigger in fired:
|
||||||
|
print(f"\033[1m{rel}\033[0m")
|
||||||
|
for letter, hit in hits[:6]:
|
||||||
|
print(f" {KIND_OF.get(letter, letter.lower()):>7} {hit}")
|
||||||
|
if len(hits) > 6:
|
||||||
|
print(f" … and {len(hits) - 6} more")
|
||||||
|
print(f" trigger: {trigger}\n")
|
||||||
|
|
||||||
|
if fired:
|
||||||
|
print(f"{len(fired)} document(s) govern something in this change.")
|
||||||
|
print("Read each trigger and decide — the rule is to update them in the")
|
||||||
|
print("SAME commit as the code, not afterwards.")
|
||||||
|
elif wrong_kind or unfilled:
|
||||||
|
# Distinct from matching nothing, and worth separating: a path here *is*
|
||||||
|
# governed, and the reason nothing fired is a declaration somebody wrote
|
||||||
|
# or a header nobody filled in, not an area no document claims.
|
||||||
|
print("Nothing fired, but these paths are governed — see below for which")
|
||||||
|
print("documents matched them and why each was not raised.")
|
||||||
|
else:
|
||||||
|
print("No document's Governs matched these paths. Worth a second look if")
|
||||||
|
print("this change added a module, a migration, or a new boundary — an")
|
||||||
|
print("unmatched path can also mean no document claims that area yet.")
|
||||||
|
|
||||||
|
if wrong_kind:
|
||||||
|
print("\nGovern a path in this change but do not fire on this kind of")
|
||||||
|
print("change, by their own Fires on declaration:")
|
||||||
|
for rel, declared in wrong_kind:
|
||||||
|
print(f" {rel} — fires on {declared}")
|
||||||
|
|
||||||
|
if unfilled:
|
||||||
|
print("\nGovern a path in this change but their status header is still a")
|
||||||
|
print("template, so they are not treated as documents of this repository:")
|
||||||
|
for rel, status in unfilled:
|
||||||
|
print(f" {rel} — Status: {status}")
|
||||||
|
|
||||||
|
if subject_only:
|
||||||
|
print("\nNot checked here — these govern a subject rather than paths, so")
|
||||||
|
print("no change can fire them mechanically. Judge them yourself:")
|
||||||
|
for rel in subject_only:
|
||||||
|
print(f" {rel}")
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
|
|
@ -0,0 +1,586 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Post, list and close Forgejo issues in the convention these projects use.
|
||||||
|
|
||||||
|
Exists because every one of the rules below was learned by getting it wrong
|
||||||
|
once. The script is the enforcement; the skill is the explanation.
|
||||||
|
|
||||||
|
create file an issue, refusing one that has no `Verify:` line or that
|
||||||
|
duplicates an existing title
|
||||||
|
batch file several from a JSON file, skipping ones already there
|
||||||
|
list open issues, grouped by milestone
|
||||||
|
close close with an evidence comment — "Done" is not a close
|
||||||
|
labels / milestones — what exists, with the ids the API wants
|
||||||
|
|
||||||
|
Run any subcommand with --dry-run to see the payload and change nothing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
# Where the credentials live, when they are not already in the environment.
|
||||||
|
#
|
||||||
|
# Keep this file OUTSIDE the repository. A token in a file the repo can see is
|
||||||
|
# a token one `git add -A` away from being published — the same argument
|
||||||
|
# `release.sh` makes about its registry env.
|
||||||
|
ENV_FILE = os.environ.get("FORGEJO_ENV_FILE", os.path.expanduser("~/.forgejo.env"))
|
||||||
|
|
||||||
|
# Cloudflare fronts the Forgejo instance and 1010-blocks Python's default
|
||||||
|
# urllib User-Agent (browser_signature_banned). Every call fails with a
|
||||||
|
# Cloudflare HTML body that looks nothing like a Forgejo error. Do not remove.
|
||||||
|
USER_AGENT = "curl/8.5.0"
|
||||||
|
|
||||||
|
SEVERITY = ("P0", "P1", "P2", "release-blocker")
|
||||||
|
|
||||||
|
|
||||||
|
# ── plumbing ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def die(msg: str, code: int = 1):
|
||||||
|
print(f"error: {msg}", file=sys.stderr)
|
||||||
|
sys.exit(code)
|
||||||
|
|
||||||
|
|
||||||
|
def load_env() -> tuple[str, str]:
|
||||||
|
"""Read host + token. Never print the token; it is not registry-scoped —
|
||||||
|
it carries admin/push/pull over the whole API."""
|
||||||
|
host = os.environ.get("FORGEJO_REGISTRY")
|
||||||
|
token = os.environ.get("FORGEJO_REGISTRY_TOKEN")
|
||||||
|
if not (host and token):
|
||||||
|
try:
|
||||||
|
with open(ENV_FILE, encoding="utf-8") as fh:
|
||||||
|
for line in fh:
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line.startswith("#") or "=" not in line:
|
||||||
|
continue
|
||||||
|
k, v = line.split("=", 1)
|
||||||
|
v = v.strip().strip("'\"")
|
||||||
|
if k.strip() == "FORGEJO_REGISTRY" and not host:
|
||||||
|
host = v
|
||||||
|
elif k.strip() == "FORGEJO_REGISTRY_TOKEN" and not token:
|
||||||
|
token = v
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
if not (host and token):
|
||||||
|
die(
|
||||||
|
f"FORGEJO_REGISTRY / FORGEJO_REGISTRY_TOKEN not in the environment "
|
||||||
|
f"or {ENV_FILE}.\n"
|
||||||
|
f"Set FORGEJO_ENV_FILE to point somewhere else, or export both."
|
||||||
|
)
|
||||||
|
return host, token
|
||||||
|
|
||||||
|
|
||||||
|
def detect_repo() -> str | None:
|
||||||
|
"""owner/name from the git remote of the current directory."""
|
||||||
|
try:
|
||||||
|
url = subprocess.run(
|
||||||
|
["git", "remote", "get-url", "origin"],
|
||||||
|
capture_output=True, text=True, check=True,
|
||||||
|
).stdout.strip()
|
||||||
|
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||||
|
return None
|
||||||
|
m = re.search(r"[:/]([^/:]+)/([^/]+?)(?:\.git)?$", url)
|
||||||
|
return f"{m.group(1)}/{m.group(2)}" if m else None
|
||||||
|
|
||||||
|
|
||||||
|
class Api:
|
||||||
|
def __init__(self, host: str, token: str, repo: str, dry_run: bool = False):
|
||||||
|
self.base = f"https://{host}/api/v1"
|
||||||
|
self.token = token
|
||||||
|
self.repo = repo
|
||||||
|
self.dry_run = dry_run
|
||||||
|
|
||||||
|
def _call(self, method: str, path: str, body=None, params=None):
|
||||||
|
url = f"{self.base}{path}"
|
||||||
|
if params:
|
||||||
|
url += "?" + urllib.parse.urlencode(params)
|
||||||
|
data = json.dumps(body).encode() if body is not None else None
|
||||||
|
req = urllib.request.Request(url, data=data, method=method)
|
||||||
|
req.add_header("Authorization", f"token {self.token}")
|
||||||
|
req.add_header("User-Agent", USER_AGENT)
|
||||||
|
if data:
|
||||||
|
req.add_header("Content-Type", "application/json")
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||||
|
raw = resp.read().decode()
|
||||||
|
return json.loads(raw) if raw.strip() else None
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
detail = e.read().decode()[:400]
|
||||||
|
if "<html" in detail.lower():
|
||||||
|
detail = ("Cloudflare returned HTML, not a Forgejo error — the "
|
||||||
|
"User-Agent was probably rejected.")
|
||||||
|
die(f"{method} {path} → HTTP {e.code}: {detail}")
|
||||||
|
except urllib.error.URLError as e:
|
||||||
|
die(f"{method} {path} → {e.reason}")
|
||||||
|
|
||||||
|
def get(self, path, params=None):
|
||||||
|
return self._call("GET", path, params=params)
|
||||||
|
|
||||||
|
def post(self, path, body):
|
||||||
|
if self.dry_run:
|
||||||
|
print(f"[dry-run] POST {path}\n{json.dumps(body, indent=2)}")
|
||||||
|
return {"number": "?", "title": body.get("title", ""), "labels": [],
|
||||||
|
"milestone": None, "_dry": True}
|
||||||
|
return self._call("POST", path, body=body)
|
||||||
|
|
||||||
|
def patch(self, path, body):
|
||||||
|
if self.dry_run:
|
||||||
|
print(f"[dry-run] PATCH {path}\n{json.dumps(body, indent=2)}")
|
||||||
|
return {"_dry": True}
|
||||||
|
return self._call("PATCH", path, body=body)
|
||||||
|
|
||||||
|
# ── repo helpers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def issues(self, state="open", limit=50):
|
||||||
|
"""type=issues matters: without it Forgejo returns pull requests too,
|
||||||
|
and every count drawn from the result is wrong.
|
||||||
|
|
||||||
|
Stops on an EMPTY page, not a short one. The server caps a page at 50
|
||||||
|
however large a `limit` you ask for, so the natural `len(batch) < limit`
|
||||||
|
test with limit=100 is true on the very first page and the loop exits
|
||||||
|
having read 50 of 72 open issues — silently, with a plausible-looking
|
||||||
|
result. That truncation feeds duplicate detection, which is the one thing
|
||||||
|
this list is for, so a short read re-files work that already exists.
|
||||||
|
Costs one extra request; cannot truncate.
|
||||||
|
"""
|
||||||
|
out, page = [], 1
|
||||||
|
while True:
|
||||||
|
batch = self.get(
|
||||||
|
f"/repos/{self.repo}/issues",
|
||||||
|
{"type": "issues", "state": state, "limit": limit, "page": page},
|
||||||
|
) or []
|
||||||
|
if not batch:
|
||||||
|
return out
|
||||||
|
out.extend(batch)
|
||||||
|
page += 1
|
||||||
|
|
||||||
|
def labels(self) -> dict[str, int]:
|
||||||
|
return {l["name"]: l["id"]
|
||||||
|
for l in (self.get(f"/repos/{self.repo}/labels",
|
||||||
|
{"limit": 100}) or [])}
|
||||||
|
|
||||||
|
def milestones(self) -> dict[str, int]:
|
||||||
|
out = {}
|
||||||
|
for state in ("open", "closed"):
|
||||||
|
for m in (self.get(f"/repos/{self.repo}/milestones",
|
||||||
|
{"state": state, "limit": 100}) or []):
|
||||||
|
out[m["title"]] = m["id"]
|
||||||
|
return out
|
||||||
|
|
||||||
|
def milestones_full(self, state="all") -> list[dict]:
|
||||||
|
states = ("open", "closed") if state == "all" else (state,)
|
||||||
|
out = []
|
||||||
|
for s in states:
|
||||||
|
out.extend(self.get(f"/repos/{self.repo}/milestones",
|
||||||
|
{"state": s, "limit": 100}) or [])
|
||||||
|
return out
|
||||||
|
|
||||||
|
def current_milestone(self) -> dict | None:
|
||||||
|
"""The first OPEN milestone that still has open issues.
|
||||||
|
|
||||||
|
'First' is the order Forgejo returns, which is creation order — NOT a
|
||||||
|
numeric sort by title. A milestone created last therefore cannot become
|
||||||
|
current while an earlier one still has open issues, which is the lever
|
||||||
|
for filing work that must not disturb the card.
|
||||||
|
"""
|
||||||
|
openms = self.milestones_full("open")
|
||||||
|
for m in openms:
|
||||||
|
if m["open_issues"] > 0:
|
||||||
|
return m
|
||||||
|
return openms[0] if openms else None
|
||||||
|
|
||||||
|
|
||||||
|
# ── convention checks ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def check_verify_line(body: str, title: str) -> str:
|
||||||
|
"""Every issue ends with a Verify: line stating the acceptance check.
|
||||||
|
A deliverable nobody can re-test cannot be closed, so it must not be filed."""
|
||||||
|
lines = [l for l in body.strip().splitlines() if l.strip()]
|
||||||
|
if not any(l.strip().startswith("Verify:") for l in lines):
|
||||||
|
die(f'"{title}" has no `Verify:` line. State the acceptance check — a\n'
|
||||||
|
" finding that cannot be re-tested cannot be closed.")
|
||||||
|
if not lines[-1].strip().startswith("Verify:"):
|
||||||
|
print(f'warning: "{title}" has a Verify: line but it is not last',
|
||||||
|
file=sys.stderr)
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_labels(names, available: dict[str, int]) -> list[int]:
|
||||||
|
"""Names → ids, failing loudly. A typo'd severity label is reported by the
|
||||||
|
Command Center as *not adopted*, not as zero defects — silently dropping it
|
||||||
|
would hide the whole repo's defect count."""
|
||||||
|
ids = []
|
||||||
|
for n in names:
|
||||||
|
# A severity label that differs only in case is the dangerous one: it
|
||||||
|
# looks right in the UI and is invisible to a query by exact name.
|
||||||
|
if n not in available:
|
||||||
|
near = [a for a in available if a.lower() == n.lower()]
|
||||||
|
if near:
|
||||||
|
die(f"label {n!r} does not exist, but {near[0]!r} does. "
|
||||||
|
"Names are matched exactly — use that one.")
|
||||||
|
die(f"label {n!r} does not exist. "
|
||||||
|
f"Available: {', '.join(sorted(available)) or '(none)'}")
|
||||||
|
if n.upper() in {s.upper() for s in SEVERITY} and n not in SEVERITY:
|
||||||
|
die(f"severity label must be spelled exactly one of {SEVERITY}, "
|
||||||
|
f"got {n!r}")
|
||||||
|
ids.append(available[n])
|
||||||
|
return ids
|
||||||
|
|
||||||
|
|
||||||
|
def find_duplicate(title: str, existing: list) -> dict | None:
|
||||||
|
t = title.strip().lower()
|
||||||
|
for i in existing:
|
||||||
|
if i["title"].strip().lower() == t:
|
||||||
|
return i
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def validate_milestone_title(title: str) -> None:
|
||||||
|
"""Refuse a comma. `milestones=` takes a comma-separated list of names, so a
|
||||||
|
title containing one splits into names that do not exist, the filter DROPS,
|
||||||
|
and the query returns the newest open issue in the WHOLE repository — which
|
||||||
|
the card then presents as that milestone's next action. Percent-encoding does
|
||||||
|
not save it; Forgejo decodes before splitting. Measured on null/fruit-fall,
|
||||||
|
where `0.3.7 Logo, Icons & Branding` was renamed for exactly this reason."""
|
||||||
|
if "," in title:
|
||||||
|
die(f"milestone title contains a comma: {title!r}\n"
|
||||||
|
" That silently breaks the `milestones=` filter and makes the project\n"
|
||||||
|
" card show the wrong next action. Rename it without the comma.")
|
||||||
|
first = title.strip().split()[0] if title.strip() else ""
|
||||||
|
if first and first.replace("v", "", 1).replace(".", "").isdigit():
|
||||||
|
rest = title.strip()[len(first):].strip()
|
||||||
|
if rest:
|
||||||
|
print(f" note: the dashboard phase will show just {first!r} — a title "
|
||||||
|
f"starting with a\n version token has the rest dropped. Put a "
|
||||||
|
f"word first to keep it whole.")
|
||||||
|
|
||||||
|
|
||||||
|
_warned_current: set[str] = set()
|
||||||
|
|
||||||
|
|
||||||
|
def _warn_if_current(api: Api, milestone: str) -> None:
|
||||||
|
"""The dashboard's next action is the NEWEST open issue in the current
|
||||||
|
milestone — not the highest priority; priority labels have no influence at
|
||||||
|
all. Filing a routine item into the milestone the team is working on
|
||||||
|
therefore replaces what the card shows."""
|
||||||
|
if milestone in _warned_current:
|
||||||
|
return
|
||||||
|
_warned_current.add(milestone)
|
||||||
|
cur = api.current_milestone()
|
||||||
|
if cur and cur["title"] == milestone:
|
||||||
|
print(f" warning: {milestone!r} is the CURRENT milestone. The next action on "
|
||||||
|
"the project\n card is the NEWEST open issue in it, ignoring "
|
||||||
|
"priority — so this will\n replace whatever is shown there now. "
|
||||||
|
"To avoid that, file into a\n milestone created later; order is "
|
||||||
|
"creation order, not title order.")
|
||||||
|
|
||||||
|
|
||||||
|
# ── commands ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_check(api: Api, args):
|
||||||
|
"""The probes that prove the card is not lying. Run before and after filing.
|
||||||
|
|
||||||
|
Everything here exists because Forgejo's filters FAIL OPEN: given a value
|
||||||
|
they cannot match they ignore the filter and return the unfiltered list, with
|
||||||
|
no error. A query returning plausible results is not evidence it filtered.
|
||||||
|
"""
|
||||||
|
print(f"tracker health — {api.repo}\n")
|
||||||
|
problems = 0
|
||||||
|
|
||||||
|
labels = api.labels()
|
||||||
|
missing = [n for n in SEVERITY if n not in labels]
|
||||||
|
if missing:
|
||||||
|
problems += 1
|
||||||
|
print(f" FAIL severity labels missing: {', '.join(missing)}")
|
||||||
|
print(" a query for a label that does not exist matches EVERYTHING")
|
||||||
|
else:
|
||||||
|
print(" ok all four severity labels defined")
|
||||||
|
|
||||||
|
openi = api.issues(state="open")
|
||||||
|
blockers = [i for i in openi
|
||||||
|
if any(l["name"] == "release-blocker" for l in i["labels"])]
|
||||||
|
if blockers:
|
||||||
|
print(f" WARN {len(blockers)} open release-blocker — takes over the whole card:")
|
||||||
|
for i in blockers[:5]:
|
||||||
|
print(f" #{i['number']} {i['title'][:58]}")
|
||||||
|
else:
|
||||||
|
print(" ok no release-blocker hijacking the card")
|
||||||
|
|
||||||
|
orphans = [i for i in openi if not i.get("milestone")]
|
||||||
|
if orphans:
|
||||||
|
problems += 1
|
||||||
|
print(f" FAIL {len(orphans)} open issue(s) with no milestone — invisible on the card:")
|
||||||
|
for i in orphans[:5]:
|
||||||
|
print(f" #{i['number']} {i['title'][:58]}")
|
||||||
|
else:
|
||||||
|
print(f" ok no orphan issues ({len(openi)} open)")
|
||||||
|
|
||||||
|
openms = api.milestones_full("open")
|
||||||
|
commas = [m["title"] for m in openms if "," in m["title"]]
|
||||||
|
if commas:
|
||||||
|
problems += 1
|
||||||
|
print(f" FAIL comma in milestone title — breaks the filter: {commas}")
|
||||||
|
else:
|
||||||
|
print(" ok no comma in any open milestone title")
|
||||||
|
|
||||||
|
empty = [m["title"] for m in openms if m["open_issues"] == 0]
|
||||||
|
if empty:
|
||||||
|
print(f" WARN {len(empty)} open milestone(s) with nothing in them — the card")
|
||||||
|
print(f" will read 'Close milestone …': {empty[:3]}")
|
||||||
|
else:
|
||||||
|
print(" ok no empty open milestones")
|
||||||
|
|
||||||
|
cur = api.current_milestone()
|
||||||
|
if cur:
|
||||||
|
first = cur["title"].strip().split()[0]
|
||||||
|
tok = first.replace("v", "", 1).replace(".", "")
|
||||||
|
print(f"\n current milestone : {cur['title']!r}")
|
||||||
|
print(f" phase shown : {(first if tok.isdigit() else cur['title'].strip())!r}")
|
||||||
|
nxt = api.get(f"/repos/{api.repo}/issues",
|
||||||
|
{"type": "issues", "state": "open", "limit": 1,
|
||||||
|
"milestones": cur["title"]}) or []
|
||||||
|
if nxt:
|
||||||
|
i = nxt[0]
|
||||||
|
in_ms = (i.get("milestone") or {}).get("title")
|
||||||
|
if in_ms != cur["title"]:
|
||||||
|
problems += 1
|
||||||
|
print(f" next action : {i['title']!r}")
|
||||||
|
print(" ^ FILTER DROPPED — that issue is in "
|
||||||
|
f"{in_ms!r}.\n The card is showing a "
|
||||||
|
"wrong next action.")
|
||||||
|
else:
|
||||||
|
print(f" next action : {i['title']!r}")
|
||||||
|
else:
|
||||||
|
print("\n no open milestones — the phase would be 'release'")
|
||||||
|
|
||||||
|
print(f"\n{'PROBLEMS: ' + str(problems) if problems else 'All checks passed.'}")
|
||||||
|
return 1 if problems else 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_milestone(api: Api, args):
|
||||||
|
"""Create a milestone. No due date is ever set — a due date means a
|
||||||
|
commitment, and an invented one is worse than none."""
|
||||||
|
title = args.title.strip()
|
||||||
|
validate_milestone_title(title)
|
||||||
|
if title in api.milestones():
|
||||||
|
print(f"exists {title!r} — not creating a second one")
|
||||||
|
return
|
||||||
|
desc = args.description or ""
|
||||||
|
if args.description_file:
|
||||||
|
desc = open(args.description_file, encoding="utf-8").read()
|
||||||
|
if not desc.strip():
|
||||||
|
print(" note: no description. It should say what the batch is for and how "
|
||||||
|
"anybody\n will know it landed.")
|
||||||
|
m = api.post(f"/repos/{api.repo}/milestones",
|
||||||
|
{"title": title, "description": desc})
|
||||||
|
print(f"created milestone {m.get('title', title)!r} (id {m.get('id', '?')})")
|
||||||
|
|
||||||
|
|
||||||
|
def create_one(api: Api, spec: dict, labels_map, ms_map, existing,
|
||||||
|
allow_dup=False) -> dict | None:
|
||||||
|
title = spec["title"].strip()
|
||||||
|
body = check_verify_line(spec.get("body", ""), title)
|
||||||
|
|
||||||
|
dup = find_duplicate(title, existing)
|
||||||
|
if dup and not allow_dup:
|
||||||
|
print(f"skip #{dup['number']} already titled {title!r} "
|
||||||
|
f"({dup['state']}) — creates are not idempotent, so this is a skip "
|
||||||
|
f"not an error")
|
||||||
|
return None
|
||||||
|
|
||||||
|
payload = {"title": title, "body": body}
|
||||||
|
if spec.get("labels"):
|
||||||
|
payload["labels"] = resolve_labels(spec["labels"], labels_map)
|
||||||
|
if "release-blocker" in spec["labels"]:
|
||||||
|
print(" warning: release-blocker does NOT filter by milestone, so one "
|
||||||
|
"stray label\n takes over the phase and next action for "
|
||||||
|
"the entire project. It means\n *nothing else can "
|
||||||
|
"proceed* — it is not a synonym for important; that is P1.")
|
||||||
|
if spec.get("milestone"):
|
||||||
|
m = spec["milestone"]
|
||||||
|
if m not in ms_map:
|
||||||
|
die(f"milestone {m!r} does not exist. Available: "
|
||||||
|
f"{', '.join(sorted(ms_map)) or '(none)'}")
|
||||||
|
payload["milestone"] = ms_map[m]
|
||||||
|
_warn_if_current(api, m)
|
||||||
|
else:
|
||||||
|
print(f" warning: {title[:48]!r} has no milestone — it can never become the "
|
||||||
|
"next action\n and never appears anywhere on the project card.")
|
||||||
|
|
||||||
|
d = api.post(f"/repos/{api.repo}/issues", payload)
|
||||||
|
names = ",".join(l["name"] for l in d.get("labels", []))
|
||||||
|
mile = (d.get("milestone") or {}).get("title", "—")
|
||||||
|
print(f"filed #{d['number']} [{names}] {d['title']}"
|
||||||
|
+ (f" → {mile}" if mile != "—" else ""))
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_create(api: Api, args):
|
||||||
|
labels_map, ms_map = api.labels(), api.milestones()
|
||||||
|
existing = api.issues(state="all")
|
||||||
|
body = args.body
|
||||||
|
if args.body_file:
|
||||||
|
body = open(args.body_file, encoding="utf-8").read()
|
||||||
|
create_one(api, {"title": args.title, "body": body or "",
|
||||||
|
"labels": args.label, "milestone": args.milestone},
|
||||||
|
labels_map, ms_map, existing, args.allow_duplicate)
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_batch(api: Api, args):
|
||||||
|
specs = json.load(open(args.file, encoding="utf-8"))
|
||||||
|
if isinstance(specs, dict):
|
||||||
|
specs = specs.get("issues", [])
|
||||||
|
if not isinstance(specs, list):
|
||||||
|
die("batch file must be a JSON list, or an object with an 'issues' list")
|
||||||
|
labels_map, ms_map = api.labels(), api.milestones()
|
||||||
|
existing = api.issues(state="all")
|
||||||
|
filed = 0
|
||||||
|
for spec in specs:
|
||||||
|
d = create_one(api, spec, labels_map, ms_map, existing,
|
||||||
|
args.allow_duplicate)
|
||||||
|
if d:
|
||||||
|
filed += 1
|
||||||
|
existing.append({"number": d["number"], "title": d["title"],
|
||||||
|
"state": "open"})
|
||||||
|
print(f"\n{filed} filed, {len(specs) - filed} skipped")
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_list(api: Api, args):
|
||||||
|
issues = api.issues(state=args.state)
|
||||||
|
groups: dict[str, list] = {}
|
||||||
|
for i in issues:
|
||||||
|
groups.setdefault((i.get("milestone") or {}).get("title",
|
||||||
|
"(no milestone)"),
|
||||||
|
[]).append(i)
|
||||||
|
for m in sorted(groups):
|
||||||
|
print(f"\n### {m}")
|
||||||
|
for i in sorted(groups[m], key=lambda x: x["number"]):
|
||||||
|
names = ",".join(l["name"] for l in i["labels"])
|
||||||
|
print(f" #{i['number']:<4} [{names}] {i['title']}")
|
||||||
|
print(f"\n{len(issues)} {args.state} issue(s) — pull requests excluded")
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_close(api: Api, args):
|
||||||
|
"""Close with the evidence that proves it: a path, a symbol, a test name,
|
||||||
|
or the command that shows it. 'Done' is not a close."""
|
||||||
|
ev = args.evidence.strip()
|
||||||
|
if len(ev) < 15:
|
||||||
|
die("evidence too thin. Give a path, a symbol, a test name, or the "
|
||||||
|
"command that proves it — 'Done' is not a close.")
|
||||||
|
api.post(f"/repos/{api.repo}/issues/{args.number}/comments", {"body": ev})
|
||||||
|
api.patch(f"/repos/{api.repo}/issues/{args.number}", {"state": "closed"})
|
||||||
|
print(f"closed #{args.number} with evidence")
|
||||||
|
print("note: prefer `closes #N` in the commit that does the work — the "
|
||||||
|
"tracker then records who and when from the thing that happened.")
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_labels(api: Api, args):
|
||||||
|
labels = api.labels()
|
||||||
|
print("severity (exact names — queried by name by the Command Center):")
|
||||||
|
for s in SEVERITY:
|
||||||
|
print(f" {'✓' if s in labels else '✗ MISSING'} {s}"
|
||||||
|
+ (f" id={labels[s]}" if s in labels else ""))
|
||||||
|
print("\nother:")
|
||||||
|
for n, i in sorted(labels.items()):
|
||||||
|
if n not in SEVERITY:
|
||||||
|
print(f" {n} id={i}")
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_milestones(api: Api, args):
|
||||||
|
for state in ("open", "closed"):
|
||||||
|
ms = api.get(f"/repos/{api.repo}/milestones",
|
||||||
|
{"state": state, "limit": 100}) or []
|
||||||
|
if ms:
|
||||||
|
print(f"\n{state}:")
|
||||||
|
for m in ms:
|
||||||
|
print(f" [{m['id']}] {m['title']} — open {m['open_issues']} / "
|
||||||
|
f"closed {m['closed_issues']}")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
# --repo and --dry-run are accepted on BOTH sides of the subcommand. Putting
|
||||||
|
# them only on the top-level parser means `… create "T" --dry-run` — the
|
||||||
|
# natural way to type it, and the position that matters most — dies with an
|
||||||
|
# argparse usage error instead of previewing. A safety flag that is easy to
|
||||||
|
# put in the wrong place is a safety flag that gets left off.
|
||||||
|
# default=SUPPRESS is load-bearing, not tidiness. With a normal default the
|
||||||
|
# subparser re-defines the same dest and argparse writes its default over
|
||||||
|
# whatever the top-level parser already parsed — so `--repo X create …`
|
||||||
|
# silently became repo=None and `--dry-run create …` silently became False.
|
||||||
|
# A --dry-run that quietly turns itself off is the worst possible bug in a
|
||||||
|
# tool whose job is writing to a live tracker. SUPPRESS leaves the attribute
|
||||||
|
# unset unless it was actually given, so neither position clobbers the other.
|
||||||
|
common = argparse.ArgumentParser(add_help=False)
|
||||||
|
common.add_argument("--repo", default=argparse.SUPPRESS,
|
||||||
|
help="owner/name (default: from git remote)")
|
||||||
|
common.add_argument("--dry-run", action="store_true",
|
||||||
|
default=argparse.SUPPRESS,
|
||||||
|
help="print payloads, change nothing")
|
||||||
|
|
||||||
|
p = argparse.ArgumentParser(
|
||||||
|
parents=[common],
|
||||||
|
description="File Forgejo issues in the tracker convention.",
|
||||||
|
epilog="Every open issue is a denominator. Do not pad the tracker.")
|
||||||
|
sub = p.add_subparsers(dest="cmd", required=True)
|
||||||
|
|
||||||
|
c = sub.add_parser("create", parents=[common], help="file one issue")
|
||||||
|
c.add_argument("title")
|
||||||
|
c.add_argument("--body", help="issue body; must end with a Verify: line")
|
||||||
|
c.add_argument("--body-file", help="read the body from a file")
|
||||||
|
c.add_argument("--label", action="append", default=[],
|
||||||
|
help="label name, repeatable")
|
||||||
|
c.add_argument("--milestone", help="milestone title")
|
||||||
|
c.add_argument("--allow-duplicate", action="store_true")
|
||||||
|
c.set_defaults(fn=cmd_create)
|
||||||
|
|
||||||
|
b = sub.add_parser("batch", parents=[common], help="file several from a JSON file")
|
||||||
|
b.add_argument("file")
|
||||||
|
b.add_argument("--allow-duplicate", action="store_true")
|
||||||
|
b.set_defaults(fn=cmd_batch)
|
||||||
|
|
||||||
|
l = sub.add_parser("list", parents=[common], help="open issues by milestone")
|
||||||
|
l.add_argument("--state", default="open",
|
||||||
|
choices=["open", "closed", "all"])
|
||||||
|
l.set_defaults(fn=cmd_list)
|
||||||
|
|
||||||
|
x = sub.add_parser("close", parents=[common], help="close with an evidence comment")
|
||||||
|
x.add_argument("number", type=int)
|
||||||
|
x.add_argument("evidence", help="what was checked and where")
|
||||||
|
x.set_defaults(fn=cmd_close)
|
||||||
|
|
||||||
|
m = sub.add_parser("milestone", parents=[common], help="create a milestone (batch)")
|
||||||
|
m.add_argument("title")
|
||||||
|
m.add_argument("--description")
|
||||||
|
m.add_argument("--description-file")
|
||||||
|
m.set_defaults(fn=cmd_milestone)
|
||||||
|
|
||||||
|
sub.add_parser("check", parents=[common], help="health probes — run before AND after filing"
|
||||||
|
).set_defaults(fn=cmd_check)
|
||||||
|
sub.add_parser("labels", parents=[common], help="labels and their ids").set_defaults(
|
||||||
|
fn=cmd_labels)
|
||||||
|
sub.add_parser("milestones", parents=[common], help="milestones and their ids").set_defaults(
|
||||||
|
fn=cmd_milestones)
|
||||||
|
|
||||||
|
args = p.parse_args()
|
||||||
|
# getattr, because SUPPRESS means the attribute may legitimately be absent.
|
||||||
|
repo = getattr(args, "repo", None) or detect_repo()
|
||||||
|
if not repo:
|
||||||
|
die("could not detect owner/name from the git remote — pass --repo")
|
||||||
|
host, token = load_env()
|
||||||
|
rc = args.fn(Api(host, token, repo, getattr(args, "dry_run", False)), args)
|
||||||
|
# `check` returns a count so it can gate a script; the rest return None.
|
||||||
|
sys.exit(rc or 0)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
@ -0,0 +1,185 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Prove a guard fails before you believe it passes.
|
||||||
|
#
|
||||||
|
# ## The failure this catches
|
||||||
|
#
|
||||||
|
# A guard that cannot fail is worse than no guard, because it is trusted.
|
||||||
|
# `docs/architecture/GUARDS.md` opens with that sentence and its first rule is
|
||||||
|
# this procedure, written out as a manual recipe: back the file up, break exactly
|
||||||
|
# the thing the guard protects, run the guard, expect one failure, restore.
|
||||||
|
#
|
||||||
|
# The recipe is thirty seconds and it is skipped anyway, for two reasons this
|
||||||
|
# script removes:
|
||||||
|
#
|
||||||
|
# - **Restoring is a step you can forget**, and forgetting is silent. The tests
|
||||||
|
# pass again once the mutation is undone in your head but not on disk, so the
|
||||||
|
# reverted code ships looking green. Here the restore is a `trap`, which runs
|
||||||
|
# on success, on failure, and on Ctrl-C.
|
||||||
|
# - **Counting the failures is the part people skip.** GUARDS.md §1: "If
|
||||||
|
# breaking the guard's target fails three tests, two of them are coincidental
|
||||||
|
# and will mask a real regression later." A human doing this by hand sees red
|
||||||
|
# and stops reading.
|
||||||
|
#
|
||||||
|
# ## Usage
|
||||||
|
#
|
||||||
|
# bash scripts/prove-guard.sh <file> <find> <replace> <test command…>
|
||||||
|
#
|
||||||
|
# bash scripts/prove-guard.sh src/lib/thing.ts \
|
||||||
|
# 'if (body.error)' 'if (false)' \
|
||||||
|
# npx vitest run tests/thing.test.ts
|
||||||
|
#
|
||||||
|
# Everything after the third argument is the command that runs the guard, so any
|
||||||
|
# runner works. `$PROVE_GUARD_CMD` is used when no command is given.
|
||||||
|
#
|
||||||
|
# ## Counting the failures
|
||||||
|
#
|
||||||
|
# "Exactly one" is a claim about test *cases*, and counting matching log lines
|
||||||
|
# does not measure that: Gradle reports a single failing test on six lines — the
|
||||||
|
# task, the test, its assertion, the summary, and twice more for the build — and
|
||||||
|
# a naive count calls that six coincidental failures. Tried that first; it fired
|
||||||
|
# on the very first run against a guard that was behaving perfectly.
|
||||||
|
#
|
||||||
|
# So the summary line is preferred, because almost every runner prints one and it
|
||||||
|
# is the runner's own count. Runners disagree about which side of the word the
|
||||||
|
# number goes on, so both orders are read: `1 failed` from vitest, `1 failed, 5
|
||||||
|
# passed` from pytest, `6 tests completed, 1 failed` from Gradle — and `fail 1`
|
||||||
|
# from node --test, `Failures: 2` from Maven and JUnit, `failures=2` from python
|
||||||
|
# unittest, `# fail 1` from TAP. The **last** such line wins, and only if none is
|
||||||
|
# found does it fall back to counting lines matching `$PROVE_GUARD_FAIL_PATTERN`
|
||||||
|
# — saying so, because an approximate count presented as an exact one is the kind
|
||||||
|
# of thing this script exists to object to.
|
||||||
|
#
|
||||||
|
# ## Exit codes
|
||||||
|
#
|
||||||
|
# 0 the guard caught it, and nothing else did — the outcome you want
|
||||||
|
# 1 the guard stayed GREEN with its target broken. It is not testing what you
|
||||||
|
# think it is, and you have just learned that for the price of one edit
|
||||||
|
# 2 nothing was proven: bad arguments, missing file, or a find-string that is
|
||||||
|
# absent or ambiguous. **Two is not a pass**
|
||||||
|
# 3 the guard caught it, but so did something else. Red for more than one
|
||||||
|
# reason hides the next regression behind a failure you have learned to
|
||||||
|
# expect — narrow the guard, or the mutation
|
||||||
|
#
|
||||||
|
# The file is restored in every one of those cases.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
FAIL_PATTERN="${PROVE_GUARD_FAIL_PATTERN:-(FAIL|✗|[0-9]+ (tests? )?failed|FAILED|AssertionError)}"
|
||||||
|
|
||||||
|
if [ "$#" -lt 3 ]; then
|
||||||
|
sed -n '2,30p' "$0" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
FILE="$1"; FIND="$2"; REPLACE="$3"; shift 3
|
||||||
|
|
||||||
|
if [ "$#" -gt 0 ]; then
|
||||||
|
CMD=("$@")
|
||||||
|
elif [ -n "${PROVE_GUARD_CMD:-}" ]; then
|
||||||
|
# shellcheck disable=SC2206
|
||||||
|
CMD=($PROVE_GUARD_CMD)
|
||||||
|
else
|
||||||
|
echo "prove-guard: no test command given and PROVE_GUARD_CMD is unset." >&2
|
||||||
|
echo "Nothing was proven, which is not the same as nothing being wrong." >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
[ -f "$FILE" ] || { echo "prove-guard: no such file: $FILE" >&2; exit 2; }
|
||||||
|
|
||||||
|
BACKUP="$(mktemp)"
|
||||||
|
cp "$FILE" "$BACKUP"
|
||||||
|
restore() {
|
||||||
|
cp "$BACKUP" "$FILE"
|
||||||
|
rm -f "$BACKUP"
|
||||||
|
echo "prove-guard: restored $FILE"
|
||||||
|
}
|
||||||
|
trap restore EXIT INT TERM
|
||||||
|
|
||||||
|
# Exact-string replacement, and it must be unique. A mutation that lands in two
|
||||||
|
# places proves nothing about either, and a regex here would make the mutation
|
||||||
|
# itself the thing to debug.
|
||||||
|
#
|
||||||
|
# Both refusals here exit 2, like every other "nothing was proven" path
|
||||||
|
# above. `sys.exit("message")` prints it and exits **1** — the code this
|
||||||
|
# script reserves for "the guard stayed GREEN with its target broken", which
|
||||||
|
# is a diagnosis about the guard, not a refusal to run. So a mistyped
|
||||||
|
# find-string accused the guard under test of being broken. `TOOLS.md`
|
||||||
|
# teaches callers to tell 1 from 2 and that "two is never a pass"; that
|
||||||
|
# distinction has to survive this block.
|
||||||
|
python3 - "$FILE" "$FIND" "$REPLACE" <<'PY'
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def refuse(message: str) -> None:
|
||||||
|
print(message, file=sys.stderr)
|
||||||
|
raise SystemExit(2)
|
||||||
|
|
||||||
|
|
||||||
|
path, find, replace = sys.argv[1], sys.argv[2], sys.argv[3]
|
||||||
|
text = open(path, encoding="utf-8").read()
|
||||||
|
count = text.count(find)
|
||||||
|
if count == 0:
|
||||||
|
refuse(f"prove-guard: the string to break is not in {path}")
|
||||||
|
if count > 1:
|
||||||
|
refuse(
|
||||||
|
f"prove-guard: {count} occurrences of that string; a mutation in "
|
||||||
|
"two places proves neither. Pick a longer, unique one."
|
||||||
|
)
|
||||||
|
open(path, "w", encoding="utf-8").write(text.replace(find, replace))
|
||||||
|
PY
|
||||||
|
|
||||||
|
LOG="$(mktemp)"
|
||||||
|
trap 'restore; rm -f "$LOG"' EXIT INT TERM
|
||||||
|
|
||||||
|
echo "prove-guard: broke $FILE — expecting '${CMD[*]}' to go red"
|
||||||
|
echo
|
||||||
|
|
||||||
|
if "${CMD[@]}" >"$LOG" 2>&1; then
|
||||||
|
echo "prove-guard: FAILED — the guard stayed GREEN with its target broken." >&2
|
||||||
|
echo >&2
|
||||||
|
echo "It is not checking what you think. Either the assertion does not reach" >&2
|
||||||
|
echo "the mutated code, or it would pass without it. Log: $LOG" >&2
|
||||||
|
tail -20 "$LOG" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "--- what failed ---"
|
||||||
|
grep -E "$FAIL_PATTERN" "$LOG" | head -12 || true
|
||||||
|
echo
|
||||||
|
|
||||||
|
# The runner's own count, from the last summary line that states one. Preferred
|
||||||
|
# over counting log lines for the reason in the header: one failing test is
|
||||||
|
# routinely reported on half a dozen lines.
|
||||||
|
# Two orders, because runners disagree about which side the number goes on.
|
||||||
|
# The first pattern reads `1 failed` (vitest, pytest, Gradle); the second reads
|
||||||
|
# the number on the right: `ℹ fail 1` (node --test), `Failures: 2` (Maven,
|
||||||
|
# JUnit), `failures=2` (python unittest), `# fail 1` (TAP).
|
||||||
|
#
|
||||||
|
# Matching only the first order made every node run fall through to the
|
||||||
|
# approximate line count, and that fallback is not conservative. A guard over a
|
||||||
|
# status enum — mutating the string `'FAILED'` — matches FAIL_PATTERN three
|
||||||
|
# times inside one AssertionError diff, so a single correct guard exited 3 with
|
||||||
|
# "narrow the guard, or narrow the mutation". The header says that exact false
|
||||||
|
# fire was tried once and rejected; it was still reachable through the fallback.
|
||||||
|
# The message compounded it, reporting "this runner printed no summary" about a
|
||||||
|
# runner that printed one this script could not read.
|
||||||
|
COUNT="$(grep -oiE '[0-9]+ (tests? )?failed' "$LOG" | tail -1 | grep -oE '^[0-9]+' || true)"
|
||||||
|
if [ -z "$COUNT" ]; then
|
||||||
|
COUNT="$(grep -oiE '\bfail(ure)?s?[:= ]+[0-9]+' "$LOG" | tail -1 | grep -oE '[0-9]+$' || true)"
|
||||||
|
fi
|
||||||
|
COUNTED_BY="the runner's summary"
|
||||||
|
if [ -z "$COUNT" ]; then
|
||||||
|
COUNT="$(grep -cE "$FAIL_PATTERN" "$LOG" || true)"
|
||||||
|
COUNTED_BY="matching log lines, approximately — this runner printed no summary"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$COUNT" -gt 1 ]; then
|
||||||
|
echo "prove-guard: the guard caught it — but $COUNT failures, by $COUNTED_BY."
|
||||||
|
echo
|
||||||
|
echo "GUARDS.md §1: if breaking one thing fails three tests, two are"
|
||||||
|
echo "coincidental and will mask a real regression later behind a red you have"
|
||||||
|
echo "learned to expect. Narrow the guard, or narrow the mutation."
|
||||||
|
exit 3
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "prove-guard: good — the guard caught it, and only it ($COUNTED_BY)."
|
||||||
|
|
@ -0,0 +1,282 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Credentials, before they are committed.
|
||||||
|
#
|
||||||
|
# ## Why this and not a generic scanner
|
||||||
|
#
|
||||||
|
# A general-purpose secret scanner knows about AWS keys and GitHub tokens. It
|
||||||
|
# does not know that *this* deployment issues `pllc_agent_<hex>`, or that its
|
||||||
|
# encrypted envelopes start `v2:<keyid>:`, or which of its environment variables
|
||||||
|
# hold a password. The project does know, and usually writes it down twice: once
|
||||||
|
# in whatever redacts its logs, and once in whatever redacts its outbound
|
||||||
|
# messages.
|
||||||
|
#
|
||||||
|
# So this reads the project's own patterns where they exist — point
|
||||||
|
# `SECRETS_PATTERN_FILE` at the module holding them — and falls back to a
|
||||||
|
# conservative built-in set. A scanner tuned to the shapes a project actually
|
||||||
|
# issues catches the leak a generic one misses, and stays quiet the rest of the
|
||||||
|
# time.
|
||||||
|
#
|
||||||
|
# ## What it scans
|
||||||
|
#
|
||||||
|
# By default the **staged diff**, which is the only moment a commit can still be
|
||||||
|
# stopped cheaply. `--tracked` scans every tracked file instead, which is what
|
||||||
|
# you want once, on adoption, to find what is already in the history's tip.
|
||||||
|
#
|
||||||
|
# bash scripts/secrets.sh # staged changes (use in pre-commit)
|
||||||
|
# bash scripts/secrets.sh --tracked # everything tracked, for an audit
|
||||||
|
# bash scripts/secrets.sh --built dist/ # the artifact users receive
|
||||||
|
# SECRETS_PATTERN_FILE=src/lib/log.ts bash scripts/secrets.sh
|
||||||
|
# bash scripts/secrets.sh --allow docs/examples/
|
||||||
|
#
|
||||||
|
# ## --built, and why the repository is the wrong place to stop
|
||||||
|
#
|
||||||
|
# The two modes above scan what is in git. Neither sees the bundle, which is the
|
||||||
|
# only artifact a user actually receives — and a key can reach it without ever
|
||||||
|
# being committed, from an environment variable inlined at build time. Somebody
|
||||||
|
# auditing applications of this kind reported finding hardcoded credentials in
|
||||||
|
# the frontend bundle of seven of eight in a single week.
|
||||||
|
#
|
||||||
|
# So --built walks a build directory instead, with two tiers of result:
|
||||||
|
#
|
||||||
|
# findings, which fail eyJ (a JWT header), service_role, apikey=, Bearer,
|
||||||
|
# plus every pattern the other modes use
|
||||||
|
# noted, which do not anon, VITE_, REACT_APP_, NEXT_PUBLIC_
|
||||||
|
#
|
||||||
|
# The second tier is printed and changes nothing. Those prefixes mean
|
||||||
|
# "deliberately shipped to the browser", so failing on them would be a
|
||||||
|
# permanently red gate, and a gate that is always red is one everybody has
|
||||||
|
# learned to ignore. But they are worth *seeing* enumerated: a Supabase anon key
|
||||||
|
# is safe exactly as far as row-level security makes it safe, and knowing it is
|
||||||
|
# out there is the input to that judgement rather than a substitute for it.
|
||||||
|
#
|
||||||
|
# ## What it cannot do
|
||||||
|
#
|
||||||
|
# It reads the working tree and the index. **A secret already committed is still
|
||||||
|
# in the history** after you delete it, and this will not tell you that — the
|
||||||
|
# fix there is a rotation, not a scan. Rotate first, then clean up.
|
||||||
|
#
|
||||||
|
# Exit codes: 0 nothing found. 1 a candidate found. 2 nothing was scanned.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
cd "$(git rev-parse --show-toplevel 2>/dev/null)" || {
|
||||||
|
printf 'secrets: not a git repository.\n' >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
|
||||||
|
say() { printf 'secrets: %s\n' "$*" >&2; }
|
||||||
|
|
||||||
|
# The delimiter for the masking substitution below. A real control byte, because
|
||||||
|
# these patterns contain both `/` and `|` and either would end the expression
|
||||||
|
# early. It cannot occur in a pattern and it cannot occur in source text.
|
||||||
|
MASK_D=$'\001'
|
||||||
|
|
||||||
|
MODE="staged"
|
||||||
|
BUILT_DIR=""
|
||||||
|
ALLOW=()
|
||||||
|
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--tracked) MODE="tracked"; shift ;;
|
||||||
|
--staged) MODE="staged"; shift ;;
|
||||||
|
--built)
|
||||||
|
MODE="built"
|
||||||
|
BUILT_DIR="${2:-}"
|
||||||
|
[ -n "$BUILT_DIR" ] || { say "--built needs a directory (dist/, build/, .next/…)"; exit 2; }
|
||||||
|
case "$BUILT_DIR" in -*) say "--built needs a directory, got '$BUILT_DIR'"; exit 2 ;; esac
|
||||||
|
shift 2 ;;
|
||||||
|
--allow) ALLOW+=("${2:-}"); shift 2 ;;
|
||||||
|
*) say "unknown argument: $1"; exit 2 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# Shapes that reach a bundle and should not. Added to PATTERNS below only in
|
||||||
|
# --built mode: `eyJ` is the base64 of `{"` that every JWT header starts with,
|
||||||
|
# and it is far too eager to run against source, where it matches ordinary
|
||||||
|
# base64. In a bundle it is worth the noise.
|
||||||
|
BUILT_PATTERNS=(
|
||||||
|
# All three segments, not just the header. Masking removes exactly what the
|
||||||
|
# pattern matched, so a pattern that stops at the first dot redacts `eyJ...`
|
||||||
|
# and prints the payload and signature beside it -- which is the token.
|
||||||
|
'eyJ[A-Za-z0-9_-]{10,}(\.[A-Za-z0-9_-]+){0,2}'
|
||||||
|
'\bservice_role\b'
|
||||||
|
'\bapikey["'"'"'[:space:]]*[:=]'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Shapes that are *meant* to be public. Reported, never failed on — see the
|
||||||
|
# header. A finding you cannot act on is a finding that teaches people to skip
|
||||||
|
# the report.
|
||||||
|
NOTED_PATTERNS=(
|
||||||
|
'\bNEXT_PUBLIC_[A-Z0-9_]+'
|
||||||
|
'\bVITE_[A-Z0-9_]+'
|
||||||
|
'\bREACT_APP_[A-Z0-9_]+'
|
||||||
|
'\banon["'"'"'[:space:]]*[:=]'
|
||||||
|
)
|
||||||
|
|
||||||
|
# The built-in set. Deliberately shapes that are *structurally* credential-like
|
||||||
|
# rather than words that merely appear near credentials — `password` in a
|
||||||
|
# sentence is not a leak, and a scanner that says it is gets muted.
|
||||||
|
PATTERNS=(
|
||||||
|
'//[^/@[:space:]:]+:[^/@[:space:]]+@' # user:pass@host in a URL
|
||||||
|
'[?&](token|key|secret|password|access_token|api_key)=[^&[:space:]"]+'
|
||||||
|
'\b(Bearer|Basic)[[:space:]]+[A-Za-z0-9._~+/=-]{20,}' # an authorization header
|
||||||
|
# Anchored to the start of a line or an `export`, because unanchored it
|
||||||
|
# matched `access_token = $1` in SQL and `apiKey=` in a property list — three
|
||||||
|
# findings in src/ that were column names, not credentials.
|
||||||
|
'(^|export )[A-Z][A-Z0-9_]*(SECRET|TOKEN|PASSWORD|API_KEY|PASSWD)[A-Z0-9_]*=[^[:space:]"'"'"']{8,}'
|
||||||
|
'-----BEGIN [A-Z ]*PRIVATE KEY-----'
|
||||||
|
'\bghp_[A-Za-z0-9]{20,}' # GitHub
|
||||||
|
'\bxox[baprs]-[A-Za-z0-9-]{10,}' # Slack
|
||||||
|
'\bAKIA[0-9A-Z]{16}\b' # AWS access key id
|
||||||
|
)
|
||||||
|
|
||||||
|
# The project's own shapes, if it has written them down. A `pllc_agent_<hex>`
|
||||||
|
# token is invisible to every generic scanner and obvious to the module that
|
||||||
|
# redacts it.
|
||||||
|
if [ -n "${SECRETS_PATTERN_FILE:-}" ] && [ -f "$SECRETS_PATTERN_FILE" ]; then
|
||||||
|
loaded=0
|
||||||
|
|
||||||
|
# Anchored on the closing `/flags,` and greedy to it, rather than on "no
|
||||||
|
# commas". The first version used `[^,]+`, which cannot cross the comma inside
|
||||||
|
# a bounded quantifier — so `pllc_[a-z]+_[0-9a-f]{8,}` was silently dropped
|
||||||
|
# along with every other `{n,}` pattern: four of six on the file this was
|
||||||
|
# written against, while the script printed that it had loaded them.
|
||||||
|
while IFS= read -r found; do
|
||||||
|
[ -n "$found" ] || continue
|
||||||
|
|
||||||
|
PATTERNS+=("$found")
|
||||||
|
loaded=$((loaded + 1))
|
||||||
|
done < <(
|
||||||
|
sed -nE 's/.*\[\/(.+)\/[gimsuy]*,[[:space:]]*".*/\1/p' "$SECRETS_PATTERN_FILE" 2>/dev/null || true
|
||||||
|
)
|
||||||
|
|
||||||
|
# The count, never a bare reassurance. "Loaded project patterns" over an empty
|
||||||
|
# list is the same lie as a green test run that executed nothing.
|
||||||
|
if [ "$loaded" -gt 0 ]; then
|
||||||
|
say "loaded $loaded project pattern(s) from $SECRETS_PATTERN_FILE"
|
||||||
|
else
|
||||||
|
say "WARNING: $SECRETS_PATTERN_FILE yielded no patterns — scanning with the"
|
||||||
|
say " built-in set only. Check the file holds regex literals."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$MODE" = "built" ]; then
|
||||||
|
[ -d "$BUILT_DIR" ] || { say "no such directory: $BUILT_DIR"; say "Nothing was scanned, which is not a pass."; exit 2; }
|
||||||
|
|
||||||
|
CONTENT=""
|
||||||
|
WHAT="the built output in $BUILT_DIR"
|
||||||
|
PATTERNS+=("${BUILT_PATTERNS[@]}")
|
||||||
|
|
||||||
|
while IFS= read -r file; do
|
||||||
|
skip=""
|
||||||
|
for allowed in ${ALLOW[@]+"${ALLOW[@]}"}; do
|
||||||
|
case "$file" in *"$allowed"*) skip="yes" ;; esac
|
||||||
|
done
|
||||||
|
[ -n "$skip" ] && continue
|
||||||
|
|
||||||
|
# Source maps are the build's own copy of the source and would double every
|
||||||
|
# finding; they are worth scanning on purpose, not by accident.
|
||||||
|
case "$file" in *.map) continue ;; esac
|
||||||
|
|
||||||
|
file "$file" 2>/dev/null | grep -q "text" || continue
|
||||||
|
|
||||||
|
# Relative to the build directory, not the absolute path find produced.
|
||||||
|
# The report truncates each line to keep a secret off the terminal, and an
|
||||||
|
# absolute path in a temp directory can consume that budget entirely --
|
||||||
|
# leaving a finding that names a file and shows nothing about the match.
|
||||||
|
rel="${file#"$BUILT_DIR"/}"
|
||||||
|
CONTENT+="$(sed "s|^|${rel}: |" "$file")"$'\n'
|
||||||
|
done < <(find "$BUILT_DIR" -type f -size -20M 2>/dev/null)
|
||||||
|
|
||||||
|
elif [ "$MODE" = "staged" ]; then
|
||||||
|
# Added lines only. A removed line containing a token is somebody deleting
|
||||||
|
# one, which is the opposite of a leak.
|
||||||
|
CONTENT="$(git diff --cached --unified=0 --no-color | grep '^+' | grep -v '^+++' || true)"
|
||||||
|
WHAT="staged changes"
|
||||||
|
else
|
||||||
|
CONTENT=""
|
||||||
|
WHAT="tracked files"
|
||||||
|
|
||||||
|
while IFS= read -r file; do
|
||||||
|
skip=""
|
||||||
|
|
||||||
|
for allowed in ${ALLOW[@]+"${ALLOW[@]}"}; do
|
||||||
|
case "$file" in *"$allowed"*) skip="yes" ;; esac
|
||||||
|
done
|
||||||
|
|
||||||
|
[ -n "$skip" ] && continue
|
||||||
|
[ -f "$file" ] || continue
|
||||||
|
|
||||||
|
# Text only; a webp full of bytes will match anything.
|
||||||
|
file "$file" 2>/dev/null | grep -q "text" || continue
|
||||||
|
|
||||||
|
CONTENT+="$(sed "s|^|${file}: |" "$file")"$'\n'
|
||||||
|
done < <(git ls-files)
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$CONTENT" ]; then
|
||||||
|
say "nothing to scan in $WHAT."
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
found=0
|
||||||
|
|
||||||
|
for pattern in "${PATTERNS[@]}"; do
|
||||||
|
while IFS= read -r hit; do
|
||||||
|
[ -n "$hit" ] || continue
|
||||||
|
|
||||||
|
skip=""
|
||||||
|
for allowed in ${ALLOW[@]+"${ALLOW[@]}"}; do
|
||||||
|
case "$hit" in *"$allowed"*) skip="yes" ;; esac
|
||||||
|
done
|
||||||
|
[ -n "$skip" ] && continue
|
||||||
|
|
||||||
|
# The match is masked, then the line is truncated. Truncation alone was not
|
||||||
|
# enough and used to be all there was: it bounds how much of a LONG value
|
||||||
|
# reaches the terminal and prints a short one whole, so the scanner
|
||||||
|
# published the very thing it was built to find — to the scrollback, the CI
|
||||||
|
# log, and wherever that log is shipped.
|
||||||
|
#
|
||||||
|
# \001 as the delimiter, because these patterns contain both `/` and `|`
|
||||||
|
# and either would end the expression early. It cannot occur in a pattern
|
||||||
|
# and it cannot occur in the text of a source file.
|
||||||
|
masked="$(printf '%s' "$hit" | sed -E "s${MASK_D}${pattern}${MASK_D}[redacted]${MASK_D}g" 2>/dev/null)"
|
||||||
|
[ -n "$masked" ] || masked="[a line matching a credential pattern, unprintable]"
|
||||||
|
printf ' %.120s…\n' "$masked"
|
||||||
|
found=$((found + 1))
|
||||||
|
done < <(printf '%s\n' "$CONTENT" | grep -nEI "$pattern" 2>/dev/null | head -20)
|
||||||
|
done
|
||||||
|
|
||||||
|
# The public-by-design tier. Printed, counted, and deliberately not fatal.
|
||||||
|
if [ "$MODE" = "built" ]; then
|
||||||
|
noted=0
|
||||||
|
|
||||||
|
for pattern in "${NOTED_PATTERNS[@]}"; do
|
||||||
|
while IFS= read -r hit; do
|
||||||
|
[ -n "$hit" ] || continue
|
||||||
|
if [ "$noted" -eq 0 ]; then
|
||||||
|
say "shipped to the browser on purpose — check each is meant to be public:"
|
||||||
|
fi
|
||||||
|
printf ' %.120s…\n' "$hit"
|
||||||
|
noted=$((noted + 1))
|
||||||
|
done < <(printf '%s\n' "$CONTENT" | grep -oEI "$pattern" 2>/dev/null | sort -u | head -20)
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$noted" -gt 0 ]; then
|
||||||
|
say "$noted public reference(s) above. Not a failure: those prefixes mean"
|
||||||
|
say "the value was compiled in deliberately. A Supabase anon key is safe"
|
||||||
|
say "exactly as far as row-level security makes it safe — this is the input"
|
||||||
|
say "to that judgement, not a substitute for it."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$found" -gt 0 ]; then
|
||||||
|
say "$found candidate credential(s) in $WHAT."
|
||||||
|
say "If one is real: rotate it first. Deleting the line does not remove it"
|
||||||
|
say "from a commit that already exists, and the scan cannot see history."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
say "no credential shapes in $WHAT."
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
google {
|
||||||
|
content {
|
||||||
|
includeGroupByRegex("com\\.android.*")
|
||||||
|
includeGroupByRegex("com\\.google.*")
|
||||||
|
includeGroupByRegex("androidx.*")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencyResolutionManagement {
|
||||||
|
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rootProject.name = "Period"
|
||||||
|
|
||||||
|
// Four modules today. core/database and core/datastore arrive with Batch 01
|
||||||
|
// issues #4 and #5 — see docs/architecture/README.md for why a module is not
|
||||||
|
// created before it has contents.
|
||||||
|
include(":app")
|
||||||
|
include(":core:designsystem")
|
||||||
|
include(":domain:cycle")
|
||||||
|
include(":domain:prediction")
|
||||||
Loading…
Reference in New Issue