Files
tiny-pattern-ts/development/tooling.md
T
tmu 16904440cf 📝 Place test-wide suppressions in the config
Drop the fixed no-floating-promises header exception from CONTRIBUTING.md
and AGENTS.md; agents must not add a source disable or edit .oxlintrc.json.
Record the split in tooling.md and testing.md: a one-site false positive is a
source disable, a file-class one lives in the test override.
2026-09-21 10:01:30 +00:00

11 KiB

Tooling

Every tool below was chosen and configured deliberately. The commands a contributor runs are in CONTRIBUTING.md and the versions in package.json.

Tool inventory

  • TypeScript 7 — type checker and build (tsc).
  • node --test + --strip-types — test runner.
  • c8 — coverage for test:ci.
  • oxlint — Rust linter, type-aware via oxlint-tsgolint (typescript-go).
  • oxfmt — Rust formatter (Prettier-compatible) for JS/TS, JSON/JSONC, YAML, Markdown, MDX, and more; its package.json key sorting replaces sort-package-json.
  • cspell — spell checking.
  • knip — unused dependencies, exports, and files.
  • check-outdated — dependencies behind the registry; exits non-zero when any is outdated.
  • publint — validates package.json for ESM publishing.
  • @arethetypeswrong/cli (attw) — validates .d.ts against module-resolution scenarios.
  • lefthook — git hooks.
  • @spences10/pi-lsp — read-only LSP code intelligence for AI agents (project-local .pi/settings.json); talks to this repo's TypeScript 7 via tsc --lsp --stdio.
  • vscode-languageserver-protocol — LSP client and protocol types for the autocomplete test helper (src/util/__tests__/lsp-completion.ts).

When each runs is in CONTRIBUTING.md § Feedback tiers.

TypeScript and build

One type-check config, one emit config

Decision (2026-09)

tsconfig.json extends @tsconfig/strictest + @tsconfig/node26. tsconfig.build.json adds the emit-only options (declaration, sourceMap, inlineSources, outDir, target: es2024, rewriteRelativeImportExtensions: true) and excludes test files.

Why

  • The editor and CI type-check from one config while the build emits from the other, so a test file cannot leak into dist/.
  • inlineSources embeds the original TypeScript in dist/*.js.map, so debuggers map into src/ without it being shipped.
  • declarationMap stays off: a .d.ts.map cannot embed source and would dangle.

The build starts from an empty dist/

Decision (2026-09)

npm run build runs a prebuild hook that empties dist/.

Why

  • tsc does not prune orphaned emit output — dropping declarationMap left stale *.d.ts.map files — so reproducibility needs an empty dist/.
  • prebuild removes only dist; the manual clean resets dist + coverage, so a local coverage report survives a build.

Source imports use .ts extensions

Decision (2026-09)

Source imports use .ts, never .js.

Why

  • node --strip-types resolves the .ts form at test time.
  • rewriteRelativeImportExtensions rewrites them to .js in the emitted JavaScript.
  • The emitted .d.ts keep the .ts specifier, which TypeScript >= 5.0 resolves (see README § Requirements), so no post-processing step is needed.

Rejected

  • "Pre-fixing" an import to .js: it breaks the inner node --strip-types loop.

Linting and formatting

Type-aware oxlint is a config property

Decision (2026-09)

Type-aware oxlint is enabled via options.typeAware: true in .oxlintrc.json (powered by oxlint-tsgolint).

Why

  • The script commands stay clean — no CLI flag.
  • A config property cannot be forgotten on one call site.

Rejected

  • A CLI flag in the check:oxlint / fix:oxlint scripts: it puts the mode in two places and invites them to drift.

oxlint-disable directives live next to the code

Decision (2026-09)

A type-aware rule that false-positives at one site is silenced with a source-level oxlint-disable directive (see src/primitive.ts). A rule that is wrong for a whole file class is turned off in a .oxlintrc.json overrides entry instead — e.g. typescript/no-floating-promises (synchronous expectTypeOf reads as an unhandled promise) and unicorn/no-null (intentional null inputs) for **/*.test.ts. The same exemption is not repeated as a file-level header in every affected file.

Why

  • A one-site disable sits next to the code it silences, visible to anyone reading the source, and the rule stays on everywhere else.
  • A file-class rule is a property of the file class, not of one line; the override states it once, where the rest of the file-class config lives.

Rejected

  • A project-wide disable in .oxlintrc.json for a one-site false positive: it hides the exemption from the reader of the affected code and switches the rule off repo-wide for a one-site problem.
  • A repeated file-level oxlint-disable header for a file-class false positive: the copies drift and scatter one config decision across the tree.

Known issue

  • Both placements are human last resorts. AI agents must neither add a source disable nor edit .oxlintrc.json; they fix the type at its root (see AGENTS.md § Never do).

Unwanted stylistic rules are turned off in the config

Decision (2026-09)

A stylistic rule the project rejects is "off" in the .oxlintrc.json rules map, not silenced at a use site. Current entries: eslint/capitalized-comments (comments may start lowercase) and eslint/no-ternary (ternaries are allowed), joining the oxfmt-superseded rules already off.

