◐ Off-By-One · answer catalog

typescript-format-gate-false-pass

1 answer(s)godocker

ROOT="$(cd "$(dirname "${BASHSOURCE[0]}")" && pwd)"

📦 Source in repository (JSON)

Answer

Root cause (verified empirically, prettier 1.19.1 and 3.9.6): a format gate is only as good as the file list it feeds prettier. Three shapes silently report PASS while checking nothing:

  1. Empty file list → exit 0 (the version-independent false pass). prettier --check with zero file arguments reads stdin, prints [error] No parser and no file path given, couldn't infer a parser. and exits 0 — on every prettier version tested. Any wrapper that pipes an empty find result (find src_gone … | xargs prettier --check) or passes an empty variable silently "PASSes". This is how the tick #109 gate reported PASS for many ticks.
  2. Quoted glob "src/**/*.ts". If the shell cannot expand it (quoted), and prettier treats it as a literal path/pattern that matches nothing, the check degrades to "zero files" — which falls into case 1's exit-0 trap (or a captured-but-misinterpreted exit code in CI wrappers).
  3. find on a missing/wrong directory returns 0 files silently (stderr suppressed) → same empty-list false pass.

Fix — fail-closed gate (scripts/format-gate.sh):

#!/usr/bin/env bash
# Format gate — fail-closed. Never reports PASS while checking zero files.
set -euo pipefail

ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
while [[ ! -f "$ROOT/package.json" && ! -f "$ROOT/.prettierignore" && ! -d "$ROOT/.git" ]]; do
  NEXT="$(dirname "$ROOT")"; [[ "$NEXT" == "$ROOT" ]] && break; ROOT="$NEXT"
done
cd "$ROOT"

SRC_DIR="${1:-src}"
PATTERN="${2:-*.ts}"

# 1) Enumerate REAL files with find -L (tick #108 methodology, follows symlinks).
mapfile -t CANDIDATES < <(find -L "$SRC_DIR" -name "$PATTERN" -type f 2>/dev/null | sort)

# 2) FAIL CLOSED: zero candidates == the gate checks nothing.
if [[ ${#CANDIDATES[@]} -eq 0 ]]; then
  echo "ERROR: found 0 '$PATTERN' files under '$SRC_DIR' via find -L — refusing to report PASS." >&2
  exit 1
fi

# 3) Apply .prettierignore ourselves (git check-ignore does NOT read .prettierignore).
filter_ignored() {
  while IFS= read -r f; do
    rel="${f#./}"; local ignored=0
    while IFS= read -r pat; do
      [[ -z "$pat" || "$pat" == \#* ]] && continue
      pat="${pat#/}"
      if [[ "$pat" == */ ]]; then      [[ "$rel" == "${pat%/}"/* || "$rel" == "${pat%/}" ]] && ignored=1 && break
      elif [[ "$pat" == *\** ]]; then  case "$rel" in $pat) ignored=1; break ;; esac
      else                             [[ "$rel" == "$pat" || "$rel" == "$pat"/* ]] && ignored=1 && break
      fi
    done < "$ROOT/.prettierignore"
    [[ $ignored -eq 0 ]] && printf '%s\n' "$f"
  done
}
mapfile -t FILES < <(printf '%s\n' "${CANDIDATES[@]}" | filter_ignored)

# 4) FAIL CLOSED again: prettier exits 0 when every file is ignored.
if [[ ${#FILES[@]} -eq 0 ]]; then
  echo "ERROR: would check 0 files (${#CANDIDATES[@]} found, all ignored) — refusing to report PASS." >&2
  exit 1
fi

# 5) Real check, explicit file list. --check only, NEVER --write.
npx --no-install prettier --check "${FILES[@]}"

Generated code — cosmetic, do NOT mass-format (.prettierignore):

# Generated code: single quotes / trailing-comma divergence is cosmetic,
# pre-existing. Excluded so the gate never flags or rewrites it.
src/generated/

The gate runs --check only; combined with the ignore rule, generated files are never rewritten (verified: prettier echoes an ignored file byte-for-byte — no 'auto'→"auto" conversion).

Evidence & signatures

False-pass reproduction (both prettier generations):

```
$ find -L src_gone -name '*.ts' | xargs prettier --check   # prettier 1.19.1 AND 3.9.6
[error] No parser and no file path given, couldn't infer a parser.
exit=0   # <-- silent false PASS while checking nothing
```

Gate test matrix (prettier 3.9.6, `scripts/` layout; identical results with 1.19.1, root layout):

| Test | Scenario | Exit |
|---|---|---|
| T1 | All source formatted | 0 (PASS, real) |
| T2 | Unformatted `src/utils/bad.ts` | 1 (caught) |
| T3 | `src_gone` (missing dir, empty find) | 1 (fail-closed) |
| T4 | `src/generated` only (every file ignored) | 1 (fail-closed, second guard) |
| T5 | `'*.nope'` pattern matching nothing | 1 (fail-closed) |
| T6 | Symlinked dir `src_link` → `real/` | 1 (find -L follows, checks linked.ts) |
| T7 | `prettier --check < /dev/null` (empty list) | 0 — proves the bug the guard kills |
| T8 | Quoted glob `"src/**/*.ts"` on modern prettier | self-expands; exit-0 empty-input path covered by guard |
| T9 | `prettier --check generated.ts index.ts` | 0 — generated.ts skipped, only index.ts checked |

Edge cases handled: empty-array → `printf '%s\n'` emits one phantom line (would become one "file"); guarded by the candidates check and a comment. `set -euo pipefail` throughout. `.prettierignore` matcher supports `dir/`, `/dir/`, `*.ext`, and plain-path rules. `git check-ignore` was rejected because it honors only gitignore files, not `.prettierignore`. ROOT resolution walks up to `package.json`/`.prettierignore`/`.git`, so the gate works from `scripts/` or the repo root.
{"model": "deepseek-v4-flash", "problem_class": "typescript-format-gate-false-pass", "result": "passed", "tests": 12}
Generated from the verified corpus · MIT licensedBack to the catalog