From 30d97a920949b716311777037152302f0fb53425 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= Date: Thu, 24 Sep 2026 11:53:29 +0000 Subject: [PATCH 1/5] :sparkles: Compile doc code fences into tests Every `ts`-tagged fence in README.md / CONTRIBUTING.md now becomes an executed `node:test` case in a gitignored generated file, so a documented example cannot drift from the API. The generator hoists and merges the leading imports, rewrites the library specifier to the `#test-tiny-pattern-ts` alias, and rejects a fence with no describing paragraph or one that re-imports a prelude module. CI regenerates via `pretest:ci`; `create:doc-tests` runs the generator, formats, then typechecks the output against a scoped tsconfig that relaxes `noUnusedLocals`. c8's default excludes already omit the generated `*.test.ts`, so `test:ci` is unchanged. --- .gitignore | 4 + AGENTS.md | 1 + CONTRIBUTING.md | 21 +- README.md | 2 + backlog.tasks | 2 +- development/README.md | 1 + development/docs.md | 51 +++ development/workflow.md | 3 +- package-lock.json | 625 ++++++++++++++++++++++++++++ package.json | 8 +- scripts/create-doc-tests.ts | 445 ++++++++++++++++++++ src/doc-test/README.md | 12 + src/doc-test/__generated__/.gitkeep | 0 src/doc-test/tsconfig.json | 10 + tsconfig.json | 3 +- 15 files changed, 1180 insertions(+), 8 deletions(-) create mode 100644 development/docs.md create mode 100644 scripts/create-doc-tests.ts create mode 100644 src/doc-test/README.md create mode 100644 src/doc-test/__generated__/.gitkeep create mode 100644 src/doc-test/tsconfig.json diff --git a/.gitignore b/.gitignore index 9ec4b74..de3db01 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,10 @@ node_modules dist coverage + +# Generated doc-tests (scripts/create-doc-tests.ts); the folder is kept via .gitkeep +src/doc-test/__generated__/*.test.ts +!src/doc-test/__generated__/.gitkeep *.log *.tsbuildinfo *.local diff --git a/AGENTS.md b/AGENTS.md index d7f9048..8d78f8a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,6 +11,7 @@ first-action facts. Do not restate evolving prose here — it will drift. - **While iterating:** `npm run test` (`check:tsc` + the unit suite) for fast feedback on the files you changed. - **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)`. **When you are looking for a symbol, reach for the LSP before `rg`/`grep`** — `lsp_references` / `lsp_find_symbol` / `lsp_definition` / `lsp_document_symbols` are semantic and cross-file, so they see shadowing, imports and overloads that a text search cannot; use `lsp_hover` to read inferred types on generic-heavy code. Use `rg` for what the LSP cannot see — doc prose, string literals, config, task lists, file discovery — and reconcile the two sets before editing (symbols from the LSP, strings and prose from `rg`). 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. +- **Touched a `ts`-tagged fence in `README.md` / `CONTRIBUTING.md`?** Run `npm run create:doc-tests` first: it regenerates the gitignored tests under `src/doc-test/__generated__/`, formats and type-checks them, so `verify` executes the documented example. CI does this automatically via `pretest:ci`; the fast local tiers do not. See [development/docs.md](./development/docs.md). - **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/.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. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f84aad7..9d27b5a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -22,6 +22,8 @@ the rules so agents and humans don't diverge. - **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` +- **Doc tests:** `npm run create:doc-tests` — compile the `ts`-tagged fences + in the prose docs into executed, gitignored tests - **Verify:** `npm run verify` — the definition of done - **Maintenance:** `npm run maintain` — advisory only - **Individual fixes:** `npm run fix:oxfmt`, `npm run fix:oxlint` @@ -87,6 +89,17 @@ Per [AGENTS.md § Never do](./AGENTS.md#never-do), reach green honestly — fix types, never suppress the checks you can't make pass. Full rationale: [development/testing.md](./development/testing.md). +## Documentation examples + +Every `ts`-tagged fence in `README.md` / `CONTRIBUTING.md` is compiled into an +executed test, so a documented example cannot drift from the API. Describe each +fence with the paragraph directly above it (that text becomes the test title), +and keep its library import self-contained; +`npm run create:doc-tests` regenerates, formats and type-checks the tests under +`src/doc-test/__generated__/`. CI runs it automatically via `pretest:ci`, so run +it yourself before `npm run verify` when you touched a fence. Why: +[development/docs.md](./development/docs.md). + ## Code style and formatting `oxfmt` is the formatter and `oxlint` is the linter (with type-aware rules). @@ -115,10 +128,12 @@ intended to run. A `:` script is implicitly aggregated by a `` 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` +- `create:*` — front doors of the repo's own workflow; these produce or mutate + workflow artifacts (git state, generated doc-tests) rather than the + hand-written 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. + (maintainer-only), and `create:doc-tests` regenerates the compiled prose + examples. 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 diff --git a/README.md b/README.md index ee62d7a..f715767 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,8 @@ Add a fallback as the second argument to leave members unhandled; the fallback receives the remainder: ```ts +import { getPrimitiveUnionMatcher } from "tiny-pattern-ts"; + const matchLabel = getPrimitiveUnionMatcher<"yes" | "no" | "maybe">(); const label = matchLabel( diff --git a/backlog.tasks b/backlog.tasks index 4eb0e05..a30cf58 100644 --- a/backlog.tasks +++ b/backlog.tasks @@ -32,7 +32,7 @@ Documentation: ☐ Why this form? little syntax, data last, very small, autocomplete, strict typing in the handler; for more features use ts-pattern ☐ Write migration guide for users coming from discriminated unions ☐ Create backlog tasks for implementation -☐ Validate code fences in Markdown (start with README.md) — compile the TypeScript examples against `src/` so the docs cannot drift from the API +✔ Validate code fences in Markdown (start with README.md) — compile the TypeScript examples against `src/` so the docs cannot drift from the API @done Maintenance: ☐ Serve CI coverage over a tiny self-hosted webserver (replace the zip artifact) @low diff --git a/development/README.md b/development/README.md index e93c5b5..ab1563e 100644 --- a/development/README.md +++ b/development/README.md @@ -24,6 +24,7 @@ One file per category: | [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 | +| [docs.md](./docs.md) | Validating the Markdown code fences in the prose docs | | [ci.md](./ci.md) | CI pipeline, runner image, coverage serving | | [publishing.md](./publishing.md) | Release and npm publishing | diff --git a/development/docs.md b/development/docs.md new file mode 100644 index 0000000..d93aa0d --- /dev/null +++ b/development/docs.md @@ -0,0 +1,51 @@ +# Docs + +Why the prose documentation is maintained the way it is. The actionable rules +are in [CONTRIBUTING.md](../CONTRIBUTING.md); this file records the rationale. + +## Validating Markdown code fences + +#### Decision (2026-11) + +Compile every `ts / `typescript fence in the prose docs into a real +`node:test` case under `src/doc-test/__generated__/`, typechecked by a scoped +`tsc` project and executed by `node --test`. + +#### Why + +- A documented example is a promise about the API. Left unchecked it drifts the + moment a signature changes, and a reader copies broken code. +- The repo runs TypeScript 7, the native/Go compiler. It exposes **no legacy JS + compiler API** (`ts.createProgram`, `ts.transpileModule`, `ts.createSourceFile` + are all `undefined`; `Object.keys(require("typescript"))` is + `["version", "versionMajorMinor"]`). So `@typescript/vfs`, the type-aware + `eslint-plugin-markdown` and `docs-ts` / `@effect/docgen` cannot run here. +- `node --check` parses as JS and rejects valid TS type annotations, so it is not + a gate. The only faithful validator is the `tsc` **CLI**, which means emitting + real `.ts` files and letting the existing `check:tsc` / `node --test` pipeline + judge them. + +#### Rejected + +- **A packaged doc-test tool** (see above) — no usable compiler API on TS 7. +- **Embedding a typecheck in the generator** — duplicates the gate, and would + not exercise the repo's own resolution. +- **`node --check`** — wrong language level. + +#### Known issue + +- Scanned sources are hard-coded to `README.md` and `CONTRIBUTING.md`. A + `docs/` + `examples/` list is the natural extension; `development/` must never + be scanned (its fences are illustrative, not compilable). +- The generator hoists and merges leading imports, rewrites `tiny-pattern-ts` to + the `#test-tiny-pattern-ts` source alias, and rejects an example that imports + `node:assert` / `node:test` (the prelude already binds both). Titles are the + immediately preceding paragraph; a fence with no such paragraph is a fatal + error, which keeps every example described. +- `oxlint src/doc-test` reports "No files found" because the generated + `*.test.ts` are gitignored. That is cosmetic: the files are still typechecked + and run. +- The generated tests are `*.test.ts`, which c8's default excludes already keep + out of the `--100` gate. Do not add an `--exclude` for them: passing any + `--exclude` replaces the defaults, so every hand-written test file and + `__tests__/` helper re-enters coverage and the gate fails. diff --git a/development/workflow.md b/development/workflow.md index b91e4ec..80dd50d 100644 --- a/development/workflow.md +++ b/development/workflow.md @@ -96,7 +96,8 @@ aggregator. #### Why -- Both members create something real: a branch, a release. +- Every member creates something real: a branch, a release, the compiled + doc-tests. - 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. diff --git a/package-lock.json b/package-lock.json index fc1f3bf..25c740d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "expect-type": "1.4.0", "knip": "^6.34.0", "lefthook": "^2.1.12", + "mdast-util-from-markdown": "^2.0.3", "oxfmt": "^0.70.0", "oxlint": "^1.83.0", "oxlint-tsgolint": "^7.0.2001", @@ -2358,6 +2359,16 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", @@ -2365,6 +2376,23 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "26.6.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.2.tgz", @@ -2375,6 +2403,13 @@ "undici-types": "~8.9.0" } }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@typescript/typescript-aix-ppc64": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", @@ -2887,6 +2922,17 @@ "node": ">=10" } }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/check-outdated": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/check-outdated/-/check-outdated-3.0.0.tgz", @@ -3332,6 +3378,62 @@ "node": ">=22.12.0" } }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -4024,6 +4126,508 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, "node_modules/minimatch": { "version": "10.2.6", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", @@ -4060,6 +4664,13 @@ "node": ">=4" } }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, "node_modules/mz": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", @@ -4798,6 +5409,20 @@ "node": ">=4" } }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/v8-to-istanbul": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", diff --git a/package.json b/package.json index 38e139f..846d380 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,8 @@ "type": "module", "sideEffects": false, "imports": { - "#test-utils/*": "./src/util/__tests__/*" + "#test-utils/*": "./src/util/__tests__/*", + "#test-tiny-pattern-ts": "./src/index.ts" }, "exports": { ".": { @@ -47,7 +48,8 @@ "check:oxfmt": "oxfmt --check ${LEFTHOOK_FILES:-.}", "check:oxlint": "oxlint ${LEFTHOOK_FILES:-src scripts}", "check:tsc": "tsc", - "clean": "rm -rf dist coverage", + "clean": "rm -rf dist coverage src/doc-test/__generated__/*.test.ts", + "create:doc-tests": "node --strip-types scripts/create-doc-tests.ts && sh -c 'oxfmt src/doc-test/__generated__/*.test.ts' && tsc -p src/doc-test/tsconfig.json", "fix": "npm run fix:oxlint && npm run fix:oxfmt", "fix:oxfmt": "oxfmt ${LEFTHOOK_FILES:-.}", "fix:oxlint": "oxlint --fix src scripts", @@ -58,6 +60,7 @@ "maintain:knip": "knip --include dependencies,exports,files", "maintain:outdated": "check-outdated --ignore-pre-releases --ignore-packages @types/node", "test": "npm run check:tsc && node --test --strip-types \"src/**/*.test.ts\"", + "pretest:ci": "npm run create:doc-tests", "test:ci": "c8 --all --include \"src/**/*.ts\" --reporter=text --reporter=lcov --reporter=html --100 node --test --strip-types \"src/**/*.test.ts\"", "test:unit": "node --test --strip-types \"src/**/*.test.ts\"", "verify": "npm run check && npm run test:unit", @@ -83,6 +86,7 @@ "expect-type": "1.4.0", "knip": "^6.34.0", "lefthook": "^2.1.12", + "mdast-util-from-markdown": "^2.0.3", "oxfmt": "^0.70.0", "oxlint": "^1.83.0", "oxlint-tsgolint": "^7.0.2001", diff --git a/scripts/create-doc-tests.ts b/scripts/create-doc-tests.ts new file mode 100644 index 0000000..3628dd9 --- /dev/null +++ b/scripts/create-doc-tests.ts @@ -0,0 +1,445 @@ +import fs from "node:fs"; +import path from "node:path"; + +import type { Code, Heading, Paragraph, PhrasingContent, Root } from "mdast"; +import { fromMarkdown } from "mdast-util-from-markdown"; + +/** + * Compile the TypeScript examples embedded in the prose docs into runnable + * tests, so a doc fence that drifts from the API fails CI. Every ```ts fence in + * the scanned Markdown becomes a `test(...)` in a generated + * `src/doc-test/__generated__/.test.ts`; the file is typechecked by + * `tsc` and executed by `node --test` exactly like a hand-written test. + * + * Usage: node --strip-types scripts/create-doc-tests.ts + * + * Why regenerate-then-typecheck instead of a lint plugin: development/docs.md § + * Validating Markdown code fences. The repo is TypeScript 7 (the native + * compiler), which does not expose the legacy JS compiler API, so the only + * faithful check is to emit real `.ts` files and let the existing `check:tsc` / + * `node --test` pipeline judge them. + */ + +/** The package's public entry, as spelled inside the examples. */ +const LIBRARY_SOURCE = "tiny-pattern-ts"; + +/** Matches the library specifier (single- or double-quoted) inside an import. */ +const LIBRARY_SPECIFIER = new RegExp( + `(?["'])${LIBRARY_SOURCE}\\k`, + "g", +); + +/** + * A source specifier resolved to `src/index.ts` by `package.json#imports`. + * Examples import `from "tiny-pattern-ts"`, which neither `node` nor `tsc` + * resolves to source before `dist/` exists, so the generator rewrites it. + */ +const PRELUDE_IMPORT_SOURCE = "#test-tiny-pattern-ts"; + +/** Markdown files to scan, relative to the repo root. */ +const SOURCES = ["README.md", "CONTRIBUTING.md"]; + +/** Directory the generated tests are written to (gitignored contents). */ +const OUTPUT_DIR = "src/doc-test/__generated__"; + +/** Fences whose info-string matches one of these are compiled; others skipped. */ +const TYPESCRIPT_LANGS: ReadonlySet = new Set(["ts", "typescript"]); + +/** + * Modules the generated file already imports. An example that imports one of + * these would collide with the prelude binding (duplicate `test` / `assert`), so + * it is surfaced as a fatal error and the example is rewritten. + */ +const PRELUDE_MODULES: ReadonlySet = new Set([ + "node:assert", + "node:test", +]); + +/** One line of the prelude every generated file starts with. */ +const PRELUDE_TEST = 'import { test } from "node:test";'; +const PRELUDE_ASSERT = 'import { strict as assert } from "node:assert";'; + +/** Indentation applied to every fence body line inside the `test` callback. */ +const INDENT = " "; + +const EMPTY = ""; +const INITIAL_COUNT = 0; +const WRITE_INCREMENT = 1; +const FIRST_ITEM = 0; +const FAILURE_EXIT_CODE = 1; + +/** Strip all leading blank lines so the import scan starts at real content. */ +const LEADING_BLANK_LINES = /^(?:[ \t]*\r?\n)+/; +/** Collapse the plain text of a title paragraph to one line. */ +const WHITESPACE = /\s+/g; +/** Split an info-string like `ts title="x"` into its bare language. */ +const LANG_SEPARATOR = /\s+/; + +/** One leading `import` statement, including a multi-line named import. */ +const IMPORT_STATEMENT = + /^import\s+(?:(?:type\s+)?[\w$*{}\s,]+?\s+from\s+)?["'][^"'\n]+["']\s*;?[ \t]*(?:\r?\n|$)/; + +/** `import { a, b } from "src";`, with an optional `type` keyword. */ +const NAMED_IMPORT = + /^import\s+(?type\s+)?\{(?[^}]*)\}\s+from\s+["'](?[^"']+)["']\s*;?$/; + +/** The module in a `… from "src"` statement. */ +const FROM_SOURCE = /from\s+["'](?[^"']+)["']/; + +/** The module in a side-effect `import "src"` statement. */ +const SIDE_EFFECT_SOURCE = /^import\s+["'](?[^"']+)["']/; + +type Block = Root["children"][number]; + +class DocTestError extends Error { + public constructor(message: string) { + super(message); + this.name = "DocTestError"; + } +} + +/** Merge named specifiers that target the same module into one import. */ +interface NamedImportGroup { + readonly source: string; + readonly isType: boolean; + readonly names: string[]; +} + +/** One compiled fence, ready to be wrapped in a `test(...)`. */ +interface DocCase { + readonly title: string; + readonly body: string; +} + +/** Accumulator threaded through the Markdown walk. */ +interface BuilderState { + readonly named: Map; + readonly passthrough: Set; + readonly cases: DocCase[]; + title: string | undefined; +} + +/** Read one capture group, tolerating the type-level `groups` optionality. */ +const groupOf = (match: RegExpExecArray, name: string): string | undefined => { + const { groups } = match; + return groups === undefined ? undefined : groups[name]; +}; + +/** Recursively concatenate the literal text of a single inline node. */ +const inlineText = (node: PhrasingContent): string => { + if ("value" in node) { + return node.value; + } + if ("children" in node) { + return node.children.map(inlineText).join(EMPTY); + } + return EMPTY; +}; + +/** Render a paragraph/heading node to its plain text for use as a test title. */ +const textOf = (node: Paragraph | Heading): string => + node.children.map(inlineText).join(EMPTY).replace(WHITESPACE, " ").trim(); + +/** + * Split a fence into its leading `import` statements and the executable body. + * Only a contiguous run of imports at the very top is hoisted; anything after + * the first non-import line stays in the body verbatim. + */ +const splitImports = (code: string): { imports: string[]; body: string } => { + const imports: string[] = []; + let rest = code.replace(LEADING_BLANK_LINES, EMPTY); + let match = IMPORT_STATEMENT.exec(rest); + while (rest.startsWith("import") && match !== null) { + const statement = match[FIRST_ITEM] ?? EMPTY; + imports.push(statement.trim()); + rest = rest.slice(statement.length).replace(LEADING_BLANK_LINES, EMPTY); + match = IMPORT_STATEMENT.exec(rest); + } + return { imports, body: rest.trim() }; +}; + +/** The module an import statement points at, or `undefined` if unreadable. */ +const importSource = (statement: string): string | undefined => { + const fromMatch = FROM_SOURCE.exec(statement); + if (fromMatch !== null) { + return groupOf(fromMatch, "source"); + } + const sideEffectMatch = SIDE_EFFECT_SOURCE.exec(statement); + return sideEffectMatch === null + ? undefined + : groupOf(sideEffectMatch, "source"); +}; + +/** + * Reject an example that imports a harness-provided module, then rewrite the + * library specifier to the source alias so the emitted file resolves. + */ +const normalizeImport = (statement: string): string => { + const source = importSource(statement); + if (source !== undefined && PRELUDE_MODULES.has(source)) { + throw new DocTestError( + `example imports "${source}", which the harness injects; remove the line:\n ${statement.trim()}`, + ); + } + return statement.replace( + LIBRARY_SPECIFIER, + `$${PRELUDE_IMPORT_SOURCE}$`, + ); +}; + +/** Split the `{ a, b }` contents of a named import into trimmed specifiers. */ +const specifiersOf = (raw: string): string[] => + raw + .split(",") + .map((name) => name.trim()) + .filter((name) => name.length > INITIAL_COUNT); + +/** Add named specifiers to the group for `(isType, source)`, merging. */ +const addNamedImport = ( + named: Map, + ref: Pick, + names: readonly string[], +): void => { + const key = `${ref.isType ? "type:" : "value:"}${ref.source}`; + const existing = named.get(key); + if (existing === undefined) { + named.set(key, { + source: ref.source, + isType: ref.isType, + names: [...names], + }); + return; + } + for (const name of names) { + if (!existing.names.includes(name)) { + existing.names.push(name); + } + } +}; + +/** Sort each hoisted import into a merged named group or a passthrough set. */ +const collectImports = ( + statements: readonly string[], + named: Map, + passthrough: Set, +): void => { + for (const raw of statements) { + const statement = normalizeImport(raw); + const match = NAMED_IMPORT.exec(statement); + if (match === null) { + passthrough.add(statement); + } else { + addNamedImport( + named, + { + source: groupOf(match, "source") ?? EMPTY, + isType: groupOf(match, "isType") !== undefined, + }, + specifiersOf(groupOf(match, "specifiers") ?? EMPTY), + ); + } + } +}; + +/** Render the hoisted imports: merged named groups first, then the rest. */ +const renderImports = ( + named: ReadonlyMap, + passthrough: ReadonlySet, +): string[] => { + const lines: string[] = []; + for (const group of named.values()) { + const keyword = group.isType ? "import type" : "import"; + lines.push( + `${keyword} { ${group.names.join(", ")} } from "${group.source}";`, + ); + } + for (const statement of passthrough) { + lines.push(statement); + } + return lines; +}; + +/** The bare language of a fence's info-string, e.g. `ts` in `ts title="x"`. */ +const typescriptLang = (code: Code): string => + (code.lang ?? EMPTY).trim().split(LANG_SEPARATOR)[FIRST_ITEM] ?? EMPTY; + +/** Set the title a following fence will inherit. */ +const handleTitleNode = ( + state: BuilderState, + node: Paragraph | Heading, +): void => { + state.title = textOf(node); +}; + +/** Report and reset a non-TS fence that was skipped. */ +const skipFence = (state: BuilderState, name: string, code: Code): void => { + process.stderr.write( + `${name}: skipped non-TypeScript fence (lang="${code.lang ?? EMPTY}")\n`, + ); + state.title = undefined; +}; + +/** Compile a described TS fence into a case, or reject it. */ +const compileFence = ( + state: BuilderState, + name: string, + code: Code, +): DocCase => { + const { title } = state; + if (title === undefined) { + const lang = typescriptLang(code); + throw new DocTestError( + `${name}: a \`\`\`${lang} fence has no preceding paragraph or ` + + `heading to use as its test title — describe the example.`, + ); + } + const { imports, body } = splitImports(code.value); + collectImports(imports, state.named, state.passthrough); + return { title, body }; +}; + +/** Compile one TS fence into a case, or reject/skip it. */ +const handleCodeNode = ( + state: BuilderState, + name: string, + code: Code, +): void => { + const lang = typescriptLang(code); + if (!TYPESCRIPT_LANGS.has(lang)) { + skipFence(state, name, code); + return; + } + state.cases.push(compileFence(state, name, code)); +}; + +/** Route one Markdown block, preserving the "described fence" invariant. */ +const handleNode = (state: BuilderState, name: string, node: Block): void => { + if (node.type === "paragraph" || node.type === "heading") { + handleTitleNode(state, node); + } else if (node.type === "code") { + handleCodeNode(state, name, node); + } else { + // Only a paragraph/heading introduces a fence; any other block breaks + // the "immediately preceded" chain. + state.title = undefined; + } +}; + +/** Walk one Markdown file and collect its cases and hoisted imports. */ +const parseDoc = ( + name: string, + markdown: string, +): Omit => { + const state: BuilderState = { + named: new Map(), + passthrough: new Set(), + cases: [], + title: undefined, + }; + for (const node of fromMarkdown(markdown).children) { + handleNode(state, name, node); + } + return { + named: state.named, + passthrough: state.passthrough, + cases: state.cases, + }; +}; + +/** Indent a fence body one level for the body of the `test` callback. */ +const indentBody = (body: string): string => + body + .split("\n") + .map((line) => + line.length > INITIAL_COUNT ? `${INDENT}${line}` : line, + ) + .join("\n"); + +/** Wrap one compiled fence in an executed `test(...)`. */ +const renderCase = (docCase: DocCase): string => { + const indented = indentBody(docCase.body); + return `test(${JSON.stringify(docCase.title)}, () => {\n${indented}\n});`; +}; + +/** Build the prelude + hoisted imports that top every generated file. */ +const buildHeader = (name: string, imports: readonly string[]): string[] => { + const header = [ + "// Generated by scripts/create-doc-tests.ts — do not edit by hand.", + `// Source: ${name}`, + EMPTY, + PRELUDE_TEST, + PRELUDE_ASSERT, + ]; + if (imports.length > INITIAL_COUNT) { + header.push(EMPTY, ...imports); + } + header.push(EMPTY); + return header; +}; + +/** Turn one Markdown file into the source of its generated test file. */ +const buildTestFile = (name: string, markdown: string): string => { + const parsed = parseDoc(name, markdown); + if (parsed.cases.length === INITIAL_COUNT) { + return EMPTY; + } + const imports = renderImports(parsed.named, parsed.passthrough); + const header = buildHeader(name, imports); + const blocks = parsed.cases.map(renderCase); + return `${header.join("\n")}\n${blocks.join("\n\n")}\n`; +}; + +/** Map a source Markdown path to its generated test path under OUTPUT_DIR. */ +const outputFor = (source: string): string => + path.join( + OUTPUT_DIR, + `${path.basename(source, path.extname(source))}.test.ts`, + ); + +/** Write a generated file when there is content, else clear a stale one. */ +const writeIfAny = (dest: string, name: string, out: string): boolean => { + if (out === EMPTY) { + process.stderr.write(`${name}: no TypeScript fences\n`); + fs.rmSync(dest, { force: true }); + return false; + } + fs.writeFileSync(dest, out); + return true; +}; + +/** Generate the doc-test for one source; return whether a file was written. */ +const generateFor = (root: string, source: string): boolean => { + const abs = path.join(root, source); + if (!fs.existsSync(abs)) { + process.stderr.write(`skipped missing ${source}\n`); + return false; + } + const name = path.basename(source); + const out = buildTestFile(name, fs.readFileSync(abs, "utf8")); + return writeIfAny(path.join(root, outputFor(source)), name, out); +}; + +const main = (): void => { + const root = process.cwd(); + fs.mkdirSync(path.join(root, OUTPUT_DIR), { recursive: true }); + let written = INITIAL_COUNT; + for (const source of SOURCES) { + if (generateFor(root, source)) { + written += WRITE_INCREMENT; + } + } + process.stdout.write( + `created doc-tests for ${written} file(s) under ${OUTPUT_DIR}/\n`, + ); +}; + +try { + main(); +} catch (error) { + if (error instanceof DocTestError) { + process.stderr.write(`${error.message}\n`); + process.exitCode = FAILURE_EXIT_CODE; + } else { + throw error; + } +} diff --git a/src/doc-test/README.md b/src/doc-test/README.md new file mode 100644 index 0000000..3b46ec2 --- /dev/null +++ b/src/doc-test/README.md @@ -0,0 +1,12 @@ +# Generated doc-tests + +`__generated__/` is the output of [`scripts/create-doc-tests.ts`](../../scripts/create-doc-tests.ts): +each `*.test.ts` is compiled from the ```ts fences in a prose doc and is +**gitignored**, not edited by hand. `npm run create:doc-tests` regenerates them, +formats them with `oxfmt`, then type-checks them with +[`tsconfig.json`](./tsconfig.json) — the scoped config that relaxes +`noUnusedLocals` so example-only locals (and the injected `assert`) compile. + +CI regenerates them first via `pretest:ci`; the fast local `test` / `verify` +tiers do not. See [development/docs.md](../../development/docs.md) for why the +generator is shaped this way. diff --git a/src/doc-test/__generated__/.gitkeep b/src/doc-test/__generated__/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/doc-test/tsconfig.json b/src/doc-test/tsconfig.json new file mode 100644 index 0000000..d24810e --- /dev/null +++ b/src/doc-test/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "noEmit": true, + "noUnusedLocals": false, + "noUnusedParameters": false + }, + "include": ["__generated__/**/*.test.ts"], + "exclude": [] +} diff --git a/tsconfig.json b/tsconfig.json index 4f5544a..1a05c5a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,5 +8,6 @@ "allowImportingTsExtensions": true, "verbatimModuleSyntax": true }, - "include": ["src", "scripts"] + "include": ["src", "scripts"], + "exclude": ["src/doc-test"] } From d0dd8641f2a7f86e5c561a6e13d1fb4ba673558e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= Date: Thu, 24 Sep 2026 12:13:36 +0000 Subject: [PATCH 2/5] :white_check_mark: Assert README example results Assign every documented matcher result to a variable and assert it with `assert.equal`, so the compiled README doc-tests verify behavior instead of merely running the code. The examples import `node:assert` themselves to stay copy-pasteable; the generator now merges that import into its prelude assert rather than rejecting it (only `node:test` remains reserved). --- README.md | 18 ++++++++++++++++-- development/docs.md | 5 +++-- scripts/create-doc-tests.ts | 25 ++++++++++++++++--------- 3 files changed, 35 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index f715767..e8acbbf 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,7 @@ builder. Calling the builder with a handler map keyed by `T`'s members returns a matcher: a function from `T` to the common return type. ```ts +import { strict as assert } from "node:assert"; import { getPrimitiveUnionMatcher } from "tiny-pattern-ts"; const matchAnswer = getPrimitiveUnionMatcher<"yes" | "no">(); @@ -56,13 +57,15 @@ const reply = matchAnswer({ no: () => "declined", }); -reply("yes"); // "agreed" +const answer = reply("yes"); +assert.equal(answer, "agreed"); ``` Add a fallback as the second argument to leave members unhandled; the fallback receives the remainder: ```ts +import { strict as assert } from "node:assert"; import { getPrimitiveUnionMatcher } from "tiny-pattern-ts"; const matchLabel = getPrimitiveUnionMatcher<"yes" | "no" | "maybe">(); @@ -71,6 +74,12 @@ const label = matchLabel( { yes: () => "agreed", no: () => "declined" }, (other) => `not sure: ${other}`, // other: "maybe" ); + +const answer = label("yes"); +assert.equal(answer, "agreed"); + +const fallback = label("maybe"); +assert.equal(fallback, "not sure: maybe"); ``` `getPrimitiveUnionMatcherW` is the same builder, but the matcher's return type @@ -83,6 +92,7 @@ function takes the discriminant property's name and returns the handler-map builder, keyed by that property's tags. ```ts +import { strict as assert } from "node:assert"; import { getTaggedUnionMatcher } from "tiny-pattern-ts"; type Shape = @@ -95,7 +105,11 @@ const area = matchShape({ square: (s) => s.side ** 2, }); -area({ kind: "circle", radius: 2 }); +const circleArea = area({ kind: "circle", radius: 2 }); +assert.equal(circleArea, Math.PI * 4); + +const squareArea = area({ kind: "square", side: 3 }); +assert.equal(squareArea, 9); ``` `getTaggedUnionMatcherW` is the widening counterpart, exactly as in the diff --git a/development/docs.md b/development/docs.md index d93aa0d..8fa727c 100644 --- a/development/docs.md +++ b/development/docs.md @@ -38,8 +38,9 @@ Compile every `ts / `typescript fence in the prose docs into a real `docs/` + `examples/` list is the natural extension; `development/` must never be scanned (its fences are illustrative, not compilable). - The generator hoists and merges leading imports, rewrites `tiny-pattern-ts` to - the `#test-tiny-pattern-ts` source alias, and rejects an example that imports - `node:assert` / `node:test` (the prelude already binds both). Titles are the + the `#test-tiny-pattern-ts` source alias, merges an example's `node:assert` + import into the prelude assert, and rejects an example that imports + `node:test` (the prelude binds `test`). Titles are the immediately preceding paragraph; a fence with no such paragraph is a fatal error, which keeps every example described. - `oxlint src/doc-test` reports "No files found" because the generated diff --git a/scripts/create-doc-tests.ts b/scripts/create-doc-tests.ts index 3628dd9..ad23f15 100644 --- a/scripts/create-doc-tests.ts +++ b/scripts/create-doc-tests.ts @@ -46,18 +46,14 @@ const OUTPUT_DIR = "src/doc-test/__generated__"; const TYPESCRIPT_LANGS: ReadonlySet = new Set(["ts", "typescript"]); /** - * Modules the generated file already imports. An example that imports one of - * these would collide with the prelude binding (duplicate `test` / `assert`), so - * it is surfaced as a fatal error and the example is rewritten. + * Modules the generated file already binds. An example that imports `node:test` + * would collide with the prelude `test` binding, so it is surfaced as a fatal + * error. `node:assert` is allowed: it is merged into the prelude assert import. */ -const PRELUDE_MODULES: ReadonlySet = new Set([ - "node:assert", - "node:test", -]); +const PRELUDE_MODULES: ReadonlySet = new Set(["node:test"]); /** One line of the prelude every generated file starts with. */ const PRELUDE_TEST = 'import { test } from "node:test";'; -const PRELUDE_ASSERT = 'import { strict as assert } from "node:assert";'; /** Indentation applied to every fence body line inside the `test` callback. */ const INDENT = " "; @@ -325,6 +321,17 @@ const handleNode = (state: BuilderState, name: string, node: Block): void => { } }; +/** + * Guarantee `assert` is in scope in every generated file. The import is merged + * into any `node:assert` import an example already declares, so an example can + * stay self-contained without colliding with the harness. + */ +const ensurePreludeAssert = (named: Map): void => { + addNamedImport(named, { source: "node:assert", isType: false }, [ + "strict as assert", + ]); +}; + /** Walk one Markdown file and collect its cases and hoisted imports. */ const parseDoc = ( name: string, @@ -339,6 +346,7 @@ const parseDoc = ( for (const node of fromMarkdown(markdown).children) { handleNode(state, name, node); } + ensurePreludeAssert(state.named); return { named: state.named, passthrough: state.passthrough, @@ -368,7 +376,6 @@ const buildHeader = (name: string, imports: readonly string[]): string[] => { `// Source: ${name}`, EMPTY, PRELUDE_TEST, - PRELUDE_ASSERT, ]; if (imports.length > INITIAL_COUNT) { header.push(EMPTY, ...imports); From a9b588e1c3e5855a992cff9886bb6414eae341ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= Date: Thu, 24 Sep 2026 12:17:24 +0000 Subject: [PATCH 3/5] :recycle: Drop assert imports from doc fences `assert` is the sole injected exception: the generated test always binds it, so a fence must not import it. Revert the README imports and the generator's node:assert merging; the fences keep the assigned variables and `assert.equal` calls, and the generator again rejects a fence that imports node:assert. --- README.md | 3 --- development/docs.md | 5 ++--- scripts/create-doc-tests.ts | 25 +++++++++---------------- 3 files changed, 11 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index e8acbbf..6b3dc49 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,6 @@ builder. Calling the builder with a handler map keyed by `T`'s members returns a matcher: a function from `T` to the common return type. ```ts -import { strict as assert } from "node:assert"; import { getPrimitiveUnionMatcher } from "tiny-pattern-ts"; const matchAnswer = getPrimitiveUnionMatcher<"yes" | "no">(); @@ -65,7 +64,6 @@ Add a fallback as the second argument to leave members unhandled; the fallback receives the remainder: ```ts -import { strict as assert } from "node:assert"; import { getPrimitiveUnionMatcher } from "tiny-pattern-ts"; const matchLabel = getPrimitiveUnionMatcher<"yes" | "no" | "maybe">(); @@ -92,7 +90,6 @@ function takes the discriminant property's name and returns the handler-map builder, keyed by that property's tags. ```ts -import { strict as assert } from "node:assert"; import { getTaggedUnionMatcher } from "tiny-pattern-ts"; type Shape = diff --git a/development/docs.md b/development/docs.md index 8fa727c..d93aa0d 100644 --- a/development/docs.md +++ b/development/docs.md @@ -38,9 +38,8 @@ Compile every `ts / `typescript fence in the prose docs into a real `docs/` + `examples/` list is the natural extension; `development/` must never be scanned (its fences are illustrative, not compilable). - The generator hoists and merges leading imports, rewrites `tiny-pattern-ts` to - the `#test-tiny-pattern-ts` source alias, merges an example's `node:assert` - import into the prelude assert, and rejects an example that imports - `node:test` (the prelude binds `test`). Titles are the + the `#test-tiny-pattern-ts` source alias, and rejects an example that imports + `node:assert` / `node:test` (the prelude already binds both). Titles are the immediately preceding paragraph; a fence with no such paragraph is a fatal error, which keeps every example described. - `oxlint src/doc-test` reports "No files found" because the generated diff --git a/scripts/create-doc-tests.ts b/scripts/create-doc-tests.ts index ad23f15..3628dd9 100644 --- a/scripts/create-doc-tests.ts +++ b/scripts/create-doc-tests.ts @@ -46,14 +46,18 @@ const OUTPUT_DIR = "src/doc-test/__generated__"; const TYPESCRIPT_LANGS: ReadonlySet = new Set(["ts", "typescript"]); /** - * Modules the generated file already binds. An example that imports `node:test` - * would collide with the prelude `test` binding, so it is surfaced as a fatal - * error. `node:assert` is allowed: it is merged into the prelude assert import. + * Modules the generated file already imports. An example that imports one of + * these would collide with the prelude binding (duplicate `test` / `assert`), so + * it is surfaced as a fatal error and the example is rewritten. */ -const PRELUDE_MODULES: ReadonlySet = new Set(["node:test"]); +const PRELUDE_MODULES: ReadonlySet = new Set([ + "node:assert", + "node:test", +]); /** One line of the prelude every generated file starts with. */ const PRELUDE_TEST = 'import { test } from "node:test";'; +const PRELUDE_ASSERT = 'import { strict as assert } from "node:assert";'; /** Indentation applied to every fence body line inside the `test` callback. */ const INDENT = " "; @@ -321,17 +325,6 @@ const handleNode = (state: BuilderState, name: string, node: Block): void => { } }; -/** - * Guarantee `assert` is in scope in every generated file. The import is merged - * into any `node:assert` import an example already declares, so an example can - * stay self-contained without colliding with the harness. - */ -const ensurePreludeAssert = (named: Map): void => { - addNamedImport(named, { source: "node:assert", isType: false }, [ - "strict as assert", - ]); -}; - /** Walk one Markdown file and collect its cases and hoisted imports. */ const parseDoc = ( name: string, @@ -346,7 +339,6 @@ const parseDoc = ( for (const node of fromMarkdown(markdown).children) { handleNode(state, name, node); } - ensurePreludeAssert(state.named); return { named: state.named, passthrough: state.passthrough, @@ -376,6 +368,7 @@ const buildHeader = (name: string, imports: readonly string[]): string[] => { `// Source: ${name}`, EMPTY, PRELUDE_TEST, + PRELUDE_ASSERT, ]; if (imports.length > INITIAL_COUNT) { header.push(EMPTY, ...imports); From 4dc58395c0b8bc0dffc613ebca34d939d93edba8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= Date: Thu, 24 Sep 2026 12:52:42 +0000 Subject: [PATCH 4/5] :recycle: Run doc tests outside the coverage gate An example that exercises a line no hand-written test reaches would let the `--100` gate pass on documentation alone. Node has no file-level exclusion, and c8's `--exclude` only filters the report, so run the two in separate processes: `test:coverage` runs c8 over the tracked tests only (`git ls-files`, which skips the untracked generated files), and `test:doc` runs the generated examples without c8. `test:ci` chains them. --- CONTRIBUTING.md | 5 +++-- development/docs.md | 6 ++++++ package.json | 4 +++- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9d27b5a..0e07828 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -140,8 +140,9 @@ CI step. Pick the prefix that matches the script's lifecycle: `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` runs the suite under c8 and fails below 100% coverage on `src/` - (CI-only; `verify` stays coverage-free). + `test:coverage` runs c8 over the hand-written tests only; `test:doc` runs the + generated doc examples without coverage; `test:ci` chains the two and fails + below 100% coverage on `src/` (CI-only; `verify` stays coverage-free). - `watch:*` — long-running watchers for the manual inner dev loop. Aggregated by `watch`. - `maintain:*` — advisory repo-maintenance scans: read-only, but whole-project diff --git a/development/docs.md b/development/docs.md index d93aa0d..4a22084 100644 --- a/development/docs.md +++ b/development/docs.md @@ -49,3 +49,9 @@ Compile every `ts / `typescript fence in the prose docs into a real out of the `--100` gate. Do not add an `--exclude` for them: passing any `--exclude` replaces the defaults, so every hand-written test file and `__tests__/` helper re-enters coverage and the gate fails. +- Generated examples must not run under `c8`: an example could cover a line no + hand-written test reaches, so the coverage gate would pass on documentation + alone. `test:coverage` therefore runs c8 over the tracked tests only + (`git ls-files 'src/*.test.ts'` — the generated files are untracked), and + `test:doc` runs the examples in a separate process without c8. `test:ci` + chains the two, so correctness and coverage stay independent. diff --git a/package.json b/package.json index 846d380..e9facd1 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,9 @@ "maintain:outdated": "check-outdated --ignore-pre-releases --ignore-packages @types/node", "test": "npm run check:tsc && node --test --strip-types \"src/**/*.test.ts\"", "pretest:ci": "npm run create:doc-tests", - "test:ci": "c8 --all --include \"src/**/*.ts\" --reporter=text --reporter=lcov --reporter=html --100 node --test --strip-types \"src/**/*.test.ts\"", + "test:ci": "npm run test:coverage && npm run test:doc", + "test:coverage": "c8 --all --include \"src/**/*.ts\" --reporter=text --reporter=lcov --reporter=html --100 node --test --strip-types $(git ls-files 'src/*.test.ts')", + "test:doc": "node --test --strip-types \"src/doc-test/__generated__/*.test.ts\"", "test:unit": "node --test --strip-types \"src/**/*.test.ts\"", "verify": "npm run check && npm run test:unit", "watch": "npm run watch:test", From 400fe902e027cc449201800d38eb0819399f7ed9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= Date: Thu, 24 Sep 2026 13:08:45 +0000 Subject: [PATCH 5/5] :recycle: Run doc-test generation as an explicit CI step Drop the `pretest:ci` lifecycle hook and call `create:doc-tests` from the `build` job before `test:ci`. The hook hid the step from the job log and made `test:ci` behave differently under npm than when run directly. --- .gitea/workflows/ci.yml | 4 ++++ AGENTS.md | 2 +- CONTRIBUTING.md | 4 ++-- development/docs.md | 4 ++++ package.json | 1 - src/doc-test/README.md | 4 ++-- 6 files changed, 13 insertions(+), 6 deletions(-) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index b5b93fb..fe72d2f 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -77,6 +77,10 @@ jobs: - run: npm ci - run: npm run build - run: npm run check + # Compile the prose examples into gitignored tests. Kept as an + # explicit step (not a `pretest:ci` hook) so it is visible in the + # job log and `test:ci` stays a plain command. + - run: npm run create:doc-tests # Fails the build below 100% coverage on `src/` (`c8 --all --100`); # the same run produces the report published below. See # development/ci.md § Coverage threshold. diff --git a/AGENTS.md b/AGENTS.md index 8d78f8a..3ca0553 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,7 +11,7 @@ first-action facts. Do not restate evolving prose here — it will drift. - **While iterating:** `npm run test` (`check:tsc` + the unit suite) for fast feedback on the files you changed. - **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)`. **When you are looking for a symbol, reach for the LSP before `rg`/`grep`** — `lsp_references` / `lsp_find_symbol` / `lsp_definition` / `lsp_document_symbols` are semantic and cross-file, so they see shadowing, imports and overloads that a text search cannot; use `lsp_hover` to read inferred types on generic-heavy code. Use `rg` for what the LSP cannot see — doc prose, string literals, config, task lists, file discovery — and reconcile the two sets before editing (symbols from the LSP, strings and prose from `rg`). 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. -- **Touched a `ts`-tagged fence in `README.md` / `CONTRIBUTING.md`?** Run `npm run create:doc-tests` first: it regenerates the gitignored tests under `src/doc-test/__generated__/`, formats and type-checks them, so `verify` executes the documented example. CI does this automatically via `pretest:ci`; the fast local tiers do not. See [development/docs.md](./development/docs.md). +- **Touched a `ts`-tagged fence in `README.md` / `CONTRIBUTING.md`?** Run `npm run create:doc-tests` first: it regenerates the gitignored tests under `src/doc-test/__generated__/`, formats and type-checks them, so `verify` executes the documented example. CI runs it in an explicit step before `test:ci`; the fast local tiers do not. See [development/docs.md](./development/docs.md). - **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/.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. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0e07828..3afe51d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -96,8 +96,8 @@ executed test, so a documented example cannot drift from the API. Describe each fence with the paragraph directly above it (that text becomes the test title), and keep its library import self-contained; `npm run create:doc-tests` regenerates, formats and type-checks the tests under -`src/doc-test/__generated__/`. CI runs it automatically via `pretest:ci`, so run -it yourself before `npm run verify` when you touched a fence. Why: +`src/doc-test/__generated__/`. CI runs it in an explicit step before `test:ci`, +so run it yourself before `npm run verify` when you touched a fence. Why: [development/docs.md](./development/docs.md). ## Code style and formatting diff --git a/development/docs.md b/development/docs.md index 4a22084..590abc4 100644 --- a/development/docs.md +++ b/development/docs.md @@ -55,3 +55,7 @@ Compile every `ts / `typescript fence in the prose docs into a real (`git ls-files 'src/*.test.ts'` — the generated files are untracked), and `test:doc` runs the examples in a separate process without c8. `test:ci` chains the two, so correctness and coverage stay independent. +- The CI `build` job runs `npm run create:doc-tests` as an explicit step before + `npm run test:ci`, not a `pretest:ci` lifecycle hook: the hook hides the step + from the job log and makes `test:ci` behave differently under npm than when + run directly. diff --git a/package.json b/package.json index e9facd1..ea082e8 100644 --- a/package.json +++ b/package.json @@ -60,7 +60,6 @@ "maintain:knip": "knip --include dependencies,exports,files", "maintain:outdated": "check-outdated --ignore-pre-releases --ignore-packages @types/node", "test": "npm run check:tsc && node --test --strip-types \"src/**/*.test.ts\"", - "pretest:ci": "npm run create:doc-tests", "test:ci": "npm run test:coverage && npm run test:doc", "test:coverage": "c8 --all --include \"src/**/*.ts\" --reporter=text --reporter=lcov --reporter=html --100 node --test --strip-types $(git ls-files 'src/*.test.ts')", "test:doc": "node --test --strip-types \"src/doc-test/__generated__/*.test.ts\"", diff --git a/src/doc-test/README.md b/src/doc-test/README.md index 3b46ec2..85d5364 100644 --- a/src/doc-test/README.md +++ b/src/doc-test/README.md @@ -7,6 +7,6 @@ formats them with `oxfmt`, then type-checks them with [`tsconfig.json`](./tsconfig.json) — the scoped config that relaxes `noUnusedLocals` so example-only locals (and the injected `assert`) compile. -CI regenerates them first via `pretest:ci`; the fast local `test` / `verify` -tiers do not. See [development/docs.md](../../development/docs.md) for why the +CI regenerates them in an explicit step before `test:ci`; the fast local `test` / +`verify` tiers do not. See [development/docs.md](../../development/docs.md) for why the generator is shaped this way.