80 Commits
Author SHA1 Message Date
tmu 7e7f716424 🚀 Release 0.4.0
CI / release-gate (push) Successful in 2s
CI / build (push) Successful in 30s
CI / maintain (push) Failing after 14s
CI / publish (push) Failing after 16s
2026-09-20 21:46:32 +00:00
tmu 62db7de537 🔀 Merge chore/unify-task-branching into main 2026-09-20 21:44:21 +00:00
tmu ff823b3cba 📝 Route every task through the full branching model
Drop the leaf-task exception in AGENTS.md: a task without subtasks was implemented on the current branch, bypassing create:branch / create:finish. Every task now opens and closes a branch, so the clean-tree / current-main / green-baseline preconditions and the post-merge verify always apply.
2026-09-20 21:43:46 +00:00
tmu eeb831b717 🔀 Merge feature/fallback-remainder into main
CI / release-gate (push) Successful in 3s
CI / build (push) Successful in 24s
CI / publish (push) Skipped
CI / maintain (push) Failing after 15s
2026-09-19 00:02:23 +00:00
tmu ae7ffb6fa2 📝 Document the two-argument fallback
Rewrite development/library.md around the new shape: the fallback is a second
argument (so it can receive the remainder), `R` needs an inference site in the
handler map, `Exact` restores the excess-property check, and the overload order
is load-bearing. Record this branch's rejected alternatives (single-object `_`,
currying, `this`/HKT, variance/`const`/`NoInfer`/brands) and the redundant-
fallback known issue. Add the changelog note and check off the backlog task.
2026-09-18 23:52:47 +00:00
tmu 0d6a3f1b9b ✅ Cover the dispatch throw
An open universe (`getMatcher<string>()`) types its handler map as an index
signature, so a runtime map with fewer keys still satisfies the exhaustive
overload. Calling the matcher with a missing shape reaches the dispatch guard
and throws — no cast needed.
2026-09-18 23:48:59 +00:00
tmu 8615723c64 ✅ Cover fallback autocompletes
When a fallback argument is present, the second overload supplies the
contextual type, so the handler map popup offers optional keys (`a? b? c?`)
and handled keys stay optional (`b? c?`). The exhaustive one-argument popup
is unchanged. Covered for both factories.
2026-09-18 23:44:51 +00:00
tmu 935e82d205 ✅ Reject a mismatched strict fallback
The factory-contract test now also covers a fallback whose return does not
fit the common return of the handler map — a `number` handler with a
`string` fallback — which `getMatcherW` accepts as `number | string`.
2026-09-18 23:37:44 +00:00
tmu c7223dbe69 🐛 Infer strict return from the handler map
The fallback overload could not infer `R` from the handlers: `R` appeared
only inside the `Exact` constraint, which is not an inference site, so with
inferred handler params it collapsed to `unknown` and the matcher silently
returned `UnaryFn<T, unknown>`. Adding `Partial<Handlers<T, R>>` to the
handler parameter gives `R` an inference site, so the common return is
inferred from the handlers and the fallback alike.

Tests drop their explicit handler return annotations: real handlers are short
and unannotated, and the common type is now inferred from them.
2026-09-18 23:33:34 +00:00
tmu 2d3577716d ✨ Narrow the fallback to unhandled keys
The fallback is now the second factory argument — `(handlers, (s) => …)` —
so its parameter is `Exclude<T, keyof handlers>`. A property's contextual
type is fixed before TypeScript infers its sibling keys, so the remainder is
not expressible while the fallback sits in the handler map; a later argument
is contextually typed from inference on an earlier one.

Both factories take the new shape; the tests and autocomplete probes follow.
The generic overloads guard excess keys with type-fest's `Exact`, so the
error lands on the offending property. A redundant fallback (a full handler
map plus one) is still accepted — the guard for it does not survive inference
and is left to its backlog task.
2026-09-18 23:08:16 +00:00
tmu cc88c07961 🚀 Release 0.3.0
CI / release-gate (push) Successful in 2s
CI / build (push) Successful in 29s
CI / maintain (push) Failing after 15s
CI / publish (push) Failing after 18s
2026-09-18 07:51:16 +00:00
tmu 62c37e6d89 🔀 Merge chore/protocol-backed-lsp-helper into main
CI / release-gate (push) Successful in 3s
CI / build (push) Successful in 24s
CI / publish (push) Skipped
CI / maintain (push) Failing after 14s
2026-09-18 07:45:22 +00:00
tmu 947eac8089 ⏪ Revert the LSP notes in testing.md
The protocol cross-link and the TS-internal shutdown-race explanation were
too detailed for the category doc. The helper comment keeps a short,
self-contained note on the stdin-EOF workaround.
2026-09-18 07:43:36 +00:00
tmu eea2fbb17d 🚨 Silence helper oxlint warnings
The completion helper is test-only (excluded from dist/), so the
style-only no-magic-numbers and unicorn/no-null warnings are tolerable
here. A file-level disable keeps them out of check:oxlint without touching
the project config.
2026-09-18 07:38:13 +00:00
tmu b278a153d3 📝 Check off the LSP shutdown bug
Record the cause (Session.updateWatches raced against exit) and the
stdin-EOF fix now that npm run verify is green with no context-canceled
output.
2026-09-18 07:31:10 +00:00
tmu 8b73143740 🐛 Stop the LSP shutdown context-canceled log
The TS 7 Go server races Session.updateWatches against the exit
notification; handleExit returns io.EOF, the background context is
cancelled, and the bare error is flushed to stderr once the outgoing
queue closes (server.go / logger.go). Send shutdown and close stdin
instead: EOF makes the server exit cleanly (code 0, no output), with the
kill kept as a fallback. testing.md records the cause.
2026-09-18 07:31:04 +00:00
tmu 13497d1df8 📝 Check off the LSP helper task
npm run verify is green: 23 tests pass, check:tsc, check:oxlint, check:oxfmt
and check:cspell clean with the protocol-backed helper.
2026-09-18 07:17:19 +00:00
tmu 998c4bfd80 📝 Document the protocol-backed LSP helper
Note that the autocomplete helper drives the server through
vscode-languageserver-protocol, cross-link the tooling decision, and replace
the obsolete string-id known issue with the benign context-canceled stderr
the Go server emits on shutdown.
2026-09-18 07:16:45 +00:00
tmu 9f56d7ae8e ♻️ Back LSP helper with protocol library
Replace the hand-rolled JSON-RPC client (framing, pending map, reply
dispatch) with createMessageConnection and typed requests from
vscode-languageserver-protocol. A catch-all onRequest answers the server
requests the old client replied to, and CompletionList/CompletionItem
replace the ad-hoc shape guards. Public LspSession API is unchanged.
2026-09-18 07:16:25 +00:00
tmu 94ccc16963 ➕ Add the LSP protocol dependency
The autocomplete helper drives tsc --lsp --stdio; vscode-languageserver-protocol
supplies the transport (re-exported vscode-jsonrpc/node) and the typed LSP
requests, replacing the hand-rolled JSON-RPC client. Record the choice and
the rejected alternatives in tooling.md.
2026-09-18 07:14:45 +00:00
tmu 0b97632a7c 📝 Track protocol-backed LSP helper
The autocomplete helper still carries a hand-rolled JSON-RPC client
(Content-Length framing, a pending-request map, server-request replies).
vscode-languageserver-protocol provides all of it plus typed requests, so
track the move as a task with subtasks.
2026-09-18 07:14:18 +00:00
tmu 6f87c2f3b5 🔀 Merge chore/adopt-three-overload-matcher into main 2026-09-17 21:56:41 +00:00
tmu 88159296d4 📝 Drop the stale scripts test glob from testing.md
No tests live under scripts/, and every test script (test, test:unit, test:ci,
watch:test) globs only src/**/*.test.ts, so state that. The scripts/**
oxlint scope it was confused with still exists and carries the same
import/no-nodejs-modules exception as the test-helper scope.
2026-09-17 21:55:41 +00:00
tmu 800c11139d ♻️ Name the matcher factories getMatcher / getMatcherW
Drop the strict/widened aliases from src/index.ts so the factories are
exported under their own names, and align the type tests, the autocomplete
probe (which now imports the public surface from ./index.ts) and library.md
with them.

Also correct the widened-overload comment: the excess-property check does not
apply to a generic P, so MatcherWidening's keyof guard — not the closed
constraint — is what rejects keys outside T/_, as library.md already
documented.
2026-09-17 21:48:54 +00:00
tmu 05fad0fcd6 📝 Record the autocomplete and matcher-design decisions
The matcher is now two three-overload factories; library.md documents why
the union merge, inferred universe, conditional RequireKeys and cases-first
paths were rejected, and lists the two open issues (the fallback sees all of
T; a redundant _ is still accepted). testing.md records the language server
as the autocomplete oracle, and CONTRIBUTING points the exception at the
matcher's own test file.

The backlog marks the design-doc and autocomplete groundwork done alongside
the adoption.
2026-09-17 20:59:28 +00:00
tmu 58d127da4e ✅ Test the LSP completion helper against the server
The helper's own contract — marker handling, requested position, label
extraction and the rejection when the marker is absent — is asserted against
in-memory documents whose contextual types are written inline, so the helper
is tested without coupling to the library's code.
2026-09-17 20:59:19 +00:00
tmu 165bd9e3df ♻️ Adopt the three-overload matcher
strict/widened each take the exhaustive/fallback pattern shape in three
overloads ordered ExhaustiveLoose -> Fallback -> Handlers. TypeScript reads
the first overload for the object-literal popup (_?, a, b) and the last for
the missing-key error, so autocomplete and the error message are tuned
independently. The fallback is a pattern shape, not a factory, so the four
getPrimitiveUnionMatcher* factories collapse to two.

src/index.ts re-exports the two factories; the prototype scratch files that
explored the alternatives are removed.
2026-09-17 20:59:09 +00:00
tmu 02634f41de 🔀 Merge chore/test-util-home into main 2026-09-17 13:33:41 +00:00
tmu 9da5b5dbf2 📝 Note the test-helper home in the changelog 2026-09-17 13:23:36 +00:00
tmu dc57529f0f 📝 Correct the upstream claim in branch.sh
`main` tracks `origin` (ssh); the comment claiming it tracked an
https companion remote was stale.
2026-09-17 13:23:34 +00:00
tmu 484e5c8ca5 📝 Allow the floating-promise header in test files
Agents may add the fixed file-level typescript/no-floating-promises
oxlint-disable header at the top of a `*.test.ts` file — the
expectTypeOf() misidentification is a documented false positive
(development/testing.md § Known issues) and the await/void workarounds
collide with eslint/no-void. Every other suppression stays the human
last resort it was.
2026-09-17 13:23:32 +00:00
tmu c51e32d6a6 📝 Record the test-helper home decision
development/testing.md § Test helpers: why name beats path under
import/no-relative-parent-imports, why the key needs `#`, why the
`__tests__` folder name, and what was rejected (flat placement, tsconfig
paths, rule exemptions).
2026-09-17 13:23:29 +00:00
tmu 9e5522c131 🚚 Home test helpers behind #test-utils/*
src/util/__tests__/ holds shared test helpers, reached from anywhere
in the source tree as `#test-utils/<name>.ts` through
package.json#imports. A bare `~/…` is not a valid imports key — Node
requires `#` — and a direction-free specifier keeps
import/no-relative-parent-imports from ever firing. The `__tests__`
folder name is the pattern tsconfig.build.json already excludes, so
helpers can never ship. Lint scoping: import/no-nodejs-modules off for
the folder (the probe spawns a language server) and
typescript/promise-function-async off to match its promise-returning
style. The LSP probe moves in from scripts/; a smoke test pins the
specifier's resolution and typing without starting a server.
2026-09-17 13:23:27 +00:00
tmu ee146ce350 🚀 Release 0.2.0
CI / release-gate (push) Successful in 3s
CI / build (push) Successful in 24s
CI / maintain (push) Failing after 15s
CI / publish (push) Failing after 17s
2026-09-16 22:07:16 +00:00
tmu 33c6d9dc8d 🔀 Merge chore/remove-example-code into main 2026-09-16 22:06:17 +00:00
tmu 65e885989b 📝 Backlog the README walkthrough restoration
The example-code removal stripped the synopsis and examples with the
template modules; track bringing them back around the real API, with the
previous section order recorded so its placement is not re-guessed.
2026-09-16 22:05:05 +00:00
tmu 16092350b8 📝 Check off example-code removal in backlog
Note the finished work under [Unreleased] and mark the task and its
subtasks done; retarget the v1.0 coverage tasks at the surviving
primitive module.
2026-09-16 21:58:14 +00:00
tmu a3eb6183af 🔥 Remove the match and pattern example modules
They were the template's demo API, not the library's real surface. Drop
them, their README walkthrough, and the tooling note that cited their
disable directives, and point index.ts at the primitive matchers that
remain.
2026-09-16 21:57:55 +00:00
tmu ce3d618757 🔥 Remove the example index spec
The spec only exercised the template's match/P example API. Removing it
first lets the modules and exports it imports go next without leaving a
dangling reference.
2026-09-16 21:57:42 +00:00
tmu 73e5bc0093 🔀 Merge chore/straighten-oxc-rules into main
CI / release-gate (push) Successful in 3s
CI / build (push) Successful in 23s
CI / publish (push) Skipped
CI / maintain (push) Failing after 14s
2026-09-16 21:50:14 +00:00
tmu 08513b36a9 📝 Check off straighten-oxc-rules in backlog
Note the finished work under [Unreleased] and mark the task and its
subtasks done.
2026-09-16 21:46:04 +00:00
tmu 068d6b4998 ♻️ Use line comments in primitive matchers
Replace the three `/* */` prose blocks with `//` line comments, matching
the rest of the codebase and the comment style the oxlint config now
allows.
2026-09-16 21:46:00 +00:00
tmu fa0b7f2e84 🔧 Allow ternaries and lowercase comments
Turn off `eslint/no-ternary` and `eslint/capitalized-comments` in the
project config: both encode a style the project rejects, so they belong in
the config's rule map rather than a per-site disable.

Scope the adjacent `oxlint-disable` decision to false positives and
record why a rejected stylistic rule is turned off project-wide.
2026-09-16 21:45:54 +00:00
tmu 71ecd508b7 🔀 Merge feature/primitve-pattern-matching into main 2026-09-16 21:40:57 +00:00
tmu c13bc2f408 📝 Document why any is retained in primitive matchers
Rewriting without any was evaluated; the alternatives were more
complex than the current solution. Record the rationale next to the
two oxlint-disable sites so the suppression is not re-litigated.
2026-09-16 21:39:46 +00:00
tmu 8e2691e6aa 📝 Will add backlog items found while working 2026-09-16 20:43:01 +00:00
tmu 16650b07e3 📝 Backlog primitive literal coverage questions 2026-09-16 20:01:20 +00:00
tmu a0f0894339 ✅ Spec mixed string and numeric union keys 2026-09-16 20:01:12 +00:00
tmu ed71929365 ✅ Check matcher handler params
The primitive union matchers invoke every handler with the matched
literal, but the specs wrote their handlers with zero parameters, so
the passed value and its inferred key-literal type went untested.

Declare the parameter on each such handler and pin it with
expectTypeOf, matching the style the two already-correct handlers used.
The `_` fallbacks assert the whole union rather than a single literal.
Return-type expectations are unchanged, so the W / non-W widening
distinctions are still covered.
2026-09-16 19:29:04 +00:00
tmu cce030b8e5 🐛 Type partial W matcher without an any cast
getPrimitiveUnionMatcherPartialW was declared with the intended
P-inferring signature but assigned via `as any`, hiding that the
declared parameter was not assignable to the implementation's. The
union in PatternPrimitiveUnionPartial makes the `_` arm demand
`_ ∈ keyof P`, which the exhaustive arm cannot prove.

Intersect the inference hook Simplify<P> with the implementation's
parameter shape, R pinned to PatternReturns<P>, so the assignment
type-checks while callers keep inferring P from the argument.

Add a spec for the exhaustive (no `_`) pattern.
2026-09-16 19:10:40 +00:00
tmu 2ff67801e0 ✨ Add VS Code node:test debugging and runner config
launch.json: 'Debug current test file' F5 config running
node --test --strip-types on the active file.

