- name
- oxlint-typescript-conventions
- description
- WHAT: Write TypeScript under `lib/node/`, `tests/`, and `config/pi/` that passes the repo's strict `oxlint --type-aware` configuration on the first try, so the husky pre-commit hook does not reject the commit. WHEN: User asks to add or edit a `.ts` / `.spec.ts` file anywhere outside `config/pi/extensions/` (the extensions tree has a relaxed override). DO-NOT: Bypass the rules with `// oxlint-disable` instead of fixing the code.
# oxlint TypeScript conventions
This repo runs `oxlint --type-aware` with `correctness: error` and `perf: error` plus the full `typescript` plugin (see
[`oxlint.config.ts`](../../../oxlint.config.ts)). The husky pre-commit hook runs `lint-staged`, which invokes `oxlint`
against every staged `.ts` and rejects the commit on any error. The rules are non-trivially strict; new code that passes
`tsc` will routinely fail oxlint unless it is written with these patterns from the start.
The `config/pi/extensions/**` tree - plus the shared pi-importing helpers under `lib/node/pi/ext/**` and their specs
under `tests/lib/node/pi/ext/**` - has a relaxed override (most `no-unsafe-*` and `require-await` rules turned off)
because those files import from `@earendil-works/*` (pi runtime / TUI / AI APIs). **This skill applies to everything
else** — `lib/node/**` (except `lib/node/pi/ext/`), `tests/**` (except `tests/lib/node/pi/ext/`), `config/pi/*.ts` (e.g.
`session-usage.ts`), and any future top-level `*.ts`.
## When to use this skill
Apply whenever the user asks to:
- Add or edit a `.ts` file under `lib/node/`.
- Add or edit a `.spec.ts` under `tests/lib/`, `tests/config/`, or anywhere else vitest picks up.
- Add a TypeScript helper that other extensions consume.
Skip this skill for `.ts` files inside `config/pi/extensions/` (different rule set), `*.d.ts` type shims, and generated
files. The `commit-message` and `lint-and-test-gate` skills are still the final word on subject lines and verification —
this one only governs the source style.
## The pre-commit gate
```sh
$ git commit ...
✖ Task killed: oxlint-tsgolint
✖ Task killed: oxfmt --no-error-on-unmatched-pattern
husky - pre-commit script failed (code 1)
```
Run `npx oxlint --type-aware <file-or-dir>` against your changes BEFORE `git commit`. The pre-commit hook does the same
thing; running it manually saves the round trip.
For format issues separately: `npx oxfmt --check <files>` (and `npx oxfmt <files>` to fix in place).
## Patterns that trip the gate
### `typescript/require-await` — async stubs without `await`
Tests routinely install async callbacks for type compatibility, then never `await`. oxlint flags this; rewrite as a
non-async `() => Promise.resolve(...)`.
```ts
// ✗ Fails: async with no await.
installSandboxWrapper(async (cmd) => ({ command: `srt -- ${cmd}`, wrapped: true }));
// ✓ Use Promise.resolve so the function stays non-async.
installSandboxWrapper((cmd) => Promise.resolve({ command: `srt -- ${cmd}`, wrapped: true }));
```
The rule is keyed off the literal `async` keyword; an async function with NO `await` is the trigger. A real async helper
that genuinely uses `await` passes.
### `typescript/prefer-includes` — `.test(literal)` on a substring
`x.includes('foo')` is preferred over `/foo/.test(x)` whenever the regex is just a literal substring with no
metacharacters. oxlint flags the `.test()` form even inside test assertions.
```ts
// ✗ Fails: regex carries no metacharacters.
expect(warnings.find((w) => /silently drop/.test(w.reason))).toBeDefined();
// ✓ Substring match.
expect(warnings.find((w) => w.reason.includes('silently drop'))).toBeDefined();
```
A real regex (anchors, character classes, alternation) is fine — keep `/^foo$/.test(s)` as-is.
### `eslint/no-use-before-define` — declare before first use
Function and variable declarations must precede their first reference in source order. ESLint's `no-use-before-define`
is on with the default config, which means the standard "helpers at the bottom" Python style does not work.
```ts
// ✗ Fails: uniqueSorted used before defined.
export function compileLinuxRules(...) {
return { paths: uniqueSorted([...paths]), ... };
}
function uniqueSorted(input: string[]): string[] {
return [...new Set(input)].sort();
}
// ✓ Define helpers above their first call.
function uniqueSorted(input: string[]): string[] {
return [...new Set(input)].sort();
}
export function compileLinuxRules(...) {
return { paths: uniqueSorted([...paths]), ... };
}
```
### `typescript/explicit-function-return-type` — annotate test helpers
`allowExpressions: true` is set, so arrow functions passed inline (`map((x) => x + 1)`) are fine. Top-level declarations
and named factory helpers in tests are NOT, even when the return type is inferable.
```ts
// ✗ Fails: top-level helper.
function makeUI(over = {}) {
return { hasUI: true, ... };
}
// ✓ Annotate the return type.
function makeUI(over: Partial<UIBridge> = {}): UIBridge {
return { hasUI: true, ... };
}
// ✗ Fails: test-local factory.
const noop = () => undefined;
// ✓ Annotate.
const noop = (): undefined => undefined;
```
### `typescript/no-unsafe-assignment` / `no-unsafe-member-access` — narrow `unknown` properly
`Array.isArray(value)` narrows to `any[]`, and `JSON.parse(...)` returns `any`. Either widens to a typed shape with an
explicit `as unknown[]` / `as Record<string, unknown>` step before iterating, or parses through a typed schema.
```ts
// ✗ Fails: `value` after Array.isArray() is `any[]`, item is `any`.
if (!Array.isArray(value)) return;
for (let i = 0; i < value.length; i++) {
const item = value[i];
if (typeof item !== 'string') continue;
out.push(item);
}
// ✓ Re-cast through unknown[] so item is `unknown`.
if (!Array.isArray(value)) return;
const arr = value as unknown[];
for (let i = 0; i < arr.length; i++) {
const item = arr[i];
if (typeof item !== 'string') continue;
out.push(item);
}
// ✗ Fails: JSON.parse returns any, .command access is unsafe.
expect(JSON.parse(line).command).toBe('first');
// ✓ Type the parse target.
const rec = JSON.parse(line) as { command: string };
expect(rec.command).toBe('first');
```
### `typescript/consistent-indexed-object-style` — prefer `Record<K, V>`
`{ [k: symbol]: unknown }` index signatures are flagged in favor of `Record<symbol, unknown>`.
```ts
// ✗ Fails.
const g = globalThis as unknown as { [k: symbol]: unknown };
// ✓
const g = globalThis as unknown as Record<symbol, unknown>;
```
### `eslint/no-empty-function` — sentinel for `let cb = () => {}`
A bare `() => {}` placeholder for a callback that's about to be reassigned trips the rule. Either use a named sentinel
function (with a comment explaining the unreachability) or restructure to avoid the placeholder.
```ts
// ✗ Fails.
let resolveFn: () => void = () => {};
const next = new Promise<void>((resolve) => {
resolveFn = resolve;
});
return resolveFn;
// ✓ Named sentinel.
function noopResolve(): void {
/* unreachable - reassigned by Promise ctor */
}
let resolveFn: () => void = noopResolve;
const next = new Promise<void>((resolve) => {
resolveFn = resolve;
});
return resolveFn;
```
### `eslint/no-unused-vars` — drop unused imports
Particularly easy to hit when refactoring: an `import { foo, bar }` line where one of the names is no longer referenced.
The rule respects the `^_` ignore prefix for arguments and destructured elements but NOT for imports — drop the unused
name from the import list.
## Workflow
1. Write the code naturally; run `npm run tsc` first to confirm types are sound.
2. Run `npx oxlint --type-aware <changed-paths>` (paths or directories — both work).
3. Fix every error reported. The patterns above cover ~95% of what trips on a fresh helper.
4. Run `npx oxfmt --check <changed-paths>`; run without `--check` to format if it complained.
5. Stage and commit. The pre-commit hook re-runs both — your local pass guarantees a clean commit.
## Anti-patterns
- **Disabling rules with `// oxlint-disable-next-line ...`.** Almost never the right answer in this repo; the rules
above all have a clean-code form. Reach for the disable comment only when a third- party type forces the unsafe
pattern, and document why in the disable comment.
- **Casting through `Record<string, unknown>` reflexively.** The `as unknown[]` / explicit-shape approach narrows
correctly without adding a load-bearing assertion. Casts that hide a real bug are a real maintenance hazard — when in
doubt, validate the shape with a guard.
- **Skipping `oxlint` locally** because "the pre-commit hook will catch it." It will, by aborting your commit
mid-message-typing. Run it yourself first.
- **Matching the relaxed `config/pi/extensions/` posture in `lib/`** because "extensions get away with it." Lib helpers
are the typed surface that extensions consume; tightening lib while extensions stay loose is the deliberate boundary.
## References
- [`oxlint.config.ts`](../../../oxlint.config.ts) — every rule in force, plus the `config/pi/extensions/**` override.
- [oxlint TypeScript rules index](https://oxc.rs/docs/guide/usage/linter/rules.html) — the upstream rule docs for the
messages above.
- [`lint-and-test-gate`](../lint-and-test-gate/SKILL.md) — when to run lint vs tests vs both.
- [`commit-message`](../commit-message/SKILL.md) — what to put in the commit subject after the gate is green.
GitHubで見る