`getMatcher` / `getMatcherW` become `getPrimitiveUnionMatcher` / `getPrimitiveUnionMatcherW`; `src/primitive.ts` and its test move to `src/primitive-union.*`. The `Union` suffix mirrors `getTaggedUnionMatcher`.
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.jsonkey sorting replacessort-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.jsonfor ESM publishing. - @arethetypeswrong/cli (
attw) — validates.d.tsagainst 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 viatsc --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/. inlineSourcesembeds the original TypeScript indist/*.js.map, so debuggers map intosrc/without it being shipped.declarationMapstays off: a.d.ts.mapcannot 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
tscdoes not prune orphaned emit output — droppingdeclarationMapleft stale*.d.ts.mapfiles — so reproducibility needs an emptydist/.prebuildremoves onlydist; the manualcleanresetsdist+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-typesresolves the.tsform at test time.rewriteRelativeImportExtensionsrewrites them to.jsin the emitted JavaScript.- The emitted
.d.tskeep the.tsspecifier, 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 innernode --strip-typesloop.
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:oxlintscripts: 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-union.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.jsonfor 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-disableheader 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-disablemeaningful: it marks a lone exception.
Rejected
- A source-level
oxlint-disableper 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
tscat 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
.editorconfigcovers the files oxfmt does not format: shell scripts, dotfiles,LICENSE, the commit-message template, and git'sCOMMIT_EDITMSGbuffer.- 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
typesproduces 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'slatestdist-tag to LTS (22.x); current-line types ride other tags. Scan sees latest < installed — permanent "reverted", exit 1, zero signal.--ignore-pre-releasesno 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/nodegoes unreported; match it to.node-versionby 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
tslibis 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#scriptsthe single source of truth;lefthook.ymlonly 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-Lengthparsing, the pending-request map and server-request dispatch are protocol plumbing the helper only reimplemented; the official client owns them and tolerates the server'sstring | numberids. InitializeRequest,CompletionRequest,DidOpenTextDocumentNotification, … carry their parameter and result types, soCompletionList/CompletionItemreplace the helper's ad-hoc shape guards.- The
./nodeentry re-exportsvscode-jsonrpc/node, so one devDependency supplies both the transport and the protocol types. It is test-only and never ships (filespublishesdist/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-previewextension. - The oxc extension provides oxlint squiggles and oxfmt format-on-save;
.vscode/settings.jsonpins 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 nolib/tsserver.js(true of thetypescript-go/tsgoport), and spawns the repo's owntsc --lsp --stdio— notypescript-language-serverdependency is needed. - Earlier releases (
<= 0.0.10) hard-wire totypescript-language-server --stdioand 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/verifyare. .pi/settings.jsonis the committed declaration;.pi/npm/is a gitignored install cache that pi recreates on a trusted startup (runningnpm installfor any missing project package), so it is deliberately not tracked.