Stop the baseline growth guard from failing open

The base-baseline lookup swallowed every git error, not just a missing file,
so any failure to read the base blob left TYPECHECK_BASELINE_BASE_PATH unset
and the job passed printing "no drift". That guard is the only thing that
catches a raised baseline: the drift check compares the tree against its own
committed baseline, so growth is invisible to it.

Ask tree objects whether the base has the file, which also distinguishes a
genuine absence from a failure, and hard-fail on anything else. The lookup is
a bare assignment because inside `[ -n "$(...)" ]` the exit status is
discarded, which would have preserved the same fail-open. Each branch now
says which one it took, since the guard is silently inactive on any PR that
introduces a baseline.

Run mobile checks on src/shared changes. Mobile's program pulls 300+ files
from there and its baseline now matches on message text, so a desktop-side
rename can reword a mobile diagnostic and redden that job for an unrelated
author, on a PR that never triggers it.

Scan the whole tree for extension shadowing rather than a list of roots that
drifts from the tsconfig globs, covering repo-root and mobile/scripts files
it previously missed, plus .mts/.cts pairs.

State what the e2e ratchet does and does not catch: identity excludes line
and column, so a new error is caught unless it exactly matches one fixed in
the same file.
This commit is contained in:
Brennan Benson
2026-08-28 03:49:48 -07:00
parent c4f33a044c
commit a1e1d295ff
4 changed files with 60 additions and 21 deletions
+18 -1
View File
@@ -12,6 +12,13 @@ on:
# Why: the mobile terminal link parsers are conformance-tested against
# these shared fixtures; desktop-side fixture edits must re-run this suite.
- 'src/shared/terminal-file-link-conformance.ts'
# Why all of src/shared: mobile's tsconfig pulls 300+ files from it, and the
# typecheck baseline now matches on message text, so a desktop-side rename can
# reword a mobile diagnostic and redden this job for an unrelated author.
- 'src/shared/**'
# Why: the mobile typecheck runs through this script and its baseline.
- 'config/scripts/typecheck-diagnostic-baseline.mjs'
- 'mobile/typecheck-test-diagnostics.json'
# Why: this job holds the only checks that load the Fastfile, so edits to
# it or to the release workflow it guards must re-run them.
- '.github/workflows/mobile.yml'
@@ -44,10 +51,20 @@ jobs:
run: |
set -euo pipefail
git cat-file -e "$BASE_SHA^{commit}"
if git cat-file -e "$BASE_SHA:mobile/typecheck-test-diagnostics.json" 2>/dev/null; then
# Why ls-tree --full-tree: it answers "does the base have this file" from tree
# objects alone, so a missing baseline is distinguishable from a git failure.
# Anything other than a clean absence aborts the step rather than silently
# leaving the growth guard unset, which would pass as "no drift".
# Why a bare assignment: inside `[ -n "$(...)" ]` the substitution's exit status
# is discarded, so a git failure would read as "base has no baseline" and pass.
base_entry="$(git ls-tree -r --full-tree --name-only "$BASE_SHA" -- mobile/typecheck-test-diagnostics.json)"
if [ -n "$base_entry" ]; then
base_baseline="$RUNNER_TEMP/typecheck-mobile-base.json"
git show "$BASE_SHA:mobile/typecheck-test-diagnostics.json" > "$base_baseline"
echo "TYPECHECK_BASELINE_BASE_PATH=$base_baseline" >> "$GITHUB_ENV"
echo "Mobile growth guard ACTIVE against $BASE_SHA."
else
echo "Mobile base has no baseline; this is the introducing PR, growth guard INACTIVE."
fi
- name: Setup Node.js
+11 -1
View File
@@ -219,10 +219,20 @@ jobs:
run: |
set -euo pipefail
git cat-file -e "$BASE_SHA^{commit}"
if git cat-file -e "$BASE_SHA:config/typecheck-e2e-diagnostics.json" 2>/dev/null; then
# Why ls-tree --full-tree: it answers "does the base have this file" from tree
# objects alone, so a missing baseline is distinguishable from a git failure.
# Anything other than a clean absence aborts the step rather than silently
# leaving the growth guard unset, which would pass as "no drift".
# Why a bare assignment: inside `[ -n "$(...)" ]` the substitution's exit status
# is discarded, so a git failure would read as "base has no baseline" and pass.
base_entry="$(git ls-tree -r --full-tree --name-only "$BASE_SHA" -- config/typecheck-e2e-diagnostics.json)"
if [ -n "$base_entry" ]; then
base_baseline="$RUNNER_TEMP/typecheck-e2e-base.json"
git show "$BASE_SHA:config/typecheck-e2e-diagnostics.json" > "$base_baseline"
echo "TYPECHECK_BASELINE_BASE_PATH=$base_baseline" >> "$GITHUB_ENV"
echo "E2E growth guard ACTIVE against $BASE_SHA."
else
echo "E2E base has no baseline; this is the introducing PR, growth guard INACTIVE."
fi
- uses: ./.github/actions/install-node-dependencies
@@ -8,38 +8,50 @@ import { describe, expect, it } from 'vitest'
* the program: tsconfig `include` expansion keeps one file per extensionless path
* and `.ts` outranks `.tsx`. Nothing errors — the file just stops being typechecked,
* which is the exact hole the typecheck-coverage work exists to close.
*
* Why the whole tree rather than a list of roots: a hand-kept list drifts from the
* tsconfig `include` globs it is meant to shadow, and the gap is invisible.
*/
const repoRoot = fileURLToPath(new URL('../..', import.meta.url))
const SCANNED_ROOTS = ['src', 'tests', 'config', 'mobile/src', 'mobile/app']
const IGNORED_DIRECTORIES = new Set([
'.git',
'node_modules',
'dist',
'out',
'build',
'.git',
'coverage',
'.cross-version-checkouts'
])
// TypeScript resolves these in order, so an earlier entry shadows a later one.
const EXTENSION_PRIORITY = ['.ts', '.tsx', '.d.ts']
const EXTENSION_PRIORITY = ['.ts', '.tsx', '.mts', '.cts', '.d.ts', '.d.mts', '.d.cts']
function collectFiles(root) {
let found = []
let entries
try {
entries = readdirSync(root)
entries = readdirSync(root, { withFileTypes: true })
} catch {
return found
}
for (const entry of entries) {
if (IGNORED_DIRECTORIES.has(entry)) {
if (IGNORED_DIRECTORIES.has(entry.name)) {
continue
}
const fullPath = join(root, entry)
if (statSync(fullPath).isDirectory()) {
const fullPath = join(root, entry.name)
let isDirectory = entry.isDirectory()
if (entry.isSymbolicLink()) {
// Why guarded: a dangling symlink must not fail the sweep with a raw ENOENT.
try {
isDirectory = statSync(fullPath).isDirectory()
} catch {
continue
}
}
if (isDirectory) {
found = found.concat(collectFiles(fullPath))
continue
}
if (EXTENSION_PRIORITY.some((extension) => entry.endsWith(extension))) {
if (EXTENSION_PRIORITY.some((extension) => entry.name.endsWith(extension))) {
found.push(fullPath)
}
}
@@ -47,21 +59,19 @@ function collectFiles(root) {
}
function extensionOf(path) {
return path.endsWith('.d.ts') ? '.d.ts' : path.endsWith('.tsx') ? '.tsx' : '.ts'
}
function stemOf(path) {
return path.slice(0, -extensionOf(path).length)
return EXTENSION_PRIORITY.filter((extension) => path.endsWith(extension)).sort(
(left, right) => right.length - left.length
)[0]
}
describe('typecheck extension shadowing', () => {
it('has no file hidden from the program by a higher-priority extension', () => {
const byStem = new Map()
for (const root of SCANNED_ROOTS) {
for (const file of collectFiles(join(repoRoot, root))) {
const stem = stemOf(relative(repoRoot, file).replaceAll('\\', '/'))
byStem.set(stem, [...(byStem.get(stem) ?? []), extensionOf(file)])
}
for (const file of collectFiles(repoRoot)) {
const relativePath = relative(repoRoot, file).replaceAll('\\', '/')
const extension = extensionOf(relativePath)
const stem = relativePath.slice(0, -extension.length)
byStem.set(stem, [...(byStem.get(stem) ?? []), extension])
}
const shadowed = [...byStem.entries()]
+3 -1
View File
@@ -1,6 +1,8 @@
{
// Enforced by `pnpm typecheck`; pre-existing diagnostic identities and counts are ratcheted
// in typecheck-e2e-diagnostics.json so new errors fail while the backlog shrinks.
// in typecheck-e2e-diagnostics.json so the backlog only shrinks. Identity is
// {file, code, message} without line/column, so a new error is caught unless it exactly
// matches one that was fixed in the same file. Regenerate with `pnpm typecheck:e2e:write`.
"extends": "@electron-toolkit/tsconfig/tsconfig.node.json",
// Why: specs import renderer and main modules directly, so the ambient declarations those
// modules rely on (vite client types, build-time defines) have to be in the program too.