Why

  • The rule is wrong for the whole project, not mis-firing at one site, so there is no line to annotate.
  • Keeping the two mechanisms separate keeps a source-level oxlint-disable meaningful: it marks a lone exception.

Rejected

  • A source-level oxlint-disable per use: the same exemption repeated at every site, and oxfmt can move the site.

check:tsc runs first

Decision (2026-09)

check:tsc runs first in the npm run check chain.

Why

  • A type error short-circuits the rest, which is faster than running oxlint/oxfmt and failing on tsc at the end.

.editorconfig is a fallback, not a gate

Decision (2026-09)

.editorconfig exists for editor compatibility; where both apply, .oxfmtrc.json is authoritative.

Why

  • .editorconfig covers the files oxfmt does not format: shell scripts, dotfiles, LICENSE, the commit-message template, and git's COMMIT_EDITMSG buffer.
  • oxfmt is the formatter; the overlapping keys only keep non-oxfmt editors close to the formatted result, so they cannot disagree with the checker.

Static analysis and packaging

knip omits the types category

Decision (2026-09)

knip --include dependencies,exports,files omits the types category.

Why

  • types produces systematic false positives for libraries whose exported types are part of the public API.
  • The narrower scope keeps the signal high without config-file boilerplate.

maintain:outdated ignores @types/node

Decision (2026-09)

Pass --ignore-packages @types/node.

Why

  • DT pins @types/node's latest dist-tag to LTS (22.x); current-line types ride other tags. Scan sees latest < installed — permanent "reverted", exit 1, zero signal. --ignore-pre-releases no help: 22.20.3 is stable.

Rejected

  • --types major,minor,patch: hides real reverted reports elsewhere.
  • @types/node@26.*: tag stays wrong across majors; un-pin per bump = ritual.

Known issue

  • A genuinely behind @types/node goes unreported; match it to .node-version by hand.

attw targets ESM-only

Decision (2026-09)

attw --profile esm-only is used.

Why

  • The package is intentionally ESM-only (no CommonJS shim), so CJS resolution scenarios are out of scope by design, not a bug.

tslib is deliberately not used

Decision (2026-09)

tslib is not a dependency.

Why

  • tslib is a runtime helper for old ES3/ES5 targets; this project targets ES2024.

Git hooks and script wiring

LEFTHOOK_FILES scopes commands to staged files

Decision (2026-09)

The pre-commit hook sets LEFTHOOK_FILES to the staged-files list, and the affected scripts use ${LEFTHOOK_FILES:-<default>} to default to the whole project.

Why

  • It keeps package.json#scripts the single source of truth; lefthook.yml only says what to run on which files.
  • The same script works by hand (whole project) and staged (scoped), so there is no second command to maintain.

Language server tooling

vscode-languageserver-protocol backs the autocomplete helper

Decision (2026-09)

The autocomplete helper (src/util/__tests__/lsp-completion.ts) drives tsc --lsp --stdio through vscode-languageserver-protocol's createMessageConnection and its typed request / notification objects, instead of a hand-rolled JSON-RPC client.

Why

  • Framing, Content-Length parsing, the pending-request map and server-request dispatch are protocol plumbing the helper only reimplemented; the official client owns them and tolerates the server's string | number ids.
  • InitializeRequest, CompletionRequest, DidOpenTextDocumentNotification, … carry their parameter and result types, so CompletionList / CompletionItem replace the helper's ad-hoc shape guards.
  • The ./node entry re-exports vscode-jsonrpc/node, so one devDependency supplies both the transport and the protocol types. It is test-only and never ships (files publishes dist/ only).

Rejected

  • vscode-languageclient: the editor-side client with a full feature registry — far more than a test helper needs.
  • Generic JSON-RPC (jsonrpc-lite, jayson): still no LSP types, so they replace framing only and leave the typed protocol surface unimplemented.
  • Keeping the hand-rolled client: the low-level shape is the maintenance cost the helper exists to remove, and it must be re-audited against the server.

Editor and agent tooling

VSCode integration

  • Recommended extensions are in .vscode/extensions.json (oxc, cspell, TypeScript native-preview, EditorConfig, todo-tasks).
  • TypeScript 7 runs via the typescriptteam.native-preview extension.
  • The oxc extension provides oxlint squiggles and oxfmt format-on-save; .vscode/settings.json pins it per language so a local [language] formatter setting cannot override the project's choice.

@spences10/pi-lsp is pinned and read-only

Decision (2026-09)

@spences10/pi-lsp is pinned to 0.0.46 and used read-only.

Why

  • It inspects node_modules/typescript, sees major >= 7 with no lib/tsserver.js (true of the typescript-go / tsgo port), and spawns the repo's own tsc --lsp --stdio — no typescript-language-server dependency is needed.
  • Earlier releases (<= 0.0.10) hard-wire to typescript-language-server --stdio and are TS6-only.
  • It is intermediate agent feedback (hover, references, definition, symbols, diagnostics), with no rename / code-action / apply-edit surface, and is never a gate — npm run check / verify are.
  • .pi/settings.json is the committed declaration; .pi/npm/ is a gitignored install cache that pi recreates on a trusted startup (running npm install for any missing project package), so it is deliberately not tracked.