Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 31 additions & 40 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,47 +36,38 @@ jobs:
- name: Tests
run: npm test -- --no-coverage

- name: Setup Deno
# Story 15-3: install Deno for Edge Function _shared utility tests.
# `denoland/setup-deno@v2` is the ACTION installer (second major
# release of the installer); `deno-version: v1.x` pins the Deno
# RUNTIME. Two independent version axes — don't conflate. Deno 2.0
# runtime migration is deferred to a follow-up story. The action
# caches the install for faster subsequent runs.
# SHA-pinning of this action (and actions/checkout / setup-node)
# deferred to `15-3-followup-action-sha-pinning` per R1 BH-1.
uses: denoland/setup-deno@v2
with:
deno-version: v1.x

- name: Deno tests (Edge Function _shared utilities)
# Story 15-3: wire Story 11-3 fetch-with-timeout + Story 12-11
# parse-upstream-error Deno tests into CI. Pre-15-3 these were
# manual-run only. Now gated on PR merge.
#
# Test files are named EXPLICITLY (R1 BH-2 / EH-1 vacuous-pass
# defense): if either file is renamed or moved, the step fails
# loudly with `Module not found` instead of silently passing on an
# empty directory match. A future file added to _shared/__tests__/
# must be appended here to be picked up.
- name: Tests with coverage threshold
# Story 15-6: enforce coverage floor on src/lib/ + src/hooks/ per
# Epic 15.6 deliverable. The previous `Tests` step runs without
# coverage instrumentation for fast green-light feedback; this step
# adds the slower instrumented run that fails CI if statements /
# branches / functions / lines drops below 40% on the configured
# scope. Per-directory thresholds (R1 BH-2/EH-4) enforce the spec
# conjunction (`src/lib/` AND `src/hooks/` ≥ 40%) — `global` alone
# would allow one dir to rot while the average stays above floor.
# Threshold pinned in `jest.config.js` `coverageThreshold`.
# Measured baseline at gate-introduction (2026-05-17): Statements
# 53.42% / Branches 55.80% / Functions 51.49% / Lines 54.12% — ~11
# points of headroom against the lowest metric. Ratchet up via
# future PRs as coverage grows.
#
# Permission flags:
# --allow-net=127.0.0.1 — required by the fetch-with-timeout
# happy-path test which spins up a local HTTP server.
# --no-check — runtime-only verification (R1 EH-10); the
# `npm run type-check` step already covers TS soundness for the
# source modules; Deno's redundant typecheck adds ~5s + a
# drift surface against std lib type updates.
# NO `--allow-all` — keep the permission surface minimal.
# pgTAP migrations (supabase/migrations/__tests__/*.sql) NOT wired
# here — requires a Postgres service container. Deferred to
# `15-3-followup-pgtap-ci-wiring`.
# Deno deps cache deferred to `15-3-followup-deno-cache` per R1 BH-4.
timeout-minutes: 3
run: |
deno test --no-check --allow-net=127.0.0.1 \
supabase/functions/_shared/__tests__/fetch-with-timeout_test.ts \
supabase/functions/_shared/__tests__/parse-upstream-error_test.ts
# CI cost: coverage instrumentation adds ~30-60% wall-clock vs the
# no-coverage `Tests` step (R1 BH-4 / EH-7). Two-step pattern keeps
# fast-feedback first; instrumented run second. Acceptable trade-off
# per Q1 RECOMMENDED.
run: npm run test:coverage

- name: Upload coverage report
# R1 BH-8 / EH-2: AC #5 explicitly noted artifact upload as
# "recommended" — operator triaging a coverage-gate failure needs
# the per-file lcov + HTML reports, not just the text-summary in
# CI logs. Runs even on failure so the failing PR has artifacts.
if: always()
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: coverage/
retention-days: 14

- name: Dependency vulnerability gate
# Story 12-10: prevent regression of `npm audit reports 0 high
Expand Down
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,13 @@ google-service-account.json
# Backup files (e.g., editor-generated foo.md.bak from accidental rename)
*.bak

# Story 15-6: jest --coverage output (lcov + HTML report). Generated by the
# `Tests with coverage threshold` CI step; never tracked in source.
# R1 BH-5: anchored to repo root via leading `/` so a future sub-directory
# legitimately named `coverage` (e.g., `docs/insurance-coverage/`) is not
# silently dropped.
/coverage/

# Metro
.metro-health-check*

Expand Down
4 changes: 4 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,7 @@ supabase/functions/
# operator-authored prose with intentional table alignment and is not subject
# to source-code formatting rules.
_bmad-output/

# Story 15-6: jest --coverage HTML report is auto-generated; not subject to
# prettier formatting.
coverage/
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ A reusable pattern for capping in-memory data structures + UI work-budgets. Two

