diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index bab356e4d2..21833f795e 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -49,6 +49,14 @@ jobs: run: bun scripts/lint/no-duplicate-deps.ts - name: Check package.json ordering run: bun scripts/format/sort-package-json.ts --check + # TODO: remove continue-on-error once the existing typeof/cast backlog is cleared. + # Pre-push hook already blocks new violations — these report on the backlog. + - name: Custom lint rules (typeof guards, raw regex, process.env) + run: bun lint:custom + continue-on-error: true + - name: Check unsafe type casts + run: bun check:casts:strict + continue-on-error: true - name: Check types run: bun check-types - name: Run Expo Doctor diff --git a/biome.json b/biome.json index a5d0642202..e775361783 100644 --- a/biome.json +++ b/biome.json @@ -47,7 +47,7 @@ "noUnusedImports": "error" }, "performance": { - "useTopLevelRegex": "warn" + "useTopLevelRegex": "error" }, "style": { "noNonNullAssertion": "error" diff --git a/lefthook.yml b/lefthook.yml index 2f99ab00e3..3a0b583cb0 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -6,3 +6,12 @@ pre-commit: lint: run: bun check {staged_files} fail_text: "Linting failed! \nRun `bun lint` to fix. \nCommit with `--no-verify` to bypass check." + +pre-push: + skip: + - run: test "${CI:-false}" = "true" + - run: test "${GITHUB_ACTIONS:-false}" = "true" + commands: + custom-checks: + run: bun scripts/check-all.ts + fail_text: "Custom checks failed! \nRun `bun check:all` to see details." diff --git a/package.json b/package.json index c3a138c8d6..ff3c96f1b3 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "api": "bun run --cwd packages/api dev", "bump": "bun .github/scripts/bump.ts", "check": "biome check --no-errors-on-unmatched", + "check:all": "bun scripts/check-all.ts", "check:casts": "bun run --cwd packages/checks check:casts", "check:casts:strict": "bun run --cwd packages/checks check:casts:strict", "check:catalog": "bun scripts/lint/no-duplicate-deps.ts", @@ -33,7 +34,7 @@ "ios": "cd apps/expo && bun ios", "lefthook": "lefthook install", "lint": "biome check --write", - "lint:custom": "bun run scripts/lint/no-raw-typeof.ts && bun run scripts/lint/no-raw-regex.ts && bun run packages/env/scripts/no-raw-process-env.ts", + "lint:custom": "bun run scripts/lint/no-raw-typeof.ts && bun run scripts/lint/no-raw-regex.ts && bun run packages/env/scripts/no-raw-process-env.ts && bun run scripts/lint/no-duplicate-guards.ts", "lint:strict": "biome check && bun run lint:custom", "lint-unsafe": "biome check --write --unsafe", "mcp": "bun run --cwd packages/mcp dev", diff --git a/scripts/check-all.ts b/scripts/check-all.ts index 3080eef20b..6829f1a093 100644 --- a/scripts/check-all.ts +++ b/scripts/check-all.ts @@ -5,9 +5,12 @@ // Runs the following checks in parallel and prints a unified summary table: // - scripts/lint/no-raw-regex.ts // - scripts/lint/no-raw-typeof.ts +// - packages/env/scripts/no-raw-process-env.ts // - scripts/lint/no-circular-deps.ts // - scripts/lint/no-duplicate-deps.ts (skipped if file doesn't exist) +// - scripts/lint/no-duplicate-guards.ts // - packages/checks/src/check-magic-strings.ts +// - packages/checks/src/check-type-casts.ts --strict // - scripts/lint/check-react-doctor.ts // - scripts/format/sort-package-json.ts --check // @@ -58,6 +61,10 @@ const ALL_CHECKS: CheckDef[] = [ name: 'no-raw-typeof', script: join(ROOT, 'scripts', 'lint', 'no-raw-typeof.ts'), }, + { + name: 'no-raw-process-env', + script: join(ROOT, 'packages', 'env', 'scripts', 'no-raw-process-env.ts'), + }, { name: 'no-circular-deps', script: join(ROOT, 'scripts', 'lint', 'no-circular-deps.ts'), @@ -66,6 +73,15 @@ const ALL_CHECKS: CheckDef[] = [ name: 'no-duplicate-deps', script: join(ROOT, 'scripts', 'lint', 'no-duplicate-deps.ts'), }, + { + name: 'no-duplicate-guards', + script: join(ROOT, 'scripts', 'lint', 'no-duplicate-guards.ts'), + }, + { + name: 'check-type-casts', + script: join(ROOT, 'packages', 'checks', 'src', 'check-type-casts.ts'), + args: ['--strict'], + }, { name: 'check-magic-strings', script: join(ROOT, 'packages', 'checks', 'src', 'check-magic-strings.ts'), diff --git a/scripts/lint/no-duplicate-guards.ts b/scripts/lint/no-duplicate-guards.ts new file mode 100644 index 0000000000..3c9f365d48 --- /dev/null +++ b/scripts/lint/no-duplicate-guards.ts @@ -0,0 +1,184 @@ +#!/usr/bin/env bun +// +// no-duplicate-guards.ts — flags re-implementations of guards that are already +// exported from @packrat/guards. +// +// The guards package (packages/guards/) is the single source of truth for all +// type narrowing and assertion helpers. Duplicating them in app code leads to +// subtle behavioural divergence and breaks the "use guards, not casts" policy. +// +// Flags: +// - assertDefined / assertNonNull / assertPresent / assertIsString / +// assertIsNumber / assertIsBoolean / assertAllDefined +// - isString / isNumber / isBoolean / isFunction / isArray / isObject / +// isDate / isDefined / isPresent (re-implementations, not re-exports) +// - makeEnumGuard / makeTypeGuard / assertError / assertNever +// +// A "re-implementation" is any function declaration or arrow-function +// assignment whose name matches one of the guard names above, found outside +// packages/guards/ and packages/checks/ (the check scripts themselves). +// +// Exit code: +// 0 — no violations +// 1 — violations found +// +// Wired into check-all.ts and lint:custom. + +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { join } from 'node:path'; + +const ROOT = join(import.meta.dir, '..', '..'); + +const SCAN_ROOTS = ['apps', 'packages']; + +// Names exported from @packrat/guards that should not be re-implemented elsewhere. +const GUARD_NAMES = new Set([ + // assertions.ts + 'assertDefined', + 'assertNonNull', + 'assertPresent', + 'assertIsString', + 'assertIsNumber', + 'assertIsBoolean', + 'assertAllDefined', + // re-exported from ts-extras — flag if home-grown + 'assertError', + 'assertNever', + 'isDefined', + 'isPresent', + // re-exported from radash — flag if home-grown + 'isString', + 'isNumber', + 'isBoolean', + 'isFunction', + 'isArray', + 'isObject', + 'isDate', + 'isFloat', + 'isInt', + 'isSymbol', + 'isPrimitive', + 'isPromise', + // custom guards/parsers + 'makeEnumGuard', + 'makeTypeGuard', +]); + +// Excluded source roots (the canonical definitions live here). +const EXCLUDED_ROOTS = ['packages/guards', 'packages/checks']; + +const EXCLUDED_DIRS = new Set(['node_modules', 'dist', 'build', '.next', '.expo', 'drizzle']); + +// Matches: +// export function assertDefined(...) +// function assertDefined(...) +// const assertDefined = (...) +// export const assertDefined = (...) +// export const assertDefined: (...) => +const IMPL_PATTERN = + /(?:export\s+)?(?:function\s+|const\s+|let\s+)([A-Za-z][A-Za-z0-9_]*)\s*(?:[=(:<])/g; + +interface Violation { + file: string; + line: number; + name: string; + source: string; +} + +function isTargetFile(name: string): boolean { + return ( + /\.(ts|tsx|cts|mts)$/.test(name) && !/\.(test|spec|stories|d)\.(ts|tsx|cts|mts)$/.test(name) + ); +} + +function isExcluded(relPath: string): boolean { + return EXCLUDED_ROOTS.some((p) => relPath === p || relPath.startsWith(p + '/')); +} + +function walkDir(dir: string, relPath: string, violations: Violation[]): void { + if (isExcluded(relPath)) return; + + let entries: string[]; + try { + entries = readdirSync(dir); + } catch { + return; + } + + for (const entry of entries) { + if (EXCLUDED_DIRS.has(entry)) continue; + + const fullPath = join(dir, entry); + const entryRel = `${relPath}/${entry}`; + + let isDir = false; + try { + isDir = statSync(fullPath).isDirectory(); + } catch { + continue; + } + + if (isDir) { + walkDir(fullPath, entryRel, violations); + } else if (isTargetFile(entry)) { + let content: string; + try { + content = readFileSync(fullPath, 'utf8'); + } catch { + continue; + } + + const lines = content.split('\n'); + for (let i = 0; i < lines.length; i++) { + const line = lines[i] ?? ''; + const trimmed = line.trimStart(); + + // Skip comment lines and import/export-from lines + if ( + trimmed.startsWith('//') || + trimmed.startsWith('*') || + trimmed.startsWith('/*') || + /^\s*export\s*\{/.test(line) || + /^\s*(import|export)\s+.*\s+from\s+['"]/.test(line) + ) { + continue; + } + + IMPL_PATTERN.lastIndex = 0; + for (let m = IMPL_PATTERN.exec(line); m !== null; m = IMPL_PATTERN.exec(line)) { + const name = m[1]; + if (name && GUARD_NAMES.has(name)) { + violations.push({ file: entryRel, line: i + 1, name, source: line.trimEnd() }); + } + } + } + } + } +} + +const violations: Violation[] = []; +for (const root of SCAN_ROOTS) { + walkDir(join(ROOT, root), root, violations); +} + +if (violations.length === 0) { + console.log('No duplicate guard implementations found.'); + process.exit(0); +} + +console.log( + `Found ${violations.length} guard re-implementation(s) outside @packrat/guards — import from '@packrat/guards' instead:\n`, +); + +let lastFile = ''; +for (const v of violations) { + if (v.file !== lastFile) { + console.log(` ${v.file}`); + lastFile = v.file; + } + console.log(` line ${v.line}: ${v.name}`); + console.log(` ${v.source}`); +} + +console.log("\nFix: remove the local copy and import from '@packrat/guards'."); +process.exit(1);