✨ 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.
This commit is contained in:
tmu committed 2026-09-24 11:53:29 +00:00
1 parent d1963b0329
commit 30d97a9209
15 files changed
+1180 -8

No files matched your search

+4
View File
@@ -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
+1
View File
@@ -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/<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.
+18 -3
View File
@@ -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 `<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`
- `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
+2
View File
@@ -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(
+1 -1
View File
@@ -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
+1
View File
@@ -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 |
+51
View File
@@ -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.
+2 -1
View File
@@ -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.
+625
View File
@@ -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",
+6 -2
View File
@@ -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",
+445
View File
@@ -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__/<Name>.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(
`(?<quote>["'])${LIBRARY_SOURCE}\\k<quote>`,
"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<string> = 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<string> = 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+(?<isType>type\s+)?\{(?<specifiers>[^}]*)\}\s+from\s+["'](?<source>[^"']+)["']\s*;?$/;
/** The module in a `… from "src"` statement. */
const FROM_SOURCE = /from\s+["'](?<source>[^"']+)["']/;
/** The module in a side-effect `import "src"` statement. */
const SIDE_EFFECT_SOURCE = /^import\s+["'](?<source>[^"']+)["']/;
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<string, NamedImportGroup>;
readonly passthrough: Set<string>;
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,
`$<quote>${PRELUDE_IMPORT_SOURCE}$<quote>`,
);
};
/** 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<string, NamedImportGroup>,
ref: Pick<NamedImportGroup, "source" | "isType">,
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<string, NamedImportGroup>,
passthrough: Set<string>,
): 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<string, NamedImportGroup>,
passthrough: ReadonlySet<string>,
): 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<BuilderState, "title"> => {
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;
}
}
+12
View File
@@ -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.
View File
Whitespace-only changes.
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"noEmit": true,
"noUnusedLocals": false,
"noUnusedParameters": false
},
"include": ["__generated__/**/*.test.ts"],
"exclude": []
}
+2 -1
View File
@@ -8,5 +8,6 @@
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true
},
"include": ["src", "scripts"]
"include": ["src", "scripts"],
"exclude": ["src/doc-test"]
}