**Lib unit tests — `srs.ts` SM-2 algorithm + `pronunciation.ts` `identifyWeakSounds` + `cache.ts` core API + `use-dictation.ts` `compareSentences`:** post-Epic-15.1, the FIRST Epic 15 (Test Coverage & QA Infrastructure) story closes the largest pure-function-test gap surfaced by the Story 12-10 audit refresh footnote. Per the coverage inventory at story-creation time, `activity.ts` was already fully tested (Story 9-2 + 12-3); `memory.ts` + `error-tracker.ts` had substantial partial coverage (sanitize via Story 9-4 prompt-injection.test.ts + dedup via Story 11-6 error-tracker-dedupe.test.ts + e2e clustering via 11-6); `pronunciation.ts` had history-cap coverage (Story 12-12) but NOT `identifyWeakSounds`. Story 15-1 lands **GAP-only scope** across **4 pure-function surfaces** (no source-module modifications — test-only): **(1) NEW [`src/lib/__tests__/srs.test.ts`](src/lib/__tests__/srs.test.ts)** (14 cases) pins the full SM-2 algorithm contract — 3 incorrect-response cases (quality 0/1/2 ease-factor deltas −0.80/−0.54/−0.32 + reset to `repetitions=0`, `intervalDays=1`); 3 correct-response cases (quality 3/4/5 ease-factor deltas −0.14/0/+0.10 + interval progression 1 → 6 → round(prev × ef)); 3 ease-factor 1.3-floor clamping cases (boundary + already-at-floor + just-above-floor); 3-consecutive-correct chain verification; 365-day interval cap; midnight-snap `nextReview` math via Date.now() before/after delta check; no-mutation invariant via input-snapshot equality + determinism via twice-call equivalence. **(2) NEW [`src/lib/__tests__/pronunciation.test.ts`](src/lib/__tests__/pronunciation.test.ts)** (10 cases) pins `identifyWeakSounds` contract — empty input + no-weak happy path + below-threshold-but-count<3 NOT flagged + single-phoneme-aggregated-across-results + same-phoneme-aggregated-across-words-in-one-result + threshold boundaries (`avgScore < 70` strict + `count >= 3` boundary) + multi-phoneme ranking ASCENDING by avgScore (worst first) + no-mutation deep-equal. **(3) NEW [`src/lib/__tests__/cache.test.ts`](src/lib/__tests__/cache.test.ts)** (20 cases) covers the AsyncStorage path (existing `cache-flush.test.ts` covers write-queue idempotency; existing `cache-secure-routing.test.ts` covers the Story 12-7 SecureStore fork) — `getCache` empty/fresh/TTL-fresh-boundary/TTL-expired-boundary/corrupted-JSON/throw paths each with `captureError` routing pinned; `setCache` envelope shape `{data, timestamp, ttlMs}` + DEFAULT_TTL_MS (1 hour) fallback + namespacing `@companion_cache:<userId>:<key>` (different userIds never collide); `setCache` → `getCache` round-trip with nested object structure; `invalidateCache` happy + error paths; `cacheWithFallback` 4-path matrix (fetcher-succeeds-writes-cache, fetcher-throws-stale-cache-hit-returns-fromCache:true, fetcher-throws-no-cache-rethrows-original, fetcher-throws-cache-also-throws-captures-fallback-read-error-rethrows-original); constant pins for `CACHE_KEYS` (5 keys) + `CACHE_TTL` (6 durations) + `SECURE_CACHE_KEYS` size===1 with PROFILE in / SKILLS+VOCABULARY out. **(4) NEW [`src/hooks/__tests__/use-dictation-compare.test.ts`](src/hooks/__tests__/use-dictation-compare.test.ts)** (15 cases) pins the pure word-by-word `compareSentences` helper from [`src/hooks/use-dictation.ts:88`](src/hooks/use-dictation.ts#L88) (despite living in a hook file, the function is pure — no React, no async, no side effects, so it fits 15-1 scope). Test mocks `@/src/hooks/use-audio-player` (transitive `expo-audio` native crash in Jest) + `@/src/lib/openai` (transitive supabase chain) at module load to keep the import boundary clean. Cases cover: perfect-match → 100% / one-wrong-word with `typed` populated → round(2/3 × 100)=67% / missing-word semantics (user shorter → past-user-length positions are "missing", overlap positions compare normally) / extra-word (user longer → extra tokens IGNORED; only original-length iterated) / empty-userInput → all "missing" / empty-original → empty wordResults + accuracy=0 + isFullyCorrect=true vacuously / both-empty same / case-insensitive ("Bonjour" vs "bonjour") with display preserving original capitalization / accent-insensitive ("café" vs "cafe") with display preserving accent / trailing punctuation stripped / internal comma stripped / multi-space collapses / leading+trailing whitespace trimmed / apostrophe stripped ("l'eau" matches "leau") / fresh wordResults array per call (no shared module-level cache). **3 operator decisions resolved per Recommended** (Q1 defer `assessPronunciation` Edge wrapper to 15-1-followup; Q2 defer `memory.ts` async/DB-touching paths to 15-2; Q3 defer `error-tracker.ts` non-dedup paths to 15-2) — keeps 15-1 pure-function-only per the Epic 14 retro lesson that test-writing stories are at scope-drift risk. **Explicitly out of scope** (filed for follow-up if motivated): `memory.ts` `extractFacts`/`persistMemories`/`retrieveMemories`; `error-tracker.ts` `getTopErrors`/`extractErrorsFromCorrections`/`persistErrorPatterns`/`getRecentResolvedError`; `pronunciation.ts` `assessPronunciation` Edge Function wrapper; `cache.ts` `enqueueWrite`/`flushWriteQueue` (already in `cache-flush.test.ts`)/`clearUserCache`/`clearAllCache` multi-key sweeps; `use-dictation.ts` `analyzeErrorPatterns`. **Cross-story invariants preserved by construction:** zero source-module modifications (Story 15-1 is test-only); Story 9-3 Sentry allowlist zero-diff (cache tests verify EXISTING `cache-get`/`cache-set`/`cache-invalidate`/`cache-fallback-read` feature tags fire; no new tags); Story 12-7 SecureStore fork unchanged (cache.test.ts uses non-secure key `"skills"` to exercise AsyncStorage path); all Epic 14 invariants orthogonal. **+59 net Jest cases** (2099 → 2158; spec target +50-65 — squarely in range). 4 new test files; 0 modified source files. All 5 design-system gates green (type-check 0 errors / lint 0 warnings / prettier clean / check:tokens clean / jest 113 suites / 2158 tests). Verified 2026-05-16, story 15-1.

**Edge Function Deno tests wired into CI:** post-Epic-15.3 partially closes Epic 12 AI #7 + Epic 13 AI #8 (pgTAP CI wiring follow-through — Deno portion shipped; pgTAP portion deferred to `15-3-followup-pgtap-ci-wiring`). Pre-15-3 the 2 Deno test files (Story 11-3 `fetch-with-timeout_test.ts` + Story 12-11 `parse-upstream-error_test.ts`) were manual-run only; regressions could silently survive PR merges. Story 15-3 wires both into [`.github/workflows/ci.yml`](.github/workflows/ci.yml) via new `Setup Deno` step (`denoland/setup-deno@v2`, pinned `deno-version: v1.x`) + `Deno tests (Edge Function _shared utilities)` step running `deno test --allow-net=127.0.0.1 supabase/functions/_shared/__tests__/`. Permission surface minimal — NO `--allow-all`. Ordering: Deno steps AFTER `Tests` so npm-test failures short-circuit before Deno install. NEW [`src/lib/__tests__/ci-deno-step-source-drift.test.ts`](src/lib/__tests__/ci-deno-step-source-drift.test.ts) (5 cases) pins via Story 12-2 P12 + Story 12-10 R1-H2 patterns. TIGHT scope — pgTAP (5 SQL files) DEFERRED to `15-3-followup-pgtap-ci-wiring`; NEW Edge Function tests DEFERRED to `15-3-followup-edge-function-coverage`. 3 operator decisions resolved per Recommended. +5 net Jest cases (drift only; Deno tests run in CI). All 5 design-system gates green. Verified 2026-05-17, story 15-3.
**CI coverage gating — `jest --coverage` wired into CI with 40% threshold floor on `src/lib/` + `src/hooks/`:** post-Epic-15.6, the SIXTH and final Epic 15 (Test Coverage & QA Infrastructure) story closes the Epic 15.6 deliverable at [`shippable-roadmap.md` line 299](_bmad-output/planning-artifacts/shippable-roadmap.md) ("CI gating — Jest threshold ≥ 40% on `src/lib/` and `src/hooks/`; fail PR on regression."). Pre-15-6 the repo had **2167+ Jest tests across libs / hooks / components / prompts / schemas** (the Story 12-10 audit-refresh footnote corrected the pre-15-X audit's stale "3-5% coverage" framing) but **no CI coverage gate** — a PR that removed tests or added substantial untested code merged silently. Story 15-6 lands the gate. **(1) [`jest.config.js`](jest.config.js)** extended with three additive blocks: `collectCoverageFrom: ["src/lib/**/*.{ts,tsx}", "src/hooks/**/*.{ts,tsx}", "!**/__tests__/**", "!**/*.d.ts"]` (scope matches spec deliverable), `coverageThreshold: { global: { statements: 40, branches: 40, functions: 40, lines: 40 } }` (40% floor per spec literal — the audit's `≥ 40%` mandate is the starting bar, not the aspirational ceiling), and `coverageReporters: ["text-summary", "lcov"]` (text-summary for CI log readability; lcov for future Codecov / Coveralls integration without re-running). **(2) [`package.json`](package.json)** gains a new `test:coverage` script wired to `jest --coverage` (placed adjacent to `test:watch` for adjacency). **(3) [`.github/workflows/ci.yml`](.github/workflows/ci.yml)** gains a new step `Tests with coverage threshold` running `npm run test:coverage` IMMEDIATELY AFTER the existing `Tests` step (Q1 RECOMMENDED — keep both for separation of concerns: fast green-light feedback before the slower instrumented run). The new step does NOT carry `continue-on-error: true` (Story 12-10 R1-H2 silent-disable defense) and does NOT carry an `if:` key (defense-in-depth against accidental gate-skipping). **(4) Measured coverage baseline at gate introduction (2026-05-17)**: Statements **53.42%** (2325/4352) / Branches **55.80%** (1417/2539) / Functions **51.49%** (362/703) / Lines **54.12%** (2126/3928). The 40% spec floor leaves **≈11 points of headroom against the lowest metric (Functions)** — sufficient for a future PR to add untested code without breaking CI immediately, while a sustained regression (e.g., a deleted test file dropping Functions below 40%) trips the gate. Q2 RECOMMENDED was "measured floor minus 3%" but the 40% spec literal already leaves comfortable headroom, so the spec floor was used as-is. A future PR can ratchet to 50% once Functions coverage grows (filed as `15-6-followup-coverage-ratchet-cadence`). **(5) NEW drift detector** at [`src/lib/__tests__/ci-coverage-gate-source-drift.test.ts`](src/lib/__tests__/ci-coverage-gate-source-drift.test.ts) (6 cases via Story 12-2 P12 source-string drift pattern + Story 13-2 P11 paired POSITIVE+NEGATIVE pin discipline + Story 12-10 R1-M1 indexOf-ordering anchor + Story 12-10 R1-H2 step-block-scoped negative guard): (a) `coverageThreshold` whole-block regex with 40 floor on all 4 metrics (single-regex pin catches partial-drop e.g., dropping `branches`), (b) `collectCoverageFrom` 4-entry per-key pins (`src/lib/**/*.{ts,tsx}` + `src/hooks/**/*.{ts,tsx}` + `!**/__tests__/**` + `!**/*.d.ts`), (c) `package.json` `test:coverage` script value === `"jest --coverage"` exactly, (d) `ci.yml` step name + `npm run test:coverage` run command, (e) `coverageStepIdx > testsStepIdx` ordering (Story 12-10 R1-M1 anchor-on-exact-step-name pattern), (f) NEGATIVE within the coverage step's block: no `continue-on-error: true` and no `if:` (Story 12-10 R1-H2 lesson — scope the silent-disable check to the step block, not the file). **(6) `coverage/` directory** added to both `.gitignore` and `.prettierignore` so the generated lcov + HTML report don't get tracked or reformatted. **3 operator decisions resolved per Recommended**: Q1 keep both `Tests` + `Tests with coverage threshold` steps (fast feedback + slow gate); Q2 use spec-literal 40% (vs measured-floor-minus-3% — spec floor already leaves ≈11 points of headroom); Q3 defer Codecov / Coveralls integration to operator-action follow-up `15-6-followup-codecov-integration` (needs Codecov account setup). **Closes Epic 15.6 deliverable architecturally** + closes the audit-mandated "CI must be a real gate, not green-light theater" framing (Story 12-10's `--audit-level=high` audit gate + Story 15-6's `--coverage` threshold gate together form the two-layer CI quality bar — vulns + tests). **Cross-story invariants preserved by construction:** Story 9-3 Sentry allowlist zero-diff (no telemetry surface — coverage is a build-time metric) / Story 9-4 stored-prompt-injection N/A / Story 11-X / 12-X / 13-X / 14-X / 15-1 through 15-5 all orthogonal (the coverage gate runs the existing suite + measures it; doesn't modify any source surface). **0 source-module modifications** beyond the 5 spec-allowed surfaces (`jest.config.js`, `package.json`, `.github/workflows/ci.yml`, `.gitignore`, `.prettierignore`) + 1 new drift test + 1 spec/sprint-status/CLAUDE.md trio. **Regression-tested with +6 net Jest cases** (2159 → 2165; matches spec target +5–7 squarely in range). All 5 design-system gates green (type-check 0 errors / lint 0 warnings / prettier clean / check:tokens clean / jest 116 suites / 2165 tests / coverage gate passes by ≈11 points on each metric). **Epic 15 implementation work is COMPLETE — 6 of 6 stories done; Epic 15 retrospective is the natural next workflow step.** Verified 2026-05-17, story 15-6.

### Routing (`app/`)

Expand Down
Loading
Loading