settings.json: register .ts with the connor4312.nodejs-testing
extension via nodejs-testing.extensions, using --strip-types (the
repo pins node >=26 with native type stripping) instead of tsx, so
the extension discovers src/*.test.ts and shows Run/Debug lenses
above test() calls in the native Testing UI.

extensions.json: recommend connor4312.nodejs-testing; cspell.json:
allow 'connor' for the extension id.

.gitignore: un-ignore .vscode/launch.json so the config is shared.
2026-09-16 17:34:53 +00:00
tmu 62a5599ab3 📝 Require labeled AAA blocks in tests
Rule in CONTRIBUTING.md; decision, why and rejected unlabeled
ordering in development/testing.md.
2026-09-16 16:00:43 +00:00
tmu 34567856a5 ♻️ Label arrange-act-assert blocks in specs
Rework every test body into // Arrange / // Act / // Assert blocks
separated by blank lines: the factory is arranged once, the matcher is
built from it in a single act, and all type/runtime checks sink to the
end. index.test.ts adopts the same shape.

Also migrate off expect-type's deprecated toMatchTypeOf: the checks
are assignability tests, so toExtend is the faithful replacement.
2026-09-16 16:00:41 +00:00
tmu a25ac6d1e2 ✅ Spec primitive union matchers
Type-driven tests: every expectTypeOf pairs an assert, covering the
header matrix (exhaustive vs partial patterns, strict R vs widened
returns, string and numeric keys, _ fallback wiring).

Negative cases use Parameters<typeof factory>[0] assignability because
expect-type's .not.toBeCallableWith collapses to never on these generic
Simplify<>-wrapped signatures.

Same documented expectTypeOf floating-promise false positive as
index.test.ts, so the same file-level disable applies; testing.md
known-issue updated to list both files.
2026-09-16 14:52:30 +00:00
tmu 40bf652b97 ✨ Add primitive union pattern matchers
Curried matchers over string|number literal unions in four variants
(exhaustive/partial x strict/widened return inference), per the header
matrix in src/primitive.ts.

- Adds type-fest for Simplify/ValueOf.
- Deliberate oxlint escape hatches (as any dispatch) until a cast-free
  formulation lands; see the file comments.
2026-09-16 14:52:21 +00:00
tmu 1e13917cbe 🚀 Release 0.1.8
CI / release-gate (push) Successful in 2s
CI / build (push) Successful in 25s
CI / maintain (push) Successful in 15s
CI / publish (push) Failing after 15s
2026-09-16 09:01:49 +00:00
tmu 42281a0f10 🔀 Merge chore/upgrade-deps into main 2026-09-16 08:56:32 +00:00
tmu c15bc0153e 📝 Require concise prose in development/
Actionable sentence in CONTRIBUTING.md § Rules the tools don't enforce;
the why — humans skim, agents imitate the dominant style, so verbosity
compounds — in development/README.md § Decision blocks. Both sides in
one commit per the write-each-fact-once rule.
2026-09-16 08:00:36 +00:00
tmu ae319f6547 🔧 Ignore @types/node in maintain:outdated
DefinitelyTyped keeps the @types/node latest dist-tag on the LTS line,
so check-outdated compared the current-line types against a lower
version: a permanent "reverted" flag, scan exit 1, zero signal. Ignored
by package name; --types filtering and a version-scoped pin rejected
(rationale in development/tooling.md).
2026-09-16 07:55:25 +00:00
tmu 63e8edce79 ⬆️ Upgrade dependencies
@types/node to ^26.6.1 and cspell to ^10.3.2 — the only packages
check-outdated found behind the registry, both bumps within the existing
semver ranges. cspell 10.3.2 drops its transitive
fast-json-stable-stringify. npm run verify is green with no rule or
format fallout. maintain:outdated still flags @types/node as "reverted"
because DefinitelyTyped's latest dist-tag stays on the 22.x LTS series;
known false positive, advisory scan only.
2026-09-16 07:31:49 +00:00
tmu b456a430a7 🚀 Release 0.1.7
CI / release-gate (push) Successful in 2s
CI / build (push) Successful in 22s
CI / maintain (push) Failing after 14s
CI / publish (push) Failing after 15s
2026-09-16 07:23:24 +00:00
tmu 36d8c4feae 🔀 Merge chore/resolve-finish-push-tension into main 2026-09-16 07:21:55 +00:00
tmu d646fd1ec4 ♻️ Let create:branch start from an ahead main
`create:finish` leaves the merge local until `create:release` pushes it, so
`main` is routinely ahead of its upstream between a merge and a release. The
branch front door demanded an exact match and refused until it was pushed,
forcing a manual `git push` that the model deliberately keeps out of it.

Reject only a `main` that is behind its upstream — matching `create:finish`,
which already tolerates being ahead — and record why pushing from `finish` was
rejected instead. The push stays with `create:release`, so the merge remains
reviewable locally, and the known issue about the deadlock is gone.

Resolves: backlog task "Resolve the finish/push tension".
2026-09-15 22:14:51 +00:00
tmu 177d63fa95 🔀 Merge chore/prune-backlog-and-changelog-rule into main
CI / release-gate (push) Successful in 2s
CI / build (push) Successful in 20s
CI / publish (push) Skipped
CI / maintain (push) Failing after 14s
2026-09-15 22:09:21 +00:00
tmu f6f820a648 📝 Require a changelog note before finishing a branch
A merged branch must carry a final commit adding a short summary of the
work under [Unreleased] in CHANGELOG.md. create:release derives the bump
heuristic from that body, so notes have to exist before release day;
rationale in development/workflow.md.
2026-09-15 22:08:08 +00:00
tmu c5cc903221 📝 Prune the backlog of closed tasks
All completed and cancelled entries are implemented and reflected in the
docs, CI and changelog; only still-open tasks remain.
2026-09-15 21:58:44 +00:00
tmu cf73857931 🚀 Release 0.1.6
CI / release-gate (push) Successful in 3s
CI / build (push) Successful in 23s
CI / maintain (push) Failing after 14s
CI / publish (push) Failing after 15s
2026-09-15 21:53:19 +00:00
tmu 9f038a230d 🔀 Merge chore/docs-restructure into main
CI / release-gate (push) Successful in 2s
CI / build (push) Successful in 20s
CI / publish (push) Skipped
CI / maintain (push) Failing after 16s
2026-09-15 21:51:33 +00:00
tmu 1465926783 📝 Check off the docs restructure and note it in the changelog 2026-09-15 21:51:21 +00:00
tmu 5e7d40b013 📝 Improve docs after split 2026-09-15 21:46:41 +00:00
tmu 74c39e1346 ♻️ Tighten the development/ prose
Same decisions, rationale, rejected alternatives and known issues, said with
less padding: ~5,530 -> ~4,730 words (-15%). Every fact from the first draft is
kept; only the wording, duplicated lead-ins and restated context are cut.
2026-09-15 13:56:49 +00:00
tmu 7ea84b66fa ♻️ Drop the last duplicated prose from development/
The GitHub Flow description and the type-first enforcement sentence still
appeared in both CONTRIBUTING.md and development/. development/ now carries only
the context and rationale that is not in CONTRIBUTING.md.
2026-09-15 13:47:01 +00:00
tmu 95d73d11b6 ♻️ Single-home the actionable rules and the rationale
development/ had restated the actionables that CONTRIBUTING.md owns: the
branching step list, the script prefix list, the feedback-tier rule of thumb,
the commit convention and the type-driven test loop. Those now live only in
CONTRIBUTING.md; development/ keeps the decision blocks and links to the rule.
development/README.md, CONTRIBUTING.md and AGENTS.md state the 'write each fact
once' principle explicitly.
2026-09-15 13:46:31 +00:00
tmu fe02317fc8 📝 Track code-fence testing and the finish/push tension
Adds a Documentation task to validate Markdown code fences against src/, and a
Workflow task for the create:finish / create:branch upstream mismatch recorded
in development/workflow.md.
2026-09-15 13:40:04 +00:00
tmu 84d48c6e67 📝 Make the rule/rationale split explicit
AGENTS.md and CONTRIBUTING.md now state that a decision, its rejected
alternatives and its known issues belong in development/<category>.md, that the
actionable rule stays in CONTRIBUTING.md, and that both change in the same
commit. development/README.md names library.md in the category list and spells
out that the sync runs in both directions.
2026-09-15 13:39:58 +00:00
tmu c7372732ba ♻️ Move script-header rationale into development/
branch.sh, finish.sh, release.sh, runner-image.sh and release-notes.sh
carried multi-paragraph 'how we got here / rejected' essays in comments. They
now live in the matching development/ category file, and each script keeps a
one-line pointer so the rationale is single-homed and cannot drift.
2026-09-15 13:27:21 +00:00
tmu 9faa0ecc55 📝 Rewrite README as the user entry point
Follows the Perl/CPAN order: name + one-line description, Synopsis, Description,
Examples, API reference, License. Tooling, CI and decision content moved to
development/; the README keeps only user-facing material and a pointer to
CONTRIBUTING.md and AGENTS.md. No version line and no package.json link.
2026-09-15 13:27:15 +00:00
tmu 73669ec5b6 📝 Rewrite CONTRIBUTING as the contributor entry point
Adds Setup, Development commands, Code style and formatting (with the VSCode
extension hint) and Submitting changes. The decision prose moves to
development/; this file keeps the actionable rules (feedback-tier table, script
prefix list, standards) and links to the category files for the why.
2026-09-15 13:27:05 +00:00
tmu dc7ce22fc5 📝 Add development/ docs for decisions and known issues
Category files under development/ replace the decision prose that was
scattered through README.md and CONTRIBUTING.md. Each decision is a block with
Decision (YYYY-MM) / Why / Rejected / Known issue; the new library.md records
the public API contract and its limitations. AGENTS.md and backlog.tasks now
point at the new home.
2026-09-15 13:26:59 +00:00
tmu 97dfe9e7b4 📝 Expand the docs-cleanup task into concrete subtasks 2026-09-15 13:21:58 +00:00
34 changed files with 2651 additions and 617 deletions

No files matched your search

+1
View File
@@ -10,5 +10,6 @@ coverage
!.vscode/extensions.json
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
.idea
.DS_Store
+10 -1
View File
@@ -13,6 +13,8 @@
"eslint/no-undefined": "off",
"eslint/sort-keys": "off",
"eslint/id-length": "off",
"eslint/capitalized-comments": "off",
"eslint/no-ternary": "off",
"import/no-named-export": "off",
"eslint/one-var": "off",
"import/group-exports": "off",
@@ -20,7 +22,8 @@
"eslint/sort-imports": "off",
"import/consistent-type-specifier-style": "off",
"unicorn/prefer-export-from": "off",
"typescript/method-signature-style": "off"
"typescript/method-signature-style": "off",
"typescript/promise-function-async": "off"
},
"options": { "typeAware": true },
"env": { "builtin": true, "es2024": true, "node": true },
@@ -39,6 +42,12 @@
"rules": {
"import/no-nodejs-modules": "off"
}
},
{
"files": ["src/util/__tests__/**"],
"rules": {
"import/no-nodejs-modules": "off"
}
}
],
"ignorePatterns": ["dist", "node_modules", "coverage"]
+1
View File
@@ -1,5 +1,6 @@
{
"recommendations": [
"connor4312.nodejs-testing",
"oxc.oxc-vscode",
"streetsidesoftware.code-spell-checker",
"typescriptteam.native-preview",
+15
View File
@@ -0,0 +1,15 @@
{
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Debug current test file",
"runtimeExecutable": "node",
"runtimeArgs": ["--test", "--strip-types"],
"args": ["${file}"],
"cwd": "${workspaceFolder}",
"console": "integratedTerminal"
}
]
}
+10
View File
@@ -1,4 +1,14 @@
{
"nodejs-testing.extensions": [
{
"extensions": ["mjs", "cjs", "js"],
"parameters": []
},
{
"extensions": ["ts"],
"parameters": ["--strip-types"]
}
],
"[typescript]": {
"editor.defaultFormatter": "oxc.oxc-vscode",
"editor.formatOnSave": true
+9 -7
View File
@@ -12,6 +12,7 @@ first-action facts. Do not restate evolving prose here — it will drift.
- **Optional code intelligence:** this repo installs `@spences10/pi-lsp` (pinned in `.pi/settings.json`) as a project-local pi extension. It talks to the repo's own TypeScript 7 via `tsc --lsp --stdio` and exposes **read-only** tools — `lsp_hover`, `lsp_definition`, `lsp_references`, `lsp_find_symbol`, `lsp_document_symbols`, `lsp_diagnostics(_many)`. Prefer `lsp_references` over `grep -w` for widely-colliding identifiers (`matches`, `type`, …); use `lsp_hover` to read inferred types on generic-heavy code. It has no rename / code-action / apply-edit surface — the write side is pi's `edit` tool + `check:tsc`. Treat empty LSP output as _inconclusive_, not success: **`npm run test` / `npm run verify` remain the sole authoritative gate** (see the next bullet). The server keeps running across that gate with a ~5 min idle timeout and registers no file watchers, so if you change `tsconfig.json` / `package.json` mid-session its diagnostics can be stale — when LSP output disagrees with `check:tsc`, trust `check:tsc` and restart pi (or wait out the idle timeout) before concluding the LSP is wrong.
- **Definition of done — run this before you call the work finished:** `npm run verify`. If all green, commit. If red, look at the output, fix the root cause, and re-run.
- **On commit:** write a good message (see [CONTRIBUTING.md § Commit messages](./CONTRIBUTING.md#commit-messages)). Lefthook's pre-commit hook already runs the fast, offline, staged-file checks — don't run them by hand. If the hook fails on style, `npm run fix`, restage, recommit.
- **Document decisions where the next maintainer will look:** rationale, rejected alternatives and known issues go in `development/<category>.md` (see [development/README.md](./development/README.md)); the actionable rule stays in [CONTRIBUTING.md](./CONTRIBUTING.md) and links to it. Write each fact once — never copy the rule into `development/` or the reason into `CONTRIBUTING.md` — and change both in the same commit when a rule changes.
- **`npm run maintain` is NOT part of the feature loop.** Its scans are advisory, never a gate; run them only on an explicit maintenance / update-deps branch.
```sh
@@ -24,10 +25,10 @@ first-action facts. Do not restate evolving prose here — it will drift.
Don't silence the type system to force a green run. As an agent these are forbidden:
- `// @ts-nocheck`, `// @ts-ignore`, `// @ts-expect-error`
- `// oxlint-disable` / `// oxlint-disable-next-line`
- `// oxlint-disable` / `// oxlint-disable-next-line` — the sole exception is the fixed file-level `typescript/no-floating-promises` header at the top of a `*.test.ts` file, spelled exactly as [CONTRIBUTING.md § Rules the tools don't enforce](./CONTRIBUTING.md#rules-the-tools-dont-enforce) prescribes
- `as` casts used to push an expression through (type-aware oxlint already flags unsafe assertions)
Fix the root cause with the type system instead — narrowing, generics, `satisfies`, conditional / mapped types, utility types (`NonNullable`, `Exclude`, …). TypeScript can express it; that's the intended tool. The `oxlint-disable`-location rule in [CONTRIBUTING.md § Rules the tools don't enforce](./CONTRIBUTING.md#rules-the-tools-dont-enforce) is a **human** last-resort convention (so a reviewer can spot a deliberate suppression) — it is not permission for you to add one. If the types genuinely cannot express something, stop and surface the conflict (commit message / handover) rather than suppress it.
Fix the root cause with the type system instead — narrowing, generics, `satisfies`, conditional / mapped types, utility types (`NonNullable`, `Exclude`, …). TypeScript can express it; that's the intended tool. The `oxlint-disable`-location rule in [CONTRIBUTING.md § Rules the tools don't enforce](./CONTRIBUTING.md#rules-the-tools-dont-enforce) is a **human** last-resort convention (so a reviewer can spot a deliberate suppression) — it is not permission for you to add one, except the one fixed `*.test.ts` file-level header named there. If the types genuinely cannot express something, stop and surface the conflict (commit message / handover) rather than suppress it.
The same applies to the checks themselves: **never `git commit --no-verify`** (or otherwise skip a pre-commit / pre-push hook). The checks are fast and offline, so a redundant run is fine — bypassing a hook to get green is the identical anti-pattern. If a commit already skipped a hook, redo it through one: `git reset --soft HEAD~1 && git commit -C <skipped-sha>`.
@@ -41,7 +42,9 @@ Never start a long-lived / blocking process such as `npm run watch`. It runs unt
### Working on tasks
- **Task with subtasks** (a task that has indented children): create the branch with `npm run create:branch -- <prefix>/<desc>`, inferring the prefix from the task content (`feature/…` / `fix/…` / `chore/…`) — do not hand-write `git switch -c`, the command enforces the clean-tree / current-`main` / green-baseline precondition. Work on each subtask with commits, then present a concise handover for the user to review. Use this fixed shape:
Every task — with or without subtasks — goes through the complete branching model:
- Create the branch with `npm run create:branch -- <prefix>/<desc>`, inferring the prefix from the task content (`feature/…` / `fix/…` / `chore/…`) — do not hand-write `git switch -c`, the command enforces the clean-tree / current-`main` / green-baseline precondition. Work with commits (each subtask gets one or more), then present a concise handover for the user to review. Use this fixed shape:
```md
## Handover — <branch>
@@ -53,9 +56,7 @@ Never start a long-lived / blocking process such as `npm run watch`. It runs unt
Once the user has no further objections, merge back: `npm run create:finish` (on the branch — it merges `--no-ff`, runs `npm run verify`, and deletes the branch). The branching model is documented in [CONTRIBUTING.md § Branching model](./CONTRIBUTING.md#branching-model).
- **Leaf task** (no indented children): implement on the current branch and commit.
In both cases, follow [CONTRIBUTING.md § Testing discipline (type-driven)](./CONTRIBUTING.md#testing-discipline-type-driven). Each subtask gets one or more commits.
Follow [CONTRIBUTING.md § Testing discipline (type-driven)](./CONTRIBUTING.md#testing-discipline-type-driven) throughout.
## Read these
@@ -64,5 +65,6 @@ In both cases, follow [CONTRIBUTING.md § Testing discipline (type-driven)](./CO
- [CONTRIBUTING.md § Script prefix convention](./CONTRIBUTING.md#script-prefix-convention) — adding an `npm run` script? reuse an existing prefix or it doesn't belong.
- [CONTRIBUTING.md § Commit messages](./CONTRIBUTING.md#commit-messages) — gitmoji + imperative + 50/72.
- [CONTRIBUTING.md § Feedback tiers](./CONTRIBUTING.md#feedback-tiers) — what runs when and at what cost (`watch` / pre-commit / pre-push / `check` / `verify` / `fix` / `maintain` / CI).
- [README.md § Tooling decisions](./README.md#tooling-decisions) — the rationale behind each tool choice; read before changing tooling.
- [development/](./development/README.md) — the decisions, rejected alternatives and known issues behind the rules; the “why” that CONTRIBUTING.md links to. Read the relevant file before changing an area.
- [development/tooling.md](./development/tooling.md) — the rationale behind each tool choice; read before changing tooling.
- [package.json `#scripts`](./package.json) — the source of truth for every command (the `LEFTHOOK_FILES` convention scopes them to staged files vs. the whole project).
+38 -1
View File
@@ -7,6 +7,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.4.0] - 2026-09-20
- move the `_` fallback out of handlers
## [0.3.0] - 2026-09-18
- condensed primitive matcher factories down to 2 from formerly 4
- house shared test helpers under `src/util/__tests__/`
## [0.2.0] - 2026-09-16
- allow ternaries and lowercase comments in oxlint
- switch `src/primitive.ts` prose from block comments to line comments
- remove the template's `match`/`P` example modules and their documentation, and point `src/index.ts` at the primitive matchers
## [0.1.8] - 2026-09-16
- upgrade dependencies
- ignore `@types/node` in `maintain:outdated` (misleading `latest` dist-tag)
- require extremely concise prose in `development/`
## [0.1.7] - 2026-09-16
- require a short summary under `[Unreleased]` in the changelog before a branch is finished
- let `create:branch` start from a `main` that is ahead of its upstream, so a finished merge no longer blocks the next branch until it is pushed
## [0.1.6] - 2026-09-15
- restructure the documentation: README.md for users, CONTRIBUTING.md for contributors, and development/ for the decisions, rejected alternatives and known issues
- document the decisions and known issues for CI, tooling, testing, publishing and the workflow
## [0.1.5] - 2026-09-15
- improve CI configuration
@@ -32,7 +63,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- basic setup
[Unreleased]: https://gitea.e1nsnull.de/tmu/tiny-pattern-ts/compare/0.1.5...main
[Unreleased]: https://gitea.e1nsnull.de/tmu/tiny-pattern-ts/compare/0.4.0...main
[0.4.0]: https://gitea.e1nsnull.de/tmu/tiny-pattern-ts/compare/0.3.0...0.4.0
[0.3.0]: https://gitea.e1nsnull.de/tmu/tiny-pattern-ts/compare/0.2.0...0.3.0
[0.2.0]: https://gitea.e1nsnull.de/tmu/tiny-pattern-ts/compare/0.1.8...0.2.0
[0.1.8]: https://gitea.e1nsnull.de/tmu/tiny-pattern-ts/compare/0.1.7...0.1.8
[0.1.7]: https://gitea.e1nsnull.de/tmu/tiny-pattern-ts/compare/0.1.6...0.1.7
[0.1.6]: https://gitea.e1nsnull.de/tmu/tiny-pattern-ts/compare/0.1.5...0.1.6
[0.1.5]: https://gitea.e1nsnull.de/tmu/tiny-pattern-ts/compare/0.1.4...0.1.5
[0.1.4]: https://gitea.e1nsnull.de/tmu/tiny-pattern-ts/compare/0.1.3...0.1.4
[0.1.3]: https://gitea.e1nsnull.de/tmu/tiny-pattern-ts/compare/0.1.2...0.1.3
+201 -78
View File
@@ -1,49 +1,36 @@
# Contributing
This document is for maintainers and contributors working on the project itself. End-user documentation is in [README.md](./README.md). The machine entry point for AI coding agents is [AGENTS.md](./AGENTS.md); keep this file as the prose home for the rules below so agents and humans don't diverge.
This document is for maintainers and contributors working on the project
itself. End-user documentation is in [README.md](./README.md). The reasons
behind the rules here — the decisions, rejected alternatives, and known issues —
live in [development/](./development/README.md). The machine entry point for AI
coding agents is [AGENTS.md](./AGENTS.md); keep this file as the prose home for
the rules so agents and humans don't diverge.
## Rules the tools don't enforce
## Setup
CI and review will bounce these even though `npm run check` and the linters don't catch them. They're the high-frequency things a contributor (or an agent) reaches for by default:
1. Clone the repository.
2. Install Node.js >= 26 — see [.node-version](./.node-version); the exact pinned
version is what CI and the runner image use.
3. `npm ci`.
4. `npm run setup` — the one-time clone configuration (currently registers the
commit-message template).
- **Source imports use `.ts` extensions, never `.js`.** `node --strip-types` only resolves the `.ts` form at test time; "Pre-fixing" an import to `.js` breaks the inner loop. (rationale: README § Tooling decisions)
- **A new `npm run` script must reuse an existing prefix** (`create:` / `check:` / `fix:` / `test:` / `watch:` / `maintain:` / `publish:` / `setup:`). If none fits, that's a signal the script doesn't belong in the pipeline — not a reason to invent a new prefix. If it genuinely does belong, add the prefix to both lists here in the same commit; an undocumented prefix becomes invisible and quietly accrues members. (see [Script prefix convention](#script-prefix-convention))
- **`oxlint-disable` directives live in source, not `.oxlintrc.json`.** The trade-off must sit next to the code it silences. This is a _human_ last-resort convention; agents must not add these — see [AGENTS.md § Never do](./AGENTS.md#never-do). (rationale: README § Tooling decisions)
- **Don't put slow / network / whole-project scans in `check` or pre-commit.** Advisory scans are not correctness gates; they belong under `maintain:`. (see [Feedback tiers](#feedback-tiers) and [Script prefix convention](#script-prefix-convention))
- **New work starts with `npm run create:branch`, never a hand-written `git switch -c`/`git checkout -b`.** The command carries the branch precondition; branching around it skips the clean-tree, current-`main` and green-baseline checks, and the skip is invisible until a failure can no longer be attributed. (see [Branching model](#branching-model))
- **Work is merged back with `npm run create:finish`, never a hand-written `git merge`.** The command carries the merge-side preconditions (clean tree, current `main`, a `feature/`/`fix/`/`chore/` branch) and runs `npm run verify` after the merge, so a merge cannot land unverified. (see [Branching model](#branching-model))
- **There is no local `npm run publish`, and `publish:publint` / `publish:attw` don't go in `check`.** (see [Publishing workflow](#publishing-workflow))
## Development commands
## Editor configuration
`.editorconfig` is for editor compatibility, not a gate — it is a sane fallback for the files oxfmt does not format (shell scripts, dotfiles, `LICENSE`, the commit-message template, and git's `COMMIT_EDITMSG` buffer). Where both apply, `.oxfmtrc.json` is authoritative: oxfmt is the formatter, and the overlapping `.editorconfig` keys only keep non-oxfmt editors close to the formatted result.
## Commit messages
Gitmoji subject, imperative mood, 50/72 wrapping. The template is `commit-message-template`; run `npm run setup:git-commit-message` once after cloning to register it as git's `commit.template` (or `npm run setup` to run every one-time clone step).
Examples from history: `:sparkles: Add watch tier with watch:test child`, `:recycle: Move type-aware config to .oxlintrc.json; use source-level disable directives`, `:memo: Restore unique maintainer content as CONTRIBUTING.md`. The body explains _what and why_, not _how_; link issues with `Resolves #...`.
## Script prefix convention
Script names in `package.json` use a prefix that signals _when_ the script is intended to run. A `<prefix>:<name>` script is implicitly aggregated by a `<prefix>` script (if one exists) and run by the corresponding lefthook hook or CI step. Picking the right prefix documents the script's intended lifecycle:
- `create:*` — front doors of the repo's own workflow; these mutate git state rather than the source. `create:branch` opens a unit of work (asserts a clean tree, a current `main` and a green baseline before it branches), `create:finish` closes the branch half (merges the current unit of work into `main` and verifies the result), `create:release` closes the release half (maintainer-only). No bare `create` aggregator on purpose — see `publish:*` for the precedent.
- `check:*` — read-only verification; never modifies files. Aggregated by `npm run check`.
- `fix:*` — mutating counterpart of a `check:*` script. Aggregated by `npm run fix`; the diff is the review surface.
- `test:*` — test scripts. `test` is the canonical entry point (`check:tsc` + unit tests); `test:unit` skips the typecheck for fast local iteration; `test:ci` adds c8 coverage.
- `watch:*` — long-running watchers for the manual inner dev loop. Aggregated by `watch`; currently a single child (`watch:test`), and a future `watch:oxlint` / `watch:tsc` would run concurrently under that umbrella.
- `maintain:*` — advisory repo-maintenance scans: read-only, but whole-project and/or network-bound, so never a correctness gate. Aggregated by `npm run maintain`.
- `publish:*` — validates the _publishable artifact_ (e.g. `dist/`) rather than the source, so it needs a fresh build. (see [Rules the tools don't enforce](#rules-the-tools-dont-enforce) and [Publishing workflow](#publishing-workflow))
- `setup:*` — one-time configuration of a fresh clone; mutates the local environment (git config, editor settings) rather than the repo source, so it is never part of a hook or CI step. Aggregated by `npm run setup` (the umbrella), run once after cloning.
A new script should pick the prefix that matches its lifecycle, not invent a new one. If no existing prefix fits, that's a signal the script doesn't belong in the standard pipeline. When it genuinely does belong, a new prefix is allowed — but it enters both lists in this file in the same commit as its first member, otherwise the rule "reuse an existing prefix" silently develops an exception (the old `use:` prefix was exactly that; it is now retired into `setup:`).
Separately, some top-level scripts are **bare** (no prefix): the entry points that either run a single tool (`build`, `clean`) or aggregate a `prefix:*` family (`check`, `fix`, `test`, `watch`, `maintain`, `setup`), plus one convenience that composes across tiers: `verify` — composing `check` + `test:unit` into one whole-project correctness gate (it deliberately uses `test:unit` rather than `test` because `check` already runs `check:tsc`, so the type checker runs exactly once). Bare commands are how you invoke a tier; the `prefix:*` scripts are what those tiers are made of.
- **Build:** `npm run build`
- **Test:** `npm run test`, `npm run test:ci`
- **Watch:** `npm run watch` - re-runs tests on file save, humans only
- **Checks:** `npm run check`, `npm run fix`
- **Verify:** `npm run verify` — the definition of done
- **Maintenance:** `npm run maintain` — advisory only
- **Individual fixes:** `npm run fix:oxfmt`, `npm run fix:oxlint`
## Feedback tiers
The tools are organized into a feedback ladder. Each tier catches different things at different costs; the rule of thumb is "earlier tiers fire more often, faster tiers catch less, slower tiers are more thorough":
The tools are organized into a feedback ladder. Each tier catches different
things at different costs; the rule of thumb is "earlier tiers fire more often,
faster tiers catch less, slower tiers are more thorough":
| Tier | When | What it runs | Time |
| -------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- |
@@ -58,62 +45,198 @@ The tools are organized into a feedback ladder. Each tier catches different thin
| CI maintain (auto, non-blocking) | on push to `main` | `npm run maintain` — reports, never fails the build | ~10s |
| CI publish (auto) | on tag | packaging checks + `publish:publint` / `publish:attw`, then the Gitea release page and `npm publish` (skipped, and the job failed, without `NPM_TOKEN`) | ~15s |
### Why these splits?
- **`watch:*` is a manual tier, not a hook.** The developer starts it on demand (it has to be killed with Ctrl-C) and it runs in a dedicated terminal pane. It sits as the earliest tier in the feedback ladder, catching failures the moment a file is saved — before staging, before commit.
- **`check:tsc`, `check:oxlint`, `check:oxfmt`, `check:cspell`** are in pre-commit because they are fast (~0.2–0.5s each), fully offline, and naturally scope to staged files via the `LEFTHOOK_FILES` env var convention. They give instant feedback on what you typed.
- **`test` (and the `tsc` it includes) is in pre-push** because it runs the whole test suite across the whole project. The pre-commit `LEFTHOOK_FILES` convention doesn't apply to the test runner, so pre-commit isn't the right home. Pre-push runs after all commits are made but before the push leaves the machine, catching regressions that span multiple commits.
### Before pushing
Run `npm run verify` — the one-shot correctness gate in the table above. Run `npm run maintain` only on a maintenance / update-deps branch.
Before pushing, run `npm run verify` — the one-shot correctness gate. Run
`npm run maintain` only on a maintenance / update-deps branch. Why the splits
are where they are: [development/workflow.md § Feedback tiers](./development/workflow.md#feedback-tiers).
## Testing discipline (type-driven)
For this library the types _are_ the feature — narrowing, `exhaustive()` returns, the `Matcher<T>` contract — so a runtime-only test loop would verify the wrong thing. New behavior follows **type-driven development** (in Edwin Brady's sense): _treat the type as the plan for a program, and use the compiler and type checker as your assistant, guiding you to a complete program that satisfies the type_ ([idris-lang.org](https://www.idris-lang.org/)). Here that plan is the `expectTypeOf` assertion, written first. The loop is **type → red → green → refactor**:
For this library the types _are_ the feature, so development is **type-driven**:
the compile-time expectation is written before the runtime assertion, and both
before the implementation. The loop is **type → red → green → refactor**:
1. **Type** — write the compile-time expectation first (`expectTypeOf(...).toEqualTypeOf<…>()`) and let `npm run check:tsc` fail on the _type_. The type error is the spec you want to hit before the runtime logic exists.
2. **Red** — add the matching runtime assertion (`assert.*`) so `npm run test:unit` now fails on behavior.
3. **Green** — implement in `src/*.ts` until both the type check and the test pass.
4. **Refactor** — with the type system and the tests as the safety net, then `npm run verify` as the definition-of-done gate.
1. **Type** — write the compile-time expectation first
(`expectTypeOf(...).toEqualTypeOf<…>()`) and let `npm run check:tsc` fail on
the _type_. The type error is the spec you want to hit before the runtime
logic exists.
2. **Red** — add the matching runtime assertion (`assert.*`) so
`npm run test:unit` now fails on behavior.
3. **Green** — implement in `src/*.ts` until both the type check and the test
pass.
4. **Refactor** — with the type system and the tests as the safety net, then
`npm run verify` as the definition-of-done gate.
This is why every test in the suite pairs an `expectTypeOf(...)` with an `assert.*` — keep them together. Type-first is also enforced structurally: `npm test` runs `check:tsc` before the test runner, so a wrong type can never be papered over by a passing assertion. Per [AGENTS.md § Never do](./AGENTS.md#never-do), reach green honestly — fix the types so both the type check and the runtime assertion pass, never suppress the ones you can't make pass.
Every test pairs an `expectTypeOf(...)` with an `assert.*`; keep them together.
The autocomplete tests (`src/util/__tests__/lsp-completion.test.ts` for the
helper, `src/primitive.test.ts` for the matcher's popup) are the
exception —
the language server, not the type system, is the oracle (see
[development/testing.md § Autocomplete](./development/testing.md#autocomplete)).
Each test body follows **AAA (Arrange–Act–Assert)** with labeled blocks
separated by a blank line: `// Arrange` sets up the inputs (e.g. the matcher
factory), `// Act` exercises the subject once from them (not a second
throwaway call), `// Assert` holds every check — type expectations first,
runtime assertions last; an empty block drops its label (see
[development/testing.md § AAA ordering](./development/testing.md#aaa-ordering)).
Type-first is enforced structurally: `npm test` runs `check:tsc` before the
test runner, so a wrong type can never be papered over by a passing assertion.
Per [AGENTS.md § Never do](./AGENTS.md#never-do), reach green honestly — fix the
types, never suppress the checks you can't make pass. Full rationale:
[development/testing.md](./development/testing.md).
## Code style and formatting
`oxfmt` is the formatter and `oxlint` is the linter (with type-aware rules).
`npm run fix` resolves the fixable issues; `npm run check` verifies without
writing. Suppressions must be fixed at the root — do not add `oxlint-disable`
directives or `as` casts to force a green run (see
[AGENTS.md § Never do](./AGENTS.md#never-do)).
Suggested VSCode extensions are in
[.vscode/extensions.json](./.vscode/extensions.json); the project's formatter
and linter are wired up there. Toolchain decisions:
[development/tooling.md](./development/tooling.md).
## Commit messages
Gitmoji subject, imperative mood, 50/72 wrapping. The template is
[commit-message-template](./commit-message-template); `npm run setup`
(or `npm run setup:git-commit-message`) registers it as git's
`commit.template`. Examples and rationale:
[development/workflow.md § Commit messages](./development/workflow.md#commit-messages).
## Script prefix convention
Script names in `package.json` use a prefix that signals _when_ the script is
intended to run. A `<prefix>:<name>` script is implicitly aggregated by a
`<prefix>` script (if one exists) and run by the corresponding lefthook hook or
CI step. Pick the prefix that matches the script's lifecycle:
- `create:*` — front doors of the repo's own workflow; these mutate git state
rather than the source. `create:branch` opens a unit of work, `create:finish`
closes the branch half, `create:release` closes the release half
(maintainer-only). No bare `create` aggregator on purpose.
- `check:*` — read-only verification; never modifies files. Aggregated by
`npm run check`.
- `fix:*` — mutating counterpart of a `check:*` script. Aggregated by
`npm run fix`; the diff is the review surface.
- `test:*` — test scripts. `test` is the canonical entry point (`check:tsc` +
unit tests); `test:unit` skips the typecheck for fast local iteration;
`test:ci` adds c8 coverage.
- `watch:*` — long-running watchers for the manual inner dev loop. Aggregated by
`watch`.
- `maintain:*` — advisory repo-maintenance scans: read-only, but whole-project
and/or network-bound, so never a correctness gate. Aggregated by
`npm run maintain`.
- `publish:*` — validates the _publishable artifact_ (e.g. `dist/`) rather than
the source, so it needs a fresh build.
- `setup:*` — one-time configuration of a fresh clone; mutates the local
environment rather than the repo source, so it is never part of a hook or CI
step. Aggregated by `npm run setup`, run once after cloning.
A new script must reuse an existing prefix. If none fits, that's a signal the
script doesn't belong in the pipeline — not a reason to invent a new prefix. If
it genuinely does belong, add the prefix to this list in the same commit as its
first member; an undocumented prefix becomes invisible and quietly accrues
members. Why `create:` exists, the rejected names, and the design of the bare
scripts: [development/workflow.md § Script prefix convention](./development/workflow.md#script-prefix-convention).
## Rules the tools don't enforce
CI and review will bounce these even though `npm run check` and the linters
don't catch them. They're the high-frequency things a contributor (or an agent)
reaches for by default:
- **Source imports use `.ts` extensions, never `.js`.** `node --strip-types`
only resolves the `.ts` form at test time; "pre-fixing" an import to `.js`
breaks the inner loop. (why:
[development/tooling.md](./development/tooling.md#source-imports-use-ts-extensions))
- **A new `npm run` script must reuse an existing prefix.** See
[Script prefix convention](#script-prefix-convention).
- **`oxlint-disable` directives live in source, not `.oxlintrc.json`.** The
trade-off must sit next to the code it silences. This is a _human_ last-resort
convention; agents must not add these — see
[AGENTS.md § Never do](./AGENTS.md#never-do). The sole agent exception is the
fixed file-level header at the top of a `*.test.ts` file, spelled exactly:
`/* oxlint-disable typescript/no-floating-promises -- expectTypeOf() is a sync
type-assertion library that the type-aware linter misidentifies as a promise
*/` — any other suppression stays human-last-resort. (why:
[development/tooling.md](./development/tooling.md#oxlint-disable-directives-live-next-to-the-code))
- **Don't put slow / network / whole-project scans in `check` or pre-commit.**
Advisory scans are not correctness gates; they belong under `maintain:`. (why:
[development/workflow.md](./development/workflow.md#feedback-tiers))
- **New work starts with `npm run create:branch`, never a hand-written
`git switch -c` / `git checkout -b`.** The command carries the branch
precondition; branching around it skips the clean-tree, current-`main` and
green-baseline checks, and the skip is invisible until a failure can no longer
be attributed. (why:
[development/workflow.md](./development/workflow.md#branching-model))
- **Work is merged back with `npm run create:finish`, never a hand-written
`git merge`.** The command carries the merge-side preconditions (clean tree,
current `main`, a `feature/`/`fix/`/`chore/` branch) and runs `npm run verify`
after the merge, so a merge cannot land unverified. (why:
[development/workflow.md](./development/workflow.md#branching-model))
- **There is no local `npm run publish`, and `publish:publint` / `publish:attw`
don't go in `check`.** (why:
[development/publishing.md](./development/publishing.md#ci-only-publishing))
- **A branch ends with a changelog note:** before `npm run create:finish`,
summarize the work under `[Unreleased]` in [CHANGELOG.md](./CHANGELOG.md).
(why:
[development/workflow.md](./development/workflow.md#changelog-notes))
- **A decision or its rationale belongs in `development/`, not here.** This file
holds the actionable rule; `development/<category>.md` holds why, the rejected
alternatives and the known issues. When you change a rule, update its category
file in the same commit and cross-link the two. (why:
[development/README.md](./development/README.md))
- **Prose in `development/` is extremely concise.** When adding or changing a
decision, write fragments if needed — sacrifice grammar for concision. (why:
[development/README.md § Decision blocks](./development/README.md#decision-blocks))
## Branching model
**GitHub Flow (single-developer).** Every change — feature, fix, refactor — branches off `main` and is merged back via a local commit (no PR workflow on Gitea yet). Collaborative review via Gitea UI is not in place — Gitea is the lab; when something is tested and ready for production it will be promoted to GitHub.
**GitHub Flow (single-developer).** Every change — feature, fix, refactor —
branches off `main` and is merged back via a local commit. There is no pull
request workflow on Gitea yet.
- **Base branch:** `main`
- **Branch naming:** `feature/<desc>` / `fix/<desc>` / `chore/<desc>`
- **Starting work:** `npm run create:branch -- <prefix>/<desc>`. It refuses, without changing anything, unless the working tree is clean (untracked files included), no merge/rebase/cherry-pick is in progress, `main` matches its upstream, and `npm run test` is green on `main` — so a later failure is always attributable to your edits. The prefix is still _your_ call, inferred from the task; the script validates it rather than guessing it.
- **Merging:** `npm run create:finish` (on the branch). It asserts the same clean-tree / no-operation / current-`main` preconditions, fast-forwards a stale `main` (a true divergence is refused), merges the branch `--no-ff`, runs `npm run verify`, and deletes the branch only after the merge is green. The push is deliberately left to `create:release`, so the merge stays local and reviewable — read the diff yourself before finishing.
- CI runs `npm run check` + `npm run test:ci` on every push to `main` — this is the authoritative gate. The one exception: a push headed by a release commit (`:rocket: Release x.y.z`) skips the full `build`/`maintain` jobs, because `create:release` pushes the tag for that exact commit right after and the tag run is the authoritative one (see `release-gate` in [.gitea/workflows/ci.yml](./.gitea/workflows/ci.yml)).
- **Releases are NOT triggered by pushes.** Only the maintainer triggers a release (see [Publishing workflow](#publishing-workflow)).
- **Starting work:** `npm run create:branch -- <prefix>/<desc>`. It refuses,
without changing anything, unless the working tree is clean, no
merge/rebase/cherry-pick is in progress, `main` is not behind its upstream
(a local merge not yet pushed is fine — the push belongs to `create:release`),
and `npm run test` is green on `main`. The prefix is _your_ call, inferred from
the task; the script validates it rather than guessing it.
- **Merging:** `npm run create:finish` (on the branch). It re-asserts the same
preconditions, merges `--no-ff`, runs `npm run verify`, and deletes the branch
only after the merge is green. The push is left to `create:release`, so the
merge stays local and reviewable.
- CI runs on every push to `main` — see [Feedback tiers](#feedback-tiers) and
[.gitea/workflows/ci.yml](./.gitea/workflows/ci.yml).
- **Releases are NOT triggered by pushes.** Only the maintainer triggers a
release; see [Publishing](#publishing).
## CI runner image
Full rationale, including the front-door decisions:
[development/workflow.md § Branching model](./development/workflow.md#branching-model).
The `build` / `maintain` / `publish` jobs run in `gitea.e1nsnull.de/tmu/act-ci:<version>` ([docker/Dockerfile](./docker/Dockerfile)) — the runner's default act image with the Node distribution overlaid at the exact `/opt/hostedtoolcache` layout `actions/setup-node` probes before downloading, so no job pays the ~50 MB fetch. The image tag MUST equal the exact version pinned in `.node-version`, and the image is rebuilt only as part of a Node bump — there is no other trigger. `release-gate` uses no Node and stays on the default image. The script is deliberately NOT an `npm run` script: building requires a docker daemon and registry credentials, so it belongs to no feedback tier — per [Script prefix convention](#script-prefix-convention), no existing prefix fits and that is the signal.
## Submitting changes
Bumping Node is one coordinated change, committed as a unit:
There is no pull request workflow on Gitea yet, so a contribution is submitted
as a branch that is merged locally:
1. Edit `.node-version` to the new exact `x.y.z` — floats like `26` resolve to the latest patch at runtime and silently bust the baked entry; `scripts/runner-image.sh` refuses them.
2. `docker login gitea.e1nsnull.de` (user + package/access token), then `./scripts/runner-image.sh --push` — it reads the version from `.node-version` and builds/pushes `<IMAGE_REPO>:<version>`.
3. Repoint the three `container.image` tags in [.gitea/workflows/ci.yml](./.gitea/workflows/ci.yml) to the same version.
1. `npm run create:branch -- <prefix>/<desc>`.
2. Commit your work (one or more commits, per the tests and style rules above).
3. `npm run verify` — the definition of done.
4. Add a changelog note under `[Unreleased]` (see
[Rules the tools don't enforce](#rules-the-tools-dont-enforce)).
5. `npm run create:finish` to merge the branch into `main` and verify the
result.
6. Present a handover for review. Once there are no further objections, the
maintainer pushes.
Skipping step 2 fails CI at image pull; skipping step 3 silently reverts to the per-job download.
When the project is promoted to GitHub, this step becomes a normal pull request
against `main`.
Two invariants the image must satisfy for the probe to hit, both easy to break:
## Publishing
- **The `x64.complete` marker.** `actions/tool-cache` accepts a cached tool only when `<version>/<arch>.complete` exists next to the directory (`tc.find()` checks it); a plausible-looking `node/<version>/x64/` alone is ignored and the download happens anyway. See the comment in [docker/Dockerfile](./docker/Dockerfile).
- **Tag freshness (force-pull deliberately off).** The tag encodes only the Node version, and the image is rebuilt only when that version changes — so the normal flow always yields a new tag, and `act_runner` pulls it. `force_pull` stays disabled (the job log shows `forcePull=false`): it is acceptable to miss a runner-side image change, and forcing a pull would re-pull the image on every job for no benefit. Known issue: a Dockerfile-only change (like the marker above) re-pushed under an unchanged tag is invisible to the runner — it keeps the old image while the registry shows the new digest. If that ever matters, remove the stale tag on the runner host (`docker rmi gitea.e1nsnull.de/tmu/act-ci:<version>`); do not reach for force-pull.
## Publishing workflow
Publishing is CI-only by policy. Local `npm publish` is not supported. The maintainer triggers releases from `main`:
1. All intended changes are merged to `main` and passing CI.
2. The maintainer runs `npm run create:release`. VS Code opens `CHANGELOG.md` to finalize the `[Unreleased]` notes; because pubv refuses a dirty tree, any edit is committed first (then folded into the release commit), and pubv's interactive prompt suggests a version from those notes — the maintainer confirms or edits it.
3. `scripts/release.sh` creates a single release commit (graduated changelog + package.json bump, amended into one commit), tags it, and pushes everything to Gitea.
4. CI fires on both pushes: the `publish` job runs on the tag (`build` + publish-tier checks + release page + `npm publish`), while the branch run's `release-gate` job recognizes the release commit and skips `build`/`maintain` — the tag verifies the identical SHA, so no work is duplicated. The job graph lives in [.gitea/workflows/ci.yml](./.gitea/workflows/ci.yml) — keep that file, not this list, as the source of truth. The publish-tier checks must pass before the artifact is published. The `publish` job also creates the Gitea release page from the matching Keep-a-Changelog section (`scripts/release-notes.sh`); it runs _before_ `npm publish` so a broken page fails CI without consuming a version, and `npm publish` stays the last step.
The Gitea release page uses the run's automatic token (`github.token`), so it only needs `contents: write`. `npm publish` is gated on `NPM_TOKEN`, lifted into job-level `env` because `secrets` is not an allowed context in a step `if`: an unset secret skips the publish instead of attempting an unauthenticated one. A tag is all-or-nothing, though — a final `always()` step fails the job unless both the release page and `npm publish` reported `success`, so a skipped or failed npm half turns the job red rather than silently green. Set `NPM_TOKEN` (npm publish rights) under Settings → Actions → Secrets.
Publishing is maintainer-only and CI-only. See
[development/publishing.md](./development/publishing.md).
+21 -55
View File
@@ -2,68 +2,34 @@
Pattern matching for TypeScript/ESM environments (F#-style, not regex).
## Development
## Description
- **Build:** `npm run build`
- **Test:** `npm run test`, `npm run test:ci`
- **Watch:** `npm run watch`
- **Checks:** `npm run check`, `npm run fix`
- **Verify:** `npm run verify` — the definition of done
- **Maintenance:** `npm run maintain` — advisory only
- **Individual fixes:** `npm run fix:oxfmt`, `npm run fix:oxlint`
`tiny-pattern-ts` brings F#-style pattern matching to TypeScript. Patterns are
ordinary objects whose `matches` method is a TypeScript type guard, so narrowing
composes the way any other guard does. It is deliberately not a regex engine and
not a macro: there is no transpiler and no DSL to learn, and the type-level
contract is the feature — see [development/library.md](./development/library.md)
for the design decisions and the known limitations.
What each tier runs, when it fires and what it costs:
[CONTRIBUTING.md § Feedback tiers](./CONTRIBUTING.md#feedback-tiers). How the
`prefix:` in a script name is chosen:
[§ Script prefix convention](./CONTRIBUTING.md#script-prefix-convention).
## Requirements
### Tooling
- **Node.js >= 26** (`engines` field; pinned via `.node-version`).
- **TypeScript >= 5.0** to consume the published declarations. The emitted `.d.ts`
use `const` type parameters (TS 5.0) and keep their relative `.ts` specifiers;
both resolve on TS >= 5.0 in `node10` / `node16` / `nodenext` / `bundler`.
- The package is **ESM-only** (no CommonJS shim).
- **TypeScript 7** — type checker and build (`tsc`).
- **node --test** + `--strip-types` — test runner.
- **c8** — code coverage for `test:ci`.
- **oxlint** — Rust-based linter, with type-aware rules powered by **oxlint-tsgolint** (typescript-go).
- **oxfmt** — Rust-based formatter (Prettier-compatible). Formats JS/TS, JSON/JSONC, YAML, Markdown, MDX, and more; built-in `package.json` key sorting replaces `sort-package-json`.
- **cspell** — spell checking.
- **knip** — finds unused dependencies, exports, and files.
- **check-outdated** — reports dependencies behind the registry; it exits non-zero whenever _any_ dependency is outdated.
- **publint** — validates `package.json` for ESM publishing correctness.
- **@arethetypeswrong/cli** (`attw`) — validates `.d.ts` declarations against multiple module-resolution scenarios.
- **lefthook** — git hooks.
- **@spences10/pi-lsp** — read-only LSP code intelligence for AI coding agents (project-local `.pi/settings.json`). Talks to this repo's TypeScript 7 via `tsc --lsp --stdio`.
## API
Each tool's configuration trade-off is recorded in [Tooling decisions](#tooling-decisions); when it runs is in [CONTRIBUTING.md § Feedback tiers](./CONTRIBUTING.md#feedback-tiers).
Yet to be implemented
### Tooling decisions
## License
The choice and configuration of each tool above is the result of deliberate trade-offs, not defaults. The non-obvious ones:
- **`tsconfig.json` extends `@tsconfig/strictest` + `@tsconfig/node26`**; `tsconfig.build.json` extends it to add the emit-only options (`declaration`, `sourceMap`, `inlineSources`, `outDir`, `target: es2024`, `rewriteRelativeImportExtensions: true`) and to exclude test files. `inlineSources` embeds the original TypeScript in `dist/*.js.map`, so debuggers can map into `src/` without it being shipped; `declarationMap` is intentionally off because a `.d.ts.map` cannot embed source and would dangle. This separation lets the editor and CI type-check from one config while the build emits from the other.
- **`npm run build` first runs a `prebuild` hook that empties `dist/`.** `tsc` does not prune orphaned emit output — dropping `declarationMap`, for example, left stale `*.d.ts.map` files behind — so the build must start from an empty `dist/` to be reproducible. `prebuild` removes only `dist`; the manual `clean` still resets `dist` + `coverage`, so a local coverage report survives a build.
- **Source imports use `.ts` extensions** so `node --strip-types` resolves them at test time. `rewriteRelativeImportExtensions: true` in `tsconfig.build.json` rewrites them to `.js` in the emitted JavaScript; the emitted `.d.ts` keep the `.ts` specifier, which TypeScript >= 5.0 resolves (see [Requirements](#requirements)), so no post-processing step is needed.
- **Type-aware oxlint is enabled declaratively** via `options.typeAware: true` in `.oxlintrc.json` (powered by `oxlint-tsgolint`). The script commands stay clean — no CLI flag — and type-aware mode is a property of the config, not the invocation.
- **Source-level `oxlint-disable` directives** are used for known type-aware false positives (see `src/pattern.ts`, `src/match.ts`, `src/index.test.ts`). The disable lives next to the code it silences, not in `.oxlintrc.json`, so the trade-off is visible to anyone reading the source.
- **`knip --include dependencies,exports,files`** intentionally omits the `types` category, which produces systematic false positives for libraries whose exported types are part of the public API. The targeted scope keeps the signal high without config-file boilerplate.
- **`attw --profile esm-only`** is semantically correct: this package is intentionally ESM-only (no CommonJS shim), so CJS resolution scenarios are out of scope by design, not a bug.
- **`check:tsc` runs first** in the `npm run check` chain so a type error short-circuits the rest (faster feedback than letting oxlint/oxfmt run and then failing on tsc at the end).
- **The pre-commit hook sets the `LEFTHOOK_FILES` env var** to the staged-files list, and the affected scripts use `${LEFTHOOK_FILES:-<default>}` to default to the whole project when invoked manually. This keeps `package.json#scripts` as the single source of truth for the underlying commands — `lefthook.yml` only describes _what to run on which files_.
- **`tslib` and `type-fest` are deliberately not used.** `tslib` is a runtime helper for old ES3/ES5 targets (the project targets ES2024); `type-fest` was never imported. knip caught both.
- **`@spences10/pi-lsp` is pinned to `0.0.46` and is read-only by design.** The package 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` binary — no `typescript-language-server` dependency is required. Earlier releases (`≤ 0.0.10`) hard-wire to `typescript-language-server --stdio` and are TS6-only. The tool is _intermediate_ agent feedback (hover, references, definition, symbols, diagnostics); it has no rename / code-action / apply-edit surface, and never a correctness gate — `npm run check` / `verify` remain that. `.pi/settings.json` is the shared, committed declaration; `.pi/npm/` is a gitignored install cache that pi recreates automatically on a trusted startup (it runs `npm install` for any missing project package), so the cache is deliberately not tracked.
### Requirements
- Node.js >= 26 (engines field; pinned via `.node-version`).
- TypeScript >= 5.0 to consume the published declarations. The emitted `.d.ts` use `const` type parameters (TS 5.0) and keep their relative `.ts` specifiers; both resolve on TS >= 5.0 in `node10`/`node16`/`nodenext`/`bundler`.
## VSCode integration
- Recommended extensions: see `.vscode/extensions.json` (oxc, cspell, TypeScript native-preview, EditorConfig, todo-tasks).
- TypeScript 7 is used via the `typescriptteam.native-preview` extension.
- oxc extension provides oxlint squiggles and oxfmt format-on-save; `.vscode/settings.json` pins it per language so a user's local `[language]` formatter settings cannot override the project's choice.
MIT © 2025 tmu. See [LICENSE](./LICENSE).
## Contributing
For maintainer and contributor docs — the script prefix convention, the feedback-tier system, the rules the tools don't enforce, and the publishing workflow — see [CONTRIBUTING.md](./CONTRIBUTING.md). AI coding agents: your entry point is [AGENTS.md](./AGENTS.md), which points back to CONTRIBUTING.md.
- Commit signing (GPG).
- Type-only tests use `expect-type`'s `expectTypeOf(...)` inside `node --test` cases.
Contributions are documented in [CONTRIBUTING.md](./CONTRIBUTING.md); the
reasons behind the project's decisions, rejected alternatives, and known issues
live in [development/](./development/README.md). AI coding agents start at
[AGENTS.md](./AGENTS.md).
+47 -28
View File
@@ -5,9 +5,18 @@ Backlog and tracking for tiny-pattern-ts. Managed in vscode-todotasks format.
---
Setup:
✔ Add gitea release page in CI @high @done
✔ Manually verify the Gitea release page on a real tag push (needs main) @high @done (9/15/2026, 1:18:15 PM)
☐ Split off template into separate package => pi --session 01a07dde-7050-7054-bb36-1606d7eb2bc3 @high
☐ Split off template into separate package => pi --session 01a07dde-7050-7054-bb36-1606d7eb2bc3 @low
✔ straighten oxc rules @done
✔ oxc forbids ternary => remove oxc rule @done
✔ oxc wants comments to start comments with a capital letter => remove oxc rule @done
✔ there already exists a /* */ block comment in primitive.ts, => change to multi-line comment @done
✔ Remove example code files and its documentation and its exports from index.ts @high @done
✔ remove src/match.ts and its documentation @high @done
✔ remove src/pattern.ts and its documentation @high @done
✔ remove src/index.test.ts and its documentation @high @done
✔ exports from `src/index.ts` should only be the public API surface @done
v1.0:
☐ API surface is stable and fully typed
@@ -15,28 +24,54 @@ v1.0:
☐ Document all exported types and functions
☐ Add JSDoc for public APIs
☐ Test coverage meets threshold
☐ Achieve 100% branch coverage on `src/pattern.ts`
☐ Achieve 100% branch coverage on `src/match.ts`
☐ Achieve 100% branch coverage on `src/primitive.ts`
☐ Achieve 100% branch coverage on `src/index.ts`
Bugs:
Testing:
✔ Cover autocomplete with real completion test cases in the suite @medium @done
→ `src/util/__tests__/lsp-completion.ts` (`#test-utils/…`) is the helper; the suite asserts its labels (see development/testing.md § Autocomplete)
→ wire it into `node --test` so a test asserts the offered labels
→ note: `Parameters<typeof factory>[0]` resolves only the *last* overload; use `@ts-expect-error` call sites for factory negatives, not `not.toExtend<Parameters<…>>`
✔ Drive the LSP completion helper through the LSP protocol library instead of a hand-rolled JSON-RPC client @medium @done
✔ Add `vscode-languageserver-protocol` and record the tooling decision @done
✔ Rewrite `src/util/__tests__/lsp-completion.ts` onto `createMessageConnection` and typed requests @done
✔ Update `development/testing.md § Autocomplete` for the new client @done
✔ Run `npm run verify` and check the task off @done
Matcher:
✔ Clean up: adopt the 3-overload matcher (`src/prototype-ac2.ts`) and delete the prototypes @high @done
→ `getMatcher` / `getMatcherW`, each with overloads `ExhaustiveLoose` → `Fallback` → `Handlers` (order is load-bearing)
→ fold into `src/primitive.ts` / the public API; drop `src/prototype*.ts`
✔ `_` should receive only the unhandled `T` keys, not all of `T` @medium @done
→ the fallback is now a second argument: `(handlers, (s) => …)`, `s: Exclude<T, keyof handlers>`
☐ A fallback for an already-exhaustive handler map must be a compile error @medium
→ `(handlers, fallback)` with `handlers` covering all of `T` is accepted; the redundant fallback should be rejected (currying would allow the guard)
Bugs:
✔ TS 7 LSP server logs `context canceled` on stderr at shutdown @done
→ `handleExit` returns `io.EOF`, cancelling the background context while `Session.updateWatches` is still in flight; the bare error is flushed to stderr and the server exits 1
→ close stdin after `shutdown` instead of sending `exit`; the server exits cleanly (code 0, no output), kill kept as a fallback
Enhancements:
☐ Allow boolean literals in primitive union patterns (e.g. `true: () => "yes"`) @medium
☐ Are there other primitive types that should be supported in union patterns? (e.g. `bigint`, `symbol`) @medium
Documentation:
☐ Clean up CONTRIBUTING.md and README.md, create docs
☐ Add usage examples to README.md
☐ Bring README.md back to its previous form — synopsis and examples restored, in the correct place
→ previous section order: title, tagline, Synopsis, Description, Requirements, Examples, API, License, Contributing
→ previous Examples order: literal/exhaustive, typeof, structural/discriminated unions, when, any
☐ Create `examples/` directory with runnable snippets
☐ Add comparison section vs. other TS pattern-matching libs
☐ Add comparison section vs. other TS pattern-matching libs in Readme.md
☐ Write migration guide for users coming from discriminated unions
☐ Create backlog tasks for implementation
✔ Document the matcher design paths in `development/` — union vs overload merge, inferred universe (`NoInfer`), conditional `RequireKeys`, cases-first — and why each was abandoned @done
☐ Validate code fences in Markdown (start with README.md) — compile the TypeScript examples against `src/` so the docs cannot drift from the API
Workflow:
✔ Resolve the finish/push tension: `create:finish` leaves `main` ahead of its upstream while `create:branch` refuses until `main` matches upstream — decide whether `finish` should push or `branch` should compare only `BEHIND` (see development/workflow.md) @done
Maintenance:
☐ Serve CI coverage over a tiny self-hosted webserver (replace the zip artifact) @low
✔ Add a minimal dir-listing webserver to the gitea docker setup (e.g. caddy `file_server browse` reusing the existing reverse proxy, or any single-binary static server, lipanski/docker-static-website) @done (9/13/2026, 9:02:37 PM)
✔ drop the `actions/upload-artifact` coverage step in favour of the shared-dir layout @done (9/13/2026, 10:37:22 PM)
☐ Explore serving coverage for non-tag pushes (e.g. `main/coverage`, PR previews) @low
✔ Manually verify the coverage was created on a real tag push (needs main) @low @done (9/14/2026, 1:55:03 PM)
→ design: no deploy step in CI; the webserver just exposes the shared directory (decided over Gitea Pages / Codecov — neither confirmed available/ wanted)
☐ serve docs over self hosted server @low
☐ Add a minimal dir-listing webserver to the gitea docker setup for serving docs (reuse existing reverse proxy)
@@ -46,19 +81,3 @@ Maintenance:
☐ Add a minimal dir-listing webserver to the gitea docker setup for serving landing page (reuse existing reverse proxy)
☐ CI writes landing page to a shared volume keyed by project + tag (e.g. `/landing/tiny-pattern-ts/<tag>/`)
☐ Browse to `…/tiny-pattern-ts/index.html` in the browser
✔ Stop Gitea CI re-downloading Node on every job @done
✔ Share the warm npm cache with the publish job @done
✔ Bake Node into the CI job image (docker/Dockerfile, container.image in ci.yml) @done
✔ Build/push gitea.e1nsnull.de/tmu/act-ci:26.8.2 and confirm setup-node skips the download @done
✔ Write the `<version>/x64.complete` marker — actions/tool-cache ignores a bare directory, so the probe missed and the download continued @done
✔ Log tool-cache state from the job container to find it (temporary, removed once understood) @done
✔ Guard the invariant in CI (`Assert the baked tool cache is present`) @done
✘ Enable force-pull for the runner so a changed act-ci image is never missed @low @cancelled
→ decided against: it is acceptable to miss a runner-side image change, and the image is only rebuilt on a Node bump, which changes the tag anyway. A Dockerfile-only change re-pushed under an unchanged tag is a known issue with a manual `docker rmi` workaround (see CONTRIBUTING § CI runner image).
✔ Improve CI publish @done
✔ Check whether publish job is only run on tags, if not, guard it @done
✔ Gate only single steps @done
✔ Do not publish to npm, if NPM_TOKEN is not set (e.g. PRs from forks) @done
✔ Do not publish to Gitea — uses the run's automatic `github.token`, so no secret gate is needed @done
✔ Otherwise run the steps @done
✔ Fail the job unless both the Gitea release and npm publish succeeded @done
+2 -1
View File
@@ -42,7 +42,8 @@
"bestikk",
"silverwind",
"idris",
"todotasks"
"todotasks",
"connor"
],
"ignorePaths": ["dist", "node_modules", "coverage", "*.svg", ".gitignore"]
}
+81
View File
@@ -0,0 +1,81 @@
# Development documentation
Why this project works the way it does: the decisions, what was rejected, and
the shortcomings and known issues we carry. Written for maintainers and
contributors.
The actionable rules — setup, running, testing, submitting — live in
[CONTRIBUTING.md](../CONTRIBUTING.md). **Each fact is written once**: the rule
there, the reason here; neither restates the other, and where a fact is useful
in both they link. Read the relevant file before changing an area, and when a
rule changes update its rationale here in the same commit.
User-facing documentation is [README.md](../README.md). `docs/` is deliberately
unused: that name is reserved for the future user documentation site, and
deploying it is out of scope. These files are not part of that site.
## Layout
One file per category:
| File | Covers |
| -------------------------------- | ----------------------------------------------------------------------- |
| [library.md](./library.md) | Public API design, the type-level contract, and its limitations |
| [workflow.md](./workflow.md) | Branching and merging, script prefixes, feedback tiers, commit messages |
| [tooling.md](./tooling.md) | Toolchain choices and configuration, editor setup |
| [testing.md](./testing.md) | Test strategy and type-driven development |
| [ci.md](./ci.md) | CI pipeline, runner image, coverage serving |
| [publishing.md](./publishing.md) | Release and npm publishing |
We start with one file per category so each area stays small enough to hold in
mind; a category that outgrows it becomes a folder with an index, and the links
in CONTRIBUTING.md and README.md point at the category, not a single decision.
## Decision blocks
Record every non-obvious choice as a block in the relevant category file:
```md
## Runner image
#### Decision (2026-09)
Bake Node into the CI job image at the setup-node tool-cache layout instead
of downloading per job.
#### Why
- ...
#### Rejected
- Gitea Pages / per-job download
- force-pull
#### Known issue
- a Dockerfile-only change re-pushed under an unchanged tag is invisible to the runner
- recover with `docker rmi <image>`
```
- The date is the month the decision was made, not when the file was edited —
the anchor for "current" versus "was current once".
- `Rejected` stops the project re-litigating the same alternatives; an empty one
usually means they were never written down.
- `Known issue` is where shortcomings live. A caveat not tied to one decision
goes under a `## Known issues` section at the end of the file.
- Replace a superseded decision in place rather than archiving it; git history
is the archive.
- Terse is the point: humans skim and agents imitate the style already in the
file, so verbosity compounds edit over edit. Grammar loses to density here on
purpose.
## Adding to these docs
1. Pick the category: `library`, `workflow`, `tooling`, `testing`, `ci`,
`publishing`.
2. Add or update a decision block; keep existing text unless the decision
changed.
3. If an actionable rule changes, update
[CONTRIBUTING.md](../CONTRIBUTING.md) in the same commit and cross-link.
Never change a rule there without updating its rationale here.
+136
View File
@@ -0,0 +1,136 @@
# CI
[.gitea/workflows/ci.yml](../.gitea/workflows/ci.yml) is the source of truth for
the job graph; this file records why it is shaped the way it is.
## Pipeline
- **`build`** (push to `main` / tag) — build + correctness + packaging.
- **`maintain`** (push to `main`, non-blocking) — `npm run maintain`; reports,
never fails the build.
- **`publish`** (tag) — packaging checks + `publish:publint` / `publish:attw`,
then the Gitea release page and `npm publish` (see
[publishing.md](./publishing.md)).
- **`release-gate`** — on a `:rocket: Release x.y.z` commit it skips
`build`/`maintain`, because `create:release` pushes the tag for the same commit
right after and the tag run is authoritative. It uses no Node and stays on the
default image.
## Runner image
#### Decision (2026-09)
`build` / `maintain` / `publish` run in `gitea.e1nsnull.de/tmu/act-ci:<version>`
([docker/Dockerfile](../docker/Dockerfile)): the default act image with Node
overlaid at the exact `/opt/hostedtoolcache` layout `actions/setup-node` probes.
#### Why
- No job pays the ~50 MB Node fetch, because the probe hits the baked entry.
- The tag must equal the exact [.node-version](../.node-version) pin, and the
image is rebuilt only as part of a Node bump — there is no other trigger.
- Building needs a docker daemon and registry credentials, so it belongs to no
feedback tier. That is why it is **not** an `npm run` script: no
[prefix](./workflow.md#script-prefix-convention) fits, and that is the signal.
#### Rejected
- Downloading Node in every job — the ~50 MB fetch was the original problem.
- Caching Proxy (Squid or similar) — adds complexity to global setup
- Mounting the tool cache - No invalidation will fill the cache with stale versions
## Bumping Node
Bumping Node is one coordinated change, committed as a unit:
1. Edit [.node-version](../.node-version) to the exact `x.y.z` — floats like `26`
resolve to the latest patch at runtime and bust the baked entry, so
[scripts/runner-image.sh](../scripts/runner-image.sh) refuses them.
2. `docker login gitea.e1nsnull.de` (user + package/access token), then
`./scripts/runner-image.sh --push`, which reads the version and pushes
`<IMAGE_REPO>:<version>`.
3. Repoint the three `container.image` tags in
[.gitea/workflows/ci.yml](../.gitea/workflows/ci.yml) to that version.
Skipping step 2 fails CI at image pull; skipping step 3 silently reverts to the
per-job download.
## Image invariants
For the `setup-node` probe to hit, two things must hold — both easy to break:
### The `x64.complete` marker
#### Decision (2026-09)
Bake a `<version>/<arch>.complete` marker next to the Node directory.
#### Why
- `actions/tool-cache` accepts a cached tool only when
`<version>/<arch>.complete` exists beside it (`tc.find()` checks). A bare
`node/<version>/x64/` is ignored and the download happens anyway. See the
comment in [docker/Dockerfile](../docker/Dockerfile).
### Tag freshness, with force-pull deliberately off
#### Decision (2026-09)
Leave `act_runner`'s `force_pull` disabled.
#### Why
- The tag encodes only the Node version, and the image is rebuilt only when that
changes — so the normal flow always yields a new tag and the runner pulls it.
- Forcing a pull re-pulls the image on every job for no benefit.
#### Rejected
- Enabling `force_pull`: it is acceptable to miss a runner-side image change,
and a `Dockerfile`-only change is not worth a per-job pull.
#### Known issue
- A `Dockerfile`-only change (like the marker above) re-pushed under an
unchanged tag is invisible to the runner, which keeps the old image while the
registry shows the new digest. Remove the stale tag on the runner host
(`docker rmi gitea.e1nsnull.de/tmu/act-ci:<version>`); do not reach for
force-pull.
## Coverage serving
#### Decision (2026-09)
Serve CI coverage from a shared directory on the runner, with no deploy step in
CI.
#### Why
- The webserver exposes the shared directory and the Gitea docker setup reuses
the existing reverse proxy — no upload artifact, no external service.
- Coverage is written to a shared volume keyed by project and tag (for example
`/docs/tiny-pattern-ts/<tag>/`).
#### Rejected
- Gitea Pages and Codecov: neither was confirmed available or wanted.
#### Known issue
- Coverage is served for tag pushes only; non-tag pushes (for example
`main/coverage`) are tracked separately.
[scripts/precompress.ts](../scripts/precompress.ts) emits `.br` / `.gz` / `.zst`
sidecars next to text assets. The Gitea pages service (`static-web-server` with
`SERVER_COMPRESSION_STATIC=true`) serves the sidecar matching `Accept-Encoding`
and falls back to the original.
#### Decision (2026-09)
Precompress into sidecars rather than per request.
#### Why
- The assets are static and change only on deploy, so the work is paid once.
- Images, fonts and archives are already compressed; a sidecar would only grow
them, so only text extensions are emitted.
+89
View File
@@ -0,0 +1,89 @@
# Library design
The type-level design of the public API and the limitations it carries. The
user-facing reference is [README § API](../README.md#api).
The matcher is implemented in `src/primitive.ts` and re-exported from
`src/index.ts` as `getMatcher` / `getMatcherW`; the rest of the library is
placeholder code.
## Matcher shape
#### Decision (2026-09)
A factory takes the universe and returns a builder; the builder takes a handler
map and an optional fallback:
```ts
const matcher = getMatcher<"a" | "b">()({ a: (s) => …, b: (s) => … });
const fallback = getMatcher<"a" | "b" | "c">()({ a: (s) => … }, (s) => …);
```
Exhaustive or fallback is decided **at the call site**, by whether the second
argument is present. The fallback's parameter is the remainder
`Exclude<T, keyof Handled>`. Only the return-strictness axis remains, so there
are two factories:
- `getMatcher` — one common `R`; the fallback must fit it;
- `getMatcherW` — the union `PatternReturns<Handled> | R`.
Each factory is two overloads whose order is load-bearing:
1. `Handlers<T, R>` — the exhaustive form, and the contextual type of the
handler-map popup;
2. `Handled extends Exact<Partial<Handlers<T, R>>, Handled>` plus
`Fallback<T, Handled, R>` — a partial handler map plus the fallback.
#### Why
- **The fallback is an argument, not a property.** TypeScript fixes a property's
contextual type before it infers its sibling keys, so `_: (s) => …` in the
handler map can only see all of `T`, never `Exclude<T, keyof Handled>`. A later
argument is contextually typed from inference on an earlier one, so the split
is what makes the remainder expressible.
- **Overload order keeps both messages.** #1 supplies the contextual type
(`a, b, c`); #2 accepts a partial map once a fallback is present, so its popup
is optional (`a?, b?, c?`). A gap without a fallback is reported against #1.
- **`R` needs an inference site.** `R` inside the `Exact<…>` constraint is not
one, so `handlers: Handled & Partial<Handlers<T, R>>` re-adds it; without that
`R` collapses to `unknown` when the handler params are inferred.
- **`Exact` restores the excess-property check.** TypeScript skips it for a
generic constraint, so without `Exact` the handler map accepts keys outside
`T`.
- Two factories, not four: the fallback is an argument, not a separate API.
#### Rejected
- **Single-object `_`** (the former shape). `_` sees only all of `T`; the
remainder is not expressible there, and an exhaustive map plus `_` was
accepted.
- **Curried handlers-first** — `(handlers)(fallback)`. `Handled` is fixed before
the second call, so a redundant-fallback guard would work. Rejected: two calls
for the common case.
- **`this` / HKT self-reference.** `this` is post-construction (method bodies,
return positions); a parameter's contextual type is pre-construction.
`keyof this` in an interface method is the interface, not the literal.
- **Variance / `const` type parameters / `NoInfer` / `unique symbol` brands /
defaulted type-param guards.** None change inference or evaluation order;
`in`/`out` on the handler map broke contextual typing outright. `NoInfer`
specifically leaks into the emitted `.d.ts`, raising the consumer floor to
TypeScript 5.4 (README promises `>= 5.0`).
- **Union merge**, **overload merge with only the exhaustive arm last**,
**inferred universe**, **conditional `RequireKeys`**, **cases-first curried** —
decided against while the API was single-object; their reasons (reported
near-miss member, no `_` in the exhaustive popup, `NoInfer`/floor, `keyof P`
counts optional keys, not pipe-friendly) hold where they still apply.
#### Known issue
- A redundant fallback is accepted: when the handler map already covers `T`, the
fallback is still allowed. The guard would be
`Exclude<T, keyof Handled> extends never ? never : unknown`, but the
conditional is evaluated before `Handled` is inferred; currying is the only
encoding that fixes it (see Rejected).
- `PatternReturns` must be
`ReturnType<Extract<ValueOf<P>, (...args: never[]) => unknown>>` so it survives
the closed, partly-optional `P` constraints.
- `Parameters<typeof factory>[0]` resolves only the **last** overload, so it is
not a sound "rejected" oracle for a factory. Factory-negative tests use
`@ts-expect-error` call sites (the test file only — the general ban stands).
+131
View File
@@ -0,0 +1,131 @@
# Publishing
Publishing is CI-only: local `npm publish` is not supported, and the maintainer
triggers releases from `main`. The mechanics are in
[scripts/release.sh](../scripts/release.sh) and
[scripts/release-notes.sh](../scripts/release-notes.sh); the job graph is
[.gitea/workflows/ci.yml](../.gitea/workflows/ci.yml).
## Release steps
1. All intended changes are merged to `main` and passing CI.
2. The maintainer runs `npm run create:release`. VS Code opens
[CHANGELOG.md](../CHANGELOG.md) to finalize the `[Unreleased]` notes; because
pubv refuses a dirty tree, the edit is committed first (then folded into the
release commit), and pubv suggests a version from those notes to confirm or
edit.
3. `scripts/release.sh` creates one release commit (graduated changelog +
`package.json` bump, amended together), tags it, and pushes.
4. CI fires on both pushes. `publish` runs on the tag (build + publish checks +
release page + `npm publish`), while `release-gate` recognizes the release
commit and skips `build`/`maintain`: the tag verifies the identical SHA, so no
work is duplicated. The publish checks pass before the artifact is published,
and the release page is created from the matching Keep-a-Changelog section
_before_ `npm publish`, so a broken page fails CI without consuming a version
and `npm publish` stays the last step.
## CI-only publishing
#### Decision (2026-09)
Releases are cut by CI from `main`; there is no local `npm publish` and no
`publish:*` script in the `check` chain.
#### Why
- The tag is the artifact marker: CI verifies the exact commit it points at, so
a local publish could ship something the tag does not describe.
- `publish:publint` / `publish:attw` validate the _publishable artifact_, which
needs a fresh build; they are not source-correctness checks and do not belong
in `check`.
## The release commit is assembled from two tools
#### Decision (2026-09)
`create:release` uses `pubv` for the changelog graduation and bump heuristic,
then `npm version` for the `package.json` + lockfile bump, amended into a single
release commit.
#### Why
- We want hand-written Keep-a-Changelog notes, an `[Unreleased]` ->
`## [x.y.z] - DATE` graduation, and a tag on the exact commit that gets
published — and no single tool did both the graduation and the `package.json`
bump.
- Split by strength: `pubv` (tiny, changelog-driven) owns preflight, the
interactive major/minor/patch heuristic, and graduating and committing
`CHANGELOG.md` (no tag, no push); `npm version` syncs `package.json` + the
lockfile; `--amend` folds them into one commit; the tag is created _after_ the
amend so it is never orphaned.
- The notes are finalized _before_ pubv because its bump heuristic reads the
`[Unreleased]` body — editing afterwards would inform the changelog only, not
the version. The staging commit that satisfies pubv's clean-tree check is
folded back into the single release commit.
#### Rejected
- The conventional-commits family: the history is gitmoji, not Conventional, and
the notes are hand-written (see
[workflow.md § Commit messages](./workflow.md#commit-messages)).
- `changesets` / `rtk`: config plus a heavier flow that fights the CI-only
publish.
- `knope` / `kacl` / `bestikk`: changelog-only (no `package.json` bump) and
5-year / 2-year / brand-new maintenance.
- `pubv` alone: it never writes `package.json`.
- `versions` (silverwind): good Gitea support, but pairing it with a hand-rolled
promote became a ~180-line script, which this ~30-line version replaces.
## The version has one source of truth
#### Decision (2026-09)
The version is derived from the graduated `## [x.y.z]` heading in
[CHANGELOG.md](../CHANGELOG.md) and written to `package.json` +
`package-lock.json` by `npm version`.
#### Why
- `release.sh` reads the version from the changelog, so the changelog is the
input and `package.json` the derived copy — one direction, no drift.
#### Rejected
- A `## Version` line in the README: it makes `release.sh` responsible for a
third file.
- Linking `package.json` from the README: it invites a hand-maintained duplicate
the link does not keep in sync.
## Release notes are extracted from the changelog
#### Decision (2026-09)
`scripts/release-notes.sh <tag>` prints the Keep-a-Changelog section for the tag
and exits non-zero when it is missing.
#### Why
- CI reuses the release body from the same file that drove the version, so the
page and the changelog cannot disagree.
- Failing on a missing section means a release can never publish an empty body.
A leading `v` is tolerated so both `v1.2.3` and `1.2.3` match `## [1.2.3]`.
## Token gates
#### Decision (2026-09)
The Gitea release page uses the run's automatic token (`github.token`).
`npm publish` is gated on `NPM_TOKEN`, lifted into job-level `env`. A final
`always()` step fails the job unless both halves reported `success`.
#### Why
- The automatic token needs only `contents: write`, so the release page needs no
secret gate.
- `secrets` is not allowed in a step `if`, so `NPM_TOKEN` must be lifted into
job-level `env`; an unset secret then skips the publish instead of attempting
an unauthenticated one.
- A tag is all-or-nothing: without the `always()` guard a skipped or failed npm
half would leave the job silently green. The guard turns it red.
Set `NPM_TOKEN` (npm publish rights) under Settings -> Actions -> Secrets.
+174
View File
@@ -0,0 +1,174 @@
# Testing
For this library the types _are_ the feature, so a runtime-only test loop would
verify the wrong thing. The commands are in
[CONTRIBUTING.md](../CONTRIBUTING.md); this file records why the loop is shaped
the way it is.
## Type-driven development
The rules — the loop and the pairing rule — are in
[CONTRIBUTING.md § Testing discipline (type-driven)](../CONTRIBUTING.md#testing-discipline-type-driven).
What follows is why and what was rejected.
#### Decision (2026-09)
The compile-time expectation is written before the runtime assertion, and both
before the implementation.
#### Why
- A runtime-only test can pass while the type is wrong, so a type-level library
would ship a broken feature its tests bless.
- The type error is a more precise spec than a failing assertion, because it
states the exact expected type before the logic exists.
#### Rejected
- Runtime-first (classic red/green): it verifies the value, not the contract,
and the contract is the product.
- Testing the type only: it would not catch handler dispatch or the `_`
fallback (see `src/primitive.test.ts`).
The runner is `node --test --strip-types "src/**/*.test.ts"` and the tiers are
in
[CONTRIBUTING.md § Development commands](../CONTRIBUTING.md#development-commands).
`c8` uses V8 coverage, so the `--strip-types` source is instrumented without a
build step, and the runner relies on the `.ts` import-extension convention (see
[tooling.md](./tooling.md#source-imports-use-ts-extensions)).
## AAA ordering
The rule is in
[CONTRIBUTING.md § Testing discipline (type-driven)](../CONTRIBUTING.md#testing-discipline-type-driven).
#### Decision (2026-09)
Test bodies read arrange → act → assert: inputs (the factory) set up first, the
subject exercised once from them, all checks last — types then runtime. The
blocks are labeled with `// Arrange` / `// Act` / `// Assert` comments and
separated by a blank line; an empty block drops its label.
#### Why
- Interleaved setup/checks hide what runs vs. what is observed; the eye
re-reads the block to find the seams.
- A factory built mid-test invites a second throwaway call of the subject;
arranging it once makes the positive construction and the negative
`Parameters<…>` check share one source of truth.
- Labels make the seams explicit, not inferred — grep-able and reviewable
without reading the statements.
#### Rejected
- Unlabeled ordering (bare blank lines): the seams still have to be found by
reading; the labels cost nothing.
## Test helpers
The rule is enforced by `import/no-relative-parent-imports`; this section records
why the mechanism is shaped like this.
#### Decision (2026-09)
A shared test helper — code that scattered `*.test.ts` files import to do their
testing — lives under `src/util/__tests__/` and is addressed by the
`#test-utils/…` self-reference (`package.json#imports`:
`"#test-utils/*": "./src/util/__tests__/*"`), never by a relative path:
```ts
import { LspSession } from "#test-utils/lsp-completion.ts";
```
#### Why
- The lint rule bans upward (`../`) imports, and a cross-cutting helper can
always be placed above _some_ scattered consumer, wherever it goes. Name
beats path: a `#test-utils/…` specifier has no direction, so the rule never
fires and file moves only touch the one mapping in `package.json`.
- `#…` is Node's reserved prefix for _private_ subpath imports: publishing
`package.json` leaks nothing and resolves nothing for consumers.
- `__tests__` as the folder name is not about tests living there; it is the
directory pattern `tsconfig.build.json` already excludes, so a helper can
never be emitted into `dist/` and shipped by accident.
- Node's own resolver handles `#…` under `--strip-types`, and `tsc` resolves it
via the same `imports` field — one mechanism for runtime and type gate, no
loader needed.
- The helper uses `node:` builtins (it drives a language server), so
`import/no-nodejs-modules` is off for `src/util/__tests__/**` in
`.oxlintrc.json` — the same exception the `scripts/**` scope carries.
- Helpers carry no library coupling: their tests probe in-memory documents
whose contextual types are written inline, so only the helper's own contract
(marker handling, position, label extraction) is under test. A test that
asserts a _matcher's_ popup belongs with the matcher.
#### Rejected
- A helper beside the tests (`src/util/test.ts`, flat `src/`): composes only
while `src/` stays flat; the first nested test reaching it reintroduces the
banned upward import.
- Bare `~/…` specifier: not valid in `imports` (keys must start with `#`) —
resolution fails at runtime with `ERR_MODULE_NOT_FOUND`. A `#~/…` “home”
shorthand was dropped in review; `#test-utils/…` states what it is.
- tsconfig `paths` alias: resolves for `tsc` but not for plain
`node --test --strip-types` (no loader hook), breaking the fast tier.
- Turning `import/no-relative-parent-imports` off for test files: the rule
still guards non-test helpers importing each other, and the exemption is
only needed for the one specifier the mapping already solves cleanly.
## Autocomplete
#### Decision (2026-09)
Completion is verified by driving the repo's own language server
(`tsc --lsp --stdio`, the same server pi's LSP extension talks to) through
the test helper `#test-utils/lsp-completion.ts`
(`src/util/__tests__/lsp-completion.ts`), not through the type system:
```sh
node --strip-types src/util/__tests__/lsp-completion.ts <file> [<marker>]
```
The script prints the labels the server offers at a `/*COMPLETE*/` marker inside
`<file>` (the marker is stripped before the document is sent). Its `LspSession`
is imported by `src/util/__tests__/lsp-completion.test.ts` — which tests the
helper itself against inline documents, never the library's code — and by
`src/primitive.test.ts`, where the same probe asserts the matcher's popup;
the CLI is for manual inspection.
#### Why
- Completion is a contextual-type property: it depends on which overload
signature TypeScript picks for the object literal, and no type-level assertion
observes that.
- `Parameters<typeof factory>[0]` resolves only the _last_ overload, so it is
not the popup's contextual type either — see
[library.md § Matcher shape](./library.md#matcher-shape).
- The server is the only ground truth; the script reproduces what the editor
shows.
#### Rejected
- **`expect-type` would not work**: there is no operator for “the popup offers
these labels”. `toExtend` / `toEqualTypeOf` test assignability and cannot say
which overload supplied the contextual type.
- **Checking by hand in the editor**: not reproducible in review or by an agent.
- **`@ts-expect-error` at a completion position**: it asserts the absence of a
compile error, not the presence of specific labels.
#### Known issue
- Each test spawns its own `tsc` server so the tests share no state and pass in
any order; the file is an integration test (~1.6 s) that needs `node_modules`.
`didOpen` is handled in order before the completion request, so no settle
delay is needed.
- The server answers some requests with a string id (`client/registerCapability`);
the client must tolerate `string | number` ids or the server stalls.
## Known issues
- The type-aware linter misidentifies `expectTypeOf()` as a floating promise, so
test files that use it (`src/primitive.test.ts`) carry a file-level
`oxlint-disable typescript/no-floating-promises` with an explanatory comment.
It is a known false positive, not a rule worth disabling project-wide (see
[tooling.md § oxlint-disable directives live next to the code](./tooling.md#oxlint-disable-directives-live-next-to-the-code)).
+315
View File
@@ -0,0 +1,315 @@
# Tooling
Every tool below was chosen and configured deliberately. The commands a
contributor runs are in [CONTRIBUTING.md](../CONTRIBUTING.md) and the versions
in [package.json](../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](../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](../README.md#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 is silenced with a source-level
`oxlint-disable` directive (see `src/primitive.ts`,
`src/primitive.test.ts`), not by turning the rule off in `.oxlintrc.json`.
#### Why
- The disable sits next to the code it silences, visible to anyone reading the
source.
- The rule stays on everywhere else, so only the mis-firing line is exempted.
#### Rejected
- A project-wide disable in `.oxlintrc.json` for a 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.
#### Known issue
- A source-level disable is a _human_ last resort. AI agents must not add one;
they fix the type at its root (see [AGENTS.md § Never do](../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](../.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.
+173
View File
@@ -0,0 +1,173 @@
# Workflow
How work moves through the repository. The rules are in
[CONTRIBUTING.md](../CONTRIBUTING.md); this file records why they are shaped the
way they are.
## Branching model
The model is GitHub Flow (single-developer); the steps are in
[CONTRIBUTING.md § Branching model](../CONTRIBUTING.md#branching-model). Context
behind it: Gitea has no collaborative review UI in use, so it is the lab, and
the project moves to GitHub once it is tested and ready.
#### Decision (2026-09)
Work is opened and closed by `create:branch` / `create:finish`, not by prose
plus hand-written `git`.
#### Why
- The preconditions were prose, and prose rots: a rule nobody checks is a
suggestion. A script asserts, then acts, so the branch or merge only exists if
the assertions passed.
- Type-driven work is only trustworthy if the baseline was green before the
first edit. Cheap checks run first and `npm run test` last, so the expensive
gate is not paid on an ineligible tree.
- The merge half owns the post-merge `npm run verify`, so a merge cannot land
unverified. The push stays with `create:release` so the merge is reviewed
locally first; `main` is therefore routinely ahead of its upstream between a
merge and the release that ships it. `create:branch` requires only that `main`
is not _behind_ — matching `create:finish`, which tolerates the local merge and
fast-forwards over a remote one — rather than an exact match.
- Every failure is non-mutating except the baseline test, which runs on `main`
after switching there: a red `main` restores the branch you started on, and a
merge conflict aborts back to the feature branch rather than stranding a
half-merged `main`.
#### Rejected
- Hand-written `git switch -c` / `git merge`: same rules, no enforcement.
- Reusing `pubv`'s preflight for `create:branch`: release-shaped, third-party,
and it would pay for a build and pack a new branch has no use for.
- Leaving the merge to reviewer judgment: that judgment moved earlier, to the
handover review before `create:finish`, rather than living in a command anyone
can run from a dirty tree.
- Fast-forward instead of `--no-ff`: `--no-ff` keeps each unit of work visible
in `git log`.
- Pushing from `create:finish` to keep `main` level with its upstream: it would
trade the local review the push waits for for a network side effect, and a
failed push would leave the merge landed but unpublished.
## Changelog notes
The rule is in
[CONTRIBUTING.md § Rules the tools don't enforce](../CONTRIBUTING.md#rules-the-tools-dont-enforce).
#### Decision (2026-09)
A merged branch carries its own summary under `[Unreleased]` in
[CHANGELOG.md](../CHANGELOG.md), added before `create:finish`;
`create:release` graduates it into the tagged section (see
[publishing.md](./publishing.md)).
#### Why
- `create:release` derives the bump heuristic from the `[Unreleased]` body, so
the notes must exist before release day.
- The contributor has fresh context; at release day the intent of a branch is
only its diff.
- Gitmoji subjects are signposts, not semantic keys, so notes cannot be derived
from the history.
#### Rejected
- Generating notes from subjects at release time: subjects carry no parseable
type/scope (see § Commit messages).
- The maintainer writing one summary during `create:release`: reconstruction
after the fact.
- Enforcing it in `create:finish`: the front doors assert git state, not
content — and _notable_ is exactly the judgment a tool cannot make.
## Script prefix convention
The prefix taxonomy is the rule, and it lives in
[CONTRIBUTING.md § Script prefix convention](../CONTRIBUTING.md#script-prefix-convention).
The design behind it: bare scripts are the tier entry points — a single tool
(`build`, `clean`) or an aggregator of a `prefix:*` family (`check`, `fix`,
`test`, `watch`, `maintain`, `setup`) — while `verify` composes `check` +
`test:unit` into the whole-project gate (it uses `test:unit`, not `test`,
because `check` already runs `check:tsc`).
#### Decision (2026-09)
`create:` is the prefix for workflow front doors, with no bare `create`
aggregator.
#### Why
- Both members create something real: a branch, a release.
- It joined both lists in [CONTRIBUTING.md](../CONTRIBUTING.md) alongside its
first members, so it could not go invisible the way the retired `use:` prefix
did.
- `publish:*` already set the precedent for a prefix without an aggregator.
#### Rejected
- `run:` / `perform:`: they mean only "do the named thing", so every script fits
and the taxonomy collapses.
- `git:`: names the tool, not the lifecycle moment, and implies passthrough
aliases.
- `start:`: describes the branch half, not the release.
- `cut:`: idiomatic but needs VCS slang to decode.
- `flow:`: overloaded in a type-level matching library.
- The existing families: `check:*` is read-only (CI would run a state-mutating
command), `fix:*` reviews as a diff not a branch, `maintain:*` is advisory and
never a gate.
## Feedback tiers
The table and invocation rules are in
[CONTRIBUTING.md § Feedback tiers](../CONTRIBUTING.md#feedback-tiers); this
section explains the split.
#### Decision (2026-09)
Fast, offline, staged-file checks sit in pre-commit; whole-project test runs in
pre-push and `verify`; slow or network-bound scans under `maintain`.
#### Why
- `watch:*` runs until killed, in its own pane, so it is the earliest tier,
firing on save before staging or commit.
- `check:tsc` / `check:oxlint` / `check:oxfmt` / `check:cspell` are fast
(~0.2–0.5s each), offline, and scope to staged files via `LEFTHOOK_FILES`, so
pre-commit gives instant feedback on what you typed.
- `test` (and its `tsc`) runs the whole suite over the whole project, and the
staged-file convention does not apply to the test runner, so it belongs in
pre-push, after the commits exist but before the push leaves the machine.
#### Rejected
- `maintain:*` in `check` or pre-commit: advisory, whole-project and
network-bound scans are not correctness gates and would slow the fast tier.
- Treating a green pre-commit as the definition of done: it sees only staged
files, hence `npm run verify`.
- A separate `git push` hook for `verify`: the pre-push test tier already covers
it.
## Commit messages
The convention is in
[CONTRIBUTING.md § Commit messages](../CONTRIBUTING.md#commit-messages).
Examples: `:sparkles: Add watch tier with watch:test child`,
`:recycle: Move type-aware config to .oxlintrc.json; use source-level disable
directives`, `:memo: Restore unique maintainer content as CONTRIBUTING.md`. The
body explains what and why, not how; link issues with `Resolves #...`.
#### Decision (2026-09)
Gitmoji subjects, imperative mood, wrapped 50/72, not Conventional Commits.
#### Why
- The history is gitmoji and predates any commit-lint tooling; switching would
rewrite the convention for no gain.
- The body carries the reasoning a reviewer needs; the subject is a signpost,
not a semantic key.
#### Rejected
- Conventional Commits: the release flow uses hand-written Keep-a-Changelog
notes, not generated ones, so the prefix has no automation value here (see
[publishing.md](./publishing.md)).
+179 -128
View File
@@ -1,22 +1,25 @@
{
"name": "tiny-pattern-ts",
"version": "0.1.5",
"version": "0.4.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "tiny-pattern-ts",
"version": "0.1.5",
"version": "0.4.0",
"license": "MIT",
"dependencies": {
"type-fest": "^5.9.0"
},
"devDependencies": {
"@arethetypeswrong/cli": "^0.18.5",
"@runwisp/pubv": "^1.5.1",
"@tsconfig/node26": "^26.0.1",
"@tsconfig/strictest": "^2.0.8",
"@types/node": "^26.4.1",
"@types/node": "^26.6.1",
"c8": "^12.0.0",
"check-outdated": "^3.0.0",
"cspell": "^10.2.2",
"cspell": "^10.3.2",
"expect-type": "1.4.0",
"knip": "^6.34.0",
"lefthook": "^2.1.12",
@@ -24,7 +27,8 @@
"oxlint": "^1.83.0",
"oxlint-tsgolint": "^7.0.2001",
"publint": "^0.3.24",
"typescript": "^7.0.2"
"typescript": "^7.0.2",
"vscode-languageserver-protocol": "^3.18.3"
},
"engines": {
"node": ">=26"
@@ -122,9 +126,9 @@
}
},
"node_modules/@cspell/cspell-bundled-dicts": {
"version": "10.3.1",
"resolved": "https://registry.npmjs.org/@cspell/cspell-bundled-dicts/-/cspell-bundled-dicts-10.3.1.tgz",
"integrity": "sha512-wHRmqaHrhmPHxsnYs9JhYCwD+lLsIOltbvaK9XfIkisblfBCjvsSihsJZhVEvSQ2pPNqESUaOQnBBeVbqv52qA==",
"version": "10.3.2",
"resolved": "https://registry.npmjs.org/@cspell/cspell-bundled-dicts/-/cspell-bundled-dicts-10.3.2.tgz",
"integrity": "sha512-b0f6fQCwcTP32dsibbPbdU2m5R0ZAwAVaB1u04187OqfRBJxr2m77bAXa50xSsjonRILLAGzxgT4+HkPojIBpA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -193,22 +197,22 @@
}
},
"node_modules/@cspell/cspell-json-reporter": {
"version": "10.3.1",
"resolved": "https://registry.npmjs.org/@cspell/cspell-json-reporter/-/cspell-json-reporter-10.3.1.tgz",
"integrity": "sha512-wdM5pdIDIDn5PHxEMU0uhu3Gxgpqp6Y0MD65eGr+sFXSxXyWwc3U0F1ghxsYCVIKfiF71tKRwtuKnMQJLXPNKw==",
"version": "10.3.2",
"resolved": "https://registry.npmjs.org/@cspell/cspell-json-reporter/-/cspell-json-reporter-10.3.2.tgz",
"integrity": "sha512-X4K1lWhrf2NUtLfX+9LrZPyN1XoYv0jzAyzME7qs/LFtmZlPnjlFjLfh2gO48bYJWnd9WN0AzzEzwOMD331l2A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@cspell/cspell-types": "10.3.1"
"@cspell/cspell-types": "10.3.2"
},
"engines": {
"node": ">=22.18.0"
}
},
"node_modules/@cspell/cspell-performance-monitor": {
"version": "10.3.1",
"resolved": "https://registry.npmjs.org/@cspell/cspell-performance-monitor/-/cspell-performance-monitor-10.3.1.tgz",
"integrity": "sha512-zSFk/7YxYUCoxCxunigiW/q0U9XsIYNQJy6+bYaVYOT6/3WMOHZkzWffxAD8WNzzMd61QFpTb2FrrzudEhY/Yw==",
"version": "10.3.2",
"resolved": "https://registry.npmjs.org/@cspell/cspell-performance-monitor/-/cspell-performance-monitor-10.3.2.tgz",
"integrity": "sha512-n2/nXk4tOHzQDbh9X5yTuofQDprZTywrYow8Fua0Gy9gyanmRShTSpnooOMk8SraIuRhIPNm98ge2XNgQrsH7Q==",
"dev": true,
"license": "MIT",
"engines": {
@@ -216,9 +220,9 @@
}
},
"node_modules/@cspell/cspell-pipe": {
"version": "10.3.1",
"resolved": "https://registry.npmjs.org/@cspell/cspell-pipe/-/cspell-pipe-10.3.1.tgz",
"integrity": "sha512-ErGssy4KPaI+PWbc92attUz4/hYcJW05c94tgzhiaEUumgrCMqV64sJlFyTh3ckMhAh9pPg2Kp+qpmS634pTwQ==",
"version": "10.3.2",
"resolved": "https://registry.npmjs.org/@cspell/cspell-pipe/-/cspell-pipe-10.3.2.tgz",
"integrity": "sha512-Iyy5aC6kgymCXwQkV56LJAbkVl8mfEVoyTgfi3Nv72CWvaPo7tStYdmyqFNs2oj3jpVArc8gRoi6uztV8wJ9rQ==",
"dev": true,
"license": "MIT",
"engines": {
@@ -226,9 +230,9 @@
}
},
"node_modules/@cspell/cspell-resolver": {
"version": "10.3.1",
"resolved": "https://registry.npmjs.org/@cspell/cspell-resolver/-/cspell-resolver-10.3.1.tgz",
"integrity": "sha512-m072aE8NesdbSt8wieWJiCZOzSekzorsaXFxetkTFI2JIM7fqd+wvCtasLYY1CUbhjGdHOjTQ5GxXL6Z4rzCbA==",
"version": "10.3.2",
"resolved": "https://registry.npmjs.org/@cspell/cspell-resolver/-/cspell-resolver-10.3.2.tgz",
"integrity": "sha512-StPrbknIKYOmgKGUYb1YLFnOR309aG/Rvo8RbNBKCBtvRV9dDynwioK1H712uva3B/+8EDjW8bOduM8ZIrovyw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -239,9 +243,9 @@
}
},
"node_modules/@cspell/cspell-service-bus": {
"version": "10.3.1",
"resolved": "https://registry.npmjs.org/@cspell/cspell-service-bus/-/cspell-service-bus-10.3.1.tgz",
"integrity": "sha512-Ha+H8SOBFA0QeWjYc9qY2/Jm41PGpmFjGqgOGZtI8BiJ5DTPLQpn2rPAxE6IKNUvtNE1kkknkbhsWVe/a14wkw==",
"version": "10.3.2",
"resolved": "https://registry.npmjs.org/@cspell/cspell-service-bus/-/cspell-service-bus-10.3.2.tgz",
"integrity": "sha512-WyBH2Ek3+viH/B3adXKUYH8mKqXx7qjUI3aro8Ci4KbCIqllRmfQisx4WnaW9b9Y471+xy4FCGRC77Qdclnewg==",
"dev": true,
"license": "MIT",
"engines": {
@@ -249,9 +253,9 @@
}
},
"node_modules/@cspell/cspell-types": {
"version": "10.3.1",
"resolved": "https://registry.npmjs.org/@cspell/cspell-types/-/cspell-types-10.3.1.tgz",
"integrity": "sha512-c0ta73Z2KQdvOqlCWNCi9GYQSw/IUFm0R0FpNOaPZn3cjc6J6pMBd4IwD07TsOXMduCrmf/Ppg6gzcyviGHeAQ==",
"version": "10.3.2",
"resolved": "https://registry.npmjs.org/@cspell/cspell-types/-/cspell-types-10.3.2.tgz",
"integrity": "sha512-Ld9OiU4PhsaYFZ2AcnfqB+YA1piyMf/C9rASQBQrEEcB9n/2Hbmd3gSA1KF29FGyfuAJBrigt7N/GTSSUZVrWw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -259,13 +263,13 @@
}
},
"node_modules/@cspell/cspell-worker": {
"version": "10.3.1",
"resolved": "https://registry.npmjs.org/@cspell/cspell-worker/-/cspell-worker-10.3.1.tgz",
"integrity": "sha512-mg4J9ia14qNoP8oqSBFU8BOq/VoI0s6xPdsw70oiDvCfxMePoe5PvcnrGNjWNHAdoOeMuFNgjpK5vl/CSjaLFQ==",
"version": "10.3.2",
"resolved": "https://registry.npmjs.org/@cspell/cspell-worker/-/cspell-worker-10.3.2.tgz",
"integrity": "sha512-YUGOI5u4aR/1Ld069mBVhi0/rDQrhmqg0TQgKy+PUhUcR4LdKZ/PFhA+3NObyZy8k2lPYdpbAYf27zuGA39HFA==",
"dev": true,
"license": "MIT",
"dependencies": {
"cspell-lib": "10.3.1"
"cspell-lib": "10.3.2"
},
"engines": {
"node": ">=22.18.0"
@@ -697,13 +701,13 @@
"license": "MIT"
},
"node_modules/@cspell/dynamic-import": {
"version": "10.3.1",
"resolved": "https://registry.npmjs.org/@cspell/dynamic-import/-/dynamic-import-10.3.1.tgz",
"integrity": "sha512-4OlYzARVBPlPjc1hsixRJObNhnzmBQOXuMjCmlLa5CiAB2y9fhVKN8EIZMSph/Tboo+LzCxUpOmklSwb1NqYRQ==",
"version": "10.3.2",
"resolved": "https://registry.npmjs.org/@cspell/dynamic-import/-/dynamic-import-10.3.2.tgz",
"integrity": "sha512-n7vpABAl6msXh5Li4dxBdA2n0ux3sCznb/F4ysEzyRJJ4gHXmC75fTeeDQbzdERxwz7bSc8K3nqlDiebyLq9aw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@cspell/url": "10.3.1",
"@cspell/url": "10.3.2",
"import-meta-resolve": "^4.2.0"
},
"engines": {
@@ -711,9 +715,9 @@
}
},
"node_modules/@cspell/filetypes": {
"version": "10.3.1",
"resolved": "https://registry.npmjs.org/@cspell/filetypes/-/filetypes-10.3.1.tgz",
"integrity": "sha512-R7pQUshSh78yWEb5SSdeKlQBamYn15BpU3EEUHKXFZq70PvixXatVtygFBHawdk8YjSAVQTT+G5Rx4LOv/3QLQ==",
"version": "10.3.2",
"resolved": "https://registry.npmjs.org/@cspell/filetypes/-/filetypes-10.3.2.tgz",
"integrity": "sha512-6dunxj9bNfVR1l8yuW5tU165l8wYzQZJQAnunonnYTdk6zeguu2odpaKMebS/Mo2PuGknhhz53KBJzVBEQZFHQ==",
"dev": true,
"license": "MIT",
"engines": {
@@ -721,9 +725,9 @@
}
},
"node_modules/@cspell/rpc": {
"version": "10.3.1",
"resolved": "https://registry.npmjs.org/@cspell/rpc/-/rpc-10.3.1.tgz",
"integrity": "sha512-3MJ62tcb27JM/Znpg9OE97iWHh0QDQsUZV15imAw2326yGAG7oyFEIyDDvhbJLQTfogs0oYdH7c5A4B/VfBIZg==",
"version": "10.3.2",
"resolved": "https://registry.npmjs.org/@cspell/rpc/-/rpc-10.3.2.tgz",
"integrity": "sha512-5TZJvPhNo8KhjFYWAlg7pX3+RnQsrLUWE3OdSsfQ6yAbJGP9SulPHjzr7wapGE/jhPVH3vg9OlZhO6o1lRKISA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -731,9 +735,9 @@
}
},
"node_modules/@cspell/strong-weak-map": {
"version": "10.3.1",
"resolved": "https://registry.npmjs.org/@cspell/strong-weak-map/-/strong-weak-map-10.3.1.tgz",
"integrity": "sha512-Bwe3+1GyKN1BcfdDsWW53ujWxUqn6onNAKJ6hIXaZCTAS/ZLMcoYP7fJQko/syEGerOvbcSgdCkXfahrM2dRfA==",
"version": "10.3.2",
"resolved": "https://registry.npmjs.org/@cspell/strong-weak-map/-/strong-weak-map-10.3.2.tgz",
"integrity": "sha512-P/QVqEzKedoRAOcQnAO23BvYdK47K4T0h3iSZE411EgAFG2RJSL2YNXyIhMe86XkK20ePXnh91/xtoC6oST7PQ==",
"dev": true,
"license": "MIT",
"engines": {
@@ -741,9 +745,9 @@
}
},
"node_modules/@cspell/url": {
"version": "10.3.1",
"resolved": "https://registry.npmjs.org/@cspell/url/-/url-10.3.1.tgz",
"integrity": "sha512-S2+Sr9LwCrUATnODo8fdyF6NfJZYAmYmA0HaJ40OGEsOwi17T5aa1jHfw62RWyZYMkxFLcL7ABeFHnWDgI784A==",
"version": "10.3.2",
"resolved": "https://registry.npmjs.org/@cspell/url/-/url-10.3.2.tgz",
"integrity": "sha512-7KG8iDDIckYf6CSCn9CoHzxABREOSA2c8pLQz4lEBJcIL4NOZbD8O7Zw69rRQ2nDIDPAbHeu+6KAr86EQJ7lUQ==",
"dev": true,
"license": "MIT",
"engines": {
@@ -2362,9 +2366,9 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "26.5.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.5.1.tgz",
"integrity": "sha512-CzNm2FezW4VR/LjG6yUdiEgLE/rAQ9Slj5gCu/C2VrdcW7I0ahNZ8DRbHT7zOZ6r3ONgd/bsQIeSaoDGrd1C6g==",
"version": "26.6.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.1.tgz",
"integrity": "sha512-VqGJBMCtdhqkBUCcBLvywI0NJ+KLuVzgNnlBUNFOQjqVxzo2lxLUNg1DSey8+u2u6ktswSAxg+s68QLzWHNOuA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -3123,30 +3127,29 @@
}
},
"node_modules/cspell": {
"version": "10.3.1",
"resolved": "https://registry.npmjs.org/cspell/-/cspell-10.3.1.tgz",
"integrity": "sha512-fd16436V8lEQ2UT8gAwcXrgfB8k5qpIhFC4Zo753v/JA1eL9ZlCDYCMefAPKVq1CfFpImsTfxxeGqscaYJ/ISA==",
"version": "10.3.2",
"resolved": "https://registry.npmjs.org/cspell/-/cspell-10.3.2.tgz",
"integrity": "sha512-r4G/4rjaFe/ASCX43+3T4sKJQQzbiyBzWQhTl+kjcEfDYZsG++Ut5dXeXVld364QrGOqry3KQbvwUijci4vrIA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@cspell/cspell-json-reporter": "10.3.1",
"@cspell/cspell-performance-monitor": "10.3.1",
"@cspell/cspell-pipe": "10.3.1",
"@cspell/cspell-types": "10.3.1",
"@cspell/cspell-worker": "10.3.1",
"@cspell/dynamic-import": "10.3.1",
"@cspell/url": "10.3.1",
"@cspell/cspell-json-reporter": "10.3.2",
"@cspell/cspell-performance-monitor": "10.3.2",
"@cspell/cspell-pipe": "10.3.2",
"@cspell/cspell-types": "10.3.2",
"@cspell/cspell-worker": "10.3.2",
"@cspell/dynamic-import": "10.3.2",
"@cspell/url": "10.3.2",
"ansi-regex": "^6.3.0",
"chalk": "^6.0.0",
"chalk-template": "^1.1.2",
"commander": "^15.0.0",
"cspell-config-lib": "10.3.1",
"cspell-dictionary": "10.3.1",
"cspell-gitignore": "10.3.1",
"cspell-glob": "10.3.1",
"cspell-io": "10.3.1",
"cspell-lib": "10.3.1",
"fast-json-stable-stringify": "^2.1.0",
"cspell-config-lib": "10.3.2",
"cspell-dictionary": "10.3.2",
"cspell-gitignore": "10.3.2",
"cspell-glob": "10.3.2",
"cspell-io": "10.3.2",
"cspell-lib": "10.3.2",
"flatted": "^3.4.4",
"semver": "^7.8.5",
"tinyglobby": "^0.2.17"
@@ -3163,13 +3166,13 @@
}
},
"node_modules/cspell-config-lib": {
"version": "10.3.1",
"resolved": "https://registry.npmjs.org/cspell-config-lib/-/cspell-config-lib-10.3.1.tgz",
"integrity": "sha512-cSZEEhXqTjvoeohRW9ZhYziOTxApN9roHWKaGslsS9UGWbY4eOBO6FikDL1r/2PQ0rGLMu570XTEJYMmFlHraw==",
"version": "10.3.2",
"resolved": "https://registry.npmjs.org/cspell-config-lib/-/cspell-config-lib-10.3.2.tgz",
"integrity": "sha512-ukPSVWkiNaiwW26yERfs2+o4rHZ5+YG9uSEqiTqRTcCTrItopyNTizurI+H3pzgrGwcZwPwxNsRZy9oKyOqfjQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@cspell/cspell-types": "10.3.1",
"@cspell/cspell-types": "10.3.2",
"comment-json": "^5.0.0",
"smol-toml": "^1.8.0",
"yaml": "^2.9.0"
@@ -3179,16 +3182,16 @@
}
},
"node_modules/cspell-dictionary": {
"version": "10.3.1",
"resolved": "https://registry.npmjs.org/cspell-dictionary/-/cspell-dictionary-10.3.1.tgz",
"integrity": "sha512-IR4ApsnqSP0m+daR1lXjpTG4hzrH5Ow7E/yzgoAKKbP9OQRVgrpXC948aOVbB6JOC/Sk2BkWmeMlnJ3YBM3FFQ==",
"version": "10.3.2",
"resolved": "https://registry.npmjs.org/cspell-dictionary/-/cspell-dictionary-10.3.2.tgz",
"integrity": "sha512-KHjGUEGNm9jvDQL3CK1vkFgRMk2AJ/6NvWHM5sh8eGX6HBEWd0GAlpnL8087YRao70gToGw/IFxTqsSTktkraw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@cspell/cspell-performance-monitor": "10.3.1",
"@cspell/cspell-pipe": "10.3.1",
"@cspell/cspell-types": "10.3.1",
"cspell-trie-lib": "10.3.1",
"@cspell/cspell-performance-monitor": "10.3.2",
"@cspell/cspell-pipe": "10.3.2",
"@cspell/cspell-types": "10.3.2",
"cspell-trie-lib": "10.3.2",
"fast-equals": "^6.0.2"
},
"engines": {
@@ -3196,15 +3199,15 @@
}
},
"node_modules/cspell-gitignore": {
"version": "10.3.1",
"resolved": "https://registry.npmjs.org/cspell-gitignore/-/cspell-gitignore-10.3.1.tgz",
"integrity": "sha512-20I1vn8iZ4h5tVGzQA894uHaS6TPYADjTfP/ZDsSv7eQwgL+T/VQU6jDfveMg4FfGvCaSkRxFKoawa86B2UejA==",
"version": "10.3.2",
"resolved": "https://registry.npmjs.org/cspell-gitignore/-/cspell-gitignore-10.3.2.tgz",
"integrity": "sha512-AnfW9XMHOJ1BgEsapLn7VHV9KT+SuqtDEy0chQtHG/2ZcP2UOFJC8bHH35DUD2cYdBNhp5Dr6BHnvvCHgQE1+w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@cspell/url": "10.3.1",
"cspell-glob": "10.3.1",
"cspell-io": "10.3.1"
"@cspell/url": "10.3.2",
"cspell-glob": "10.3.2",
"cspell-io": "10.3.2"
},
"bin": {
"cspell-gitignore": "bin.mjs"
@@ -3214,13 +3217,13 @@
}
},
"node_modules/cspell-glob": {
"version": "10.3.1",
"resolved": "https://registry.npmjs.org/cspell-glob/-/cspell-glob-10.3.1.tgz",
"integrity": "sha512-w6DvrttxHpSYUYep7RzcIvTZbCtXovWkWVkuGw+RPLOSXbUEcoqDYg3EW9uTg57LIT3p5Jsop+VOTuJzQ8ahTw==",
"version": "10.3.2",
"resolved": "https://registry.npmjs.org/cspell-glob/-/cspell-glob-10.3.2.tgz",
"integrity": "sha512-fmyBeErzjF0Vtf8oGqlc76qsx257x7bUQ7hHB2SQUOJb50KOoZ+en+hfE2g1eAIPX0yEXqU65D9f6D83XaaiLw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@cspell/url": "10.3.1",
"@cspell/url": "10.3.2",
"picomatch": "^4.0.7"
},
"engines": {
@@ -3228,14 +3231,14 @@
}
},
"node_modules/cspell-grammar": {
"version": "10.3.1",
"resolved": "https://registry.npmjs.org/cspell-grammar/-/cspell-grammar-10.3.1.tgz",
"integrity": "sha512-GLtG5fzT4+o9FMqB2M10LT/hnA0iFEO4OmKl1+XMyDrK4rdFrqsBpVfc9Uyk+5AZ+nkR9/PudZBEkFpDDrpgNg==",
"version": "10.3.2",
"resolved": "https://registry.npmjs.org/cspell-grammar/-/cspell-grammar-10.3.2.tgz",
"integrity": "sha512-Dpi1PgyAk9fYKfJqHHWxBL51ESWR8EMn6NodplYcx8i0UVr8rCcUrISq9+yhgdsi0R547lxe3iufUBdaP1RiFg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@cspell/cspell-pipe": "10.3.1",
"@cspell/cspell-types": "10.3.1"
"@cspell/cspell-pipe": "10.3.2",
"@cspell/cspell-types": "10.3.2"
},
"bin": {
"cspell-grammar": "bin.mjs"
@@ -3245,42 +3248,42 @@
}
},
"node_modules/cspell-io": {
"version": "10.3.1",
"resolved": "https://registry.npmjs.org/cspell-io/-/cspell-io-10.3.1.tgz",
"integrity": "sha512-LGC739gKC/wzoCrLFl+F0F/wQQZNATzvMoIlFk7acr+xGrRVJUUudxh0Fh0yCBnYXtCGhbSUxLgg8eHgFScAag==",
"version": "10.3.2",
"resolved": "https://registry.npmjs.org/cspell-io/-/cspell-io-10.3.2.tgz",
"integrity": "sha512-0tY0wMPHvGN0G1DG+NLlJNEcRzY2ycZwWOpz6p/zgaBimEanLY+NJjoOsYPBAhzXhFbDdamsttL0bBPoJKYJPA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@cspell/cspell-service-bus": "10.3.1",
"@cspell/url": "10.3.1"
"@cspell/cspell-service-bus": "10.3.2",
"@cspell/url": "10.3.2"
},
"engines": {
"node": ">=22.18.0"
}
},
"node_modules/cspell-lib": {
"version": "10.3.1",
"resolved": "https://registry.npmjs.org/cspell-lib/-/cspell-lib-10.3.1.tgz",
"integrity": "sha512-msDAL+F0suXMTFj6OCWPmeBUNUe+19Bb1yPtOkbGVBU65K7sKHfwjCt8ev3iTdhhIKi8hIaq6xmJShBb1vznmw==",
"version": "10.3.2",
"resolved": "https://registry.npmjs.org/cspell-lib/-/cspell-lib-10.3.2.tgz",
"integrity": "sha512-svq9qWoMY58/BdyEQwY1YJhDKrmE8/z4VIiwvtgNkxV/km/6wPTNvm2iQNI4VjSa+DWYSvHyX16LwNjrcGp0sw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@cspell/cspell-bundled-dicts": "10.3.1",
"@cspell/cspell-performance-monitor": "10.3.1",
"@cspell/cspell-pipe": "10.3.1",
"@cspell/cspell-resolver": "10.3.1",
"@cspell/cspell-types": "10.3.1",
"@cspell/dynamic-import": "10.3.1",
"@cspell/filetypes": "10.3.1",
"@cspell/rpc": "10.3.1",
"@cspell/strong-weak-map": "10.3.1",
"@cspell/url": "10.3.1",
"cspell-config-lib": "10.3.1",
"cspell-dictionary": "10.3.1",
"cspell-glob": "10.3.1",
"cspell-grammar": "10.3.1",
"cspell-io": "10.3.1",
"cspell-trie-lib": "10.3.1",
"@cspell/cspell-bundled-dicts": "10.3.2",
"@cspell/cspell-performance-monitor": "10.3.2",
"@cspell/cspell-pipe": "10.3.2",
"@cspell/cspell-resolver": "10.3.2",
"@cspell/cspell-types": "10.3.2",
"@cspell/dynamic-import": "10.3.2",
"@cspell/filetypes": "10.3.2",
"@cspell/rpc": "10.3.2",
"@cspell/strong-weak-map": "10.3.2",
"@cspell/url": "10.3.2",
"cspell-config-lib": "10.3.2",
"cspell-dictionary": "10.3.2",
"cspell-glob": "10.3.2",
"cspell-grammar": "10.3.2",
"cspell-io": "10.3.2",
"cspell-trie-lib": "10.3.2",
"env-paths": "^4.0.0",
"gensequence": "^8.0.8",
"import-fresh": "^4.0.0",
@@ -3294,16 +3297,16 @@
}
},
"node_modules/cspell-trie-lib": {
"version": "10.3.1",
"resolved": "https://registry.npmjs.org/cspell-trie-lib/-/cspell-trie-lib-10.3.1.tgz",
"integrity": "sha512-6mJGGPEtD4CMoq9qxmRIa+XB7BgLPTgDSGFn1FluNbhF/x3VFeSErcTO+lcfqQhAdAZvEIqptzKt/m6CJJUUPg==",
"version": "10.3.2",
"resolved": "https://registry.npmjs.org/cspell-trie-lib/-/cspell-trie-lib-10.3.2.tgz",
"integrity": "sha512-YYberpBG16hLviQHLkciXjtqEUc6Q7f9UL71GlJAflSD9TYFuXIvhpIYV80YXPyDrh2Jk3ZYeUqWLQmpZjKaeQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=22.18.0"
},
"peerDependencies": {
"@cspell/cspell-types": "10.3.1"
"@cspell/cspell-types": "10.3.2"
}
},
"node_modules/cspell/node_modules/chalk": {
@@ -3416,13 +3419,6 @@
"node": ">=6.0.0"
}
},
"node_modules/fast-json-stable-stringify": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
"integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
"dev": true,
"license": "MIT"
},
"node_modules/fd-package-json": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/fd-package-json/-/fd-package-json-2.0.0.tgz",
@@ -4630,6 +4626,18 @@
"url": "https://github.com/chalk/supports-hyperlinks?sponsor=1"
}
},
"node_modules/tagged-tag": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz",
"integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==",
"license": "MIT",
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/test-exclude": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-8.0.0.tgz",
@@ -4713,6 +4721,21 @@
"license": "0BSD",
"optional": true
},
"node_modules/type-fest": {
"version": "5.9.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.9.0.tgz",
"integrity": "sha512-yANm3Jr3GiJ1qgJlxGAVxTOIcEOk1rhQHamlXtnrCK7EHP4HeM9OGxtMg/W7HFdrVzw/ZWJKGVIJusVH85sLtw==",
"license": "(MIT OR CC0-1.0)",
"dependencies": {
"tagged-tag": "^1.0.0"
},
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/typescript": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
@@ -4800,6 +4823,27 @@
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
}
},
"node_modules/vscode-jsonrpc": {
"version": "9.0.2",
"resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-9.0.2.tgz",
"integrity": "sha512-SbQSV9yRemARxeXw6LU5sS6Zq0e9/DgCCX5yelH263ZQWukbTk8EF8fjTrr1dziasf4GwlJbvTwFnTrnQFWZXQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/vscode-languageserver-protocol": {
"version": "3.18.3",
"resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.18.3.tgz",
"integrity": "sha512-DF49+WeV5py4zO5hhobp60jjsDSK0lAqA0OuKBLBvp423HPWQcCbhZz3JgyfIewsEz2f8U+X75xNIFHdiXZm2w==",
"dev": true,
"license": "MIT",
"dependencies": {
"vscode-jsonrpc": "9.0.2",
"vscode-languageserver-types": "3.18.3"
}
},
"node_modules/vscode-languageserver-textdocument": {
"version": "1.0.14",
"resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.14.tgz",
@@ -4807,6 +4851,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/vscode-languageserver-types": {
"version": "3.18.3",
"resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.18.3.tgz",
"integrity": "sha512-XIlzJ7Qp/jzSI1ds7/FwPAWrPeTZA7pAtlW4hdJ1J6xXWJL6dR9QYnDhJOdLzdKhUQ5Mm6mvUMw+3DcOQQasPw==",
"dev": true,
"license": "MIT"
},
"node_modules/vscode-uri": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.2.0.tgz",
+12 -5
View File
@@ -1,6 +1,6 @@
{
"name": "tiny-pattern-ts",
"version": "0.1.5",
"version": "0.4.0",
"description": "Pattern matching for TypeScript/ESM environments (F#-style, not regex)",
"keywords": [
"adt",
@@ -27,6 +27,9 @@
],
"type": "module",
"sideEffects": false,
"imports": {
"#test-utils/*": "./src/util/__tests__/*"
},
"exports": {
".": {
"types": "./dist/index.d.ts",
@@ -53,7 +56,7 @@
"create:release": "./scripts/release.sh",
"maintain": "npm run maintain:knip; npm run maintain:outdated",
"maintain:knip": "knip --include dependencies,exports,files",
"maintain:outdated": "check-outdated --ignore-pre-releases",
"maintain:outdated": "check-outdated --ignore-pre-releases --ignore-packages @types/node",
"test": "npm run check:tsc && node --test --strip-types \"src/**/*.test.ts\"",
"test:ci": "c8 --reporter=text --reporter=lcov --reporter=html node --test --strip-types \"src/**/*.test.ts\"",
"test:unit": "node --test --strip-types \"src/**/*.test.ts\"",
@@ -65,15 +68,18 @@
"setup": "npm run setup:git-commit-message",
"setup:git-commit-message": "git config commit.template commit-message-template"
},
"dependencies": {
"type-fest": "^5.9.0"
},
"devDependencies": {
"@arethetypeswrong/cli": "^0.18.5",
"@runwisp/pubv": "^1.5.1",
"@tsconfig/node26": "^26.0.1",
"@tsconfig/strictest": "^2.0.8",
"@types/node": "^26.4.1",
"@types/node": "^26.6.1",
"c8": "^12.0.0",
"check-outdated": "^3.0.0",
"cspell": "^10.2.2",
"cspell": "^10.3.2",
"expect-type": "1.4.0",
"knip": "^6.34.0",
"lefthook": "^2.1.12",
@@ -81,7 +87,8 @@
"oxlint": "^1.83.0",
"oxlint-tsgolint": "^7.0.2001",
"publint": "^0.3.24",
"typescript": "^7.0.2"
"typescript": "^7.0.2",
"vscode-languageserver-protocol": "^3.18.3"
},
"engines": {
"node": ">=26"
+16 -44
View File
@@ -4,43 +4,13 @@ set -eu
# Branch front-door. Run as `npm run create:branch -- <prefix>/<desc>`.
#
# How we got here (short): the branching model says every change starts from a
# clean, current `main`, and the type-driven loop only produces trustworthy
# results if the baseline was green *before* the first edit. Both facts were
# prose. Prose rots silently — a rule nobody checks is a suggestion — so the
# precondition became this script: it asserts, then branches, and the branch
# only appears if the assertions passed. Cheap checks run first, `npm run test`
# runs last: the expensive gate is not paid on a tree that was never eligible.
# Asserts the branch preconditions — clean tree, no in-progress operation,
# current `main`, green baseline — and only then creates the branch, so the
# expensive `npm run test` is not paid on a tree that was never eligible.
#
# Rejected for the prefix name: `run:` / `perform:` (both mean only "do the
# thing named after them", so every script in the repo would fit under them and
# the taxonomy collapses); `git:` (names the tool, not the lifecycle moment, and
# advertises passthrough aliases); `start:` (describes this half, not the
# release); `cut:` (idiomatic for both, but it needs VCS slang to decode, and a
# signpost that has to be explained is not one); `flow:` (overloaded in a library
# about type-level matching); and the existing families — `check:*` is read-only
# and aggregated by `check`, so CI would run a command that mutates repo state;
# `fix:*`'s review surface is a file diff, not a branch; `maintain:*` is advisory
# and explicitly never a gate.
#
# `create:` was kept because both members really do create something: a branch,
# a release. It was added to both prefix lists in CONTRIBUTING.md in the same
# commit as its first members, because a prefix missing from those lists is
# invisible — which was the `use:` mistake this repo carried in backlog.tasks (since retired into `setup:`).
# There is deliberately no bare `create` aggregator: "run all the workflows"
# describes nothing anyone wants, and `publish:*` already sets the precedent for
# a prefix without one.
#
# Also rejected here: reusing `pubv`'s preflight (release-shaped, third-party,
# and it would make branch start pay a build + pack it has no use for). The
# merge half was originally rejected too ("review the diff yourself" is
# judgment), but it now has its own front door — `create:finish` — which owns
# the merge-side preconditions and the post-merge `verify`, so the start half
# does not have to carry that burden.
#
# Every refusal is non-mutating except the baseline test, which runs on `main`
# after we switch there — so a red `main` restores the branch you started on
# rather than stranding you on it.
# Why the front door exists, the rejected prefix names, and the `create:`
# decision: development/workflow.md § Branching model and § Script prefix
# convention.
BASE="main"
PREFIXES="feature fix chore"
@@ -103,9 +73,9 @@ git show-ref --verify --quiet "refs/heads/${BASE}" || {
exit 1
}
# Derive the remote rather than hardcoding it: this repo has `origin` (ssh) and
# `origin_https`, and `main` tracks the latter — `git fetch origin main` would
# check currency against a ref that is never updated here.
# Derive the remote rather than hardcoding it: `main` tracks `origin` (ssh)
# here; a hardcoded name would check currency against a ref that may not
# exist on a differently configured clone.
# `--quiet` echoes the unresolved `main@{upstream}` literal to stdout on
# failure, so it cannot be paired with a `$(...) || fallback`; the non-quiet
# form prints nothing on failure and the fallback runs.
@@ -116,12 +86,14 @@ if [ -n "${UPSTREAM}" ]; then
echo " refusing to branch on a possibly stale '${BASE}'." >&2
exit 1
}
# Only *behind* is a problem. `create:finish` deliberately leaves the local
# merge on '${BASE}' until `create:release` pushes it, so being ahead is the
# normal state between a merge and the release that ships it; branching from
# those commits is intended. Missing remote commits is not.
BEHIND=$(git rev-list --count "${BASE}..${UPSTREAM}")
AHEAD=$(git rev-list --count "${UPSTREAM}..${BASE}")
if [ "${BEHIND}" -ne 0 ] || [ "${AHEAD}" -ne 0 ]; then
echo "error: '${BASE}' has diverged from '${UPSTREAM}' (ahead ${AHEAD}, behind ${BEHIND})." >&2
[ "${AHEAD}" -ne 0 ] && echo " not yet pushed commits on '${BASE}': push them, or rebase this work onto them." >&2
[ "${BEHIND}" -ne 0 ] && echo " update it: git switch ${BASE} && git pull --ff-only" >&2
if [ "${BEHIND}" -ne 0 ]; then
echo "error: '${BASE}' is behind '${UPSTREAM}' (by ${BEHIND})." >&2
echo " update it: git switch ${BASE} && git pull --ff-only" >&2
exit 1
fi
else
+7 -22
View File
@@ -4,29 +4,14 @@ set -eu
# Feature-finish front door. Run as `npm run create:finish`.
#
# Why this exists: `create:branch` opens a unit of work, but the close half
# (`git checkout main && git merge --no-ff <branch>`) stayed prose in the
# branching model, so it drifted per contributor and per session. This is the
# mirror image of `create:branch`: it asserts the same preconditions (clean
# tree, no in-progress operation, `main` matching its upstream), merges the
# current `feature/`/`fix/`/`chore/` branch into `main`, proves the result with
# `npm run verify`, and only then deletes the branch.
# Mirror image of `create:branch`: asserts the merge-side preconditions, merges
# the current `feature/`/`fix/`/`chore/` branch into `main` with `--no-ff`,
# proves the result with `npm run verify`, and only then deletes the branch. The
# push is owned by `create:release`, so the merge stays local and reviewable.
# On a conflict it aborts and returns to the feature branch.
#
# `create:branch`'s comment argued against wrapping the merge as "judgment —
# review the diff yourself". That judgment still lives here, just moved: the
# maintainer reviews the handover *before* invoking this, and the script only
# commits the merge, never the push. The push is owned by `create:release`, so
# the release commit and its tag leave together and a local merge stays
# reviewable (and can be reverted with `git revert -m 1`) until then. `--no-ff`
# keeps the unit of work visible in `git log`.
#
# Unlike `create:branch` a stale `main` is fast-forwarded instead of refused:
# the tree is clean (checked above) and `main` is not the checked-out branch
# yet, so there is no local state to lose. True divergence (local commits *and*
# upstream commits) is still refused — that needs a human.
#
# On a merge conflict we abort and return to the feature branch, so a failed
# finish never strands you on a half-merged `main`.
# Rationale and the rejected alternatives: development/workflow.md § Branching
# model.
BASE="main"
PREFIXES="feature fix chore"
+3
View File
@@ -9,6 +9,9 @@ import zlib from "node:zlib";
* matching `Accept-Encoding` and falls back to the original for the rest.
*
* Usage: node --strip-types scripts/precompress.ts <dir> [<dir>...]
*
* Why sidecars rather than per-request compression: development/ci.md § Coverage
* serving.
*/
/**
+2
View File
@@ -9,6 +9,8 @@ set -eu
# `v1.2.3` and `1.2.3` match the `## [1.2.3]` heading. Prints to stdout and
# exits non-zero when the tag has no section, so a release can never publish
# with an empty body.
#
# Why: development/publishing.md § Release notes are extracted from the changelog.
TAG="${1:-}"
CHANGELOG="${CHANGELOG:-CHANGELOG.md}"
+6 -22
View File
@@ -4,29 +4,13 @@ set -eu
# Release front-door. Run as `npm run create:release`.
#
# How we got here (short): we want hand-written Keep-a-Changelog notes, an
# [Unreleased] -> "## [x.y.z] - DATE" graduation, and a tag that marks the exact
# commit on main that gets published. No single tool did BOTH the [Unreleased]
# graduation AND the package.json bump. So split by strength: `pubv` (tiny,
# changelog-driven) owns preflight + the interactive major/minor/patch heuristic
# + graduating/committing CHANGELOG.md (no tag, no push); `npm version` syncs
# package.json + the lockfile; `--amend` folds them into pubv's single commit;
# tag AFTER the amend (so the tag is never orphaned) and push.
# Graduates the [Unreleased] changelog notes, bumps package.json + the lockfile,
# and commits then tags the exact SHA that CI publishes. The notes are finalized
# in VS Code before pubv because pubv's bump heuristic reads the [Unreleased]
# body.
#
# The notes are finalized in VS Code *before* pubv: the [Unreleased] body is
# what pubv's bump heuristic reads, so editing afterwards would inform the
# changelog only, not the version choice. pubv refuses a dirty tree, so that
# edit is committed as a staging commit and folded back into the single release
# commit below.
#
# Rejected: the conventional-commits family (our history is gitmoji, not
# Conventional; and we want hand-written notes); changesets/rtk (config + a
# heavier version/publish flow that fights our CI-only publish); knope/kacl/
# bestikk (changelog-only — don't bump package.json; plus 5yr/2yr/brand-new
# maintenance); pubv alone (verified it never writes package.json). We also
# tried `versions` (silverwind) — great Gitea support — but pairing it with a
# hand-rolled promote became a ~180-line script we'd have to maintain, which is
# exactly what this ~30-line version replaces.
# Tooling rationale, the rejected alternatives, and the version source of truth:
# development/publishing.md.
CHANGELOG="CHANGELOG.md"
BASE="main"
+3
View File
@@ -8,6 +8,9 @@ set -euo pipefail
# pulls the image by name.
#
# Usage: scripts/runner-image.sh [--push]
#
# Why the image is baked, its two invariants, and the coordinated Node-bump
# steps: development/ci.md.
IMAGE_REPO="gitea.e1nsnull.de/tmu/act-ci"
-56
View File
@@ -1,56 +0,0 @@
/* oxlint-disable typescript/no-floating-promises -- expectTypeOf() is a sync type-assertion library that the type-aware linter misidentifies as a promise */
import { strict as assert } from "node:assert";
import { test } from "node:test";
import { expectTypeOf } from "expect-type";
import { type Matcher, P, match } from "./index.ts";
test("match returns a builder", () => {
const builder = match("x");
expectTypeOf(builder).toHaveProperty("with");
expectTypeOf(builder).toHaveProperty("exhaustive");
expectTypeOf(builder).toHaveProperty("otherwise");
});
test("P.literal narrows to its literal type", () => {
const matcher = P.literal("yes");
expectTypeOf(matcher).toMatchTypeOf<Matcher<"yes">>();
assert.equal(matcher.matches("yes"), true);
assert.equal(matcher.matches("no"), false);
});
test("P.type narrows to the typeof target", () => {
const matcher = P.type<string>("string");
expectTypeOf(matcher).toMatchTypeOf<Matcher<string>>();
assert.equal(matcher.matches("hi"), true);
assert.equal(matcher.matches(42), false);
});
test("exhaustive() returns the union of handler return types", () => {
const result = match<"a" | "b">("a")
.with(P.literal("a"), () => 1 as const)
.with(P.literal("b"), () => "two" as const)
.exhaustive();
expectTypeOf(result).toEqualTypeOf<1 | "two">();
assert.equal(result, 1);
});
test("otherwise() falls back when no case matches", () => {
const result = match<"x" | "y" | "z">("z")
.with(P.literal("x"), (v): string => `got ${v}`)
.otherwise((v): string => `fallback ${v}`);
assert.equal(result, "fallback z");
});
test("exhaustive throws when no case matches", () => {
assert.throws(
() =>
match<"a" | "b" | "c">("c")
.with(P.literal("a"), () => "A")
.with(P.literal("b"), () => "B")
.exhaustive(),
/no matching case/,
);
});
+1 -2
View File
@@ -1,2 +1 @@
export { match, P } from "./match.ts";
export type { Matcher, Pattern } from "./pattern.ts";
export { getMatcher, getMatcherW } from "./primitive.ts";
-62
View File
@@ -1,62 +0,0 @@
import { P, type Matcher, type Pattern } from "./pattern.ts";
type Cases<R> = readonly (readonly [Matcher<unknown>, (value: unknown) => R])[];
interface MatchBuilder<T, R> {
with<U extends T, V>(
pattern: Matcher<U>,
handler: (value: U) => V,
): MatchBuilder<T, R | V>;
exhaustive(): R;
otherwise(handler: (value: T) => R): R;
}
const buildMatch = <T, R>(value: T, cases: Cases<R>): MatchBuilder<T, R> => {
const apply = (): R | undefined => {
for (const [matcher, handler] of cases) {
if (matcher.matches(value)) {
return handler(value);
}
}
return undefined;
};
const builder = {
with<U extends T, V>(
pattern: Matcher<U>,
handler: (value: U) => V,
): MatchBuilder<T, R | V> {
const nextCases: Cases<R | V> = [
...cases,
// oxlint-disable-next-line typescript/no-unsafe-type-assertion
[pattern, handler as (value: unknown) => R | V],
];
return buildMatch(value, nextCases);
},
exhaustive(): R {
const result = apply();
if (result === undefined) {
throw new Error(
"tiny-pattern-ts: match.exhaustive() called with no matching case",
);
}
return result;
},
otherwise(handler: (value: T) => R): R {
for (const [matcher, run] of cases) {
if (matcher.matches(value)) {
return run(value);
}
}
return handler(value);
},
};
return builder;
};
export const match = <T>(value: T): MatchBuilder<T, never> =>
buildMatch<T, never>(value, []);
export type { Matcher, Pattern };
export { P };
-105
View File
@@ -1,105 +0,0 @@
/**
* Pattern matching primitives. Each constructor returns a lightweight
* matcher object whose `matches` method returns a type guard.
*/
export interface Matcher<T> {
readonly matches: (value: unknown) => value is T;
}
const literalMatcher = <
const L extends string | number | boolean | null | undefined,
>(
value: L,
): Matcher<L> => ({
matches: (candidate): candidate is L => candidate === value,
});
const typeMatcher = <T>(
type:
| "string"
| "number"
| "boolean"
| "bigint"
| "symbol"
| "undefined"
| "object"
| "function",
): Matcher<T> => {
const matches = (value: unknown): value is T => {
if (type === "undefined") {
return value === undefined;
}
if (type === "object") {
return (
(typeof value === "object" && value !== null) ||
typeof value === "function"
);
}
return typeof value === type;
};
return { matches };
},
whenMatcher = <T>(
predicate: (value: unknown) => value is T,
): Matcher<T> => ({
matches: predicate,
}),
whenMatcherAny = <T>(
predicate: (value: unknown) => boolean,
): Matcher<T> => ({
matches: (value: unknown): value is T => predicate(value),
}),
isNestedMatcher = (expected: unknown): expected is Matcher<unknown> =>
typeof expected === "object" &&
expected !== null &&
"matches" in expected,
// oxlint-disable-next-line typescript/no-unnecessary-type-parameters
keysMatch = <S extends object>(shape: S, candidate: object): boolean => {
// oxlint-disable-next-line typescript/no-unsafe-type-assertion
for (const key of Object.keys(shape) as (keyof S)[]) {
if (!(key in candidate)) {
return false;
}
// oxlint-disable-next-line typescript/no-unsafe-type-assertion
const expected = shape[key],
// oxlint-disable-next-line typescript/no-unsafe-type-assertion
actual = candidate[key as keyof object];
if (isNestedMatcher(expected)) {
if (!expected.matches(actual)) {
return false;
}
} else if (actual !== expected) {
return false;
}
}
return true;
},
structuralMatcher = <S extends object, T extends S>(
shape: S,
refine?: (value: S) => value is T,
): Matcher<T> => ({
matches: (value: unknown): value is T => {
if (typeof value !== "object" || value === null) {
return false;
}
// oxlint-disable-next-line typescript/no-unsafe-type-assertion
const candidate = value as S;
if (!keysMatch(shape, candidate)) {
return false;
}
if (refine && !refine(candidate)) {
return false;
}
return true;
},
});
export const P = {
literal: literalMatcher,
type: typeMatcher,
when: whenMatcher,
any: whenMatcherAny,
shape: structuralMatcher,
} as const;
export type Pattern<T> = Matcher<T>;
+491
View File
@@ -0,0 +1,491 @@
/* oxlint-disable typescript/no-floating-promises -- expectTypeOf() is a sync type-assertion library that the type-aware linter misidentifies as a promise */
import { strict as assert } from "node:assert";
import path from "node:path";
import { test } from "node:test";
import { expectTypeOf } from "expect-type";
import {
type CompletionTarget,
LspSession,
} from "#test-utils/lsp-completion.ts";
import { getMatcher, getMatcherW } from "./primitive.ts";
// ============================================================================
// API: getMatcher — ✔️ Exhaustive / ✔️ ReturnsStrict
// ============================================================================
test("getMatcher: exhaustive pattern infers one common return type", () => {
// Arrange
const factory = getMatcher<"a" | "b">();
// Act
const matcher = factory({
a: (s) => {
expectTypeOf(s).toEqualTypeOf<"a">();
return 1;
},
b: (s) => {
expectTypeOf(s).toEqualTypeOf<"b">();
return 2;
},
});
// Assert
// ✔️ ReturnsStrict: R is the best common return type, not a widening union.
expectTypeOf(matcher).toEqualTypeOf<(shape: "a" | "b") => number>();
// ❌ a value outside T must not be accepted by the matcher
expectTypeOf<"c">().not.toExtend<Parameters<typeof matcher>[0]>();
assert.equal(matcher("a"), 1);
assert.equal(matcher("b"), 2);
});
test("getMatcher: dispatches on numeric literal keys", () => {
// Arrange
const factory = getMatcher<1 | 2>();
// Act
const matcher = factory({
1: (n) => {
expectTypeOf(n).toEqualTypeOf<1>();
return n + 1;
},
2: (n) => {
expectTypeOf(n).toEqualTypeOf<2>();
return n * 10;
},
});
// Assert
expectTypeOf(matcher).toEqualTypeOf<(shape: 1 | 2) => number>();
assert.equal(matcher(1), 2);
assert.equal(matcher(2), 20);
});
test("getMatcher: dispatches on mixed string and numeric keys", () => {
// Arrange
const factory = getMatcher<"a" | "b" | 1 | 2>();
// Act
const matcher = factory({
a: (s) => {
expectTypeOf(s).toEqualTypeOf<"a">();
return 1;
},
b: (s) => {
expectTypeOf(s).toEqualTypeOf<"b">();
return 2;
},
1: (n) => {
expectTypeOf(n).toEqualTypeOf<1>();
return 10;
},
2: (n) => {
expectTypeOf(n).toEqualTypeOf<2>();
return 20;
},
});
// Assert
expectTypeOf(matcher).toEqualTypeOf<(shape: "a" | "b" | 1 | 2) => number>();
assert.equal(matcher("a"), 1);
assert.equal(matcher("b"), 2);
assert.equal(matcher(1), 10);
assert.equal(matcher(2), 20);
});
// ============================================================================
// API: getMatcher — ❌ Exhaustive (fallback) / ✔️ ReturnsStrict
// ============================================================================
test("getMatcher: a `_` fallback receives the unhandled keys", () => {
// Arrange
const factory = getMatcher<"a" | "b" | "c">();
// Act
const matcher = factory(
{
a: (s) => {
expectTypeOf(s).toEqualTypeOf<"a">();
return 1 as const;
},
},
(s) => {
// The fallback sees only the keys `a` did not handle.
expectTypeOf(s).toEqualTypeOf<"b" | "c">();
return 2 as const;
},
);
// Assert
expectTypeOf(matcher).toEqualTypeOf<(shape: "a" | "b" | "c") => 1 | 2>();
// ❌ a value outside T must not be accepted by the matcher
expectTypeOf<"d">().not.toExtend<Parameters<typeof matcher>[0]>();
assert.equal(matcher("a"), 1);
assert.equal(matcher("b"), 2);
assert.equal(matcher("c"), 2);
});
test("getMatcher: a `_` fallback routes mixed string and numeric gaps", () => {
// Arrange
const factory = getMatcher<"a" | "b" | 1 | 2>();
// Act
const matcher = factory(
{
a: (s) => {
expectTypeOf(s).toEqualTypeOf<"a">();
return "A";
},
1: (n) => {
expectTypeOf(n).toEqualTypeOf<1>();
return "one";
},
},
(s) => {
expectTypeOf(s).toEqualTypeOf<"b" | 2>();
return "fallback";
},
);
// Assert
expectTypeOf(matcher).toEqualTypeOf<(shape: "a" | "b" | 1 | 2) => string>();
assert.equal(matcher("a"), "A");
assert.equal(matcher("b"), "fallback");
assert.equal(matcher(1), "one");
assert.equal(matcher(2), "fallback");
});
// ============================================================================
// API: getMatcherW — ✔️ Exhaustive / ❌ ReturnsStrict
// ============================================================================
test("getMatcherW: exhaustive pattern widens to the union of handler returns", () => {
// Arrange
const factory = getMatcherW<"x" | "y">();
// Act
const matcher = factory({
x: (s) => {
expectTypeOf(s).toEqualTypeOf<"x">();
return 1 as const;
},
y: (s) => {
expectTypeOf(s).toEqualTypeOf<"y">();
return "two" as const;
},
});
// Assert
// ❌ ReturnsStrict: mixed handler returns widen to their union.
expectTypeOf(matcher).toEqualTypeOf<(shape: "x" | "y") => 1 | "two">();
// ❌ a value outside T must not be accepted by the matcher
expectTypeOf<"z">().not.toExtend<Parameters<typeof matcher>[0]>();
assert.equal(matcher("x"), 1);
assert.equal(matcher("y"), "two");
});
test("getMatcherW: dispatches on mixed string and numeric keys", () => {
// Arrange
const factory = getMatcherW<"a" | "b" | 1 | 2>();
// Act
const matcher = factory({
a: (s) => {
expectTypeOf(s).toEqualTypeOf<"a">();
return "A" as const;
},
b: (s) => {
expectTypeOf(s).toEqualTypeOf<"b">();
return "B" as const;
},
1: (n) => {
expectTypeOf(n).toEqualTypeOf<1>();
return 10 as const;
},
2: (n) => {
expectTypeOf(n).toEqualTypeOf<2>();
return 20 as const;
},
});
// Assert
expectTypeOf(matcher).toEqualTypeOf<
(shape: "a" | "b" | 1 | 2) => "A" | "B" | 10 | 20
>();
assert.equal(matcher("a"), "A");
assert.equal(matcher("b"), "B");
assert.equal(matcher(1), 10);
assert.equal(matcher(2), 20);
});
// ============================================================================
// API: getMatcherW — ❌ Exhaustive (fallback) / ❌ ReturnsStrict
// ============================================================================
test("getMatcherW: a `_` fallback widens gaps into the union", () => {
// Arrange
const factory = getMatcherW<"x" | "y" | "z">();
// Act
const matcher = factory(
{
x: (s) => {
expectTypeOf(s).toEqualTypeOf<"x">();
return 1 as const;
},
},
(s) => {
// The fallback sees only the keys `x` did not handle.
expectTypeOf(s).toEqualTypeOf<"y" | "z">();
return "fallback" as const;
},
);
// Assert
expectTypeOf(matcher).toEqualTypeOf<
(shape: "x" | "y" | "z") => 1 | "fallback"
>();
// ❌ a value outside T must not be accepted by the matcher
expectTypeOf<"w">().not.toExtend<Parameters<typeof matcher>[0]>();
assert.equal(matcher("x"), 1);
assert.equal(matcher("y"), "fallback");
assert.equal(matcher("z"), "fallback");
});
test("getMatcherW: a `_` fallback widens mixed string and numeric returns", () => {
// Arrange
const factory = getMatcherW<"a" | "b" | 1 | 2>();
// Act
const matcher = factory(
{
a: (s) => {
expectTypeOf(s).toEqualTypeOf<"a">();
return "A" as const;
},
1: (n) => {
expectTypeOf(n).toEqualTypeOf<1>();
return 10 as const;
},
},
(s) => {
expectTypeOf(s).toEqualTypeOf<"b" | 2>();
return "fallback" as const;
},
);
// Assert
// ❌ ReturnsStrict: mixed handler returns widen to their union.
expectTypeOf(matcher).toEqualTypeOf<
(shape: "a" | "b" | 1 | 2) => "A" | 10 | "fallback"
>();
assert.equal(matcher("a"), "A");
assert.equal(matcher("b"), "fallback");
assert.equal(matcher(1), 10);
assert.equal(matcher(2), "fallback");
});
// ============================================================================
// Factory contracts — calls that must not compile
// ============================================================================
test("getMatcher factory rejects patterns outside its contract", () => {
// Arrange
const factory = getMatcher<"a" | "b">();
// Act / Assert — the calls below must not compile.
// `Parameters<typeof factory>[0]` resolves only the *last* overload, so it
// is not a sound "rejected" oracle (an accepted partial pattern does not
// extend it either); the call sites are the oracle instead.
factory({
a: () => 1,
b: () => 2,
// @ts-expect-error `c` is not part of the universe `"a" | "b"`
c: () => 3,
});
// @ts-expect-error a gap without a `_` fallback is not exhaustive
factory({ a: () => 1 });
// @ts-expect-error a `string` fallback does not fit the `number` handler
factory({ a: () => 1 }, () => "x");
});
test("getMatcherW factory rejects patterns outside its contract", () => {
// Arrange
const factory = getMatcherW<"x" | "y">();
// Act / Assert — the calls below must not compile
factory({
x: () => 1 as const,
y: () => 2 as const,
// @ts-expect-error `z` is not part of the universe `"x" | "y"`
z: () => 3 as const,
});
// @ts-expect-error a gap without a `_` fallback is not exhaustive
factory({ x: () => 1 as const });
});
// ============================================================================
// Dispatch — runtime behavior
// ============================================================================
test("getMatcher: an unhandled shape throws without a fallback", () => {
// Arrange — an open universe types its handler map as an index signature,
// so the type system cannot prove the runtime map is exhaustive.
const handlers: Record<string, () => number> = { a: () => 1 };
const matcher = getMatcher<string>()(handlers);
// Act / Assert
assert.equal(matcher("a"), 1);
assert.throws(() => matcher("b"), /Unhandled shape: b/);
});
// ============================================================================
// Autocomplete — the language server is the oracle, not the type system
// ============================================================================
// Completion is a contextual-type property that the type system cannot observe,
// so the cases below read the popup from the repo's language server (via the
// test helper) rather than pairing `expectTypeOf` with `assert` — see
// development/testing.md § Autocomplete. They probe the *matcher's* overloads,
// which is why they live with the matcher and not with the helper.
const REPO_ROOT = path.resolve(import.meta.dirname, "..");
const UNIVERSE = `"a" | "b" | "c"`;
// One session per probe: the tests share no language-server state (open
// documents, project membership), so they pass in any order.
interface LabelsProbe {
readonly name: string;
readonly factory: "getMatcher" | "getMatcherW";
readonly body: string;
readonly tail?: string;
}
const labelsFor = ({
name,
factory,
body,
tail = "",
}: LabelsProbe): Promise<readonly string[]> => {
const session = new LspSession(REPO_ROOT);
const target: CompletionTarget = {
file: `src/__autocomplete_${name}.ts`,
source: [
`import { ${factory} } from "./index.ts";`,
`const m = ${factory}<${UNIVERSE}>()({`,
body,
`}${tail});`,
"",
].join("\n"),
};
return session
.completionLabelsAt(target)
.then((result) => result.labels)
.finally(() => session.close());
};
test("autocomplete: an exhaustive pattern requires the universe", () => {
// Arrange
const name = "getMatcher_fresh";
// Act
const labels = labelsFor({
name,
factory: "getMatcher",
body: " /*COMPLETE*/",
});
// Assert
return labels.then((result) => {
assert.deepEqual([...result], ["a", "b", "c"]);
});
});
test("autocomplete: handled keys drop out of the popup", () => {
// Arrange
const name = "getMatcher_after_key";
// Act
const labels = labelsFor({
name,
factory: "getMatcher",
body: " a: () => 1,\n /*COMPLETE*/",
});
// Assert
return labels.then((result) => {
assert.deepEqual([...result], ["b", "c"]);
});
});
test("autocomplete: a fallback makes the remaining keys optional", () => {
// Arrange
const name = "getMatcher_with_fallback";
// Act
const labels = labelsFor({
name,
factory: "getMatcher",
body: " /*COMPLETE*/",
tail: ", () => 0",
});
// Assert
return labels.then((result) => {
assert.deepEqual([...result], ["a?", "b?", "c?"]);
});
});
test("autocomplete: with a fallback, handled keys stay optional", () => {
// Arrange
const name = "getMatcher_with_fallback_after_key";
// Act
const labels = labelsFor({
name,
factory: "getMatcher",
body: " a: () => 1,\n /*COMPLETE*/",
tail: ", () => 0",
});
// Assert
return labels.then((result) => {
assert.deepEqual([...result], ["b?", "c?"]);
});
});
test("autocomplete: `getMatcherW` offers the same popup as `getMatcher`", () => {
// Arrange
const name = "getMatcherW_fresh";
// Act
const labels = labelsFor({
name,
factory: "getMatcherW",
body: " /*COMPLETE*/",
});
// Assert
return labels.then((result) => {
assert.deepEqual([...result], ["a", "b", "c"]);
});
});
test("autocomplete: `getMatcherW` also offers optional keys with a fallback", () => {
// Arrange
const name = "getMatcherW_with_fallback";
// Act
const labels = labelsFor({
name,
factory: "getMatcherW",
body: " /*COMPLETE*/",
tail: ", () => 0",
});
// Assert
return labels.then((result) => {
assert.deepEqual([...result], ["a?", "b?", "c?"]);
});
});
+75
View File
@@ -0,0 +1,75 @@
import type { Exact, ValueOf } from "type-fest";
type UnaryFn<T, R> = (shape: T) => R;
// `Extract` drops optional handlers (`undefined`) so `PatternReturns` also works
// when `P`'s constraint has optional keys.
type PatternReturns<P> = ReturnType<
Extract<ValueOf<P>, (...args: never[]) => unknown>
>;
type Handlers<T extends string | number, R> = { [K in T]: UnaryFn<K, R> };
// The fallback is a *second argument*, not a property of the handler map,
// because its parameter is the remainder `Exclude<T, keyof Handled>` and TypeScript
// fixes a property's contextual type before it infers its sibling keys. A later
// argument, by contrast, is contextually typed from inference on an earlier
// one, so the split is what makes the remainder expressible at all.
// See development/library.md.
type Fallback<T extends string | number, Handled, R> = UnaryFn<
Exclude<T, keyof Handled>,
R
>;
// TypeScript does not apply the excess-property check to a generic constraint,
// so `Exact` restores it for the generic forms: a handler map can otherwise
// carry keys outside `T`.
// oxlint-disable typescript/unified-signatures
// Strict returns: one common `R`. Overload order is load-bearing:
// #1 Handlers (first) -> the exhaustive form and the autocomplete popup
// #2 Fallback (last) -> accepts a partial handler map plus a fallback
interface MatcherStrict<T extends string | number> {
<R>(handlers: Handlers<T, R>): UnaryFn<T, R>;
<R, Handled extends Exact<Partial<Handlers<T, R>>, Handled>>(
handlers: Handled & Partial<Handlers<T, R>>,
fallback: Fallback<T, Handled, R>,
): UnaryFn<T, R>;
}
// oxlint-enable typescript/unified-signatures
// Widened returns: the union of every handler's return type. `P` is inferred
// from the whole handler map, whose closed constraint supplies the
// contextual/autocomplete type.
// oxlint-disable typescript/unified-signatures
interface MatcherWidening<T extends string | number> {
<P extends Exact<Handlers<T, unknown>, P>>(
handlers: P,
): UnaryFn<T, PatternReturns<P>>;
<R, Handled extends Exact<Partial<Handlers<T, unknown>>, Handled>>(
handlers: Handled,
fallback: Fallback<T, Handled, R>,
): UnaryFn<T, PatternReturns<Handled> | R>;
}
// oxlint-enable typescript/unified-signatures
type HandlerMap = Record<string | number, UnaryFn<never, unknown> | undefined>;
const dispatch =
(handlers: HandlerMap, fallback?: UnaryFn<never, unknown>) =>
(shape: string | number): unknown =>
(
handlers[shape] ??
fallback ??
(() => {
throw new Error(`Unhandled shape: ${shape}`);
})
)(
// oxlint-disable-next-line typescript/no-unsafe-type-assertion
shape as never,
);
export const getMatcher = <T extends string | number>(): MatcherStrict<T> =>
dispatch;
export const getMatcherW = <T extends string | number>(): MatcherWidening<T> =>
dispatch;
+148
View File
@@ -0,0 +1,148 @@
/* oxlint-disable typescript/no-floating-promises -- expectTypeOf() is a sync type-assertion library that the type-aware linter misidentifies as a promise */
import { strict as assert } from "node:assert";
import path from "node:path";
import { test } from "node:test";
import { expectTypeOf } from "expect-type";
import {
type CompletionResult,
type CompletionTarget,
LspSession,
} from "#test-utils/lsp-completion.ts";
const REPO_ROOT = path.resolve(import.meta.dirname, "../../..");
// The `#test-utils/*` self-reference (package.json#imports) is the one route
// scattered test files take to reach test helpers; this pins that it resolves
// and types without starting a language server
// (development/testing.md § Test helpers).
test("test-helper home: `#test-utils/…` resolves to the helper and types it", () => {
// Arrange
const target: CompletionTarget = {
file: "src/util/__tests__/lsp-completion.ts",
source: "",
};
// Assert — no session is constructed: the constructor spawns `tsc --lsp`,
// so only the resolved values and their types are checked here.
expectTypeOf(LspSession).toBeConstructibleWith("repo-root");
expectTypeOf<CompletionResult>().toMatchTypeOf<{
labels: readonly string[];
}>();
assert.equal(typeof LspSession, "function");
assert.equal(target.file, "src/util/__tests__/lsp-completion.ts");
});
// The helper drives a language server, so its contract is asserted against the
// server. Every document below is probed from memory and carries its
// contextual type inline, so the helper is tested without the library's code.
const probe = (source: string, marker?: string): Promise<CompletionResult> => {
const session = new LspSession(REPO_ROOT);
const target: CompletionTarget =
marker === undefined
? { file: "src/__probe.ts", source }
: { file: "src/__probe.ts", source, marker };
return session.completionLabelsAt(target).finally(() => session.close());
};
const HANDLERS = [
"type Handlers = { a: () => number; b: () => number; _?: () => number };",
"declare const apply: (handlers: Handlers) => Handlers;",
];
test("helper: a fresh object literal completes with its contextual keys", () => {
// Arrange
const source = [
...HANDLERS,
"const done = apply({",
" /*COMPLETE*/",
"});",
"export { done };",
].join("\n");
// Act
const probed = probe(source);
// Assert — the position is the marker's, which the helper strips
expectTypeOf(probed).toEqualTypeOf<Promise<CompletionResult>>();
return probed.then((result) => {
assert.deepEqual(result.position, { line: 3, character: 4 });
assert.deepEqual([...result.labels], ["_?", "a", "b"]);
});
});
test("helper: a handled key drops out of the popup", () => {
// Arrange
const source = [
...HANDLERS,
"const done = apply({",
" b: () => 1,",
" /*COMPLETE*/",
"});",
"export { done };",
].join("\n");
// Act
const probed = probe(source);
// Assert
expectTypeOf<CompletionResult["labels"]>().toEqualTypeOf<
readonly string[]
>();
return probed.then((result) => {
assert.deepEqual([...result.labels], ["_?", "a"]);
});
});
test("helper: a custom marker is located at the line start", () => {
// Arrange
const source = [
"type Handlers = { a: () => number; _?: () => number };",
"declare const apply: (handlers: Handlers) => Handlers;",
"const done = apply({",
"@@@",
"});",
"export { done };",
].join("\n");
// Act
const probed = probe(source, "@@@");
// Assert
expectTypeOf(probed).resolves.toEqualTypeOf<CompletionResult>();
return probed.then((result) => {
assert.deepEqual(result.position, { line: 3, character: 0 });
assert.deepEqual([...result.labels], ["_?", "a"]);
});
});
test("helper: labels are read from the server, not from a pattern literal", () => {
// Arrange
const source = [
'const text = "x";',
"const upper = text./*COMPLETE*/;",
"export { upper };",
].join("\n");
// Act
const probed = probe(source);
// Assert
expectTypeOf(probed).resolves.toHaveProperty("labels");
return probed.then((result) => {
assert.ok(result.labels.includes("toUpperCase"));
});
});
test("helper: a source without the marker rejects", () => {
// Arrange
const source = "const text = 1;\nexport { text };\n";
// Act
const probed = probe(source);
// Assert
expectTypeOf(probed).resolves.toEqualTypeOf<CompletionResult>();
return assert.rejects(probed, /marker not found/);
});
+254
View File
@@ -0,0 +1,254 @@
// oxlint-disable no-magic-numbers unicorn/no-null - tolerable here, this is a helper
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import { pathToFileURL } from "node:url";
import {
type CompletionItem,
type CompletionList,
CompletionRequest,
ConfigurationRequest,
createMessageConnection,
DidOpenTextDocumentNotification,
InitializedNotification,
InitializeRequest,
type MessageConnection,
ShutdownRequest,
StreamMessageReader,
StreamMessageWriter,
} from "vscode-languageserver-protocol/node";
/**
* Print the completion labels TypeScript's language server offers at a marker
* inside a source file.
*
* Usage: node --strip-types src/util/__tests__/lsp-completion.ts <file> [<marker>]
*
* The marker (default `DEFAULT_MARKER`, a `COMPLETE` block comment) is stripped
* from the source before it is sent; the request position is where the marker
* stood. Point it at a scratch file whose expected type is a matcher pattern to
* inspect the popup.
*
* `LspSession` exposes the same probe to the test suite, so completion is
* asserted against the server rather than the type system.
*
* Why a script and not a type assertion: completion is a contextual-type
* property that `expect-type` cannot observe, and `Parameters<…>` resolves only
* a single overload. The server is the only ground truth. See
* development/testing.md § Autocomplete.
*/
const DEFAULT_MARKER = "/*COMPLETE*/";
const ARGV_PREFIX_LENGTH = 2;
const EXIT_DELAY_MS = 200;
const FAILURE_EXIT_CODE = 1;
const USAGE = "usage: lsp-completion.ts <file> [<marker>]";
export interface Position {
readonly line: number;
readonly character: number;
}
/** A document to probe: `source` carries `marker`, which is stripped. */
export interface CompletionTarget {
/** Path relative to the session's repo root; drives URI and resolution. */
readonly file: string;
readonly source: string;
readonly marker?: string;
}
export interface CompletionResult {
readonly position: Position;
readonly labels: readonly string[];
}
const completionLabels = (
result: CompletionItem[] | CompletionList | null,
): readonly string[] => {
if (result === null) {
return [];
}
const items = Array.isArray(result) ? result : result.items;
return items
.map((item) => item.label)
.toSorted((left, right) => left.localeCompare(right));
};
const markerPosition = (source: string, marker: string): Position => {
const index = source.indexOf(marker);
if (index === -1) {
throw new Error(`marker not found: ${marker}`);
}
const lines = source.slice(0, index).split("\n");
const lineIndex = lines.length - 1;
const line = lines[lineIndex];
return { line: lineIndex, character: line === undefined ? 0 : line.length };
};
/**
* One language server, initialized on first use, shared across probes. Callers
* own the lifecycle and must `close()` it.
*/
export class LspSession {
readonly #child: ChildProcessWithoutNullStreams;
readonly #connection: MessageConnection;
readonly #repoRoot: string;
#ready: Promise<void> | undefined;
public constructor(repoRoot: string) {
this.#repoRoot = repoRoot;
this.#child = spawn(
path.join(repoRoot, "node_modules", ".bin", "tsc"),
["--lsp", "--stdio"],
{ cwd: repoRoot },
);
this.#child.stderr.on("data", (chunk: Buffer) => {
process.stderr.write(chunk);
});
this.#connection = createMessageConnection(
new StreamMessageReader(this.#child.stdout),
new StreamMessageWriter(this.#child.stdin),
);
// Server -> client requests the server waits on: answer so it proceeds.
// `workspace/configuration` wants one reply per requested item; a
// catch-all covers the rest (e.g. `client/registerCapability`).
this.#connection.onRequest(ConfigurationRequest.type, ({ items }) =>
items.map(() => null),
);
this.#connection.onRequest(() => null);
this.#connection.listen();
}
public completionLabelsAt(
target: CompletionTarget,
): Promise<CompletionResult> {
return this.#ensureInitialized()
.then(() => this.#open(target))
.then(({ uri, position }) =>
this.#connection
.sendRequest(CompletionRequest.type, {
textDocument: { uri },
position,
context: { triggerKind: 1 },
})
.then((result) => ({
position,
labels: completionLabels(result),
})),
);
}
/** `shutdown` + close stdin, then kill the server; safe after a failed probe. */
public close(): Promise<void> {
return (
this.#connection
.sendRequest(ShutdownRequest.type)
// The TS 7 Go server logs a bare `context canceled` to stderr
// when it handles `exit`; EOF on stdin shuts it down cleanly
// (exit 0, no output) instead.
.then(() => {
this.#child.stdin.end();
})
.then(() => delay(EXIT_DELAY_MS))
.finally(() => {
this.#connection.dispose();
this.#child.kill();
})
);
}
#ensureInitialized(): Promise<void> {
this.#ready ??= this.#initialize();
return this.#ready;
}
#initialize(): Promise<void> {
return this.#connection
.sendRequest(InitializeRequest.type, {
processId: process.pid,
rootUri: pathToFileURL(this.#repoRoot).href,
workspaceFolders: [
{ uri: pathToFileURL(this.#repoRoot).href, name: "repo" },
],
capabilities: {
textDocument: {
completion: {
completionItem: { snippetSupport: false },
},
publishDiagnostics: {},
},
},
})
.then(() =>
this.#connection.sendNotification(
InitializedNotification.type,
{},
),
);
}
#open(target: CompletionTarget): Promise<{
readonly uri: string;
readonly position: Position;
}> {
const absolute = path.resolve(this.#repoRoot, target.file);
const marker = target.marker ?? DEFAULT_MARKER;
const position = markerPosition(target.source, marker);
const text = target.source.replace(marker, "");
const uri = pathToFileURL(absolute).href;
// The server handles `didOpen` in order before the completion request,
// so no settle delay is needed.
return this.#connection
.sendNotification(DidOpenTextDocumentNotification.type, {
textDocument: {
uri,
languageId: "typescript",
version: 1,
text,
},
})
.then(() => ({ uri, position }));
}
}
const main = (args: readonly string[]): Promise<void> => {
const [file, markerArgument] = args;
if (file === undefined) {
process.stderr.write(`${USAGE}\n`);
process.exitCode = FAILURE_EXIT_CODE;
return Promise.resolve();
}
const repoRoot = path.resolve(import.meta.dirname, "../../..");
const absolute = path.resolve(repoRoot, file);
const source = fs.readFileSync(absolute, "utf8");
const session = new LspSession(repoRoot);
return session
.completionLabelsAt({
file,
source,
marker: markerArgument ?? DEFAULT_MARKER,
})
.then(({ position, labels }) => {
process.stdout.write(
`\n[${file}] completions @ ${position.line}:${position.character}:\n${labels.join(", ")}\n`,
);
})
.finally(() => session.close());
};
const isEntryPoint = (): boolean => {
const [entry] = process.argv.slice(1, 2);
return entry !== undefined && import.meta.url === pathToFileURL(entry).href;
};
if (isEntryPoint()) {
main(process.argv.slice(ARGV_PREFIX_LENGTH)).catch((error: unknown) => {
process.stderr.write(
`${error instanceof Error ? error.message : String(error)}\n`,
);
process.exitCode = FAILURE_EXIT_CODE;
});
}