Every .ts/.tsx file of the app compiles under one strict tsconfig set and passes one ESLint config
with no warnings, no inline disables and hard size limits; Prettier formats and knip finds dead code.
Style is enforced by machines or it does not exist. This skill holds those configs, the limits table,
the forbidden patterns, the TSDoc contract, and two checkers that prove the repo still follows them.
Rules that must hold
- Never silence a gate. No
eslint-disable,@ts-ignoreor@ts-nocheck;@ts-expect-erroronly where a third-party type is wrong, with a reason of 10+ characters. Why: silencing a check is the likeliest failure of an unsupervised agent, and inline config is switched off anyway. - One strict
tsconfig.base.json. It extendsexpo/tsconfig.baseand holds every compiler option and no other (no member ofstrictset tofalse); each program tsconfig sets onlytypes,paths,tsBuildInfoFile,includeandexclude, neverlib(the Expo base gives DOM + ESNext). Why: one set of guarantees for all code; no program quietly dropsstrict. - Two programs, two worlds.
src/tsconfig.jsonis the app (typesjest, no Node); the roottsconfig.jsonchecks the whole repo with Node types. App code never imports a Node-world file (app.config.ts,plugins/,scripts/,node:*), not even withimport type;expo-env.d.tsnever enters a program;src/app-env.d.tsstays out of the root program; the app program excludes nothing; both programs resolve@/*and@test/*to the same folders. Why: a type-only import still pulls the file into the app program and Node code into the bundle. - One ESLint config, errors only, all app code in scope.
eslint.config.mjsat the root is the only config; every rule is an error, lint runs aseslint . --max-warnings 0and only through the npm scripts. No ignore list (PRE_EXISTING,.prettierignore, the rootexclude) reaches app code. The canonical blocks, file groups and shared lists stay as the template ships them; an exception is a new block that names exact files and rebuilds the rule from the shared lists, with a reason comment and aGate-Change:trailer. Why: a later flat-config block replaces a rule's options, it does not merge, so a fresh short list, a widened group or a folder-wide block silently drops every other ban, however it is spelled. - Stay inside the limits. 250 code lines per file (400 in tests), 40 per
.tsfunction, 80 per.tsxcomponent, cyclomatic complexity 10 (modified), cognitive 15, nesting 3, 3 parameters (function types too), 3 nested callbacks (4 in tests), JSX depth 5, one component and one class per file, a string literal at most twice (the third use gets a name). Why: a small unit is read whole and fails in one place. - When a limit trips, split by responsibility. Extract named pure functions, a lookup table, an
options object or a sub-component with its own test. Never join lines, never
part-2.ts, never move logic into JSON; large static data does go in.json. Why: the limit detects a symptom; gaming it keeps the problem. - Erasable TypeScript only. No
enum(anas constarray and a union), nonamespace, no constructor parameter properties,typeinstead ofinterface(merging in*.d.tsaside), noany, no non-null!outside tests. Why:erasableSyntaxOnlykeeps every file runnable by Node type stripping and single-file transforms, and unions work with exhaustive switches. - Named exports, one alias, no parent imports.
export defaultonly where a tool reads it: Expo Router route files insrc/app/,app.config.ts, config plugins,__mocks__,jest.setup.ts. Import a sibling as./xand any other folder as@/…; Node-run files (app.config.ts,plugins/,scripts/) write the extension (./x.ts) and may use../. When multi-package, every import writes its extension. Why: greppable names and paths that survive moves; Node type stripping resolves no extensionless import. - Errors are values or loud. An expected failure returns a
Resultwith akindunion (the type issrc/core/result.ts, owned by error-handling-typed-results); throw onlyErrorobjects with{ cause }, for programmer errors; everycatchmaps, records through the error-reporting port with a fallback, or rethrows. Why: callers must handle every case, and nothing fails silently. - No floating promises; handlers stay synchronous and return nothing. Await, return, or end
with
.catch(reportError);void promisedoes not count. AhandleXfunction or inlineon*handler is neverasyncand has a block body that returns nothing, andhandleSubmit(onValid)is never passed straight to anon*prop. Why: React Native's Strict TypeScript API typesonPressas returningunknown, sono-misused-promisesmisses an async or promise-returning handler (onPress={() => Linking.openURL(url)}) and its rejection vanishes; in a block bodyno-floating-promisessees the promise. A promise-returning function passed by reference (onPress={retry}) passes every lint rule; async-safety's checker (ruleasync-by-reference) catches it. - Immutable data, injected time and randomness.
readonlytypes andas consttables, new objects instead of mutation;Date.now,new Date(),performance.nowandMath.randomonly behind the clock and random ports; deterministic code (src/core/rng,src/core/dates,*-generator.tsmodules, game rules) uses integer-safe math and never reads the device locale (localeCompare,Intl,toLocale*). Why: wall-clock code cannot be tested, and seeded output must match on every device (Hermes uses the platform libm and ICU data, V8 in Jest its own). - Prettier formats, knip prunes, pins hold. Prettier is the only formatter
(
eslint-config-prettierlast, nevereslint-plugin-prettier); every knip rule iserror; TypeScript, ESLint and its plugins, Prettier and knip stay on the verified exact pins. A limit, pin or baseline change is a stop-and-ask decision. Why: one formatter, no dead code, reproducible lint. - Comments say why; contracts get TSDoc. Every exported function or class in
src/core/and every exported*Porttype has a one-sentence/** … */stating its contract; noTODO/FIXME/HACK, no commented-out code. Why: summaries are read before bodies, and nothing comes back to a TODO.
Workflow
- Pick the task: set up or change configs (step 2), write code (step 3), fix a
tscor lint error (step 4), or add an exception (step 5). Always finish with step 6. - Configs. Read references/tsconfig-set.md and
references/eslint-config-guide.md. Copy each template to its
destination (table below), list each pre-existing top-level folder in three places
(
PRE_EXISTING,.prettierignoreand the roottsconfig.jsonexclude), install the pinned versions and theoverridesblock with the guide's commands, then runnode ${CLAUDE_SKILL_DIR}/scripts/check-configs.mjs .. A folder holding code the app imports (an older template'scomponents/orhooks/) is not pre-existing: move it undersrc/or declare it inrn-skills.config.json"layoutEntries". - Code. Before error handling, async code, a service or a new module, read the matching section of references/code-policies.md. Imitate examples/parse-task-id-param.ts (a Result), examples/with-timeout.ts (a timeout as a value; a synced copy of async-safety's template) and examples/use-task-editor-model.ts (a synchronous handler). Keep every unit inside the limits from the start.
- Errors. Find the rule in references/forbidden-patterns.md and write the replacement it names. For a limit, read references/limits-and-splitting.md and follow examples/splitting-a-long-function.md. Never edit the config to make an error go away.
- Exception. Only for a tool or library contract. Follow "Adding an exception, step by step" in
references/eslint-config-guide.md: a file-exact block rebuilt
from the shared lists, a reason comment,
--print-configproof, the quality-gates guardrail still green, aGate-Change:trailer. A limit or baseline change: stop and ask the developer. - Check. From the repo root, once
node_modulesexist:npm run typecheck,npm run lint,npm run format:check,npm run check:unused(before quality-gates adds the scripts:npx tsc --noEmit -p tsconfig.json && npx tsc --noEmit -p src/tsconfig.json,npx eslint . --max-warnings 0,npx prettier --check .,npx knip). Always:node ${CLAUDE_SKILL_DIR}/scripts/check-source.mjs .andnode ${CLAUDE_SKILL_DIR}/scripts/check-configs.mjs .. Fix everyFAILline (each names the file, the rule and the fix) and rerun until all printRESULT: PASS.
The templates are the verified canonical files; expo-project-bootstrap writes the same bytes when it
creates an app. Fill one thing when the repo has top-level folders or files that predate the app
(handbook/): add one line each at the end of .prettierignore (handbook/), the same entries to
PRE_EXISTING in eslint.config.mjs ('handbook/**') and to the root tsconfig.json exclude
("handbook": its **/*.ts include would otherwise type-check them). With none, the files stay as
shipped. A tool config the app uses (tailwind.config.js) is app code, not pre-existing: declare it
in rn-skills.config.json "layoutEntries" when check-configs asks. design/ (the design tokens)
is app layout; pre-existing exports inside it get their own .prettierignore line (design/exports/).
| Template | Destination in the app repo |
|---|---|
templates/tsconfig.base.json |
tsconfig.base.json |
templates/tsconfig.json |
tsconfig.json (root program: Node and tests) |
templates/src/tsconfig.json |
src/tsconfig.json (app program) |
templates/src/app-env.d.ts |
src/app-env.d.ts |
templates/eslint.config.mjs |
eslint.config.mjs |
templates/dot-prettierrc.json, templates/dot-prettierignore |
.prettierrc.json, .prettierignore |
templates/knip.json |
knip.json, with quality-gates' templates/contracts/knip-ignores.json at contracts/knip-ignores.json (the reasons for its ten ignore entries; its knip-ignores rule fails without them) |
When multi-package, read the "When multi-package" sections of both config references first; one
.prettierignore line per generated native folder (apps/*/ios/, apps/*/android/) is required.
Definition of done
-
npm run typecheckpasses for both programs (tsc --noEmit -p tsconfig.jsonand-p src/tsconfig.json). -
npm run lintpasses with--max-warnings 0;npm run format:checkandnpm run check:unusedpass. - No new
eslint-disable,@ts-ignoreor@ts-nocheck; any@ts-expect-errornames its third-party cause. - No file over 250 code lines (400 for tests), no function over 40 (80 for a component); every split followed a responsibility.
- Every new exported function in
src/core/and every port type has a one-sentence TSDoc summary; no TODOs, no commented-out code. - Every expected failure is a
Resultwith akindunion; everycatchmaps, records or rethrows withcause; no floating promise; every handler is synchronous with a block body. - Any config exception is a named, commented, file-exact block, committed with a
Gate-Change:trailer. - Every pre-existing top-level folder is in
PRE_EXISTING,.prettierignoreand the roottsconfig.jsonexclude, and no entry of those lists reaches app code;package.jsonpins the tools exactly and itsoverrideshold the ten@typescript-eslint/*packages. -
node ${CLAUDE_SKILL_DIR}/scripts/check-configs.mjs .printsRESULT: PASS(neither checker prints SKIP lines: every file they read is this skill's own) -
node ${CLAUDE_SKILL_DIR}/scripts/check-source.mjs .printsRESULT: PASS
Anti-patterns
eslint-disableor@ts-ignore"just this once". Inline config is inert and reported; the error is still there. Fix the code, or add a file-exact exception block for a real tool contract.- Raising a limit or turning a rule off in a later block.
check-configsreportseslint-limit-changedin every shape ESLint accepts (['error', 6],['error', { max: 6 }], a bare'error'that falls back to ESLint's default), any extra declaration of a limit rule, andeslint-rule-off. Split the code; a limit change is the developer's call. - Ignoring app code to get to green.
PRE_EXISTING: ['src/features/**'], a.prettierignoreline, anexcludeentry,eslint srcor--ignore-patterntake app code out of the gates while every tool stays quiet;check-configsreportsignore-app-codeandlint-script, andcheck-sourcechecks those files anyway. Fix the code. - A fresh short list, a widened group or a new folder-wide block.
'no-restricted-imports': ['error', { paths: [x] }],RESTRICTED_PROPERTIES.filter(...)or'src/**/*-screen.tsx'added toTESTSdrops bans for whole folders while ESLint stays green.check-configscompares the config with the template: a changed canonical block ([canonical-block]), a block beyond them that reaches more than named files ([exemption-scope]), a file group that loses a glob or gains a folder ([file-group]), an edited shared list ([canonical-const]), plus[fresh-list]and[option-redeclared]. Name the one file and rebuild fromRUNTIME_PATHSwithwithout(...). - Compressing code to get under a limit. Joined lines and nested ternaries read worse and trip
no-nested-ternary. Extract named functions or a lookup table. import typefromapp.config.tsorscripts/intosrc/. It drags the Node world into the app program. Put the shared type in a neutral file undersrc/and import it from both sides.onPress={async () => ...},onPress={() => Linking.openURL(url)}oronPress={handleSubmit(onValid)}because it type-checks. The Strict API hides the rejection (andopenURLrejects when no app handles the URL), and react-hook-form rethrows whatonValidthrows. Keep the handler synchronous with a block body:onPress={() => { handleSubmit(onValid)().catch(reportError); }}.- Throwing for bad outside input. Callers forget to catch. Return a
Resultwith akindunion. - Rewording a
RESTRICTED_PROPERTIESmessage or reformatting an option list. The quality-gates guardrail compares resolved values verbatim, andcheck-configspins the canonical messages. noUnusedLocalsin tsconfig. Two reporters disagree about_-prefixed parameters; ESLint'sno-unused-varsis the one reporter.- Declaring "the checkers pass" without ESLint and tsc. The scripts mirror the rules most often
broken and run before
node_modulesexist;npm run lintandnpm run typecheckare the full gate.
Files in this skill
| File | What it is | Read/run when |
|---|---|---|
| references/tsconfig-set.md | The two programs, every compiler option, app-env.d.ts, typed routes, expo-env.d.ts, Node-run code, when multi-package |
Workflow step 2; a missing global or a file in the wrong program |
| references/eslint-config-guide.md | Versions and install, the blocks, file groups, shared lists, exemptions, adding an exception, Prettier, knip, troubleshooting, when multi-package | Workflow steps 2 and 5; an unclear lint message |
| references/limits-and-splitting.md | The limits table with rationale, how lines are counted, the move for each tripped limit | Workflow step 4 for a limit; before a large module |
| references/forbidden-patterns.md | Every rejected pattern, why, which rule, and what to write instead | Workflow step 4 |
| references/code-policies.md | Comments and TSDoc, errors, async, immutability, dependency injection, imports | Workflow step 3 |
templates/tsconfig.base.json |
The strict compiler options every program extends | Workflow step 2 |
templates/tsconfig.json |
Root program: the whole repo with jest and node types, the aliases |
Workflow step 2 |
templates/src/tsconfig.json |
App program: jest types only, typed routes, the same aliases |
Workflow step 2 |
templates/src/app-env.d.ts |
process for EXPO_PUBLIC_* dot access in the app program |
Workflow step 2 |
templates/eslint.config.mjs |
The complete, verified ESLint 9 flat config | Workflow steps 2 and 5 |
templates/dot-prettierrc.json |
Prettier options (copy to .prettierrc.json) |
Workflow step 2 |
templates/dot-prettierignore |
Prettier ignore list; pre-existing top-level entries go at its end (copy to .prettierignore) |
Workflow step 2 |
templates/knip.json |
knip entry points, the agent-folder ignore list and every rule at error |
Workflow step 2 |
| examples/parse-task-id-param.ts | A route parameter parsed into a Result instead of a throw |
Workflow step 3 |
| examples/parse-task-id-param.test.ts | Its test: success and each expected failure | Workflow step 3 |
| examples/with-timeout.ts | A timeout and a cancel as expected failures, with the abandoned task aborted (synced from async-safety, which owns src/services/async/with-timeout.ts) |
Workflow step 3 (async adapters) |
| examples/with-timeout.test.ts | Fake-timer tests for it (synced from async-safety) | Workflow step 3 |
| examples/use-report-error.ts | The rejection handler for fire-and-forget work | Workflow step 3 (handlers) |
| examples/use-task-editor-model.ts | A model hook whose synchronous handler starts async work | Workflow step 3 (handlers) |
| examples/splitting-a-long-function.md | A 44-line, complexity-12 function, its four lint errors, and the split | Workflow step 4 for a limit |
| examples/score-day.ts | The split result: options object, lookup tables, named steps | Workflow step 4 |
| examples/score-day.test.ts | Tests for each extracted piece | Workflow step 4 |
| examples/rn-skills.config.example.json | The optional repo keys both checkers read | When the app's layout differs from the default |
scripts/check-configs.mjs |
Checks the tsconfig set, the ESLint config against templates/eslint.config.mjs (canonical blocks, file groups, shared lists, extra blocks name exact files, limits in any shape, fresh lists, re-declared options), Prettier and its ignore list, pre-existing folders in all three lists, ignore lists that reach app code, knip, pins, overrides, lint scripts and stray configs against the baseline |
Workflow steps 2, 5 and 6 |
scripts/check-source.mjs |
Checks every .ts/.tsx for silenced gates, limits, non-erasable syntax, exports, imports, the two worlds, handlers, errors and TSDoc |
Workflow step 6 |
scripts/selftest.mjs |
Proves both checkers pass the good fixtures and catch every planted bug | After changing a checker, the baseline or a template |
scripts/check-lib.mjs |
Shared script helper (synced; never edit here) | Never by hand |
scripts/lib/source-scan.mjs |
Dependency-free lexer and import resolver (synced) | Only when changing a checker |
scripts/lib/workspaces.mjs |
Workspace and Expo-app detection for the multi-package checks (synced) | Only when changing a checker |
scripts/lib/app-scope.mjs |
Which repo paths are app code and whether an ignore entry reaches them (this skill's own helper, used by both checkers) | Only when changing a checker |
scripts/lib/eslint-structure.mjs |
Reads an eslint.config.mjs without running it: consts, blocks, the files each block reaches (this skill's own helper) |
Only when changing check-configs |
assets/ts-lint-baseline.json |
The canonical compiler options, programs, required ESLint settings, limits, pins, knip rules | When a verified setting changes (stop and ask) |
assets/shared.json |
The shared files this skill copies in | When adding a shared file |
tests/ |
Overlay fixtures: tests/bases/configs-app plus the templates for check-configs, good/ for check-source |
When adding a rule to a checker |
Related skills
naming-conventions- file, identifier, route, testID and key names (the naming rules inside this config), and the path header (line 1 names the repo path).architecture-and-boundaries- where code goes, the import zones, ports and the composition root.error-handling-typed-results- theResulttype and failure design that rule 9 relies on.quality-gates- the npm scripts,lefthook.ymlhooks and the config guardrail that run these checks.react-components-and-hooks- the UI lint rules (primitives, memo, selectors) in depth.unit-and-component-tests- the Jest and Testing Library rules and the tests that go with a split.i18n-strings-and-catalogsandrtl-and-direction- the literal-string and logical-style rules.seeded-determinism-and-golden-vectors- the deterministic folders and their integer-safe math.dependency-management- installing, pinning and the release-age policy for these tools.expo-project-bootstrap- creates a new app with these files already in place.