diff --git a/.github/workflows/mobile.yml b/.github/workflows/mobile.yml index 3256a8ed5e9..5af2ed16c22 100644 --- a/.github/workflows/mobile.yml +++ b/.github/workflows/mobile.yml @@ -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 diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index f8208e1d82a..a8fb02bc924 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -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 diff --git a/config/scripts/typecheck-extension-shadowing.test.mjs b/config/scripts/typecheck-extension-shadowing.test.mjs index 8cb8de1d572..8988344761d 100644 --- a/config/scripts/typecheck-extension-shadowing.test.mjs +++ b/config/scripts/typecheck-extension-shadowing.test.mjs @@ -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()] diff --git a/config/tsconfig.e2e.json b/config/tsconfig.e2e.json index 7c2535b8338..763d8399616 100644 --- a/config/tsconfig.e2e.json +++ b/config/tsconfig.e2e.json @@ -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.