Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/workflows/checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link

Copilot AI Apr 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CI adds a lint:custom step, but it currently runs only no-raw-typeof, no-raw-regex, and no-raw-process-env (per package.json). The newly added no-duplicate-guards check is not executed anywhere in CI, so duplicates can still land by bypassing local hooks. Consider running bun check:all (optionally with continue-on-error while backlog is cleared) or adding no-duplicate-guards to lint:custom / as its own step.

Suggested change
continue-on-error: true
continue-on-error: true
# TODO: remove continue-on-error once the duplicate-guards backlog is cleared.
- name: Check duplicate guards
run: bun no-duplicate-guards
continue-on-error: true

Copilot uses AI. Check for mistakes.
- 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
Expand Down
2 changes: 1 addition & 1 deletion biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@
"noUnusedImports": "error"
},
"performance": {
"useTopLevelRegex": "warn"
"useTopLevelRegex": "error"
},
"style": {
"noNonNullAssertion": "error"
Expand Down
9 changes: 9 additions & 0 deletions lefthook.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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."
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
16 changes: 16 additions & 0 deletions scripts/check-all.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
//
Expand Down Expand Up @@ -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'),
Expand All @@ -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'),
Expand Down
184 changes: 184 additions & 0 deletions scripts/lint/no-duplicate-guards.ts
Original file line number Diff line number Diff line change
@@ -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'];

Comment on lines +67 to +69
Copy link

Copilot AI Apr 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

EXCLUDED_PREFIXES values include a trailing /, but walkDir builds relPath values like packages/guards (no trailing slash) for the directory itself. As a result, isExcluded('packages/guards') returns false and this script will scan the canonical implementations in packages/guards/ (and packages/checks/), causing self-violations and making the check fail on every run. Consider normalizing relPath (e.g., ensure it always ends with / when comparing) or updating isExcluded to treat both exact directory matches and prefix matches as excluded.

Copilot uses AI. Check for mistakes.
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);
Loading