From 30ba71a78b1dc2240a60f0be905f6f4772d44a1a Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Tue, 14 Jul 2026 17:20:56 +0000 Subject: [PATCH 1/3] [#176] CI & gates hardening: release-invariants, SHA-pins, release gates, gate blind spots, VAD note MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five verified automation gaps that guard our invariants (#134 audit), one workflow-surface fix-unit. Creates the `release-invariants` job that #177 extends. 1. release-invariants CI job (ci.yml, macos): compiles livecap-app in RELEASE (`cargo test --release --no-run` + `cargo build --release`) and `strings`- asserts the debug-only env-escape names are ABSENT — LIVECAP_BLEED_DUMP_DIR / LIVECAP_CAPTURE_VISIBLE / LIVECAP_AUTOSTART / LIVECAP_UI_PROBE all live only in `#[cfg(debug_assertions)]` items, so release compiles them out (#146/#161). CI only tested the debug profile before, so a dropped guard was invisible. 2. SHA-pin every third-party action in ci.yml + release.yml to a full commit SHA with a version comment (supply-chain: release.yml materializes the Apple signing secrets). dtolnay/rust-toolchain pinned to a master SHA with an explicit `toolchain: stable` input (channel can't be inferred from a SHA ref). 3. release.yml runs the same gates (no-stub-gate, color-guard, cargo test, pnpm test) before the tag build — tag pushes bypass branch protection, so a release is now always cut from a green tree. 4. no-stub-gate.sh: extend the pattern to stub/dummy/unimplemented/XXX (the free phrase "not implemented" is deliberately excluded — it false-positives on shipped platform-unsupported error prose, e.g. error.rs; `unimplemented` covers the deferred-code case). Add a fixtures/ self-test that fails if the pattern ever stops matching a known-bad marker. 5. color-guard.sh: extend CSS matching to hsl/hwb/oklch/oklab/lch/lab/color-mix/ color() + a curated named-color set, and add a second pass flagging hex/rgb()/ hsl()-family literals on TS lines that set an inline style. Documented limits (named colors in TS, cross-line/runtime values, value-line comments). 6. Reconcile the vad.rs NOTE (and the same false claim in force_cut_wav.rs): the synthetic generator IS classified as speech by this Silero build (a passing test proves it), so the "never detected" claim was wrong. Add a vacuity guard to longer_redemption_does_not_fragment_more so a Silero change that stops detection fails loudly instead of passing 0<=0. Seeded-violation proofs for every changed gate are in the PR description. No new dependencies; no caption/audio content logged. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 74 ++++++++++-- .github/workflows/release.yml | 25 +++- crates/livecap-core/src/vad.rs | 30 ++++- crates/livecap-core/tests/force_cut_wav.rs | 8 +- scripts/color-guard.sh | 129 ++++++++++++++------- scripts/fixtures/no-stub-selftest.txt | 13 +++ scripts/no-stub-gate.sh | 30 ++++- 7 files changed, 239 insertions(+), 70 deletions(-) create mode 100644 scripts/fixtures/no-stub-selftest.txt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ee2d640..36e6fec 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,29 +5,32 @@ on: branches: [main] pull_request: +# Third-party actions are pinned to full commit SHAs (#176 supply-chain hygiene); +# the trailing comment records the human-readable version they were pinned at. jobs: no-stub-gate: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - run: ./scripts/no-stub-gate.sh color-guard: - # #116: fail if src CSS uses a raw color literal outside the :root tokens. + # #116: fail if src CSS or a TS-set inline style uses a raw color literal + # outside the :root tokens. runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - run: ./scripts/color-guard.sh packages-linux: # Engine/archive packages are Tauri-free and must build & test headless. runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 with: version: 10 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: 22 cache: pnpm @@ -41,19 +44,68 @@ jobs: app-macos: runs-on: macos-14 steps: - - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 with: version: 10 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: 22 cache: pnpm - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@fa04a1451ff1842e2626ccb99004d0195b455a88 # dtolnay/rust-toolchain master (channel via `toolchain:` input) with: + toolchain: stable components: clippy - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 - run: pnpm install --frozen-lockfile - run: pnpm build - run: cargo clippy --workspace --all-targets -- -D warnings - run: cargo test --workspace + + release-invariants: + # #176: turn the #161-class privacy invariant into a permanent gate. CI's + # other jobs only compile/test the DEBUG profile, so a dropped + # `#[cfg(debug_assertions)]` guard (e.g. a bad merge on the #64 bleed dump or a + # #108 dev flag) is invisible to them and only surfaces at tag time. This job + # compiles livecap-app in RELEASE and asserts the debug-only env-escape NAMES + # are absent from the binary — they exist only inside `#[cfg(debug_assertions)]` + # items, so a release build compiles them out entirely (#146/#161). + runs-on: macos-14 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + with: + version: 10 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 22 + cache: pnpm + - uses: dtolnay/rust-toolchain@fa04a1451ff1842e2626ccb99004d0195b455a88 # dtolnay/rust-toolchain master (channel via `toolchain:` input) + with: + toolchain: stable + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + - run: pnpm install --frozen-lockfile + - run: pnpm build # tauri-build embeds the built frontend, so dist/ must exist + - name: Compile livecap-app in RELEASE (tests no-run + binary) + run: | + cargo test --release -p livecap-app --no-run + cargo build --release -p livecap-app + - name: Assert the release binary strips debug-only env escapes (#161/#176) + run: | + set -euo pipefail + BIN=target/release/livecap-app + test -f "$BIN" || { echo "::error::release binary not found at $BIN"; exit 1; } + # These env vars are read ONLY inside #[cfg(debug_assertions)] items, so a + # release build must contain none of their names. A hit means a guard was + # dropped and the shipped binary regained a covert env path (#161-class). + FORBIDDEN="LIVECAP_BLEED_DUMP_DIR LIVECAP_CAPTURE_VISIBLE LIVECAP_AUTOSTART LIVECAP_UI_PROBE" + fail=0 + for name in $FORBIDDEN; do + if strings "$BIN" | grep -q "$name"; then + echo "::error::release binary contains debug-only env name '$name' — a #[cfg(debug_assertions)] guard was dropped (regression of #161)." + fail=1 + else + echo "ok: '$name' absent from the release binary" + fi + done + exit $fail diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 066a72d..5755c2f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,12 +13,16 @@ jobs: # Apple silicon; universal binary doubles CI time — revisit on demand). runs-on: macos-14 timeout-minutes: 90 + # Third-party actions are pinned to full commit SHAs (#176 supply-chain + # hygiene) — this job materializes the Apple signing secrets, so a compromised + # mutable tag/branch must not be able to run in it; the trailing comment + # records the human-readable version pinned at. steps: - - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 with: version: 10 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: 22 cache: pnpm @@ -39,14 +43,25 @@ jobs: exit 1 fi - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@fa04a1451ff1842e2626ccb99004d0195b455a88 # dtolnay/rust-toolchain master (channel via `toolchain:` input) with: + toolchain: stable targets: aarch64-apple-darwin - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 - run: pnpm install --frozen-lockfile - run: pnpm build + - name: Quality gates before building the release (#176) + # Tag pushes bypass branch protection, so nothing otherwise enforces that a + # tagged commit ever passed CI. Run the same gates here so a release is + # always cut from a green tree — no stub/color-token drift, tests passing. + run: | + ./scripts/no-stub-gate.sh + ./scripts/color-guard.sh + cargo test --workspace + pnpm test + - name: Detect Apple signing secrets # GitHub cannot test secret presence in `if:` reliably without first # mapping secrets into env, so this step maps them (values never diff --git a/crates/livecap-core/src/vad.rs b/crates/livecap-core/src/vad.rs index becb51b..87005b2 100644 --- a/crates/livecap-core/src/vad.rs +++ b/crates/livecap-core/src/vad.rs @@ -427,6 +427,17 @@ mod tests { let segments_400 = run(400); let segments_2000 = run(2000); + // Guard against a vacuous pass (#176): if this Silero build ever stops + // classifying the synthetic generator as speech, both runs return 0 and the + // `<=` below would pass silently (0 <= 0). Require the baseline to have + // produced segments so the comparison is real — a failure here signals the + // synthetic fixture needs porting to a real-speech WAV (cf. force_cut_wav). + assert!( + !segments_400.is_empty(), + "precondition: the synthetic generator must be detected as speech; 0 segments \ + makes the redemption comparison vacuous (Silero build changed?)" + ); + // Longer redemption bridges more pauses, so it must not produce more // segments than the short one. assert!( @@ -464,10 +475,17 @@ mod tests { } } - // NOTE: the force-cut → SpeechEnd no-duplication / no-panic invariant (#138/ - // #162) is covered by the REAL-SPEECH integration test in - // `tests/force_cut_wav.rs`. A synthetic-harmonic unit test cannot exercise - // it: this Silero build never classifies the generator's output as speech, - // so the force-cut path is never entered (the earlier synthetic test was - // vacuous and gave false confidence). + // NOTE (#176 — corrected): the force-cut → natural-SpeechEnd no-duplication / + // no-panic invariant (#138/#162) is covered by the REAL-SPEECH integration + // test in `tests/force_cut_wav.rs`, which drives the exact production path with + // realistic utterance timing (the #162 panic reproduced only there). The + // synthetic-harmonic generator above IS classified as speech by this Silero + // build — `vad_state_is_maintained_across_chunks` asserts non-empty segments + // and passes, so the earlier claim that it is "never" detected was wrong — but + // the continuous synthetic signal does not reproduce the force-cut → natural + // end sequence. Because these two unit tests DO depend on synthetic detection, + // they are Silero-version-sensitive: a model bump that changes detection would + // fail `vad_state_is_maintained_across_chunks` (and trip the vacuity guard in + // `longer_redemption_does_not_fragment_more`), signalling the fixtures need a + // refresh rather than passing on false confidence. } diff --git a/crates/livecap-core/tests/force_cut_wav.rs b/crates/livecap-core/tests/force_cut_wav.rs index 4672b38..487b0df 100644 --- a/crates/livecap-core/tests/force_cut_wav.rs +++ b/crates/livecap-core/tests/force_cut_wav.rs @@ -3,9 +3,11 @@ //! //! This exercises the exact production path (`pipeline.rs` force-cuts at //! `max_utterance_ms`, then the utterance ends naturally). It uses REAL speech -//! synthesized with macOS `say`: the synthetic-harmonic generator in the vad -//! unit tests is never classified as speech by this Silero build, so the -//! force-cut → SpeechEnd path can only be reached with real audio. +//! synthesized with macOS `say` for production-fidelity timing. (The +//! synthetic-harmonic generator in the vad unit tests IS classified as speech by +//! this Silero build — see `vad_state_is_maintained_across_chunks` — but its +//! continuous signal does not reproduce the force-cut → natural-SpeechEnd +//! sequence where the #162 panic occurred, so that regression is guarded here.) //! //! Before the #162 fix this test PANICS at the natural SpeechEnd (draining //! Silero's buffer on the cut left its state-enum `start_ms` stale). macOS only. diff --git a/scripts/color-guard.sh b/scripts/color-guard.sh index c976fe2..c16e355 100755 --- a/scripts/color-guard.sh +++ b/scripts/color-guard.sh @@ -1,60 +1,103 @@ #!/bin/bash -# Raw-color guard (#116): src CSS must reference colors through design tokens, -# not raw literals — every hex / rgb() / rgba() color belongs in the :root token -# block. A raw color anywhere else fails CI so the debt can't regrow with each -# new UI surface (the reason #116 exists). +# Raw-color guard (#116, extended #176): colors must come from design tokens, not +# raw literals — every hex / rgb() / hsl() / modern-color-function / named color +# belongs behind a :root token (as var(...)). A raw color anywhere else fails CI +# so the #116 token debt can't regrow with each new UI surface. # -# What it flags: a hex (#rgb / #rgba / #rrggbb / #rrggbbaa) or rgb()/rgba() -# literal appearing in a DECLARATION VALUE (the text after the first ':' on a -# line) in src/**/*.css, OUTSIDE the ':root { ... }' block. +# Two passes: +# 1) src/**/*.css — a color literal in a DECLARATION VALUE (text after the first +# ':' on a line), OUTSIDE the ':root { ... }' block. +# 2) src/**/*.ts — a color literal (hex / rgb() / hsl()-family) on a line that +# SETS an inline style (`.style.`, `.setProperty(`, or a `style=` attribute in +# a template string). Best-effort grep; the LIMITS are documented below. # -# Why value-only (casebook #7 — color-scan false positives): hex-looking ID -# selectors such as `#feed`, `#feed-wrap`, `#feed-note` are legitimate and must -# NOT be flagged. They sit in selector position — no property ':' precedes them -# — so scanning only the value side skips them WITHOUT a brittle name allowlist. +# Why value-only for CSS (casebook #7 — color-scan false positives): hex-looking +# ID selectors (`#feed`, `#feed-wrap`) sit in SELECTOR position (no property ':' +# precedes them), so scanning only the value side skips them without a name +# allowlist. # -# Escape hatch: prefer moving the literal into a :root token. As a last resort, -# append `/* color-guard-allow */` to the specific line to exempt it. +# LIMITS (best-effort, #176) — NOT covered, by design: +# - named colors in TS (bare `red`/`blue` collide with ordinary identifiers/ +# strings, so the TS pass matches only hex/rgb()/hsl()-family literals); +# - a color built across lines or from a runtime expression / concatenation; +# - values inside CSS/JS block comments on a value line (use the escape hatch). +# +# Escape hatch: prefer a :root token. As a last resort, append +# `/* color-guard-allow */` to the specific line to exempt it. set -euo pipefail -# Draft pattern from the issue, refined: hex must be followed by a non-hex char -# (or line end) so `#feedcafe`-style tails don't over/under-match a color. -COLOR_RE='#[0-9a-fA-F]{3,8}([^0-9a-fA-F]|$)|rgba?\(' +# Hex (#rgb / #rgba / #rrggbb / #rrggbbaa): a trailing non-hex char (or EOL) keeps +# `#feedcafe`-style tails from over/under-matching. Modern CSS color functions +# (#176): hsl/hsla/hwb/oklch/oklab/lch/lab/color-mix/color(). A curated set of CSS +# NAMED colors (#176) — the CSS-wide keywords (transparent/currentColor/inherit/ +# initial/unset/revert/none/auto) are deliberately absent, so those still pass. +HEX='#[0-9a-fA-F]{3,8}([^0-9a-fA-F]|$)' +COLOR_FUNC='rgba?\(|hsla?\(|hwb\(|oklch\(|oklab\(|lch\(|lab\(|color-mix\(|color\(' +NAMED='\b(aqua|aquamarine|beige|black|blue|brown|chocolate|coral|crimson|cyan|fuchsia|gold|goldenrod|gray|grey|green|indigo|ivory|khaki|lavender|lime|magenta|maroon|navy|olive|orange|orchid|pink|plum|purple|red|salmon|sienna|silver|tan|teal|tomato|turquoise|violet|wheat|white|yellow)\b' +COLOR_RE="$HEX|$COLOR_FUNC|$NAMED" +# The TS pass omits NAMED — bare color words are too common in code/strings. +TS_COLOR_RE="$HEX|$COLOR_FUNC" +# ---- Pass 1: CSS ---------------------------------------------------------- # bash-3.2 compatible (stock macOS bash has no `mapfile`, #126). -FILES=() -while IFS= read -r f; do - FILES+=("$f") -done < <(find src -type f -name '*.css' 2>/dev/null | sort) -if [ ${#FILES[@]} -eq 0 ]; then - echo "color-guard: no src CSS files found (nothing to check)" - exit 0 +CSS_FILES=() +while IFS= read -r f; do [ -n "$f" ] && CSS_FILES+=("$f"); done < <(find src -type f -name '*.css' 2>/dev/null | sort) + +CSS_HITS="" +if [ ${#CSS_FILES[@]} -gt 0 ]; then + # awk strips the :root block and emits "::" for every + # other line that has a property ':'; grep then tests only that value side. + CSS_HITS=$( + for f in "${CSS_FILES[@]}"; do + awk ' + /^[[:space:]]*:root[[:space:]]*\{/ { inroot = 1 } + inroot { if ($0 ~ /\}/) inroot = 0; next } + /color-guard-allow/ { next } + { + i = index($0, ":") + if (i == 0) next + printf "%s:%d:%s\n", FILENAME, NR, substr($0, i + 1) + } + ' "$f" + done | grep -E "$COLOR_RE" || true + ) fi -# awk strips the :root block and emits "::" for every -# other line that has a property ':' — grep then tests only that value side. -HITS=$( - for f in "${FILES[@]}"; do - awk ' - /^[[:space:]]*:root[[:space:]]*\{/ { inroot = 1 } - inroot { if ($0 ~ /\}/) inroot = 0; next } - /color-guard-allow/ { next } - { - i = index($0, ":") - if (i == 0) next - printf "%s:%d:%s\n", FILENAME, NR, substr($0, i + 1) - } - ' "$f" - done | grep -E "$COLOR_RE" || true -) +# ---- Pass 2: TS inline styles (#176) -------------------------------------- +# A raw color literal on a line that sets an inline style. Value-side heuristic +# via the style-set anchor; the escape hatch and var() tokens are honored. +TS_FILES=() +while IFS= read -r f; do [ -n "$f" ] && TS_FILES+=("$f"); done < <(find src -type f -name '*.ts' 2>/dev/null | sort) + +TS_HITS="" +if [ ${#TS_FILES[@]} -gt 0 ]; then + TS_HITS=$( + for f in "${TS_FILES[@]}"; do + grep -nE '(\.style\.|\.setProperty\(|style=)' "$f" 2>/dev/null \ + | grep -v 'color-guard-allow' \ + | grep -E "$TS_COLOR_RE" \ + | sed "s|^|$f:|" || true + done + ) +fi -if [ -n "$HITS" ]; then - echo "color-guard FAILED — raw color literal(s) outside the :root token block:" - echo "$HITS" +FAILED=0 +if [ -n "$CSS_HITS" ]; then + echo "color-guard FAILED — raw color literal(s) outside the :root token block (CSS):" + echo "$CSS_HITS" + FAILED=1 +fi +if [ -n "$TS_HITS" ]; then + echo "color-guard FAILED — raw color literal(s) in a TS-set inline style:" + echo "$TS_HITS" + FAILED=1 +fi +if [ "$FAILED" -ne 0 ]; then echo echo "Fix: replace with a design token — e.g. var(--surface-2) — and define any" echo "new token in the :root block (mirror it in design/system/tokens.css)." + echo "Last resort: append /* color-guard-allow */ to the specific line." exit 1 fi -echo "color-guard passed — all colors in src CSS come from :root tokens" +echo "color-guard passed — all colors in src CSS + TS-set inline styles come from :root tokens" diff --git a/scripts/fixtures/no-stub-selftest.txt b/scripts/fixtures/no-stub-selftest.txt new file mode 100644 index 0000000..6240c63 --- /dev/null +++ b/scripts/fixtures/no-stub-selftest.txt @@ -0,0 +1,13 @@ +# no-stub-gate self-test fixture (#176): one banned marker per non-comment line. +# The gate asserts its PATTERN matches EVERY line below, so a future edit that +# weakens the pattern fails loudly here instead of silently letting stubs ship. +# This file lives under fixtures/ so the gate's own app-code scan skips it. +TODO leftover work +FIXME a broken thing +HACK around a bug +a placeholder value +a mock server +function stubTranslateBatch() {} +a dummy value +unimplemented!() +danger XXX here diff --git a/scripts/no-stub-gate.sh b/scripts/no-stub-gate.sh index c25fb3b..4136a94 100755 --- a/scripts/no-stub-gate.sh +++ b/scripts/no-stub-gate.sh @@ -2,7 +2,33 @@ # No-stub gate (EPIC #1 engineering policy): application code must not contain # deferred-functionality markers or mock/placeholder logic. Test code is exempt. set -euo pipefail -PATTERN='TODO|FIXME|HACK|placeholder|\bmock' + +# Deferred-functionality / placeholder markers (#176 extends the original 5 to +# cover the gate's own namesake and its cousins). `\bmock`/`\bstub`/`\bdummy` use +# a LEADING word boundary only, so camelCase (`stubTranslateBatch`) is caught; the +# whole scan is case-insensitive (grep -i below). The Rust `unimplemented!()` +# marker is matched; the free-form phrase "not implemented" is deliberately NOT — +# it legitimately describes shipped platform-unsupported errors (e.g. +# error.rs), and `unimplemented` already covers the deferred-code case. +PATTERN='TODO|FIXME|HACK|placeholder|\bmock|\bstub|\bdummy|unimplemented|\bXXX\b' + +# Self-test (#176): a fixture with one banned marker per line proves the gate can +# still FIRE. If a future edit weakens PATTERN so it stops matching a known-bad +# marker, fail loudly HERE rather than silently letting stubs reach a release. +SELFTEST="scripts/fixtures/no-stub-selftest.txt" +if [ -f "$SELFTEST" ]; then + EXPECTED=$(grep -cvE '^[[:space:]]*(#|$)' "$SELFTEST") + MATCHED=$(grep -vE '^[[:space:]]*(#|$)' "$SELFTEST" | grep -icE "$PATTERN" || true) + if [ "$MATCHED" -ne "$EXPECTED" ]; then + echo "no-stub gate SELF-TEST FAILED — PATTERN matched $MATCHED of $EXPECTED marker lines in $SELFTEST." + echo "The gate is weakened (it would miss real stubs); restore the missing marker(s)." + exit 1 + fi +else + echo "::warning::no-stub-gate self-test fixture missing ($SELFTEST) — gate ran without its own regression check." + EXPECTED=0 +fi + # Application source roots (extend as the workspace grows): ROOTS=(src src-tauri/src crates packages/*/src) HITS=$(grep -rinE "$PATTERN" "${ROOTS[@]}" 2>/dev/null | grep -viE '(^|/)(test|tests|__tests__|fixtures)/' || true) @@ -11,4 +37,4 @@ if [ -n "$HITS" ]; then echo "$HITS" exit 1 fi -echo "no-stub gate passed" +echo "no-stub gate passed (self-test: $EXPECTED markers fire)" From 8fa7c42f42efe02aaff8bc7cae623dba272f3be8 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Tue, 14 Jul 2026 17:31:57 +0000 Subject: [PATCH 2/3] [#176] no-stub-gate: restore "not implemented" with a narrow platform carve-out; fail on a missing fixture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address RE1 review on PR #187: (1) Restore the ticket-required whitespace-tolerant `not[[:space:]]+implemented` marker instead of dropping it globally, and NARROWLY allow only the legitimate platform-error phrasing via an `ALLOW='not[[:space:]]+implemented[[:space:]]+for[[:space:]]'` carve-out (crates/livecap-core/src/error.rs's "…not implemented for the current platform" is a shipped capability statement, not a stub). A bare "not implemented" / "not implemented yet" still fires. Re-added the marker to the self-test fixture (now 10 markers). (2) A MISSING self-test fixture is now a hard failure (exit 1), not a warn-and-pass — the gate must never run without its own regression proof. Proofs (local): current tree passes (error.rs exempted); `not implemented yet` and `summary is not implemented` in src/ both FAIL; missing fixture FAILS; weakening PATTERN still trips the self-test. bash -n clean, bash-3.2-safe. Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/fixtures/no-stub-selftest.txt | 1 + scripts/no-stub-gate.sh | 47 ++++++++++++++++----------- 2 files changed, 29 insertions(+), 19 deletions(-) diff --git a/scripts/fixtures/no-stub-selftest.txt b/scripts/fixtures/no-stub-selftest.txt index 6240c63..6e44de7 100644 --- a/scripts/fixtures/no-stub-selftest.txt +++ b/scripts/fixtures/no-stub-selftest.txt @@ -10,4 +10,5 @@ a mock server function stubTranslateBatch() {} a dummy value unimplemented!() +this feature is not implemented yet danger XXX here diff --git a/scripts/no-stub-gate.sh b/scripts/no-stub-gate.sh index 4136a94..87cdd83 100755 --- a/scripts/no-stub-gate.sh +++ b/scripts/no-stub-gate.sh @@ -5,33 +5,42 @@ set -euo pipefail # Deferred-functionality / placeholder markers (#176 extends the original 5 to # cover the gate's own namesake and its cousins). `\bmock`/`\bstub`/`\bdummy` use -# a LEADING word boundary only, so camelCase (`stubTranslateBatch`) is caught; the -# whole scan is case-insensitive (grep -i below). The Rust `unimplemented!()` -# marker is matched; the free-form phrase "not implemented" is deliberately NOT — -# it legitimately describes shipped platform-unsupported errors (e.g. -# error.rs), and `unimplemented` already covers the deferred-code case. -PATTERN='TODO|FIXME|HACK|placeholder|\bmock|\bstub|\bdummy|unimplemented|\bXXX\b' +# a LEADING word boundary only, so camelCase (`stubTranslateBatch`) is caught; +# `not[[:space:]]+implemented` is whitespace-tolerant; the whole scan is +# case-insensitive (grep -i below). +PATTERN='TODO|FIXME|HACK|placeholder|\bmock|\bstub|\bdummy|unimplemented|not[[:space:]]+implemented|\bXXX\b' + +# Narrow carve-out (#176): "not implemented FOR " is a shipped +# capability statement in an error description (e.g. crates/livecap-core/src/ +# error.rs — "System-audio capture is not implemented for the current platform"), +# NOT a deferred stub. Only this precise phrasing is exempted; a bare "not +# implemented" / "not implemented yet" still fires. +ALLOW='not[[:space:]]+implemented[[:space:]]+for[[:space:]]' # Self-test (#176): a fixture with one banned marker per line proves the gate can # still FIRE. If a future edit weakens PATTERN so it stops matching a known-bad -# marker, fail loudly HERE rather than silently letting stubs reach a release. +# marker, fail loudly HERE rather than silently letting stubs reach a release. A +# MISSING fixture is a hard failure — the gate must never run without its own +# regression proof. SELFTEST="scripts/fixtures/no-stub-selftest.txt" -if [ -f "$SELFTEST" ]; then - EXPECTED=$(grep -cvE '^[[:space:]]*(#|$)' "$SELFTEST") - MATCHED=$(grep -vE '^[[:space:]]*(#|$)' "$SELFTEST" | grep -icE "$PATTERN" || true) - if [ "$MATCHED" -ne "$EXPECTED" ]; then - echo "no-stub gate SELF-TEST FAILED — PATTERN matched $MATCHED of $EXPECTED marker lines in $SELFTEST." - echo "The gate is weakened (it would miss real stubs); restore the missing marker(s)." - exit 1 - fi -else - echo "::warning::no-stub-gate self-test fixture missing ($SELFTEST) — gate ran without its own regression check." - EXPECTED=0 +if [ ! -f "$SELFTEST" ]; then + echo "no-stub gate FAILED — self-test fixture missing ($SELFTEST)." + echo "The gate must not run without its regression proof; restore the fixture." + exit 1 +fi +EXPECTED=$(grep -cvE '^[[:space:]]*(#|$)' "$SELFTEST") +MATCHED=$(grep -vE '^[[:space:]]*(#|$)' "$SELFTEST" | grep -icE "$PATTERN" || true) +if [ "$MATCHED" -ne "$EXPECTED" ]; then + echo "no-stub gate SELF-TEST FAILED — PATTERN matched $MATCHED of $EXPECTED marker lines in $SELFTEST." + echo "The gate is weakened (it would miss real stubs); restore the missing marker(s)." + exit 1 fi # Application source roots (extend as the workspace grows): ROOTS=(src src-tauri/src crates packages/*/src) -HITS=$(grep -rinE "$PATTERN" "${ROOTS[@]}" 2>/dev/null | grep -viE '(^|/)(test|tests|__tests__|fixtures)/' || true) +HITS=$(grep -rinE "$PATTERN" "${ROOTS[@]}" 2>/dev/null \ + | grep -viE '(^|/)(test|tests|__tests__|fixtures)/' \ + | grep -viE "$ALLOW" || true) if [ -n "$HITS" ]; then echo "no-stub gate FAILED — banned markers in application code:" echo "$HITS" From 1fa6790bd106b2add88c3bc5b457b4581673c9d2 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Tue, 14 Jul 2026 17:40:30 +0000 Subject: [PATCH 3/3] [#176] no-stub-gate: replace the global carve-out with a per-line no-stub-allow escape hatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address RE1 re-review (#985): the `ALLOW='not implemented for'` global filter let future deferred prose ("not implemented for this mode yet") bypass the gate. Replace it with an explicit PER-LINE escape hatch — a line carrying `no-stub-allow` is exempt — mirroring color-guard's `color-guard-allow`. Scope the one verified exemption to error.rs precisely: reword its doc to the accurate "unavailable on the current platform" (matching the SystemAudioUnavailable variant) and carry the "not implemented for non-macOS targets" capability note in a `// … no-stub-allow` comment on one line. Proofs (local): current tree passes (error.rs line exempted; self-test 10); RE1's `// not implemented for this mode yet` (no marker) FAILS; a `no-stub-allow` line is exempt; error.rs compiles. bash -n clean, bash-3.2-safe. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/livecap-core/src/error.rs | 7 ++++--- scripts/no-stub-gate.sh | 14 +++++++------- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/crates/livecap-core/src/error.rs b/crates/livecap-core/src/error.rs index 7a5f796..2fdb712 100644 --- a/crates/livecap-core/src/error.rs +++ b/crates/livecap-core/src/error.rs @@ -8,9 +8,10 @@ use thiserror::Error; #[derive(Debug, Error)] pub enum CoreError { - /// System-audio capture is not implemented for the current platform. - /// (On macOS it uses a Core Audio process tap; other platforms would need - /// WASAPI/PulseAudio loopback, which LiveCap does not ship yet.) + /// System-audio capture is unavailable on the current platform. (On macOS it + /// uses a Core Audio process tap; other platforms would need WASAPI/PulseAudio + /// loopback, which LiveCap does not ship yet.) + // A target-capability statement, not a deferred stub: system audio is not implemented for non-macOS targets. no-stub-allow #[error("system audio capture is unavailable on {platform}: {reason}")] SystemAudioUnavailable { platform: &'static str, diff --git a/scripts/no-stub-gate.sh b/scripts/no-stub-gate.sh index 87cdd83..407f193 100755 --- a/scripts/no-stub-gate.sh +++ b/scripts/no-stub-gate.sh @@ -10,12 +10,12 @@ set -euo pipefail # case-insensitive (grep -i below). PATTERN='TODO|FIXME|HACK|placeholder|\bmock|\bstub|\bdummy|unimplemented|not[[:space:]]+implemented|\bXXX\b' -# Narrow carve-out (#176): "not implemented FOR " is a shipped -# capability statement in an error description (e.g. crates/livecap-core/src/ -# error.rs — "System-audio capture is not implemented for the current platform"), -# NOT a deferred stub. Only this precise phrasing is exempted; a bare "not -# implemented" / "not implemented yet" still fires. -ALLOW='not[[:space:]]+implemented[[:space:]]+for[[:space:]]' +# Escape hatch (#176), mirroring color-guard's `color-guard-allow`: a line +# carrying `no-stub-allow` is exempt. This is a PER-LINE, explicit, auditable +# exemption — used only where a marker word legitimately appears as prose (e.g. a +# capability description "…not implemented for " in +# crates/livecap-core/src/error.rs), NOT a blanket phrase filter. Future deferred +# prose without the marker still fires. # Self-test (#176): a fixture with one banned marker per line proves the gate can # still FIRE. If a future edit weakens PATTERN so it stops matching a known-bad @@ -40,7 +40,7 @@ fi ROOTS=(src src-tauri/src crates packages/*/src) HITS=$(grep -rinE "$PATTERN" "${ROOTS[@]}" 2>/dev/null \ | grep -viE '(^|/)(test|tests|__tests__|fixtures)/' \ - | grep -viE "$ALLOW" || true) + | grep -v 'no-stub-allow' || true) if [ -n "$HITS" ]; then echo "no-stub gate FAILED — banned markers in application code:" echo "$HITS"