diff --git a/CLAUDE.md b/CLAUDE.md index 912dd13..4b90f42 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,8 @@ reversed once. The reasoning, the measurements and the history are frozen verbat `../.archive/climb-trainer-CLAUDE-2026-09-03.md` (one level above the repo root). **Grep the archive by the heading named on the tripwire line; never `Read` it whole.** Guard docstrings elsewhere in this repo cite section headings that now live only in that archive. -**To add a line here, archive one.** +**To add a line here, archive one.** A finding is triaged the moment it is found — GUARD, +TRIPWIRE, ARCHIVE or DELETE — and there is nowhere to stage one. Most findings end in DELETE. ## Tripwires @@ -20,8 +21,8 @@ One line each; `→` names the archive heading that holds the reasoning. - Never add a `requirements.txt`: `pyproject.toml` wins and the requirements file is silently ignored → *3. Never add a `requirements.txt`* - Never give a secret a `VITE_*` prefix — the repo is public and the bundle is plain text; the one injected build-time value, `__BUILD_ID__`, is deliberately NOT a `VITE_*` var and must not become configurable → *4. `VITE_*` is PUBLIC, by definition* - The function region must match Neon's, which is fixed at project creation → *5. Function region and Neon region must match* -- Never reflect raw request headers; diagnostics use an allowlist of names, because Vercel injects a live `x-vercel-oidc-token` on every request → *6. Never reflect raw request headers* -- Never delete or raise `maxDuration` on `api/index.py` — it is a correctness setting, the thing that makes the client's auth abort an *outer* bound → *7. `maxDuration` is pinned* +- Never reflect raw request headers, and any diagnostics you add must allowlist the names it returns — Vercel injects a live `x-vercel-oidc-token` on every request, which is a fact about the platform and appears nowhere in this repo to grep for → *6. Never reflect raw request headers* +- Never delete or raise `maxDuration` on `api/index.py` — it is a correctness setting, the thing that makes the client's auth abort an *outer* bound → *7. `functions."api/index.py".maxDuration` is pinned* - Do not move the app into `api/`, do not delete the `sys.path` line in `api/index.py`, and add any new `server/` subpackage to `[tool.setuptools] packages` in the same commit — there is no autodiscovery → *Repo layout — do not rearrange it* ### Frontend, router, MF, PWA @@ -38,10 +39,10 @@ One line each; `→` names the archive heading that holds the reasoning. - Text over a photograph on the landing page is legal ONLY behind the `--ct-scrim` overlay, and the lightest stop under any copy — `0.66` on `&__band` — is the measured 4.5:1 floor: do not lighten a stop without redoing that arithmetic → *Landing imagery* - `web/scripts/gen-landing-images.mjs` is an authoring tool and must never enter `build` → *Landing imagery* - Icons are SVG components, never ``, and an icon-only control owes its own `aria-label` → *Landing imagery* · *The nav's thresholds are MEASUREMENTS* -- Generated API types are COMMITTED: regenerate with `npm run codegen:api`, never loosen the `openapi-sha256` digest header, and never recreate `web/src/api/vocabularies.ts`; a FastAPI or Pydantic bump fails that test and Dependabot cannot fix it → *OpenAPI codegen* -- PWA: `registerType: 'autoUpdate'` with `injectRegister: null`; the asset generator is deliberately not a devDependency and its config stays plain JS → *PWA — only the decisions a reader would otherwise reverse* +- Generated API types are COMMITTED: regenerate with `npm run codegen:api` and never loosen the `openapi-sha256` digest header; a FastAPI or Pydantic bump fails that test and Dependabot cannot fix it → *OpenAPI codegen* +- PWA: the asset generator is deliberately not a devDependency and its config stays plain JS → *PWA — only the decisions a reader would otherwise reverse* - `&__prose` is `56ch` and the number is MEASURED — do not "fix" it up to the usual `65ch` → *The reading measure is a GRID COLUMN* -- The four screen sizes are NAMED container sizes and some widths are deliberately not on the scale; read them out of `web/src/styles/_sizes.scss` rather than inventing one → *The four screen sizes are NAMED* +- The four screen sizes are NAMED container sizes — three names in `web/src/styles/_sizes.scss` plus the unnamed base below the first — so read a width out of that file rather than inventing one; the px-to-rem arithmetic and the widths deliberately NOT on the scale are archive-only, because a stylesheet's prose cap is ZERO → *The four screen sizes are NAMED* - The reading measure and the inline gutters are a GRID COLUMN in `web/src/styles/_layout.scss` — never `max-inline-size` or `padding-inline` back on `.ct-app`, because nothing inside a capped box can reach the screen edge and the landing page must - `100cqi` is what makes the wide-column escape legal where `50% - 50vw` is banned, and a length unit resolves against the NEAREST container and cannot be aimed at a name — never add a `container-type` between `.ct-app` and a `cqi` consumer - `.ct-app`'s `isolation: isolate` is load-bearing: it is the only thing stopping an app `z-index` painting over the shell's own chrome @@ -86,11 +87,11 @@ One line each; `→` names the archive heading that holds the reasoning. - Bound parameters only: never an f-string, `%`, `.format()`, `+`, or interpolated `text()`. Identifiers cannot be parameterised — use an allowlist → *Bound parameters only — never string-built SQL* · *Identifiers cannot be parameterised* - A 422 must never echo the request back and FastAPI's default handler does, so do not remove the custom one; never build an ORM object by splatting request data → *Validate at the edge with Pydantic* - Notes are untrusted on OUTPUT too: build DOM nodes, never assemble an HTML string → *Notes are untrusted on OUTPUT too* -- When you add a free-text column, add its row to the inventory in the SAME PR — that table has been wrong three times and every time the new field did not look like "a note" (`logged_session.location`, `user_injury.note`, `invite.label`, all bound by the output-escaping rule too); the bounds themselves live in `server/fields.py` and are proven by `tests/test_profile_validation.py` → *The free-text inventory — ELEVEN fields, and three of them get forgotten* +- When you add a free-text column, add its row to the inventory in the SAME PR — that table has been wrong on three separate OCCASIONS (fixes, not the sites disagreeing today, which is #139's) and every time the new field did not look like "a note" (`logged_session.location`, `user_injury.note`, `invite.label`, all bound by the output-escaping rule too); the bounds are SPLIT — the numbers in `server/models.py`, the Pydantic types over them in `server/fields.py` — and not every field on the inventory has either, while `tests/test_profile_validation.py` proves only the profile patch's own fields: none of that is proof of COVERAGE, which is open in #139 → *The free-text inventory — ELEVEN fields, and three of them get forgotten* - Never set `Cross-Origin-Resource-Policy` or `Cross-Origin-Embedder-Policy`, and we deliberately do not set HSTS → *Security response headers* - Drop the token before EVERY `POST /api/auth/*`, not just login and register; demo scope re-mints and cannot refresh → *Auth UI — the client half of the contract* - The client's give-up deadline must stay the OUTER bound and must never clear `inFlight`, and the UI tier deliberately does not release the Web Lock → *Auth UI — the client half of the contract* -- Every route must be in `PUBLIC_ROUTE_IDS` or under `_authed`, and the route guard never reads `window.location` → *Auth UI — the client half of the contract* +- The `_authed` route guard never reads `window.location` → *Auth UI — the client half of the contract* - Registration is invite-gated: per-person digests in a table, never a shared env secret, and the rejection messages must never be split → *Registration is invite-gated* - Do not tick any of the end-to-end security verification off from memory → *TODO — the end-to-end security verification pass* @@ -110,7 +111,7 @@ One line each; `→` names the archive heading that holds the reasoning. - Never re-dose `lead_route_doubles`, `campus_ladders` or `hangboard_repeaters` off the sources — all three corrections were declined and the refusals stand: four laps contradicts the row's own key and a rename is a data migration, the source's campus ladder is a different up-and-down exercise with no row here, and run-to-failure repeaters is another protocol the source itself calls the least effective - There is no abandon endpoint, and an `IntegrityError` is never re-raised → *Persisting a plan* - An item is done or not — no skipped state on the server — and completion is the blocks at 100%, never the Finish button → *Logging a session* · *Session player invariants* -- The `sets` array is a DELTA, not a replacement, and `set_index` is the whole session's 1..N ordinal → *The `sets` array is a DELTA, not a replacement* +- The `sets` array is a DELTA, not a replacement, and `logged_set.set_index` is the whole logged session's 1..N ordinal — `prescribed_set.set_index` is NOT the same thing, it is scoped to its block by `UniqueConstraint("session_block_id", "set_index")`, so this line does not cover a reader editing `prescribed_set` → *The `sets` array is a DELTA, not a replacement* - `duration_minutes` only ever grows, and a session's status never moves backwards → *`duration_minutes` only ever grows* - A 4xx on flush is PERMANENT — quarantine it, never retry; 5xx is retryable → *Logging a session* - Which sets a block owns is `prescribed_set_id` membership, never an ordinal window → *Session player invariants* @@ -127,12 +128,11 @@ One line each; `→` names the archive heading that holds the reasoning. - Never put a database URL in `.env` — the test URL lives in `CT_TEST_DATABASE_URL` and nowhere else, exported from `~/.zshrc`, which a non-interactive shell does not read → *Local Postgres for the test suite* - An exported variable beats the file, and the Vite dev proxy is NOT Vercel's rewrite → *`.env` is loaded for you — but only outside Vercel* - The dev database and the test database are the same database, and a local database means LOCAL ACCOUNTS ONLY → *Local Postgres for the test suite* · *Local development* -- A dev server running during the gate can blank every route; the trigger is UNCONFIRMED, so do not substitute a fresh guess for the recorded one → *A dev server and the gate at the same time can blank every route* +- A dev server running during the gate can blank every route; the trigger is UNCONFIRMED, so do not substitute a fresh guess for the recorded one, and recovery is a restart of BOTH servers — Vite with `web/node_modules/.vite` deleted, a plain restart having not been enough, and `uvicorn --reload`, because a stale one 404s any route added since it booted → *A dev server and the gate at the same time can blank every route* - A guard test must be SHOWN to fail before it is trusted: break the thing, capture the red, restore, and put the failure in the PR → *A guard test must be SHOWN to fail* - Measure the counterfactual before shipping a mechanism somebody prescribed, and let the sabotage decide which condition is load-bearing — three in one PR measured byte-identical over the whole sweep and were dropped, one of them green under the very thing it existed to deliver → *Three prescribed mechanisms measured BYTE-IDENTICAL* - A class name in markup with no CSS fails SILENTLY, and interpolated class names are that guard's one blind spot → *A class name in markup with no CSS fails SILENTLY* -- Prose is capped and an executable claim must not be prose: plain comments 2 lines, module docstrings 10, wire-contract docstrings 20; over-cap needs a row in `tests/comment_budget_allowlist.toml` with a real reason, and `BASELINE_RATCHET` may only go down → *Prose is capped, and an executable claim must not be prose* -- Never weaken the generated digest header to satisfy gitleaks, and `useDefault = true` must stay in the gitleaks config or the default ruleset is REPLACED → *Quality gate* +- Prose is capped and an executable claim must not be prose: plain comments 2 lines, module docstrings 10, wire-contract docstrings 20; over-cap needs a row in `tests/comment_budget_allowlist.toml` with a real reason, and `BASELINE_RATCHET` may only go down. A stylesheet's cap is ZERO and takes no allowlist row at all — delete the comment → *Prose is capped, and an executable claim must not be prose* ## Quality gate @@ -159,21 +159,6 @@ Working agreement: - Test critical logic, core user paths and anything that can lose user data; skip static or presentational UI, and ask rather than defaulting to writing a test → *Testing policy*. -## Findings inbox — one line each, dated. Emptied at every promotion to main. - -New findings land HERE, never as a new `##` section. At each promotion every line goes to -exactly ONE of these, then leaves the inbox: - 1. a GUARD — the claim is executable, so it becomes a test (best outcome) - 2. a TRIPWIRE — a prohibition nobody could infer from working code (one line, stays above) - 3. the ARCHIVE — reasoning, or history - 4. DELETED — it did not matter after all (most lines should end here) - -- 2026-09-08: `sessions/routes.py::_fold_sessions` drops its last session whenever the row count lands exactly on `_COMPLETION_ROWS_MAX` with nothing actually cut — a false-positive truncation, and that endpoint has no flag telling a client it happened. `journal`'s read fetches `cap + 1` instead. Found while building `GET /api/journal`. -- 2026-09-08: recovering the blanked dev server is **restart Vite with `web/node_modules/.vite` deleted** — a plain restart was not enough (Kilian: "always do the restart of the vite server and clean the cache"). Observed right after `npm run check:web` ran while his server was up, which matches the recorded symptom; the trigger itself is still UNCONFIRMED. A stale `uvicorn --reload` also 404s a route added since it booted, so restart both. -- 2026-09-08: **`.scss` is outside the prose budget** — `SCOPED_SUFFIXES` covers `.py`, `.ts`, `.tsx`, `.yml`, `.yaml` only, so a stylesheet's comments are uncapped and unread by `tests/test_comment_budget.py`. `_diary.scss` now carries ~33 lines of chart and full-height-chain doctrine that a `.ts` module docstring's 10-line cap would have refused. Executable, so it is a GUARD: add `.scss` and ratchet, or decide stylesheets are exempt on purpose and say so where the suffix list lives. -- 2026-09-09: `JournalResponse.trends` (`body_weight_kg` and `body_weight_direction`) now has **no client consumer**: the diary's Body weight section was deleted outright (Kilian) and the weigh-in is a column of the readings table, which reads the entries themselves. The wire and the server are deliberately untouched — triage it with the unused columns/wire issue at the next promotion. -- 2026-09-08: **the web suite has a flake class, not a flake.** `diaryScreen.test.tsx`'s `settle()` (one macrotask) is wrong for any click that triggers a SECOND read, and `sessionReminder.test.tsx:131` timed out once under the full 62-file parallel run on a bare `findByRole` that passes alone. Both are the harness racing a real fetch, not the app. Two sightings in one day, so it is worth a look before it lands in CI as an intermittent red. - ## Where things live - `README.md` — the pitch only: *What it does* and *Stack*. No section may return to it, and no ten-word run of prose may live in both files. diff --git a/package.json b/package.json index 53d7435..24a8dbf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "climb-trainer", - "version": "9.2.0", + "version": "9.3.0", "private": true, "description": "Climbing training app — plan generator, guided session player, training diary", "engines": { diff --git a/tests/comment_budget_allowlist.toml b/tests/comment_budget_allowlist.toml index b618d00..16ccc07 100644 --- a/tests/comment_budget_allowlist.toml +++ b/tests/comment_budget_allowlist.toml @@ -1075,7 +1075,7 @@ reason = "BASELINE 2026-08-27 - not yet reviewed. Trim or justify." path = "server/domain/planner/generate.py" kind = "docstring" anchor = "server.domain.planner.generate" -limit = 28 +limit = 27 reason = "The four invariants are priority-ordered and the fourth exists only because the second never relaxes; and two user-facing safety rules bind every string this module builds." [[exception]] @@ -1138,7 +1138,7 @@ reason = "BASELINE 2026-08-27 - not yet reviewed. Trim or justify." path = "server/domain/planner/periodisation.py" kind = "docstring" anchor = "server.domain.planner.periodisation" -limit = 33 +limit = 31 reason = "BASELINE 2026-08-27 - not yet reviewed. Trim or justify." [[exception]] @@ -1362,7 +1362,7 @@ reason = "BASELINE 2026-08-27 - not yet reviewed. Trim or justify." path = "server/fields.py" kind = "docstring" anchor = "server.fields" -limit = 31 +limit = 23 reason = "BASELINE 2026-08-27 - not yet reviewed. Trim or justify." [[exception]] @@ -2139,7 +2139,7 @@ reason = "BASELINE 2026-08-27 - not yet reviewed. Trim or justify." path = "server/vocabulary/routes.py" kind = "docstring" anchor = "server.vocabulary.routes" -limit = 37 +limit = 34 reason = "BASELINE 2026-08-27 - not yet reviewed. Trim or justify." [[exception]] @@ -5268,7 +5268,7 @@ reason = "BASELINE 2026-08-27 - not yet reviewed. Trim or justify." path = "web/src/main.tsx" kind = "block" anchor = "standalone entry climb kilianmc" -limit = 13 +limit = 10 reason = "BASELINE 2026-08-27 - not yet reviewed. Trim or justify." [[exception]] @@ -5401,7 +5401,7 @@ reason = "BASELINE 2026-08-27 - not yet reviewed. Trim or justify." path = "web/src/plan/api.ts" kind = "block" anchor = "the one read that costs a generation" -limit = 35 +limit = 34 reason = "BASELINE 2026-08-27 - not yet reviewed. Trim or justify." [[exception]] @@ -6206,7 +6206,7 @@ reason = "BASELINE 2026-08-27 - not yet reviewed. Trim or justify." path = "web/src/profile/grades.ts" kind = "block" anchor = "which grades the pickers offer" -limit = 23 +limit = 22 reason = "Three reasons the grade floor stays client-side: the seeded ladder must stay complete for convert(), a shared cached response must carry no product rule, and a stored below-floor grade must still render." [[exception]] @@ -6384,13 +6384,6 @@ anchor = "virtual pwa register is created" limit = 4 reason = "BASELINE 2026-08-27 - not yet reviewed. Trim or justify." -[[exception]] -path = "web/src/pwaContract.test.ts" -kind = "block" -anchor = "four pwa properties claude md records" -limit = 20 -reason = "Four config properties whose breach is silent; each bullet names the failure the setting prevents, which is the only place that mapping is written down." - [[exception]] path = "web/src/registerSubmit.test.tsx" kind = "block" @@ -6892,7 +6885,7 @@ reason = "BASELINE 2026-08-27 - not yet reviewed. Trim or justify." path = "web/src/routes/_authed/plan.lazy.tsx" kind = "slash_run" anchor = "the precedence a plan the climber" -limit = 4 +limit = 3 reason = "BASELINE 2026-08-27 - not yet reviewed. Trim or justify." [[exception]] @@ -6906,7 +6899,7 @@ reason = "BASELINE 2026-08-27 - not yet reviewed. Trim or justify." path = "web/src/routes/_authed/plan.lazy.tsx" kind = "block" anchor = "everything the climber can do about" -limit = 13 +limit = 10 reason = "BASELINE 2026-08-27 - not yet reviewed. Trim or justify." [[exception]] @@ -7221,7 +7214,7 @@ reason = "BASELINE 2026-08-27 - not yet reviewed. Trim or justify." path = "web/src/ui/CredentialsForm.tsx" kind = "block" anchor = "the one email password form shared" -limit = 26 +limit = 25 reason = "BASELINE 2026-08-27 - not yet reviewed. Trim or justify." [[exception]] @@ -7326,7 +7319,7 @@ reason = "BASELINE 2026-08-27 - not yet reviewed. Trim or justify." path = "web/src/ui/icons.tsx" kind = "block" anchor = "two classes not one the route is" -limit = 4 +limit = 3 reason = "BASELINE 2026-08-27 - not yet reviewed. Trim or justify." [[exception]] @@ -7459,7 +7452,7 @@ reason = "BASELINE 2026-08-27 - not yet reviewed. Trim or justify." path = "tests/test_sessions_log.py" kind = "docstring" anchor = "tests.test_sessions_log" -limit = 56 +limit = 54 reason = "CLAUDE.md requires a guard test to be SHOWN to fail: this carries the eleven sabotages and the exact red output of each, including the three that revealed a guard was weaker than it looked (the replay arm survives replace semantics, the planned-session 404 has a second layer, and the cardio 409 is the same status either way)." [[exception]] @@ -7620,21 +7613,21 @@ reason = "The five invariants the machine exists to hold: rAF not setInterval, p path = "web/src/session/useSessionRun.ts" kind = "block" anchor = "a timed set that ran to its boundary" -limit = 26 +limit = 3 reason = "The five invariants the machine exists to hold: rAF not setInterval, performance.now for maths, the ref-written countdown, one cue per tick, and that starting the SESSION starts no timer." [[exception]] path = "web/src/session/useSessionRun.ts" kind = "block" anchor = "one flush whatever triggered it" -limit = 26 +limit = 9 reason = "The five invariants the machine exists to hold: rAF not setInterval, performance.now for maths, the ref-written countdown, one cue per tick, and that starting the SESSION starts no timer." [[exception]] path = "web/src/session/useSessionRun.ts" kind = "block" anchor = "restart this phase re stamp the" -limit = 26 +limit = 5 reason = "The five invariants the machine exists to hold: rAF not setInterval, performance.now for maths, the ref-written countdown, one cue per tick, and that starting the SESSION starts no timer." [[exception]] @@ -7662,7 +7655,7 @@ reason = "Rejection and a hidden tab are normal outcomes, and the new paragraph path = "web/src/session/wakeLock.ts" kind = "block" anchor = "hold a screen wake lock for as" -limit = 11 +limit = 7 reason = "Rejection and a hidden tab are normal outcomes, and the new paragraph is load-bearing: this is safe to call when the preference is already on, which is what makes a press do something after the OS drops the lock." [[exception]] @@ -7704,7 +7697,7 @@ reason = "The app's FIRST overlay, so this is the precedent every later one copi path = "web/src/session/SessionSummary.tsx" kind = "block" anchor = "the last screen of every run and" -limit = 10 +limit = 8 reason = "Peak-end: why the run always ends here and never on the last set, and why the save state is a sentence rather than a spinner." [[exception]] @@ -8061,7 +8054,7 @@ reason = "The register's editing rule, the three keys origin/dev carried (the on path = "tests/test_planner_library_reach.py" kind = "hash_run" anchor = "both disciplines x all three bands" -limit = 12 +limit = 8 reason = "Records the MEASUREMENT that added the seventh profile: a candidate pool is indexed by a WEEK count, so 28-32-week plans and a 20-week plan do not sample the same pool positions. It also records that the row this was found on no longer depends on the short plan, which is the only way a reader can tell the profile is kept on the mechanism rather than on that one row." [[exception]] @@ -8089,7 +8082,7 @@ reason = "Why a length-driven slot abandons the rotation, and why it takes the l path = "tests/test_planner_climbing_floor.py" kind = "hash_run" anchor = "the dimension this sweeps is plan length and it is the one the gate was missing round 3 reordered wall pref" -limit = 16 +limit = 10 reason = "Records two MEASUREMENTS a reader cannot recover from the code: which dimension exposes the _wall_pref sabotage (plan length, re-measured at 65.8-68.6% against the 65 ceiling), and why the advanced ceiling is 65 - a re-baseline off deload weeks that dev itself already breached at 62.3%, then off §3.4's ordering, and due back down at the fixed 12-week plan length. Without it the next reader trims the gaps back, or reads 65 as a weakened guard." [[exception]] @@ -8173,7 +8166,7 @@ reason = "Names the coverage this re-scoped guard GAVE UP against the pairwise o path = "tests/test_planner_safety.py" kind = "docstring" anchor = "tests.test_planner_safety.test_the_generators_title_bound_IS_the_columns_width" -limit = 4 +limit = 3 reason = "Says why a second arm exists beside the sampled one: a title long enough to be refused by the column appeared on a profile the sampled arm does not generate." [[exception]] @@ -8411,7 +8404,7 @@ reason = "The floor exists for one caller in another module: the comment has to path = "tests/test_phase_guide.py" kind = "docstring" anchor = "tests.test_phase_guide.test_the_copys_POWER_ENDURANCE_AEROBIC_claim_is_a_DAY_COUNT_claim" -limit = 12 +limit = 10 reason = "Ruling 24's floor was revoked, so this guard is the whole mechanism behind an authored sentence and has to say so. It also has to state its GRANULARITY and why: the same claim read per profile is green on 18 of 18 while 12 of those 72 WEEKS hold none, and no day boundary repairs it - a reader who pooled it back would not know." [[exception]] @@ -8567,7 +8560,7 @@ reason = "The ruling 27 arm has to say why it cannot become a hole for a padded path = "tests/test_planner_climbing_floor.py" kind = "hash_run" anchor = "empty and that is a measurement ruling 27 s length fill repaired every one of the seven rows" -limit = 8 +limit = 7 reason = "An empty register with no history reads as a register nobody filled in. The seven repaired rows and their accepted losses are listed so a regression is recognisable; the mechanisms each was accepted on went with the rows." [[exception]] @@ -8581,28 +8574,28 @@ reason = "Records why the fixture stopped needing two three-block sessions, so t path = "tests/test_planner_climbing_floor.py" kind = "docstring" anchor = "tests.test_planner_climbing_floor.test_the_measured_climbing_share_lands_inside_its_bands_target_range" -limit = 14 +limit = 7 reason = "An OPEN RED on 108 rows has to carry its own numbers and the reason it is not re-based, or the next reader either reads it as new breakage or quietly widens the ceiling that ruling 27 says may not be widened without a sabotage proof." [[exception]] path = "server/domain/exercises.py" kind = "hash_run" anchor = "open climbing the length fill climb for fun is also training is kilian s doctrine" -limit = 17 +limit = 16 reason = "Ruling 29's attribution warning and ruling 30's whole design in one place: why the doctrine is Kilian's and never the sources', why the family is four rows rather than one (instructions sits on the spec, so per-phase cue text has no other home), and why the technique row is prescribed in every phase - it is the fallback that makes the fill a filter." [[exception]] path = "server/domain/planner/generate.py" kind = "hash_run" anchor = "ruling 29 made this a filter the filler family is ordered by the phase s own" -limit = 6 +limit = 5 reason = "Ruling 30's two invariants are one choice here, and the reason the walk exists at all: a day ruling 9's hard-energy ceiling has made easy cannot be credited with the block's own hard quality without putting that injury ceiling back." [[exception]] path = "server/domain/planner/selection.py" kind = "docstring" anchor = "server.domain.planner.selection.ordinary" -limit = 5 +limit = 4 reason = "Says what the subtraction is FOR rather than what it does: open climbing has no dose to progress, so it may only ever arrive as the length fill and never as a session's prescribed work." [[exception]] @@ -8623,84 +8616,84 @@ reason = "An import-time check owes its reason at the raise site: a phase whose path = "tests/test_exercise_library.py" kind = "hash_run" anchor = "ruling 30 s first invariant kilian 2026 09 06 add what is the intention on the block" -limit = 6 +limit = 5 reason = "Kilian's sentence, plus the containment trap that makes a plain membership assertion vacuous here: power endurance contains power, so the matcher deletes every other aspect name first and that has to be explained where the helper is." [[exception]] path = "tests/test_exercise_library.py" kind = "docstring" anchor = "tests.test_exercise_library.test_every_OPEN_CLIMBING_row_TELLS_THE_CLIMBER_WHAT_THE_BLOCK_IS_FOR" -limit = 10 +limit = 9 reason = "Three claims in one guard and each needs its reason: the cue is the only place a protocol-free block can state its intention, the dose has no progression because it is time on the wall, and the gear is a boulder wall so it is not gated the way the rope aerobic rows are." [[exception]] path = "tests/test_planner_climbing_floor.py" kind = "hash_run" anchor = "kilian s target bands this and the two tables below are restated independently of" -limit = 5 +limit = 4 reason = "The high edge is kept in the table and never asserted, which is exactly the kind of thing a reader deletes as dead; the run says why it stays and points at the reason constant." [[exception]] path = "tests/test_planner_climbing_floor.py" kind = "hash_run" anchor = "ruling 28 s replacement guard and the three regimes are wall pref s whole contract" -limit = 5 +limit = 4 reason = "Why this guard is a hand-built draft rather than a plan sweep: the promise is about candidate ORDER, which no finished plan records, and the week share that stood in for it stopped separating once the fill became the largest item in a session." [[exception]] path = "tests/test_planner_climbing_floor.py" kind = "hash_run" anchor = "one laps block so session window across gives the draft a 30 minute window floor" -limit = 4 +limit = 3 reason = "The fixture's two load-bearing choices: LAPS is what makes the `first` regime reachable at all, and _wall_pref reads wall_seconds off the draft rather than off the block's equipment, so the block's gear is deliberately arbitrary." [[exception]] path = "tests/test_planner_climbing_floor.py" kind = "docstring" anchor = "tests.test_planner_climbing_floor.test__wall_pref_WITHHOLDS_THE_WALL_from_a_session_at_its_bands_top" -limit = 13 +limit = 12 reason = "Three regimes, each with the measurement that put it there, plus the sabotage result that made this test necessary - 36 of 36 beginner rows stayed green under the band ceiling it replaces." [[exception]] path = "tests/test_planner_climbing_floor.py" kind = "hash_run" anchor = "honest 0 of 1632 with wall pref forced to first 14 the advanced band is where this" -limit = 5 +limit = 4 reason = "Names the granularity choice and its evidence: beginner and intermediate sessions legitimately end all-climbing (226 and 44 of 1632), so a zero at those levels would be a false claim rather than a stricter one." [[exception]] path = "tests/test_planner_climbing_floor.py" kind = "docstring" anchor = "tests.test_planner_climbing_floor.test_an_ADVANCED_loading_session_always_keeps_room_for_work_off_the_wall" -limit = 6 +limit = 5 reason = "Says what this arm covers that the unit arm cannot - a preference computed right and then ignored - and why an advanced session with nothing off the wall is the visible symptom." [[exception]] path = "tests/test_planner_climbing_floor.py" kind = "hash_run" anchor = "ruling 29 30 measured over 6 climbers x sessions 1 7 x gaps 0 3 6" -limit = 4 +limit = 3 reason = "The population the two zeros were measured over, without which a zero looks like an unmeasured aspiration rather than a count." [[exception]] path = "tests/test_planner_climbing_floor.py" kind = "docstring" anchor = "tests.test_planner_climbing_floor.test_the_LENGTH_FILL_is_ONE_block_carrying_THE_BLOCKS_OWN_INTENTION" -limit = 13 +limit = 12 reason = "Two arms from two different rulings, and the second needs its one exception spelled out: the fill walks off the block's own quality only on a day ruling 9's ceiling has made easy, which is why an off-lead fill on a hard day is the defect and not the design." [[exception]] path = "tests/test_planner_climbing_floor.py" kind = "hash_run" anchor = "ruling 23 s cause as the number it repaired before the boulder reachable row all four" -limit = 8 +limit = 7 reason = "Carries the 12-of-12 defect this repaired and, more importantly, the boundary: 12 boulder power-endurance weeks still hold no aerobic block and closing THAT is ruling 24's floor, which is not implemented - so the guard cannot be read as proving a per-week floor exists." [[exception]] path = "tests/test_planner_climbing_floor.py" kind = "docstring" anchor = "tests.test_planner_climbing_floor.test_a_BOULDERER_GETS_AEROBIC_WORK_in_the_power_endurance_block" -limit = 7 +limit = 6 reason = "Ruling 23's cause is not an emphasis-order problem and a reader will assume it is; and the dose arm has to say out loud that it reads only a boulderer's rows, or it will be mistaken for the aspect-wide RPE ceiling that lives in another file." [[exception]] @@ -8749,7 +8742,7 @@ reason = "Issue #100 was closed on the ground that this field is not dead payloa path = "tests/test_exercise_library.py" kind = "docstring" anchor = "tests.test_exercise_library.test_no_ENDURANCE_row_is_DOSED_OVER_THE_AEROBIC_CAPACITY_RPE_CEILING" -limit = 4 +limit = 3 reason = "Names F19 as the defect and says why the ceiling is aspect-wide rather than per-cell, which is what stops it being read as a duplicate of the boulder-reachability guard's dose arm." [[exception]] @@ -8770,7 +8763,7 @@ reason = "The denominator and the granularity, which a reader cannot recompute: path = "tests/test_exercise_library.py" kind = "docstring" anchor = "tests.test_exercise_library.test_the_SECTION_7_SHAPE_REGISTER_names_every_shape_the_library_actually_HAS" -limit = 4 +limit = 3 reason = "Both directions and what each one catches: an unnamed shape is a row no band reads, which is invisible from every other test, and a named shape the library has dropped is a stale exemption." [[exception]] @@ -8798,7 +8791,7 @@ reason = "Records what this register replaced: a ceiling = authored pin that gua path = "tests/test_planner_progression.py" kind = "hash_run" anchor = "anti vacuity floors 90 of what the sweep measured" -limit = 13 +limit = 12 reason = "Carries the measured cell counts the floors are 90% of, so a future reader can tell a drifted sweep from a rule that has stopped firing altogether. Names both re-bases: ruling 41's wall row cut the shorter-rest arm from 57 pairs to 15, and ruling 50 answered that by widening the sampling unit rather than by lowering a floor, which is the one thing a reader must not undo here." [[exception]] diff --git a/tests/test_claude_md_claims.py b/tests/test_claude_md_claims.py index 8e7c935..0b57639 100644 --- a/tests/test_claude_md_claims.py +++ b/tests/test_claude_md_claims.py @@ -32,16 +32,18 @@ PATH_PREFIXES: Final = ("server/", "web/", "tests/", "migrations/", "api/", "scripts/", ".github/") -# Paths CLAUDE.md names on purpose that do NOT exist. Each one is a deliberate statement about -# an absence, so it needs a reason rather than a filesystem hit. +# Paths that must NOT exist, whether or not any document still names one. Each carries the +# reason its absence is load-bearing; the arm below is what enforces it. ABSENT_PATHS: Final = { "web/src/api/vocabularies.ts": ( - "documented as GONE — the hand-written vocabulary mirror retired by PR #9's codegen. " - "The tripwire exists to stop somebody recreating it." + "must stay absent: a hand-written mirror of the vocabularies, retired by PR #9's " + "codegen. Recreating it splits one source of truth across a generated file and a " + "hand-edited one, and the hand-edited copy drifts without failing anything." ), } -# 19 paths are extracted today; the floor only has to be high enough to catch a broken regex. +# The floor only has to be high enough to catch a broken regex; the real count is asserted, +# so it must not be restated here where it would rot. PATH_FLOOR: Final = 15 # Curated LITERALS, not a regex: backticked `[A-Z_]{4,}` also yields `NULL`, `TIMESTAMPTZ` and diff --git a/web/src/profile/steps.tsx b/web/src/profile/steps.tsx index 612292e..2b198b4 100644 --- a/web/src/profile/steps.tsx +++ b/web/src/profile/steps.tsx @@ -20,10 +20,10 @@ import { DEFAULT_ASPECT_SCORE, STRENGTH_SCORE, WEAKNESS_SCORE, type ProfileDraft * * ## Closed inputs, everywhere * - * There is exactly one free-text field in this whole flow (an injury note, bounded at 500 - * characters server-side). Everything else is a select, a checkbox or a slider over a - * seeded vocabulary, submitted as ids — CLAUDE.md's cheapest injection defence is having - * nothing to inject into. + * These four groups hold exactly one free-text field (an injury note, bounded at 500 + * characters server-side). The display name belongs to NO step, so it is not here — it is + * the editor's Account section. Everything else is a select, a checkbox or a slider over a + * seeded vocabulary, submitted as ids: nothing to inject into is the cheapest defence. * * Per the testing policy these are not unit-tested: they render the props they were given. * What is tested is the part that can be wrong invisibly — `patchFor` in `draft.ts`, which diff --git a/web/src/pwaContract.test.ts b/web/src/pwaContract.test.ts index e2169fa..29847bb 100644 --- a/web/src/pwaContract.test.ts +++ b/web/src/pwaContract.test.ts @@ -7,29 +7,14 @@ import { describe, expect, it } from 'vitest'; import { stripComments } from './test/sourceScan'; -/** - * Four PWA properties CLAUDE.md records, none of which anything else in the gate could see. Modelled on `mf-contract.test.ts`, and for the same reason it gives: this is not - * config restated, it is a contract whose breach is **silent** — every check stays green and the - * damage lands on a visitor's phone. - * - * - **`runtimeCaching` for `/api`** would put authenticated JSON in Cache Storage, on disk, where - * it **survives logout** and nothing in the app clears it. - * - **Dropping the `/api` `navigateFallbackDenylist`** recreates deployment trap 2 inside the - * browser: the worker answers an API request with `index.html`, `res.ok` is true and - * `apiFetch` throws `NotJsonError` far from the cause. - * - **`registerType: 'prompt'`** waits for the page to ask for the new worker, and nothing in the - * app asks: there is no update prompt, so a precached build would never be taken up. - * - **`injectRegister` other than `null`** either double-registers (we register from `main.tsx`) - * or emits an inline script the production CSP's `script-src 'self'` blocks outright. - * - * Asserted against the SOURCE config rather than `dist/sw.js` deliberately: `distContract.test.ts` - * already introduces one ordering dependency on `build`, and there is no reason to add a second - * for a property the config states directly. - */ +/** Four VitePWA properties whose breach is SILENT: every check stays green and the damage lands + * on a visitor's phone. Each `it` below states the rule it enforces and why it matters. */ const CONFIG = stripComments( readFileSync(fileURLToPath(new URL('../vite.config.ts', import.meta.url)), 'utf8'), ); +// Read from the SOURCE config, not `dist/sw.js`: `distContract.test.ts` already owns the one +// ordering dependency on `build`, and every property here is stated directly in the config. /** Each detector takes source text, so the positive controls can run the real thing. */ const hasPromptRegisterType = (s: string) => /registerType:\s*'prompt'/.test(s); const hasAutoUpdate = (s: string) => /registerType:\s*'autoUpdate'/.test(s); @@ -45,20 +30,20 @@ describe('the PWA contract in vite.config.ts', () => { expect(CONFIG).toContain('navigateFallback:'); }); - it('activates a new worker itself rather than waiting to be asked', () => { + it('activates a new worker itself — `prompt` would wait for an update prompt the app never shows, so a precached build would never be taken up', () => { expect(hasAutoUpdate(CONFIG)).toBe(true); expect(hasPromptRegisterType(CONFIG)).toBe(false); }); - it('injects no registration of its own, so `main.tsx` stays the only one', () => { + it('injects no registration of its own — anything but null either double-registers alongside `main.tsx` or emits an inline script the CSP `script-src self` blocks', () => { expect(hasNullInjectRegister(CONFIG)).toBe(true); }); - it('keeps /api out of the navigation fallback', () => { + it('keeps /api out of the navigation fallback — without the denylist the worker answers an API request with index.html, `res.ok` is true, and `apiFetch` throws NotJsonError far from the cause', () => { expect(hasApiNavigateFallbackDenylist(CONFIG)).toBe(true); }); - it('caches no API response at runtime — not for /api, not for anything', () => { + it('caches no API response at runtime — `runtimeCaching` would put authenticated JSON in Cache Storage, on disk, where it survives logout and nothing clears it', () => { expect(hasRuntimeCaching(CONFIG)).toBe(false); }); });